feat: add theme management with light and dark modes
- Implemented a new composable `useTheme` for managing theme state. - Added functions to read and write theme preference to local storage. - Applied theme styles to the DOM based on user preference. - Introduced a toggle function to switch between light and dark themes. refactor: enhance copilot plugin functionality - Improved request handling with sequence and document versioning. - Refactored ghost text handling to improve clarity and efficiency. - Updated markdown insertion logic to handle parsed content more robustly. - Enhanced error handling and logging for better debugging. style: update global styles for light and dark themes - Defined CSS variables for light and dark themes to streamline styling. - Improved overall styling consistency and responsiveness. - Added transitions for smoother theme changes and interactions.
This commit is contained in:
+159
-191
@@ -1,10 +1,11 @@
|
||||
import { Plugin, PluginKey, Selection } from '@milkdown/prose/state'
|
||||
import { $prose, $ctx, $markSchema } from '@milkdown/kit/utils'
|
||||
import { parserCtx, serializerCtx } from '@milkdown/kit/core'
|
||||
import { Node as ProseNode, Fragment } from '@milkdown/prose/model'
|
||||
import { Node as ProseNode, DOMParser, DOMSerializer } from '@milkdown/prose/model'
|
||||
import type { Ctx } from '@milkdown/kit/core'
|
||||
import { Decoration, DecorationSet } from '@milkdown/prose/view'
|
||||
import type { EditorView } from '@milkdown/prose/view'
|
||||
import { getOcrCache, checkSizeLimit as checkOcrSizeLimit, OCR_SIZE_LIMIT, extractTextFromOCR } from '../utils/ocrCache'
|
||||
import { getOcrCache, OCR_SIZE_LIMIT, extractTextFromOCR } from '../utils/ocrCache'
|
||||
|
||||
const COPILOT_PLUGIN_KEY = new PluginKey('milkdown-copilot')
|
||||
const DEBOUNCE_MS = 1000
|
||||
@@ -28,6 +29,8 @@ interface CopilotRuntime {
|
||||
debounceTimer: ReturnType<typeof setTimeout> | null
|
||||
abortController: AbortController | null
|
||||
ctx: Ctx
|
||||
requestSeq: number
|
||||
docVersion: number
|
||||
}
|
||||
|
||||
const initialState: CopilotState = {
|
||||
@@ -44,7 +47,7 @@ export const copilotConfigCtx = $ctx<CopilotConfig, 'copilotConfig'>({
|
||||
}, 'copilotConfig')
|
||||
|
||||
export const copilotGhostMark = $markSchema('copilot_ghost', () => ({
|
||||
excludes: '_',
|
||||
excludes: '',
|
||||
inclusive: true,
|
||||
parseDOM: [{ tag: 'span[data-copilot-ghost]' }],
|
||||
toDOM: () => ['span', { 'data-copilot-ghost': '', class: 'copilot-ghost-text' }, 0],
|
||||
@@ -58,7 +61,7 @@ export const copilotGhostMark = $markSchema('copilot_ghost', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
function clearRuntimeRequests(runtime: CopilotRuntime) {
|
||||
function clearRuntimeRequests(runtime: CopilotRuntime, invalidateRequest = true) {
|
||||
if (runtime.debounceTimer) {
|
||||
clearTimeout(runtime.debounceTimer)
|
||||
runtime.debounceTimer = null
|
||||
@@ -68,6 +71,10 @@ function clearRuntimeRequests(runtime: CopilotRuntime) {
|
||||
runtime.abortController.abort()
|
||||
runtime.abortController = null
|
||||
}
|
||||
|
||||
if (invalidateRequest) {
|
||||
runtime.requestSeq += 1
|
||||
}
|
||||
}
|
||||
|
||||
function findGhostRangeByMarks(view: EditorView): { from: number; to: number } | null {
|
||||
@@ -101,65 +108,71 @@ function hasGhostText(view: EditorView): boolean {
|
||||
return getGhostRange(view) !== null
|
||||
}
|
||||
|
||||
function clearGhostText(view: EditorView) {
|
||||
function clearGhostText(view: EditorView): boolean {
|
||||
const range = getGhostRange(view)
|
||||
if (!range) return
|
||||
if (!range) return false
|
||||
|
||||
const tr = view.state.tr
|
||||
.delete(range.from, range.to)
|
||||
.setMeta(COPILOT_PLUGIN_KEY, { ...initialState })
|
||||
view.dispatch(tr)
|
||||
return true
|
||||
}
|
||||
|
||||
function isBlockNode(node: ProseNode): boolean {
|
||||
return node.type.isBlock && node.type.name !== 'paragraph'
|
||||
}
|
||||
function buildGhostBlockDecorations(state: any): DecorationSet | null {
|
||||
const pluginState = COPILOT_PLUGIN_KEY.getState(state) as CopilotState | undefined
|
||||
if (!pluginState || !pluginState.suggestion || pluginState.from >= pluginState.to) {
|
||||
return null
|
||||
}
|
||||
|
||||
function hasBlockNodes(doc: ProseNode): boolean {
|
||||
let hasBlock = false
|
||||
doc.forEach((node) => {
|
||||
if (isBlockNode(node)) {
|
||||
hasBlock = true
|
||||
}
|
||||
})
|
||||
return hasBlock
|
||||
}
|
||||
const from = Math.max(0, Math.min(pluginState.from, state.doc.content.size))
|
||||
const to = Math.max(from, Math.min(pluginState.to, state.doc.content.size))
|
||||
const decorations: Decoration[] = []
|
||||
|
||||
function extractInlineContent(doc: ProseNode, schema: any): Fragment {
|
||||
const nodes: ProseNode[] = []
|
||||
let isFirstBlock = true
|
||||
|
||||
doc.forEach((blockNode) => {
|
||||
if (!isFirstBlock) {
|
||||
const hardBreak = schema.nodes.hard_break?.create()
|
||||
if (hardBreak) {
|
||||
nodes.push(hardBreak)
|
||||
} else {
|
||||
nodes.push(schema.text('\n'))
|
||||
}
|
||||
}
|
||||
isFirstBlock = false
|
||||
|
||||
blockNode.forEach((inlineNode) => {
|
||||
if (inlineNode.isText) {
|
||||
nodes.push(inlineNode)
|
||||
} else if (inlineNode.type.name === 'hard_break') {
|
||||
nodes.push(inlineNode)
|
||||
} else if (inlineNode.isLeaf) {
|
||||
nodes.push(inlineNode)
|
||||
} else if (inlineNode.content.size > 0) {
|
||||
inlineNode.forEach((nestedNode) => {
|
||||
if (nestedNode.isText) {
|
||||
nodes.push(nestedNode)
|
||||
} else if (nestedNode.isLeaf) {
|
||||
nodes.push(nestedNode)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
state.doc.nodesBetween(from, to, (node: any, pos: number) => {
|
||||
if (!node.isBlock || node.nodeSize <= 0) return true
|
||||
decorations.push(Decoration.node(pos, pos + node.nodeSize, { class: 'copilot-ghost-block' }))
|
||||
return true
|
||||
})
|
||||
|
||||
return Fragment.from(nodes)
|
||||
if (decorations.length === 0) return null
|
||||
return DecorationSet.create(state.doc, decorations)
|
||||
}
|
||||
|
||||
function getCursorBeforeGhostInsert(tr: any, from: number): number {
|
||||
const mapped = tr.mapping.map(from, -1)
|
||||
return Math.max(0, Math.min(mapped, tr.doc.content.size))
|
||||
}
|
||||
|
||||
function insertParsedMarkdownSlice(
|
||||
tr: any,
|
||||
schema: any,
|
||||
from: number,
|
||||
parsedDoc: ProseNode
|
||||
): { from: number; to: number } | null {
|
||||
if (parsedDoc.content.size <= 0) return null
|
||||
|
||||
const insertPos = tr.mapping.map(from, -1)
|
||||
const dom = DOMSerializer.fromSchema(schema).serializeFragment(parsedDoc.content)
|
||||
const parsedSlice = DOMParser.fromSchema(schema).parseSlice(dom)
|
||||
if (!parsedSlice || parsedSlice.size <= 0) return null
|
||||
|
||||
tr.replaceRange(insertPos, insertPos, parsedSlice)
|
||||
const endPos = Math.min(insertPos + parsedSlice.size, tr.doc.content.size)
|
||||
if (endPos <= insertPos) return null
|
||||
return { from: insertPos, to: endPos }
|
||||
}
|
||||
|
||||
function addGhostMarksToTextNodes(tr: any, from: number, to: number, markType: any) {
|
||||
tr.doc.nodesBetween(from, to, (node: any, pos: number) => {
|
||||
if (!node.isText || node.nodeSize <= 0) return true
|
||||
|
||||
const $pos = tr.doc.resolve(pos)
|
||||
if ($pos.parent.type.allowsMarkType?.(markType)) {
|
||||
tr.addMark(pos, pos + node.nodeSize, markType.create())
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeSuggestionText(raw: string): string {
|
||||
@@ -168,7 +181,6 @@ function normalizeSuggestionText(raw: string): string {
|
||||
let text = raw.replace(/\r\n?/g, '\n')
|
||||
const trimmed = text.trim()
|
||||
|
||||
// Some models may return a JSON-encoded string literal, decode it if so.
|
||||
if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed)
|
||||
@@ -180,7 +192,6 @@ function normalizeSuggestionText(raw: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
// If newlines are escaped literally, convert them back.
|
||||
if (!text.includes('\n') && text.includes('\\n')) {
|
||||
text = text.replace(/\\n/g, '\n')
|
||||
}
|
||||
@@ -206,38 +217,24 @@ async function insertGhostText(view: EditorView, suggestion: string, from: numbe
|
||||
const parser = ctx.get(parserCtx)
|
||||
const parsedDoc = await parser(suggestion)
|
||||
|
||||
if (!parsedDoc) {
|
||||
if (!parsedDoc || parsedDoc.content.size <= 0) {
|
||||
insertPlainText(view, suggestion, from, markType)
|
||||
return
|
||||
}
|
||||
|
||||
const containsBlocks = hasBlockNodes(parsedDoc)
|
||||
const tr = view.state.tr
|
||||
const insertedRange = insertParsedMarkdownSlice(tr, schema, from, parsedDoc)
|
||||
|
||||
if (containsBlocks) {
|
||||
const $from = view.state.doc.resolve(from)
|
||||
const insertPos = $from.after($from.depth)
|
||||
|
||||
const blockNodes: ProseNode[] = []
|
||||
parsedDoc.forEach((node) => {
|
||||
blockNodes.push(node)
|
||||
})
|
||||
|
||||
const fragment = Fragment.from(blockNodes)
|
||||
const tr = view.state.tr
|
||||
tr.insert(insertPos, fragment)
|
||||
const endPos = insertPos + fragment.size
|
||||
tr.addMark(insertPos, endPos, markType.create())
|
||||
tr.setMeta(COPILOT_PLUGIN_KEY, { from: insertPos, to: endPos, suggestion })
|
||||
view.dispatch(tr)
|
||||
} else {
|
||||
const inlineFragment = extractInlineContent(parsedDoc, schema)
|
||||
const tr = view.state.tr
|
||||
tr.insert(from, inlineFragment)
|
||||
const endPos = from + inlineFragment.size
|
||||
tr.addMark(from, endPos, markType.create())
|
||||
tr.setMeta(COPILOT_PLUGIN_KEY, { from, to: endPos, suggestion })
|
||||
view.dispatch(tr)
|
||||
if (!insertedRange) {
|
||||
console.warn('[Copilot] parsed markdown insertion failed, falling back to plain text')
|
||||
insertPlainText(view, suggestion, from, markType)
|
||||
return
|
||||
}
|
||||
|
||||
addGhostMarksToTextNodes(tr, insertedRange.from, insertedRange.to, markType)
|
||||
tr.setSelection(Selection.near(tr.doc.resolve(getCursorBeforeGhostInsert(tr, from)), -1))
|
||||
tr.setMeta(COPILOT_PLUGIN_KEY, { from: insertedRange.from, to: insertedRange.to, suggestion })
|
||||
view.dispatch(tr)
|
||||
} catch (e) {
|
||||
console.error('[Copilot] Parser error:', e)
|
||||
insertPlainText(view, suggestion, from, markType)
|
||||
@@ -249,6 +246,7 @@ function insertPlainText(view: EditorView, suggestion: string, from: number, mar
|
||||
tr.insertText(suggestion, from)
|
||||
const endPos = from + suggestion.length
|
||||
tr.addMark(from, endPos, markType.create())
|
||||
tr.setSelection(Selection.near(tr.doc.resolve(getCursorBeforeGhostInsert(tr, from)), -1))
|
||||
tr.setMeta(COPILOT_PLUGIN_KEY, { from, to: endPos, suggestion })
|
||||
view.dispatch(tr)
|
||||
}
|
||||
@@ -270,70 +268,50 @@ function getImageLabel(node: ProseNode): string {
|
||||
return 'untitled'
|
||||
}
|
||||
|
||||
function extractImageFilenames(doc: ProseNode): string[] {
|
||||
const filenames: string[] = []
|
||||
doc.descendants((node: ProseNode) => {
|
||||
if (isImageNodeWithSrc(node)) {
|
||||
filenames.push(getImageSrc(node))
|
||||
}
|
||||
})
|
||||
return filenames
|
||||
}
|
||||
|
||||
function buildPrefixWithOCRFromMarkdown(
|
||||
function serializeRangeToMarkdown(
|
||||
doc: ProseNode,
|
||||
cursorPos: number,
|
||||
prefixMarkdown: string,
|
||||
serializer: any,
|
||||
schema: any
|
||||
from: number,
|
||||
to: number,
|
||||
schema: any,
|
||||
serializer: any
|
||||
): string {
|
||||
const imageNodes: Array<{pos: number, src: string, label: string}> = []
|
||||
|
||||
doc.descendants((node: ProseNode, pos) => {
|
||||
if (!isImageNodeWithSrc(node)) return pos < cursorPos
|
||||
const src = getImageSrc(node)
|
||||
const label = getImageLabel(node)
|
||||
imageNodes.push({ pos, src, label })
|
||||
return pos < cursorPos
|
||||
})
|
||||
|
||||
if (imageNodes.length === 0) {
|
||||
return prefixMarkdown
|
||||
}
|
||||
|
||||
imageNodes.sort((a, b) => a.pos - b.pos)
|
||||
|
||||
const parts: string[] = []
|
||||
let lastPos = 0
|
||||
|
||||
for (const img of imageNodes) {
|
||||
if (img.pos > lastPos) {
|
||||
const slice = doc.slice(lastPos, img.pos)
|
||||
const sliceDoc = schema.topNodeType.createAndFill(undefined, slice.content)
|
||||
parts.push(sliceDoc ? serializer(sliceDoc) : doc.textBetween(lastPos, img.pos))
|
||||
}
|
||||
const imageSyntax = ``
|
||||
parts.push(imageSyntax)
|
||||
const ocrText = getOcrCache(img.src)
|
||||
if (ocrText) {
|
||||
const textOnly = extractTextFromOCR(ocrText, 100)
|
||||
if (textOnly) {
|
||||
parts.push(` <OCR:${textOnly}>`)
|
||||
}
|
||||
}
|
||||
lastPos = img.pos + 1
|
||||
}
|
||||
|
||||
if (lastPos < cursorPos) {
|
||||
const slice = doc.slice(lastPos, cursorPos)
|
||||
const sliceDoc = schema.topNodeType.createAndFill(undefined, slice.content)
|
||||
parts.push(sliceDoc ? serializer(sliceDoc) : doc.textBetween(lastPos, cursorPos))
|
||||
}
|
||||
|
||||
return parts.join('')
|
||||
if (from >= to) return ''
|
||||
const slice = doc.slice(from, to)
|
||||
if (slice.content.size <= 0) return ''
|
||||
const sliceDoc = schema.topNodeType.createAndFill(undefined, slice.content)
|
||||
return sliceDoc ? serializer(sliceDoc) : doc.textBetween(from, to, '\n', '\n')
|
||||
}
|
||||
|
||||
function doFetchSuggestion(view: EditorView, runtime: CopilotRuntime, pos: number, prefix: string, suffix: string) {
|
||||
function buildOcrContextForRequest(doc: ProseNode, cursorPos: number): string {
|
||||
const lines: string[] = []
|
||||
|
||||
doc.nodesBetween(0, cursorPos, (node) => {
|
||||
if (!isImageNodeWithSrc(node)) return true
|
||||
const src = getImageSrc(node)
|
||||
const ocrText = getOcrCache(src)
|
||||
if (!ocrText) return true
|
||||
|
||||
const textOnly = extractTextFromOCR(ocrText, 100)
|
||||
if (!textOnly) return true
|
||||
|
||||
const label = getImageLabel(node)
|
||||
lines.push(` <OCR:${textOnly}>`)
|
||||
return true
|
||||
})
|
||||
|
||||
if (lines.length === 0) return ''
|
||||
return `\n\n${lines.join('\n')}`
|
||||
}
|
||||
|
||||
function doFetchSuggestion(
|
||||
view: EditorView,
|
||||
runtime: CopilotRuntime,
|
||||
pos: number,
|
||||
prefix: string,
|
||||
suffix: string,
|
||||
requestSeq: number,
|
||||
requestDocVersion: number
|
||||
) {
|
||||
const config = runtime.ctx.get(copilotConfigCtx.key)
|
||||
|
||||
if (runtime.abortController) {
|
||||
@@ -347,6 +325,8 @@ function doFetchSuggestion(view: EditorView, runtime: CopilotRuntime, pos: numbe
|
||||
config.fetchSuggestion(prefix, suffix, controller.signal)
|
||||
.then((suggestion) => {
|
||||
if (!runtime.enabled) return
|
||||
if (runtime.requestSeq !== requestSeq) return
|
||||
if (runtime.docVersion !== requestDocVersion) return
|
||||
if (view.state.selection.from !== pos || view.state.selection.to !== pos) return
|
||||
|
||||
const normalizedSuggestion = normalizeSuggestionText(suggestion)
|
||||
@@ -366,13 +346,12 @@ function doFetchSuggestion(view: EditorView, runtime: CopilotRuntime, pos: numbe
|
||||
})
|
||||
}
|
||||
|
||||
function scheduleFetch(view: EditorView, runtime: CopilotRuntime, pos: number, prefix: string, suffix: string) {
|
||||
function scheduleFetch(view: EditorView, runtime: CopilotRuntime, pos: number) {
|
||||
if (!runtime.enabled) return
|
||||
|
||||
const doc = view.state.doc
|
||||
const schema = view.state.schema
|
||||
const imageFilenames = extractImageFilenames(doc)
|
||||
const { overLimit } = checkOcrSizeLimit(doc.content.size, imageFilenames)
|
||||
const overLimit = doc.content.size > SIZE_LIMIT
|
||||
|
||||
if (overLimit) {
|
||||
setCopilotEnabled(view, false)
|
||||
@@ -380,43 +359,16 @@ function scheduleFetch(view: EditorView, runtime: CopilotRuntime, pos: number, p
|
||||
}
|
||||
|
||||
const serializer = runtime.ctx.get(serializerCtx)
|
||||
|
||||
// 尝试使用 serializer 将文档切片转换为 Markdown
|
||||
let prefixMarkdown = ''
|
||||
let suffixMarkdown = ''
|
||||
|
||||
|
||||
try {
|
||||
// 方法1: 使用 slice 创建文档节点
|
||||
const prefixSlice = doc.slice(0, pos)
|
||||
if (prefixSlice.content.size > 0) {
|
||||
const prefixDoc = schema.topNodeType.createAndFill(undefined, prefixSlice.content)
|
||||
if (prefixDoc) {
|
||||
prefixMarkdown = serializer(prefixDoc)
|
||||
}
|
||||
}
|
||||
prefixMarkdown = serializeRangeToMarkdown(doc, 0, pos, schema, serializer)
|
||||
if (!prefixMarkdown) {
|
||||
// 方法2: 直接序列化整个文档然后截取
|
||||
const fullMarkdown = serializer(doc)
|
||||
const fullDoc = view.state.doc
|
||||
const totalLen = fullDoc.content.size
|
||||
if (totalLen > 0 && pos < totalLen) {
|
||||
// 简单估算位置
|
||||
prefixMarkdown = fullMarkdown.substring(0, Math.floor(fullMarkdown.length * pos / totalLen))
|
||||
}
|
||||
}
|
||||
if (!prefixMarkdown) {
|
||||
// 回退到 textBetween 但添加换行符
|
||||
prefixMarkdown = doc.textBetween(0, pos, '\n', '\n')
|
||||
}
|
||||
|
||||
// Suffix
|
||||
const suffixSlice = doc.slice(pos)
|
||||
if (suffixSlice.content.size > 0) {
|
||||
const suffixDoc = schema.topNodeType.createAndFill(undefined, suffixSlice.content)
|
||||
if (suffixDoc) {
|
||||
suffixMarkdown = serializer(suffixDoc)
|
||||
}
|
||||
}
|
||||
|
||||
suffixMarkdown = serializeRangeToMarkdown(doc, pos, doc.content.size, schema, serializer)
|
||||
if (!suffixMarkdown) {
|
||||
suffixMarkdown = doc.textBetween(pos, doc.content.size, '\n', '\n')
|
||||
}
|
||||
@@ -426,11 +378,11 @@ function scheduleFetch(view: EditorView, runtime: CopilotRuntime, pos: number, p
|
||||
suffixMarkdown = doc.textBetween(pos, doc.content.size, '\n', '\n')
|
||||
}
|
||||
|
||||
const prefixWithOCR = buildPrefixWithOCRFromMarkdown(doc, pos, prefixMarkdown, serializer, schema)
|
||||
const requestPrefix = `${prefixMarkdown}${buildOcrContextForRequest(doc, pos)}`
|
||||
|
||||
if (DEBUG) {
|
||||
console.log('[Copilot] ===== LLM Request =====')
|
||||
console.log('[Copilot] PREFIX:', prefixWithOCR)
|
||||
console.log('[Copilot] PREFIX:', requestPrefix)
|
||||
console.log('[Copilot] SUFFIX:', suffixMarkdown)
|
||||
console.log('[Copilot] ======================')
|
||||
}
|
||||
@@ -441,9 +393,13 @@ function scheduleFetch(view: EditorView, runtime: CopilotRuntime, pos: number, p
|
||||
}
|
||||
|
||||
const debounceMs = runtime.ctx.get(copilotConfigCtx.key).debounceMs ?? DEBOUNCE_MS
|
||||
const requestSeq = runtime.requestSeq + 1
|
||||
runtime.requestSeq = requestSeq
|
||||
const requestDocVersion = runtime.docVersion
|
||||
|
||||
runtime.debounceTimer = setTimeout(() => {
|
||||
runtime.debounceTimer = null
|
||||
doFetchSuggestion(view, runtime, pos, prefixWithOCR, suffixMarkdown)
|
||||
doFetchSuggestion(view, runtime, pos, requestPrefix, suffixMarkdown, requestSeq, requestDocVersion)
|
||||
}, debounceMs)
|
||||
}
|
||||
|
||||
@@ -473,9 +429,11 @@ function acceptSuggestion(view: EditorView) {
|
||||
|
||||
function rejectSuggestion(view: EditorView) {
|
||||
if (!hasGhostText(view)) return false
|
||||
return clearGhostText(view)
|
||||
}
|
||||
|
||||
clearGhostText(view)
|
||||
return true
|
||||
export function clearGhostSuggestion(view: EditorView): boolean {
|
||||
return clearGhostText(view)
|
||||
}
|
||||
|
||||
export const copilotPlugin = $prose((ctx) => new Plugin<CopilotState>({
|
||||
@@ -496,6 +454,7 @@ export const copilotPlugin = $prose((ctx) => new Plugin<CopilotState>({
|
||||
}
|
||||
},
|
||||
props: {
|
||||
decorations: (state) => buildGhostBlockDecorations(state),
|
||||
handleKeyDown: (view, event) => {
|
||||
const hasGhost = hasGhostText(view)
|
||||
|
||||
@@ -534,7 +493,9 @@ export const copilotPlugin = $prose((ctx) => new Plugin<CopilotState>({
|
||||
enabled: true,
|
||||
debounceTimer: null,
|
||||
abortController: null,
|
||||
ctx
|
||||
ctx,
|
||||
requestSeq: 0,
|
||||
docVersion: 0
|
||||
}
|
||||
runtimeByView.set(view, runtime)
|
||||
|
||||
@@ -563,8 +524,7 @@ export const copilotPlugin = $prose((ctx) => new Plugin<CopilotState>({
|
||||
const target = targetNode instanceof Element ? targetNode : targetNode?.parentElement
|
||||
if (!target) return
|
||||
|
||||
// Accept suggestion when user clicks any rendered ghost-text fragment.
|
||||
if (target.closest('[data-copilot-ghost]')) {
|
||||
if (target.closest('[data-copilot-ghost]') || target.closest('.copilot-ghost-block')) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
event.stopImmediatePropagation?.()
|
||||
@@ -596,11 +556,24 @@ export const copilotPlugin = $prose((ctx) => new Plugin<CopilotState>({
|
||||
const docChanged = !nextView.state.doc.eq(prevState.doc)
|
||||
const selectionChanged = !nextView.state.selection.eq(prevState.selection)
|
||||
|
||||
if (docChanged) {
|
||||
runtime.docVersion += 1
|
||||
}
|
||||
|
||||
if (!docChanged && !selectionChanged) {
|
||||
return
|
||||
}
|
||||
|
||||
if (hasGhostText(nextView)) {
|
||||
const ghostRange = getGhostRange(nextView)
|
||||
if (ghostRange) {
|
||||
const { from, to } = nextView.state.selection
|
||||
const overlapsGhost = from < ghostRange.to && to > ghostRange.from
|
||||
const alreadyAtGhostEnd = from === to && from === ghostRange.to
|
||||
if (overlapsGhost && !alreadyAtGhostEnd) {
|
||||
const endPos = Math.min(ghostRange.to, nextView.state.doc.content.size)
|
||||
const tr = nextView.state.tr.setSelection(Selection.near(nextView.state.doc.resolve(endPos), 1))
|
||||
nextView.dispatch(tr)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -610,11 +583,7 @@ export const copilotPlugin = $prose((ctx) => new Plugin<CopilotState>({
|
||||
return
|
||||
}
|
||||
|
||||
const doc = nextView.state.doc
|
||||
const prefix = doc.textBetween(0, from)
|
||||
const suffix = doc.textBetween(to, doc.content.size)
|
||||
|
||||
scheduleFetch(nextView, runtime, from, prefix, suffix)
|
||||
scheduleFetch(nextView, runtime, from)
|
||||
},
|
||||
destroy: () => {
|
||||
unbindDomListeners(activeDom)
|
||||
@@ -642,10 +611,9 @@ export function setCopilotEnabled(view: EditorView, value: boolean): void {
|
||||
}
|
||||
|
||||
export function checkSizeLimit(view: EditorView): { size: number; overLimit: boolean } {
|
||||
const doc = view.state.doc
|
||||
const imageFilenames = extractImageFilenames(doc)
|
||||
const result = checkOcrSizeLimit(doc.content.size, imageFilenames)
|
||||
return { size: result.size, overLimit: result.overLimit }
|
||||
const size = view.state.doc.content.size
|
||||
return { size, overLimit: size > SIZE_LIMIT }
|
||||
}
|
||||
|
||||
export { SIZE_LIMIT }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user