feat(frontend): 完善主题导入与手帐工作区并修复 Mermaid 预览
This commit is contained in:
@@ -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` 稳定。
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+10
-9
@@ -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
|
||||
|
||||
|
||||
@@ -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); }
|
||||
@@ -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<HTMLElement>('.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 })
|
||||
</script>
|
||||
@@ -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%;
|
||||
|
||||
@@ -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 = '<div class="milkdown-code-block"><button class="language-button">Python</button></div>'
|
||||
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')
|
||||
})
|
||||
@@ -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<HTMLElement>('.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()
|
||||
}
|
||||
@@ -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: '<svg><text>new</text></svg>', 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: '<svg><text>old</text></svg>', 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: ['<img src=x onerror=alert(1)>'], 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('点击编辑')
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 '<!doctype html>' + doc.documentElement.outerHTML
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -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<HTMLInputElement>('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()
|
||||
|
||||
@@ -1,19 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, onBeforeUnmount, ref } from 'vue'
|
||||
import MarkdownContent from '@/components/common/MarkdownContent.vue'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import { mockCommunityThemes } from '@/services/themePackageService'
|
||||
import { mockCommunityThemes, decodeThemePackage, fetchThemePackage, inspectThemePackage, MAX_THEME_BYTES } from '@/services/themePackageService'
|
||||
import type { ThemePackageInspection } from '@/contracts'
|
||||
import { t } from '@/i18n'
|
||||
import CommunityThemePreview from './CommunityThemePreview.vue'
|
||||
import paperMomentsUrl from '@/assets/themes/paper-moments.theme?url'
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
|
||||
const activeTab = ref<'installed' | 'community'>('installed')
|
||||
const showImportDialog = ref(false)
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const previewThemeId = ref<string | null>(null)
|
||||
const communityPreviewId = ref<string | null>(null)
|
||||
const actionError = ref('')
|
||||
const importUrl = ref('')
|
||||
const importing = ref(false)
|
||||
let importGeneration = 0
|
||||
let downloadController: AbortController | undefined
|
||||
|
||||
function resetImport() {
|
||||
importGeneration++
|
||||
downloadController?.abort()
|
||||
importing.value = false
|
||||
themeStore.pendingInspection = null
|
||||
themeStore.importError = null
|
||||
actionError.value = ''
|
||||
}
|
||||
function closeImport() { resetImport(); showImportDialog.value = false }
|
||||
function openImport() { resetImport(); showImportDialog.value = true }
|
||||
onBeforeUnmount(resetImport)
|
||||
|
||||
async function importPackage(load: () => Promise<string>) {
|
||||
resetImport()
|
||||
const generation = importGeneration
|
||||
importing.value = true
|
||||
try {
|
||||
const result = await inspectThemePackage(await load())
|
||||
if (generation !== importGeneration) return
|
||||
themeStore.pendingInspection = result
|
||||
if (!result.compatible) actionError.value = result.warnings[0] ?? '主题包无法解析'
|
||||
} catch (error) {
|
||||
if (generation === importGeneration) actionError.value = error instanceof Error ? error.message : '导入失败'
|
||||
} finally { if (generation === importGeneration) importing.value = false }
|
||||
}
|
||||
|
||||
function importFromUrl() {
|
||||
void importPackage(() => {
|
||||
downloadController = new AbortController()
|
||||
return fetchThemePackage(importUrl.value, downloadController.signal)
|
||||
})
|
||||
}
|
||||
|
||||
const shikiPreview = `\`\`\`typescript
|
||||
const notes = await search('本地优先')
|
||||
@@ -30,23 +69,16 @@ function handleFileImport(event: Event) {
|
||||
const file = input.files?.[0]
|
||||
input.value = ''
|
||||
if (!file) return
|
||||
actionError.value = ''
|
||||
void importPackage(async () => {
|
||||
if (file.size > MAX_THEME_BYTES) throw new Error('主题包不能超过 5 MB')
|
||||
const bytes = await new Promise<ArrayBuffer>((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = async () => {
|
||||
try {
|
||||
const result = await themeStore.inspectThemePackage(String(reader.result ?? ''))
|
||||
if (result.compatible) {
|
||||
previewThemeId.value = result.manifest.theme_id
|
||||
} else {
|
||||
actionError.value = result.warnings[0] ?? '主题包无法解析'
|
||||
}
|
||||
} catch (error) {
|
||||
actionError.value = error instanceof Error ? error.message : '导入失败'
|
||||
}
|
||||
}
|
||||
reader.onerror = () => { actionError.value = '文件读取失败' }
|
||||
// 主题包是文本格式(YAML 清单 + --- + CSS),二进制包在解析阶段会被拒绝。
|
||||
reader.readAsText(file)
|
||||
reader.onload = () => resolve(reader.result as ArrayBuffer)
|
||||
reader.onerror = () => reject(new Error('文件读取失败'))
|
||||
reader.readAsArrayBuffer(file)
|
||||
})
|
||||
return decodeThemePackage(new Uint8Array(bytes))
|
||||
})
|
||||
}
|
||||
|
||||
async function confirmInstall(inspection: ThemePackageInspection) {
|
||||
@@ -90,7 +122,7 @@ onMounted(() => {
|
||||
<p>浏览、导入和管理主题,打造你的知识工作流。</p>
|
||||
</div>
|
||||
<div class="inline-actions">
|
||||
<button class="button-secondary" @click="showImportDialog = true">导入主题</button>
|
||||
<button class="button-secondary" @click="openImport">导入主题</button>
|
||||
<button class="button-secondary" @click="themeStore.resetToDefault()">{{ t('恢复默认', 'Reset defaults') }}</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -124,7 +156,7 @@ onMounted(() => {
|
||||
:class="{ selected: themeStore.currentThemeId === theme.theme_id }"
|
||||
@click="themeStore.applyTheme(theme.theme_id)"
|
||||
>
|
||||
<div class="theme-preview" :class="theme.is_dark ? 'preview-dark' : (theme.theme_id === 'sepia' ? 'preview-sepia' : 'preview-light')">
|
||||
<div class="theme-preview" :class="theme.theme_id === 'paper-moments' ? 'preview-paper' : theme.is_dark ? 'preview-dark' : (theme.theme_id === 'sepia' ? 'preview-sepia' : 'preview-light')">
|
||||
<span></span><span></span><span></span><div></div>
|
||||
</div>
|
||||
<div class="theme-info">
|
||||
@@ -150,7 +182,7 @@ onMounted(() => {
|
||||
:key="theme.theme_id"
|
||||
class="item-card theme-card"
|
||||
>
|
||||
<div class="theme-preview" :class="theme.is_dark ? 'preview-dark' : 'preview-light'">
|
||||
<div class="theme-preview" :class="theme.theme_id === 'paper-moments' ? 'preview-paper' : theme.is_dark ? 'preview-dark' : 'preview-light'">
|
||||
<span></span><span></span><span></span><div></div>
|
||||
</div>
|
||||
<div class="theme-info">
|
||||
@@ -165,14 +197,15 @@ onMounted(() => {
|
||||
<span v-for="tag in theme.tags" :key="tag" class="tag">{{ tag }}</span>
|
||||
</div>
|
||||
<div class="theme-actions">
|
||||
<a v-if="theme.theme_id === 'paper-moments'" class="button-secondary small" :href="paperMomentsUrl" download="paper-moments.theme">下载主题包</a>
|
||||
<button
|
||||
v-if="themeStore.isThemeInstalled(theme.theme_id)"
|
||||
v-if="themeStore.allThemes.some(installed => installed.theme_id === theme.theme_id && installed.version === theme.version)"
|
||||
class="button-secondary small"
|
||||
@click="themeStore.applyTheme(theme.theme_id)"
|
||||
>启用</button>
|
||||
<template v-else>
|
||||
<button class="button-secondary small" @click="previewCommunity(theme.theme_id)">预览</button>
|
||||
<button class="button-primary small" @click="installFromCommunity(theme.theme_id)">安装</button>
|
||||
<button class="button-primary small" @click="installFromCommunity(theme.theme_id)">{{ themeStore.isThemeInstalled(theme.theme_id) ? '更新' : '安装' }}</button>
|
||||
</template>
|
||||
</div>
|
||||
</article>
|
||||
@@ -193,11 +226,12 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showImportDialog" class="modal-backdrop" @click.self="showImportDialog = false">
|
||||
<div v-if="showImportDialog" class="modal-backdrop" @click.self="closeImport">
|
||||
<div class="modal import-modal">
|
||||
<span class="badge info">主题导入</span>
|
||||
<h2>导入主题包</h2>
|
||||
<p class="subtle">单文件主题包:YAML 清单 + 一行 <code>---</code> + 主题 CSS。安装前会校验清单与 CSS 安全性。</p>
|
||||
<p class="subtle">选择本地文件或粘贴主题包直链。支持单文件主题与 ZIP,安装前会校验清单和 CSS。</p>
|
||||
<p v-if="actionError" class="error-banner" role="alert">{{ actionError }}</p>
|
||||
|
||||
<div v-if="themeStore.pendingInspection?.compatible" class="inspection-result">
|
||||
<div class="inspect-head">
|
||||
@@ -222,13 +256,21 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<div v-else class="upload-area">
|
||||
<input type="file" accept=".yaml,.yml,.theme" @change="handleFileImport" />
|
||||
<p>点击选择主题包文件</p>
|
||||
<p class="subtle">支持 .yaml / .yml / .theme;ZIP 需要 Host 端解压,暂不支持。</p>
|
||||
<input ref="fileInput" class="theme-file-input" type="file" accept=".yaml,.yml,.theme,.zip" tabindex="-1" aria-label="主题包文件" @change="handleFileImport" />
|
||||
<button type="button" class="button-primary" :disabled="importing" @click="fileInput?.click()">选择主题包文件</button>
|
||||
<p>从本地导入你喜欢的主题</p>
|
||||
<p class="subtle">支持 .yaml / .yml / .theme / .zip,最大 5 MB。</p>
|
||||
<form class="url-import" @submit.prevent="importFromUrl">
|
||||
<label for="theme-package-url">从 URL 导入</label>
|
||||
<input id="theme-package-url" v-model="importUrl" class="input" type="url" required placeholder="https://example.com/theme.zip" :disabled="importing" />
|
||||
<button class="button-secondary" type="submit" :disabled="importing">{{ importing ? '正在读取…' : '下载并校验' }}</button>
|
||||
<p class="subtle">请使用文件直链;远程服务器需允许跨域访问。</p>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="inline-actions">
|
||||
<button class="button-secondary" @click="showImportDialog = false">取消</button>
|
||||
<button v-if="themeStore.pendingInspection?.compatible" class="button-secondary" @click="resetImport">重新选择</button>
|
||||
<button class="button-secondary" @click="closeImport">取消</button>
|
||||
<button
|
||||
v-if="themeStore.pendingInspection?.compatible"
|
||||
class="button-primary"
|
||||
@@ -262,9 +304,16 @@ onMounted(() => {
|
||||
.preview-sepia { background: #fbf3df; border-color: #ddcfad; }
|
||||
.preview-sepia span { background: #d8c69c; }
|
||||
.preview-sepia div { background: #f4e8ca; }
|
||||
.preview-paper { background: #fffdf5; border: 1px dashed #8b7865; box-shadow: 3px 3px 0 #d8e6e2, 6px 6px 0 #f0d8cf; }
|
||||
.preview-paper span { background: #efd8d0; }
|
||||
.preview-paper span:nth-child(2) { background: #d8e7e8; }
|
||||
.preview-paper span:nth-child(3) { background: #f6e9b8; }
|
||||
.preview-paper div { border: 1px solid #b5a693; background: repeating-linear-gradient(#fffef8 0 14px, #dce4db 14px 15px); }
|
||||
.theme-actions a { text-decoration: none; }
|
||||
|
||||
.theme-info { display: flex; justify-content: space-between; gap: var(--space-md); align-items: flex-start; }
|
||||
.theme-info strong { display: block; margin-bottom: 2px; }
|
||||
.theme-info > .badge { flex-shrink: 0; white-space: nowrap; }
|
||||
|
||||
.theme-tags { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
.tag {
|
||||
@@ -347,10 +396,10 @@ onMounted(() => {
|
||||
transition: border-color var(--motion-fast);
|
||||
}
|
||||
.upload-area:hover { border-color: var(--color-accent-secondary); }
|
||||
.upload-area input {
|
||||
display: block;
|
||||
margin: 0 auto var(--space-md);
|
||||
}
|
||||
.url-import { display: grid; gap: 10px; margin-top: 20px; padding-top: 20px; border-top: 1px solid var(--color-border-default); text-align: left; }
|
||||
.url-import .input { width: 100%; min-width: 0; }
|
||||
.upload-area .theme-file-input { display: none; }
|
||||
.upload-area > button { margin-bottom: var(--space-md); }
|
||||
.upload-area p { color: var(--color-text-secondary); }
|
||||
|
||||
.inspection-result {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { strToU8, zipSync } from 'fflate'
|
||||
import { decodeThemePackage, fetchThemePackage, inspectThemePackage, MAX_THEME_BYTES } from './themePackageService'
|
||||
import paper from '@/assets/themes/paper-moments.theme?raw'
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
it('reads ZIP manifests under repository folders and validates the bundled CSS', async () => {
|
||||
const [yaml, css] = paper.split('\n---\n')
|
||||
const zip = zipSync({ 'repo-main/theme.yaml': strToU8(yaml!), 'repo-main/theme.css': strToU8(css!) })
|
||||
const result = await inspectThemePackage(await decodeThemePackage(zip))
|
||||
expect(result.compatible).toBe(true)
|
||||
expect(result.css).toBe(css!.trim())
|
||||
})
|
||||
it('accepts a zipped single-file theme', async () => {
|
||||
expect(await decodeThemePackage(zipSync({ 'paper.theme': strToU8(paper) }))).toBe(paper)
|
||||
})
|
||||
it('rejects unsafe paths, ambiguous manifests and oversized input', async () => {
|
||||
await expect(decodeThemePackage(zipSync({ '../paper.theme': strToU8(paper) }))).rejects.toThrow('非法')
|
||||
await expect(decodeThemePackage(zipSync({ 'theme.yaml': strToU8(paper), 'manifest.yml': strToU8(paper) }))).rejects.toThrow('多个')
|
||||
await expect(decodeThemePackage(new Uint8Array(MAX_THEME_BYTES + 1))).rejects.toThrow('5 MB')
|
||||
})
|
||||
it('uses the same ZIP parser for URL downloads without sending credentials', async () => {
|
||||
const fetcher = vi.fn().mockResolvedValue(new Response(zipSync({ 'paper.theme': strToU8(paper) })))
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
expect(await fetchThemePackage('https://example.com/theme.zip')).toBe(paper)
|
||||
expect(fetcher).toHaveBeenCalledWith('https://example.com/theme.zip', expect.objectContaining({ credentials: 'omit' }))
|
||||
await expect(fetchThemePackage('file:///theme.zip')).rejects.toThrow('HTTP(S)')
|
||||
})
|
||||
it('reports HTTP and streaming size failures', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response('', { status: 404 })).mockResolvedValueOnce(new Response(new Uint8Array(MAX_THEME_BYTES + 1))))
|
||||
await expect(fetchThemePackage('https://example.com/theme')).rejects.toThrow('404')
|
||||
await expect(fetchThemePackage('https://example.com/theme')).rejects.toThrow('5 MB')
|
||||
})
|
||||
@@ -1,7 +1,83 @@
|
||||
import type { InstalledTheme, ThemeManifest, ThemePackageInspection } from '@/contracts'
|
||||
import paperMomentsPackage from '@/assets/themes/paper-moments.theme?raw'
|
||||
|
||||
const STORAGE_KEY = 'installed-themes'
|
||||
const ACTIVE_CUSTOM_KEY = 'active-custom-theme'
|
||||
export const MAX_THEME_BYTES = 5 * 1024 * 1024
|
||||
|
||||
/** Normalize all transports to the existing single-file inspection format. */
|
||||
export async function decodeThemePackage(bytes: Uint8Array): Promise<string> {
|
||||
if (bytes.length > MAX_THEME_BYTES) throw new Error('主题包不能超过 5 MB')
|
||||
const decode = (data: Uint8Array) => new TextDecoder('utf-8', { fatal: true }).decode(data)
|
||||
if (bytes[0] !== 0x50 || bytes[1] !== 0x4b) return decode(bytes)
|
||||
const { unzipSync } = await import('fflate')
|
||||
let total = 0
|
||||
let count = 0
|
||||
const names = new Set<string>()
|
||||
const safePath = (path: string) => path.length > 0 && !path.startsWith('/') && !path.includes('\\') && !path.includes(':') && !path.split('/').some(part => part === '..' || part === '.')
|
||||
const files = unzipSync(bytes, { filter: file => {
|
||||
if (!safePath(file.name) || names.has(file.name)) throw new Error('ZIP 包含非法或重复路径')
|
||||
names.add(file.name)
|
||||
total += file.originalSize
|
||||
if (++count > 100 || total > 10 * 1024 * 1024) throw new Error('ZIP 解压内容不能超过 10 MB 或 100 个文件')
|
||||
return !file.name.endsWith('/')
|
||||
} })
|
||||
const entries = Object.keys(files)
|
||||
const manifests = entries.filter(name => /(^|\/)(theme|manifest)\.ya?ml$/i.test(name))
|
||||
if (!manifests.length) {
|
||||
const single = entries.filter(name => name.endsWith('.theme'))
|
||||
if (single.length !== 1) throw new Error('ZIP 需要唯一的 theme.yaml / manifest.yaml,或一个 .theme 文件')
|
||||
return decode(files[single[0]!]!)
|
||||
}
|
||||
if (manifests.length !== 1) throw new Error('ZIP 中存在多个主题清单,请每包只放一个主题')
|
||||
const manifestPath = manifests[0]!
|
||||
const yaml = decode(files[manifestPath]!)
|
||||
const manifest = inspectYamlContent(yaml)
|
||||
if (!safePath(manifest.css_entry)) throw new Error('css_entry 必须是包内相对路径')
|
||||
const base = manifestPath.slice(0, manifestPath.lastIndexOf('/') + 1)
|
||||
const css = files[base + manifest.css_entry]
|
||||
if (!css) throw new Error(`ZIP 中找不到 CSS 文件:${manifest.css_entry}`)
|
||||
return `${yaml}\n---\n${decode(css)}`
|
||||
}
|
||||
|
||||
export async function fetchThemePackage(urlText: string, signal?: AbortSignal): Promise<string> {
|
||||
const url = new URL(urlText.trim())
|
||||
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) throw new Error('请输入不含账号密码的 HTTP(S) 主题包直链')
|
||||
const controller = new AbortController()
|
||||
const abort = () => controller.abort()
|
||||
signal?.addEventListener('abort', abort, { once: true })
|
||||
if (signal?.aborted) abort()
|
||||
const timeout = setTimeout(abort, 30000)
|
||||
try {
|
||||
const response = await fetch(url.href, { signal: controller.signal, credentials: 'omit', referrerPolicy: 'no-referrer' })
|
||||
if (!response.ok) throw new Error(`下载失败:HTTP ${response.status}`)
|
||||
if (Number(response.headers.get('content-length')) > MAX_THEME_BYTES) throw new Error('主题包不能超过 5 MB')
|
||||
if (!response.body) throw new Error('下载内容为空')
|
||||
const reader = response.body.getReader()
|
||||
const chunks: Uint8Array[] = []
|
||||
let size = 0
|
||||
try {
|
||||
while (true) {
|
||||
const { value, done } = await reader.read()
|
||||
if (done) break
|
||||
size += value.length
|
||||
if (size > MAX_THEME_BYTES) throw new Error('主题包不能超过 5 MB')
|
||||
chunks.push(value)
|
||||
}
|
||||
} finally { await reader.cancel() }
|
||||
const bytes = new Uint8Array(size)
|
||||
let offset = 0
|
||||
for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.length }
|
||||
return await decodeThemePackage(bytes)
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) throw new Error('下载已取消或超时,请重试')
|
||||
if (error instanceof TypeError) throw new Error('无法下载,请检查直链及服务器是否允许跨域访问(CORS)')
|
||||
throw error
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
signal?.removeEventListener('abort', abort)
|
||||
}
|
||||
}
|
||||
|
||||
function loadStoredThemes(): InstalledTheme[] {
|
||||
try {
|
||||
@@ -113,8 +189,7 @@ function inspectYamlContent(yamlText: string): ThemeManifest {
|
||||
* ---
|
||||
* [data-theme="my-theme"] { --color-... }
|
||||
*
|
||||
* 浏览器端没有解压能力,所以不支持 ZIP —— 与其把二进制当文本解析出
|
||||
* 一堆乱码再报「清单无效」,不如直接告诉用户格式不支持。
|
||||
* ZIP 必须先通过 decodeThemePackage 解码;此函数只处理规范化后的文本。
|
||||
*/
|
||||
export function parseThemePackage(packageData: string): { manifestText: string; css: string } {
|
||||
if (looksLikeZip(packageData)) {
|
||||
@@ -146,19 +221,19 @@ function looksLikeZip(data: string): boolean {
|
||||
}
|
||||
|
||||
export async function selectThemePackage(): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
// 只接受能在浏览器里解析的单文件主题;ZIP 需要 Host 端解压,暂不支持。
|
||||
input.accept = '.yaml,.yml,.theme'
|
||||
input.accept = '.yaml,.yml,.theme,.zip'
|
||||
input.multiple = false
|
||||
input.onchange = () => {
|
||||
const file = input.files?.[0]
|
||||
if (!file) { resolve(null); return }
|
||||
if (file.size > MAX_THEME_BYTES) { reject(new Error('主题包不能超过 5 MB')); return }
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(reader.result as string)
|
||||
reader.onload = () => { void decodeThemePackage(new Uint8Array(reader.result as ArrayBuffer)).then(resolve, reject) }
|
||||
reader.onerror = () => resolve(null)
|
||||
reader.readAsText(file)
|
||||
reader.readAsArrayBuffer(file)
|
||||
}
|
||||
input.oncancel = () => resolve(null)
|
||||
input.click()
|
||||
@@ -280,7 +355,10 @@ export function setActiveCustomTheme(themeId: string | null) {
|
||||
else localStorage.removeItem(ACTIVE_CUSTOM_KEY)
|
||||
}
|
||||
|
||||
const paperMoments = parseThemePackage(paperMomentsPackage)
|
||||
|
||||
export const mockCommunityThemes: ThemeManifest[] = [
|
||||
{ ...inspectYamlContent(paperMoments.manifestText), tags: ['浅色', '手帐', '纸张'] },
|
||||
{
|
||||
theme_id: 'ocean-blue',
|
||||
name: 'Ocean Blue',
|
||||
@@ -293,18 +371,6 @@ export const mockCommunityThemes: ThemeManifest[] = [
|
||||
tags: ['浅色', '蓝色', '阅读'],
|
||||
license: 'MIT',
|
||||
},
|
||||
{
|
||||
theme_id: 'forest-green',
|
||||
name: 'Forest Green',
|
||||
version: '1.0.1',
|
||||
author: 'nature-collection',
|
||||
description: '森林绿色护眼主题',
|
||||
min_app_version: '0.2.0',
|
||||
is_dark: false,
|
||||
css_entry: 'theme.css',
|
||||
tags: ['浅色', '绿色', '护眼'],
|
||||
license: 'MIT',
|
||||
},
|
||||
{
|
||||
theme_id: 'midnight-purple',
|
||||
name: 'Midnight Purple',
|
||||
@@ -317,39 +383,12 @@ export const mockCommunityThemes: ThemeManifest[] = [
|
||||
tags: ['深色', '紫色', '极客'],
|
||||
license: 'Apache-2.0',
|
||||
},
|
||||
{
|
||||
theme_id: 'solarized-light',
|
||||
name: 'Solarized Light',
|
||||
version: '1.1.0',
|
||||
author: 'solarized',
|
||||
description: '经典 Solarized 浅色主题',
|
||||
min_app_version: '0.1.0',
|
||||
is_dark: false,
|
||||
css_entry: 'theme.css',
|
||||
tags: ['浅色', '经典', '阅读'],
|
||||
license: 'MIT',
|
||||
},
|
||||
{
|
||||
theme_id: 'dracula',
|
||||
name: 'Dracula',
|
||||
version: '3.0.0',
|
||||
author: 'dracula-theme',
|
||||
description: '流行的 Dracula 暗色主题',
|
||||
min_app_version: '0.2.0',
|
||||
is_dark: true,
|
||||
css_entry: 'theme.css',
|
||||
tags: ['深色', '紫色', '高对比'],
|
||||
license: 'MIT',
|
||||
},
|
||||
]
|
||||
|
||||
function buildCommunityThemeCss(themeId: string, isDark: boolean, accent: string): string {
|
||||
function buildCommunityThemeCss(themeId: string, isDark: boolean): string {
|
||||
const palettes: Record<string, { primary: string; soft: string; hover: string }> = {
|
||||
'ocean-blue': { primary: '#0077b6', soft: '#e0f0fa', hover: '#005f92' },
|
||||
'forest-green': { primary: '#2d6a4f', soft: '#e8f5ec', hover: '#1b4332' },
|
||||
'midnight-purple': { primary: '#9d4edd', soft: '#2b1a3e', hover: '#7b2cbf' },
|
||||
'solarized-light': { primary: '#b58900', soft: '#fdf6e3', hover: '#8a6d0b' },
|
||||
'dracula': { primary: '#bd93f9', soft: '#2d2a3e', hover: '#a77bf5' },
|
||||
}
|
||||
const p = palettes[themeId] ?? palettes['ocean-blue']
|
||||
if (isDark) {
|
||||
@@ -415,12 +454,13 @@ function buildCommunityThemeCss(themeId: string, isDark: boolean, accent: string
|
||||
export async function installCommunityTheme(themeId: string): Promise<InstalledTheme> {
|
||||
const themeManifest = mockCommunityThemes.find((t) => t.theme_id === themeId)
|
||||
if (!themeManifest) throw new Error('THEME_PACKAGE_NOT_FOUND')
|
||||
const css = buildCommunityThemeCss(themeId, themeManifest.is_dark, themeManifest.theme_id)
|
||||
const css = getCommunityThemePreviewCss(themeId)
|
||||
return installTheme(themeManifest, css)
|
||||
}
|
||||
|
||||
export function getCommunityThemePreviewCss(themeId: string): string {
|
||||
if (themeId === 'paper-moments') return paperMoments.css
|
||||
const t = mockCommunityThemes.find((m) => m.theme_id === themeId)
|
||||
if (!t) return ''
|
||||
return buildCommunityThemeCss(themeId, t.is_dark, themeId)
|
||||
return buildCommunityThemeCss(themeId, t.is_dark)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user