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:
2026-02-15 15:44:09 +08:00
parent 03bb21d5c6
commit 838eec30a8
205 changed files with 1868 additions and 344 deletions
+159 -191
View File
@@ -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 = `![${img.label}](${img.src})`
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(`![${label}](${src}) <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 }