@lemoncat7/dsh-knowledge 2.2.10 → 2.2.13-next.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +3 -3
  2. package/docs/architecture.md +6 -2
  3. package/lib/api.d.ts.map +1 -1
  4. package/lib/api.js +20 -0
  5. package/lib/api.js.map +1 -1
  6. package/lib/client.js +22 -22
  7. package/lib/client.js.map +2 -2
  8. package/lib/local-provider.d.ts +8 -1
  9. package/lib/local-provider.d.ts.map +1 -1
  10. package/lib/local-provider.js +12 -0
  11. package/lib/local-provider.js.map +1 -1
  12. package/lib/management-proxy.js +1 -1
  13. package/lib/management-proxy.js.map +1 -1
  14. package/lib/notes/domain.d.ts +10 -0
  15. package/lib/notes/domain.d.ts.map +1 -1
  16. package/lib/notes/domain.js.map +1 -1
  17. package/lib/notes/store.d.ts +15 -1
  18. package/lib/notes/store.d.ts.map +1 -1
  19. package/lib/notes/store.js +277 -28
  20. package/lib/notes/store.js.map +1 -1
  21. package/lib/provider.d.ts +8 -1
  22. package/lib/provider.d.ts.map +1 -1
  23. package/lib/remote-provider.d.ts +8 -1
  24. package/lib/remote-provider.d.ts.map +1 -1
  25. package/lib/remote-provider.js +22 -0
  26. package/lib/remote-provider.js.map +1 -1
  27. package/lib/theme-bridge.js +7 -7
  28. package/lib/theme-bridge.js.map +1 -1
  29. package/lib/tool-authorization.js +11 -16
  30. package/lib/tool-authorization.js.map +1 -1
  31. package/lib/web-note-editor-chrome.d.ts +17 -0
  32. package/lib/web-note-editor-chrome.d.ts.map +1 -0
  33. package/lib/web-note-editor-chrome.js +38 -0
  34. package/lib/web-note-editor-chrome.js.map +1 -0
  35. package/lib/web-note-editor-find.d.ts +17 -0
  36. package/lib/web-note-editor-find.d.ts.map +1 -0
  37. package/lib/web-note-editor-find.js +225 -0
  38. package/lib/web-note-editor-find.js.map +1 -0
  39. package/lib/web-note-editor-outline.d.ts +15 -0
  40. package/lib/web-note-editor-outline.d.ts.map +1 -0
  41. package/lib/web-note-editor-outline.js +169 -0
  42. package/lib/web-note-editor-outline.js.map +1 -0
  43. package/lib/web-note-editor-search.d.ts +30 -0
  44. package/lib/web-note-editor-search.d.ts.map +1 -0
  45. package/lib/web-note-editor-search.js +143 -0
  46. package/lib/web-note-editor-search.js.map +1 -0
  47. package/lib/web-note-editor-selection.d.ts +15 -0
  48. package/lib/web-note-editor-selection.d.ts.map +1 -0
  49. package/lib/web-note-editor-selection.js +255 -0
  50. package/lib/web-note-editor-selection.js.map +1 -0
  51. package/lib/web-note-editor.d.ts +7 -0
  52. package/lib/web-note-editor.d.ts.map +1 -1
  53. package/lib/web-note-editor.js +36 -6
  54. package/lib/web-note-editor.js.map +1 -1
  55. package/lib/web-note-history.d.ts +28 -0
  56. package/lib/web-note-history.d.ts.map +1 -0
  57. package/lib/web-note-history.js +163 -0
  58. package/lib/web-note-history.js.map +1 -0
  59. package/lib/web-workspace-effects.d.ts.map +1 -1
  60. package/lib/web-workspace-effects.js +1 -2
  61. package/lib/web-workspace-effects.js.map +1 -1
  62. package/lib/web.d.ts.map +1 -1
  63. package/lib/web.js +4 -0
  64. package/lib/web.js.map +1 -1
  65. package/package.json +1 -1
  66. package/web/api-client.js +94 -0
  67. package/web/app.js +248 -230
  68. package/web/host-theme.js +63 -0
  69. package/web/index.html +1 -0
  70. package/web/note-editor.js +70 -70
  71. package/web/note-history.js +1 -0
  72. package/web/styles.css +353 -154
  73. package/web/ui-primitives.js +77 -0
  74. package/web/workspace-effects.js +1 -1
package/web/app.js CHANGED
@@ -2,16 +2,15 @@ const API_BASE = document.querySelector('meta[name="dsh-knowledge-api"]')?.conte
2
2
  const AUTH_MODE = document.querySelector('meta[name="dsh-knowledge-auth-mode"]')?.content || 'bearer'
3
3
  const WEB_PATH = document.querySelector('meta[name="dsh-knowledge-web"]')?.content || '/knowledge'
4
4
  const ASSET_VERSION = document.querySelector('meta[name="dsh-knowledge-asset-version"]')?.content || ''
5
- const HOST_THEME_MESSAGE = '@lemoncat7/dsh-knowledge/host-theme'
6
- const HOST_THEME_READY_MESSAGE = '@lemoncat7/dsh-knowledge/host-theme-ready'
7
- const HOST_THEME_PROTOCOL_VERSION = 1
8
- const HOST_THEME_COLOR_TOKENS = new Set([
9
- '--bg', '--surface', '--surface-raised', '--surface-soft', '--surface-hover', '--dialog-surface',
10
- '--text', '--text-secondary', '--text-tertiary', '--border', '--border-strong',
11
- '--accent', '--accent-hover', '--accent-soft', '--on-accent',
12
- '--success', '--success-soft', '--warning', '--warning-soft', '--danger', '--danger-soft',
5
+ const moduleUrl = name => `./${name}.js${ASSET_VERSION ? `?v=${encodeURIComponent(ASSET_VERSION)}` : ''}`
6
+ const [apiModule, themeModule, uiModule] = await Promise.all([
7
+ import(moduleUrl('api-client')),
8
+ import(moduleUrl('host-theme')),
9
+ import(moduleUrl('ui-primitives')),
13
10
  ])
14
- const HOST_THEME_STYLE_TOKENS = new Set(['--shadow'])
11
+ const { createApiClient } = apiModule
12
+ const { installHostThemeBridge } = themeModule
13
+ const { actionButton, badge, createToastPresenter, element, interfaceIcon, paneToggleButton } = uiModule
15
14
  const TOKEN_KEY = 'dsh-knowledge.session-token'
16
15
  const TYPES = ['preference', 'fact', 'decision', 'procedure', 'lesson']
17
16
  const TYPE_LABELS = { preference: '偏好', fact: '事实', decision: '决策', procedure: '流程', lesson: '经验' }
@@ -33,6 +32,7 @@ const mountContext = {
33
32
  }
34
33
  const app = document.querySelector('#app')
35
34
  const toastRegion = document.querySelector('#toast-region')
35
+ const showToast = createToastPresenter(toastRegion)
36
36
  const savedDocumentLayout = readDocumentLayout()
37
37
 
38
38
  function createDocumentViewState(overrides = {}) {
@@ -121,62 +121,10 @@ let noteSelectionRequest = 0
121
121
  let knowledgeDocumentDrag = null
122
122
  let movingDocumentId = ''
123
123
 
124
- function installHostThemeBridge() {
125
- if (window.parent === window) return Promise.resolve()
126
- const parentOrigin = referrerOrigin()
127
- return new Promise(resolve => {
128
- let initialThemeSettled = false
129
- const settleInitialTheme = () => {
130
- if (initialThemeSettled) return
131
- initialThemeSettled = true
132
- window.clearTimeout(fallback)
133
- resolve()
134
- }
135
- const fallback = window.setTimeout(settleInitialTheme, 160)
136
- window.addEventListener('message', event => {
137
- if (event.source !== window.parent) return
138
- if (parentOrigin && event.origin !== parentOrigin) return
139
- const message = event.data
140
- if (!message || message.type !== HOST_THEME_MESSAGE || message.version !== HOST_THEME_PROTOCOL_VERSION) return
141
- if (message.colorScheme !== 'light' && message.colorScheme !== 'dark') return
142
- if (!message.tokens || typeof message.tokens !== 'object' || Array.isArray(message.tokens)) return
143
-
144
- const root = document.documentElement
145
- for (const name of [...HOST_THEME_COLOR_TOKENS, ...HOST_THEME_STYLE_TOKENS]) root.style.removeProperty(name)
146
- for (const [name, value] of Object.entries(message.tokens)) {
147
- if (typeof value !== 'string' || value.length === 0 || value.length > 512) continue
148
- if (HOST_THEME_COLOR_TOKENS.has(name) && CSS.supports('color', value)) root.style.setProperty(name, value)
149
- else if (HOST_THEME_STYLE_TOKENS.has(name) && CSS.supports('box-shadow', value)) root.style.setProperty(name, value)
150
- }
151
- root.dataset.dshHostTheme = 'true'
152
- root.dataset.colorScheme = message.colorScheme
153
- root.style.colorScheme = message.colorScheme
154
- document.querySelector('meta[name="color-scheme"]')?.setAttribute('content', message.colorScheme)
155
- const background = root.style.getPropertyValue('--bg')
156
- if (background) document.querySelector('meta[name="theme-color"]')?.setAttribute('content', background)
157
- settleInitialTheme()
158
- })
159
- window.parent.postMessage({
160
- type: HOST_THEME_READY_MESSAGE,
161
- version: HOST_THEME_PROTOCOL_VERSION,
162
- }, parentOrigin || '*')
163
- })
164
- }
165
-
166
- function referrerOrigin() {
167
- if (!document.referrer) return ''
168
- try {
169
- const origin = new URL(document.referrer).origin
170
- return origin === 'null' ? '' : origin
171
- } catch {
172
- return ''
173
- }
174
- }
175
-
176
124
  function readDocumentLayout() {
177
125
  const fallback = {
178
126
  sidebarHidden: false,
179
- sidebarWidth: 236,
127
+ sidebarWidth: 214,
180
128
  }
181
129
  try {
182
130
  const value = JSON.parse(localStorage.getItem(DOCUMENT_LAYOUT_KEY) || '{}')
@@ -236,153 +184,15 @@ function restoreScrollPosition(view) {
236
184
  })
237
185
  }
238
186
 
239
- function element(tag, attributes = {}, ...children) {
240
- const node = document.createElement(tag)
241
- for (const [key, value] of Object.entries(attributes)) {
242
- if (value === undefined || value === null || value === false) continue
243
- if (key === 'class') node.className = value
244
- else if (key === 'text') node.textContent = value
245
- else if (key.startsWith('on') && typeof value === 'function') node.addEventListener(key.slice(2).toLowerCase(), value)
246
- else if (key === 'checked' || key === 'selected' || key === 'disabled') node[key] = Boolean(value)
247
- else node.setAttribute(key, String(value))
248
- }
249
- for (const child of children.flat(Infinity)) {
250
- if (child === undefined || child === null || child === false) continue
251
- node.append(child instanceof Node ? child : document.createTextNode(String(child)))
252
- }
253
- return node
254
- }
255
-
256
187
  function refreshWorkspaceEffects(root = document) {
257
188
  window.DshKnowledgeEffects?.refresh(root)
258
189
  }
259
190
 
260
- function actionButton(label, onClick, variant = '', attributes = {}) {
261
- return element('button', { type: 'button', class: `button ${variant}`.trim(), onClick, ...attributes }, label)
262
- }
263
-
264
- function paneToggleButton(pane, visible, onClick, label) {
265
- const action = `${visible ? '隐藏' : '显示'}${label}`
266
- return element('button', {
267
- type: 'button', class: 'pane-toggle-button', 'data-pane': pane,
268
- 'aria-label': action, 'aria-pressed': String(visible), title: action, onClick,
269
- }, element('span', { class: `pane-icon pane-icon-${pane}`, 'aria-hidden': 'true' }))
270
- }
271
-
272
- function interfaceIcon(name, className = 'interface-icon') {
273
- const paths = {
274
- search: 'M10.8 4.5a6.3 6.3 0 1 0 0 12.6 6.3 6.3 0 0 0 0-12.6Zm4.6 11 4.1 4',
275
- }
276
- return element('svg', {
277
- class: className, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor',
278
- 'stroke-width': '1.8', 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'aria-hidden': 'true',
279
- }, element('path', { d: paths[name] }))
280
- }
281
-
282
- function badge(label, variant = '') {
283
- return element('span', { class: `badge ${variant}`.trim() }, label)
284
- }
285
-
286
- function showToast(message, kind = '') {
287
- const toast = element('div', { class: `toast ${kind}`.trim(), role: kind === 'error' ? 'alert' : 'status' },
288
- element('span', {}, message),
289
- kind === 'error' ? actionButton('关闭', () => toast.remove(), 'ghost small toast-close', { 'aria-label': '关闭错误提示' }) : null,
290
- )
291
- toastRegion.append(toast)
292
- if (kind !== 'error') window.setTimeout(() => toast.remove(), 4200)
293
- }
294
-
295
- async function api(path, options = {}) {
296
- const headers = { accept: 'application/json', ...(options.body === undefined ? {} : { 'content-type': 'application/json' }) }
297
- if (AUTH_MODE === 'same-origin') headers['x-dsh-knowledge-client'] = 'management-web'
298
- if (state.token) headers.authorization = `Bearer ${state.token}`
299
- const response = await fetch(`${API_BASE}/${path.replace(/^\/+/, '')}`, {
300
- method: options.method || 'GET',
301
- headers,
302
- body: options.body === undefined ? undefined : JSON.stringify(options.body),
303
- signal: options.signal,
304
- })
305
- const text = await response.text()
306
- let payload
307
- if (text) {
308
- try { payload = JSON.parse(text) } catch { throw new Error('服务返回了无法识别的数据') }
309
- }
310
- if (!response.ok) {
311
- const error = new Error(payload?.error || `请求失败(HTTP ${response.status})`)
312
- error.status = response.status
313
- throw error
314
- }
315
- return payload
316
- }
317
-
318
- async function binaryRequest(path, options = {}) {
319
- const headers = { accept: options.accept || 'application/json' }
320
- if (AUTH_MODE === 'same-origin') headers['x-dsh-knowledge-client'] = 'management-web'
321
- if (state.token) headers.authorization = `Bearer ${state.token}`
322
- if (options.contentType) headers['content-type'] = options.contentType
323
- const response = await fetch(`${API_BASE}/${path.replace(/^\/+/, '')}`, {
324
- method: options.method || 'GET',
325
- headers,
326
- body: options.body,
327
- signal: options.signal,
328
- })
329
- if (options.responseType === 'blob') {
330
- if (!response.ok) throw await responseError(response)
331
- return response.blob()
332
- }
333
- const text = await response.text()
334
- let payload
335
- if (text) {
336
- try { payload = JSON.parse(text) } catch { throw new Error('服务返回了无法识别的数据') }
337
- }
338
- if (!response.ok) {
339
- const error = new Error(payload?.error || `请求失败(HTTP ${response.status})`)
340
- error.status = response.status
341
- throw error
342
- }
343
- return payload
344
- }
345
-
346
- function binaryUploadRequest(path, body, options = {}) {
347
- return new Promise((resolve, reject) => {
348
- const request = new XMLHttpRequest()
349
- request.open(options.method || 'POST', `${API_BASE}/${path.replace(/^\/+/, '')}`)
350
- request.responseType = 'text'
351
- request.setRequestHeader('accept', 'application/json')
352
- if (AUTH_MODE === 'same-origin') request.setRequestHeader('x-dsh-knowledge-client', 'management-web')
353
- if (state.token) request.setRequestHeader('authorization', `Bearer ${state.token}`)
354
- if (options.contentType) request.setRequestHeader('content-type', options.contentType)
355
- request.upload.addEventListener('progress', event => {
356
- if (event.lengthComputable) options.onProgress?.(event.loaded, event.total)
357
- })
358
- request.addEventListener('load', () => {
359
- let payload
360
- if (request.responseText) {
361
- try { payload = JSON.parse(request.responseText) }
362
- catch { return reject(new Error('服务返回了无法识别的数据')) }
363
- }
364
- if (request.status < 200 || request.status >= 300) {
365
- const error = new Error(payload?.error || `请求失败(HTTP ${request.status})`)
366
- error.status = request.status
367
- reject(error)
368
- return
369
- }
370
- resolve(payload)
371
- })
372
- request.addEventListener('error', () => reject(new Error('上传连接中断,请检查网络后重试')))
373
- request.addEventListener('abort', () => reject(new DOMException('上传已取消', 'AbortError')))
374
- request.send(body)
375
- })
376
- }
377
-
378
- async function responseError(response) {
379
- let message = `请求失败(HTTP ${response.status})`
380
- try {
381
- const payload = await response.json()
382
- if (payload?.error) message = payload.error
383
- } catch {}
384
- return Object.assign(new Error(message), { status: response.status })
385
- }
191
+ const { api, binaryRequest, binaryUploadRequest } = createApiClient({
192
+ apiBase: API_BASE,
193
+ authMode: AUTH_MODE,
194
+ getToken: () => state.token,
195
+ })
386
196
 
387
197
  async function boot() {
388
198
  if (AUTH_MODE === 'same-origin') {
@@ -2223,8 +2033,8 @@ function openNoteAgentGuide() {
2223
2033
  element('section', { class: 'notes-agent-guide-rule' },
2224
2034
  element('span', { class: 'notes-agent-guide-index', 'aria-hidden': 'true' }, '01'),
2225
2035
  element('div', {},
2226
- element('h3', {}, '在当前消息里明确授权'),
2227
- element('p', {}, '写清动作、对象和位置,并明确使用“笔记文档”“笔记目录”或“笔记工作区”。为了避免误写,不能只依赖上一轮说过的授权。'))),
2036
+ element('h3', {}, '在当前消息里指定笔记文档'),
2037
+ element('p', {}, '只要当前消息明确提到“笔记文档”“笔记目录”或“笔记工作区”,会话就可以按你的要求查看和维护;无需套用固定句式。为了避免误操作,删除仍需明确说出删除,授权也不会从上一轮延续。'))),
2228
2038
  element('section', { class: 'notes-agent-guide-rule' },
2229
2039
  element('span', { class: 'notes-agent-guide-index', 'aria-hidden': 'true' }, '02'),
2230
2040
  element('div', {},
@@ -2234,7 +2044,7 @@ function openNoteAgentGuide() {
2234
2044
  element('section', { class: 'notes-agent-examples' },
2235
2045
  element('h3', {}, '可以直接这样说'),
2236
2046
  element('ul', {}, examples)),
2237
- element('p', { class: 'notes-agent-guide-note' }, '只说“新建 Markdown”或“创建本地目录”不会获得笔记写入权限;请明确指出这是笔记工作区中的文档或目录。'))
2047
+ element('p', { class: 'notes-agent-guide-note' }, '只说“新建 Markdown”或“创建本地目录”仍不会获得笔记权限;在当前消息中带上“笔记文档”即可。'))
2238
2048
  return openSheet({
2239
2049
  title: '让会话整理笔记',
2240
2050
  description: '会话可以操作笔记工作区,但每次写入都需要当前用户消息明确授权。',
@@ -2367,9 +2177,10 @@ function renderNoteFolderContent(folder) {
2367
2177
  renderNoteIcon(node, true),
2368
2178
  element('span', {}, element('strong', {}, node.name), element('small', {}, noteKindLabel(node))),
2369
2179
  ),
2370
- element('time', { datetime: node.updatedAt }, formatDate(node.updatedAt)),
2371
- element('span', { class: 'notes-file-size' }, node.kind === 'folder' ? '' : formatBytes(node.size)),
2180
+ element('time', { datetime: node.updatedAt, 'data-label': '更新' }, formatDate(node.updatedAt)),
2181
+ element('span', { class: 'notes-file-size', 'data-label': node.kind === 'folder' ? '类型' : '大小' }, node.kind === 'folder' ? '目录' : formatBytes(node.size)),
2372
2182
  element('div', { class: 'notes-file-actions' },
2183
+ node.kind !== 'folder' ? noteDownloadButton(node, 'ghost tiny') : null,
2373
2184
  actionButton('重命名', () => openRenameNoteNode(node), 'ghost tiny'),
2374
2185
  actionButton('复制', () => { void copyNoteNode(node) }, 'ghost tiny'),
2375
2186
  actionButton('删除', () => { void confirmDeleteNoteNode(node) }, 'ghost tiny danger-text'),
@@ -2412,10 +2223,7 @@ function renderEditableNote(node) {
2412
2223
  ? element('div', { class: 'notes-live-editor', role: 'status', 'aria-label': `正在打开 ${node.name}` },
2413
2224
  element('div', { class: 'notes-editor-loading' }, '正在打开文档…'))
2414
2225
  : createPlainTextNoteEditor(node)
2415
- if (markdown) mountMarkdownNoteEditor(editor, node)
2416
- else mountPlainTextNoteEditor(editor, node)
2417
- return element('main', { class: `notes-content is-document${markdown ? '' : ' has-line-numbers'}` },
2418
- renderNoteFileToolbar(node, { editable: true }),
2226
+ const scrollHost = element('div', { class: 'notes-document-scroll', 'data-scroll-key': `notes-document:${node.id}` },
2419
2227
  element('h1', {
2420
2228
  class: 'notes-document-title', contenteditable: 'plaintext-only', spellcheck: 'false',
2421
2229
  'aria-label': `修改 ${node.name} 的标题`, title: '点击修改标题',
@@ -2427,6 +2235,17 @@ function renderEditableNote(node) {
2427
2235
  }, title),
2428
2236
  editor,
2429
2237
  )
2238
+ const outlineHost = markdown ? element('aside', { id: 'dsh-note-outline', class: 'notes-editor-outline', 'aria-label': '文档大纲', 'aria-hidden': 'true' }) : null
2239
+ const editorFrame = markdown
2240
+ ? element('div', { class: 'notes-editor-frame', 'data-outline-open': 'false', 'data-find-open': 'false' }, scrollHost, outlineHost)
2241
+ : scrollHost
2242
+ if (markdown) mountMarkdownNoteEditor(editor, node, editorFrame, scrollHost, outlineHost)
2243
+ else mountPlainTextNoteEditor(editor, node)
2244
+ return element('main', { class: `notes-content is-document${markdown ? '' : ' has-line-numbers'}` },
2245
+ renderNoteFileToolbar(node, { editable: true, enhanced: markdown }),
2246
+ editorFrame,
2247
+ renderNoteFileStatusbar(node),
2248
+ )
2430
2249
  }
2431
2250
 
2432
2251
  function editableNoteTitle(node) {
@@ -2479,8 +2298,11 @@ function updateNoteDraft(value) {
2479
2298
  syncNoteEditorChrome()
2480
2299
  }
2481
2300
 
2482
- function mountMarkdownNoteEditor(host, node) {
2301
+ function mountMarkdownNoteEditor(host, node, frame, scrollHost, outlineHost) {
2483
2302
  mountMarkdownEditor(host, {
2303
+ frame,
2304
+ scrollHost,
2305
+ outlineHost,
2484
2306
  markdown: state.notes.draft,
2485
2307
  label: `编辑 ${node.name}`,
2486
2308
  isCurrent: () => state.notes.selectedNode?.id === node.id,
@@ -2526,6 +2348,13 @@ function mountMarkdownEditor(host, options) {
2526
2348
  host.removeAttribute('role')
2527
2349
  markdownEditorHandle = runtime.createMarkdownEditor({
2528
2350
  host,
2351
+ ...(options.frame ? {
2352
+ frame: options.frame,
2353
+ scrollHost: options.scrollHost,
2354
+ outlineHost: options.outlineHost,
2355
+ findButton: host.closest('.notes-content')?.querySelector('[data-note-find]') || null,
2356
+ outlineButton: host.closest('.notes-content')?.querySelector('[data-note-outline]') || null,
2357
+ } : {}),
2529
2358
  markdown: options.markdown,
2530
2359
  label: options.label,
2531
2360
  onChange: options.onChange,
@@ -2601,12 +2430,13 @@ function renderNoteFile(node) {
2601
2430
  renderNoteIcon(node, true),
2602
2431
  element('h2', {}, node.name),
2603
2432
  element('p', {}, '该格式不能在浏览器中直接编辑,可以下载后使用本地应用打开。'),
2604
- actionButton('下载文件', () => { void downloadNoteFile(node) }, 'primary small'),
2433
+ noteDownloadButton(node, 'primary small', '下载文件'),
2605
2434
  )
2606
2435
  }
2607
2436
  return element('main', { class: 'notes-content is-file' },
2608
2437
  renderNoteFileToolbar(node),
2609
2438
  element('section', { class: `notes-inline-viewer is-${previewKind}`, 'aria-label': `${node.name}内容` }, preview),
2439
+ renderNoteFileStatusbar(node),
2610
2440
  )
2611
2441
  }
2612
2442
 
@@ -2614,19 +2444,89 @@ function renderNoteFileToolbar(node, options = {}) {
2614
2444
  return element('header', { class: 'notes-document-toolbar' },
2615
2445
  element('div', { class: 'notes-toolbar-leading' },
2616
2446
  renderNoteDocumentBreadcrumb(node),
2617
- element('div', { class: 'notes-file-info', 'aria-label': '文件信息' },
2618
- noteInfoItem('编号', shortNoteId(node.id), node.id),
2619
- noteInfoItem('类型', node.kind === 'document' ? 'Markdown' : node.mediaType || '未知'),
2620
- noteInfoItem('大小', formatBytes(node.size), '', 'size'),
2621
- noteInfoItem('更新', formatDate(node.updatedAt), '', 'updated'),
2622
- ),
2623
2447
  ),
2624
2448
  element('div', { class: 'notes-document-actions' },
2625
2449
  options.editable ? element('span', { class: 'notes-save-state', role: 'status', 'data-note-save-state': '', 'data-dirty': String(state.notes.dirty) }, state.notes.dirty ? '未保存' : '已保存') : null,
2626
- node.kind === 'file' ? actionButton('下载', () => { void downloadNoteFile(node) }, 'ghost small') : null,
2627
- actionButton('复制引用', () => { void copyNoteReference(node) }, 'ghost small'),
2628
- actionButton('重命名', () => openRenameNoteNode(node), 'ghost small'),
2629
- options.editable ? actionButton('保存', () => { void saveNoteDocument() }, 'primary small', { 'data-note-save': '', disabled: !state.notes.dirty }) : null,
2450
+ options.enhanced ? actionButton('查找', () => markdownEditorHandle?.openFind(), 'ghost small', { 'data-note-find': '', 'data-note-mobile-overflow': '', 'aria-keyshortcuts': 'Control+F Meta+F', 'aria-pressed': 'false' }) : null,
2451
+ options.enhanced ? actionButton([
2452
+ interfaceIcon('outline', 'notes-toolbar-action-icon'),
2453
+ element('span', {}, '大纲'),
2454
+ ], () => markdownEditorHandle?.toggleOutline(), 'ghost small notes-outline-action', { 'data-note-outline': '', 'aria-label': '打开标题大纲', 'aria-controls': 'dsh-note-outline', 'aria-expanded': 'false', 'aria-pressed': 'false' }) : null,
2455
+ options.editable ? actionButton('历史', () => { void openNoteHistory(node) }, 'ghost small', { 'data-note-history': '', 'data-note-mobile-overflow': '' }) : null,
2456
+ noteDownloadButton(node, 'ghost small', '下载', { 'data-note-mobile-overflow': '' }),
2457
+ actionButton('复制引用', () => { void copyNoteReference(node) }, 'ghost small', { 'data-note-mobile-overflow': '' }),
2458
+ actionButton('重命名', () => openRenameNoteNode(node), 'ghost small', { 'data-note-mobile-overflow': '' }),
2459
+ options.editable ? actionButton('保存', () => { void saveNoteDocument() }, 'primary small', { 'data-note-save': '', 'data-note-mobile-overflow': '', disabled: !state.notes.dirty }) : null,
2460
+ renderNoteFileOverflowMenu(node, options),
2461
+ ),
2462
+ )
2463
+ }
2464
+
2465
+ function renderNoteFileOverflowMenu(node, options) {
2466
+ const details = element('details', {
2467
+ class: 'notes-document-more',
2468
+ onKeyDown: event => {
2469
+ if (event.key !== 'Escape') return
2470
+ event.preventDefault()
2471
+ details.open = false
2472
+ summary.focus()
2473
+ },
2474
+ onFocusOut: () => {
2475
+ window.setTimeout(() => {
2476
+ if (!details.contains(document.activeElement)) details.open = false
2477
+ }, 0)
2478
+ },
2479
+ })
2480
+ const summary = element('summary', { class: 'button ghost small', title: '更多操作', 'aria-label': `打开 ${node.name} 的更多操作` },
2481
+ interfaceIcon('more', 'notes-document-more-icon'),
2482
+ )
2483
+ details.addEventListener('toggle', () => {
2484
+ summary.setAttribute('aria-expanded', String(details.open))
2485
+ summary.setAttribute('aria-label', `${details.open ? '关闭' : '打开'} ${node.name} 的更多操作`)
2486
+ })
2487
+ const closeThen = action => event => {
2488
+ details.open = false
2489
+ action(event)
2490
+ }
2491
+ const menuItems = []
2492
+ if (options.editable && state.notes.dirty) {
2493
+ menuItems.push(noteMenuAction('保存修改', 'save', closeThen(() => { void saveNoteDocument() })))
2494
+ menuItems.push(noteMenuDivider())
2495
+ }
2496
+ if (options.enhanced) {
2497
+ menuItems.push(noteMenuAction('查找', 'search', closeThen(() => markdownEditorHandle?.openFind())))
2498
+ }
2499
+ if (options.editable) menuItems.push(noteMenuAction('页面历史', 'history', closeThen(() => { void openNoteHistory(node) })))
2500
+ if (options.enhanced || options.editable) menuItems.push(noteMenuDivider())
2501
+ menuItems.push(
2502
+ noteMenuAction('下载', 'download', closeThen(event => { void downloadNoteFile(node, event.currentTarget) })),
2503
+ noteMenuAction('复制引用', 'link', closeThen(() => { void copyNoteReference(node) })),
2504
+ noteMenuAction('重命名', 'rename', closeThen(() => openRenameNoteNode(node))),
2505
+ )
2506
+ const menu = element('div', { class: 'notes-document-more-menu', role: 'menu', 'aria-label': `${node.name} 的更多操作` }, menuItems)
2507
+ summary.setAttribute('aria-expanded', 'false')
2508
+ details.append(summary, menu)
2509
+ return details
2510
+ }
2511
+
2512
+ function noteMenuAction(label, icon, onClick, attributes = {}) {
2513
+ return actionButton([
2514
+ interfaceIcon(icon, 'notes-document-menu-icon'),
2515
+ element('span', { class: 'notes-document-menu-label' }, label),
2516
+ ], onClick, 'ghost small notes-document-menu-item', { role: 'menuitem', ...attributes })
2517
+ }
2518
+
2519
+ function noteMenuDivider() {
2520
+ return element('div', { class: 'notes-document-menu-divider', role: 'separator' })
2521
+ }
2522
+
2523
+ function renderNoteFileStatusbar(node) {
2524
+ return element('footer', { class: 'notes-document-statusbar', 'aria-label': '文档信息' },
2525
+ element('div', { class: 'notes-file-info' },
2526
+ noteInfoItem('编号', shortNoteId(node.id), node.id, 'id'),
2527
+ noteInfoItem('类型', node.kind === 'document' ? 'Markdown' : node.mediaType || '未知'),
2528
+ noteInfoItem('大小', formatBytes(node.size), '', 'size'),
2529
+ noteInfoItem('更新', formatDate(node.updatedAt), '', 'updated'),
2630
2530
  ),
2631
2531
  )
2632
2532
  }
@@ -3154,14 +3054,132 @@ async function saveNoteDocument() {
3154
3054
  }
3155
3055
  }
3156
3056
 
3157
- async function downloadNoteFile(node) {
3057
+ async function openNoteHistory(node) {
3058
+ if (state.notes.selectedNode?.id === node.id && state.notes.dirty && !await saveNoteDocument()) return
3059
+ try {
3060
+ const current = state.notes.selectedNode?.id === node.id ? state.notes.selectedNode : await api(`notes/${encodeURIComponent(node.id)}`)
3061
+ if (!current?.editable) throw new Error('只有可编辑的笔记文档支持页面历史。')
3062
+ const versions = await api(`notes/${encodeURIComponent(node.id)}/versions?limit=100`)
3063
+ if (!versions.length) {
3064
+ showToast('这个文档还没有可查看的保存记录。')
3065
+ return
3066
+ }
3067
+ let modal
3068
+ const view = window.DshKnowledgeNoteHistory.createNoteHistoryView({
3069
+ versions,
3070
+ currentVersion: current.version,
3071
+ currentContent: state.notes.selectedNode?.id === node.id ? state.notes.content : '',
3072
+ loadContent: async (version, signal) => {
3073
+ const blob = await binaryRequest(`notes/${encodeURIComponent(node.id)}/versions/${version.version}/content`, {
3074
+ responseType: 'blob', accept: version.mediaType || 'text/plain', signal,
3075
+ })
3076
+ return blob.text()
3077
+ },
3078
+ renderPreview: content => renderHistoricalNotePreview(content, isMarkdownNote(current)),
3079
+ renderDiff: renderNoteHistoryDiff,
3080
+ formatDate,
3081
+ formatBytes,
3082
+ onRestore: async (version, content) => {
3083
+ const updated = await api(`notes/${encodeURIComponent(node.id)}/versions/${version.version}/restore`, {
3084
+ method: 'POST', body: { expectedVersion: current.version },
3085
+ })
3086
+ if (state.notes.selectedNode?.id === node.id) {
3087
+ state.notes.selectedNode = updated
3088
+ state.notes.content = content
3089
+ state.notes.draft = content
3090
+ state.notes.dirty = false
3091
+ await loadNoteChildren(updated.parentId, true)
3092
+ }
3093
+ modal?.close(true)
3094
+ renderShell()
3095
+ showToast(`已将版本 ${version.version} 恢复为新的版本 ${updated.version}。`)
3096
+ },
3097
+ onError: error => showToast(friendlyError(error), 'error'),
3098
+ })
3099
+ modal = openModal({
3100
+ title: `${current.name} · 页面历史`,
3101
+ description: '每次内容保存都会形成不可变快照;恢复历史不会删除当前版本。',
3102
+ body: view.element,
3103
+ cancelLabel: '关闭',
3104
+ onClose: () => view.destroy(),
3105
+ })
3106
+ modal.dialog.classList.add('note-history-dialog')
3107
+ modal.dialog.classList.remove('narrow')
3108
+ } catch (error) {
3109
+ showToast(friendlyError(error), 'error')
3110
+ }
3111
+ }
3112
+
3113
+ function renderHistoricalNotePreview(content, markdown) {
3114
+ if (!markdown) return element('pre', { class: 'note-history-plain-preview', role: 'document' }, content || '(空文档)')
3115
+ const rendered = renderMarkdownPreview(content).cloneNode(true)
3116
+ rendered.classList.add('note-history-markdown-preview')
3117
+ rendered.querySelectorAll('a').forEach(link => {
3118
+ link.removeAttribute('href')
3119
+ link.removeAttribute('role')
3120
+ link.removeAttribute('tabindex')
3121
+ link.removeAttribute('title')
3122
+ })
3123
+ return rendered
3124
+ }
3125
+
3126
+ function renderNoteHistoryDiff(historical, current) {
3127
+ const diff = window.DshKnowledgeReview.createLineDiff(historical, current)
3128
+ const lines = window.DshKnowledgeReview.compactDiffLines(diff.lines, 3)
3129
+ return element('section', { class: 'note-history-diff', 'aria-label': '历史版本与当前版本的逐行差异' },
3130
+ element('div', { class: 'note-history-diff-summary' },
3131
+ element('strong', {}, '历史版本 → 当前版本'),
3132
+ element('div', { class: 'diff-summary', 'aria-label': `新增 ${diff.additions} 行,删除 ${diff.deletions} 行` },
3133
+ element('span', { class: 'diff-stat additions' }, `+${diff.additions}`),
3134
+ element('span', { class: 'diff-stat deletions' }, `-${diff.deletions}`),
3135
+ ),
3136
+ ),
3137
+ diff.simplified ? element('div', { class: 'diff-notice' }, '内容较长,已使用有界的简化差异视图。') : null,
3138
+ element('div', { class: 'diff-viewer', role: 'table' },
3139
+ element('div', { class: 'diff-column-headings', role: 'row' },
3140
+ element('span', { role: 'columnheader' }, '旧'),
3141
+ element('span', { role: 'columnheader' }, '新'),
3142
+ element('span', { 'aria-hidden': 'true' }),
3143
+ element('span', { role: 'columnheader' }, '正文'),
3144
+ ),
3145
+ lines.length ? lines.map(renderDiffLine) : element('div', { class: 'diff-empty' }, '所选版本与当前内容相同。'),
3146
+ ),
3147
+ )
3148
+ }
3149
+
3150
+ function noteDownloadButton(node, variant = 'ghost small', label = '下载', attributes = {}) {
3151
+ return actionButton(label, event => { void downloadNoteFile(node, event.currentTarget) }, variant, {
3152
+ 'aria-label': `下载 ${node.name}`,
3153
+ title: `下载 ${node.name}`,
3154
+ ...attributes,
3155
+ })
3156
+ }
3157
+
3158
+ async function downloadNoteFile(node, button) {
3159
+ const originalLabel = button?.textContent || ''
3160
+ if (button) {
3161
+ button.disabled = true
3162
+ button.setAttribute('aria-busy', 'true')
3163
+ button.textContent = '下载中'
3164
+ }
3158
3165
  try {
3159
- const blob = await binaryRequest(`notes/${encodeURIComponent(node.id)}/content?download=1`, { responseType: 'blob', accept: node.mediaType || 'application/octet-stream' })
3166
+ if (state.notes.selectedNode?.id === node.id && state.notes.dirty && !await saveNoteDocument()) return
3167
+ const current = state.notes.selectedNode?.id === node.id ? state.notes.selectedNode : node
3168
+ const blob = await binaryRequest(`notes/${encodeURIComponent(current.id)}/content?download=1`, { responseType: 'blob', accept: current.mediaType || 'application/octet-stream' })
3160
3169
  const url = URL.createObjectURL(blob)
3161
- const anchor = element('a', { href: url, download: node.name })
3170
+ const anchor = element('a', { href: url, download: current.name })
3162
3171
  document.body.append(anchor); anchor.click(); anchor.remove()
3163
3172
  window.setTimeout(() => URL.revokeObjectURL(url), 1000)
3164
- } catch (error) { showToast(friendlyError(error), 'error') }
3173
+ showToast(`已下载 ${current.name}。`)
3174
+ } catch (error) {
3175
+ showToast(`下载失败:${friendlyError(error)}`, 'error')
3176
+ } finally {
3177
+ if (button?.isConnected) {
3178
+ button.disabled = false
3179
+ button.removeAttribute('aria-busy')
3180
+ button.textContent = originalLabel
3181
+ }
3182
+ }
3165
3183
  }
3166
3184
 
3167
3185
  async function copyNoteReference(node) {
@@ -0,0 +1,63 @@
1
+ const HOST_THEME_MESSAGE = '@lemoncat7/dsh-knowledge/host-theme'
2
+ const HOST_THEME_READY_MESSAGE = '@lemoncat7/dsh-knowledge/host-theme-ready'
3
+ const HOST_THEME_PROTOCOL_VERSION = 1
4
+ const COLOR_TOKENS = new Set([
5
+ '--bg', '--surface', '--surface-raised', '--surface-soft', '--surface-hover', '--dialog-surface',
6
+ '--text', '--text-secondary', '--text-tertiary', '--border', '--border-strong',
7
+ '--accent', '--accent-hover', '--accent-soft', '--on-accent',
8
+ '--success', '--success-soft', '--warning', '--warning-soft', '--danger', '--danger-soft',
9
+ ])
10
+ const STYLE_TOKENS = new Set(['--shadow'])
11
+
12
+ function referrerOrigin() {
13
+ if (!document.referrer) return ''
14
+ try {
15
+ const origin = new URL(document.referrer).origin
16
+ return origin === 'null' ? '' : origin
17
+ } catch {
18
+ return ''
19
+ }
20
+ }
21
+
22
+ /** Synchronise only the host colour scheme and the explicitly allowed tokens. */
23
+ export function installHostThemeBridge() {
24
+ if (window.parent === window) return Promise.resolve()
25
+ const parentOrigin = referrerOrigin()
26
+ return new Promise(resolve => {
27
+ let settled = false
28
+ const settle = () => {
29
+ if (settled) return
30
+ settled = true
31
+ window.clearTimeout(fallback)
32
+ resolve()
33
+ }
34
+ const fallback = window.setTimeout(settle, 160)
35
+ window.addEventListener('message', event => {
36
+ if (event.source !== window.parent || (parentOrigin && event.origin !== parentOrigin)) return
37
+ const message = event.data
38
+ if (!message || message.type !== HOST_THEME_MESSAGE || message.version !== HOST_THEME_PROTOCOL_VERSION) return
39
+ if (message.colorScheme !== 'light' && message.colorScheme !== 'dark') return
40
+ if (!message.tokens || typeof message.tokens !== 'object' || Array.isArray(message.tokens)) return
41
+
42
+ const root = document.documentElement
43
+ for (const name of [...COLOR_TOKENS, ...STYLE_TOKENS]) root.style.removeProperty(name)
44
+ for (const [name, value] of Object.entries(message.tokens)) {
45
+ if (typeof value !== 'string' || value.length === 0 || value.length > 512) continue
46
+ if (COLOR_TOKENS.has(name) && CSS.supports('color', value)) root.style.setProperty(name, value)
47
+ else if (STYLE_TOKENS.has(name) && CSS.supports('box-shadow', value)) root.style.setProperty(name, value)
48
+ }
49
+ root.dataset.dshHostTheme = 'true'
50
+ root.dataset.colorScheme = message.colorScheme
51
+ root.style.colorScheme = message.colorScheme
52
+ document.querySelector('meta[name="color-scheme"]')?.setAttribute('content', message.colorScheme)
53
+ const background = root.style.getPropertyValue('--bg')
54
+ if (background) document.querySelector('meta[name="theme-color"]')?.setAttribute('content', background)
55
+ settle()
56
+ })
57
+ window.parent.postMessage({
58
+ type: HOST_THEME_READY_MESSAGE,
59
+ version: HOST_THEME_PROTOCOL_VERSION,
60
+ }, parentOrigin || '*')
61
+ })
62
+ }
63
+