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:
143
src/App.vue
143
src/App.vue
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
70
src/composables/useTheme.js
Normal file
70
src/composables/useTheme.js
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
|
||||
216
src/style.css
216
src/style.css
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user