From d5b1050a8678f3415670f3a520d1df59514b39aa Mon Sep 17 00:00:00 2001 From: KiriAky 107 Date: Sat, 5 Sep 2026 18:42:14 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(frontend):=20=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E4=B8=BB=E9=A2=98=E5=AF=BC=E5=85=A5=E4=B8=8E=E6=89=8B=E5=B8=90?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E5=8C=BA=E5=B9=B6=E4=BF=AE=E5=A4=8D=20Mermai?= =?UTF-8?q?d=20=E9=A2=84=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 39 ++++ frontend/package.json | 1 + frontend/pnpm-lock.yaml | 19 +- .../src/assets/themes/paper-moments.theme | 221 ++++++++++++++++++ .../features/editor/VisualMarkdownEditor.vue | 16 +- .../features/editor/codeBlockLabels.spec.ts | 18 ++ .../src/features/editor/codeBlockLabels.ts | 11 + .../features/editor/mermaidPreview.spec.ts | 37 +++ .../src/features/editor/mermaidPreview.ts | 39 ++++ .../features/themes/CommunityThemePreview.vue | 7 +- .../src/features/themes/ThemesView.spec.ts | 54 ++++- frontend/src/features/themes/ThemesView.vue | 117 +++++++--- frontend/src/services/themeImport.spec.ts | 34 +++ frontend/src/services/themePackageService.ts | 138 +++++++---- 14 files changed, 655 insertions(+), 96 deletions(-) create mode 100644 frontend/src/assets/themes/paper-moments.theme create mode 100644 frontend/src/features/editor/codeBlockLabels.spec.ts create mode 100644 frontend/src/features/editor/codeBlockLabels.ts create mode 100644 frontend/src/features/editor/mermaidPreview.spec.ts create mode 100644 frontend/src/features/editor/mermaidPreview.ts create mode 100644 frontend/src/services/themeImport.spec.ts diff --git a/README.md b/README.md index 3e555c2..641878a 100644 --- a/README.md +++ b/README.md @@ -134,3 +134,42 @@ pnpm build - 前端不直接访问 SQLite 或厂商模型协议;持久数据通过 FastAPI 服务读写。 - 接口或数据结构变化时,同一提交同步更新前后端类型、契约和开发说明。 - 当前行为以代码、测试和运行中的 `/openapi.json` 为准;规划能力必须在文档中明确标注。 + +## 主题包与仓库发布(临时规范) + +主题页支持本地文件及 HTTP(S) 文件直链导入。两种入口均先解析、校验并展示清单和 CSS,用户点击安装后才写入本地存储。安装不会自动启用主题。 + +### 单文件 + +使用 UTF-8 编码,扩展名 `.theme`、`.yaml` 或 `.yml`。内容为 YAML 清单、一行 `---`、完整 CSS。可参考 `frontend/src/assets/themes/paper-moments.theme`。 + +### ZIP + +一个 ZIP 只包含一个主题。清单命名为 `theme.yaml`、`theme.yml`、`manifest.yaml` 或 `manifest.yml`,可以放在顶层,也可以放在仓库压缩包的子目录中。 + +```text +my-theme/ + theme.yaml + styles/ + theme.css +``` + +```yaml +theme_id: my-theme +name: My Theme +version: 1.0.0 +author: your-name +min_app_version: 0.2.0 +is_dark: false +css_entry: styles/theme.css +``` + +`css_entry` 相对于清单目录解析,不允许绝对路径、反斜杠及 `..`。CSS 应以 `[data-theme="my-theme"]` 限定主题样式。也支持仅包含一个 `.theme` 文件的 ZIP。 + +目前安装持久化的是清单和 CSS,不会托管 ZIP 内的图片、字体等资源;需要这些资源时请将它们内嵌为 CSS data URL。禁止 `@import` 和脚本表达式。 + +### URL 与社区仓库 + +发布主题仓库时可提供原始 `.theme` 文件链接或 ZIP 发布附件直链,不要使用仓库 HTML 浏览页面地址。下载请求不携带 Cookie 或 HTTP 登录信息,服务器需允许应用来源的 CORS 请求;暂不支持私有仓库认证。 + +下载和本地文件限制为 5 MB;ZIP 解压总大小限制为 10 MB,最多 100 个条目。URL 下载超时为 30 秒。取消导入会取消下载,过期请求不会替换当前待安装主题。更新时递增清单版本号,并保持 `theme_id` 稳定。 diff --git a/frontend/package.json b/frontend/package.json index 76a1adf..ffbaf5a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -35,6 +35,7 @@ "@vueuse/core": "^14.0.0", "codemirror": "^6.0.0", "dompurify": "^3.4.14", + "fflate": "^0.8.3", "marked": "^15.0.0", "mermaid": "^11.17.2", "pinia": "^4.0.0", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 9e942b1..c648d1c 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -80,6 +80,9 @@ importers: dompurify: specifier: ^3.4.14 version: 3.4.14 + fflate: + specifier: ^0.8.3 + version: 0.8.3 marked: specifier: ^15.0.0 version: 15.0.12 @@ -954,11 +957,6 @@ packages: '@volar/typescript@2.4.15': resolution: {integrity: sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==} - peerDependencies: - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true '@vue-macros/common@3.1.4': resolution: {integrity: sha512-/5Fv+6DgIcM9ajY05ZmKBv+LMX1M9A0X+IUwDRVdt67ciw8OV9bvG2r34p3RiEadlsQybjhKPRKNXDC8Bp23cw==} @@ -1392,6 +1390,9 @@ packages: picomatch: optional: true + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -3310,13 +3311,11 @@ snapshots: '@volar/source-map@2.4.15': {} - '@volar/typescript@2.4.15(typescript@5.9.3)': + '@volar/typescript@2.4.15': dependencies: '@volar/language-core': 2.4.15 path-browserify: 1.0.1 vscode-uri: 3.2.0 - optionalDependencies: - typescript: 5.9.3 '@vue-macros/common@3.1.4(vue@3.5.42(typescript@5.9.3))': dependencies: @@ -3805,6 +3804,8 @@ snapshots: optionalDependencies: picomatch: 4.0.7 + fflate@0.8.3: {} + fsevents@2.3.3: optional: true @@ -4780,7 +4781,7 @@ snapshots: vue-tsc@2.2.12(typescript@5.9.3): dependencies: - '@volar/typescript': 2.4.15(typescript@5.9.3) + '@volar/typescript': 2.4.15 '@vue/language-core': 2.2.12(typescript@5.9.3) typescript: 5.9.3 diff --git a/frontend/src/assets/themes/paper-moments.theme b/frontend/src/assets/themes/paper-moments.theme new file mode 100644 index 0000000..109426b --- /dev/null +++ b/frontend/src/assets/themes/paper-moments.theme @@ -0,0 +1,221 @@ +theme_id: paper-moments +name: 纸间时光 · Paper Moments +version: 1.2.0 +author: NotesAgent +description: 奶油纸张、手帐虚线与粉蓝胶带,把每天的灵感好好收藏。 +min_app_version: 0.2.0 +is_dark: false +css_entry: theme.css +license: MIT +--- +[data-theme="paper-moments"] { + color-scheme: light; + --color-background-primary: #faf7ee; + --color-background-secondary: #f3eee3; + --color-background-tertiary: #ece5d7; + --color-background-hover: #f1e5da; + --color-background-active: #ecdbd2; + --color-background-overlay: rgba(65, 55, 45, .35); + --color-surface-primary: #fffdf5; + --color-surface-secondary: #f7f1e5; + --color-surface-elevated: #fffdf7; + --color-text-primary: #493f35; + --color-text-secondary: #6e6053; + --color-text-tertiary: #7d6b5e; + --color-text-inverse: #fffdf5; + --color-text-link: #875343; + --color-text-disabled: #9c9081; + --color-accent-primary: #875343; + --color-accent-primary-hover: #704334; + --color-accent-primary-active: #5e382b; + --color-accent-secondary: #a77a67; + --color-accent-soft: #f3e1d8; + --color-accent-soft-hover: #ecd3c7; + --color-border-default: #b5a693; + --color-border-subtle: #ded5c5; + --color-border-focus: #875343; + --color-border-disabled: #e2dacc; + --color-success: #526849; + --color-success-soft: #e5ecd9; + --color-warning: #806323; + --color-warning-soft: #faf0cb; + --color-error: #a0423c; + --color-error-soft: #f8e2dc; + --color-info: #456671; + --color-info-soft: #e1eef0; + --color-markdown-grid: #ded5c5; + --color-markdown-marker: #a77a67; + --color-markdown-table-header: #eee7d7; + --shadow-sm: 2px 3px 0 #e5ded0; + --shadow-md: 3px 4px 0 #dae5df, 6px 7px 0 #f0d8cf; + --shadow-lg: 4px 5px 0 #dae5df, 8px 9px 0 #f0d8cf; + --shadow-xl: 5px 6px 0 #dae5df, 10px 11px 0 #f0d8cf, 0 18px 42px #493f3520; +} + +[data-theme="paper-moments"] body, +[data-theme="paper-moments"] .feature-page, +[data-theme="paper-moments"] .main-content { + background-color: var(--color-background-primary); + background-image: radial-gradient(#b5a69350 .8px, transparent .8px); + background-size: 20px 20px; +} + +[data-theme="paper-moments"] .feature-header { + flex-wrap: wrap; + position: relative; + padding: 24px; + margin-top: 12px; + border: 1px solid #685949; + outline: 1px dashed #b5a693; + outline-offset: -8px; + border-radius: 12px 5px 12px 5px; + background: #fffdf5; + box-shadow: var(--shadow-md); +} + +[data-theme="paper-moments"] .feature-header::before, +[data-theme="paper-moments"] .editor-preview::before { + content: ''; + position: absolute; + top: -10px; + left: 42%; + width: 86px; + height: 22px; + background: repeating-linear-gradient(45deg, #c5dfe0b0 0 8px, #daeceba0 8px 16px); + transform: rotate(-3deg); + pointer-events: none; +} + +[data-theme="paper-moments"] .feature-header h1, +[data-theme="paper-moments"] .panel-title, +[data-theme="paper-moments"] .preview-heading h3 { + color: #875343; + font-family: Georgia, 'Noto Serif SC', 'Songti SC', SimSun, serif; + letter-spacing: .04em; +} + +[data-theme="paper-moments"] .panel, +[data-theme="paper-moments"] .item-card { + border-color: #b5a693; + border-radius: 8px; + box-shadow: var(--shadow-sm); +} + +[data-theme="paper-moments"] .theme-card:nth-child(3n + 1) { background: #f8e9e3; } +[data-theme="paper-moments"] .theme-card:nth-child(3n + 2) { background: #e8f0f0; } +[data-theme="paper-moments"] .theme-card:nth-child(3n) { background: #fbf3d8; } + +[data-theme="paper-moments"] .editor-preview { + position: relative; + border: 1px solid #685949; + border-radius: 4px 14px 4px 10px; + background-color: #fffef8; + background-image: linear-gradient(90deg, transparent 20px, #e9cfc780 20px 22px, transparent 22px), repeating-linear-gradient(transparent 0 31px, #b6c7bd55 31px 32px); + box-shadow: 4px 5px 0 #e3e9d7; +} + +[data-theme="paper-moments"] .editor-preview::before { + background: repeating-linear-gradient(45deg, #e7bcb3b0 0 8px, #f2d4cba0 8px 16px); +} + +[data-theme="paper-moments"] .modal { border-color: #685949; border-radius: 12px; } +[data-theme="paper-moments"] .upload-area { background: #fbf6e7; } +[data-theme="paper-moments"] .button-secondary { background: #fff9e5; } + +[data-theme="paper-moments"] .workspace-view, +[data-theme="paper-moments"] .visual-editor { + background: radial-gradient(#b5a69355 .8px, transparent .8px) 0 0 / 20px 20px #f3eee3; +} +[data-theme="paper-moments"] .secondary-sidebar { + background: #fff9e9; + border-right: 1px dashed #b5a693; +} +[data-theme="paper-moments"] .primary-sidebar { background: #f1e9dc; } +[data-theme="paper-moments"] .file-tree-panel .toolbar, +[data-theme="paper-moments"] .sidebar-header { background: #e5eeee; border-bottom: 1px dashed #b5a693; } +[data-theme="paper-moments"] .editor-header { background: #f8e9e3; border-bottom: 1px solid #b5a693; } +[data-theme="paper-moments"] .markdown-toolbar { background: #fff9e9; border-bottom: 1px dashed #b5a693; } +[data-theme="paper-moments"] .milkdown-host { padding: 30px 24px 40px; } +[data-theme="paper-moments"] .milkdown-host .milkdown { background: transparent; } +[data-theme="paper-moments"] .milkdown-host .ProseMirror { + width: 90%; + max-width: none; + position: relative; + min-height: calc(100vh - 220px); + padding: 44px 40px 60px 52px; + border: 1px solid #685949; + border-radius: 8px 16px 8px 8px; + outline: 1px dashed #c5b9a7; + outline-offset: -10px; + background: linear-gradient(90deg, transparent 32px, #e9cfc7 32px 34px, transparent 34px), #fffef8; + box-shadow: 6px 6px 0 #d8e6e2, 12px 12px 0 #f0d8cf; +} +[data-theme="paper-moments"] .milkdown-host .ProseMirror::before { + content: ''; + position: absolute; + top: -11px; + left: calc(50% - 48px); + width: 96px; + height: 24px; + background: repeating-linear-gradient(45deg, #e7bcb3c0 0 8px, #f2d4cbc0 8px 16px); + transform: rotate(-3deg); + pointer-events: none; +} +[data-theme="paper-moments"] .milkdown-host .ProseMirror > p { + background-image: repeating-linear-gradient(transparent 0 calc(1lh - 1px), #b6c7bd55 calc(1lh - 1px) 1lh); +} +[data-theme="paper-moments"] .milkdown-host .ProseMirror > :is(h1, h2, h3) { color: #875343; } +[data-theme="paper-moments"] .editor-pane.source { margin: 20px; width: calc(100% - 40px); border: 1px solid #b5a693; border-radius: 8px; background: #fffef8; box-shadow: var(--shadow-md); } +@media (max-width: 720px) { + [data-theme="paper-moments"] .milkdown-host { padding: 20px 12px 28px; } + [data-theme="paper-moments"] .milkdown-host .ProseMirror { width: 100%; padding: 30px 18px 40px 38px; } +} + +/* Warm neutral surfaces preserve the contrast of the selected Shiki palette. */ +[data-theme="paper-moments"][data-code-theme="github-light"] { + --color-code-background: #f1ecdf; + --color-code-text: #302b25; + --color-code-muted: #6d6256; + --color-code-border: #b1a18b; +} +[data-theme="paper-moments"][data-code-theme="github-dark"] { + --color-code-background: #282723; + --color-code-text: #f1e9da; + --color-code-muted: #bdb19f; + --color-code-border: #786b59; +} +[data-theme="paper-moments"] .milkdown-host .milkdown-code-block { + position: relative; + padding-top: 34px; + padding-bottom: 30px; + border-color: var(--color-code-border); + box-shadow: 3px 4px 0 #d8cebd; +} +[data-theme="paper-moments"] .milkdown-code-block::before { + content: ''; + position: absolute; + top: 15px; + left: 18px; + width: 10px; + height: 10px; + border-radius: 50%; + background: #c77768; + box-shadow: 18px 0 0 #c9a65d, 36px 0 0 #819b75; + pointer-events: none; +} +[data-theme="paper-moments"] .milkdown-code-block::after { + content: attr(data-language-label); + position: absolute; + right: 18px; + bottom: 9px; + max-width: calc(100% - 36px); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--color-code-muted); + font: 600 12px/1.4 var(--font-ui-mono); + pointer-events: none; +} +[data-theme="paper-moments"] .milkdown-code-block .tools { margin-left: 72px; } +[data-theme="paper-moments"] .milkdown-code-block .cm-activeLine, +[data-theme="paper-moments"] .milkdown-code-block .cm-activeLineGutter { background: color-mix(in srgb, var(--color-code-text) 7%, transparent); } diff --git a/frontend/src/features/editor/VisualMarkdownEditor.vue b/frontend/src/features/editor/VisualMarkdownEditor.vue index 416f314..4d50ebb 100644 --- a/frontend/src/features/editor/VisualMarkdownEditor.vue +++ b/frontend/src/features/editor/VisualMarkdownEditor.vue @@ -9,6 +9,8 @@ import { indentWithTab } from '@codemirror/commands' import { shikiEditorTheme, shikiLanguages, renderCodeLanguage } from './shikiCodeMirror' import './language-icons.css' import { installLanguagePickerPopover } from './languagePickerPopover' +import { installCodeBlockLabels } from './codeBlockLabels' +import { createMermaidPreview } from './mermaidPreview' import { createCodeBlockCommand, toggleEmphasisCommand, @@ -41,6 +43,7 @@ const loading = ref(true) const fontSizeInput = ref(16) let crepe: Crepe | null = null let disposeLanguagePicker: (() => void) | undefined +let disposeCodeLabels: (() => void) | undefined function applyProofingPreferences() { const editable = editorRoot.value?.querySelector('.ProseMirror') @@ -125,7 +128,9 @@ onMounted(async () => { featureConfigs: { [Crepe.Feature.Placeholder]: { text: t('开始记录你的想法…', 'Start writing your thoughts…') }, [Crepe.Feature.CodeMirror]: { - previewOnlyByDefault: false, + previewOnlyByDefault: true, + previewToggleText: previewOnly => previewOnly ? t('编辑', 'Edit') : t('预览', 'Preview'), + previewLabel: t('图表预览', 'Preview'), searchPlaceholder: t('搜索语言', 'Search languages'), noResultText: t('没有匹配的语言', 'No matching language'), copyText: t('复制', 'Copy'), @@ -182,6 +187,9 @@ onMounted(async () => { ...config, languages: shikiLanguages(themeStore.resolvedCodeBlockTheme), renderLanguage: renderCodeLanguage, + renderPreview: (language, content, applyPreview) => language.trim().toLowerCase() === 'mermaid' + ? createMermaidPreview(content, themeStore.isDark, applyPreview) + : config.renderPreview(language, content, applyPreview), extensions: [basicSetup, keymap.of([indentWithTab]), shikiEditorTheme(themeStore.resolvedCodeBlockTheme)], }))) crepe.editor.use(fontSizeMarkdownPlugin) @@ -195,13 +203,14 @@ onMounted(async () => { }) await crepe.create() if (editorRoot.value) disposeLanguagePicker = installLanguagePickerPopover(editorRoot.value) + if (editorRoot.value) disposeCodeLabels = installCodeBlockLabels(editorRoot.value) applyProofingPreferences() loading.value = false }) watch([() => settingsStore.spellCheck, () => settingsStore.language], applyProofingPreferences) -onBeforeUnmount(() => { disposeLanguagePicker?.(); void crepe?.destroy() }) +onBeforeUnmount(() => { disposeCodeLabels?.(); disposeLanguagePicker?.(); void crepe?.destroy() }) defineExpose({ getEditor: () => crepe?.editor }) @@ -273,6 +282,9 @@ defineExpose({ getEditor: () => crepe?.editor }) .toolbar-divider { width: 1px; height: 20px; margin: 0 var(--space-xs); background: var(--color-border-default); } .milkdown-host { flex: 1; min-height: 0; overflow: auto; color: var(--color-text-primary); } .milkdown-host.loading { visibility: hidden; } +.milkdown-host :deep(.editor-mermaid-preview) { padding: 20px; overflow: auto; background: var(--color-surface-primary); color: var(--color-text-primary); } +.milkdown-host :deep(.editor-mermaid-preview svg) { display: block; max-width: 100%; height: auto; margin: auto; } +.milkdown-host :deep(.editor-mermaid-preview.has-error) { color: var(--color-error); white-space: pre-wrap; } .editor-loading { padding: var(--space-xl); color: var(--color-text-tertiary); } .milkdown-host :deep(.milkdown) { min-height: 100%; diff --git a/frontend/src/features/editor/codeBlockLabels.spec.ts b/frontend/src/features/editor/codeBlockLabels.spec.ts new file mode 100644 index 0000000..982c5c7 --- /dev/null +++ b/frontend/src/features/editor/codeBlockLabels.spec.ts @@ -0,0 +1,18 @@ +// @vitest-environment happy-dom +import { expect, it } from 'vitest' +import { installCodeBlockLabels } from './codeBlockLabels' + +it('keeps footer labels in sync when the language changes and stops after disposal', async () => { + const root = document.createElement('div') + root.innerHTML = '
' + const dispose = installCodeBlockLabels(root) + const block = root.firstElementChild as HTMLElement + expect(block.dataset.languageLabel).toBe('Python') + block.querySelector('button')!.textContent = 'TypeScript' + await new Promise(resolve => setTimeout(resolve, 0)) + expect(block.dataset.languageLabel).toBe('TypeScript') + dispose() + block.querySelector('button')!.textContent = 'Rust' + await new Promise(resolve => setTimeout(resolve, 0)) + expect(block.dataset.languageLabel).toBe('TypeScript') +}) diff --git a/frontend/src/features/editor/codeBlockLabels.ts b/frontend/src/features/editor/codeBlockLabels.ts new file mode 100644 index 0000000..a1f10b5 --- /dev/null +++ b/frontend/src/features/editor/codeBlockLabels.ts @@ -0,0 +1,11 @@ +/** Mirror the live picker label for theme decorations without changing Markdown. */ +export function installCodeBlockLabels(root: HTMLElement): () => void { + const sync = () => root.querySelectorAll('.milkdown-code-block').forEach(block => { + const label = block.querySelector('.language-button')?.textContent?.trim() || 'Plain text' + if (block.dataset.languageLabel !== label) block.dataset.languageLabel = label + }) + const observer = new MutationObserver(sync) + observer.observe(root, { subtree: true, childList: true, characterData: true }) + sync() + return () => observer.disconnect() +} diff --git a/frontend/src/features/editor/mermaidPreview.spec.ts b/frontend/src/features/editor/mermaidPreview.spec.ts new file mode 100644 index 0000000..951c739 --- /dev/null +++ b/frontend/src/features/editor/mermaidPreview.spec.ts @@ -0,0 +1,37 @@ +// @vitest-environment happy-dom +import { expect, it, vi } from 'vitest' +import { flushPromises } from '@vue/test-utils' +import { renderMermaid } from '@/services/mermaidService' +import { createMermaidPreview } from './mermaidPreview' + +vi.mock('@/services/mermaidService', () => ({ renderMermaid: vi.fn() })) + +it('renders SVG with the requested theme and keeps async revisions isolated', async () => { + let finish!: (value: any) => void + vi.mocked(renderMermaid).mockImplementationOnce(() => new Promise(resolve => { finish = resolve })) + vi.mocked(renderMermaid).mockResolvedValueOnce({ svg: 'new', warnings: [], width: 10, height: 10 }) + const oldPublish = vi.fn() + const latestPublish = vi.fn() + const old = createMermaidPreview('graph TD; A-->B', false, oldPublish) + const latest = createMermaidPreview('graph TD; A-->C', true, latestPublish) + document.body.append(latest.cloneNode(true)) + await flushPromises() + finish({ svg: 'old', warnings: [] }) + await flushPromises() + expect(latest.querySelector('svg')?.textContent).toBe('new') + expect(old.querySelector('svg')?.textContent).toBe('old') + expect(oldPublish).not.toHaveBeenCalled() + expect(latestPublish).toHaveBeenCalledWith(latest) + expect(latestPublish.mock.calls[0]![0]).not.toBe(latest) + document.getElementById(latest.id)?.remove() + expect(renderMermaid).toHaveBeenLastCalledWith('graph TD; A-->C', { theme: 'dark' }) +}) + +it('shows syntax errors as text without executing markup', async () => { + vi.mocked(renderMermaid).mockResolvedValueOnce({ svg: '', warnings: [''], width: 0, height: 0 }) + const preview = createMermaidPreview('invalid', false, vi.fn()) + await flushPromises() + expect(preview.classList.contains('has-error')).toBe(true) + expect(preview.querySelector('img')).toBeNull() + expect(preview.textContent).toContain('点击编辑') +}) diff --git a/frontend/src/features/editor/mermaidPreview.ts b/frontend/src/features/editor/mermaidPreview.ts new file mode 100644 index 0000000..64c6e36 --- /dev/null +++ b/frontend/src/features/editor/mermaidPreview.ts @@ -0,0 +1,39 @@ +import { nextTick } from 'vue' +import { renderMermaid } from '@/services/mermaidService' +import { t } from '@/i18n' + +let previewId = 0 +export function createMermaidPreview(source: string, dark: boolean, applyPreview: (value: HTMLElement) => void): HTMLElement { + // Each revision owns its element, so a slow render cannot replace newer content. + const container = document.createElement('div') + container.className = 'editor-mermaid-preview' + container.id = `editor-mermaid-preview-${++previewId}` + container.setAttribute('aria-live', 'polite') + container.textContent = t('正在渲染图表…', 'Rendering diagram…') + const publish = async () => { + await nextTick() + // Milkdown sanitizes and copies this element. Publish only if its revision + // still exists; edits, language changes and unmounts remove the old marker. + const visible = document.getElementById(container.id) + if (visible) { + // PreviewPanel copies HTML instead of retaining the supplied element. + // Update the current copy through Milkdown's reactive callback. + applyPreview(container.cloneNode(true) as HTMLElement) + } + } + void renderMermaid(source, { theme: dark ? 'dark' : 'light' }).then(result => { + if (result.warnings.length) { + container.classList.add('has-error') + container.textContent = `${t('图表语法有误,可点击编辑修改:', 'Diagram syntax error. Choose Edit to fix:')} ${result.warnings.join('\n')}` + void publish() + return + } + // Mermaid runs in strict mode; Milkdown sanitizes the preview before insertion. + container.innerHTML = result.svg + void publish() + }).catch(() => { + container.textContent = t('图表渲染失败,请点击编辑检查源码。', 'Unable to render diagram. Choose Edit to inspect the source.') + void publish() + }) + return container +} diff --git a/frontend/src/features/themes/CommunityThemePreview.vue b/frontend/src/features/themes/CommunityThemePreview.vue index b1e5ecb..f643ecf 100644 --- a/frontend/src/features/themes/CommunityThemePreview.vue +++ b/frontend/src/features/themes/CommunityThemePreview.vue @@ -16,10 +16,15 @@ const previewDocument = computed(() => { style.textContent = `${tokensCss}\n${getCommunityThemePreviewCss(props.themeId)}\nbody { margin:0; padding:24px; background:var(--color-background-primary); color:var(--color-text-primary); font:16px/1.6 system-ui; } article { padding:20px; border:1px solid var(--color-border-default); border-radius:8px; background:var(--color-surface-primary); } p { color:var(--color-text-secondary); } button { padding:8px 16px; border:0; border-radius:6px; background:var(--color-accent-primary); color:white; }` doc.head.append(style) const article = doc.createElement('article') + article.className = 'panel' + const header = doc.createElement('header'); header.className = 'feature-header' const heading = doc.createElement('h1'); heading.textContent = theme.value?.name ?? props.themeId + header.append(heading) + const journal = doc.createElement('section'); journal.className = 'editor-preview'; journal.style.cssText = 'padding:24px;margin:28px 0;' const text = doc.createElement('p'); text.textContent = t('知识的价值不只在于保存,更在于被重新发现和使用。', 'Knowledge gains value when it can be rediscovered and used.') const button = doc.createElement('button'); button.textContent = t('示例按钮', 'Example button') - article.append(heading, text, button); doc.body.append(article) + journal.append(text) + article.append(header, journal, button); doc.body.append(article) return '' + doc.documentElement.outerHTML }) diff --git a/frontend/src/features/themes/ThemesView.spec.ts b/frontend/src/features/themes/ThemesView.spec.ts index e913295..9ad8e1b 100644 --- a/frontend/src/features/themes/ThemesView.spec.ts +++ b/frontend/src/features/themes/ThemesView.spec.ts @@ -5,10 +5,62 @@ import { createPinia, setActivePinia } from 'pinia' import ThemesView from './ThemesView.vue' import { useThemeStore } from '@/stores/theme' import { mockCommunityThemes, getCommunityThemePreviewCss } from '@/services/themePackageService' +import paperPackage from '@/assets/themes/paper-moments.theme?raw' let wrapper: VueWrapper beforeEach(() => { localStorage.clear(); setActivePinia(createPinia()) }) -afterEach(() => { wrapper?.unmount(); vi.useRealTimers() }) +afterEach(() => { useThemeStore().applyTheme('light'); wrapper?.unmount(); vi.restoreAllMocks(); vi.unstubAllGlobals(); vi.useRealTimers() }) + +it('downloads a URL for inspection without automatically installing it', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(paperPackage))) + wrapper = mount(ThemesView, { global: { stubs: { MarkdownContent: true } } }) + await flushPromises() + await wrapper.findAll('button').find(button => button.text() === '导入主题')!.trigger('click') + await wrapper.get('#theme-package-url').setValue('https://example.com/paper.theme') + await wrapper.get('.url-import').trigger('submit') + await vi.waitFor(() => expect(useThemeStore().pendingInspection?.compatible).toBe(true)) + expect(useThemeStore().isThemeInstalled('paper-moments')).toBe(false) + expect(wrapper.get('.inspection-result').text()).toContain('纸间时光') +}) + +it('ignores a URL response after the dialog is cancelled', async () => { + let respond!: (response: Response) => void + vi.stubGlobal('fetch', vi.fn(() => new Promise(resolve => { respond = resolve }))) + wrapper = mount(ThemesView, { global: { stubs: { MarkdownContent: true } } }) + await flushPromises() + await wrapper.findAll('button').find(button => button.text() === '导入主题')!.trigger('click') + await wrapper.get('#theme-package-url').setValue('https://example.com/paper.theme') + await wrapper.get('.url-import').trigger('submit') + await wrapper.get('.import-modal .inline-actions button').trigger('click') + respond(new Response(paperPackage)) + await flushPromises() + expect(useThemeStore().pendingInspection).toBeNull() + expect(wrapper.find('.import-modal').exists()).toBe(false) +}) + +it('opens the file picker from the styled button and imports the actual paper theme', async () => { + const store = useThemeStore() + wrapper = mount(ThemesView, { global: { stubs: { MarkdownContent: true } } }) + await flushPromises() + await wrapper.findAll('button').find(button => button.text() === '导入主题')!.trigger('click') + const input = wrapper.get('input[type="file"]') + const click = vi.spyOn(input.element, 'click').mockImplementation(() => {}) + await wrapper.get('.upload-area .button-primary').trigger('click') + expect(click).toHaveBeenCalledOnce() + Object.defineProperty(input.element, 'files', { value: [new File([paperPackage], 'paper-moments.theme', { type: 'text/plain' })] }) + await input.trigger('change') + await vi.waitFor(() => expect(store.pendingInspection?.compatible).toBe(true)) + expect(store.pendingInspection!.warnings).toEqual([]) + await wrapper.get('.import-modal .inline-actions .button-primary').trigger('click') + await flushPromises() + expect(store.isThemeInstalled('paper-moments')).toBe(true) + expect(localStorage.getItem('installed-themes-css-paper-moments')).toBe(getCommunityThemePreviewCss('paper-moments')) + expect(document.getElementById('theme-style-paper-moments')).toBeNull() + store.applyTheme('paper-moments') + expect(document.getElementById('theme-style-paper-moments')!.textContent).toBe(getCommunityThemePreviewCss('paper-moments')) + store.applyTheme('light') + expect(document.getElementById('theme-style-paper-moments')).toBeNull() +}) it.each(mockCommunityThemes)('previews uninstalled $theme_id using its actual CSS without changing the active theme', async theme => { const store = useThemeStore() diff --git a/frontend/src/features/themes/ThemesView.vue b/frontend/src/features/themes/ThemesView.vue index f1b4c72..5f60437 100644 --- a/frontend/src/features/themes/ThemesView.vue +++ b/frontend/src/features/themes/ThemesView.vue @@ -1,19 +1,58 @@ diff --git a/frontend/src/features/editor/VisualMarkdownEditor.vue b/frontend/src/features/editor/VisualMarkdownEditor.vue index 58323fd..23e61e0 100644 --- a/frontend/src/features/editor/VisualMarkdownEditor.vue +++ b/frontend/src/features/editor/VisualMarkdownEditor.vue @@ -62,6 +62,22 @@ const fontSizeInput = ref(16) let crepe: Crepe | null = null let disposeLanguagePicker: (() => void) | undefined let disposeCodeLabels: (() => void) | undefined +const diagramPreviews = new Map void }>() +function renderDiagram(source: string, apply: (value: HTMLElement) => void) { + for (const [id, entry] of diagramPreviews) { + if (entry.apply === apply) diagramPreviews.delete(id) + } + const element = createMermaidPreview(source, themeStore.isDark, apply) + diagramPreviews.set(element.id, { source, apply }) + return element +} +watch(() => themeStore.currentThemeId, () => { + const current = [...diagramPreviews.entries()] + diagramPreviews.clear() + for (const [id, entry] of current) { + if (editorRoot.value?.querySelector(`[id="${id}"]`)) entry.apply(renderDiagram(entry.source, entry.apply)) + } +}, { flush: 'post' }) function applyProofingPreferences() { const editable = editorRoot.value?.querySelector('.ProseMirror') @@ -206,7 +222,7 @@ onMounted(async () => { languages: shikiLanguages(themeStore.resolvedCodeBlockTheme), renderLanguage: renderCodeLanguage, renderPreview: (language, content, applyPreview) => language.trim().toLowerCase() === 'mermaid' - ? createMermaidPreview(content, themeStore.isDark, applyPreview) + ? renderDiagram(content, applyPreview) : config.renderPreview(language, content, applyPreview), extensions: [basicSetup, keymap.of([indentWithTab]), shikiEditorTheme(themeStore.resolvedCodeBlockTheme)], }))) @@ -242,7 +258,7 @@ watch(() => editorStore.headingRequest, request => { }) }) -onBeforeUnmount(() => { disposeCodeLabels?.(); disposeLanguagePicker?.(); void crepe?.destroy() }) +onBeforeUnmount(() => { diagramPreviews.clear(); disposeCodeLabels?.(); disposeLanguagePicker?.(); void crepe?.destroy() }) defineExpose({ getEditor: () => crepe?.editor }) diff --git a/frontend/src/features/workspace/FileTreePanel.spec.ts b/frontend/src/features/workspace/FileTreePanel.spec.ts index c532221..24b1675 100644 --- a/frontend/src/features/workspace/FileTreePanel.spec.ts +++ b/frontend/src/features/workspace/FileTreePanel.spec.ts @@ -48,6 +48,16 @@ afterEach(() => { }) describe('FileTreePanel file switching', () => { + it('expands every nested folder from the toolbar', async () => { + const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/workspace', component: { template: '
' } }] }) + await router.push('/workspace') + const store = useWorkspaceStore() + store.fileTree = [{ id: 'a', name: 'A', path: '/a', type: 'folder', is_open: false, children: [{ id: 'b', name: 'B', path: '/a/b', type: 'folder', is_open: false }] }] + wrapper = mount(FileTreePanel, { global: { plugins: [router] } }) + await wrapper.get('[aria-label="全部展开文件夹"]').trigger('click') + expect(store.fileTree[0]!.is_open).toBe(true) + expect(store.fileTree[0]!.children![0]!.is_open).toBe(true) + }) it('switches full-height panels using tabs and preserves the file search', async () => { const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/workspace', component: { template: '
' } }] }) await router.push('/workspace') diff --git a/frontend/src/features/workspace/FileTreePanel.vue b/frontend/src/features/workspace/FileTreePanel.vue index 35fc624..59da5d1 100644 --- a/frontend/src/features/workspace/FileTreePanel.vue +++ b/frontend/src/features/workspace/FileTreePanel.vue @@ -68,6 +68,12 @@ const filteredTree = computed(() => { }) return filter(workspaceStore.fileTree) }) +function expandAllFiles() { + const expand = (nodes: FileNode[]) => nodes.forEach(node => { + if (node.type === 'folder') { node.is_open = true; expand(node.children ?? []) } + }) + expand(workspaceStore.fileTree) +} let lastScrollTop = 0 function revealSearch(event: WheelEvent) { if (activeTab.value !== 'files') return @@ -216,6 +222,7 @@ function containingFolder(path: string): string { +