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

View File

@@ -1,10 +1,14 @@
<script setup>
import { defineAsyncComponent, ref } from 'vue'
import { computed, defineAsyncComponent, ref } from 'vue'
import { useTheme } from './composables/useTheme'
const MilkdownEditor = defineAsyncComponent(() => import('./components/MilkdownEditor.vue'))
const markdown = ref('')
const emit = defineEmits(['update:markdown'])
const { isDark, toggleTheme } = useTheme()
const themeToggleLabel = computed(() => (isDark.value ? '切换到浅色模式' : '切换到深色模式'))
function onChange(markdownValue) {
markdown.value = markdownValue
@@ -13,5 +17,140 @@ function onChange(markdownValue) {
</script>
<template>
<MilkdownEditor @update:markdown="onChange" />
<div class="app-shell">
<button
type="button"
class="theme-toggle"
:class="{ 'is-dark': isDark }"
:aria-label="themeToggleLabel"
:title="themeToggleLabel"
@click="toggleTheme"
>
<span class="theme-toggle__track">
<span class="theme-toggle__sun" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2" />
<path d="M12 20v2" />
<path d="m4.93 4.93 1.41 1.41" />
<path d="m17.66 17.66 1.41 1.41" />
<path d="M2 12h2" />
<path d="M20 12h2" />
<path d="m6.34 17.66-1.41 1.41" />
<path d="m19.07 4.93-1.41 1.41" />
</svg>
</span>
<span class="theme-toggle__moon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 12.79A9 9 0 1 1 11.21 3c0 .26-.01.51-.01.77a7 7 0 0 0 9.03 6.7c.26-.08.52-.16.77-.24z" />
</svg>
</span>
<span class="theme-toggle__thumb" aria-hidden="true"></span>
</span>
</button>
<MilkdownEditor @update:markdown="onChange" />
</div>
</template>
<style scoped>
.app-shell {
position: relative;
width: 100%;
height: 100%;
background: var(--app-bg);
color: var(--app-text);
}
.theme-toggle {
position: fixed;
top: 16px;
right: 20px;
z-index: 11000;
border: none;
padding: 0;
background: none;
cursor: pointer;
}
.theme-toggle__track {
position: relative;
width: 78px;
height: 40px;
display: block;
border-radius: 999px;
border: 1px solid var(--panel-border);
background: linear-gradient(135deg, var(--toggle-bg-start), var(--toggle-bg-end));
box-shadow: var(--panel-shadow);
}
.theme-toggle__thumb {
position: absolute;
top: 3px;
left: 3px;
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--toggle-thumb-bg);
box-shadow: 0 5px 14px rgba(0, 0, 0, 0.2);
transition: transform 220ms ease;
}
.theme-toggle__sun,
.theme-toggle__moon {
position: absolute;
top: 50%;
width: 16px;
height: 16px;
transform: translateY(-50%);
transition: opacity 200ms ease, transform 220ms ease;
}
.theme-toggle__sun {
left: 12px;
color: var(--toggle-sun);
opacity: 1;
transform: translateY(-50%) rotate(0deg);
}
.theme-toggle__moon {
right: 12px;
color: var(--toggle-moon);
opacity: 0.55;
transform: translateY(-50%) rotate(-16deg);
}
.theme-toggle.is-dark .theme-toggle__thumb {
transform: translateX(38px);
}
.theme-toggle.is-dark .theme-toggle__sun {
opacity: 0.55;
transform: translateY(-50%) rotate(16deg);
}
.theme-toggle.is-dark .theme-toggle__moon {
opacity: 1;
transform: translateY(-50%) rotate(0deg);
}
.theme-toggle:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 3px;
border-radius: 999px;
}
@media (max-width: 640px) {
.theme-toggle {
top: 12px;
right: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.theme-toggle__thumb,
.theme-toggle__sun,
.theme-toggle__moon {
transition: none;
}
}
</style>

View File

@@ -100,7 +100,8 @@ import { onMounted, onUnmounted, ref, computed } from 'vue'
import { replaceAll } from '@milkdown/kit/utils'
import { Crepe } from '@milkdown/crepe'
import { editorViewCtx, serializerCtx } from '@milkdown/kit/core'
import { copilotPlugin, copilotConfigCtx, copilotGhostMark, setCopilotEnabled, COPILOT_PLUGIN_KEY, SIZE_LIMIT, checkSizeLimit } from '../plugins/copilotPlugin'
import { Selection } from '@milkdown/prose/state'
import { copilotPlugin, copilotConfigCtx, copilotGhostMark, setCopilotEnabled, COPILOT_PLUGIN_KEY, SIZE_LIMIT, checkSizeLimit, clearGhostSuggestion } from '../plugins/copilotPlugin'
import { fetchSuggestion } from '../utils/api.js'
import { DEBUG, OCR_URL } from '../utils/config.js'
import { setOcrCache, clearOcrCache, clearAllOcrCache } from '../utils/ocrCache.js'
@@ -252,13 +253,7 @@ const logDebugInfo = async () => {
}
const clearCurrentSuggestion = (view) => {
const state = COPILOT_PLUGIN_KEY.getState(view.state)
if (state?.suggestion && state.from < state.to) {
const tr = view.state.tr
.delete(state.from, state.to)
.setMeta(COPILOT_PLUGIN_KEY, { from: 0, to: 0, suggestion: '' })
view.dispatch(tr)
}
clearGhostSuggestion(view)
}
const performOCR = async (file, cacheKey) => {
@@ -289,11 +284,6 @@ const performOCR = async (file, cacheKey) => {
if (data.text) {
setOcrCache(cacheKey, data.text)
setOcrCache(file.name, data.text)
if (crepe?.editor) {
crepe.editor.action((ctx) => {
refreshSizeAndLimit(ctx)
})
}
}
} catch (e) {
console.error('[OCR] Error:', e)
@@ -438,13 +428,16 @@ const insertImageAtCursor = (src) => {
const view = ctx.get(editorViewCtx)
const { state } = view
const { schema } = state
const { from, to } = state.selection
const imageType = schema.nodes.image
if (!imageType) return
const imageNode = imageType.create({ src })
const tr = state.tr.replaceSelectionWith(imageNode)
view.dispatch(tr)
const tr = state.tr.replaceRangeWith(from, to, imageNode)
const cursorPos = Math.min(from + imageNode.nodeSize, tr.doc.content.size)
tr.setSelection(Selection.near(tr.doc.resolve(cursorPos), 1))
view.dispatch(tr.scrollIntoView())
})
}
@@ -513,61 +506,61 @@ onUnmounted(() => {
width: 44px;
height: 44px;
padding: 10px;
background-color: #fff;
color: #666;
border: 1px solid #ddd;
background-color: var(--btn-bg);
color: var(--btn-fg);
border: 1px solid var(--panel-border);
border-radius: 8px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
box-shadow: var(--panel-shadow);
opacity: 0.5;
}
.action-btn:hover {
background-color: #4a90d9;
color: white;
border-color: #4a90d9;
background-color: var(--btn-hover-bg);
color: var(--btn-hover-fg);
border-color: var(--btn-hover-bg);
opacity: 1;
}
.action-btn.ai-disabled {
background-color: #333;
color: #fff;
border-color: #333;
background-color: var(--crepe-color-surface-low);
color: var(--crepe-color-on-background);
border-color: var(--panel-border);
}
.action-btn.ai-disabled:hover {
background-color: #4a90d9;
color: white;
border-color: #4a90d9;
background-color: var(--btn-hover-bg);
color: var(--btn-hover-fg);
border-color: var(--btn-hover-bg);
}
.action-btn.force-disabled {
background-color: #ccc;
color: #999;
border-color: #ccc;
background-color: var(--btn-disabled-bg);
color: var(--btn-disabled-fg);
border-color: var(--btn-disabled-bg);
cursor: not-allowed;
opacity: 0.6;
}
.action-btn.force-disabled:hover {
background-color: #ccc;
color: #999;
border-color: #ccc;
background-color: var(--btn-disabled-bg);
color: var(--btn-disabled-fg);
border-color: var(--btn-disabled-bg);
opacity: 0.6;
}
.size-indicator {
font-size: 10px;
color: #999;
color: var(--muted-text);
text-align: center;
margin-top: 4px;
}
.size-indicator.over-limit {
color: #e74c3c;
color: var(--danger-text);
}
.action-btn {
@@ -580,8 +573,8 @@ onUnmounted(() => {
right: 100%;
transform: translateY(-50%);
margin-right: 8px;
background: #333;
color: #fff;
background: var(--tooltip-bg);
color: var(--tooltip-fg);
font-size: 12px;
padding: 4px 8px;
border-radius: 4px;
@@ -608,10 +601,10 @@ onUnmounted(() => {
bottom: 100%;
right: 0;
margin-bottom: 8px;
background: #fff;
border: 1px solid #ddd;
background: var(--panel-bg);
border: 1px solid var(--panel-border);
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
box-shadow: var(--panel-shadow);
overflow: hidden;
z-index: 10000;
min-width: 160px;
@@ -626,11 +619,11 @@ onUnmounted(() => {
text-align: left;
cursor: pointer;
font-size: 14px;
color: #333;
color: var(--app-text);
}
.image-dropdown button:hover {
background: #f5f5f5;
background: var(--crepe-color-hover);
}
.url-dialog-overlay {
@@ -639,7 +632,7 @@ onUnmounted(() => {
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.3);
background: var(--overlay-bg);
display: flex;
align-items: center;
justify-content: center;
@@ -647,32 +640,35 @@ onUnmounted(() => {
}
.url-dialog {
background: #fff;
background: var(--panel-bg);
padding: 20px;
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0,0,0,0.2);
border: 1px solid var(--panel-border);
box-shadow: var(--panel-shadow);
min-width: 320px;
}
.url-dialog h3 {
margin: 0 0 12px 0;
font-size: 16px;
color: #333;
color: var(--app-text);
}
.url-dialog input {
width: 100%;
box-sizing: border-box;
padding: 10px 12px;
border: 1px solid #ddd;
border: 1px solid var(--panel-border);
border-radius: 4px;
font-size: 14px;
margin-bottom: 16px;
color: var(--app-text);
background: var(--crepe-color-background);
}
.url-dialog input:focus {
outline: none;
border-color: #4a90d9;
border-color: var(--focus-ring);
}
.url-dialog-buttons {
@@ -683,32 +679,32 @@ onUnmounted(() => {
.dialog-btn {
padding: 8px 16px;
border: 1px solid #ddd;
border: 1px solid var(--panel-border);
border-radius: 4px;
cursor: pointer;
font-size: 14px;
background: #fff;
color: #333;
background: var(--btn-bg);
color: var(--btn-fg);
}
.dialog-btn:hover {
background: #f5f5f5;
background: var(--crepe-color-hover);
}
.dialog-btn.primary {
background: #4a90d9;
color: #fff;
border-color: #4a90d9;
background: var(--btn-hover-bg);
color: var(--btn-hover-fg);
border-color: var(--btn-hover-bg);
}
.dialog-btn.primary:hover {
background: #3a80c9;
filter: brightness(0.92);
}
.milkdown-editor {
width: 100%;
height: 100%;
background-color: #ffffff;
background-color: var(--crepe-color-background);
overflow-y: auto;
}
@@ -770,10 +766,14 @@ onUnmounted(() => {
}
.milkdown-editor::-webkit-scrollbar-thumb {
background-color: #ddd;
background-color: var(--scrollbar-thumb);
border-radius: 4px;
}
.milkdown-editor::-webkit-scrollbar-thumb:hover {
background-color: var(--scrollbar-thumb-hover);
}
.milkdown-editor :deep(.milkdown__toolbar),
.milkdown-editor :deep(.milkdown__menu),
.milkdown-editor :deep(.milkdown__statusbar),
@@ -799,8 +799,8 @@ onUnmounted(() => {
<style>
.copilot-ghost-text {
color: #999;
opacity: 0.6;
color: var(--ghost-text);
opacity: 0.72;
pointer-events: auto;
}
@@ -817,7 +817,7 @@ onUnmounted(() => {
}
.copilot-ghost-text code {
background-color: rgba(0, 0, 0, 0.05);
background-color: var(--ghost-code-bg);
padding: 0.2em 0.4em;
border-radius: 3px;
}
@@ -825,4 +825,16 @@ onUnmounted(() => {
.copilot-ghost-text a {
text-decoration: underline;
}
.copilot-ghost-block {
color: var(--ghost-text);
opacity: 0.72;
}
.copilot-ghost-block code,
.copilot-ghost-block pre,
.copilot-ghost-block a {
color: inherit;
opacity: inherit;
}
</style>

View File

@@ -0,0 +1,70 @@
import { computed, ref } from 'vue'
const STORAGE_KEY = 'llm-in-text.theme'
const DEFAULT_THEME = 'light'
const THEME_LIGHT = 'light'
const THEME_DARK = 'dark'
const theme = ref(DEFAULT_THEME)
const canUseDom = typeof document !== 'undefined'
const canUseStorage = typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'
const normalizeTheme = (value) => (value === THEME_DARK ? THEME_DARK : THEME_LIGHT)
const readStoredTheme = () => {
if (!canUseStorage) return DEFAULT_THEME
try {
const stored = window.localStorage.getItem(STORAGE_KEY)
return normalizeTheme(stored)
} catch {
return DEFAULT_THEME
}
}
const writeStoredTheme = (value) => {
if (!canUseStorage) return
try {
window.localStorage.setItem(STORAGE_KEY, value)
} catch {
// Ignore persistence errors (privacy mode, quota, etc).
}
}
const applyThemeToDom = (value) => {
if (!canUseDom) return
const next = normalizeTheme(value)
document.documentElement.setAttribute('data-theme', next)
document.documentElement.style.colorScheme = next
}
const setTheme = (nextTheme) => {
const next = normalizeTheme(nextTheme)
theme.value = next
applyThemeToDom(next)
writeStoredTheme(next)
}
const toggleTheme = () => {
setTheme(theme.value === THEME_DARK ? THEME_LIGHT : THEME_DARK)
}
const isDark = computed(() => theme.value === THEME_DARK)
const initTheme = () => {
setTheme(readStoredTheme())
}
initTheme()
export function useTheme() {
return {
theme,
isDark,
setTheme,
toggleTheme
}
}

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 }

View File

@@ -1,71 +1,150 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
font-family: 'Noto Sans', 'Segoe UI', Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light;
color: #213547;
background-color: #ffffff;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #747bff;
:root,
:root[data-theme='light'] {
color-scheme: light;
--app-bg: #f4f6fb;
--app-text: #1f2937;
--panel-bg: #ffffff;
--panel-border: #d7deea;
--panel-shadow: 0 8px 24px rgba(16, 24, 40, 0.12);
--btn-bg: #ffffff;
--btn-fg: #5b6470;
--btn-hover-bg: #4a90d9;
--btn-hover-fg: #ffffff;
--btn-disabled-bg: #cfd5df;
--btn-disabled-fg: #8a92a0;
--overlay-bg: rgba(15, 23, 42, 0.3);
--tooltip-bg: #111827;
--tooltip-fg: #f9fafb;
--muted-text: #6b7280;
--danger-text: #dc2626;
--scrollbar-thumb: #d4dae4;
--scrollbar-thumb-hover: #bbc4d2;
--focus-ring: #3b82f6;
--toggle-bg-start: #fff8dd;
--toggle-bg-end: #f2f4ff;
--toggle-thumb-bg: #ffffff;
--toggle-sun: #f59e0b;
--toggle-moon: #475569;
--ghost-text: #7d8796;
--ghost-code-bg: rgba(15, 23, 42, 0.06);
--crepe-color-background: #ffffff;
--crepe-color-on-background: #000000;
--crepe-color-surface: #f7f7f7;
--crepe-color-surface-low: #ededed;
--crepe-color-on-surface: #1c1c1c;
--crepe-color-on-surface-variant: #4d4d4d;
--crepe-color-outline: #a8a8a8;
--crepe-color-primary: #333333;
--crepe-color-secondary: #cfcfcf;
--crepe-color-on-secondary: #000000;
--crepe-color-inverse: #f0f0f0;
--crepe-color-on-inverse: #1a1a1a;
--crepe-color-inline-code: #ba1a1a;
--crepe-color-error: #ba1a1a;
--crepe-color-hover: #e0e0e0;
--crepe-color-selected: #d5d5d5;
--crepe-color-inline-area: #cacaca;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
overflow-x: hidden;
:root[data-theme='dark'] {
color-scheme: dark;
--app-bg: #0f1117;
--app-text: #e5e7eb;
--panel-bg: #1a1e27;
--panel-border: #2f3644;
--panel-shadow: 0 10px 26px rgba(0, 0, 0, 0.5);
--btn-bg: #222834;
--btn-fg: #d2d8e4;
--btn-hover-bg: #6ea8ff;
--btn-hover-fg: #0d1117;
--btn-disabled-bg: #2e3441;
--btn-disabled-fg: #7a8498;
--overlay-bg: rgba(2, 6, 23, 0.65);
--tooltip-bg: #f8fafc;
--tooltip-fg: #0f172a;
--muted-text: #aeb6c5;
--danger-text: #f87171;
--scrollbar-thumb: #40485a;
--scrollbar-thumb-hover: #5f6980;
--focus-ring: #60a5fa;
--toggle-bg-start: #2d3140;
--toggle-bg-end: #1f2430;
--toggle-thumb-bg: #dbe3f2;
--toggle-sun: #fbbf24;
--toggle-moon: #e2e8f0;
--ghost-text: #95a0b4;
--ghost-code-bg: rgba(226, 232, 240, 0.12);
--crepe-color-background: #1a1a1a;
--crepe-color-on-background: #e6e6e6;
--crepe-color-surface: #121212;
--crepe-color-surface-low: #1c1c1c;
--crepe-color-on-surface: #d1d1d1;
--crepe-color-on-surface-variant: #a9a9a9;
--crepe-color-outline: #757575;
--crepe-color-primary: #b5b5b5;
--crepe-color-secondary: #4d4d4d;
--crepe-color-on-secondary: #d6d6d6;
--crepe-color-inverse: #e5e5e5;
--crepe-color-on-inverse: #2a2a2a;
--crepe-color-inline-code: #ff6666;
--crepe-color-error: #ff6666;
--crepe-color-hover: #232323;
--crepe-color-selected: #2f2f2f;
--crepe-color-inline-area: #2b2b2b;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
:root[data-theme='light'] .milkdown {
--crepe-color-background: #ffffff;
--crepe-color-on-background: #000000;
--crepe-color-surface: #f7f7f7;
--crepe-color-surface-low: #ededed;
--crepe-color-on-surface: #1c1c1c;
--crepe-color-on-surface-variant: #4d4d4d;
--crepe-color-outline: #a8a8a8;
--crepe-color-primary: #333333;
--crepe-color-secondary: #cfcfcf;
--crepe-color-on-secondary: #000000;
--crepe-color-inverse: #f0f0f0;
--crepe-color-on-inverse: #1a1a1a;
--crepe-color-inline-code: #ba1a1a;
--crepe-color-error: #ba1a1a;
--crepe-color-hover: #e0e0e0;
--crepe-color-selected: #d5d5d5;
--crepe-color-inline-area: #cacaca;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #f9f9f9;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
:root[data-theme='dark'] .milkdown {
--crepe-color-background: #1a1a1a;
--crepe-color-on-background: #e6e6e6;
--crepe-color-surface: #121212;
--crepe-color-surface-low: #1c1c1c;
--crepe-color-on-surface: #d1d1d1;
--crepe-color-on-surface-variant: #a9a9a9;
--crepe-color-outline: #757575;
--crepe-color-primary: #b5b5b5;
--crepe-color-secondary: #4d4d4d;
--crepe-color-on-secondary: #d6d6d6;
--crepe-color-inverse: #e5e5e5;
--crepe-color-on-inverse: #2a2a2a;
--crepe-color-inline-code: #ff6666;
--crepe-color-error: #ff6666;
--crepe-color-hover: #232323;
--crepe-color-selected: #2f2f2f;
--crepe-color-inline-area: #2b2b2b;
}
.card {
padding: 2em;
}
#app {
width: 100vw;
height: 100vh;
margin: 0;
padding: 0;
max-width: none;
}
/* Global */
html,
body {
margin: 0;
@@ -74,4 +153,39 @@ body {
height: 100%;
overflow-x: hidden;
overflow-y: auto;
background: var(--app-bg);
color: var(--app-text);
}
body {
min-width: 320px;
}
#app {
width: 100vw;
height: 100vh;
margin: 0;
padding: 0;
max-width: none;
background: var(--app-bg);
color: var(--app-text);
}
*,
*::before,
*::after {
box-sizing: border-box;
transition:
background-color 220ms ease,
color 220ms ease,
border-color 220ms ease,
box-shadow 220ms ease;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
transition: none !important;
}
}