From 63e006703789dcf7e566036f8dbad83b6c280b64 Mon Sep 17 00:00:00 2001 From: landaiqing Date: Sun, 12 Apr 2026 19:48:24 +0800 Subject: [PATCH 1/5] :sparkles: Upgrade editor block tools and command system --- .../voidraft/internal/models/models.ts | 43 ++ .../internal/services/currencyservice.ts | 23 + .../voidraft/internal/services/index.ts | 2 + .../voidraft/internal/services/models.ts | 57 +- frontend/components.d.ts | 1 + frontend/package.json | 3 +- frontend/src/App.vue | 4 + frontend/src/assets/icons/trash-white.svg | 4 - frontend/src/common/constant/config.ts | 16 +- .../src/components/toolbar/BlockMetaTools.vue | 205 ++++++ frontend/src/components/toolbar/Toolbar.vue | 15 +- frontend/src/i18n/locales/en-US.ts | 35 + frontend/src/i18n/locales/zh-CN.ts | 35 + frontend/src/stores/configStore.ts | 8 + frontend/src/stores/currencyStore.ts | 98 +++ frontend/src/stores/editorStore.ts | 119 +++- frontend/src/views/editor/Editor.vue | 10 + frontend/src/views/editor/basic/basicSetup.ts | 4 +- .../editor/basic/cursorBlinkExtension.ts | 31 + .../extensions/blockMove/MoveBlockDialog.vue | 416 +++++++++++ .../editor/extensions/blockMove/index.ts | 216 ++++++ .../editor/extensions/blockMove/manager.ts | 67 ++ .../editor/extensions/codeblock/commands.ts | 111 ++- .../extensions/codeblock/contextMenu.ts | 48 ++ .../editor/extensions/codeblock/copyPaste.ts | 68 +- .../extensions/codeblock/decorations.ts | 88 ++- .../editor/extensions/codeblock/fold.test.ts | 79 +++ .../views/editor/extensions/codeblock/fold.ts | 232 ++++++ .../editor/extensions/codeblock/index.ts | 20 +- .../codeblock/lang-parser/codeblock-lang.ts | 17 +- .../codeblock/lang-parser/codeblock.grammar | 7 +- .../codeblock/lang-parser/external-tokens.ts | 9 +- .../codeblock/lang-parser/languages.ts | 3 +- .../codeblock/lang-parser/parser.terms.ts | 4 +- .../codeblock/lang-parser/parser.ts | 14 +- .../extensions/codeblock/parser.test.ts | 39 +- .../editor/extensions/codeblock/parser.ts | 23 +- .../editor/extensions/codeblock/state.ts | 38 +- .../editor/extensions/codeblock/timestamp.ts | 26 + .../editor/extensions/codeblock/types.ts | 5 +- .../commandPalette/CommandPaletteDialog.vue | 432 ++++++++++++ .../editor/extensions/commandPalette/index.ts | 7 + .../extensions/commandPalette/manager.ts | 46 ++ .../editor/extensions/contextMenu/index.ts | 2 + .../extensions/inlineImage/inlineImage.ts | 9 - .../inlineImage/inlineImageWidget.ts | 27 +- .../extensions/vscodeSearch/SearchPanel.vue | 69 +- .../editor/extensions/vscodeSearch/plugin.ts | 16 +- .../extensions/vscodeSearch/searchScope.ts | 71 ++ frontend/src/views/editor/keymap/commands.ts | 49 +- frontend/src/views/editor/keymap/shortcut.ts | 34 + .../editor/language/mathjs/build-parser.js | 52 ++ .../editor/language/mathjs/builtins.json | 664 ++++++++++++++++++ .../src/views/editor/language/mathjs/index.ts | 3 + .../editor/language/mathjs/mathjs-language.ts | 36 + .../editor/language/mathjs/mathjs.grammar | 41 ++ .../language/mathjs/mathjs.grammar.template | 41 ++ .../language/mathjs/mathjs.parser.terms.ts | 16 + .../editor/language/mathjs/mathjs.parser.ts | 18 + .../src/views/editor/manager/extensions.ts | 5 +- frontend/src/views/editor/theme/base.ts | 18 +- .../views/settings/pages/AppearancePage.vue | 36 + .../src/views/settings/pages/EditingPage.vue | 65 ++ internal/models/config.go | 23 +- internal/models/document_content.go | 31 +- internal/models/key_binding.go | 176 ++--- internal/services/config_service.go | 7 + internal/services/currency_service.go | 144 ++++ internal/services/document_service.go | 32 +- internal/services/service_manager.go | 6 +- 70 files changed, 4134 insertions(+), 285 deletions(-) create mode 100644 frontend/bindings/voidraft/internal/services/currencyservice.ts delete mode 100644 frontend/src/assets/icons/trash-white.svg create mode 100644 frontend/src/components/toolbar/BlockMetaTools.vue create mode 100644 frontend/src/stores/currencyStore.ts create mode 100644 frontend/src/views/editor/basic/cursorBlinkExtension.ts create mode 100644 frontend/src/views/editor/extensions/blockMove/MoveBlockDialog.vue create mode 100644 frontend/src/views/editor/extensions/blockMove/index.ts create mode 100644 frontend/src/views/editor/extensions/blockMove/manager.ts create mode 100644 frontend/src/views/editor/extensions/codeblock/contextMenu.ts create mode 100644 frontend/src/views/editor/extensions/codeblock/fold.test.ts create mode 100644 frontend/src/views/editor/extensions/codeblock/fold.ts create mode 100644 frontend/src/views/editor/extensions/codeblock/timestamp.ts create mode 100644 frontend/src/views/editor/extensions/commandPalette/CommandPaletteDialog.vue create mode 100644 frontend/src/views/editor/extensions/commandPalette/index.ts create mode 100644 frontend/src/views/editor/extensions/commandPalette/manager.ts create mode 100644 frontend/src/views/editor/extensions/vscodeSearch/searchScope.ts create mode 100644 frontend/src/views/editor/keymap/shortcut.ts create mode 100644 frontend/src/views/editor/language/mathjs/build-parser.js create mode 100644 frontend/src/views/editor/language/mathjs/builtins.json create mode 100644 frontend/src/views/editor/language/mathjs/index.ts create mode 100644 frontend/src/views/editor/language/mathjs/mathjs-language.ts create mode 100644 frontend/src/views/editor/language/mathjs/mathjs.grammar create mode 100644 frontend/src/views/editor/language/mathjs/mathjs.grammar.template create mode 100644 frontend/src/views/editor/language/mathjs/mathjs.parser.terms.ts create mode 100644 frontend/src/views/editor/language/mathjs/mathjs.parser.ts create mode 100644 internal/services/currency_service.go diff --git a/frontend/bindings/voidraft/internal/models/models.ts b/frontend/bindings/voidraft/internal/models/models.ts index b7146ad2..3bb54d19 100644 --- a/frontend/bindings/voidraft/internal/models/models.ts +++ b/frontend/bindings/voidraft/internal/models/models.ts @@ -115,6 +115,11 @@ export class AppearanceConfig { */ "currentTheme": string; + /** + * 光标闪烁周期(毫秒) + */ + "cursorBlinkRate": number; + /** Creates a new AppearanceConfig instance. */ constructor($$source: Partial = {}) { if (!("language" in $$source)) { @@ -126,6 +131,9 @@ export class AppearanceConfig { if (!("currentTheme" in $$source)) { this["currentTheme"] = ""; } + if (!("cursorBlinkRate" in $$source)) { + this["cursorBlinkRate"] = 0; + } Object.assign(this, $$source); } @@ -246,6 +254,22 @@ export class EditingConfig { */ "autoSaveDelay": number; + /** + * 默认块设置 + * 默认新块语言 + */ + "defaultBlockLanguage": string; + + /** + * 默认新块自动识别 + */ + "defaultBlockAutoDetect": boolean; + + /** + * 块分隔符高度(像素) + */ + "blockSeparatorHeight": number; + /** Creates a new EditingConfig instance. */ constructor($$source: Partial = {}) { if (!("fontSize" in $$source)) { @@ -275,6 +299,15 @@ export class EditingConfig { if (!("autoSaveDelay" in $$source)) { this["autoSaveDelay"] = 0; } + if (!("defaultBlockLanguage" in $$source)) { + this["defaultBlockLanguage"] = ""; + } + if (!("defaultBlockAutoDetect" in $$source)) { + this["defaultBlockAutoDetect"] = false; + } + if (!("blockSeparatorHeight" in $$source)) { + this["blockSeparatorHeight"] = 0; + } Object.assign(this, $$source); } @@ -759,6 +792,11 @@ export enum KeyBindingName { */ HideSearch = "hideSearch", + /** + * 打开命令面板 + */ + OpenCommandPalette = "openCommandPalette", + /** * 块内选择全部 */ @@ -774,6 +812,11 @@ export enum KeyBindingName { */ BlockAddAfterLast = "blockAddAfterLast", + /** + * 在最后添加新块并滚动到底部 + */ + BlockAddAfterLastAndScrollDown = "blockAddAfterLastAndScrollDown", + /** * 在当前块前添加新块 */ diff --git a/frontend/bindings/voidraft/internal/services/currencyservice.ts b/frontend/bindings/voidraft/internal/services/currencyservice.ts new file mode 100644 index 00000000..88ba8178 --- /dev/null +++ b/frontend/bindings/voidraft/internal/services/currencyservice.ts @@ -0,0 +1,23 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import {Call as $Call, Create as $Create} from "@wailsio/runtime"; + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import * as $models from "./models.js"; + +export function GetCurrencyData(): Promise<$models.CurrencyData | null> & { cancel(): void } { + let $resultPromise = $Call.ByID(3852526484) as any; + let $typingPromise = $resultPromise.then(($result: any) => { + return $$createType1($result); + }) as any; + $typingPromise.cancel = $resultPromise.cancel.bind($resultPromise); + return $typingPromise; +} + +// Private type creation functions +const $$createType0 = $models.CurrencyData.createFrom; +const $$createType1 = $Create.Nullable($$createType0); diff --git a/frontend/bindings/voidraft/internal/services/index.ts b/frontend/bindings/voidraft/internal/services/index.ts index 5de85d45..d0dba9dc 100644 --- a/frontend/bindings/voidraft/internal/services/index.ts +++ b/frontend/bindings/voidraft/internal/services/index.ts @@ -2,6 +2,7 @@ // This file is automatically generated. DO NOT EDIT import * as ConfigService from "./configservice.js"; +import * as CurrencyService from "./currencyservice.js"; import * as DatabaseService from "./databaseservice.js"; import * as DialogService from "./dialogservice.js"; import * as DocumentService from "./documentservice.js"; @@ -22,6 +23,7 @@ import * as TranslationService from "./translationservice.js"; import * as WindowService from "./windowservice.js"; export { ConfigService, + CurrencyService, DatabaseService, DialogService, DocumentService, diff --git a/frontend/bindings/voidraft/internal/services/models.ts b/frontend/bindings/voidraft/internal/services/models.ts index ca50677f..5eed0dd9 100644 --- a/frontend/bindings/voidraft/internal/services/models.ts +++ b/frontend/bindings/voidraft/internal/services/models.ts @@ -12,6 +12,36 @@ import * as http$0 from "../../../net/http/models.js"; // @ts-ignore: Unused imports import * as time$0 from "../../../time/models.js"; +export class CurrencyData { + "base": string; + "rates": { [_: string]: number }; + "timestamp"?: number; + + /** Creates a new CurrencyData instance. */ + constructor($$source: Partial = {}) { + if (!("base" in $$source)) { + this["base"] = ""; + } + if (!("rates" in $$source)) { + this["rates"] = {}; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new CurrencyData instance from a string or object. + */ + static createFrom($$source: any = {}): CurrencyData { + const $$createField1_0 = $$createType0; + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + if ("rates" in $$parsedSource) { + $$parsedSource["rates"] = $$createField1_0($$parsedSource["rates"]); + } + return new CurrencyData($$parsedSource as Partial); + } +} + /** * DocumentSaveResult describes the outcome of a document save request. */ @@ -89,7 +119,7 @@ export class HttpRequest { * Creates a new HttpRequest instance from a string or object. */ static createFrom($$source: any = {}): HttpRequest { - const $$createField2_0 = $$createType0; + const $$createField2_0 = $$createType1; let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; if ("headers" in $$parsedSource) { $$parsedSource["headers"] = $$createField2_0($$parsedSource["headers"]); @@ -149,7 +179,7 @@ export class HttpResponse { * Creates a new HttpResponse instance from a string or object. */ static createFrom($$source: any = {}): HttpResponse { - const $$createField4_0 = $$createType1; + const $$createField4_0 = $$createType2; let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; if ("headers" in $$parsedSource) { $$parsedSource["headers"] = $$createField4_0($$parsedSource["headers"]); @@ -524,8 +554,8 @@ export class SystemInfo { * Creates a new SystemInfo instance from a string or object. */ static createFrom($$source: any = {}): SystemInfo { - const $$createField3_0 = $$createType5; - const $$createField4_0 = $$createType6; + const $$createField3_0 = $$createType6; + const $$createField4_0 = $$createType7; let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; if ("osInfo" in $$parsedSource) { $$parsedSource["osInfo"] = $$createField3_0($$parsedSource["osInfo"]); @@ -539,14 +569,15 @@ export class SystemInfo { // Private type creation functions const $$createType0 = $Create.Map($Create.Any, $Create.Any); -var $$createType1 = (function $$initCreateType1(...args): any { - if ($$createType1 === $$initCreateType1) { - $$createType1 = $$createType3; +const $$createType1 = $Create.Map($Create.Any, $Create.Any); +var $$createType2 = (function $$initCreateType2(...args): any { + if ($$createType2 === $$initCreateType2) { + $$createType2 = $$createType4; } - return $$createType1(...args); + return $$createType2(...args); }); -const $$createType2 = $Create.Array($Create.Any); -const $$createType3 = $Create.Map($Create.Any, $$createType2); -const $$createType4 = OSInfo.createFrom; -const $$createType5 = $Create.Nullable($$createType4); -const $$createType6 = $Create.Map($Create.Any, $Create.Any); +const $$createType3 = $Create.Array($Create.Any); +const $$createType4 = $Create.Map($Create.Any, $$createType3); +const $$createType5 = OSInfo.createFrom; +const $$createType6 = $Create.Nullable($$createType5); +const $$createType7 = $Create.Map($Create.Any, $Create.Any); diff --git a/frontend/components.d.ts b/frontend/components.d.ts index d6dc3e51..2088d63f 100644 --- a/frontend/components.d.ts +++ b/frontend/components.d.ts @@ -14,6 +14,7 @@ declare module 'vue' { AccordionContainer: typeof import('./src/components/accordion/AccordionContainer.vue')['default'] AccordionItem: typeof import('./src/components/accordion/AccordionItem.vue')['default'] BlockLanguageSelector: typeof import('./src/components/toolbar/BlockLanguageSelector.vue')['default'] + BlockMetaTools: typeof import('./src/components/toolbar/BlockMetaTools.vue')['default'] DocumentSelector: typeof import('./src/components/toolbar/DocumentSelector.vue')['default'] DrawImageDialog: typeof import('./src/components/inlineImage/DrawImageDialog.vue')['default'] DrawImageFooter: typeof import('./src/components/inlineImage/draw/DrawImageFooter.vue')['default'] diff --git a/frontend/package.json b/frontend/package.json index 3215d1a6..43aeba44 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,7 +19,8 @@ "app:dev": "cd .. &&wails3 dev", "app:build": "cd .. && wails3 task build", "app:package": "cd .. && wails3 package", - "app:generate": "cd .. && wails3 generate bindings -ts" + "app:generate": "cd .. && wails3 generate bindings -ts", + "build:mathjs-parser": "node src/views/editor/language/mathjs/build-parser.js" }, "dependencies": { "@codemirror/autocomplete": "^6.20.1", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 4551002c..a4eaa65f 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -5,6 +5,7 @@ import {useSystemStore} from '@/stores/systemStore'; import {useKeybindingStore} from '@/stores/keybindingStore'; import {useThemeStore} from '@/stores/themeStore'; import {useUpdateStore} from '@/stores/updateStore'; +import {useCurrencyStore} from '@/stores/currencyStore'; import WindowTitleBar from '@/components/titlebar/WindowTitleBar.vue'; import ToastContainer from '@/components/toast/ToastContainer.vue'; import {useTranslationStore} from "@/stores/translationStore"; @@ -17,6 +18,7 @@ const keybindingStore = useKeybindingStore(); const themeStore = useThemeStore(); const updateStore = useUpdateStore(); const translationStore = useTranslationStore(); +const currencyStore = useCurrencyStore(); const {locale} = useI18n(); onBeforeMount(async () => { @@ -33,6 +35,8 @@ onBeforeMount(async () => { // 启动时检查更新 await updateStore.checkOnStartup(); + + await currencyStore.initCurrencySync(); }); diff --git a/frontend/src/assets/icons/trash-white.svg b/frontend/src/assets/icons/trash-white.svg deleted file mode 100644 index 864fb2d0..00000000 --- a/frontend/src/assets/icons/trash-white.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/src/common/constant/config.ts b/frontend/src/common/constant/config.ts index 839d7afd..3a3a58d5 100644 --- a/frontend/src/common/constant/config.ts +++ b/frontend/src/common/constant/config.ts @@ -31,10 +31,14 @@ export const CONFIG_KEY_MAP = { tabType: 'editing.tabType', keymapMode: 'editing.keymapMode', autoSaveDelay: 'editing.autoSaveDelay', + defaultBlockLanguage: 'editing.defaultBlockLanguage', + defaultBlockAutoDetect: 'editing.defaultBlockAutoDetect', + blockSeparatorHeight: 'editing.blockSeparatorHeight', language: 'appearance.language', systemTheme: 'appearance.systemTheme', currentTheme: 'appearance.currentTheme', + cursorBlinkRate: 'appearance.cursorBlinkRate', autoUpdate: 'updates.autoUpdate', backupBeforeUpdate: 'updates.backupBeforeUpdate', @@ -57,13 +61,15 @@ export const CONFIG_KEY_MAP = { } as const; export type ConfigKey = keyof typeof CONFIG_KEY_MAP; -export type NumberConfigKey = 'fontSize' | 'tabSize' | 'lineHeight'; +export type NumberConfigKey = 'fontSize' | 'tabSize' | 'lineHeight' | 'cursorBlinkRate' | 'blockSeparatorHeight'; // 配置限制 export const CONFIG_LIMITS = { fontSize: {min: 12, max: 28, default: 13}, tabSize: {min: 2, max: 8, default: 4}, lineHeight: {min: 1.0, max: 3.0, default: 1.5}, + cursorBlinkRate: {min: 0, max: 2000, default: 1000}, + blockSeparatorHeight: {min: 4, max: 24, default: 12}, tabType: {values: [TabType.TabTypeSpaces, TabType.TabTypeTab], default: TabType.TabTypeSpaces} } as const; @@ -96,12 +102,16 @@ export const DEFAULT_CONFIG: AppConfig = { tabSize: CONFIG_LIMITS.tabSize.default, tabType: CONFIG_LIMITS.tabType.default, keymapMode: KeyBindingType.Standard, - autoSaveDelay: 5000 + autoSaveDelay: 5000, + defaultBlockLanguage: 'text', + defaultBlockAutoDetect: true, + blockSeparatorHeight: CONFIG_LIMITS.blockSeparatorHeight.default, }, appearance: { language: LanguageType.LangZhCN, systemTheme: SystemThemeType.SystemThemeDark, - currentTheme: 'default-dark' + currentTheme: 'default-dark', + cursorBlinkRate: CONFIG_LIMITS.cursorBlinkRate.default, }, updates: { version: "1.0.0", diff --git a/frontend/src/components/toolbar/BlockMetaTools.vue b/frontend/src/components/toolbar/BlockMetaTools.vue new file mode 100644 index 00000000..ed782a85 --- /dev/null +++ b/frontend/src/components/toolbar/BlockMetaTools.vue @@ -0,0 +1,205 @@ + + + + + diff --git a/frontend/src/components/toolbar/Toolbar.vue b/frontend/src/components/toolbar/Toolbar.vue index 2191455d..6408f2ea 100644 --- a/frontend/src/components/toolbar/Toolbar.vue +++ b/frontend/src/components/toolbar/Toolbar.vue @@ -9,6 +9,7 @@ import {useWindowStore} from '@/stores/windowStore'; import {useSystemStore} from '@/stores/systemStore'; import {useRouter} from 'vue-router'; import BlockLanguageSelector from './BlockLanguageSelector.vue'; +import BlockMetaTools from './BlockMetaTools.vue'; import DocumentSelector from './DocumentSelector.vue'; import {getActiveNoteBlock} from '@/views/editor/extensions/codeblock/state'; import {getLanguage} from '@/views/editor/extensions/codeblock/lang-parser/languages'; @@ -230,11 +231,7 @@ const statsData = computed(() => { {{ config.editing.fontSize }}px - - - - - +
{
+ + + + + + + +
{ setEnableMemoryMonitor: (value: boolean) => updateConfig('enableMemoryMonitor', value), setKeymapMode: (value: any) => updateConfig('keymapMode', value), setAutoUpdate: (value: boolean) => updateConfig('autoUpdate', value), + setDefaultBlockLanguage: (value: string) => updateConfig('defaultBlockLanguage', value), + setDefaultBlockAutoDetect: (value: boolean) => updateConfig('defaultBlockAutoDetect', value), + setBlockSeparatorHeight: async (value: number) => { + await updateConfig('blockSeparatorHeight', clampValue(value, 'blockSeparatorHeight')); + }, + setCursorBlinkRate: async (value: number) => { + await updateConfig('cursorBlinkRate', clampValue(value, 'cursorBlinkRate')); + }, setSyncTarget: (value: SyncTarget) => updateConfig('sync_target', value), setEnableSync: (value: boolean) => updateConfig('sync_enabled', value), diff --git a/frontend/src/stores/currencyStore.ts b/frontend/src/stores/currencyStore.ts new file mode 100644 index 00000000..39ee927d --- /dev/null +++ b/frontend/src/stores/currencyStore.ts @@ -0,0 +1,98 @@ +import {defineStore} from 'pinia'; +import {readonly, ref} from 'vue'; +import {CurrencyService} from '@/../bindings/voidraft/internal/services'; +import {useEditorStore} from './editorStore'; + +declare global { + interface Window { + math: any; + } +} + +const CURRENCY_REFRESH_INTERVAL = 1000 * 3600 * 4; + +export const useCurrencyStore = defineStore('currency', () => { + const editorStore = useEditorStore(); + + const initialized = ref(false); + const currenciesLoaded = ref(false); + const isLoading = ref(false); + const lastLoadedAt = ref(null); + + let refreshTimer: number | null = null; + + const applyCurrencies = async (): Promise => { + if (isLoading.value) { + return false; + } + + if (typeof window === 'undefined' || typeof window.math === 'undefined') { + return false; + } + + isLoading.value = true; + + try { + const data = await CurrencyService.GetCurrencyData(); + if (!data?.base || !data?.rates) { + return false; + } + + const math = window.math; + + if (!currenciesLoaded.value) { + math.createUnit(data.base, { + override: false, + aliases: [data.base.toLowerCase()], + }); + } + + Object.entries(data.rates) + .filter(([currency]) => currency !== data.base) + .forEach(([currency, rate]) => { + if (typeof rate !== 'number' || !Number.isFinite(rate) || rate <= 0) { + return; + } + + math.createUnit(currency, { + definition: math.unit(1 / rate, data.base), + aliases: currency === 'CUP' ? [] : [currency.toLowerCase()], + }, {override: currenciesLoaded.value}); + }); + + currenciesLoaded.value = true; + lastLoadedAt.value = Date.now(); + document.dispatchEvent(new Event('currenciesLoaded')); + editorStore.triggerCurrencyRefresh(); + return true; + } catch (error) { + console.warn('[currency] Failed to refresh currencies', error); + return false; + } finally { + isLoading.value = false; + } + }; + + const initCurrencySync = async (): Promise => { + if (initialized.value) { + return; + } + + initialized.value = true; + + await applyCurrencies(); + + refreshTimer = window.setInterval(() => { + void applyCurrencies(); + }, CURRENCY_REFRESH_INTERVAL); + }; + + return { + initialized: readonly(initialized), + currenciesLoaded: readonly(currenciesLoaded), + isLoading: readonly(isLoading), + lastLoadedAt: readonly(lastLoadedAt), + initCurrencySync, + refreshCurrencies: applyCurrencies, + }; +}); diff --git a/frontend/src/stores/editorStore.ts b/frontend/src/stores/editorStore.ts index 39edbf15..4d21fa6e 100644 --- a/frontend/src/stores/editorStore.ts +++ b/frontend/src/stores/editorStore.ts @@ -9,6 +9,7 @@ import {createBasicSetup} from '@/views/editor/basic/basicSetup'; import {createThemeExtension, updateEditorTheme} from '@/views/editor/basic/themeExtension'; import {getTabExtensions, updateTabConfig} from '@/views/editor/basic/tabExtension'; import {createFontExtensionFromBackend, updateFontConfig} from '@/views/editor/basic/fontExtension'; +import {createCursorBlinkExtension, updateCursorBlinkRate} from '@/views/editor/basic/cursorBlinkExtension'; import {createStatsUpdateExtension} from '@/views/editor/basic/statsExtension'; import {createContentChangePlugin, externalDocumentUpdateAnnotation} from '@/views/editor/basic/contentChangeExtension'; import {createWheelZoomExtension} from '@/views/editor/basic/wheelZoomExtension'; @@ -21,6 +22,8 @@ import { setExtensionManagerView } from '@/views/editor/manager'; import createCodeBlockExtension from "@/views/editor/extensions/codeblock"; +import { applyBlockSeparatorHeightStyle, updateBlockSeparatorHeight } from '@/views/editor/extensions/codeblock'; +import {triggerCurrenciesLoaded} from '@/views/editor/extensions/codeblock/commands'; import {LruCache} from '@/common/utils/lruCache'; import {EDITOR_CONFIG} from '@/common/constant/editor'; import {useEditorStateStore, type DocumentStats} from './editorStateStore'; @@ -35,6 +38,12 @@ interface EditorInstance { isDirty: boolean; } +interface DocumentContentSnapshot { + content: string; + baseUpdatedAt: string; + hasCachedEditor: boolean; +} + export const useEditorStore = defineStore('editor', () => { const configStore = useConfigStore(); const documentStore = useDocumentStore(); @@ -118,9 +127,16 @@ export const useEditorStore = defineStore('editor', () => { // 代码块扩展 const codeBlockExtension = createCodeBlockExtension({ showBackground: true, - enableAutoDetection: true + enableAutoDetection: true, + defaultLanguage: (configStore.config.editing.defaultBlockLanguage || 'text') as any, + separatorHeight: configStore.config.editing.blockSeparatorHeight, }); + // 光标闪烁扩展 + const cursorBlinkExtension = createCursorBlinkExtension( + configStore.config.appearance.cursorBlinkRate + ); + // 光标位置持久化扩展 const cursorPositionExtension = createCursorPositionExtension(docId); @@ -140,6 +156,7 @@ export const useEditorStore = defineStore('editor', () => { themeExtension, ...tabExtensions, fontExtension, + cursorBlinkExtension, wheelZoomExtension, statsExtension, contentChangeExtension, @@ -164,6 +181,7 @@ export const useEditorStore = defineStore('editor', () => { }); const view = new EditorView({state}); + applyBlockSeparatorHeightStyle(view, configStore.config.editing.blockSeparatorHeight); return { view, @@ -232,6 +250,7 @@ export const useEditorStore = defineStore('editor', () => { try { applyFontSettingsToView(instance.view); + applyBlockSeparatorHeightStyle(instance.view, configStore.config.editing.blockSeparatorHeight); // 移除当前编辑器 DOM const currentEditor = editorCache.get(currentEditorId.value || 0); if (currentEditor && currentEditor.view.dom && currentEditor.view.dom.parentElement) { @@ -374,6 +393,78 @@ export const useEditorStore = defineStore('editor', () => { } }; + const getDocumentSnapshot = async (docId: number): Promise => { + const instance = editorCache.get(docId); + if (instance) { + return { + content: instance.view.state.doc.toString(), + baseUpdatedAt: instance.baseUpdatedAt, + hasCachedEditor: true, + }; + } + + const doc = await documentStore.getDocument(docId); + if (!doc) { + return null; + } + + return { + content: doc.content || '', + baseUpdatedAt: doc.updated_at || '', + hasCachedEditor: false, + }; + }; + + const applyDocumentContent = ( + docId: number, + content: string, + options: { selection?: number } = {} + ): boolean => { + const instance = editorCache.get(docId); + if (!instance) { + return false; + } + + const currentContent = instance.view.state.doc.toString(); + const selection = options.selection !== undefined + ? Math.max(0, Math.min(options.selection, content.length)) + : undefined; + + if (currentContent === content) { + if (selection !== undefined) { + instance.view.dispatch({ + selection: { anchor: selection, head: selection } + }); + } + return true; + } + + instance.view.dispatch({ + changes: { + from: 0, + to: instance.view.state.doc.length, + insert: content + }, + annotations: [externalDocumentUpdateAnnotation.of(true)], + ...(selection !== undefined + ? { selection: { anchor: selection, head: selection } } + : {}) + }); + + instance.contentLength = content.length; + instance.isDirty = true; + + documentStore.scheduleAutoSave( + docId, + async () => { + await saveEditorInstance(instance); + }, + configStore.config.editing.autoSaveDelay + ); + + return true; + }; + // 获取当前内容 const getCurrentContent = (): string => { if (!currentEditorId.value) return ''; @@ -467,6 +558,27 @@ export const useEditorStore = defineStore('editor', () => { ); }; + const applyCursorBlinkSettings = () => { + editorCache.values().forEach(instance => { + updateCursorBlinkRate(instance.view, configStore.config.appearance.cursorBlinkRate); + }); + }; + + const applyBlockSeparatorSettings = () => { + editorCache.values().forEach(instance => { + updateBlockSeparatorHeight(instance.view, configStore.config.editing.blockSeparatorHeight); + }); + }; + + const triggerCurrencyRefresh = () => { + editorCache.values().forEach(instance => { + triggerCurrenciesLoaded({ + state: instance.view.state, + dispatch: (transaction: any) => instance.view.dispatch(transaction), + }); + }); + }; + const hasContainer = computed(() => containerElement.value !== null); const currentEditor = computed(() => { if (!currentEditorId.value) return null; @@ -487,6 +599,8 @@ export const useEditorStore = defineStore('editor', () => { switchToEditor, destroyEditor, clearEditorCache, + getDocumentSnapshot, + applyDocumentContent, // 查询方法 getCurrentContent, @@ -500,6 +614,9 @@ export const useEditorStore = defineStore('editor', () => { applyThemeSettings, applyTabSettings, applyKeymapSettings, + applyCursorBlinkSettings, + applyBlockSeparatorSettings, + triggerCurrencyRefresh, }; async function saveEditorInstance(instance: EditorInstance): Promise { diff --git a/frontend/src/views/editor/Editor.vue b/frontend/src/views/editor/Editor.vue index ab607d59..dacc43c6 100644 --- a/frontend/src/views/editor/Editor.vue +++ b/frontend/src/views/editor/Editor.vue @@ -13,6 +13,10 @@ import TranslatorDialog from './extensions/translator/TranslatorDialog.vue'; import {translatorManager} from './extensions/translator/manager'; import DrawImageDialog from '@/components/inlineImage/DrawImageDialog.vue'; import {inlineImageDrawManager} from './extensions/inlineImage/manager'; +import MoveBlockDialog from './extensions/blockMove/MoveBlockDialog.vue'; +import {moveBlockManager} from './extensions/blockMove/manager'; +import CommandPaletteDialog from './extensions/commandPalette/CommandPaletteDialog.vue'; +import {commandPaletteManager} from './extensions/commandPalette/manager'; const editorStore = useEditorStore(); @@ -47,6 +51,8 @@ onBeforeUnmount(() => { contextMenuManager.destroy(); translatorManager.destroy(); inlineImageDrawManager.destroy(); + moveBlockManager.destroy(); + commandPaletteManager.destroy(); }); @@ -67,6 +73,10 @@ onBeforeUnmount(() => { + + + +
diff --git a/frontend/src/views/editor/basic/basicSetup.ts b/frontend/src/views/editor/basic/basicSetup.ts index 8cda9001..08074da1 100644 --- a/frontend/src/views/editor/basic/basicSetup.ts +++ b/frontend/src/views/editor/basic/basicSetup.ts @@ -1,7 +1,6 @@ import {Extension} from '@codemirror/state'; import { crosshairCursor, - drawSelection, dropCursor, EditorView, highlightActiveLine, @@ -27,7 +26,6 @@ export const createBasicSetup = (): Extension[] => { history(), // 选择与高亮 - drawSelection(), highlightActiveLine(), highlightSelectionMatches(), rectangularSelection(), @@ -46,4 +44,4 @@ export const createBasicSetup = (): Extension[] => { ...closeBracketsKeymap, ]), ]; -}; \ No newline at end of file +}; diff --git a/frontend/src/views/editor/basic/cursorBlinkExtension.ts b/frontend/src/views/editor/basic/cursorBlinkExtension.ts new file mode 100644 index 00000000..5a948b06 --- /dev/null +++ b/frontend/src/views/editor/basic/cursorBlinkExtension.ts @@ -0,0 +1,31 @@ +import { Compartment, type Extension } from '@codemirror/state'; +import { drawSelection, type EditorView } from '@codemirror/view'; + +export const cursorBlinkCompartment = new Compartment(); + +const DEFAULT_CURSOR_BLINK_RATE = 1000; + +function normalizeCursorBlinkRate(rate?: number): number { + if (!Number.isFinite(rate)) { + return DEFAULT_CURSOR_BLINK_RATE; + } + + const nextRate = Math.round(rate ?? DEFAULT_CURSOR_BLINK_RATE); + return Math.max(0, Math.min(2000, nextRate)); +} + +function createCursorBlinkConfig(rate?: number): Extension { + return drawSelection({ + cursorBlinkRate: normalizeCursorBlinkRate(rate), + }); +} + +export function createCursorBlinkExtension(rate?: number): Extension { + return cursorBlinkCompartment.of(createCursorBlinkConfig(rate)); +} + +export function updateCursorBlinkRate(view: EditorView, rate?: number): void { + view.dispatch({ + effects: cursorBlinkCompartment.reconfigure(createCursorBlinkConfig(rate)), + }); +} diff --git a/frontend/src/views/editor/extensions/blockMove/MoveBlockDialog.vue b/frontend/src/views/editor/extensions/blockMove/MoveBlockDialog.vue new file mode 100644 index 00000000..1b668e44 --- /dev/null +++ b/frontend/src/views/editor/extensions/blockMove/MoveBlockDialog.vue @@ -0,0 +1,416 @@ + + + + + diff --git a/frontend/src/views/editor/extensions/blockMove/index.ts b/frontend/src/views/editor/extensions/blockMove/index.ts new file mode 100644 index 00000000..96193c98 --- /dev/null +++ b/frontend/src/views/editor/extensions/blockMove/index.ts @@ -0,0 +1,216 @@ +import { EditorState } from "@codemirror/state"; +import type { Command, EditorView } from "@codemirror/view"; +import { useDocumentStore } from "@/stores/documentStore"; +import { useEditorStore } from "@/stores/editorStore"; +import { useEditorStateStore } from "@/stores/editorStateStore"; +import { useConfigStore } from "@/stores/configStore"; +import { useTabStore } from "@/stores/tabStore"; +import type { MenuContext } from "../contextMenu/menuSchema"; +import { blockState, getActiveNoteBlock, getNoteBlockFromPos } from "../codeblock/state"; +import type { Block, EditorOptions } from "../codeblock/types"; +import { computeDeleteBlockChange } from "../codeblock/commands"; +import { getBlocksFromString } from "../codeblock/parser"; +import { moveBlockManager } from "./manager"; + +function getDefaultBlockOptions(): EditorOptions { + const configStore = useConfigStore(); + return { + defaultBlockToken: configStore.config.editing.defaultBlockLanguage || "text", + defaultBlockAutoDetect: configStore.config.editing.defaultBlockAutoDetect !== false, + defaultBlockAccess: "write", + }; +} + +function normalizeBlockForDocument(text: string, insertAtDocumentStart = false): string { + if (insertAtDocumentStart) { + return text.startsWith("\n∞∞∞") ? text.slice(1) : text; + } + + return text.startsWith("∞∞∞") ? `\n${text}` : text; +} + +function applyTextChange( + content: string, + change: { from: number; to: number; insert: string } +): string { + return `${content.slice(0, change.from)}${change.insert}${content.slice(change.to)}`; +} + +function shouldReplaceDocumentContent(content: string): boolean { + const state = EditorState.create({ doc: content }); + const blocks = getBlocksFromString(state); + if (blocks.length !== 1) { + return false; + } + + const block = blocks[0]; + if (!block) { + return false; + } + + return content.slice(block.content.from, block.content.to).trim().length === 0; +} + +function getInsertedSelection(content: string): number { + const state = EditorState.create({ doc: content }); + const [block] = getBlocksFromString(state); + return block?.content.from ?? content.length; +} + +function resolveCurrentBlock(view: EditorView, sourceBlock: Block): Block | null { + const blocks = view.state.field(blockState, false) ?? []; + const matchedBlock = blocks.find(block => + block.range.from === sourceBlock.range.from && block.range.to === sourceBlock.range.to + ); + + if (matchedBlock) { + return matchedBlock; + } + + return getNoteBlockFromPos(view.state, sourceBlock.content.from) + ?? getActiveNoteBlock(view.state) + ?? null; +} + +async function persistDocumentContent( + documentId: number, + content: string, + selection?: number, +): Promise { + const editorStore = useEditorStore(); + const documentStore = useDocumentStore(); + const editorStateStore = useEditorStateStore(); + const snapshot = await editorStore.getDocumentSnapshot(documentId); + + if (!snapshot) { + throw new Error(`Document ${documentId} not found`); + } + + if (snapshot.hasCachedEditor) { + editorStore.applyDocumentContent(documentId, content, { selection }); + await editorStore.saveDirtyEditor(documentId); + return; + } + + if (selection !== undefined) { + editorStateStore.saveCursorPosition(documentId, selection); + } + + await documentStore.saveDocument(documentId, content, snapshot.baseUpdatedAt); +} + +async function switchToDocument(documentId: number): Promise { + const documentStore = useDocumentStore(); + const editorStore = useEditorStore(); + const tabStore = useTabStore(); + + const success = await documentStore.openDocument(documentId); + if (!success) { + throw new Error(`Failed to open document ${documentId}`); + } + + await editorStore.switchToEditor(documentId); + + if (documentStore.currentDocument && tabStore.isTabsEnabled) { + tabStore.addOrActivateTab(documentStore.currentDocument); + } +} + +function resolveDialogPosition( + view: EditorView, + block: Block, + anchor?: { x: number; y: number } | null, +): { x: number; y: number } | null { + if (anchor) { + return anchor; + } + + const coords = view.coordsAtPos(block.content.from) + ?? view.coordsAtPos(view.state.selection.main.head); + + if (!coords) { + return null; + } + + return { + x: coords.left, + y: coords.bottom + 8, + }; +} + +export const openMoveBlockDialogCommand: Command = (view) => { + return openMoveBlockDialogFromContext(view); +}; + +export function openMoveBlockDialogFromContext(view: EditorView, context?: MenuContext): boolean { + const documentStore = useDocumentStore(); + const block = getActiveNoteBlock(view.state); + const sourceDocumentId = documentStore.currentDocumentId; + + if (!block || !sourceDocumentId) { + return false; + } + + moveBlockManager.show( + view, + block, + sourceDocumentId, + resolveDialogPosition(view, block, context ? { x: context.event.clientX, y: context.event.clientY } : null), + ); + return true; +} + +export async function moveBlockToDocument(targetDocumentId: number): Promise { + const documentStore = useDocumentStore(); + const state = moveBlockManager.useState().value; + + if (!state.sourceView || !state.sourceBlock || !state.sourceDocumentId) { + return false; + } + + if (targetDocumentId === state.sourceDocumentId) { + return false; + } + + const sourceView = state.sourceView as EditorView; + const currentBlock = resolveCurrentBlock(sourceView, state.sourceBlock); + if (!currentBlock) { + return false; + } + + const sourceContent = sourceView.state.doc.toString(); + const movedBlockText = sourceView.state.doc.sliceString(currentBlock.range.from, currentBlock.range.to); + const deleteSpec = computeDeleteBlockChange(sourceView.state, currentBlock, getDefaultBlockOptions()); + const nextSourceContent = applyTextChange(sourceContent, deleteSpec.changes); + + const targetSnapshot = await useEditorStore().getDocumentSnapshot(targetDocumentId); + if (!targetSnapshot) { + return false; + } + + const replaceTargetContent = shouldReplaceDocumentContent(targetSnapshot.content); + const normalizedBlockText = normalizeBlockForDocument(movedBlockText, replaceTargetContent); + const nextTargetContent = replaceTargetContent + ? normalizedBlockText + : `${targetSnapshot.content}${normalizedBlockText}`; + const targetSelection = (replaceTargetContent ? 0 : targetSnapshot.content.length) + + getInsertedSelection(normalizedBlockText); + + await persistDocumentContent(targetDocumentId, nextTargetContent, targetSelection); + await persistDocumentContent(state.sourceDocumentId, nextSourceContent, deleteSpec.selection); + await switchToDocument(targetDocumentId); + + moveBlockManager.hide({ focusSourceView: false }); + return Boolean(documentStore.currentDocumentId === targetDocumentId); +} + +export async function moveBlockToNewDocument(title: string): Promise { + const documentStore = useDocumentStore(); + const newDocument = await documentStore.createNewDocument(title); + if (!newDocument?.id) { + return null; + } + + const moved = await moveBlockToDocument(newDocument.id); + return moved ? newDocument.id : null; +} diff --git a/frontend/src/views/editor/extensions/blockMove/manager.ts b/frontend/src/views/editor/extensions/blockMove/manager.ts new file mode 100644 index 00000000..3055ffc9 --- /dev/null +++ b/frontend/src/views/editor/extensions/blockMove/manager.ts @@ -0,0 +1,67 @@ +import type { EditorView } from "@codemirror/view"; +import { readonly, shallowRef, type ShallowRef } from "vue"; +import type { Block } from "../codeblock/types"; + +interface MoveBlockState { + visible: boolean; + sourceView: EditorView | null; + sourceBlock: Block | null; + sourceDocumentId: number | null; + position: { x: number; y: number } | null; +} + +class MoveBlockManager { + private state: ShallowRef = shallowRef({ + visible: false, + sourceView: null, + sourceBlock: null, + sourceDocumentId: null, + position: null, + }); + + useState() { + return readonly(this.state); + } + + show( + sourceView: EditorView, + sourceBlock: Block, + sourceDocumentId: number, + position: { x: number; y: number } | null = null, + ): void { + this.state.value = { + visible: true, + sourceView, + sourceBlock, + sourceDocumentId, + position, + }; + } + + hide(options: { focusSourceView?: boolean } = {}): void { + const view = this.state.value.sourceView; + this.state.value = { + visible: false, + sourceView: null, + sourceBlock: null, + sourceDocumentId: null, + position: null, + }; + + if (options.focusSourceView !== false) { + view?.focus(); + } + } + + destroy(): void { + this.state.value = { + visible: false, + sourceView: null, + sourceBlock: null, + sourceDocumentId: null, + position: null, + }; + } +} + +export const moveBlockManager = new MoveBlockManager(); diff --git a/frontend/src/views/editor/extensions/codeblock/commands.ts b/frontend/src/views/editor/extensions/codeblock/commands.ts index c43132fa..b4cfc1a7 100644 --- a/frontend/src/views/editor/extensions/codeblock/commands.ts +++ b/frontend/src/views/editor/extensions/codeblock/commands.ts @@ -8,6 +8,7 @@ import { blockState, getActiveNoteBlock, getFirstNoteBlock, getLastNoteBlock, ge import type { Block, BlockAccess, EditorOptions, SupportedLanguage } from "./types"; import { formatBlockContent } from "./formatCode"; import { createDelimiter } from "./parser"; +import { createBlockCreatedAt } from "./timestamp"; import { codeBlockEvent, LANGUAGE_CHANGE, ADD_NEW_BLOCK, MOVE_BLOCK, DELETE_BLOCK, CURRENCIES_LOADED, USER_EVENTS } from "./annotation"; /** @@ -17,8 +18,9 @@ export function getBlockDelimiter( defaultToken: string, autoDetect: boolean, access: BlockAccess = 'write', + createdAt?: string, ): string { - return createDelimiter(defaultToken as SupportedLanguage, autoDetect, access); + return createDelimiter(defaultToken as SupportedLanguage, autoDetect, access, createdAt); } function getDefaultAccess(options: EditorOptions): BlockAccess { @@ -37,7 +39,7 @@ function replaceBlockDelimiter( return false; } - const newDelimiter = createDelimiter(language as SupportedLanguage, auto, access); + const newDelimiter = createDelimiter(language as SupportedLanguage, auto, access, block.createdAt); dispatch({ changes: { @@ -51,6 +53,57 @@ function replaceBlockDelimiter( return true; } +export function computeDeleteBlockChange( + state: any, + block: Block, + options: EditorOptions, +) { + const blocks = state.field(blockState); + + if (blocks.length <= 1) { + const replacement = getBlockDelimiter( + options.defaultBlockToken, + options.defaultBlockAutoDetect, + getDefaultAccess(options), + createBlockCreatedAt(), + ); + + return { + changes: { + from: block.range.from, + to: block.range.to, + insert: replacement, + }, + selection: replacement.length, + }; + } + + const blockIndex = blocks.indexOf(block); + if (blockIndex === blocks.length - 1) { + const previousBlock = blocks[blockIndex - 1]; + return { + changes: { + from: block.range.from, + to: block.range.to, + insert: "", + }, + selection: previousBlock.content.to, + }; + } + + const nextBlock = blocks[blockIndex + 1]; + const removedLength = block.range.to - block.range.from; + + return { + changes: { + from: block.range.from, + to: block.range.to, + insert: "", + }, + selection: Math.max(0, nextBlock.content.from - removedLength), + }; +} + /** * 在光标处插入新块 */ @@ -63,11 +116,13 @@ export const insertNewBlockAtCursor = (options: EditorOptions): Command => ({ st currentBlock.language.name as SupportedLanguage, currentBlock.language.auto, currentBlock.access, + createBlockCreatedAt(), ) : getBlockDelimiter( options.defaultBlockToken, options.defaultBlockAutoDetect, getDefaultAccess(options), + createBlockCreatedAt(), ); dispatch(state.replaceSelection(delimText), { @@ -91,6 +146,7 @@ export const addNewBlockBeforeCurrent = (options: EditorOptions): Command => ({ options.defaultBlockToken, options.defaultBlockAutoDetect, getDefaultAccess(options), + createBlockCreatedAt(), ); dispatch(state.update({ @@ -121,6 +177,7 @@ export const addNewBlockAfterCurrent = (options: EditorOptions): Command => ({ s options.defaultBlockToken, options.defaultBlockAutoDetect, getDefaultAccess(options), + createBlockCreatedAt(), ); dispatch(state.update({ @@ -151,6 +208,7 @@ export const addNewBlockBeforeFirst = (options: EditorOptions): Command => ({ st options.defaultBlockToken, options.defaultBlockAutoDetect, getDefaultAccess(options), + createBlockCreatedAt(), ); dispatch(state.update({ @@ -181,6 +239,7 @@ export const addNewBlockAfterLast = (options: EditorOptions): Command => ({ stat options.defaultBlockToken, options.defaultBlockAutoDetect, getDefaultAccess(options), + createBlockCreatedAt(), ); dispatch(state.update({ @@ -198,6 +257,28 @@ export const addNewBlockAfterLast = (options: EditorOptions): Command => ({ stat return true; }; +/** + * 在最后一个块之后添加新块,并滚动到底部聚焦新块 + */ +export const addNewBlockAfterLastAndScrollDown = (options: EditorOptions): Command => (view) => { + const inserted = addNewBlockAfterLast(options)(view); + if (!inserted) { + return false; + } + + const scrollToBottom = () => { + const scroller = view.scrollDOM; + scroller.scrollTop = scroller.scrollHeight; + view.focus(); + }; + + requestAnimationFrame(() => { + requestAnimationFrame(scrollToBottom); + }); + + return true; +}; + /** * 更改块语言 */ @@ -348,31 +429,11 @@ export const deleteBlock = (_options: EditorOptions): Command => ({ state, dispa const block = getActiveNoteBlock(state); if (!block) return false; - const blocks = state.field(blockState); - if (blocks.length <= 1) return false; - - const blockIndex = blocks.indexOf(block); - let newCursorPos: number; - - if (blockIndex === blocks.length - 1) { - const prevBlock = blocks[blockIndex - 1]; - newCursorPos = prevBlock.content.to; - } else { - const nextBlock = blocks[blockIndex + 1]; - const blockLength = block.range.to - block.range.from; - newCursorPos = nextBlock.content.from - blockLength; - } - - const docLengthAfterDelete = state.doc.length - (block.range.to - block.range.from); - newCursorPos = Math.max(0, Math.min(newCursorPos, docLengthAfterDelete)); + const transactionSpec = computeDeleteBlockChange(state, block, _options); dispatch(state.update({ - changes: { - from: block.range.from, - to: block.range.to, - insert: "" - }, - selection: EditorSelection.cursor(newCursorPos), + changes: transactionSpec.changes, + selection: EditorSelection.cursor(transactionSpec.selection), annotations: [codeBlockEvent.of(DELETE_BLOCK)] }, { scrollIntoView: true, diff --git a/frontend/src/views/editor/extensions/codeblock/contextMenu.ts b/frontend/src/views/editor/extensions/codeblock/contextMenu.ts new file mode 100644 index 00000000..087c3669 --- /dev/null +++ b/frontend/src/views/editor/extensions/codeblock/contextMenu.ts @@ -0,0 +1,48 @@ +import type { MenuSchemaNode } from "../contextMenu/menuSchema"; +import { getMenuBlock, runCommandInMenuBlock } from "../contextMenu/blockContext"; +import { openMoveBlockDialogFromContext } from "../blockMove"; +import { + canFoldBlock, + isBlockFolded, + foldBlockCommand, + unfoldBlockCommand, +} from "./fold"; + +export const codeBlockMenuNodes: MenuSchemaNode[] = [ + { + id: "move-block-to-document", + labelKey: "blockTools.moveBlock", + command: (view, context) => { + const blockCommand = runCommandInMenuBlock((commandView) => openMoveBlockDialogFromContext(commandView, context)); + return blockCommand(view, context); + }, + visible: context => Boolean(getMenuBlock(context)), + enabled: context => Boolean(getMenuBlock(context)), + }, + { + id: "fold-block", + labelKey: "blockTools.foldBlock", + command: runCommandInMenuBlock(foldBlockCommand), + visible: context => { + const block = getMenuBlock(context); + return Boolean(block) && canFoldBlock(context.view.state, block) && !isBlockFolded(context.view.state, block); + }, + enabled: context => { + const block = getMenuBlock(context); + return Boolean(block) && canFoldBlock(context.view.state, block) && !isBlockFolded(context.view.state, block); + }, + }, + { + id: "unfold-block", + labelKey: "blockTools.unfoldBlock", + command: runCommandInMenuBlock(unfoldBlockCommand), + visible: context => { + const block = getMenuBlock(context); + return Boolean(block) && isBlockFolded(context.view.state, block); + }, + enabled: context => { + const block = getMenuBlock(context); + return Boolean(block) && isBlockFolded(context.view.state, block); + }, + }, +]; diff --git a/frontend/src/views/editor/extensions/codeblock/copyPaste.ts b/frontend/src/views/editor/extensions/codeblock/copyPaste.ts index 63747ec4..368c1f7a 100644 --- a/frontend/src/views/editor/extensions/codeblock/copyPaste.ts +++ b/frontend/src/views/editor/extensions/codeblock/copyPaste.ts @@ -7,6 +7,7 @@ import {EditorSelection, EditorState} from "@codemirror/state"; import {Command, EditorView} from "@codemirror/view"; import {LANGUAGES} from "./lang-parser/languages"; import {codeBlockEvent, CONTENT_EDIT, USER_EVENTS} from "./annotation"; +import {blockState, getNoteBlockFromPos} from "./state"; import {inlineImageEnabledFacet, inlineImageOptionsFacet} from "../inlineImage"; import { copySelectedInlineImageIfNeeded, @@ -22,6 +23,12 @@ import * as runtime from "@wailsio/runtime"; const languageTokensMatcher = LANGUAGES.map(lang => lang.token).join("|"); const blockSeparatorRegex = new RegExp(`(?:^|\\n)∞∞∞(?:${languageTokensMatcher})(?:-(?:a|r|w))*\\n`, "g"); +interface CutLineSpec { + text: string; + changes: { from: number; to: number }; + selection: EditorSelection; +} + /** * 获取被复制的范围和内容 */ @@ -66,6 +73,39 @@ function copiedRange(state: EditorState, forCut = false) { }; } +function getSingleCursorCutLineSpec(state: EditorState): CutLineSpec | null { + if (state.selection.ranges.length !== 1 || !state.selection.main.empty) { + return null; + } + + const cursor = state.selection.main; + const line = state.doc.lineAt(cursor.head); + const block = getNoteBlockFromPos(state, cursor.head); + const blocks = state.field(blockState, false) ?? []; + const blockIndex = block ? blocks.indexOf(block) : -1; + const hasNextBlock = blockIndex >= 0 && blockIndex < blocks.length - 1; + const endsAtBlockBoundary = Boolean(block && hasNextBlock && line.to === block.content.to); + + let from = line.from; + let to = line.to < state.doc.length ? line.to + 1 : line.to; + + if (endsAtBlockBoundary) { + if (block && line.from > block.content.from) { + from = line.from - 1; + to = line.to; + } else { + from = line.from; + to = line.to; + } + } + + return { + text: state.sliceDoc(line.from, line.to), + changes: { from, to }, + selection: EditorSelection.create([EditorSelection.cursor(from)]), + }; +} + function normalizeCopiedText(text: string): string { return text .replaceAll(blockSeparatorRegex, "\n\n") @@ -103,19 +143,31 @@ async function handleCopyCut(view: EditorView, cut: boolean, event?: ClipboardEv } } - let {text} = copiedRange(view.state, cut); - const {ranges} = copiedRange(view.state, cut); + const lineCutSpec = cut ? getSingleCursorCutLineSpec(view.state) : null; + const copied = copiedRange(view.state, cut); + let text = lineCutSpec?.text ?? copied.text; + const {ranges} = copied; text = normalizeCopiedText(text); await writeTextToClipboard(text, event); if (cut && !view.state.readOnly) { - view.dispatch({ - changes: ranges, - scrollIntoView: true, - userEvent: USER_EVENTS.DELETE_CUT, - annotations: [codeBlockEvent.of(CONTENT_EDIT)], - }); + if (lineCutSpec) { + view.dispatch({ + changes: lineCutSpec.changes, + selection: lineCutSpec.selection, + scrollIntoView: true, + userEvent: USER_EVENTS.DELETE_CUT, + annotations: [codeBlockEvent.of(CONTENT_EDIT)], + }); + } else { + view.dispatch({ + changes: ranges, + scrollIntoView: true, + userEvent: USER_EVENTS.DELETE_CUT, + annotations: [codeBlockEvent.of(CONTENT_EDIT)], + }); + } } return true; diff --git a/frontend/src/views/editor/extensions/codeblock/decorations.ts b/frontend/src/views/editor/extensions/codeblock/decorations.ts index de775c90..af95d13f 100644 --- a/frontend/src/views/editor/extensions/codeblock/decorations.ts +++ b/frontend/src/views/editor/extensions/codeblock/decorations.ts @@ -3,13 +3,51 @@ */ import { ViewPlugin, EditorView, Decoration, WidgetType, layer, RectangleMarker } from "@codemirror/view"; -import { StateField, RangeSetBuilder, EditorState, Transaction } from "@codemirror/state"; +import { StateField, StateEffect, RangeSetBuilder, EditorState, Transaction } from "@codemirror/state"; +import { externalDocumentUpdateAnnotation } from "@/views/editor/basic/contentChangeExtension"; import { blockState } from "./state"; import { codeBlockEvent, USER_EVENTS } from "./annotation"; // IME 输入状态 let isComposing = false; +const DEFAULT_BLOCK_SEPARATOR_HEIGHT = 12; +const MIN_BLOCK_SEPARATOR_HEIGHT = 4; +const MAX_BLOCK_SEPARATOR_HEIGHT = 24; +const FIRST_BLOCK_TOP_PADDING = 7; + +export const blockSeparatorHeightEffect = StateEffect.define(); + +function normalizeBlockSeparatorHeight(height?: number): number { + if (!Number.isFinite(height)) { + return DEFAULT_BLOCK_SEPARATOR_HEIGHT; + } + + return Math.max( + MIN_BLOCK_SEPARATOR_HEIGHT, + Math.min(MAX_BLOCK_SEPARATOR_HEIGHT, Math.round(height as number)), + ); +} + +function createBlockSeparatorHeightField(initialHeight?: number) { + const defaultHeight = normalizeBlockSeparatorHeight(initialHeight); + + return StateField.define({ + create() { + return defaultHeight; + }, + update(value, transaction) { + for (const effect of transaction.effects) { + if (effect.is(blockSeparatorHeightEffect)) { + return normalizeBlockSeparatorHeight(effect.value); + } + } + + return value; + }, + }); +} + /** * 块开始装饰组件 */ @@ -123,12 +161,14 @@ const atomicNoteBlock = ViewPlugin.fromClass( * 这样即使某些字符被隐藏(如 heading 的 # 标记 fontSize: 0), * 行的坐标也不会受影响,边界线位置正确。 */ -const blockLayer = layer({ +function createBlockLayer(separatorHeightField: StateField) { + return layer({ above: false, markers(view: EditorView) { const markers: RectangleMarker[] = []; let idx = 0; + const separatorHeight = view.state.field(separatorHeightField); function rangesOverlaps(range1: any, range2: any) { return range1.from <= range2.to && range2.from <= range1.to; @@ -175,12 +215,17 @@ const blockLayer = layer({ isEvenBlock ? "block-even" : "block-odd", block.access === "read" ? "block-readonly" : "", ].filter(Boolean).join(" "); + const topPadding = block.delimiter.from === 0 + ? FIRST_BLOCK_TOP_PADDING + : Math.round(separatorHeight / 2) + 1; + const bottomPadding = separatorHeight + 3; + markers.push(new RectangleMarker( blockClass, 0, - fromCoordsTop - (view.documentTop - view.documentPadding.top) - 1 - 6, + fromCoordsTop - (view.documentTop - view.documentPadding.top) - topPadding, null, // 宽度在 CSS 中设置为 100% - (toCoordsBottom - fromCoordsTop) + 15, + (toCoordsBottom - fromCoordsTop) + bottomPadding, )); }); @@ -188,11 +233,16 @@ const blockLayer = layer({ }, update(update: any, _dom: any) { - return update.docChanged || update.viewportChanged; + return update.docChanged + || update.viewportChanged + || update.transactions.some((transaction: Transaction) => + transaction.effects.some(effect => effect.is(blockSeparatorHeightEffect)) + ); }, class: "code-blocks-layer" -}); + }); +} /** * 防止第一个块被删除 @@ -201,10 +251,11 @@ const blockLayer = layer({ const preventFirstBlockFromBeingDeleted = EditorState.changeFilter.of((tr: any) => { const protect: number[] = []; const internalEvent = tr.annotation(codeBlockEvent); + const externalUpdate = tr.annotation(externalDocumentUpdateAnnotation); // 获取块状态并获取第一个块的分隔符大小 const blocks = tr.startState.field(blockState); - if (!internalEvent && blocks && blocks.length > 0) { + if (!internalEvent && !externalUpdate && blocks && blocks.length > 0) { const firstBlock = blocks[0]; const firstBlockDelimiterSize = firstBlock.delimiter.to; @@ -234,7 +285,7 @@ const preventFirstBlockFromBeingDeleted = EditorState.changeFilter.of((tr: any) const preventSelectionBeforeFirstBlock = EditorState.transactionFilter.of((tr: any) => { if (isComposing) return tr; - if (tr.annotation(codeBlockEvent)) { + if (tr.annotation(codeBlockEvent) || tr.annotation(externalDocumentUpdateAnnotation)) { return tr; } // 获取块状态并获取第一个块的分隔符大小 @@ -288,12 +339,16 @@ const imeStateSynchronizer = ViewPlugin.fromClass( */ export function getBlockDecorationExtensions(options: { showBackground?: boolean; + separatorHeight?: number; } = {}) { const { showBackground = true, + separatorHeight = DEFAULT_BLOCK_SEPARATOR_HEIGHT, } = options; + const separatorHeightField = createBlockSeparatorHeightField(separatorHeight); const extensions: any[] = [ + separatorHeightField, noteBlockWidget(), atomicNoteBlock, preventFirstBlockFromBeingDeleted, @@ -302,8 +357,23 @@ export function getBlockDecorationExtensions(options: { ]; if (showBackground) { - extensions.push(blockLayer); + extensions.push(createBlockLayer(separatorHeightField)); } return extensions; } + +export function applyBlockSeparatorHeightStyle(view: EditorView, height?: number): number { + const normalizedHeight = normalizeBlockSeparatorHeight(height); + view.dom.style.setProperty('--cm-block-separator-height', `${normalizedHeight}px`); + return normalizedHeight; +} + +export function updateBlockSeparatorHeight(view: EditorView, height?: number): void { + const normalizedHeight = applyBlockSeparatorHeightStyle(view, height); + + view.dispatch({ + effects: blockSeparatorHeightEffect.of(normalizedHeight), + annotations: [Transaction.addToHistory.of(false)], + }); +} diff --git a/frontend/src/views/editor/extensions/codeblock/fold.test.ts b/frontend/src/views/editor/extensions/codeblock/fold.test.ts new file mode 100644 index 00000000..5ccb17c3 --- /dev/null +++ b/frontend/src/views/editor/extensions/codeblock/fold.test.ts @@ -0,0 +1,79 @@ +import { EditorState } from "@codemirror/state"; +import { foldNodeProp } from "@codemirror/language"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("./lang-parser/languages", () => ({ + LANGUAGES: [ + { token: "text" }, + { token: "ts" }, + { token: "json" }, + ], + languageMapping: { + text: null, + ts: null, + json: null, + }, +})); + +import { createDelimiter, getBlocksFromString } from "./parser"; +import { CodeBlockLanguage } from "./lang-parser"; + +function getBlockContentFoldRange(doc: string, targetFrom: number) { + const state = EditorState.create({ doc }); + const tree = CodeBlockLanguage.parser.parse(doc); + let foldRange: { from: number; to: number } | null = null; + + tree.iterate({ + enter(node) { + if (node.name !== "BlockContent" || node.from !== targetFrom) { + return; + } + + const prop = node.type.prop(foldNodeProp); + if (!prop) { + return false; + } + + foldRange = prop(node.node, state); + return false; + }, + }); + + return foldRange; +} + +describe("codeblock fold ranges", () => { + it("默认折叠范围会包含分隔符前的最后一个字符", () => { + const doc = [ + createDelimiter("ts", false, "write"), + "public", + createDelimiter("json", false, "write"), + "{}", + ].join(""); + const state = EditorState.create({ doc }); + const [block] = getBlocksFromString(state); + + expect(block).toBeTruthy(); + + expect(getBlockContentFoldRange(doc, block!.content.from)).toEqual({ + from: block!.content.from, + to: block!.content.to, + }); + }); + + it("文档末尾块折叠时不会漏掉最后一个字符", () => { + const doc = [ + createDelimiter("ts", false, "write"), + "console.log('done')", + ].join(""); + const state = EditorState.create({ doc }); + const [block] = getBlocksFromString(state); + + expect(block).toBeTruthy(); + + expect(getBlockContentFoldRange(doc, block!.content.from)).toEqual({ + from: block!.content.from, + to: block!.content.to, + }); + }); +}); diff --git a/frontend/src/views/editor/extensions/codeblock/fold.ts b/frontend/src/views/editor/extensions/codeblock/fold.ts new file mode 100644 index 00000000..001140f8 --- /dev/null +++ b/frontend/src/views/editor/extensions/codeblock/fold.ts @@ -0,0 +1,232 @@ +import type { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { + codeFolding, + foldEffect, + foldedRanges, + foldGutter, + unfoldEffect, +} from "@codemirror/language"; +import { getActiveNoteBlock, getNoteBlockFromPos, getNoteBlocksFromRangeSet } from "./state"; +import type { Block } from "./types"; +import { ADD_NEW_BLOCK, LANGUAGE_CHANGE, transactionsHasAnnotationsAny } from "./annotation"; +import { formatBlockCreatedAt } from "./timestamp"; + +const FOLD_LABEL_LENGTH = 80; + +function getLocale(): string | undefined { + if (typeof document !== "undefined") { + return document.documentElement.lang || navigator.language || undefined; + } + return undefined; +} + +function getSelectedBlocks(state: EditorState): Block[] { + return getNoteBlocksFromRangeSet(state, state.selection.ranges); +} + +function getFoldedBlockRanges(state: EditorState, block: Block): { from: number; to: number }[] { + const firstLine = state.doc.lineAt(block.content.from); + const ranges: { from: number; to: number }[] = []; + + foldedRanges(state).between(block.content.from, block.content.to, (from, to) => { + if (from <= firstLine.to && to === block.content.to) { + ranges.push({ from, to }); + } + }); + + return ranges; +} + +export function getFoldBlockRange(block: Block, state: EditorState): { from: number; to: number } | null { + const from = block.content.from; + const to = block.content.to; + + if (from >= to) { + return null; + } + + return { from, to }; +} + +export function canFoldBlock(state: EditorState, block?: Block | null): boolean { + if (!block) { + return false; + } + + return Boolean(getFoldBlockRange(block, state)); +} + +export function isBlockFolded(state: EditorState, block?: Block | null): boolean { + if (!block) { + return false; + } + + return getFoldedBlockRanges(state, block).length > 0; +} + +function createPlaceholderDOM(state: EditorState, from: number, to: number): HTMLElement { + const block = getNoteBlockFromPos(state, from) ?? getNoteBlockFromPos(state, Math.max(0, to - 1)) ?? null; + const firstLine = state.doc.lineAt(from); + const lastLinePos = Math.max(from, to - 1); + const lastLine = state.doc.lineAt(lastLinePos); + const lineCount = Math.max(1, lastLine.number - firstLine.number + 1); + + const dom = document.createElement("span"); + dom.className = "cm-foldPlaceholder cm-block-fold-placeholder"; + + const label = document.createElement("span"); + label.className = "cm-block-fold-label"; + label.textContent = firstLine.text.slice(0, FOLD_LABEL_LENGTH).trimEnd(); + dom.appendChild(label); + + const summary = document.createElement("span"); + summary.className = "cm-block-fold-summary"; + summary.textContent = `${label.textContent ? " " : ""}… (${lineCount} lines)`; + dom.appendChild(summary); + + if (block?.createdAt && block.content.from === from && block.content.to === to) { + const createdTime = formatBlockCreatedAt(block.createdAt, undefined, getLocale()); + if (createdTime) { + const created = document.createElement("span"); + created.className = "cm-block-fold-created-time"; + created.textContent = createdTime; + created.title = block.createdAt; + dom.appendChild(created); + } + } + + return dom; +} + +function autoUnfoldOnEdit() { + return EditorView.updateListener.of((update) => { + if (!update.docChanged) { + return; + } + + const foldRanges = foldedRanges(update.state); + if (!foldRanges || foldRanges.size === 0) { + return; + } + + if (transactionsHasAnnotationsAny(update.transactions, [ADD_NEW_BLOCK, LANGUAGE_CHANGE])) { + return; + } + + const unfoldTargets: { from: number; to: number }[] = []; + + update.changes.iterChanges((_fromA, _toA, fromB, toB) => { + foldRanges.between(0, update.state.doc.length, (from, to) => { + const lineFrom = update.state.doc.lineAt(from).from; + const lineTo = update.state.doc.lineAt(to).to; + + if ((fromB >= lineFrom && fromB <= lineTo) || (toB >= lineFrom && toB <= lineTo)) { + unfoldTargets.push({ from, to }); + } + }); + }); + + if (unfoldTargets.length > 0) { + update.view.dispatch({ + effects: unfoldTargets.map(range => unfoldEffect.of(range)), + }); + } + }); +} + +export const foldBlockCommand = (view: EditorView): boolean => { + const blocks = getSelectedBlocks(view.state); + const ranges = blocks + .map(block => getFoldBlockRange(block, view.state)) + .filter((range): range is { from: number; to: number } => Boolean(range)); + + if (ranges.length === 0) { + return false; + } + + view.dispatch({ + effects: ranges.map(range => foldEffect.of(range)), + }); + return true; +}; + +export const unfoldBlockCommand = (view: EditorView): boolean => { + const blocks = getSelectedBlocks(view.state); + const ranges = blocks.flatMap(block => getFoldedBlockRanges(view.state, block)); + + if (ranges.length === 0) { + return false; + } + + view.dispatch({ + effects: ranges.map(range => unfoldEffect.of(range)), + }); + return true; +}; + +export const toggleFoldBlockCommand = (view: EditorView): boolean => { + const blocks = getSelectedBlocks(view.state); + if (blocks.length === 0) { + return false; + } + + const foldRanges: { from: number; to: number }[] = []; + const unfoldRanges: { from: number; to: number }[] = []; + let foldedCount = 0; + let unfoldedCount = 0; + + for (const block of blocks) { + const currentFoldedRanges = getFoldedBlockRanges(view.state, block); + if (currentFoldedRanges.length > 0) { + foldedCount += 1; + unfoldRanges.push(...currentFoldedRanges); + continue; + } + + const foldRange = getFoldBlockRange(block, view.state); + if (foldRange) { + unfoldedCount += 1; + foldRanges.push(foldRange); + } + } + + if (foldRanges.length === 0 && unfoldRanges.length === 0) { + return false; + } + + view.dispatch({ + effects: (unfoldedCount >= foldedCount ? foldRanges : unfoldRanges) + .map(range => (unfoldedCount >= foldedCount ? foldEffect : unfoldEffect).of(range)), + }); + return true; +}; + +export function toggleActiveBlockFold(view: EditorView): boolean { + if (!getActiveNoteBlock(view.state)) { + return false; + } + + return toggleFoldBlockCommand(view); +} + +export function createBlockFoldExtension() { + return [ + foldGutter({ + domEventHandlers: { + click(view) { + view.focus(); + return false; + }, + }, + }), + codeFolding({ + preparePlaceholder: (state, range) => createPlaceholderDOM(state, range.from, range.to), + placeholderDOM: (_view, onClick, prepared) => { + prepared.addEventListener("click", onClick); + return prepared; + }, + }), + autoUnfoldOnEdit(), + ]; +} diff --git a/frontend/src/views/editor/extensions/codeblock/index.ts b/frontend/src/views/editor/extensions/codeblock/index.ts index c626bd97..5f6dbcd7 100644 --- a/frontend/src/views/editor/extensions/codeblock/index.ts +++ b/frontend/src/views/editor/extensions/codeblock/index.ts @@ -26,6 +26,7 @@ import {createLanguageDetection} from './lang-detect'; import {SupportedLanguage} from './types'; import {getMathBlockExtensions} from './mathBlock'; import {createCursorProtection} from './cursorProtection'; +import {createBlockFoldExtension} from './fold'; /** * 代码块扩展配置选项 @@ -89,6 +90,7 @@ export function createCodeBlockExtension(options: CodeBlockOptions = {}): Extens showBackground = true, enableAutoDetection = true, defaultLanguage = 'text', + separatorHeight = 12, } = options; return [ @@ -107,7 +109,8 @@ export function createCodeBlockExtension(options: CodeBlockOptions = {}): Extens // 视觉装饰系统 ...getBlockDecorationExtensions({ - showBackground + showBackground, + separatorHeight, }), // 光标保护(防止方向键移动到分隔符上) @@ -213,12 +216,27 @@ export { // 行号相关 export {getBlockLineFromPos, blockLineNumbers}; +export { + applyBlockSeparatorHeightStyle, + updateBlockSeparatorHeight, +} from './decorations'; // 数学块功能 export { getMathBlockExtensions } from './mathBlock'; +export { + canFoldBlock, + createBlockFoldExtension, + foldBlockCommand, + getFoldBlockRange, + isBlockFolded, + toggleActiveBlockFold, + toggleFoldBlockCommand, + unfoldBlockCommand, +} from './fold'; + // 光标保护功能 export { createCursorProtection diff --git a/frontend/src/views/editor/extensions/codeblock/lang-parser/codeblock-lang.ts b/frontend/src/views/editor/extensions/codeblock/lang-parser/codeblock-lang.ts index b91bf64e..a9c5229f 100644 --- a/frontend/src/views/editor/extensions/codeblock/lang-parser/codeblock-lang.ts +++ b/frontend/src/views/editor/extensions/codeblock/lang-parser/codeblock-lang.ts @@ -6,22 +6,11 @@ import { parser } from "./parser"; import { configureNesting } from "./nested-parser"; -import { - LRLanguage, - LanguageSupport, - foldNodeProp, -} from "@codemirror/language"; +import { LRLanguage, LanguageSupport, foldNodeProp } from "@codemirror/language"; import { styleTags, tags as t } from "@lezer/highlight"; import { json } from "@codemirror/lang-json"; -/** - * 折叠节点函数 - */ -function foldNode(node: any) { - return { from: node.from, to: node.to - 1 }; -} - /** * 代码块语言定义 */ @@ -34,7 +23,7 @@ export const CodeBlockLanguage = LRLanguage.define({ foldNodeProp.add({ BlockContent(node: any) { - return { from: node.from, to: node.to - 1 }; + return { from: node.from, to: node.to }; }, }), ], @@ -62,4 +51,4 @@ export function codeBlockLang() { */ export function getCodeBlockLanguageExtension() { return codeBlockLang(); -} \ No newline at end of file +} diff --git a/frontend/src/views/editor/extensions/codeblock/lang-parser/codeblock.grammar b/frontend/src/views/editor/extensions/codeblock/lang-parser/codeblock.grammar index 388d52eb..2f099e24 100644 --- a/frontend/src/views/editor/extensions/codeblock/lang-parser/codeblock.grammar +++ b/frontend/src/views/editor/extensions/codeblock/lang-parser/codeblock.grammar @@ -9,7 +9,7 @@ Block { } BlockDelimiter { - "\n∞∞∞" BlockLanguage BlockFlag* "\n" + "\n∞∞∞" BlockLanguage BlockFlag* BlockMetadata* "\n" } BlockLanguage { @@ -25,8 +25,13 @@ BlockFlag { Auto | ReadOnly | Writable } +BlockMetadata { + CreatedMetadata +} + @tokens { Auto { "-a" } ReadOnly { "-r" } Writable { "-w" } + CreatedMetadata { ";created=" $[0-9a-zA-Z:._+-]+ } } diff --git a/frontend/src/views/editor/extensions/codeblock/lang-parser/external-tokens.ts b/frontend/src/views/editor/extensions/codeblock/lang-parser/external-tokens.ts index a3823a85..6790298e 100644 --- a/frontend/src/views/editor/extensions/codeblock/lang-parser/external-tokens.ts +++ b/frontend/src/views/editor/extensions/codeblock/lang-parser/external-tokens.ts @@ -15,13 +15,9 @@ const SECOND_TOKEN_CHAR = DELIMITER_PREFIX.charCodeAt(1); const languageTokensMatcher = LANGUAGES.map(l => l.token).join("|"); const escapeForRegex = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const tokenRegEx = new RegExp( - `^${escapeForRegex(DELIMITER_PREFIX)}(?:${languageTokensMatcher})(?:-(?:a|r|w))*${escapeForRegex(DELIMITER_SUFFIX)}`, - "g", + `^${escapeForRegex(DELIMITER_PREFIX)}(?:${languageTokensMatcher})(?:-(?:a|r|w))*(?:;created=[^\\n;]+)?${escapeForRegex(DELIMITER_SUFFIX)}`, ); -const maxDelimiterLookahead = DELIMITER_PREFIX.length - + Math.max(...LANGUAGES.map(lang => lang.token.length)) - + "-a-w".length - + DELIMITER_SUFFIX.length; +const maxDelimiterLookahead = 256; /** * 代码块内容标记器 @@ -46,7 +42,6 @@ export const blockContent = new ExternalTokenizer((input) => { potentialDelimiter += String.fromCharCode(char); } - tokenRegEx.lastIndex = 0; if (tokenRegEx.test(potentialDelimiter)) { input.acceptToken(BlockContent); return; diff --git a/frontend/src/views/editor/extensions/codeblock/lang-parser/languages.ts b/frontend/src/views/editor/extensions/codeblock/lang-parser/languages.ts index b98bbad0..a293cc55 100644 --- a/frontend/src/views/editor/extensions/codeblock/lang-parser/languages.ts +++ b/frontend/src/views/editor/extensions/codeblock/lang-parser/languages.ts @@ -45,6 +45,7 @@ import {toml} from "@codemirror/legacy-modes/mode/toml"; import {elixir} from "codemirror-lang-elixir"; import {dockerFile} from "@codemirror/legacy-modes/mode/dockerfile"; import {lua} from "@codemirror/legacy-modes/mode/lua"; +import {mathjsLanguage} from "@/views/editor/language/mathjs"; import {SupportedLanguage} from '../types'; import typescriptPlugin from "prettier/plugins/typescript"; @@ -221,7 +222,7 @@ export const LANGUAGES: LanguageInfo[] = [ parser: "lua", plugins: [luaPrettierPlugin] }), - new LanguageInfo("math", "Math", null, ["math"]), + new LanguageInfo("math", "Math", mathjsLanguage.parser, ["math"]), new LanguageInfo("lezer", "Lezer", lezerLanguage.parser, ["lezer"]), new LanguageInfo("liquid", "Liquid", liquidLanguage.parser, ["liquid"]), new LanguageInfo("wast", "WebAssembly", wastLanguage.parser, ["wast"]), diff --git a/frontend/src/views/editor/extensions/codeblock/lang-parser/parser.terms.ts b/frontend/src/views/editor/extensions/codeblock/lang-parser/parser.terms.ts index d6f1e180..8e7b5535 100644 --- a/frontend/src/views/editor/extensions/codeblock/lang-parser/parser.terms.ts +++ b/frontend/src/views/editor/extensions/codeblock/lang-parser/parser.terms.ts @@ -8,4 +8,6 @@ export const BlockFlag = 6, Auto = 7, ReadOnly = 8, - Writable = 9 + Writable = 9, + BlockMetadata = 10, + CreatedMetadata = 11 diff --git a/frontend/src/views/editor/extensions/codeblock/lang-parser/parser.ts b/frontend/src/views/editor/extensions/codeblock/lang-parser/parser.ts index 491c905f..08d13a1f 100644 --- a/frontend/src/views/editor/extensions/codeblock/lang-parser/parser.ts +++ b/frontend/src/views/editor/extensions/codeblock/lang-parser/parser.ts @@ -3,14 +3,14 @@ import {LRParser} from "@lezer/lr" import {blockContent} from "./external-tokens.js" export const parser = LRParser.deserialize({ version: 14, - states: "!|QQOQOOOVOQO'#C`O#{OPO'#C_OOOO'#Cf'#CfQQOQOOOOOO'#Ca'#CaO$QOSO,58zOOOO,58y,58yOOOO-E6d-E6dOOOO'#Cb'#CbOOOO'#Cg'#CgO$`OSO1G.fOOOP1G.f1G.fOOOO-E6e-E6eOOOP7+$Q7+$Q", - stateData: "$n~O]PO~O^TO_TO`TOaTObTOcTOdTOeTOfTOgTOhTOiTOjTOkTOlTOmTOnTOoTOpTOqTOrTOsTOtTOuTOvTOwTOxTOyTOzTO{TO|TO}TO!OTO!PTO!QTO!RTO!STO!TTO!UTO!VTO!WTO!XTO~OPVO~OVXOWXOXXO!Y[O~OVXOWXOXXO!Y^O~O", - goto: "x[PPP]aehPPPlrTROSTQOSRUPTYUZQSORWSQZUR]Z", - nodeNames: "⚠ BlockContent Document Block BlockDelimiter BlockLanguage BlockFlag Auto ReadOnly Writable", - maxTerm: 56, + states: "#rQQOQOOOVOQO'#C`O#{OPO'#C_OOOO'#Ch'#ChQQOQOOOOOO'#Ca'#CaO$QOSO,58zOOOO,58y,58yOOOO-E6f-E6fOOOO'#Cb'#CbOOOO'#Ci'#CiO$cOSO1G.fOOOO'#Cf'#CfOOOO'#Cj'#CjO$lOSO1G.fOOOP1G.f1G.fOOOO-E6g-E6gO$tOSO7+$QOOOP7+$Q7+$QOOOO-E6h-E6hOOOP< ({ + LANGUAGES: [ + { token: 'text' }, + { token: 'ts' }, + { token: 'json' }, + { token: 'math' }, + { token: 'go' }, + ], +})); + import { createDelimiter, getBlocksFromString, parseDelimiter } from './parser'; describe('codeblock delimiter access', () => { @@ -51,4 +62,30 @@ describe('codeblock delimiter access', () => { expect(blocks[1]?.access).toBe('write'); expect(blocks[1]?.language).toEqual({ name: 'json', auto: true }); }); + + it('parses createdAt metadata from delimiters', () => { + const createdAt = '2026-04-12T08:30:00.000Z'; + const delimiter = createDelimiter('math', false, 'write', createdAt); + + expect(parseDelimiter(delimiter)).toEqual({ + language: 'math', + auto: false, + access: 'write', + createdAt, + }); + }); + + it('keeps createdAt metadata when reading blocks from string', () => { + const createdAt = '2026-04-12T08:30:00.000Z'; + const document = [ + createDelimiter('math', false, 'write', createdAt), + '1 + 1\n', + ].join(''); + + const state = EditorState.create({ doc: document }); + const [block] = getBlocksFromString(state); + + expect(block?.createdAt).toBe(createdAt); + expect(block?.language).toEqual({ name: 'math', auto: false }); + }); }); diff --git a/frontend/src/views/editor/extensions/codeblock/parser.ts b/frontend/src/views/editor/extensions/codeblock/parser.ts index 498a5fbe..fd471be8 100644 --- a/frontend/src/views/editor/extensions/codeblock/parser.ts +++ b/frontend/src/views/editor/extensions/codeblock/parser.ts @@ -3,7 +3,7 @@ */ import { EditorState } from '@codemirror/state'; -import { syntaxTree, ensureSyntaxTree } from '@codemirror/language'; +import { syntaxTree, syntaxTreeAvailable } from '@codemirror/language'; import type { Tree } from '@lezer/common'; import { Block as BlockNode, @@ -16,6 +16,7 @@ import { type BlockDelimiterInfo, type SupportedLanguage, AUTO_DETECT_SUFFIX, + BLOCK_CREATED_AT_PREFIX, DELIMITER_PREFIX, DELIMITER_REGEX, DELIMITER_START, @@ -33,6 +34,7 @@ function getDefaultDelimiterInfo(): BlockDelimiterInfo { language: DEFAULT_LANGUAGE, auto: false, access: DEFAULT_ACCESS, + createdAt: undefined, }; } @@ -81,6 +83,7 @@ function collectBlocksFromTree(tree: Tree, state: EditorState): Block[] | null { name: delimiterInfo.language, auto: delimiterInfo.auto, }, + createdAt: delimiterInfo.createdAt, access: delimiterInfo.access, content, delimiter, @@ -151,6 +154,7 @@ export function getBlocksFromString(state: EditorState): Block[] { name: delimiterInfo.language, auto: delimiterInfo.auto, }, + createdAt: delimiterInfo.createdAt, access: delimiterInfo.access, content: { from: contentStart, to: contentEnd }, delimiter: { from: blockStart, to: delimiterEnd + suffixLength }, @@ -174,14 +178,8 @@ export function getBlocksFromString(state: EditorState): Block[] { * 获取文档中的所有块 */ export function getBlocks(state: EditorState): Block[] { - let blocks = getBlocksFromSyntaxTree(state); - if (blocks) { - return blocks; - } - - const ensuredTree = ensureSyntaxTree(state, state.doc.length, 200); - if (ensuredTree) { - blocks = collectBlocksFromTree(ensuredTree, state); + if (syntaxTreeAvailable(state, state.doc.length)) { + const blocks = getBlocksFromSyntaxTree(state); if (blocks) { return blocks; } @@ -257,13 +255,15 @@ export function createDelimiter( language: SupportedLanguage, autoDetect = false, access: BlockAccess = DEFAULT_ACCESS, + createdAt?: string, ): string { const suffixes: string[] = []; if (autoDetect) { suffixes.push(AUTO_DETECT_SUFFIX); } suffixes.push(access === 'read' ? READONLY_SUFFIX : WRITABLE_SUFFIX); - return `${DELIMITER_PREFIX}${language}${suffixes.join('')}${DELIMITER_SUFFIX}`; + const metadata = createdAt ? `${BLOCK_CREATED_AT_PREFIX}${createdAt}` : ''; + return `${DELIMITER_PREFIX}${language}${suffixes.join('')}${metadata}${DELIMITER_SUFFIX}`; } /** @@ -282,7 +282,7 @@ export function parseDelimiter(delimiterText: string): BlockDelimiterInfo | null return null; } - const [, languageName, rawFlags = ''] = match; + const [, languageName, rawFlags = '', createdAt] = match; const validLanguage = LANGUAGES.some(lang => lang.token === languageName) ? languageName as SupportedLanguage : DEFAULT_LANGUAGE; @@ -307,6 +307,7 @@ export function parseDelimiter(delimiterText: string): BlockDelimiterInfo | null language: validLanguage, auto, access, + createdAt: createdAt || undefined, }; } diff --git a/frontend/src/views/editor/extensions/codeblock/state.ts b/frontend/src/views/editor/extensions/codeblock/state.ts index ca32b3d4..25f63c7b 100644 --- a/frontend/src/views/editor/extensions/codeblock/state.ts +++ b/frontend/src/views/editor/extensions/codeblock/state.ts @@ -2,7 +2,7 @@ * Block 状态管理 */ -import { StateField, EditorState } from '@codemirror/state'; +import { StateField, EditorState, type SelectionRange } from '@codemirror/state'; import { Block } from './types'; import { getBlocks } from './parser'; @@ -70,4 +70,38 @@ export function getNoteBlockFromPos(state: EditorState, pos: number): Block | un return state.field(blockState).find(block => block.range.from <= pos && block.range.to >= pos ); -} \ No newline at end of file +} + +/** + * 获取指定范围内的所有块 + */ +export function getNoteBlocksBetween(state: EditorState, from: number, to: number): Block[] { + if (!state.field(blockState, false)) { + return []; + } + + return state.field(blockState).filter(block => + block.range.from < to && block.range.to >= from + ); +} + +/** + * 根据选择范围集获取涉及的所有块 + */ +export function getNoteBlocksFromRangeSet(state: EditorState, ranges: readonly SelectionRange[]): Block[] { + const blocks: Block[] = []; + const seenBlockStarts = new Set(); + + for (const range of ranges) { + for (const block of getNoteBlocksBetween(state, range.from, range.to)) { + if (seenBlockStarts.has(block.range.from)) { + continue; + } + + seenBlockStarts.add(block.range.from); + blocks.push(block); + } + } + + return blocks; +} diff --git a/frontend/src/views/editor/extensions/codeblock/timestamp.ts b/frontend/src/views/editor/extensions/codeblock/timestamp.ts new file mode 100644 index 00000000..2244b2ea --- /dev/null +++ b/frontend/src/views/editor/extensions/codeblock/timestamp.ts @@ -0,0 +1,26 @@ +export function createBlockCreatedAt(): string { + return new Date().toISOString(); +} + +export function formatBlockCreatedAt( + value?: string, + options: Intl.DateTimeFormatOptions = { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }, + locale?: string, +): string { + if (!value) { + return ""; + } + + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return ""; + } + + return new Intl.DateTimeFormat(locale, options).format(date); +} diff --git a/frontend/src/views/editor/extensions/codeblock/types.ts b/frontend/src/views/editor/extensions/codeblock/types.ts index 7bc8ad3d..0ff83299 100644 --- a/frontend/src/views/editor/extensions/codeblock/types.ts +++ b/frontend/src/views/editor/extensions/codeblock/types.ts @@ -6,6 +6,7 @@ export interface Block { name: string; auto: boolean; }; + createdAt?: string; access: BlockAccess; content: { from: number; @@ -33,6 +34,7 @@ export interface BlockDelimiterInfo { language: SupportedLanguage; auto: boolean; access: BlockAccess; + createdAt?: string; } /** @@ -108,5 +110,6 @@ export const DELIMITER_SUFFIX = '\n'; export const AUTO_DETECT_SUFFIX = '-a'; export const READONLY_SUFFIX = '-r'; export const WRITABLE_SUFFIX = '-w'; +export const BLOCK_CREATED_AT_PREFIX = ';created='; -export const DELIMITER_REGEX = /^(?:\n)?∞∞∞([a-zA-Z0-9_]+)((?:-(?:a|r|w))*)\n$/m; +export const DELIMITER_REGEX = /^(?:\n)?∞∞∞([a-zA-Z0-9_]+)((?:-(?:a|r|w))*)(?:;created=([^\n;]+))?\n$/m; diff --git a/frontend/src/views/editor/extensions/commandPalette/CommandPaletteDialog.vue b/frontend/src/views/editor/extensions/commandPalette/CommandPaletteDialog.vue new file mode 100644 index 00000000..9b7e863f --- /dev/null +++ b/frontend/src/views/editor/extensions/commandPalette/CommandPaletteDialog.vue @@ -0,0 +1,432 @@ + + + + + diff --git a/frontend/src/views/editor/extensions/commandPalette/index.ts b/frontend/src/views/editor/extensions/commandPalette/index.ts new file mode 100644 index 00000000..09a0f589 --- /dev/null +++ b/frontend/src/views/editor/extensions/commandPalette/index.ts @@ -0,0 +1,7 @@ +import type { Command } from "@codemirror/view"; +import { commandPaletteManager } from "./manager"; + +export const openCommandPaletteCommand: Command = () => { + commandPaletteManager.show(); + return true; +}; diff --git a/frontend/src/views/editor/extensions/commandPalette/manager.ts b/frontend/src/views/editor/extensions/commandPalette/manager.ts new file mode 100644 index 00000000..12e9d4fe --- /dev/null +++ b/frontend/src/views/editor/extensions/commandPalette/manager.ts @@ -0,0 +1,46 @@ +import { readonly, shallowRef, type ShallowRef } from "vue"; + +interface CommandPaletteState { + visible: boolean; + initialQuery: string; +} + +class CommandPaletteManager { + private state: ShallowRef = shallowRef({ + visible: false, + initialQuery: "", + }); + + useState() { + return readonly(this.state); + } + + show(initialQuery = ""): void { + this.state.value = { + visible: true, + initialQuery, + }; + } + + hide(): void { + this.state.value = { + visible: false, + initialQuery: "", + }; + } + + toggle(initialQuery = ""): void { + if (this.state.value.visible) { + this.hide(); + return; + } + + this.show(initialQuery); + } + + destroy(): void { + this.hide(); + } +} + +export const commandPaletteManager = new CommandPaletteManager(); diff --git a/frontend/src/views/editor/extensions/contextMenu/index.ts b/frontend/src/views/editor/extensions/contextMenu/index.ts index fd3ecaec..473e248f 100644 --- a/frontend/src/views/editor/extensions/contextMenu/index.ts +++ b/frontend/src/views/editor/extensions/contextMenu/index.ts @@ -11,6 +11,7 @@ import type {MenuSchemaNode} from './menuSchema'; import {buildRegisteredMenu, createMenuContext, registerMenuNodes} from './menuSchema'; import {blockImageMenuNodes} from '../blockImage/contextMenu'; import {blockReadonlyMenuNodes} from '../blockReadonly/contextMenu'; +import {codeBlockMenuNodes} from '../codeblock/contextMenu'; function t(key: string): string { @@ -109,6 +110,7 @@ function ensureBuiltinMenuRegistered(): void { if (builtinMenuRegistered) return; registerMenuNodes([ ...builtinMenuNodes(), + ...codeBlockMenuNodes, ...blockReadonlyMenuNodes, ...blockImageMenuNodes, ]); diff --git a/frontend/src/views/editor/extensions/inlineImage/inlineImage.ts b/frontend/src/views/editor/extensions/inlineImage/inlineImage.ts index 34f0979c..3431eb18 100644 --- a/frontend/src/views/editor/extensions/inlineImage/inlineImage.ts +++ b/frontend/src/views/editor/extensions/inlineImage/inlineImage.ts @@ -5,7 +5,6 @@ import copyDarkIconUrl from '@/assets/icons/copy-dark.svg'; import pencilWhiteIconUrl from '@/assets/icons/pencil-white.svg'; import resizeHandleDarkUrl from '@/assets/icons/resize-handle-se-dark.png'; import resizeHandleLightUrl from '@/assets/icons/resize-handle-se-light.png'; -import trashWhiteIconUrl from '@/assets/icons/trash-white.svg'; import {parseInlineImages} from './inlineImageParsing'; import {InlineImageWidget} from './inlineImageWidget'; import type {ParsedInlineImage} from './types'; @@ -161,14 +160,6 @@ function createInlineImageTheme(): Extension { '.inline-image .buttons-container button.draw': { backgroundImage: `url("${pencilWhiteIconUrl}")`, }, - '.inline-image .buttons-container button.delete': { - backgroundImage: `url("${trashWhiteIconUrl}")`, - padding: 'calc(3px * var(--button-scale, 1)) calc(8px * var(--button-scale, 1)) calc(3px * var(--button-scale, 1)) calc(22px * var(--button-scale, 1))', - backgroundColor: '#8f3d3d', - }, - '.inline-image .buttons-container button.delete:hover': { - backgroundColor: '#7c3131', - }, '.inline-image.selected': { '--handle-color': 'var(--outline-color)', }, diff --git a/frontend/src/views/editor/extensions/inlineImage/inlineImageWidget.ts b/frontend/src/views/editor/extensions/inlineImage/inlineImageWidget.ts index 31a07710..2f6e5eba 100644 --- a/frontend/src/views/editor/extensions/inlineImage/inlineImageWidget.ts +++ b/frontend/src/views/editor/extensions/inlineImage/inlineImageWidget.ts @@ -1,9 +1,9 @@ import {EditorSelection, type Compartment} from '@codemirror/state'; import {EditorView, WidgetType} from '@codemirror/view'; import i18n from '@/i18n'; -import {copyImage, deleteImageAsset} from './clipboard'; +import {copyImage} from './clipboard'; import {inlineImageDrawManager} from './manager'; -import {parseInlineImages, removeInlineImage, setInlineImageDisplayDimensions} from './inlineImageParsing'; +import {parseInlineImages, setInlineImageDisplayDimensions} from './inlineImageParsing'; const FOLDED_HEIGHT = 16; @@ -99,7 +99,6 @@ export class InlineImageWidget extends WidgetType { let copyButton: HTMLButtonElement | null = null; let drawButton: HTMLButtonElement | null = null; - let deleteButton: HTMLButtonElement | null = null; if (this.interactive && !this.isFolded) { const buttonsContainer = document.createElement('div'); @@ -119,22 +118,12 @@ export class InlineImageWidget extends WidgetType { drawButton.innerHTML = `${t('inlineImage.draw')}`; buttonsContainer.appendChild(drawButton); - deleteButton = document.createElement('button'); - deleteButton.type = 'button'; - deleteButton.className = 'delete'; - deleteButton.title = t('inlineImage.delete'); - deleteButton.innerHTML = `${t('inlineImage.delete')}`; - buttonsContainer.appendChild(deleteButton); - copyButton.addEventListener('mousedown', event => { event.preventDefault(); }); drawButton.addEventListener('mousedown', event => { event.preventDefault(); }); - deleteButton.addEventListener('mousedown', event => { - event.preventDefault(); - }); requestAnimationFrame(() => { this.syncControlScale(wrap, this.getNumericWidth(), buttonsContainer); @@ -172,18 +161,6 @@ export class InlineImageWidget extends WidgetType { }); } - if (deleteButton) { - deleteButton.addEventListener('click', async event => { - event.preventDefault(); - try { - await deleteImageAsset(this.assetRef || this.path); - removeInlineImage(view, this.id); - } catch (error) { - console.error('[inlineImage] Failed to delete image:', error); - } - }); - } - if (this.interactive && !this.isFolded && this.domEventCompartment) { const resizeHandle = document.createElement('div'); resizeHandle.className = 'resize-handle'; diff --git a/frontend/src/views/editor/extensions/vscodeSearch/SearchPanel.vue b/frontend/src/views/editor/extensions/vscodeSearch/SearchPanel.vue index ab0f3e28..c6fbae5f 100644 --- a/frontend/src/views/editor/extensions/vscodeSearch/SearchPanel.vue +++ b/frontend/src/views/editor/extensions/vscodeSearch/SearchPanel.vue @@ -1,5 +1,6 @@ @@ -232,6 +263,40 @@ const handleAutoSaveDelayChange = async (event: Event) => { /> + + + + + + + + + + + +
+ + {{ configStore.config.editing.blockSeparatorHeight }}px + +
+
+
diff --git a/internal/models/config.go b/internal/models/config.go index e92ec227..5554bb71 100644 --- a/internal/models/config.go +++ b/internal/models/config.go @@ -92,13 +92,19 @@ type EditingConfig struct { // 保存选项 AutoSaveDelay int `json:"autoSaveDelay"` // 自动保存延迟(毫秒) + + // 默认块设置 + DefaultBlockLanguage string `json:"defaultBlockLanguage"` // 默认新块语言 + DefaultBlockAutoDetect bool `json:"defaultBlockAutoDetect"` // 默认新块自动识别 + BlockSeparatorHeight int `json:"blockSeparatorHeight"` // 块分隔符高度(像素) } // AppearanceConfig 外观设置配置 type AppearanceConfig struct { - Language LanguageType `json:"language"` // 界面语言 - SystemTheme SystemThemeType `json:"systemTheme"` // 系统界面主题 - CurrentTheme string `json:"currentTheme"` // 当前选择的预设主题名称 + Language LanguageType `json:"language"` // 界面语言 + SystemTheme SystemThemeType `json:"systemTheme"` // 系统界面主题 + CurrentTheme string `json:"currentTheme"` // 当前选择的预设主题名称 + CursorBlinkRate int `json:"cursorBlinkRate"` // 光标闪烁周期(毫秒) } // UpdatesConfig 更新设置配置 @@ -220,11 +226,16 @@ func NewDefaultAppConfig() *AppConfig { KeymapMode: Standard, // 默认使用标准模式 // 保存选项 AutoSaveDelay: 2000, + // 默认块设置 + DefaultBlockLanguage: "text", + DefaultBlockAutoDetect: true, + BlockSeparatorHeight: 12, }, Appearance: AppearanceConfig{ - Language: LangEnUS, - SystemTheme: SystemThemeDark, - CurrentTheme: "default-dark", // 默认使用 default-dark 主题 + Language: LangEnUS, + SystemTheme: SystemThemeDark, + CurrentTheme: "default-dark", // 默认使用 default-dark 主题 + CursorBlinkRate: 1000, }, Updates: UpdatesConfig{ Version: version.Version, diff --git a/internal/models/document_content.go b/internal/models/document_content.go index ca15d247..b8e9a4f0 100644 --- a/internal/models/document_content.go +++ b/internal/models/document_content.go @@ -1,13 +1,27 @@ package models -import "strings" +import ( + "strings" + "unicode" +) const ( DocumentBlockDelimiterStart = "∞∞∞" DocumentBlockDelimiterPrefix = "\n" + DocumentBlockDelimiterStart + DefaultDocumentBlockLanguage = "text" DefaultDocumentContent = DocumentBlockDelimiterPrefix + "text-a-w\n" ) +func BuildDefaultDocumentContent(language string, autoDetect bool) string { + token := normalizeBlockLanguageToken(language) + flags := "" + if autoDetect { + flags += "-a" + } + flags += "-w" + return DocumentBlockDelimiterPrefix + token + flags + "\n" +} + func NormalizeDocumentContent(content string) string { if strings.HasPrefix(content, DocumentBlockDelimiterStart) && !strings.HasPrefix(content, DocumentBlockDelimiterPrefix) { @@ -15,3 +29,18 @@ func NormalizeDocumentContent(content string) string { } return content } + +func normalizeBlockLanguageToken(language string) string { + language = strings.ToLower(strings.TrimSpace(language)) + if language == "" { + return DefaultDocumentBlockLanguage + } + + for _, char := range language { + if !unicode.IsLetter(char) && !unicode.IsDigit(char) && char != '_' { + return DefaultDocumentBlockLanguage + } + } + + return language +} diff --git a/internal/models/key_binding.go b/internal/models/key_binding.go index 304c4508..64c10578 100644 --- a/internal/models/key_binding.go +++ b/internal/models/key_binding.go @@ -25,85 +25,87 @@ type KeyBinding struct { } const ( - ShowSearch KeyBindingName = "showSearch" // 显示搜索 - HideSearch KeyBindingName = "hideSearch" // 隐藏搜索 - BlockSelectAll KeyBindingName = "blockSelectAll" // 块内选择全部 - BlockAddAfterCurrent KeyBindingName = "blockAddAfterCurrent" // 在当前块后添加新块 - BlockAddAfterLast KeyBindingName = "blockAddAfterLast" // 在最后添加新块 - BlockAddBeforeCurrent KeyBindingName = "blockAddBeforeCurrent" // 在当前块前添加新块 - BlockGotoPrevious KeyBindingName = "blockGotoPrevious" // 跳转到上一个块 - BlockGotoNext KeyBindingName = "blockGotoNext" // 跳转到下一个块 - BlockSelectPrevious KeyBindingName = "blockSelectPrevious" // 选择上一个块 - BlockSelectNext KeyBindingName = "blockSelectNext" // 选择下一个块 - BlockDelete KeyBindingName = "blockDelete" // 删除当前块 - BlockMoveUp KeyBindingName = "blockMoveUp" // 向上移动当前块 - BlockMoveDown KeyBindingName = "blockMoveDown" // 向下移动当前块 - BlockDeleteLine KeyBindingName = "blockDeleteLine" // 删除行 - BlockMoveLineUp KeyBindingName = "blockMoveLineUp" // 向上移动行 - BlockMoveLineDown KeyBindingName = "blockMoveLineDown" // 向下移动行 - BlockTransposeChars KeyBindingName = "blockTransposeChars" // 字符转置 - BlockFormat KeyBindingName = "blockFormat" // 格式化代码块 - BlockCopy KeyBindingName = "blockCopy" // 复制 - BlockCut KeyBindingName = "blockCut" // 剪切 - BlockPaste KeyBindingName = "blockPaste" // 粘贴 - FoldCode KeyBindingName = "foldCode" // 折叠代码 - UnfoldCode KeyBindingName = "unfoldCode" // 展开代码 - FoldAll KeyBindingName = "foldAll" // 折叠全部 - UnfoldAll KeyBindingName = "unfoldAll" // 展开全部 - CursorSyntaxLeft KeyBindingName = "cursorSyntaxLeft" // 光标按语法左移 - CursorSyntaxRight KeyBindingName = "cursorSyntaxRight" // 光标按语法右移 - SelectSyntaxLeft KeyBindingName = "selectSyntaxLeft" // 按语法选择左侧 - SelectSyntaxRight KeyBindingName = "selectSyntaxRight" // 按语法选择右侧 - CopyLineUp KeyBindingName = "copyLineUp" // 向上复制行 - CopyLineDown KeyBindingName = "copyLineDown" // 向下复制行 - InsertBlankLine KeyBindingName = "insertBlankLine" // 插入空行 - SelectLine KeyBindingName = "selectLine" // 选择行 - SelectParentSyntax KeyBindingName = "selectParentSyntax" // 选择父级语法 - SimplifySelection KeyBindingName = "simplifySelection" // 简化选择 - AddCursorAbove KeyBindingName = "addCursorAbove" // 在上方添加光标 - AddCursorBelow KeyBindingName = "addCursorBelow" // 在下方添加光标 - CursorGroupLeft KeyBindingName = "cursorGroupLeft" // 光标按单词左移 - CursorGroupRight KeyBindingName = "cursorGroupRight" // 光标按单词右移 - SelectGroupLeft KeyBindingName = "selectGroupLeft" // 按单词选择左侧 - SelectGroupRight KeyBindingName = "selectGroupRight" // 按单词选择右侧 - DeleteToLineEnd KeyBindingName = "deleteToLineEnd" // 删除到行尾 - DeleteToLineStart KeyBindingName = "deleteToLineStart" // 删除到行首 - CursorLineStart KeyBindingName = "cursorLineStart" // 移动到行首 - CursorLineEnd KeyBindingName = "cursorLineEnd" // 移动到行尾 - SelectLineStart KeyBindingName = "selectLineStart" // 选择到行首 - SelectLineEnd KeyBindingName = "selectLineEnd" // 选择到行尾 - CursorDocStart KeyBindingName = "cursorDocStart" // 跳转到文档开头 - CursorDocEnd KeyBindingName = "cursorDocEnd" // 跳转到文档结尾 - SelectDocStart KeyBindingName = "selectDocStart" // 选择到文档开头 - SelectDocEnd KeyBindingName = "selectDocEnd" // 选择到文档结尾 - SelectMatchingBracket KeyBindingName = "selectMatchingBracket" // 选择到匹配括号 - SplitLine KeyBindingName = "splitLine" // 分割行 - CursorCharLeft KeyBindingName = "cursorCharLeft" // 光标左移一个字符 - CursorCharRight KeyBindingName = "cursorCharRight" // 光标右移一个字符 - CursorLineUp KeyBindingName = "cursorLineUp" // 光标上移一行 - CursorLineDown KeyBindingName = "cursorLineDown" // 光标下移一行 - CursorPageUp KeyBindingName = "cursorPageUp" // 向上翻页 - CursorPageDown KeyBindingName = "cursorPageDown" // 向下翻页 - SelectCharLeft KeyBindingName = "selectCharLeft" // 选择左移一个字符 - SelectCharRight KeyBindingName = "selectCharRight" // 选择右移一个字符 - SelectLineUp KeyBindingName = "selectLineUp" // 选择上移一行 - SelectLineDown KeyBindingName = "selectLineDown" // 选择下移一行 - IndentLess KeyBindingName = "indentLess" // 减少缩进 - IndentMore KeyBindingName = "indentMore" // 增加缩进 - IndentSelection KeyBindingName = "indentSelection" // 缩进选择 - CursorMatchingBracket KeyBindingName = "cursorMatchingBracket" // 光标到匹配括号 - ToggleComment KeyBindingName = "toggleComment" // 切换注释 - ToggleBlockComment KeyBindingName = "toggleBlockComment" // 切换块注释 - InsertNewlineAndIndent KeyBindingName = "insertNewlineAndIndent" // 插入新行并缩进 - DeleteCharBackward KeyBindingName = "deleteCharBackward" // 向后删除字符 - DeleteCharForward KeyBindingName = "deleteCharForward" // 向前删除字符 - DeleteGroupBackward KeyBindingName = "deleteGroupBackward" // 向后删除组 - DeleteGroupForward KeyBindingName = "deleteGroupForward" // 向前删除组 - HistoryUndo KeyBindingName = "historyUndo" // 撤销 - HistoryRedo KeyBindingName = "historyRedo" // 重做 - HistoryUndoSelection KeyBindingName = "historyUndoSelection" // 撤销选择 - HistoryRedoSelection KeyBindingName = "historyRedoSelection" // 重做选择 - CopyBlockImage KeyBindingName = "copyBlockImage" // 复制块为图片 + ShowSearch KeyBindingName = "showSearch" // 显示搜索 + HideSearch KeyBindingName = "hideSearch" // 隐藏搜索 + OpenCommandPalette KeyBindingName = "openCommandPalette" // 打开命令面板 + BlockSelectAll KeyBindingName = "blockSelectAll" // 块内选择全部 + BlockAddAfterCurrent KeyBindingName = "blockAddAfterCurrent" // 在当前块后添加新块 + BlockAddAfterLast KeyBindingName = "blockAddAfterLast" // 在最后添加新块 + BlockAddAfterLastAndScrollDown KeyBindingName = "blockAddAfterLastAndScrollDown" // 在最后添加新块并滚动到底部 + BlockAddBeforeCurrent KeyBindingName = "blockAddBeforeCurrent" // 在当前块前添加新块 + BlockGotoPrevious KeyBindingName = "blockGotoPrevious" // 跳转到上一个块 + BlockGotoNext KeyBindingName = "blockGotoNext" // 跳转到下一个块 + BlockSelectPrevious KeyBindingName = "blockSelectPrevious" // 选择上一个块 + BlockSelectNext KeyBindingName = "blockSelectNext" // 选择下一个块 + BlockDelete KeyBindingName = "blockDelete" // 删除当前块 + BlockMoveUp KeyBindingName = "blockMoveUp" // 向上移动当前块 + BlockMoveDown KeyBindingName = "blockMoveDown" // 向下移动当前块 + BlockDeleteLine KeyBindingName = "blockDeleteLine" // 删除行 + BlockMoveLineUp KeyBindingName = "blockMoveLineUp" // 向上移动行 + BlockMoveLineDown KeyBindingName = "blockMoveLineDown" // 向下移动行 + BlockTransposeChars KeyBindingName = "blockTransposeChars" // 字符转置 + BlockFormat KeyBindingName = "blockFormat" // 格式化代码块 + BlockCopy KeyBindingName = "blockCopy" // 复制 + BlockCut KeyBindingName = "blockCut" // 剪切 + BlockPaste KeyBindingName = "blockPaste" // 粘贴 + FoldCode KeyBindingName = "foldCode" // 折叠代码 + UnfoldCode KeyBindingName = "unfoldCode" // 展开代码 + FoldAll KeyBindingName = "foldAll" // 折叠全部 + UnfoldAll KeyBindingName = "unfoldAll" // 展开全部 + CursorSyntaxLeft KeyBindingName = "cursorSyntaxLeft" // 光标按语法左移 + CursorSyntaxRight KeyBindingName = "cursorSyntaxRight" // 光标按语法右移 + SelectSyntaxLeft KeyBindingName = "selectSyntaxLeft" // 按语法选择左侧 + SelectSyntaxRight KeyBindingName = "selectSyntaxRight" // 按语法选择右侧 + CopyLineUp KeyBindingName = "copyLineUp" // 向上复制行 + CopyLineDown KeyBindingName = "copyLineDown" // 向下复制行 + InsertBlankLine KeyBindingName = "insertBlankLine" // 插入空行 + SelectLine KeyBindingName = "selectLine" // 选择行 + SelectParentSyntax KeyBindingName = "selectParentSyntax" // 选择父级语法 + SimplifySelection KeyBindingName = "simplifySelection" // 简化选择 + AddCursorAbove KeyBindingName = "addCursorAbove" // 在上方添加光标 + AddCursorBelow KeyBindingName = "addCursorBelow" // 在下方添加光标 + CursorGroupLeft KeyBindingName = "cursorGroupLeft" // 光标按单词左移 + CursorGroupRight KeyBindingName = "cursorGroupRight" // 光标按单词右移 + SelectGroupLeft KeyBindingName = "selectGroupLeft" // 按单词选择左侧 + SelectGroupRight KeyBindingName = "selectGroupRight" // 按单词选择右侧 + DeleteToLineEnd KeyBindingName = "deleteToLineEnd" // 删除到行尾 + DeleteToLineStart KeyBindingName = "deleteToLineStart" // 删除到行首 + CursorLineStart KeyBindingName = "cursorLineStart" // 移动到行首 + CursorLineEnd KeyBindingName = "cursorLineEnd" // 移动到行尾 + SelectLineStart KeyBindingName = "selectLineStart" // 选择到行首 + SelectLineEnd KeyBindingName = "selectLineEnd" // 选择到行尾 + CursorDocStart KeyBindingName = "cursorDocStart" // 跳转到文档开头 + CursorDocEnd KeyBindingName = "cursorDocEnd" // 跳转到文档结尾 + SelectDocStart KeyBindingName = "selectDocStart" // 选择到文档开头 + SelectDocEnd KeyBindingName = "selectDocEnd" // 选择到文档结尾 + SelectMatchingBracket KeyBindingName = "selectMatchingBracket" // 选择到匹配括号 + SplitLine KeyBindingName = "splitLine" // 分割行 + CursorCharLeft KeyBindingName = "cursorCharLeft" // 光标左移一个字符 + CursorCharRight KeyBindingName = "cursorCharRight" // 光标右移一个字符 + CursorLineUp KeyBindingName = "cursorLineUp" // 光标上移一行 + CursorLineDown KeyBindingName = "cursorLineDown" // 光标下移一行 + CursorPageUp KeyBindingName = "cursorPageUp" // 向上翻页 + CursorPageDown KeyBindingName = "cursorPageDown" // 向下翻页 + SelectCharLeft KeyBindingName = "selectCharLeft" // 选择左移一个字符 + SelectCharRight KeyBindingName = "selectCharRight" // 选择右移一个字符 + SelectLineUp KeyBindingName = "selectLineUp" // 选择上移一行 + SelectLineDown KeyBindingName = "selectLineDown" // 选择下移一行 + IndentLess KeyBindingName = "indentLess" // 减少缩进 + IndentMore KeyBindingName = "indentMore" // 增加缩进 + IndentSelection KeyBindingName = "indentSelection" // 缩进选择 + CursorMatchingBracket KeyBindingName = "cursorMatchingBracket" // 光标到匹配括号 + ToggleComment KeyBindingName = "toggleComment" // 切换注释 + ToggleBlockComment KeyBindingName = "toggleBlockComment" // 切换块注释 + InsertNewlineAndIndent KeyBindingName = "insertNewlineAndIndent" // 插入新行并缩进 + DeleteCharBackward KeyBindingName = "deleteCharBackward" // 向后删除字符 + DeleteCharForward KeyBindingName = "deleteCharForward" // 向前删除字符 + DeleteGroupBackward KeyBindingName = "deleteGroupBackward" // 向后删除组 + DeleteGroupForward KeyBindingName = "deleteGroupForward" // 向前删除组 + HistoryUndo KeyBindingName = "historyUndo" // 撤销 + HistoryRedo KeyBindingName = "historyRedo" // 重做 + HistoryUndoSelection KeyBindingName = "historyUndoSelection" // 撤销选择 + HistoryRedoSelection KeyBindingName = "historyRedoSelection" // 重做选择 + CopyBlockImage KeyBindingName = "copyBlockImage" // 复制块为图片 ) const DefaultExtension = "editor" @@ -129,6 +131,14 @@ func NewDefaultKeyBindings() []KeyBinding { Enabled: true, PreventDefault: false, }, + { + Name: OpenCommandPalette, + Type: Standard, + Key: "F1", + Extension: DefaultExtension, + Enabled: true, + PreventDefault: true, + }, // 块操作相关 { @@ -155,6 +165,14 @@ func NewDefaultKeyBindings() []KeyBinding { Enabled: true, PreventDefault: true, }, + { + Name: BlockAddAfterLastAndScrollDown, + Type: Standard, + Key: "Mod-Alt-Enter", + Extension: DefaultExtension, + Enabled: true, + PreventDefault: true, + }, { Name: BlockAddBeforeCurrent, Type: Standard, diff --git a/internal/services/config_service.go b/internal/services/config_service.go index beeffee7..64cd94fa 100644 --- a/internal/services/config_service.go +++ b/internal/services/config_service.go @@ -80,6 +80,13 @@ func (cs *ConfigService) initConfig() error { return fmt.Errorf("failed to load config: %w", err) } + if cs.configMigrator != nil { + defaultConfig := models.NewDefaultAppConfig() + if _, err := cs.configMigrator.AutoMigrate(defaultConfig, cs.koanf); err != nil { + return fmt.Errorf("failed to migrate config: %w", err) + } + } + return nil } diff --git a/internal/services/currency_service.go b/internal/services/currency_service.go new file mode 100644 index 00000000..6970cab2 --- /dev/null +++ b/internal/services/currency_service.go @@ -0,0 +1,144 @@ +package services + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/wailsapp/wails/v3/pkg/services/log" +) + +const ( + currencyRatesURL = "https://currencies.heynote.com/rates.json" + currencyStaleTime = 12 * time.Hour + currencyHTTPTimeout = 15 * time.Second +) + +type CurrencyData struct { + Base string `json:"base"` + Rates map[string]float64 `json:"rates"` + Timestamp int64 `json:"timestamp,omitempty"` +} + +type currencyCache struct { + Data *CurrencyData `json:"data,omitempty"` + TimeFetched int64 `json:"timeFetched,omitempty"` +} + +type CurrencyService struct { + configService *ConfigService + logger *log.LogService + client *http.Client + now func() time.Time +} + +func NewCurrencyService(configService *ConfigService, logger *log.LogService) *CurrencyService { + if logger == nil { + logger = log.New() + } + + return &CurrencyService{ + configService: configService, + logger: logger, + client: &http.Client{ + Timeout: currencyHTTPTimeout, + }, + now: time.Now, + } +} + +func (s *CurrencyService) GetCurrencyData(ctx context.Context) (*CurrencyData, error) { + cached, cacheErr := s.readCachedCurrency() + if cacheErr != nil { + s.logger.Error("failed to read cached currency data", "error", cacheErr) + } + + if cached != nil && cached.Data != nil && cached.TimeFetched > 0 { + fetchedAt := time.UnixMilli(cached.TimeFetched) + if s.now().Sub(fetchedAt) < currencyStaleTime { + return cached.Data, nil + } + } + + request, err := http.NewRequestWithContext(ctx, http.MethodGet, currencyRatesURL, nil) + if err != nil { + return nil, err + } + request.Header.Set("Cache-Control", "no-cache") + + response, err := s.client.Do(request) + if err != nil { + if cached != nil && cached.Data != nil { + s.logger.Error("fetch currency data failed, using cached data", "error", err) + return cached.Data, nil + } + return nil, err + } + defer response.Body.Close() + + if response.StatusCode < 200 || response.StatusCode >= 300 { + if cached != nil && cached.Data != nil { + s.logger.Error("currency service returned non-success status, using cached data", "status", response.Status) + return cached.Data, nil + } + return nil, fmt.Errorf("currency service returned status %s", response.Status) + } + + var data CurrencyData + if err := json.NewDecoder(response.Body).Decode(&data); err != nil { + if cached != nil && cached.Data != nil { + s.logger.Error("decode currency data failed, using cached data", "error", err) + return cached.Data, nil + } + return nil, err + } + + if data.Base == "" || len(data.Rates) == 0 { + if cached != nil && cached.Data != nil { + s.logger.Error("currency data invalid, using cached data") + return cached.Data, nil + } + return nil, fmt.Errorf("currency data is invalid") + } + + if s.configService != nil { + cache := currencyCache{ + Data: &data, + TimeFetched: s.now().UnixMilli(), + } + if err := s.configService.Set("currency", cache); err != nil { + s.logger.Error("failed to persist currency cache", "error", err) + } + } + + return &data, nil +} + +func (s *CurrencyService) readCachedCurrency() (*currencyCache, error) { + if s.configService == nil { + return nil, nil + } + + rawValue := s.configService.Get("currency") + if rawValue == nil { + return nil, nil + } + + payload, err := json.Marshal(rawValue) + if err != nil { + return nil, err + } + + var cache currencyCache + if err := json.Unmarshal(payload, &cache); err != nil { + return nil, err + } + + if cache.Data == nil || cache.Data.Base == "" || len(cache.Data.Rates) == 0 { + return nil, nil + } + + return &cache, nil +} diff --git a/internal/services/document_service.go b/internal/services/document_service.go index e6d56400..f9a3b4db 100644 --- a/internal/services/document_service.go +++ b/internal/services/document_service.go @@ -32,17 +32,23 @@ type DocumentSaveResult struct { // DocumentService 文档服务 type DocumentService struct { - db *DatabaseService - logger *log.LogService - mediaSync *MediaSyncService + db *DatabaseService + logger *log.LogService + mediaSync *MediaSyncService + configService *ConfigService } // NewDocumentService 创建文档服务 -func NewDocumentService(db *DatabaseService, logger *log.LogService, mediaSync *MediaSyncService) *DocumentService { +func NewDocumentService(db *DatabaseService, logger *log.LogService, mediaSync *MediaSyncService, configService *ConfigService) *DocumentService { if logger == nil { logger = log.New() } - return &DocumentService{db: db, logger: logger, mediaSync: mediaSync} + return &DocumentService{ + db: db, + logger: logger, + mediaSync: mediaSync, + configService: configService, + } } // ServiceStartup 服务启动 @@ -74,7 +80,7 @@ func (s *DocumentService) GetDocumentByID(ctx context.Context, id int) (*ent.Doc func (s *DocumentService) CreateDocument(ctx context.Context, title string) (*ent.Document, error) { doc, err := s.db.Client.Document.Create(). SetTitle(title). - SetContent(models.DefaultDocumentContent). + SetContent(s.getDefaultDocumentContent()). Save(ctx) if err != nil { return nil, fmt.Errorf("create document error: %w", err) @@ -82,6 +88,20 @@ func (s *DocumentService) CreateDocument(ctx context.Context, title string) (*en return doc, nil } +func (s *DocumentService) getDefaultDocumentContent() string { + if s.configService == nil { + return models.DefaultDocumentContent + } + + defaultLanguage, _ := s.configService.Get("editing.defaultBlockLanguage").(string) + defaultAutoDetect, ok := s.configService.Get("editing.defaultBlockAutoDetect").(bool) + if !ok { + defaultAutoDetect = true + } + + return models.BuildDefaultDocumentContent(defaultLanguage, defaultAutoDetect) +} + // UpdateDocumentContent 更新文档内容 func (s *DocumentService) UpdateDocumentContent(ctx context.Context, id int, content string, baseUpdatedAt string) (*DocumentSaveResult, error) { doc, err := s.db.Client.Document.Get(ctx, id) diff --git a/internal/services/service_manager.go b/internal/services/service_manager.go index 33f89387..457883de 100644 --- a/internal/services/service_manager.go +++ b/internal/services/service_manager.go @@ -29,6 +29,7 @@ type ServiceManager struct { selfUpdateService *SelfUpdateService translationService *TranslationService themeService *ThemeService + currencyService *CurrencyService badgeService *dock.DockService notificationService *notifications.NotificationService testService *TestService @@ -50,7 +51,7 @@ func NewServiceManager() *ServiceManager { migrationService := NewMigrationService(databaseService, configService, logger) mediaSyncService := NewMediaSyncService(configService, logger, databaseService) mediaHTTPService := NewMediaHTTPService(configService, logger, databaseService, mediaSyncService) - documentService := NewDocumentService(databaseService, logger, mediaSyncService) + documentService := NewDocumentService(databaseService, logger, mediaSyncService, configService) windowSnapService := NewWindowSnapService(logger, configService) windowService := NewWindowService(logger, documentService, windowSnapService) systemService := NewSystemService(logger) @@ -63,6 +64,7 @@ func NewServiceManager() *ServiceManager { selfUpdateService := NewSelfUpdateService(configService, badgeService, notificationService, logger) translationService := NewTranslationService(logger) themeService := NewThemeService(databaseService, logger) + currencyService := NewCurrencyService(configService, logger) syncService := NewSyncService(configService, databaseService, logger) httpClientService := NewHttpClientService(logger) testService := NewTestService(badgeService, notificationService, logger) @@ -86,6 +88,7 @@ func NewServiceManager() *ServiceManager { selfUpdateService: selfUpdateService, translationService: translationService, themeService: themeService, + currencyService: currencyService, badgeService: badgeService, notificationService: notificationService, testService: testService, @@ -116,6 +119,7 @@ func (sm *ServiceManager) GetServices() []application.Service { application.NewService(sm.selfUpdateService), application.NewService(sm.translationService), application.NewService(sm.themeService), + application.NewService(sm.currencyService), application.NewService(sm.badgeService), application.NewService(sm.notificationService), application.NewService(sm.testService), From a19150dfd49354679a4c4ab70db404d81d859204 Mon Sep 17 00:00:00 2001 From: landaiqing Date: Sun, 12 Apr 2026 21:43:00 +0800 Subject: [PATCH 2/5] :bug: Fix tray i18n initialization timing --- internal/systray/systray.go | 10 ++++++++-- main.go | 4 +--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/internal/systray/systray.go b/internal/systray/systray.go index 54fb9e32..e9c79eb3 100644 --- a/internal/systray/systray.go +++ b/internal/systray/systray.go @@ -8,10 +8,16 @@ import ( "voidraft/internal/version" "github.com/wailsapp/wails/v3/pkg/application" - wailsevents "github.com/wailsapp/wails/v3/pkg/events" + "github.com/wailsapp/wails/v3/pkg/events" "github.com/wailsapp/wails/v3/pkg/icons" ) +func SetupSystemTrayOnAppStarted(app *application.App, mainWindow *application.WebviewWindow, assets embed.FS, trayService *services.TrayService) { + app.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(_ *application.ApplicationEvent) { + SetupSystemTray(mainWindow, assets, trayService) + }) +} + func SetupSystemTray(mainWindow *application.WebviewWindow, assets embed.FS, trayService *services.TrayService) { app := application.Get() systray := app.SystemTray.New() @@ -40,7 +46,7 @@ func SetupSystemTray(mainWindow *application.WebviewWindow, assets embed.FS, tra trayService.AutoShowHide() }) - mainWindow.RegisterHook(wailsevents.Common.WindowClosing, func(event *application.WindowEvent) { + mainWindow.RegisterHook(events.Common.WindowClosing, func(event *application.WindowEvent) { event.Cancel() trayService.HandleWindowClose() }) diff --git a/main.go b/main.go index aabf2238..cfc30f3e 100644 --- a/main.go +++ b/main.go @@ -93,9 +93,7 @@ func main() { }) mainWindow.Center() window = mainWindow - trayService := serviceManager.GetTrayService() - // 设置系统托盘 - systray.SetupSystemTray(mainWindow, assets, trayService) + systray.SetupSystemTrayOnAppStarted(app, mainWindow, assets, serviceManager.GetTrayService()) // Run the application. This blocks until the application has been exited. err := app.Run() From e1abc868348eb0d0f25057d72e455baa5a7df7b9 Mon Sep 17 00:00:00 2001 From: landaiqing Date: Sun, 12 Apr 2026 22:00:46 +0800 Subject: [PATCH 3/5] :bug: Fix inline image missing placeholder --- frontend/src/i18n/locales/en-US.ts | 2 + frontend/src/i18n/locales/zh-CN.ts | 2 + .../extensions/inlineImage/inlineImage.ts | 84 +++++++++++++++ .../inlineImage/inlineImageWidget.ts | 100 ++++++++++++++++-- 4 files changed, 182 insertions(+), 6 deletions(-) diff --git a/frontend/src/i18n/locales/en-US.ts b/frontend/src/i18n/locales/en-US.ts index 79fde378..a06c61bc 100644 --- a/frontend/src/i18n/locales/en-US.ts +++ b/frontend/src/i18n/locales/en-US.ts @@ -462,6 +462,8 @@ export default { copied: 'Copied!', draw: 'Draw', delete: 'Delete', + missing: 'Image resource not found', + imageAlt: 'Inline image', drawDialog: { title: 'Image Annotation', select: 'Select', diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index d33c26b5..1ea001d3 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -464,6 +464,8 @@ export default { copied: '已复制', draw: '绘制', delete: '删除', + missing: '图片资源不存在', + imageAlt: '内联图片', drawDialog: { title: '图片标注', select: '选择', diff --git a/frontend/src/views/editor/extensions/inlineImage/inlineImage.ts b/frontend/src/views/editor/extensions/inlineImage/inlineImage.ts index 3431eb18..6b7250ad 100644 --- a/frontend/src/views/editor/extensions/inlineImage/inlineImage.ts +++ b/frontend/src/views/editor/extensions/inlineImage/inlineImage.ts @@ -61,6 +61,9 @@ function createInlineImageTheme(): Extension { '--snapped-outline-color': '#39a363', '--handle-color': '#ccc', '--image-border-color': '#c9c9c9', + '--image-missing-bg-color': '#f5f7fa', + '--image-missing-border-color': '#d6dce5', + '--image-missing-text-color': '#667085', padding: '6px 2px', display: 'inline-block', position: 'relative', @@ -71,6 +74,9 @@ function createInlineImageTheme(): Extension { '--snapped-outline-color': '#39a363', '--handle-color': '#192736', '--image-border-color': '#252525', + '--image-missing-bg-color': '#161d27', + '--image-missing-border-color': '#303946', + '--image-missing-text-color': '#9aa7b6', }, '.inline-image.folded': { padding: '0', @@ -108,6 +114,7 @@ function createInlineImageTheme(): Extension { border: '3px solid var(--outline-color)', boxSizing: 'border-box', pointerEvents: 'none', + zIndex: '3', }, '.inline-image .buttons-container': { position: 'absolute', @@ -122,6 +129,7 @@ function createInlineImageTheme(): Extension { overflow: 'hidden', containerType: 'inline-size', pointerEvents: 'none', + zIndex: '2', }, '.inline-image .buttons-container button': { height: 'calc(24px * var(--button-scale, 1))', @@ -215,6 +223,82 @@ function createInlineImageTheme(): Extension { '.inline-image.resizing.snapped': { '--outline-color': 'var(--snapped-outline-color)', }, + '.inline-image.image-missing img': { + opacity: '0', + }, + '.inline-image .missing-placeholder': { + display: 'none', + position: 'absolute', + inset: '0', + alignItems: 'center', + justifyContent: 'center', + flexDirection: 'column', + gap: '6px', + padding: '10px', + boxSizing: 'border-box', + backgroundColor: 'var(--image-missing-bg-color)', + color: 'var(--image-missing-text-color)', + border: '1px dashed var(--image-missing-border-color)', + textAlign: 'center', + fontSize: '12px', + lineHeight: '1.4', + pointerEvents: 'none', + zIndex: '1', + }, + '.inline-image.image-missing .missing-placeholder': { + display: 'block', + }, + '.inline-image.image-missing .buttons-container': { + display: 'none', + }, + '.inline-image.image-missing .resize-handle': { + display: 'none', + }, + '.inline-image .missing-placeholder .missing-content': { + position: 'absolute', + top: '50%', + left: '50%', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + flexDirection: 'column', + gap: '6px', + width: 'calc(100% - 20px)', + maxWidth: '100%', + transform: 'translate(-50%, -50%)', + }, + '.inline-image .missing-placeholder .missing-icon': { + display: 'block', + width: '32px', + height: '32px', + color: 'var(--image-missing-text-color)', + opacity: '0.75', + }, + '.inline-image .missing-placeholder .missing-icon svg': { + display: 'block', + width: '100%', + height: '100%', + }, + '.inline-image .missing-placeholder .missing-text': { + maxWidth: '100%', + textAlign: 'center', + wordBreak: 'break-word', + }, + '.inline-image.folded .missing-placeholder': { + padding: '0', + gap: '0', + }, + '.inline-image.folded .missing-placeholder .missing-content': { + width: '100%', + gap: '0', + }, + '.inline-image.folded .missing-placeholder .missing-icon': { + width: '14px', + height: '14px', + }, + '.inline-image.folded .missing-placeholder .missing-text': { + display: 'none', + }, }); } diff --git a/frontend/src/views/editor/extensions/inlineImage/inlineImageWidget.ts b/frontend/src/views/editor/extensions/inlineImage/inlineImageWidget.ts index 2f6e5eba..67820c11 100644 --- a/frontend/src/views/editor/extensions/inlineImage/inlineImageWidget.ts +++ b/frontend/src/views/editor/extensions/inlineImage/inlineImageWidget.ts @@ -131,11 +131,11 @@ export class InlineImageWidget extends WidgetType { } const image = document.createElement('img'); - image.src = this.path; - image.style.width = this.getWidth(); - image.style.height = this.getHeight(); inner.appendChild(image); + inner.appendChild(this.createMissingPlaceholder()); + this.syncImage(image, wrap); + if (copyButton) { copyButton.addEventListener('click', async event => { event.preventDefault(); @@ -182,9 +182,8 @@ export class InlineImageWidget extends WidgetType { return false; } - image.src = this.path; - image.style.width = this.getWidth(); - image.style.height = this.getHeight(); + this.syncImage(image, dom); + this.syncMissingPlaceholder(dom); this.syncControlScale(dom, this.getNumericWidth(), dom.querySelector('.buttons-container')); return true; } @@ -199,10 +198,99 @@ export class InlineImageWidget extends WidgetType { private syncWrapClassName(dom: HTMLElement): void { const isControlsVisible = dom.classList.contains('controls-visible'); + const isImageMissing = dom.classList.contains('image-missing'); dom.className = this.getClassName(); if (isControlsVisible) { dom.classList.add('controls-visible'); } + if (isImageMissing) { + dom.classList.add('image-missing'); + } + } + + private createMissingPlaceholder(): HTMLDivElement { + const placeholder = document.createElement('div'); + placeholder.className = 'missing-placeholder'; + placeholder.title = t('inlineImage.missing'); + + const content = document.createElement('div'); + content.className = 'missing-content'; + placeholder.appendChild(content); + + const icon = document.createElement('span'); + icon.className = 'missing-icon'; + icon.innerHTML = ` + + `; + content.appendChild(icon); + + const text = document.createElement('span'); + text.className = 'missing-text'; + text.textContent = t('inlineImage.missing'); + content.appendChild(text); + + return placeholder; + } + + private syncMissingPlaceholder(dom: HTMLElement): void { + const placeholder = this.ensureMissingPlaceholder(dom); + if (!placeholder) { + return; + } + + const message = t('inlineImage.missing'); + placeholder.title = message; + const text = placeholder.querySelector('.missing-text'); + if (text instanceof HTMLElement) { + text.textContent = message; + } + } + + private ensureMissingPlaceholder(dom: HTMLElement): HTMLElement | null { + const placeholder = dom.querySelector('.missing-placeholder'); + if (placeholder instanceof HTMLElement) { + return placeholder; + } + + const inner = dom.querySelector('.inner'); + if (!(inner instanceof HTMLElement)) { + return null; + } + + const nextPlaceholder = this.createMissingPlaceholder(); + inner.appendChild(nextPlaceholder); + return nextPlaceholder; + } + + private syncImage(image: HTMLImageElement, wrap: HTMLElement): void { + this.syncMissingPlaceholder(wrap); + const source = this.path; + image.alt = t('inlineImage.imageAlt'); + image.style.width = this.getWidth(); + image.style.height = this.getHeight(); + image.onload = () => { + if (image.getAttribute('src') === source) { + wrap.classList.remove('image-missing'); + } + }; + image.onerror = () => { + if (image.getAttribute('src') === source) { + wrap.classList.add('image-missing'); + } + }; + + if (image.getAttribute('src') !== source) { + wrap.classList.remove('image-missing'); + } + + image.src = source; + if (image.complete && image.naturalWidth === 0 && image.getAttribute('src') === source) { + wrap.classList.add('image-missing'); + } } private getWidth(): string { From dafbb66794a531723f4ef9754a7985f54e1f3244 Mon Sep 17 00:00:00 2001 From: landaiqing Date: Sun, 12 Apr 2026 22:35:16 +0800 Subject: [PATCH 4/5] :bug: Fix sync issues --- .../syncer/resource/adapter_apply_test.go | 181 ++++++++++++++++++ .../syncer/resource/extension_adapter.go | 10 +- internal/common/syncer/resource/helpers.go | 12 ++ .../syncer/resource/keybinding_adapter.go | 13 +- .../common/syncer/resource/theme_adapter.go | 10 +- internal/common/syncer/snapshot/snapshot.go | 1 - internal/common/syncer/snapshot/store.go | 31 ++- internal/common/syncer/snapshot/store_test.go | 35 ++++ 8 files changed, 273 insertions(+), 20 deletions(-) create mode 100644 internal/common/syncer/resource/adapter_apply_test.go diff --git a/internal/common/syncer/resource/adapter_apply_test.go b/internal/common/syncer/resource/adapter_apply_test.go new file mode 100644 index 00000000..8c9cd775 --- /dev/null +++ b/internal/common/syncer/resource/adapter_apply_test.go @@ -0,0 +1,181 @@ +package resource + +import ( + "context" + "fmt" + "testing" + "voidraft/internal/common/syncer/snapshot" + "voidraft/internal/models/ent" + "voidraft/internal/models/ent/enttest" + extensionent "voidraft/internal/models/ent/extension" + keybindingent "voidraft/internal/models/ent/keybinding" + themeent "voidraft/internal/models/ent/theme" + + _ "github.com/mattn/go-sqlite3" +) + +func TestExtensionAdapterApplyMatchesByName(t *testing.T) { + ctx := context.Background() + client := openSyncResourceTestClient(t) + defer client.Close() + + existing, err := client.Extension.Create(). + SetUUID("local-extension-uuid"). + SetName("inlineImage"). + SetEnabled(true). + SetConfig(map[string]interface{}{"mode": "old"}). + SetCreatedAt("2026-04-12T10:00:00Z"). + SetUpdatedAt("2026-04-12T10:00:00Z"). + Save(ctx) + if err != nil { + t.Fatalf("create local extension: %v", err) + } + + record, err := snapshot.NewRecord("extensions", extensionSyncID("inlineImage"), map[string]interface{}{ + "created_at": "2026-04-12T10:00:00Z", + "updated_at": "2026-04-12T10:05:00Z", + "name": "inlineImage", + "enabled": false, + "config": map[string]interface{}{"mode": "new"}, + }, nil) + if err != nil { + t.Fatalf("build extension record: %v", err) + } + + adapter := NewExtensionAdapter(client) + if err := adapter.Apply(ctx, []snapshot.Record{record}); err != nil { + t.Fatalf("apply extension record: %v", err) + } + + items, err := client.Extension.Query().Where(extensionent.NameEQ("inlineImage")).All(importContext(ctx)) + if err != nil { + t.Fatalf("query extensions: %v", err) + } + if len(items) != 1 { + t.Fatalf("expected 1 extension row, got %d", len(items)) + } + if items[0].UUID != existing.UUID { + t.Fatalf("expected extension UUID to stay %q, got %q", existing.UUID, items[0].UUID) + } + if items[0].Enabled { + t.Fatalf("expected extension to be updated") + } +} + +func TestKeyBindingAdapterApplyMatchesByTypeAndName(t *testing.T) { + ctx := context.Background() + client := openSyncResourceTestClient(t) + defer client.Close() + + existing, err := client.KeyBinding.Create(). + SetUUID("local-keybinding-uuid"). + SetName("save"). + SetType("standard"). + SetKey("Mod-s"). + SetMacos("Cmd-s"). + SetWindows("Ctrl-s"). + SetLinux("Ctrl-s"). + SetExtension("core"). + SetEnabled(true). + SetPreventDefault(true). + SetScope("editor"). + SetCreatedAt("2026-04-12T10:00:00Z"). + SetUpdatedAt("2026-04-12T10:00:00Z"). + Save(ctx) + if err != nil { + t.Fatalf("create local keybinding: %v", err) + } + + record, err := snapshot.NewRecord("keybindings", keyBindingSyncID("standard", "save"), map[string]interface{}{ + "created_at": "2026-04-12T10:00:00Z", + "updated_at": "2026-04-12T10:05:00Z", + "name": "save", + "type": "standard", + "key": "Mod-Shift-s", + "macos": "Cmd-Shift-s", + "windows": "Ctrl-Shift-s", + "linux": "Ctrl-Shift-s", + "extension": "core", + "enabled": false, + "prevent_default": true, + "scope": "editor", + }, nil) + if err != nil { + t.Fatalf("build keybinding record: %v", err) + } + + adapter := NewKeyBindingAdapter(client) + if err := adapter.Apply(ctx, []snapshot.Record{record}); err != nil { + t.Fatalf("apply keybinding record: %v", err) + } + + items, err := client.KeyBinding.Query(). + Where(keybindingent.TypeEQ("standard"), keybindingent.NameEQ("save")). + All(importContext(ctx)) + if err != nil { + t.Fatalf("query keybindings: %v", err) + } + if len(items) != 1 { + t.Fatalf("expected 1 keybinding row, got %d", len(items)) + } + if items[0].UUID != existing.UUID { + t.Fatalf("expected keybinding UUID to stay %q, got %q", existing.UUID, items[0].UUID) + } + if items[0].Key != "Mod-Shift-s" { + t.Fatalf("expected keybinding key to be updated, got %q", items[0].Key) + } +} + +func TestThemeAdapterApplyMatchesByName(t *testing.T) { + ctx := context.Background() + client := openSyncResourceTestClient(t) + defer client.Close() + + existing, err := client.Theme.Create(). + SetUUID("local-theme-uuid"). + SetName("dark-plus"). + SetType(themeent.TypeDark). + SetColors(map[string]interface{}{"bg": "#000"}). + SetCreatedAt("2026-04-12T10:00:00Z"). + SetUpdatedAt("2026-04-12T10:00:00Z"). + Save(ctx) + if err != nil { + t.Fatalf("create local theme: %v", err) + } + + record, err := snapshot.NewRecord("themes", themeSyncID("dark-plus"), map[string]interface{}{ + "created_at": "2026-04-12T10:00:00Z", + "updated_at": "2026-04-12T10:05:00Z", + "name": "dark-plus", + "type": "dark", + "colors": map[string]interface{}{"bg": "#111"}, + }, nil) + if err != nil { + t.Fatalf("build theme record: %v", err) + } + + adapter := NewThemeAdapter(client) + if err := adapter.Apply(ctx, []snapshot.Record{record}); err != nil { + t.Fatalf("apply theme record: %v", err) + } + + items, err := client.Theme.Query().Where(themeent.NameEQ("dark-plus")).All(importContext(ctx)) + if err != nil { + t.Fatalf("query themes: %v", err) + } + if len(items) != 1 { + t.Fatalf("expected 1 theme row, got %d", len(items)) + } + if items[0].UUID != existing.UUID { + t.Fatalf("expected theme UUID to stay %q, got %q", existing.UUID, items[0].UUID) + } + if items[0].Colors["bg"] != "#111" { + t.Fatalf("expected theme colors to be updated, got %#v", items[0].Colors) + } +} + +func openSyncResourceTestClient(t *testing.T) *ent.Client { + t.Helper() + dsnName := fmt.Sprintf("file:%s?mode=memory&cache=shared&_fk=1", t.Name()) + return enttest.Open(t, "sqlite3", dsnName) +} diff --git a/internal/common/syncer/resource/extension_adapter.go b/internal/common/syncer/resource/extension_adapter.go index 7610feb7..e48c97c9 100644 --- a/internal/common/syncer/resource/extension_adapter.go +++ b/internal/common/syncer/resource/extension_adapter.go @@ -33,7 +33,6 @@ func (a *ExtensionAdapter) Export(ctx context.Context) ([]snapshot.Record, error records := make([]snapshot.Record, 0, len(extensions)) for _, item := range extensions { values := map[string]interface{}{ - extension.FieldUUID: item.UUID, extension.FieldCreatedAt: item.CreatedAt, extension.FieldUpdatedAt: item.UpdatedAt, extension.FieldName: item.Name, @@ -44,9 +43,10 @@ func (a *ExtensionAdapter) Export(ctx context.Context) ([]snapshot.Record, error values[extension.FieldDeletedAt] = *item.DeletedAt } - record, err := snapshot.NewRecord(a.Kind(), item.UUID, values, nil) + recordID := extensionSyncID(item.Name) + record, err := snapshot.NewRecord(a.Kind(), recordID, values, nil) if err != nil { - return nil, fmt.Errorf("build extension record %s: %w", item.UUID, err) + return nil, fmt.Errorf("build extension record %s: %w", recordID, err) } records = append(records, record) } @@ -59,7 +59,8 @@ func (a *ExtensionAdapter) Apply(ctx context.Context, records []snapshot.Record) applyCtx := importContext(ctx) for _, record := range records { - found, err := a.client.Extension.Query().Where(extension.UUIDEQ(record.ID)).First(applyCtx) + name := stringValue(record, extension.FieldName) + found, err := a.client.Extension.Query().Where(extension.NameEQ(name)).First(applyCtx) switch { case ent.IsNotFound(err): if err := a.create(applyCtx, record); err != nil { @@ -82,7 +83,6 @@ func (a *ExtensionAdapter) Apply(ctx context.Context, records []snapshot.Record) // create 创建新的扩展记录。 func (a *ExtensionAdapter) create(ctx context.Context, record snapshot.Record) error { builder := a.client.Extension.Create(). - SetUUID(record.ID). SetName(stringValue(record, extension.FieldName)). SetEnabled(boolValue(record, extension.FieldEnabled)). SetConfig(mapValue(record, extension.FieldConfig)). diff --git a/internal/common/syncer/resource/helpers.go b/internal/common/syncer/resource/helpers.go index 0f947cdf..2e750324 100644 --- a/internal/common/syncer/resource/helpers.go +++ b/internal/common/syncer/resource/helpers.go @@ -96,3 +96,15 @@ func recordApplyTime(record snapshot.Record) time.Time { } return record.UpdatedAt } + +func extensionSyncID(name string) string { + return name +} + +func keyBindingSyncID(bindingType string, name string) string { + return bindingType + ":" + name +} + +func themeSyncID(name string) string { + return name +} diff --git a/internal/common/syncer/resource/keybinding_adapter.go b/internal/common/syncer/resource/keybinding_adapter.go index bdc628f6..8ef81b14 100644 --- a/internal/common/syncer/resource/keybinding_adapter.go +++ b/internal/common/syncer/resource/keybinding_adapter.go @@ -33,7 +33,6 @@ func (a *KeyBindingAdapter) Export(ctx context.Context) ([]snapshot.Record, erro records := make([]snapshot.Record, 0, len(keyBindings)) for _, item := range keyBindings { values := map[string]interface{}{ - keybinding.FieldUUID: item.UUID, keybinding.FieldCreatedAt: item.CreatedAt, keybinding.FieldUpdatedAt: item.UpdatedAt, keybinding.FieldName: item.Name, @@ -51,9 +50,10 @@ func (a *KeyBindingAdapter) Export(ctx context.Context) ([]snapshot.Record, erro values[keybinding.FieldDeletedAt] = *item.DeletedAt } - record, err := snapshot.NewRecord(a.Kind(), item.UUID, values, nil) + recordID := keyBindingSyncID(item.Type, item.Name) + record, err := snapshot.NewRecord(a.Kind(), recordID, values, nil) if err != nil { - return nil, fmt.Errorf("build keybinding record %s: %w", item.UUID, err) + return nil, fmt.Errorf("build keybinding record %s: %w", recordID, err) } records = append(records, record) } @@ -66,7 +66,11 @@ func (a *KeyBindingAdapter) Apply(ctx context.Context, records []snapshot.Record applyCtx := importContext(ctx) for _, record := range records { - found, err := a.client.KeyBinding.Query().Where(keybinding.UUIDEQ(record.ID)).First(applyCtx) + name := stringValue(record, keybinding.FieldName) + bindingType := stringValue(record, keybinding.FieldType) + found, err := a.client.KeyBinding.Query(). + Where(keybinding.TypeEQ(bindingType), keybinding.NameEQ(name)). + First(applyCtx) switch { case ent.IsNotFound(err): if err := a.create(applyCtx, record); err != nil { @@ -89,7 +93,6 @@ func (a *KeyBindingAdapter) Apply(ctx context.Context, records []snapshot.Record // create 创建新的快捷键记录。 func (a *KeyBindingAdapter) create(ctx context.Context, record snapshot.Record) error { builder := a.client.KeyBinding.Create(). - SetUUID(record.ID). SetName(stringValue(record, keybinding.FieldName)). SetType(stringValue(record, keybinding.FieldType)). SetKey(stringValue(record, keybinding.FieldKey)). diff --git a/internal/common/syncer/resource/theme_adapter.go b/internal/common/syncer/resource/theme_adapter.go index 5369e524..1c5b7e59 100644 --- a/internal/common/syncer/resource/theme_adapter.go +++ b/internal/common/syncer/resource/theme_adapter.go @@ -33,7 +33,6 @@ func (a *ThemeAdapter) Export(ctx context.Context) ([]snapshot.Record, error) { records := make([]snapshot.Record, 0, len(themes)) for _, item := range themes { values := map[string]interface{}{ - theme.FieldUUID: item.UUID, theme.FieldCreatedAt: item.CreatedAt, theme.FieldUpdatedAt: item.UpdatedAt, theme.FieldName: item.Name, @@ -44,9 +43,10 @@ func (a *ThemeAdapter) Export(ctx context.Context) ([]snapshot.Record, error) { values[theme.FieldDeletedAt] = *item.DeletedAt } - record, err := snapshot.NewRecord(a.Kind(), item.UUID, values, nil) + recordID := themeSyncID(item.Name) + record, err := snapshot.NewRecord(a.Kind(), recordID, values, nil) if err != nil { - return nil, fmt.Errorf("build theme record %s: %w", item.UUID, err) + return nil, fmt.Errorf("build theme record %s: %w", recordID, err) } records = append(records, record) } @@ -59,7 +59,8 @@ func (a *ThemeAdapter) Apply(ctx context.Context, records []snapshot.Record) err applyCtx := importContext(ctx) for _, record := range records { - found, err := a.client.Theme.Query().Where(theme.UUIDEQ(record.ID)).First(applyCtx) + name := stringValue(record, theme.FieldName) + found, err := a.client.Theme.Query().Where(theme.NameEQ(name)).First(applyCtx) switch { case ent.IsNotFound(err): if err := a.create(applyCtx, record); err != nil { @@ -82,7 +83,6 @@ func (a *ThemeAdapter) Apply(ctx context.Context, records []snapshot.Record) err // create 创建新的主题记录。 func (a *ThemeAdapter) create(ctx context.Context, record snapshot.Record) error { builder := a.client.Theme.Create(). - SetUUID(record.ID). SetName(stringValue(record, theme.FieldName)). SetType(theme.Type(stringValue(record, theme.FieldType))). SetColors(mapValue(record, theme.FieldColors)). diff --git a/internal/common/syncer/snapshot/snapshot.go b/internal/common/syncer/snapshot/snapshot.go index 8a67224f..8a102e15 100644 --- a/internal/common/syncer/snapshot/snapshot.go +++ b/internal/common/syncer/snapshot/snapshot.go @@ -93,7 +93,6 @@ func newRecord(kind string, id string, values map[string]interface{}, blobs map[ if id == "" { return Record{}, errors.New("record id is required") } - normalizedValues["uuid"] = id updatedAt, err := parseRequiredTime(normalizedValues["updated_at"]) if err != nil { diff --git a/internal/common/syncer/snapshot/store.go b/internal/common/syncer/snapshot/store.go index 1279a69e..108c9503 100644 --- a/internal/common/syncer/snapshot/store.go +++ b/internal/common/syncer/snapshot/store.go @@ -2,8 +2,10 @@ package snapshot import ( "context" + "encoding/base64" "encoding/json" "io" + "net/url" "os" "path/filepath" "sort" @@ -12,6 +14,7 @@ import ( ) const manifestFileName = "manifest.json" +const encodedRecordIDPrefix = "__id__" // Store 描述快照落盘与读取能力。 type Store interface { @@ -106,14 +109,15 @@ func (s *FileStore) Write(ctx context.Context, root string, snap *Snapshot) erro }) for _, record := range records { + recordFileName := encodeRecordID(record.ID) if !record.HasBlobs() { - if err := writeJSON(filepath.Join(kindDir, record.ID+".json"), record.Values); err != nil { + if err := writeJSON(filepath.Join(kindDir, recordFileName+".json"), record.Values); err != nil { return err } continue } - recordDir := filepath.Join(kindDir, record.ID) + recordDir := filepath.Join(kindDir, recordFileName) if err := os.MkdirAll(recordDir, 0755); err != nil { return err } @@ -217,7 +221,7 @@ func (s *FileStore) readFlatRecord(path string, kind string) (Record, error) { } id := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) - return NewRecord(kind, id, values, nil) + return NewRecord(kind, decodeRecordID(id), values, nil) } // readBlobRecord 读取目录型记录。 @@ -240,7 +244,26 @@ func (s *FileStore) readBlobRecord(root string, kind string) (Record, error) { blobFiles[entry.Name()] = filepath.Join(root, entry.Name()) } - return NewRecordWithBlobFiles(kind, filepath.Base(root), values, blobFiles) + return NewRecordWithBlobFiles(kind, decodeRecordID(filepath.Base(root)), values, blobFiles) +} + +func encodeRecordID(id string) string { + return encodedRecordIDPrefix + base64.RawURLEncoding.EncodeToString([]byte(id)) +} + +func decodeRecordID(name string) string { + if strings.HasPrefix(name, encodedRecordIDPrefix) { + decoded, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(name, encodedRecordIDPrefix)) + if err == nil { + return string(decoded) + } + } + + id, err := url.PathUnescape(name) + if err != nil { + return name + } + return id } // readValues 读取 JSON 字段集合。 diff --git a/internal/common/syncer/snapshot/store_test.go b/internal/common/syncer/snapshot/store_test.go index d6085dda..59760a89 100644 --- a/internal/common/syncer/snapshot/store_test.go +++ b/internal/common/syncer/snapshot/store_test.go @@ -57,3 +57,38 @@ func TestFileStoreReadWrite(t *testing.T) { t.Fatalf("expected digests to match, got %s != %s", originalDigest, loadedDigest) } } + +func TestFileStoreReadWriteEscapedRecordID(t *testing.T) { + root := t.TempDir() + recordID := "standard:editor/save" + + record, err := NewRecord("keybindings", recordID, map[string]interface{}{ + "updated_at": time.Date(2026, 4, 12, 10, 0, 0, 0, time.UTC).Format(time.RFC3339), + "name": "editor/save", + "type": "standard", + }, nil) + if err != nil { + t.Fatalf("build keybinding record: %v", err) + } + + snap := New() + snap.Resources["keybindings"] = []Record{record} + + store := NewFileStore() + if err := store.Write(context.Background(), root, snap); err != nil { + t.Fatalf("write snapshot: %v", err) + } + + loaded, err := store.Read(context.Background(), root) + if err != nil { + t.Fatalf("read snapshot: %v", err) + } + + records := loaded.Resources["keybindings"] + if len(records) != 1 { + t.Fatalf("expected 1 keybinding record, got %d", len(records)) + } + if records[0].ID != recordID { + t.Fatalf("expected record id %q, got %q", recordID, records[0].ID) + } +} From a678d538ee3765386ebaec8efaa6f3a733c29264 Mon Sep 17 00:00:00 2001 From: landaiqing Date: Sun, 12 Apr 2026 22:41:31 +0800 Subject: [PATCH 5/5] :pencil: Update README --- README.md | 18 +++++++----------- README_ZH.md | 19 +++++++------------ version.txt | 2 +- 3 files changed, 15 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 74a2068c..802719e0 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ voidraft is a modern developer-focused text editor that allows you to record, or - Block editing mode - Split content into independent code blocks, each with different language settings - Multi-window support - edit multiple documents at the same time - Support for custom themes - Custom editor themes +- . . . ### Modern Interface @@ -36,6 +37,7 @@ voidraft is a modern developer-focused text editor that allows you to record, or - Hyperlink support - Checkbox support - Minimap + - . . . ## Quick Start @@ -113,17 +115,11 @@ voidraft/ ### Platform Extension Plans -| Platform | Status | Expected Time | -|----------|--------|---------------| -| macOS | Planned | Future versions | -| Linux | Planned | Future versions | - -### Planned Features -- ✅ Custom themes - Customize editor themes -- ✅ Multi-window support - Support editing multiple documents simultaneously -- ✅ Data synchronization - Cloud backup for documents -- [ ] Enhanced clipboard - Monitor and manage clipboard history -- [ ] Extension system - Support for custom plugins +| Platform | Status | Expected Time | +|----------|--------|-----------------| +| Windows | In progress | LTS | +| macOS | Planned | Future versions | +| Linux | Planned | Future versions | ## Acknowledgments diff --git a/README_ZH.md b/README_ZH.md index a8af9a9f..7b14640c 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -16,6 +16,7 @@ voidraft 是一个现代化的开发者专用文本编辑器,让你能够随 - 块状编辑模式 - 将内容分割为独立的代码块,每个块可设置不同语言 - 支持多窗口 - 同时编辑多个文档 - 支持自定义主题 - 自定义编辑器主题 +- . . . ### 现代化界面 @@ -36,6 +37,7 @@ voidraft 是一个现代化的开发者专用文本编辑器,让你能够随 - 超链接支持 - 复选框支持 - 小地图 + - . . . ## 快速开始 @@ -114,18 +116,11 @@ voidraft/ ### 平台扩展计划 -| 平台 | 状态 | 预期时间 | -|------|------|----------| -| macOS | 计划中 | 后续版本 | -| Linux | 计划中 | 后续版本 | - -### 计划添加的功能 -- ✅ 自定义主题 - 自定义编辑器主题 -- ✅ 多窗口支持 - 支持同时编辑多个文档 -- ✅ 数据同步 - 文档云端备份 -- [ ] 剪切板增强 - 监听和管理剪切板历史 -- [ ] 扩展系统 - 支持自定义插件 - +| 平台 | 状态 | 预期时间 | +|---------|-----|------| +| Windows | 进行中 | 长久支持 | +| macOS | 计划中 | 后续版本 | +| Linux | 计划中 | 后续版本 | ## 致谢 diff --git a/version.txt b/version.txt index 3fc90d5b..e7a93898 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -VERSION=1.6.0 +VERSION=1.6.1