@lemoncat7/dsh-knowledge 1.2.0 → 2.0.0
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.
- package/README.md +22 -2
- package/docs/architecture.md +30 -4
- package/docs/requirements.md +10 -0
- package/lib/api.d.ts.map +1 -1
- package/lib/api.js +194 -1
- package/lib/api.js.map +1 -1
- package/lib/atomic-file.d.ts +1 -1
- package/lib/atomic-file.d.ts.map +1 -1
- package/lib/atomic-file.js +0 -1
- package/lib/atomic-file.js.map +1 -1
- package/lib/client.d.ts.map +1 -1
- package/lib/client.js +37 -34
- package/lib/client.js.map +2 -2
- package/lib/content-safety.d.ts +12 -0
- package/lib/content-safety.d.ts.map +1 -0
- package/lib/content-safety.js +38 -0
- package/lib/content-safety.js.map +1 -0
- package/lib/domain.d.ts +13 -0
- package/lib/domain.d.ts.map +1 -1
- package/lib/domain.js.map +1 -1
- package/lib/extraction.d.ts.map +1 -1
- package/lib/extraction.js +8 -2
- package/lib/extraction.js.map +1 -1
- package/lib/index.d.ts +1 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +6 -1
- package/lib/index.js.map +1 -1
- package/lib/local-provider.d.ts +24 -1
- package/lib/local-provider.d.ts.map +1 -1
- package/lib/local-provider.js +302 -2
- package/lib/local-provider.js.map +1 -1
- package/lib/management-proxy.d.ts.map +1 -1
- package/lib/management-proxy.js +22 -13
- package/lib/management-proxy.js.map +1 -1
- package/lib/note-reference-handle.d.ts +15 -0
- package/lib/note-reference-handle.d.ts.map +1 -0
- package/lib/note-reference-handle.js +54 -0
- package/lib/note-reference-handle.js.map +1 -0
- package/lib/note-reference-tools.d.ts +6 -0
- package/lib/note-reference-tools.d.ts.map +1 -0
- package/lib/note-reference-tools.js +119 -0
- package/lib/note-reference-tools.js.map +1 -0
- package/lib/note-tools.d.ts +6 -0
- package/lib/note-tools.d.ts.map +1 -0
- package/lib/note-tools.js +283 -0
- package/lib/note-tools.js.map +1 -0
- package/lib/notes/domain.d.ts +44 -0
- package/lib/notes/domain.d.ts.map +1 -0
- package/lib/notes/domain.js +38 -0
- package/lib/notes/domain.js.map +1 -0
- package/lib/notes/store.d.ts +39 -0
- package/lib/notes/store.d.ts.map +1 -0
- package/lib/notes/store.js +407 -0
- package/lib/notes/store.js.map +1 -0
- package/lib/provider.d.ts +17 -0
- package/lib/provider.d.ts.map +1 -1
- package/lib/remote-provider.d.ts +19 -0
- package/lib/remote-provider.d.ts.map +1 -1
- package/lib/remote-provider.js +139 -13
- package/lib/remote-provider.js.map +1 -1
- package/lib/runtime.d.ts +1 -0
- package/lib/runtime.d.ts.map +1 -1
- package/lib/runtime.js.map +1 -1
- package/lib/theme-bridge.js +12 -12
- package/lib/theme-bridge.js.map +1 -1
- package/lib/tool-authorization.d.ts +14 -0
- package/lib/tool-authorization.d.ts.map +1 -0
- package/lib/tool-authorization.js +124 -0
- package/lib/tool-authorization.js.map +1 -0
- package/lib/tool-input.d.ts +6 -0
- package/lib/tool-input.d.ts.map +1 -0
- package/lib/tool-input.js +27 -0
- package/lib/tool-input.js.map +1 -0
- package/lib/tools.d.ts +2 -1
- package/lib/tools.d.ts.map +1 -1
- package/lib/tools.js +29 -179
- package/lib/tools.js.map +1 -1
- package/lib/tracking.d.ts +29 -0
- package/lib/tracking.d.ts.map +1 -0
- package/lib/tracking.js +141 -0
- package/lib/tracking.js.map +1 -0
- package/lib/web-markdown-preview.d.ts.map +1 -1
- package/lib/web-markdown-preview.js +10 -0
- package/lib/web-markdown-preview.js.map +1 -1
- package/lib/web-note-editor.d.ts +44 -0
- package/lib/web-note-editor.d.ts.map +1 -0
- package/lib/web-note-editor.js +118 -0
- package/lib/web-note-editor.js.map +1 -0
- package/lib/web.d.ts.map +1 -1
- package/lib/web.js +2 -1
- package/lib/web.js.map +1 -1
- package/package.json +12 -1
- package/web/app.js +1617 -89
- package/web/index.html +1 -0
- package/web/markdown-preview.js +8 -8
- package/web/note-editor.js +220 -0
- package/web/styles.css +512 -160
package/web/app.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
const API_BASE = document.querySelector('meta[name="dsh-knowledge-api"]')?.content || '/knowledge-api/v1'
|
|
2
2
|
const AUTH_MODE = document.querySelector('meta[name="dsh-knowledge-auth-mode"]')?.content || 'bearer'
|
|
3
|
+
const WEB_PATH = document.querySelector('meta[name="dsh-knowledge-web"]')?.content || '/knowledge'
|
|
4
|
+
const ASSET_VERSION = document.querySelector('meta[name="dsh-knowledge-asset-version"]')?.content || ''
|
|
3
5
|
const HOST_THEME_MESSAGE = '@lemoncat7/dsh-knowledge/host-theme'
|
|
4
6
|
const HOST_THEME_READY_MESSAGE = '@lemoncat7/dsh-knowledge/host-theme-ready'
|
|
5
7
|
const HOST_THEME_PROTOCOL_VERSION = 1
|
|
@@ -20,6 +22,7 @@ const WRITE_MODE_LABELS = { none: '仅召回', audit: '审核写入', direct: '
|
|
|
20
22
|
const EVIDENCE_LABELS = { explicit: '用户明确', verified: '结果已验证', inferred: '模型推断' }
|
|
21
23
|
const DOCUMENT_STATE_LABELS = { open: '进行中', resolved: '已解决', complete: '已收集完成' }
|
|
22
24
|
const DOCUMENT_LAYOUT_KEY = 'dsh-knowledge.document-layout'
|
|
25
|
+
const NOTE_MAX_FILE_SIZE = 64 * 1024 * 1024
|
|
23
26
|
const pageParams = new URLSearchParams(location.search)
|
|
24
27
|
const mountContext = {
|
|
25
28
|
sessionId: pageParams.get('sessionId')?.trim() || '',
|
|
@@ -32,7 +35,9 @@ const savedDocumentLayout = readDocumentLayout()
|
|
|
32
35
|
function createDocumentViewState(overrides = {}) {
|
|
33
36
|
return {
|
|
34
37
|
knowledgeBaseId: '', documentId: '', query: '', mode: 'preview', treeOpen: false,
|
|
35
|
-
expandedBases: new Set(),
|
|
38
|
+
expandedBases: new Set(), documentPages: new Map(),
|
|
39
|
+
searchResults: [], searchNextCursor: '', searchTotal: 0, searchLoading: false, searchError: '', searchRequest: 0,
|
|
40
|
+
editor: null, editorLoading: false, loadingDocumentId: '', ...overrides,
|
|
36
41
|
}
|
|
37
42
|
}
|
|
38
43
|
|
|
@@ -75,9 +80,17 @@ const state = {
|
|
|
75
80
|
modelCatalog: null,
|
|
76
81
|
settingsSaving: false,
|
|
77
82
|
tokens: [],
|
|
83
|
+
notes: {
|
|
84
|
+
children: new Map(), loadedFolders: new Set(), expandedFolders: new Set(),
|
|
85
|
+
selectedId: '', selectedNode: null, currentFolderId: null, breadcrumbs: [],
|
|
86
|
+
content: '', draft: '', dirty: false, assetUrl: '', query: '', searchResults: [],
|
|
87
|
+
transfer: null, loadingNodeId: '',
|
|
88
|
+
},
|
|
78
89
|
service: { publicApiEnabled: false, publicApiPrefix: '/knowledge-api/v1', remote: false },
|
|
79
90
|
scrollPositions: new Map(),
|
|
80
91
|
loading: false,
|
|
92
|
+
loadingPhase: '',
|
|
93
|
+
loadingProgress: 0,
|
|
81
94
|
error: '',
|
|
82
95
|
}
|
|
83
96
|
|
|
@@ -86,6 +99,16 @@ let navigationController = null
|
|
|
86
99
|
let navigationRequest = 0
|
|
87
100
|
let navigationSave = Promise.resolve(true)
|
|
88
101
|
let libraryDetailRequest = 0
|
|
102
|
+
let documentSearchTimer = 0
|
|
103
|
+
let noteSearchTimer = 0
|
|
104
|
+
let markdownEditorHandle = null
|
|
105
|
+
let noteEditorLoader = null
|
|
106
|
+
let markdownEditorMountRequest = 0
|
|
107
|
+
let plainTextEditorHandle = null
|
|
108
|
+
let plainTextEditorMountRequest = 0
|
|
109
|
+
let noteTransferFrame = 0
|
|
110
|
+
let noteTransferSequence = 0
|
|
111
|
+
let noteSelectionRequest = 0
|
|
89
112
|
|
|
90
113
|
function installHostThemeBridge() {
|
|
91
114
|
if (window.parent === window) return Promise.resolve()
|
|
@@ -277,11 +300,83 @@ async function api(path, options = {}) {
|
|
|
277
300
|
return payload
|
|
278
301
|
}
|
|
279
302
|
|
|
303
|
+
async function binaryRequest(path, options = {}) {
|
|
304
|
+
const headers = { accept: options.accept || 'application/json' }
|
|
305
|
+
if (AUTH_MODE === 'same-origin') headers['x-dsh-knowledge-client'] = 'management-web'
|
|
306
|
+
if (state.token) headers.authorization = `Bearer ${state.token}`
|
|
307
|
+
if (options.contentType) headers['content-type'] = options.contentType
|
|
308
|
+
const response = await fetch(`${API_BASE}/${path.replace(/^\/+/, '')}`, {
|
|
309
|
+
method: options.method || 'GET',
|
|
310
|
+
headers,
|
|
311
|
+
body: options.body,
|
|
312
|
+
signal: options.signal,
|
|
313
|
+
})
|
|
314
|
+
if (options.responseType === 'blob') {
|
|
315
|
+
if (!response.ok) throw await responseError(response)
|
|
316
|
+
return response.blob()
|
|
317
|
+
}
|
|
318
|
+
const text = await response.text()
|
|
319
|
+
let payload
|
|
320
|
+
if (text) {
|
|
321
|
+
try { payload = JSON.parse(text) } catch { throw new Error('服务返回了无法识别的数据') }
|
|
322
|
+
}
|
|
323
|
+
if (!response.ok) {
|
|
324
|
+
const error = new Error(payload?.error || `请求失败(HTTP ${response.status})`)
|
|
325
|
+
error.status = response.status
|
|
326
|
+
throw error
|
|
327
|
+
}
|
|
328
|
+
return payload
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function binaryUploadRequest(path, body, options = {}) {
|
|
332
|
+
return new Promise((resolve, reject) => {
|
|
333
|
+
const request = new XMLHttpRequest()
|
|
334
|
+
request.open(options.method || 'POST', `${API_BASE}/${path.replace(/^\/+/, '')}`)
|
|
335
|
+
request.responseType = 'text'
|
|
336
|
+
request.setRequestHeader('accept', 'application/json')
|
|
337
|
+
if (AUTH_MODE === 'same-origin') request.setRequestHeader('x-dsh-knowledge-client', 'management-web')
|
|
338
|
+
if (state.token) request.setRequestHeader('authorization', `Bearer ${state.token}`)
|
|
339
|
+
if (options.contentType) request.setRequestHeader('content-type', options.contentType)
|
|
340
|
+
request.upload.addEventListener('progress', event => {
|
|
341
|
+
if (event.lengthComputable) options.onProgress?.(event.loaded, event.total)
|
|
342
|
+
})
|
|
343
|
+
request.addEventListener('load', () => {
|
|
344
|
+
let payload
|
|
345
|
+
if (request.responseText) {
|
|
346
|
+
try { payload = JSON.parse(request.responseText) }
|
|
347
|
+
catch { return reject(new Error('服务返回了无法识别的数据')) }
|
|
348
|
+
}
|
|
349
|
+
if (request.status < 200 || request.status >= 300) {
|
|
350
|
+
const error = new Error(payload?.error || `请求失败(HTTP ${request.status})`)
|
|
351
|
+
error.status = request.status
|
|
352
|
+
reject(error)
|
|
353
|
+
return
|
|
354
|
+
}
|
|
355
|
+
resolve(payload)
|
|
356
|
+
})
|
|
357
|
+
request.addEventListener('error', () => reject(new Error('上传连接中断,请检查网络后重试')))
|
|
358
|
+
request.addEventListener('abort', () => reject(new DOMException('上传已取消', 'AbortError')))
|
|
359
|
+
request.send(body)
|
|
360
|
+
})
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async function responseError(response) {
|
|
364
|
+
let message = `请求失败(HTTP ${response.status})`
|
|
365
|
+
try {
|
|
366
|
+
const payload = await response.json()
|
|
367
|
+
if (payload?.error) message = payload.error
|
|
368
|
+
} catch {}
|
|
369
|
+
return Object.assign(new Error(message), { status: response.status })
|
|
370
|
+
}
|
|
371
|
+
|
|
280
372
|
async function boot() {
|
|
281
373
|
if (AUTH_MODE === 'same-origin') {
|
|
282
374
|
state.token = ''
|
|
283
|
-
|
|
284
|
-
|
|
375
|
+
await Promise.all([
|
|
376
|
+
api('service').then(service => { state.service = service }).catch(() => {}),
|
|
377
|
+
navigate('entries'),
|
|
378
|
+
])
|
|
379
|
+
renderShell()
|
|
285
380
|
return
|
|
286
381
|
}
|
|
287
382
|
if (!state.token) {
|
|
@@ -342,15 +437,18 @@ function renderLogin(message = '') {
|
|
|
342
437
|
}
|
|
343
438
|
|
|
344
439
|
function signOut() {
|
|
440
|
+
releaseNoteEditors()
|
|
441
|
+
releaseNoteAsset()
|
|
345
442
|
sessionStorage.removeItem(TOKEN_KEY)
|
|
346
443
|
Object.assign(state, { token: '', stats: null, overview: null, knowledgeBases: [], mounts: [], resolvedMounts: [], entries: [], documents: [], candidates: [], candidateTargets: new Map(), settings: { writebackPolicy: 'conservative', updatedAt: '' }, tokens: [] })
|
|
444
|
+
state.notes = { children: new Map(), loadedFolders: new Set(), expandedFolders: new Set(), selectedId: '', selectedNode: null, currentFolderId: null, breadcrumbs: [], content: '', draft: '', dirty: false, assetUrl: '', query: '', searchResults: [], transfer: null, loadingNodeId: '' }
|
|
347
445
|
if (AUTH_MODE === 'same-origin') void boot()
|
|
348
446
|
else renderLogin()
|
|
349
447
|
}
|
|
350
448
|
|
|
351
449
|
async function navigate(view) {
|
|
352
450
|
const request = ++navigationRequest
|
|
353
|
-
navigationSave = navigationSave.then(() =>
|
|
451
|
+
navigationSave = navigationSave.then(() => saveBeforeNavigation(), () => saveBeforeNavigation())
|
|
354
452
|
if (!await navigationSave || request !== navigationRequest) return
|
|
355
453
|
navigationController?.abort()
|
|
356
454
|
const controller = new AbortController()
|
|
@@ -362,13 +460,16 @@ async function navigate(view) {
|
|
|
362
460
|
state.view = view
|
|
363
461
|
state.menuOpen = false
|
|
364
462
|
state.loading = true
|
|
463
|
+
state.loadingPhase = loadingPhaseForView(view)
|
|
464
|
+
state.loadingProgress = .12
|
|
365
465
|
state.error = ''
|
|
366
466
|
renderShell()
|
|
367
467
|
try {
|
|
368
468
|
if (view === 'overview') await loadOverview(controller.signal)
|
|
369
469
|
if (view === 'bases') await loadKnowledgeBasesPage(controller.signal)
|
|
370
|
-
if (view === 'entries') await loadDocuments(controller.signal)
|
|
470
|
+
if (view === 'entries') await loadDocuments(controller.signal, (label, progress) => updateLoadingPhase(request, label, progress))
|
|
371
471
|
if (view === 'candidates') await loadCandidates(controller.signal)
|
|
472
|
+
if (view === 'notes') await loadNotes(controller.signal, (label, progress) => updateLoadingPhase(request, label, progress))
|
|
372
473
|
if (view === 'tokens') await loadTokens(controller.signal)
|
|
373
474
|
} catch (error) {
|
|
374
475
|
if (controller.signal.aborted) return
|
|
@@ -377,10 +478,31 @@ async function navigate(view) {
|
|
|
377
478
|
} finally {
|
|
378
479
|
if (navigationController !== controller) return
|
|
379
480
|
state.loading = false
|
|
481
|
+
state.loadingPhase = ''
|
|
482
|
+
state.loadingProgress = 1
|
|
380
483
|
renderShell()
|
|
381
484
|
}
|
|
382
485
|
}
|
|
383
486
|
|
|
487
|
+
function loadingPhaseForView(view) {
|
|
488
|
+
return ({
|
|
489
|
+
overview: '正在汇总知识活动', bases: '正在读取知识库配置', entries: '正在读取知识目录',
|
|
490
|
+
notes: '正在读取笔记目录', candidates: '正在准备审核队列', tokens: '正在读取访问权限',
|
|
491
|
+
})[view] || '正在准备工作区'
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function updateLoadingPhase(request, label, progress) {
|
|
495
|
+
if (request !== navigationRequest || !state.loading) return
|
|
496
|
+
state.loadingPhase = label
|
|
497
|
+
state.loadingProgress = Math.max(0, Math.min(1, progress))
|
|
498
|
+
renderShell()
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
async function saveBeforeNavigation() {
|
|
502
|
+
if (state.view === 'notes' && state.notes.dirty) return saveNoteDocument()
|
|
503
|
+
return saveBeforeLeavingDocument()
|
|
504
|
+
}
|
|
505
|
+
|
|
384
506
|
async function saveBeforeLeavingDocument() {
|
|
385
507
|
const workspace = activeDocumentWorkspace()
|
|
386
508
|
const editor = workspace?.view.editor
|
|
@@ -457,19 +579,26 @@ async function loadEntries(cursor = '') {
|
|
|
457
579
|
if (!state.stats) await refreshStats()
|
|
458
580
|
}
|
|
459
581
|
|
|
460
|
-
async function loadDocuments(signal) {
|
|
582
|
+
async function loadDocuments(signal, onPhase = () => {}) {
|
|
583
|
+
onPhase('正在读取可用知识库', .24)
|
|
461
584
|
const options = { signal }
|
|
462
|
-
const requests = [api('knowledge-bases', options)
|
|
585
|
+
const requests = [api('knowledge-bases', options)]
|
|
463
586
|
if (state.mountContext.sessionId) {
|
|
464
587
|
const params = new URLSearchParams({ sessionId: state.mountContext.sessionId })
|
|
465
588
|
if (state.mountContext.projectId) params.set('projectId', state.mountContext.projectId)
|
|
466
589
|
requests.push(api(`mounts/resolve?${params}`, options))
|
|
467
590
|
}
|
|
468
|
-
const [
|
|
591
|
+
const [results, stats] = await Promise.all([
|
|
592
|
+
Promise.all(requests),
|
|
593
|
+
state.stats ? Promise.resolve(state.stats) : api('stats', options),
|
|
594
|
+
])
|
|
595
|
+
const [bases, resolved = []] = results
|
|
469
596
|
state.knowledgeBases = bases
|
|
470
597
|
state.resolvedMounts = resolved
|
|
598
|
+
state.stats = stats
|
|
599
|
+
onPhase('正在建立文档索引', .56)
|
|
471
600
|
const visibleBaseIds = new Set(documentKnowledgeBases(bases).map(base => base.id))
|
|
472
|
-
state.documents = documents.filter(document => visibleBaseIds.has(document.knowledgeBaseId))
|
|
601
|
+
state.documents = state.documents.filter(document => visibleBaseIds.has(document.knowledgeBaseId))
|
|
473
602
|
const workspace = sessionDocumentWorkspace()
|
|
474
603
|
const view = workspace.view
|
|
475
604
|
const availableBaseIds = visibleBaseIds
|
|
@@ -478,10 +607,30 @@ async function loadDocuments(signal) {
|
|
|
478
607
|
? state.entryFilters.knowledgeBaseId
|
|
479
608
|
: documentKnowledgeBases(bases)[0]?.id || ''
|
|
480
609
|
}
|
|
610
|
+
if (view.knowledgeBaseId) {
|
|
611
|
+
view.expandedBases.add(view.knowledgeBaseId)
|
|
612
|
+
await loadDocumentPage(workspace, view.knowledgeBaseId, { signal })
|
|
613
|
+
}
|
|
481
614
|
selectDefaultDocument(workspace)
|
|
482
|
-
if (view.documentId)
|
|
615
|
+
if (view.documentId) {
|
|
616
|
+
onPhase('正在打开最近文档', .82)
|
|
617
|
+
await loadDocumentEditor(workspace, view.documentId, signal)
|
|
618
|
+
}
|
|
483
619
|
else view.editor = null
|
|
484
|
-
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
async function loadNotes(signal, onPhase = () => {}) {
|
|
623
|
+
onPhase('正在读取顶层目录', .36)
|
|
624
|
+
state.notes.children.set('root', await api('notes?limit=500', { signal }))
|
|
625
|
+
state.notes.loadedFolders.add('root')
|
|
626
|
+
if (state.notes.selectedId) {
|
|
627
|
+
onPhase('正在恢复上次打开的笔记', .74)
|
|
628
|
+
const selected = state.notes.selectedNode?.id === state.notes.selectedId
|
|
629
|
+
? state.notes.selectedNode
|
|
630
|
+
: await api(`notes/${encodeURIComponent(state.notes.selectedId)}`, { signal }).catch(() => null)
|
|
631
|
+
if (!selected) clearNoteSelection()
|
|
632
|
+
else state.notes.selectedNode = selected
|
|
633
|
+
}
|
|
485
634
|
}
|
|
486
635
|
|
|
487
636
|
function documentKnowledgeBases(bases = state.knowledgeBases) {
|
|
@@ -514,6 +663,139 @@ function setDocumentWorkspaceDocuments(workspace, documents) {
|
|
|
514
663
|
else state.documents = documents
|
|
515
664
|
}
|
|
516
665
|
|
|
666
|
+
function documentPageState(workspace, knowledgeBaseId) {
|
|
667
|
+
const pages = workspace.view.documentPages
|
|
668
|
+
if (!pages.has(knowledgeBaseId)) {
|
|
669
|
+
pages.set(knowledgeBaseId, { loaded: false, loading: false, nextCursor: '', total: 0, error: '' })
|
|
670
|
+
}
|
|
671
|
+
return pages.get(knowledgeBaseId)
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function mergeDocumentSummaries(workspace, items, knowledgeBaseId, reset) {
|
|
675
|
+
const current = documentWorkspaceDocuments(workspace)
|
|
676
|
+
const retained = reset ? current.filter(document => document.knowledgeBaseId !== knowledgeBaseId) : current
|
|
677
|
+
const merged = new Map(retained.map(document => [document.id, document]))
|
|
678
|
+
for (const item of items) merged.set(item.id, item)
|
|
679
|
+
setDocumentWorkspaceDocuments(workspace, [...merged.values()])
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
async function loadDocumentPage(workspace, knowledgeBaseId, options = {}) {
|
|
683
|
+
if (!knowledgeBaseId) return
|
|
684
|
+
const page = documentPageState(workspace, knowledgeBaseId)
|
|
685
|
+
if (page.loading || (!options.reset && !options.append && page.loaded)) return
|
|
686
|
+
if (options.append && !page.nextCursor) return
|
|
687
|
+
page.loading = true
|
|
688
|
+
page.error = ''
|
|
689
|
+
const cursor = options.append ? page.nextCursor : ''
|
|
690
|
+
try {
|
|
691
|
+
const params = new URLSearchParams({ knowledgeBaseId, limit: '60' })
|
|
692
|
+
if (cursor) params.set('cursor', cursor)
|
|
693
|
+
const result = await api(`document-index?${params}`, { signal: options.signal })
|
|
694
|
+
mergeDocumentSummaries(workspace, result.items, knowledgeBaseId, !options.append)
|
|
695
|
+
page.loaded = true
|
|
696
|
+
page.nextCursor = result.nextCursor || ''
|
|
697
|
+
page.total = result.total
|
|
698
|
+
} catch (error) {
|
|
699
|
+
if (error.name === 'AbortError') throw error
|
|
700
|
+
page.error = friendlyError(error)
|
|
701
|
+
if (options.throwOnError) throw error
|
|
702
|
+
} finally {
|
|
703
|
+
page.loading = false
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
async function toggleDocumentBase(workspace, baseId) {
|
|
708
|
+
const view = workspace.view
|
|
709
|
+
view.knowledgeBaseId = baseId
|
|
710
|
+
if (view.expandedBases.has(baseId)) {
|
|
711
|
+
view.expandedBases.delete(baseId)
|
|
712
|
+
renderShell()
|
|
713
|
+
return
|
|
714
|
+
}
|
|
715
|
+
view.expandedBases.add(baseId)
|
|
716
|
+
const page = documentPageState(workspace, baseId)
|
|
717
|
+
if (!page.loaded) page.loading = true
|
|
718
|
+
renderShell()
|
|
719
|
+
if (!page.loaded) {
|
|
720
|
+
page.loading = false
|
|
721
|
+
await loadDocumentPage(workspace, baseId)
|
|
722
|
+
renderShell()
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function resetDocumentSearch(view) {
|
|
727
|
+
view.searchRequest += 1
|
|
728
|
+
view.searchResults = []
|
|
729
|
+
view.searchNextCursor = ''
|
|
730
|
+
view.searchTotal = 0
|
|
731
|
+
view.searchLoading = false
|
|
732
|
+
view.searchError = ''
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function scheduleDocumentSearch(workspace) {
|
|
736
|
+
window.clearTimeout(documentSearchTimer)
|
|
737
|
+
const view = workspace.view
|
|
738
|
+
if (!view.query.trim()) {
|
|
739
|
+
resetDocumentSearch(view)
|
|
740
|
+
renderShell()
|
|
741
|
+
return
|
|
742
|
+
}
|
|
743
|
+
view.searchLoading = true
|
|
744
|
+
view.searchError = ''
|
|
745
|
+
renderShell()
|
|
746
|
+
documentSearchTimer = window.setTimeout(() => { void loadDocumentSearch(workspace, true) }, 220)
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function renderDocumentSearchState(workspace) {
|
|
750
|
+
renderShell()
|
|
751
|
+
window.requestAnimationFrame(() => {
|
|
752
|
+
const input = document.querySelector(`.note-tree-search[data-document-scope="${workspace.kind}"]`)
|
|
753
|
+
input?.focus()
|
|
754
|
+
input?.setSelectionRange(input.value.length, input.value.length)
|
|
755
|
+
})
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
async function loadDocumentSearch(workspace, reset = false) {
|
|
759
|
+
const view = workspace.view
|
|
760
|
+
const query = view.query.trim()
|
|
761
|
+
if (!query) return resetDocumentSearch(view)
|
|
762
|
+
const request = ++view.searchRequest
|
|
763
|
+
view.searchLoading = true
|
|
764
|
+
view.searchError = ''
|
|
765
|
+
const params = new URLSearchParams({ q: query, limit: '80' })
|
|
766
|
+
const bases = documentWorkspaceBases(workspace)
|
|
767
|
+
if (bases.length === 0) {
|
|
768
|
+
resetDocumentSearch(view)
|
|
769
|
+
return renderShell()
|
|
770
|
+
}
|
|
771
|
+
if (workspace.kind === 'library') {
|
|
772
|
+
params.append('knowledgeBaseId', state.libraryDetail.knowledgeBaseId)
|
|
773
|
+
} else if (state.mountContext.sessionId) {
|
|
774
|
+
params.set('sessionId', state.mountContext.sessionId)
|
|
775
|
+
if (state.mountContext.projectId) params.set('projectId', state.mountContext.projectId)
|
|
776
|
+
} else {
|
|
777
|
+
params.set('active', '1')
|
|
778
|
+
}
|
|
779
|
+
if (!reset && view.searchNextCursor) params.set('cursor', view.searchNextCursor)
|
|
780
|
+
try {
|
|
781
|
+
const result = await api(`document-index?${params}`)
|
|
782
|
+
if (request !== view.searchRequest || query !== view.query.trim()) return
|
|
783
|
+
const merged = new Map((reset ? [] : view.searchResults).map(document => [document.id, document]))
|
|
784
|
+
for (const item of result.items) merged.set(item.id, item)
|
|
785
|
+
view.searchResults = [...merged.values()]
|
|
786
|
+
view.searchNextCursor = result.nextCursor || ''
|
|
787
|
+
view.searchTotal = result.total
|
|
788
|
+
} catch (error) {
|
|
789
|
+
if (request !== view.searchRequest) return
|
|
790
|
+
view.searchError = friendlyError(error)
|
|
791
|
+
} finally {
|
|
792
|
+
if (request === view.searchRequest) {
|
|
793
|
+
view.searchLoading = false
|
|
794
|
+
renderDocumentSearchState(workspace)
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
517
799
|
function documentWorkspaceBases(workspace) {
|
|
518
800
|
if (workspace.kind === 'library') {
|
|
519
801
|
const base = state.knowledgeBases.find(item => item.id === state.libraryDetail.knowledgeBaseId)
|
|
@@ -527,21 +809,23 @@ function documentWorkspaceReadOnly(workspace) {
|
|
|
527
809
|
}
|
|
528
810
|
|
|
529
811
|
async function reloadDocumentWorkspace(workspace) {
|
|
530
|
-
const
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
else {
|
|
539
|
-
const visibleBaseIds = new Set(documentKnowledgeBases().map(base => base.id))
|
|
540
|
-
setDocumentWorkspaceDocuments(workspace, documents.filter(document => visibleBaseIds.has(document.knowledgeBaseId)))
|
|
812
|
+
const baseId = workspace.kind === 'library' ? state.libraryDetail.knowledgeBaseId : workspace.view.knowledgeBaseId
|
|
813
|
+
if (!baseId) return true
|
|
814
|
+
await loadDocumentPage(workspace, baseId, { reset: true, throwOnError: true })
|
|
815
|
+
if (workspace.kind === 'library' && state.libraryDetail.knowledgeBaseId !== baseId) return false
|
|
816
|
+
const selectedId = workspace.view.documentId
|
|
817
|
+
if (selectedId && !documentWorkspaceDocuments(workspace).some(document => document.id === selectedId)) {
|
|
818
|
+
const document = await api(`documents/${encodeURIComponent(selectedId)}`)
|
|
819
|
+
mergeDocumentSummaries(workspace, [documentSummary(document)], baseId, false)
|
|
541
820
|
}
|
|
542
821
|
return true
|
|
543
822
|
}
|
|
544
823
|
|
|
824
|
+
function documentSummary(document) {
|
|
825
|
+
const { content: _content, ...summary } = document
|
|
826
|
+
return summary
|
|
827
|
+
}
|
|
828
|
+
|
|
545
829
|
function selectDefaultDocument(workspace) {
|
|
546
830
|
const view = workspace.view
|
|
547
831
|
const documents = documentWorkspaceDocuments(workspace).filter(document => document.knowledgeBaseId === view.knowledgeBaseId)
|
|
@@ -554,11 +838,15 @@ function selectDefaultDocument(workspace) {
|
|
|
554
838
|
async function loadDocumentEditor(workspace, id, signal) {
|
|
555
839
|
const revision = (workspace.view.loadRevision || 0) + 1
|
|
556
840
|
workspace.view.loadRevision = revision
|
|
557
|
-
const entry = await
|
|
841
|
+
const [entry, noteReferences] = await Promise.all([
|
|
842
|
+
api(`entries/${encodeURIComponent(id)}`, { signal }),
|
|
843
|
+
api(`entries/${encodeURIComponent(id)}/note-references`, { signal }),
|
|
844
|
+
])
|
|
558
845
|
if (workspace.view.loadRevision !== revision || workspace.view.documentId !== id) return false
|
|
559
846
|
workspace.view.mode = 'preview'
|
|
560
847
|
workspace.view.editor = {
|
|
561
848
|
...entry,
|
|
849
|
+
noteReferences,
|
|
562
850
|
tagsText: entry.tags.join(', '),
|
|
563
851
|
dirty: false,
|
|
564
852
|
isNew: false,
|
|
@@ -578,7 +866,7 @@ function createBlankDocument(workspace, baseId) {
|
|
|
578
866
|
view.expandedBases.add(base.id)
|
|
579
867
|
view.editor = {
|
|
580
868
|
id: '', knowledgeBaseId: base.id, title: '', body: '', type: 'fact', tags: [], tagsText: '',
|
|
581
|
-
scope: { kind: 'global' }, confidence: .8, dirty: true, isNew: true, saveState: '新文档',
|
|
869
|
+
scope: { kind: 'global' }, confidence: .8, noteReferences: [], dirty: true, isNew: true, saveState: '新文档',
|
|
582
870
|
}
|
|
583
871
|
renderShell()
|
|
584
872
|
document.querySelector('.note-title-input')?.focus()
|
|
@@ -597,7 +885,21 @@ async function selectDocument(workspace, id) {
|
|
|
597
885
|
if (editor?.dirty && !emptyDraft && !await saveDocumentEditor(workspace)) return
|
|
598
886
|
workspace.view.documentId = id
|
|
599
887
|
workspace.view.treeOpen = false
|
|
600
|
-
|
|
888
|
+
workspace.view.editor = null
|
|
889
|
+
workspace.view.editorLoading = true
|
|
890
|
+
workspace.view.loadingDocumentId = id
|
|
891
|
+
renderShell()
|
|
892
|
+
try {
|
|
893
|
+
await loadDocumentEditor(workspace, id)
|
|
894
|
+
} catch (error) {
|
|
895
|
+
if (workspace.view.documentId === id) showToast(friendlyError(error), 'error')
|
|
896
|
+
} finally {
|
|
897
|
+
if (workspace.view.loadingDocumentId === id) {
|
|
898
|
+
workspace.view.editorLoading = false
|
|
899
|
+
workspace.view.loadingDocumentId = ''
|
|
900
|
+
renderShell()
|
|
901
|
+
}
|
|
902
|
+
}
|
|
601
903
|
}
|
|
602
904
|
|
|
603
905
|
function editorDraft(editor) {
|
|
@@ -680,15 +982,18 @@ async function loadTokens(signal) {
|
|
|
680
982
|
}
|
|
681
983
|
|
|
682
984
|
function renderShell() {
|
|
985
|
+
releaseNoteEditors()
|
|
683
986
|
captureScrollPosition()
|
|
684
987
|
const titles = {
|
|
685
988
|
overview: ['概览', '知识库运行状态与最近活动'],
|
|
686
989
|
bases: ['知识库与挂载', '管理知识目录,并限定项目与会话的召回和写入范围'],
|
|
687
990
|
entries: ['知识文档', '在知识目录中阅读、整理和维护 Markdown 文档'],
|
|
991
|
+
notes: ['笔记文档', '像本地目录一样整理笔记和资料,并按需关联到知识文档'],
|
|
688
992
|
candidates: ['待审核', '确认 AI 提取结果后再写入知识文档'],
|
|
689
993
|
tokens: ['访问管理', '管理其他客户端连接中央知识库的权限'],
|
|
690
994
|
}
|
|
691
995
|
const [title, subtitle] = titles[state.view]
|
|
996
|
+
const viewIndexes = { overview: '00', notes: '01', entries: '02', candidates: '03', bases: '04', tokens: '05' }
|
|
692
997
|
const shell = element('div', {
|
|
693
998
|
class: 'app-shell', 'data-menu-open': String(state.menuOpen),
|
|
694
999
|
'data-view': state.view, 'data-loading': String(state.loading),
|
|
@@ -713,10 +1018,17 @@ function renderShell() {
|
|
|
713
1018
|
workspace.view.treeOpen = !workspace.view.treeOpen
|
|
714
1019
|
renderShell()
|
|
715
1020
|
}, '知识目录') : null,
|
|
716
|
-
element('div', {
|
|
1021
|
+
element('div', { class: 'topbar-heading' },
|
|
1022
|
+
element('span', { class: 'topbar-kicker' }, `KNOWLEDGE / ${viewIndexes[state.view] || '00'}`),
|
|
1023
|
+
element('h1', {}, title),
|
|
1024
|
+
element('p', {}, subtitle)),
|
|
717
1025
|
),
|
|
1026
|
+
state.loading ? element('div', {
|
|
1027
|
+
class: 'route-progress', role: 'progressbar', 'aria-label': state.loadingPhase || '正在加载',
|
|
1028
|
+
'aria-valuemin': '0', 'aria-valuemax': '100', 'aria-valuenow': String(Math.round(state.loadingProgress * 100)),
|
|
1029
|
+
}, element('span', { style: `--route-progress: ${state.loadingProgress}` })) : null,
|
|
718
1030
|
),
|
|
719
|
-
element('div', { class: 'page' }, renderCurrentView()),
|
|
1031
|
+
element('div', { class: 'page' }, element('div', { class: 'view-stage', 'data-view-stage': state.view }, renderCurrentView())),
|
|
720
1032
|
),
|
|
721
1033
|
)
|
|
722
1034
|
if (state.menuOpen) shell.addEventListener('click', (event) => {
|
|
@@ -794,11 +1106,14 @@ function setSidebarHidden(hidden) {
|
|
|
794
1106
|
function renderSidebar() {
|
|
795
1107
|
const pending = state.stats?.candidates.pending
|
|
796
1108
|
const navGroups = [
|
|
797
|
-
['
|
|
1109
|
+
['笔记工作区', [['notes', '笔记文档']]],
|
|
1110
|
+
['知识工作区', [['entries', '知识文档'], ['candidates', '待审核'], ['bases', '知识库与挂载']]],
|
|
798
1111
|
['连接', [['tokens', '访问管理']].filter(([id]) => id !== 'tokens' || !state.service.remote)],
|
|
799
1112
|
].filter(([, items]) => items.length)
|
|
1113
|
+
let navIndex = 0
|
|
800
1114
|
return element('aside', { class: 'sidebar', 'aria-label': '知识库导航' },
|
|
801
1115
|
element('div', { class: 'brand' },
|
|
1116
|
+
element('span', { class: 'brand-emblem', 'aria-hidden': 'true' }, element('span', {})),
|
|
802
1117
|
element('div', { class: 'brand-copy' }, element('span', {}, 'DSH Knowledge'), element('strong', {}, '知识库')),
|
|
803
1118
|
),
|
|
804
1119
|
element('nav', { class: 'nav' }, navGroups.map(([group, items]) => element('div', { class: 'nav-group' },
|
|
@@ -806,7 +1121,7 @@ function renderSidebar() {
|
|
|
806
1121
|
items.map(([id, label]) => element('button', {
|
|
807
1122
|
type: 'button', class: 'nav-button', 'aria-current': state.view === id ? 'page' : undefined,
|
|
808
1123
|
onClick: () => navigate(id),
|
|
809
|
-
}, element('span', { class: 'nav-label' }, label),
|
|
1124
|
+
}, element('span', { class: 'nav-index', 'aria-hidden': 'true' }, String(++navIndex).padStart(2, '0')), element('span', { class: 'nav-label' }, label),
|
|
810
1125
|
id === 'candidates' && pending ? element('span', { class: 'nav-count', 'aria-label': `${pending} 条待审核` }, pending) : null))))),
|
|
811
1126
|
element('div', { class: 'sidebar-footer' },
|
|
812
1127
|
element('div', { class: 'connection' }, element('span', { class: 'status-dot', 'aria-hidden': 'true' }), state.service.remote ? '中央知识库已连接' : '本地知识库已连接'),
|
|
@@ -816,26 +1131,38 @@ function renderSidebar() {
|
|
|
816
1131
|
}
|
|
817
1132
|
|
|
818
1133
|
function renderCurrentView() {
|
|
819
|
-
if (state.loading) return loadingView()
|
|
1134
|
+
if (state.loading) return loadingView(state.view, state.loadingPhase, state.loadingProgress)
|
|
820
1135
|
if (state.error) return errorView(state.error, () => navigate(state.view))
|
|
821
1136
|
if (state.view === 'overview') return renderOverview()
|
|
822
1137
|
if (state.view === 'bases') return renderKnowledgeBases()
|
|
823
1138
|
if (state.view === 'entries') return renderEntries()
|
|
1139
|
+
if (state.view === 'notes') return renderNotes()
|
|
824
1140
|
if (state.view === 'candidates') return renderCandidates()
|
|
825
1141
|
return renderTokens()
|
|
826
1142
|
}
|
|
827
1143
|
|
|
828
|
-
function loadingView() {
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
element('div', { class: 'skeleton-line skeleton-title', 'aria-hidden': 'true' }),
|
|
832
|
-
element('
|
|
833
|
-
element('div', { class: 'skeleton-grid', 'aria-hidden': 'true' },
|
|
834
|
-
element('div', { class: 'skeleton-block' }),
|
|
835
|
-
element('div', { class: 'skeleton-block' }),
|
|
836
|
-
element('div', { class: 'skeleton-block' }),
|
|
837
|
-
),
|
|
1144
|
+
function loadingView(view = state.view, phase = '正在加载', progress = 0) {
|
|
1145
|
+
const label = phase || loadingPhaseForView(view)
|
|
1146
|
+
const header = element('div', { class: 'skeleton-heading' },
|
|
1147
|
+
element('div', {}, element('div', { class: 'skeleton-line skeleton-title', 'aria-hidden': 'true' }), element('div', { class: 'skeleton-line skeleton-copy', 'aria-hidden': 'true' })),
|
|
1148
|
+
element('span', { class: 'skeleton-phase' }, label),
|
|
838
1149
|
)
|
|
1150
|
+
const content = view === 'entries'
|
|
1151
|
+
? element('div', { class: 'skeleton-workspace skeleton-workspace-documents', 'aria-hidden': 'true' },
|
|
1152
|
+
element('div', { class: 'skeleton-pane skeleton-pane-tree' }, ...Array.from({ length: 7 }, (_, index) => element('div', { class: `skeleton-line skeleton-tree-row is-${index % 3}` }))),
|
|
1153
|
+
element('div', { class: 'skeleton-pane skeleton-pane-paper' }, element('div', { class: 'skeleton-line skeleton-paper-title' }), ...Array.from({ length: 8 }, (_, index) => element('div', { class: `skeleton-line skeleton-paper-row is-${index % 4}` }))),
|
|
1154
|
+
)
|
|
1155
|
+
: view === 'notes'
|
|
1156
|
+
? element('div', { class: 'skeleton-workspace skeleton-workspace-notes', 'aria-hidden': 'true' },
|
|
1157
|
+
element('div', { class: 'skeleton-pane skeleton-pane-tree' }, ...Array.from({ length: 8 }, (_, index) => element('div', { class: `skeleton-line skeleton-tree-row is-${index % 3}` }))),
|
|
1158
|
+
element('div', { class: 'skeleton-pane skeleton-pane-list' }, ...Array.from({ length: 6 }, () => element('div', { class: 'skeleton-list-row' }, element('span', { class: 'skeleton-block' }), element('span', { class: 'skeleton-line' })))),
|
|
1159
|
+
)
|
|
1160
|
+
: element('div', { class: 'skeleton-grid', 'aria-hidden': 'true' },
|
|
1161
|
+
element('div', { class: 'skeleton-block' }), element('div', { class: 'skeleton-block' }), element('div', { class: 'skeleton-block' }))
|
|
1162
|
+
return element('div', {
|
|
1163
|
+
class: `loading-skeleton loading-skeleton--${view}`, role: 'status', 'aria-label': label,
|
|
1164
|
+
style: `--loading-progress: ${progress}`,
|
|
1165
|
+
}, element('span', { class: 'visually-hidden' }, label), header, content)
|
|
839
1166
|
}
|
|
840
1167
|
|
|
841
1168
|
function errorView(message, retry) {
|
|
@@ -1249,53 +1576,77 @@ function renderDocumentWorkspace(workspace, options = {}) {
|
|
|
1249
1576
|
'data-document-scope': workspace.kind,
|
|
1250
1577
|
onInput: (event) => {
|
|
1251
1578
|
view.query = event.target.value
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1579
|
+
scheduleDocumentSearch(workspace)
|
|
1580
|
+
window.requestAnimationFrame(() => {
|
|
1581
|
+
const input = document.querySelector(`.note-tree-search[data-document-scope="${workspace.kind}"]`)
|
|
1582
|
+
input?.focus()
|
|
1583
|
+
input?.setSelectionRange(input.value.length, input.value.length)
|
|
1584
|
+
})
|
|
1256
1585
|
},
|
|
1257
1586
|
})
|
|
1258
|
-
const
|
|
1587
|
+
const visibleBases = query
|
|
1588
|
+
? activeBases.filter(base => view.searchResults.some(document => document.knowledgeBaseId === base.id))
|
|
1589
|
+
: activeBases
|
|
1590
|
+
const tree = visibleBases.map(base => {
|
|
1259
1591
|
const expanded = view.expandedBases.has(base.id) || Boolean(query)
|
|
1260
|
-
const
|
|
1261
|
-
|
|
1592
|
+
const page = documentPageState(workspace, base.id)
|
|
1593
|
+
const documents = (query ? view.searchResults : workspaceDocuments).filter(document => document.knowledgeBaseId === base.id)
|
|
1262
1594
|
return element('section', { class: 'note-tree-group', 'data-expanded': String(expanded) },
|
|
1263
1595
|
element('button', {
|
|
1264
1596
|
type: 'button', class: 'note-tree-base', 'aria-expanded': String(expanded),
|
|
1265
1597
|
onClick: () => {
|
|
1266
|
-
view.knowledgeBaseId = base.id
|
|
1267
|
-
|
|
1268
|
-
else view.expandedBases.add(base.id)
|
|
1269
|
-
renderShell()
|
|
1598
|
+
if (query) { view.knowledgeBaseId = base.id; return renderShell() }
|
|
1599
|
+
void toggleDocumentBase(workspace, base.id)
|
|
1270
1600
|
},
|
|
1271
1601
|
},
|
|
1272
1602
|
element('span', { class: 'tree-disclosure', 'aria-hidden': 'true' }),
|
|
1273
1603
|
element('span', { class: 'tree-folder-icon', 'aria-hidden': 'true' }),
|
|
1274
1604
|
element('span', { class: 'tree-base-name' }, base.name),
|
|
1275
|
-
element('span', { class: 'tree-count' }, documents.length)),
|
|
1605
|
+
element('span', { class: 'tree-count' }, query ? documents.length : page.loaded ? page.total : '—')),
|
|
1276
1606
|
expanded ? element('div', { class: 'note-tree-documents', role: 'group', 'aria-label': `${base.name}文档` },
|
|
1607
|
+
!query && page.loading && documents.length === 0 ? element('div', { class: 'note-tree-status', role: 'status' }, '正在读取目录…') : null,
|
|
1608
|
+
!query && page.error ? element('div', { class: 'note-tree-status is-error' },
|
|
1609
|
+
element('span', {}, page.error),
|
|
1610
|
+
actionButton('重试', () => { void loadDocumentPage(workspace, base.id, { reset: true }).then(renderShell) }, 'ghost small')) : null,
|
|
1277
1611
|
documents.map(document => element('button', {
|
|
1278
1612
|
type: 'button', class: 'note-tree-document', 'aria-current': document.id === view.documentId ? 'page' : undefined,
|
|
1279
|
-
onClick: () => { void selectDocument(workspace, document.id) },
|
|
1613
|
+
onClick: () => { view.knowledgeBaseId = base.id; void selectDocument(workspace, document.id) },
|
|
1280
1614
|
}, element('span', { class: 'tree-document-icon', 'aria-hidden': 'true' }), element('span', { class: 'tree-document-copy' },
|
|
1281
1615
|
element('strong', {}, document.title), element('small', {}, document.relPath)),
|
|
1282
1616
|
document.documentState !== 'open' ? badge(DOCUMENT_STATE_LABELS[document.documentState] || '已结束', 'success') : null)),
|
|
1617
|
+
!query && page.nextCursor ? element('button', {
|
|
1618
|
+
type: 'button', class: 'note-tree-more', disabled: page.loading,
|
|
1619
|
+
onClick: () => { void loadDocumentPage(workspace, base.id, { append: true }).then(renderShell) },
|
|
1620
|
+
}, page.loading ? '正在加载…' : `继续加载(已显示 ${documents.length} / ${page.total})`) : null,
|
|
1621
|
+
!query && page.loaded && documents.length === 0 ? element('div', { class: 'note-tree-status' }, '这个知识库还没有文档。') : null,
|
|
1283
1622
|
!query && !readOnly ? element('button', { type: 'button', class: 'note-tree-new', onClick: () => { void startBlankDocument(workspace, base.id) } },
|
|
1284
1623
|
element('span', { 'aria-hidden': 'true' }, '+'), '新建文档') : null,
|
|
1285
1624
|
) : null,
|
|
1286
1625
|
)
|
|
1287
1626
|
})
|
|
1627
|
+
const treeContent = query
|
|
1628
|
+
? [
|
|
1629
|
+
view.searchLoading && view.searchResults.length === 0 ? element('div', { class: 'note-tree-status', role: 'status' }, '正在搜索文档…') : null,
|
|
1630
|
+
view.searchError ? element('div', { class: 'note-tree-status is-error' }, view.searchError) : null,
|
|
1631
|
+
...tree,
|
|
1632
|
+
!view.searchLoading && !view.searchError && view.searchResults.length === 0 ? element('div', { class: 'note-tree-empty' }, '没有匹配的文档') : null,
|
|
1633
|
+
view.searchNextCursor ? element('button', {
|
|
1634
|
+
type: 'button', class: 'note-tree-more search-more', disabled: view.searchLoading,
|
|
1635
|
+
onClick: () => { void loadDocumentSearch(workspace, false) },
|
|
1636
|
+
}, view.searchLoading ? '正在加载…' : `继续加载搜索结果(${view.searchResults.length} / ${view.searchTotal})`) : null,
|
|
1637
|
+
]
|
|
1638
|
+
: tree.length ? tree : [element('div', { class: 'note-tree-empty' }, workspace.kind === 'session' && state.mountContext.sessionId ? '当前会话未挂载知识库' : '还没有知识库')]
|
|
1288
1639
|
return element('section', {
|
|
1289
1640
|
class: `note-workspace note-workspace--${workspace.kind}`, 'aria-labelledby': `${workspace.kind}-documents-heading`,
|
|
1290
1641
|
'data-tree-open': String(view.treeOpen),
|
|
1291
1642
|
},
|
|
1292
1643
|
element('aside', { class: 'note-tree-panel', 'aria-label': '知识目录' },
|
|
1293
1644
|
element('header', { class: 'note-tree-header' },
|
|
1294
|
-
element('div', {}, element('h2', { id: `${workspace.kind}-documents-heading` }, options.heading || '知识目录'), element('span', {}, `${workspaceDocuments.length}
|
|
1645
|
+
element('div', {}, element('h2', { id: `${workspace.kind}-documents-heading` }, options.heading || '知识目录'), element('span', {}, query ? `${view.searchTotal} 条搜索结果` : `${workspaceDocuments.length} 篇已加载`)),
|
|
1295
1646
|
!readOnly ? actionButton('+', () => { void startBlankDocument(workspace, view.knowledgeBaseId || activeBases[0]?.id) }, 'ghost note-add-button', { 'aria-label': '新建文档', title: '新建文档' }) : null,
|
|
1296
1647
|
),
|
|
1297
1648
|
element('div', { class: 'note-tree-search-wrap' }, interfaceIcon('search', 'search-symbol'), search),
|
|
1298
|
-
element('nav', { class: 'note-tree', 'data-scroll-key': `${workspace.kind}-note-tree` },
|
|
1649
|
+
element('nav', { class: 'note-tree', 'data-scroll-key': `${workspace.kind}-note-tree` }, treeContent),
|
|
1299
1650
|
workspace.kind === 'session' ? element('footer', { class: 'note-tree-footer' }, actionButton('新建知识库', () => openKnowledgeBaseEditor(), 'ghost small')) : null,
|
|
1300
1651
|
),
|
|
1301
1652
|
view.treeOpen ? element('button', {
|
|
@@ -1309,6 +1660,7 @@ function renderDocumentWorkspace(workspace, options = {}) {
|
|
|
1309
1660
|
function renderNoteEditor(workspace, editor, base) {
|
|
1310
1661
|
const view = workspace.view
|
|
1311
1662
|
const readOnly = documentWorkspaceReadOnly(workspace)
|
|
1663
|
+
if (view.editorLoading) return renderDocumentEditorLoading(base)
|
|
1312
1664
|
if (!editor) return element('main', { class: 'note-editor note-editor-empty' },
|
|
1313
1665
|
element('div', { class: 'note-empty-content' },
|
|
1314
1666
|
element('span', { class: 'empty-document-mark', 'aria-hidden': 'true' }),
|
|
@@ -1332,28 +1684,29 @@ function renderNoteEditor(workspace, editor, base) {
|
|
|
1332
1684
|
class: 'note-title-input', value: editor.title, maxlength: 200, placeholder: '无标题文档', 'aria-label': '文档标题',
|
|
1333
1685
|
onInput: event => update('title', event.target.value), onKeyDown: saveShortcut,
|
|
1334
1686
|
})
|
|
1335
|
-
const body = element('textarea', {
|
|
1336
|
-
class: 'note-body-editor', maxlength: 50000, placeholder: '从这里开始记录…', 'aria-label': '文档正文',
|
|
1337
|
-
onInput: event => { update('body', event.target.value); resizeDocumentEditor(event.target) }, onKeyDown: saveShortcut,
|
|
1338
|
-
})
|
|
1339
|
-
body.value = editor.body
|
|
1340
1687
|
const finalized = !editor.isNew && editor.documentState !== 'open'
|
|
1341
|
-
const
|
|
1342
|
-
|
|
1688
|
+
const editable = !finalized && !readOnly
|
|
1689
|
+
const body = editable
|
|
1690
|
+
? element('div', { class: 'notes-live-editor knowledge-live-editor', role: 'status', 'aria-label': '正在打开知识文档正文' },
|
|
1691
|
+
element('div', { class: 'notes-editor-loading' }, '正在打开文档…'))
|
|
1692
|
+
: renderMarkdownPreview(editor.body)
|
|
1693
|
+
if (editable) mountMarkdownEditor(body, {
|
|
1694
|
+
markdown: editor.body,
|
|
1695
|
+
label: '编辑知识文档正文',
|
|
1696
|
+
onChange: value => update('body', value),
|
|
1697
|
+
onSave: () => { void saveDocumentEditor(workspace).then(saved => { if (saved) renderShell() }) },
|
|
1698
|
+
})
|
|
1343
1699
|
return element('main', { class: 'note-editor', 'aria-label': '文档编辑器' },
|
|
1344
1700
|
element('header', { class: 'note-editor-toolbar' },
|
|
1345
1701
|
element('div', { class: 'note-breadcrumb' },
|
|
1346
1702
|
element('span', {}, base?.name || '知识库'),
|
|
1347
1703
|
element('span', { 'aria-hidden': 'true' }, '/'),
|
|
1348
|
-
element('strong', {}, editor.isNew ? '新文档' : documentWorkspaceDocuments(workspace).find(item => item.id === editor.id)?.relPath || editor.title)
|
|
1704
|
+
element('strong', {}, editor.isNew ? '新文档' : documentWorkspaceDocuments(workspace).find(item => item.id === editor.id)?.relPath || editor.title),
|
|
1705
|
+
element('span', { class: 'note-toolbar-meta' },
|
|
1706
|
+
element('span', {}, editor.isNew ? '尚未保存' : `更新于 ${formatDate(editor.updatedAt)}`),
|
|
1707
|
+
element('span', {}, editor.scope.kind === 'global' ? '全局知识' : `项目 ${editor.scope.id}`))),
|
|
1349
1708
|
element('div', { class: 'note-editor-actions' },
|
|
1350
|
-
finalized ? badge(DOCUMENT_STATE_LABELS[editor.documentState] || '已结束', 'success') : readOnly ? badge('只读') :
|
|
1351
|
-
['edit', '编辑'],
|
|
1352
|
-
['preview', '预览'],
|
|
1353
|
-
].map(([value, label]) => element('button', {
|
|
1354
|
-
type: 'button', role: 'tab', 'aria-selected': String(mode === value),
|
|
1355
|
-
onClick: () => switchDocumentMode(workspace, value),
|
|
1356
|
-
}, label))),
|
|
1709
|
+
finalized ? badge(DOCUMENT_STATE_LABELS[editor.documentState] || '已结束', 'success') : readOnly ? badge('只读') : null,
|
|
1357
1710
|
element('span', { class: 'editor-save-status', role: 'status' }, editor.saveState),
|
|
1358
1711
|
finalized && !readOnly ? actionButton('重新打开', () => reopenDocument(workspace, editor), 'small') : null,
|
|
1359
1712
|
!readOnly && !editor.isNew && !finalized ? actionButton('标记结束', () => openFinalizeDocument(workspace, editor), 'small') : null,
|
|
@@ -1370,14 +1723,12 @@ function renderNoteEditor(workspace, editor, base) {
|
|
|
1370
1723
|
editor.finalizationNote ? element('p', {}, editor.finalizationNote) : null,
|
|
1371
1724
|
editor.finalizedAt ? element('small', {}, `结束于 ${formatDate(editor.finalizedAt)}`) : null,
|
|
1372
1725
|
) : null,
|
|
1373
|
-
element('article', { class: `note-paper is-${
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
element('span', {}, editor.isNew ? '尚未保存' : `更新于 ${formatDate(editor.updatedAt)}`),
|
|
1377
|
-
element('span', {}, editor.scope.kind === 'global' ? '全局知识' : `项目 · ${editor.scope.id}`)),
|
|
1378
|
-
mode === 'edit' ? body : renderMarkdownPreview(editor.body),
|
|
1726
|
+
element('article', { class: `note-paper is-${editable ? 'edit' : 'preview'}`, 'data-document-mode': editable ? 'edit' : 'preview' },
|
|
1727
|
+
editable ? title : element('h1', { class: 'note-preview-title' }, editor.title || '无标题文档'),
|
|
1728
|
+
body,
|
|
1379
1729
|
),
|
|
1380
1730
|
),
|
|
1731
|
+
renderDocumentReferenceBar(workspace, editor, editable),
|
|
1381
1732
|
element('footer', { class: 'note-inspector' },
|
|
1382
1733
|
finalized || readOnly ? element('span', { class: 'note-format-hint' }, readOnly ? '知识库已归档 · 只读' : '只读封存 · 重新打开后才能编辑') : element('label', {}, element('span', {}, '类型'), element('select', {
|
|
1383
1734
|
class: 'note-meta-select', onChange: event => update('type', event.target.value),
|
|
@@ -1385,16 +1736,109 @@ function renderNoteEditor(workspace, editor, base) {
|
|
|
1385
1736
|
finalized || readOnly ? null : element('label', { class: 'note-tags-field' }, element('span', {}, '标签'), element('input', {
|
|
1386
1737
|
value: editor.tagsText, placeholder: '用逗号分隔', onInput: event => update('tagsText', event.target.value),
|
|
1387
1738
|
})),
|
|
1388
|
-
|
|
1739
|
+
editable ? element('span', { class: 'note-format-hint' }, 'Markdown · Ctrl/⌘ S 保存') : null,
|
|
1389
1740
|
),
|
|
1390
1741
|
)
|
|
1391
1742
|
}
|
|
1392
1743
|
|
|
1393
|
-
function
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1744
|
+
function renderDocumentEditorLoading(base) {
|
|
1745
|
+
return element('main', { class: 'note-editor note-editor-loading', role: 'status', 'aria-label': '正在打开知识文档' },
|
|
1746
|
+
element('header', { class: 'note-editor-toolbar' },
|
|
1747
|
+
element('div', { class: 'note-breadcrumb' }, element('span', {}, base?.name || '知识库'), element('span', { 'aria-hidden': 'true' }, '/'), element('strong', {}, '正在打开文档')),
|
|
1748
|
+
element('span', { class: 'editor-load-label' }, '按需加载正文与关联笔记'),
|
|
1749
|
+
),
|
|
1750
|
+
element('div', { class: 'note-editor-scroll' }, element('article', { class: 'note-paper editor-paper-skeleton', 'aria-hidden': 'true' },
|
|
1751
|
+
element('div', { class: 'skeleton-line skeleton-paper-title' }),
|
|
1752
|
+
...Array.from({ length: 9 }, (_, index) => element('div', { class: `skeleton-line skeleton-paper-row is-${index % 4}` })),
|
|
1753
|
+
)),
|
|
1754
|
+
)
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
function renderDocumentReferenceBar(workspace, editor, editable) {
|
|
1758
|
+
const references = editor.noteReferences || []
|
|
1759
|
+
return element('section', { class: 'document-reference-bar', 'aria-label': '关联笔记' },
|
|
1760
|
+
element('div', { class: 'document-reference-heading' },
|
|
1761
|
+
element('span', { class: 'document-reference-mark', 'aria-hidden': 'true' }),
|
|
1762
|
+
element('strong', {}, '关联笔记'),
|
|
1763
|
+
element('small', {}, references.length ? `${references.length} 项` : '未关联')),
|
|
1764
|
+
element('div', { class: 'document-reference-list' }, references.length
|
|
1765
|
+
? references.map(reference => element('span', { class: 'document-reference-chip' },
|
|
1766
|
+
element('button', {
|
|
1767
|
+
type: 'button', class: 'document-reference-open', title: `打开 ${reference.note.name}`,
|
|
1768
|
+
onClick: () => { void openNoteReference(reference.note.id) },
|
|
1769
|
+
}, renderNoteIcon(reference.note), element('span', {}, reference.note.name)),
|
|
1770
|
+
editable ? element('button', {
|
|
1771
|
+
type: 'button', class: 'document-reference-remove', 'aria-label': `移除 ${reference.note.name} 的关联`, title: '移除关联',
|
|
1772
|
+
onClick: () => { void removeDocumentNoteReference(workspace, editor, reference) },
|
|
1773
|
+
}, '×') : null,
|
|
1774
|
+
))
|
|
1775
|
+
: element('span', { class: 'document-reference-empty' }, editor.isNew ? '保存文档后可以关联笔记或资料文件' : '这篇知识还没有关联笔记')),
|
|
1776
|
+
editable && !editor.isNew ? actionButton('+ 添加', () => { void openDocumentNoteReferencePicker(workspace, editor) }, 'ghost small document-reference-add') : null,
|
|
1777
|
+
)
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
async function openDocumentNoteReferencePicker(workspace, editor) {
|
|
1781
|
+
const search = element('input', {
|
|
1782
|
+
class: 'input', type: 'search', placeholder: '搜索笔记文档或文件', 'aria-label': '搜索可关联的笔记',
|
|
1783
|
+
})
|
|
1784
|
+
const results = element('div', { class: 'note-picker-results', 'aria-live': 'polite' })
|
|
1785
|
+
let modal
|
|
1786
|
+
let request = 0
|
|
1787
|
+
const paint = async () => {
|
|
1788
|
+
const current = ++request
|
|
1789
|
+
const query = search.value.trim()
|
|
1790
|
+
if (!query) {
|
|
1791
|
+
results.replaceChildren(element('div', { class: 'note-picker-empty' }, '输入名称以搜索可关联的笔记文档和文件。'))
|
|
1792
|
+
return
|
|
1793
|
+
}
|
|
1794
|
+
results.replaceChildren(element('div', { class: 'note-picker-empty' }, '正在搜索…'))
|
|
1795
|
+
try {
|
|
1796
|
+
const linked = new Set((editor.noteReferences || []).map(reference => reference.note.id))
|
|
1797
|
+
const visible = (await api(`notes?q=${encodeURIComponent(query)}&limit=100`)).filter(node => node.kind !== 'folder' && !linked.has(node.id))
|
|
1798
|
+
if (current !== request) return
|
|
1799
|
+
results.replaceChildren(...(visible.length
|
|
1800
|
+
? visible.map(node => element('button', {
|
|
1801
|
+
type: 'button', class: 'note-picker-row',
|
|
1802
|
+
onClick: async event => {
|
|
1803
|
+
event.currentTarget.disabled = true
|
|
1804
|
+
try {
|
|
1805
|
+
const reference = await api(`entries/${encodeURIComponent(editor.id)}/note-references`, { method: 'POST', body: { noteId: node.id } })
|
|
1806
|
+
editor.noteReferences = [...(editor.noteReferences || []), reference]
|
|
1807
|
+
modal.close(true)
|
|
1808
|
+
renderShell()
|
|
1809
|
+
showToast('已关联笔记。')
|
|
1810
|
+
} catch (error) {
|
|
1811
|
+
event.currentTarget.disabled = false
|
|
1812
|
+
showToast(friendlyError(error), 'error')
|
|
1813
|
+
}
|
|
1814
|
+
},
|
|
1815
|
+
},
|
|
1816
|
+
renderNoteIcon(node),
|
|
1817
|
+
element('span', {}, element('strong', {}, node.name), element('small', {}, `${node.kind === 'document' ? '笔记文档' : formatBytes(node.size)} ${shortNoteId(node.id)}`))))
|
|
1818
|
+
: [element('div', { class: 'note-picker-empty' }, '没有匹配的笔记文档或文件。')]))
|
|
1819
|
+
} catch (error) {
|
|
1820
|
+
if (current !== request) return
|
|
1821
|
+
results.replaceChildren(element('div', { class: 'note-picker-empty is-error' }, friendlyError(error)))
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
let timer = 0
|
|
1825
|
+
search.addEventListener('input', () => { window.clearTimeout(timer); timer = window.setTimeout(() => void paint(), 180) })
|
|
1826
|
+
modal = openSheet({
|
|
1827
|
+
title: '添加关联笔记',
|
|
1828
|
+
description: '关联保存在文档正文之外;笔记移动或改名后仍然有效。',
|
|
1829
|
+
body: element('div', { class: 'note-picker' }, search, results),
|
|
1830
|
+
cancelLabel: '取消',
|
|
1831
|
+
})
|
|
1832
|
+
search.focus()
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
async function removeDocumentNoteReference(workspace, editor, reference) {
|
|
1836
|
+
try {
|
|
1837
|
+
await api(`entries/${encodeURIComponent(editor.id)}/note-references/${encodeURIComponent(reference.note.id)}`, { method: 'DELETE' })
|
|
1838
|
+
editor.noteReferences = (editor.noteReferences || []).filter(item => item.note.id !== reference.note.id)
|
|
1839
|
+
if (workspace.view.editor === editor) renderShell()
|
|
1840
|
+
showToast('已移除关联。')
|
|
1841
|
+
} catch (error) { showToast(friendlyError(error), 'error') }
|
|
1398
1842
|
}
|
|
1399
1843
|
|
|
1400
1844
|
function renderMarkdownPreview(markdown) {
|
|
@@ -1409,12 +1853,30 @@ function renderMarkdownPreview(markdown) {
|
|
|
1409
1853
|
table.replaceWith(scroller)
|
|
1410
1854
|
scroller.append(table)
|
|
1411
1855
|
})
|
|
1856
|
+
preview.querySelectorAll('[data-note-id]').forEach(link => {
|
|
1857
|
+
link.setAttribute('role', 'button')
|
|
1858
|
+
link.setAttribute('tabindex', '0')
|
|
1859
|
+
link.setAttribute('title', '打开笔记文档')
|
|
1860
|
+
const open = event => {
|
|
1861
|
+
if (event.type === 'keydown' && event.key !== 'Enter' && event.key !== ' ') return
|
|
1862
|
+
event.preventDefault()
|
|
1863
|
+
void openNoteReference(link.dataset.noteId)
|
|
1864
|
+
}
|
|
1865
|
+
link.addEventListener('click', open)
|
|
1866
|
+
link.addEventListener('keydown', open)
|
|
1867
|
+
})
|
|
1412
1868
|
return preview
|
|
1413
1869
|
}
|
|
1414
1870
|
|
|
1415
|
-
function
|
|
1416
|
-
|
|
1417
|
-
|
|
1871
|
+
async function openNoteReference(id) {
|
|
1872
|
+
try {
|
|
1873
|
+
if (!await saveBeforeLeavingDocument()) return
|
|
1874
|
+
const node = await api(`notes/${encodeURIComponent(id)}`)
|
|
1875
|
+
state.view = 'notes'
|
|
1876
|
+
state.loading = false
|
|
1877
|
+
await selectNoteNode(node)
|
|
1878
|
+
renderShell()
|
|
1879
|
+
} catch (error) { showToast(friendlyError(error), 'error') }
|
|
1418
1880
|
}
|
|
1419
1881
|
|
|
1420
1882
|
function openFinalizeDocument(workspace, editor) {
|
|
@@ -1484,6 +1946,1068 @@ function confirmDeleteDocument(workspace, editor) {
|
|
|
1484
1946
|
})
|
|
1485
1947
|
}
|
|
1486
1948
|
|
|
1949
|
+
function renderNotes() {
|
|
1950
|
+
const fileInput = element('input', {
|
|
1951
|
+
class: 'visually-hidden', type: 'file', multiple: true, tabindex: '-1',
|
|
1952
|
+
onChange: event => { void uploadNoteFiles([...event.target.files], state.notes.currentFolderId); event.target.value = '' },
|
|
1953
|
+
})
|
|
1954
|
+
const rootNodes = state.notes.children.get('root') || []
|
|
1955
|
+
const tree = state.notes.query.trim()
|
|
1956
|
+
? renderNoteSearchResults()
|
|
1957
|
+
: renderNoteTreeBranch(null)
|
|
1958
|
+
const workspace = element('section', {
|
|
1959
|
+
class: 'notes-workspace', 'data-drop-active': 'false',
|
|
1960
|
+
onDragEnter: noteWorkspaceDragEnter, onDragOver: noteWorkspaceDragEnter,
|
|
1961
|
+
onDragLeave: noteWorkspaceDragLeave,
|
|
1962
|
+
onDrop: event => {
|
|
1963
|
+
clearNoteDragState()
|
|
1964
|
+
if (!hasDragType(event, 'Files')) return
|
|
1965
|
+
event.preventDefault()
|
|
1966
|
+
void importDroppedNotes(event.dataTransfer, state.notes.currentFolderId)
|
|
1967
|
+
},
|
|
1968
|
+
},
|
|
1969
|
+
element('aside', { class: 'notes-browser', 'aria-label': '笔记目录' },
|
|
1970
|
+
element('header', { class: 'notes-browser-header' },
|
|
1971
|
+
element('div', {}, element('h2', {}, '目录'), element('span', {}, `${rootNodes.length} 个根目录项目`)),
|
|
1972
|
+
element('div', { class: 'notes-browser-actions' },
|
|
1973
|
+
actionButton('新建', () => openCreateNoteDocument(), 'primary small'),
|
|
1974
|
+
actionButton('目录', () => openCreateNoteFolder(), 'small'),
|
|
1975
|
+
actionButton('导入', () => fileInput.click(), 'ghost small'),
|
|
1976
|
+
fileInput,
|
|
1977
|
+
),
|
|
1978
|
+
),
|
|
1979
|
+
element('div', { class: 'notes-search' }, element('input', {
|
|
1980
|
+
class: 'input', type: 'search', value: state.notes.query, placeholder: '搜索笔记文档', 'aria-label': '搜索笔记文档',
|
|
1981
|
+
onInput: event => scheduleNoteSearch(event.target.value),
|
|
1982
|
+
})),
|
|
1983
|
+
element('div', {
|
|
1984
|
+
class: 'notes-tree', role: 'tree', 'data-scroll-key': 'notes-tree',
|
|
1985
|
+
onDragOver: event => {
|
|
1986
|
+
if (hasDragType(event, 'application/x-dsh-note-id') || hasDragType(event, 'Files')) event.preventDefault()
|
|
1987
|
+
},
|
|
1988
|
+
onDrop: event => { if (event.target === event.currentTarget) void dropNoteNode(event, null) },
|
|
1989
|
+
}, tree),
|
|
1990
|
+
),
|
|
1991
|
+
renderNoteContent(),
|
|
1992
|
+
element('div', { class: 'notes-drop-overlay', 'aria-hidden': 'true' },
|
|
1993
|
+
element('strong', {}, '放开以导入当前目录'),
|
|
1994
|
+
element('span', {}, '支持文件和完整目录,原有目录层级会被保留。')),
|
|
1995
|
+
renderNoteTransfer(),
|
|
1996
|
+
)
|
|
1997
|
+
return element('section', { class: 'notes-page', 'aria-label': '笔记工作区' }, workspace)
|
|
1998
|
+
}
|
|
1999
|
+
|
|
2000
|
+
function renderNoteTransfer() {
|
|
2001
|
+
const transfer = state.notes.transfer
|
|
2002
|
+
if (!transfer) return null
|
|
2003
|
+
const dismissible = transfer.phase === 'complete' || transfer.phase === 'error'
|
|
2004
|
+
return element('section', {
|
|
2005
|
+
class: 'notes-transfer', 'data-note-transfer': transfer.id, 'data-state': transfer.phase,
|
|
2006
|
+
role: transfer.phase === 'error' ? 'alert' : 'status', 'aria-live': 'polite',
|
|
2007
|
+
},
|
|
2008
|
+
element('div', { class: 'notes-transfer-heading' },
|
|
2009
|
+
element('span', { class: 'notes-transfer-mark', 'aria-hidden': 'true' }),
|
|
2010
|
+
element('strong', { class: 'notes-transfer-title' }, noteTransferTitle(transfer)),
|
|
2011
|
+
dismissible ? actionButton('关闭', dismissNoteTransfer, 'ghost tiny notes-transfer-close', { 'aria-label': '关闭导入进度' }) : null,
|
|
2012
|
+
),
|
|
2013
|
+
element('div', { class: 'notes-transfer-detail', title: transfer.currentName || '' }, noteTransferDetail(transfer)),
|
|
2014
|
+
element('div', {
|
|
2015
|
+
class: 'notes-transfer-track', role: 'progressbar',
|
|
2016
|
+
'aria-label': '笔记导入进度', 'aria-valuemin': '0', 'aria-valuemax': '100',
|
|
2017
|
+
'aria-valuenow': transfer.phase === 'scanning' ? undefined : String(noteTransferPercent(transfer)),
|
|
2018
|
+
}, element('span', { class: 'notes-transfer-fill' })),
|
|
2019
|
+
element('div', { class: 'notes-transfer-meta' },
|
|
2020
|
+
element('span', { class: 'notes-transfer-count' }, noteTransferCount(transfer)),
|
|
2021
|
+
element('span', { class: 'notes-transfer-percent' }, transfer.phase === 'scanning' ? '正在整理' : `${noteTransferPercent(transfer)}%`),
|
|
2022
|
+
),
|
|
2023
|
+
)
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
function renderNoteTreeBranch(parentId, depth = 0) {
|
|
2027
|
+
const key = parentId || 'root'
|
|
2028
|
+
const nodes = state.notes.children.get(key) || []
|
|
2029
|
+
if (!nodes.length && depth === 0) {
|
|
2030
|
+
return element('div', { class: 'notes-tree-empty' }, element('strong', {}, '还没有笔记'), element('span', {}, '新建目录或文档开始整理。'))
|
|
2031
|
+
}
|
|
2032
|
+
return element('div', { class: 'notes-tree-branch', role: depth ? 'group' : undefined }, nodes.map(node => {
|
|
2033
|
+
const selected = state.notes.selectedId === node.id
|
|
2034
|
+
const expanded = node.kind === 'folder' && state.notes.expandedFolders.has(node.id)
|
|
2035
|
+
const row = element('div', {
|
|
2036
|
+
class: 'notes-tree-item', 'data-kind': node.kind, 'data-selected': String(selected),
|
|
2037
|
+
'data-note-id': node.id,
|
|
2038
|
+
draggable: 'true',
|
|
2039
|
+
onDragStart: event => {
|
|
2040
|
+
event.dataTransfer.effectAllowed = 'move'
|
|
2041
|
+
event.dataTransfer.setData('application/x-dsh-note-id', node.id)
|
|
2042
|
+
event.currentTarget.dataset.dragging = 'true'
|
|
2043
|
+
},
|
|
2044
|
+
onDragEnd: () => clearNoteDragState(),
|
|
2045
|
+
onDragEnter: event => activateNoteFolderDropTarget(event, node),
|
|
2046
|
+
onDragOver: event => activateNoteFolderDropTarget(event, node),
|
|
2047
|
+
onDragLeave: event => { if (!event.currentTarget.contains(event.relatedTarget)) event.currentTarget.dataset.dropTarget = 'false' },
|
|
2048
|
+
onDrop: event => { if (node.kind === 'folder') void dropNoteNode(event, node.id) },
|
|
2049
|
+
},
|
|
2050
|
+
element('button', {
|
|
2051
|
+
type: 'button', class: 'notes-tree-row', role: 'treeitem',
|
|
2052
|
+
'aria-selected': String(selected), 'aria-expanded': node.kind === 'folder' ? String(expanded) : undefined,
|
|
2053
|
+
onClick: () => { void selectNoteNode(node, { toggleFolder: node.kind === 'folder' }) },
|
|
2054
|
+
},
|
|
2055
|
+
node.kind === 'folder' ? element('span', { class: 'notes-tree-chevron', 'aria-hidden': 'true' }) : element('span', { class: 'notes-tree-spacer' }),
|
|
2056
|
+
renderNoteIcon(node),
|
|
2057
|
+
element('span', { class: 'notes-tree-name', title: node.name }, node.name),
|
|
2058
|
+
),
|
|
2059
|
+
element('div', { class: 'notes-row-actions' },
|
|
2060
|
+
actionButton('重命名', event => { event.stopPropagation(); openRenameNoteNode(node) }, 'ghost tiny'),
|
|
2061
|
+
actionButton('复制', event => { event.stopPropagation(); void copyNoteNode(node) }, 'ghost tiny'),
|
|
2062
|
+
actionButton('删除', event => { event.stopPropagation(); void confirmDeleteNoteNode(node) }, 'ghost tiny danger-text'),
|
|
2063
|
+
),
|
|
2064
|
+
)
|
|
2065
|
+
const children = expanded
|
|
2066
|
+
? state.notes.loadedFolders.has(node.id)
|
|
2067
|
+
? renderNoteTreeBranch(node.id, depth + 1)
|
|
2068
|
+
: element('div', { class: 'notes-tree-loading', role: 'status' }, '正在读取…')
|
|
2069
|
+
: null
|
|
2070
|
+
return element('div', { class: 'notes-tree-node' }, row, children)
|
|
2071
|
+
}))
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2074
|
+
function renderNoteSearchResults() {
|
|
2075
|
+
if (!state.notes.searchResults.length) return element('div', { class: 'notes-tree-empty' }, '没有匹配的笔记文档。')
|
|
2076
|
+
return element('div', { class: 'notes-search-results' }, state.notes.searchResults.map(node => element('button', {
|
|
2077
|
+
type: 'button', class: 'notes-search-row', onClick: () => { void selectNoteNode(node) },
|
|
2078
|
+
}, renderNoteIcon(node), element('span', {}, element('strong', {}, node.name), element('small', {}, noteKindLabel(node))))))
|
|
2079
|
+
}
|
|
2080
|
+
|
|
2081
|
+
function renderNoteContent() {
|
|
2082
|
+
const node = state.notes.selectedNode
|
|
2083
|
+
if (!node) return renderNoteFolderContent(null)
|
|
2084
|
+
if (state.notes.loadingNodeId === node.id) return renderNoteSelectionLoading(node)
|
|
2085
|
+
if (node.kind === 'folder') return renderNoteFolderContent(node)
|
|
2086
|
+
if (node.kind === 'document') return renderNoteDocument(node)
|
|
2087
|
+
return renderNoteFile(node)
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
function renderNoteSelectionLoading(node) {
|
|
2091
|
+
return element('main', { class: `notes-content notes-selection-loading is-${node.kind}`, role: 'status', 'aria-label': `正在打开 ${node.name}` },
|
|
2092
|
+
element('header', { class: 'notes-content-header' },
|
|
2093
|
+
element('div', { class: 'notes-breadcrumb' }, element('span', {}, '笔记文档'), element('span', { 'aria-hidden': 'true' }, '/'), element('strong', {}, node.name)),
|
|
2094
|
+
element('div', { class: 'notes-content-title' }, element('div', {}, element('h2', {}, node.name), element('p', {}, node.kind === 'folder' ? '正在读取目录内容' : '正在按需读取文档内容'))),
|
|
2095
|
+
),
|
|
2096
|
+
element('div', { class: 'notes-selection-skeleton', 'aria-hidden': 'true' },
|
|
2097
|
+
element('div', { class: 'skeleton-line skeleton-paper-title' }),
|
|
2098
|
+
...Array.from({ length: node.kind === 'folder' ? 6 : 9 }, (_, index) => element('div', { class: `skeleton-line skeleton-paper-row is-${index % 4}` })),
|
|
2099
|
+
),
|
|
2100
|
+
)
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
function renderNoteFolderContent(folder) {
|
|
2104
|
+
const parentId = folder?.id || null
|
|
2105
|
+
const children = state.notes.children.get(parentId || 'root') || []
|
|
2106
|
+
return element('main', {
|
|
2107
|
+
class: 'notes-content is-folder',
|
|
2108
|
+
onDragOver: event => event.preventDefault(),
|
|
2109
|
+
onDrop: event => void dropNoteNode(event, parentId),
|
|
2110
|
+
},
|
|
2111
|
+
renderNoteContentHeader(folder),
|
|
2112
|
+
children.length
|
|
2113
|
+
? element('div', { class: 'notes-file-list', role: 'list' }, children.map(node => element('div', {
|
|
2114
|
+
class: 'notes-file-row', role: 'listitem', draggable: 'true',
|
|
2115
|
+
onDragStart: event => { event.dataTransfer.effectAllowed = 'move'; event.dataTransfer.setData('application/x-dsh-note-id', node.id) },
|
|
2116
|
+
onDblClick: () => { void selectNoteNode(node, { toggleFolder: node.kind === 'folder' }) },
|
|
2117
|
+
},
|
|
2118
|
+
element('button', { type: 'button', class: 'notes-file-main', onClick: () => { void selectNoteNode(node) } },
|
|
2119
|
+
renderNoteIcon(node, true),
|
|
2120
|
+
element('span', {}, element('strong', {}, node.name), element('small', {}, noteKindLabel(node))),
|
|
2121
|
+
),
|
|
2122
|
+
element('time', { datetime: node.updatedAt }, formatDate(node.updatedAt)),
|
|
2123
|
+
element('span', { class: 'notes-file-size' }, node.kind === 'folder' ? '' : formatBytes(node.size)),
|
|
2124
|
+
element('div', { class: 'notes-file-actions' },
|
|
2125
|
+
actionButton('重命名', () => openRenameNoteNode(node), 'ghost tiny'),
|
|
2126
|
+
actionButton('复制', () => { void copyNoteNode(node) }, 'ghost tiny'),
|
|
2127
|
+
actionButton('删除', () => { void confirmDeleteNoteNode(node) }, 'ghost tiny danger-text'),
|
|
2128
|
+
),
|
|
2129
|
+
)))
|
|
2130
|
+
: element('div', { class: 'notes-folder-empty' },
|
|
2131
|
+
element('span', { class: 'notes-empty-folder-mark', 'aria-hidden': 'true' }),
|
|
2132
|
+
element('h3', {}, '这个目录还是空的'),
|
|
2133
|
+
element('p', {}, '可以新建笔记文档、建立子目录,或把本地文件拖到这里。'),
|
|
2134
|
+
element('div', {}, actionButton('新建文档', () => openCreateNoteDocument(parentId), 'primary small'), actionButton('新建目录', () => openCreateNoteFolder(parentId), 'small')),
|
|
2135
|
+
),
|
|
2136
|
+
)
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2139
|
+
function renderNoteContentHeader(folder) {
|
|
2140
|
+
const path = folder ? [...state.notes.breadcrumbs] : []
|
|
2141
|
+
return element('header', { class: 'notes-content-header' },
|
|
2142
|
+
element('nav', { class: 'notes-breadcrumb', 'aria-label': '当前位置' },
|
|
2143
|
+
element('button', { type: 'button', onClick: () => { void openNoteRoot() } }, '笔记文档'),
|
|
2144
|
+
path.map(node => element('span', {}, element('span', { 'aria-hidden': 'true' }, '/'), element('button', { type: 'button', onClick: () => { void selectNoteNode(node) } }, node.name))),
|
|
2145
|
+
),
|
|
2146
|
+
element('div', { class: 'notes-content-title' },
|
|
2147
|
+
element('div', {}, element('h2', {}, folder?.name || '全部笔记'), element('p', {}, folder ? `${(state.notes.children.get(folder.id) || []).length} 个项目` : '你的独立笔记与资料目录')),
|
|
2148
|
+
element('div', { class: 'notes-content-actions' },
|
|
2149
|
+
actionButton('新建文档', () => openCreateNoteDocument(folder?.id || null), 'primary small'),
|
|
2150
|
+
actionButton('新建目录', () => openCreateNoteFolder(folder?.id || null), 'small'),
|
|
2151
|
+
),
|
|
2152
|
+
),
|
|
2153
|
+
)
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
function renderNoteDocument(node) {
|
|
2157
|
+
return renderEditableNote(node)
|
|
2158
|
+
}
|
|
2159
|
+
|
|
2160
|
+
function renderEditableNote(node) {
|
|
2161
|
+
const markdown = isMarkdownNote(node)
|
|
2162
|
+
const title = editableNoteTitle(node)
|
|
2163
|
+
const editor = markdown
|
|
2164
|
+
? element('div', { class: 'notes-live-editor', role: 'status', 'aria-label': `正在打开 ${node.name}` },
|
|
2165
|
+
element('div', { class: 'notes-editor-loading' }, '正在打开文档…'))
|
|
2166
|
+
: createPlainTextNoteEditor(node)
|
|
2167
|
+
if (markdown) mountMarkdownNoteEditor(editor, node)
|
|
2168
|
+
else mountPlainTextNoteEditor(editor, node)
|
|
2169
|
+
return element('main', { class: `notes-content is-document${markdown ? '' : ' has-line-numbers'}` },
|
|
2170
|
+
renderNoteFileToolbar(node, { editable: true }),
|
|
2171
|
+
element('h1', {
|
|
2172
|
+
class: 'notes-document-title', contenteditable: 'plaintext-only', spellcheck: 'false',
|
|
2173
|
+
'aria-label': `修改 ${node.name} 的标题`, title: '点击修改标题',
|
|
2174
|
+
onBlur: event => { void saveEditableNoteTitle(event.currentTarget, node) },
|
|
2175
|
+
onKeyDown: event => {
|
|
2176
|
+
if (event.key === 'Enter') { event.preventDefault(); event.currentTarget.blur() }
|
|
2177
|
+
if (event.key === 'Escape') { event.preventDefault(); event.currentTarget.textContent = title; event.currentTarget.blur() }
|
|
2178
|
+
},
|
|
2179
|
+
}, title),
|
|
2180
|
+
editor,
|
|
2181
|
+
)
|
|
2182
|
+
}
|
|
2183
|
+
|
|
2184
|
+
function editableNoteTitle(node) {
|
|
2185
|
+
return node.kind === 'document' ? node.name.replace(/\.md$/i, '') : node.name
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2188
|
+
async function saveEditableNoteTitle(editor, node) {
|
|
2189
|
+
const title = readPlainTextEditor(editor).replace(/\s+/g, ' ').trim()
|
|
2190
|
+
const name = node.kind === 'document' && title ? `${title.replace(/\.md$/i, '')}.md` : title
|
|
2191
|
+
if (!name) {
|
|
2192
|
+
editor.textContent = editableNoteTitle(node)
|
|
2193
|
+
showToast('标题不能为空。', 'error')
|
|
2194
|
+
return
|
|
2195
|
+
}
|
|
2196
|
+
if (name === node.name) {
|
|
2197
|
+
editor.textContent = editableNoteTitle(node)
|
|
2198
|
+
return
|
|
2199
|
+
}
|
|
2200
|
+
editor.setAttribute('contenteditable', 'false')
|
|
2201
|
+
try {
|
|
2202
|
+
const updated = await api(`notes/${encodeURIComponent(node.id)}`, { method: 'PATCH', body: { name } })
|
|
2203
|
+
if (state.notes.selectedNode?.id === node.id) state.notes.selectedNode = updated
|
|
2204
|
+
editor.textContent = editableNoteTitle(updated)
|
|
2205
|
+
const breadcrumb = document.querySelector('.notes-document-toolbar .notes-breadcrumb strong')
|
|
2206
|
+
if (breadcrumb) breadcrumb.textContent = updated.name
|
|
2207
|
+
await loadNoteChildren(updated.parentId, true).catch(() => {})
|
|
2208
|
+
const treeName = document.querySelector(`.notes-tree-item[data-note-id="${updated.id}"] .notes-tree-name`)
|
|
2209
|
+
if (treeName) treeName.textContent = updated.name
|
|
2210
|
+
showToast('标题已更新。')
|
|
2211
|
+
} catch (error) {
|
|
2212
|
+
editor.textContent = editableNoteTitle(node)
|
|
2213
|
+
showToast(friendlyError(error), 'error')
|
|
2214
|
+
} finally {
|
|
2215
|
+
if (editor.isConnected) editor.setAttribute('contenteditable', 'plaintext-only')
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2219
|
+
function createPlainTextNoteEditor(node) {
|
|
2220
|
+
return element('div', { class: 'notes-plain-editor', role: 'status', 'aria-label': `正在打开 ${node.name}` },
|
|
2221
|
+
element('div', { class: 'notes-editor-loading' }, '正在打开文档…'))
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
function readPlainTextEditor(editor) {
|
|
2225
|
+
return editor.innerText.replace(/\r\n?/g, '\n')
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2228
|
+
function updateNoteDraft(value) {
|
|
2229
|
+
state.notes.draft = value
|
|
2230
|
+
state.notes.dirty = state.notes.draft !== state.notes.content
|
|
2231
|
+
syncNoteEditorChrome()
|
|
2232
|
+
}
|
|
2233
|
+
|
|
2234
|
+
function mountMarkdownNoteEditor(host, node) {
|
|
2235
|
+
mountMarkdownEditor(host, {
|
|
2236
|
+
markdown: state.notes.draft,
|
|
2237
|
+
label: `编辑 ${node.name}`,
|
|
2238
|
+
isCurrent: () => state.notes.selectedNode?.id === node.id,
|
|
2239
|
+
onChange: updateNoteDraft,
|
|
2240
|
+
onSave: () => { void saveNoteDocument() },
|
|
2241
|
+
})
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2244
|
+
function mountPlainTextNoteEditor(host, node) {
|
|
2245
|
+
const request = ++plainTextEditorMountRequest
|
|
2246
|
+
window.requestAnimationFrame(async () => {
|
|
2247
|
+
if (!host.isConnected || state.notes.selectedNode?.id !== node.id || request !== plainTextEditorMountRequest) return
|
|
2248
|
+
try {
|
|
2249
|
+
const runtime = await loadMarkdownNoteEditor()
|
|
2250
|
+
if (!host.isConnected || state.notes.selectedNode?.id !== node.id || request !== plainTextEditorMountRequest) return
|
|
2251
|
+
host.replaceChildren()
|
|
2252
|
+
host.removeAttribute('role')
|
|
2253
|
+
plainTextEditorHandle = runtime.createPlainTextEditor({
|
|
2254
|
+
host,
|
|
2255
|
+
text: state.notes.draft,
|
|
2256
|
+
label: `编辑 ${node.name}`,
|
|
2257
|
+
onChange: updateNoteDraft,
|
|
2258
|
+
onSave: () => { void saveNoteDocument() },
|
|
2259
|
+
})
|
|
2260
|
+
} catch (error) {
|
|
2261
|
+
if (!host.isConnected || request !== plainTextEditorMountRequest) return
|
|
2262
|
+
host.replaceChildren(element('div', { class: 'notes-editor-error', role: 'alert' },
|
|
2263
|
+
element('strong', {}, '无法打开纯文本编辑器'),
|
|
2264
|
+
element('span', {}, friendlyError(error)),
|
|
2265
|
+
actionButton('重试', () => { noteEditorLoader = null; host.replaceChildren(); mountPlainTextNoteEditor(host, node) }, 'small')))
|
|
2266
|
+
}
|
|
2267
|
+
})
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2270
|
+
function mountMarkdownEditor(host, options) {
|
|
2271
|
+
const request = ++markdownEditorMountRequest
|
|
2272
|
+
window.requestAnimationFrame(async () => {
|
|
2273
|
+
if (!host.isConnected || options.isCurrent?.() === false || request !== markdownEditorMountRequest) return
|
|
2274
|
+
try {
|
|
2275
|
+
const runtime = await loadMarkdownNoteEditor()
|
|
2276
|
+
if (!host.isConnected || options.isCurrent?.() === false || request !== markdownEditorMountRequest) return
|
|
2277
|
+
host.replaceChildren()
|
|
2278
|
+
host.removeAttribute('role')
|
|
2279
|
+
markdownEditorHandle = runtime.createMarkdownEditor({
|
|
2280
|
+
host,
|
|
2281
|
+
markdown: options.markdown,
|
|
2282
|
+
label: options.label,
|
|
2283
|
+
onChange: options.onChange,
|
|
2284
|
+
onSave: options.onSave,
|
|
2285
|
+
})
|
|
2286
|
+
} catch (error) {
|
|
2287
|
+
if (!host.isConnected || request !== markdownEditorMountRequest) return
|
|
2288
|
+
host.replaceChildren(element('div', { class: 'notes-editor-error', role: 'alert' },
|
|
2289
|
+
element('strong', {}, '无法打开 Markdown 编辑器'),
|
|
2290
|
+
element('span', {}, friendlyError(error)),
|
|
2291
|
+
actionButton('重试', () => { noteEditorLoader = null; host.replaceChildren(); mountMarkdownEditor(host, options) }, 'small')))
|
|
2292
|
+
}
|
|
2293
|
+
})
|
|
2294
|
+
}
|
|
2295
|
+
|
|
2296
|
+
function loadMarkdownNoteEditor() {
|
|
2297
|
+
if (window.DshKnowledgeNoteEditor?.createMarkdownEditor) return Promise.resolve(window.DshKnowledgeNoteEditor)
|
|
2298
|
+
if (noteEditorLoader) return noteEditorLoader
|
|
2299
|
+
noteEditorLoader = new Promise((resolve, reject) => {
|
|
2300
|
+
const script = document.createElement('script')
|
|
2301
|
+
const suffix = ASSET_VERSION ? `?v=${encodeURIComponent(ASSET_VERSION)}` : ''
|
|
2302
|
+
script.src = `${WEB_PATH.replace(/\/$/, '')}/note-editor.js${suffix}`
|
|
2303
|
+
script.async = true
|
|
2304
|
+
script.onload = () => window.DshKnowledgeNoteEditor?.createMarkdownEditor
|
|
2305
|
+
? resolve(window.DshKnowledgeNoteEditor)
|
|
2306
|
+
: reject(new Error('Markdown 编辑器模块无效'))
|
|
2307
|
+
script.onerror = () => { script.remove(); reject(new Error('Markdown 编辑器加载失败')) }
|
|
2308
|
+
document.head.append(script)
|
|
2309
|
+
}).catch(error => {
|
|
2310
|
+
noteEditorLoader = null
|
|
2311
|
+
throw error
|
|
2312
|
+
})
|
|
2313
|
+
return noteEditorLoader
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2316
|
+
function releaseNoteEditors() {
|
|
2317
|
+
markdownEditorMountRequest += 1
|
|
2318
|
+
plainTextEditorMountRequest += 1
|
|
2319
|
+
markdownEditorHandle?.destroy()
|
|
2320
|
+
plainTextEditorHandle?.destroy()
|
|
2321
|
+
markdownEditorHandle = null
|
|
2322
|
+
plainTextEditorHandle = null
|
|
2323
|
+
}
|
|
2324
|
+
|
|
2325
|
+
function syncNoteEditorChrome() {
|
|
2326
|
+
const status = document.querySelector('[data-note-save-state]')
|
|
2327
|
+
if (status) {
|
|
2328
|
+
status.textContent = state.notes.dirty ? '未保存' : '已保存'
|
|
2329
|
+
status.dataset.dirty = String(state.notes.dirty)
|
|
2330
|
+
}
|
|
2331
|
+
const save = document.querySelector('[data-note-save]')
|
|
2332
|
+
if (save) save.disabled = !state.notes.dirty
|
|
2333
|
+
}
|
|
2334
|
+
|
|
2335
|
+
function renderNoteDocumentBreadcrumb(node) {
|
|
2336
|
+
return element('nav', { class: 'notes-breadcrumb', 'aria-label': '当前位置' },
|
|
2337
|
+
element('button', { type: 'button', onClick: () => { void openNoteRoot() } }, '笔记文档'),
|
|
2338
|
+
state.notes.breadcrumbs.map(parent => element('span', {}, element('span', { 'aria-hidden': 'true' }, '/'), element('button', { type: 'button', onClick: () => { void selectNoteNode(parent) } }, parent.name))),
|
|
2339
|
+
element('span', {}, element('span', { 'aria-hidden': 'true' }, '/'), element('strong', {}, node.name)),
|
|
2340
|
+
)
|
|
2341
|
+
}
|
|
2342
|
+
|
|
2343
|
+
function renderNoteFile(node) {
|
|
2344
|
+
if (node.editable) return renderEditableNote(node)
|
|
2345
|
+
const previewKind = noteFilePreviewKind(node)
|
|
2346
|
+
let preview
|
|
2347
|
+
if (previewKind === 'image' && state.notes.assetUrl) {
|
|
2348
|
+
preview = element('img', { class: 'notes-inline-image', src: state.notes.assetUrl, alt: node.name })
|
|
2349
|
+
} else if (previewKind === 'pdf' && state.notes.assetUrl) {
|
|
2350
|
+
preview = element('iframe', { class: 'notes-inline-frame', src: state.notes.assetUrl, title: node.name })
|
|
2351
|
+
} else {
|
|
2352
|
+
preview = element('div', { class: 'notes-inline-unavailable' },
|
|
2353
|
+
renderNoteIcon(node, true),
|
|
2354
|
+
element('h2', {}, node.name),
|
|
2355
|
+
element('p', {}, '该格式不能在浏览器中直接编辑,可以下载后使用本地应用打开。'),
|
|
2356
|
+
actionButton('下载文件', () => { void downloadNoteFile(node) }, 'primary small'),
|
|
2357
|
+
)
|
|
2358
|
+
}
|
|
2359
|
+
return element('main', { class: 'notes-content is-file' },
|
|
2360
|
+
renderNoteFileToolbar(node),
|
|
2361
|
+
element('section', { class: `notes-inline-viewer is-${previewKind}`, 'aria-label': `${node.name}内容` }, preview),
|
|
2362
|
+
)
|
|
2363
|
+
}
|
|
2364
|
+
|
|
2365
|
+
function renderNoteFileToolbar(node, options = {}) {
|
|
2366
|
+
return element('header', { class: 'notes-document-toolbar' },
|
|
2367
|
+
element('div', { class: 'notes-toolbar-leading' },
|
|
2368
|
+
renderNoteDocumentBreadcrumb(node),
|
|
2369
|
+
element('div', { class: 'notes-file-info', 'aria-label': '文件信息' },
|
|
2370
|
+
noteInfoItem('编号', shortNoteId(node.id), node.id),
|
|
2371
|
+
noteInfoItem('类型', node.kind === 'document' ? 'Markdown' : node.mediaType || '未知'),
|
|
2372
|
+
noteInfoItem('大小', formatBytes(node.size), '', 'size'),
|
|
2373
|
+
noteInfoItem('更新', formatDate(node.updatedAt), '', 'updated'),
|
|
2374
|
+
),
|
|
2375
|
+
),
|
|
2376
|
+
element('div', { class: 'notes-document-actions' },
|
|
2377
|
+
options.editable ? element('span', { class: 'notes-save-state', role: 'status', 'data-note-save-state': '', 'data-dirty': String(state.notes.dirty) }, state.notes.dirty ? '未保存' : '已保存') : null,
|
|
2378
|
+
node.kind === 'file' ? actionButton('下载', () => { void downloadNoteFile(node) }, 'ghost small') : null,
|
|
2379
|
+
actionButton('复制引用', () => { void copyNoteReference(node) }, 'ghost small'),
|
|
2380
|
+
actionButton('重命名', () => openRenameNoteNode(node), 'ghost small'),
|
|
2381
|
+
options.editable ? actionButton('保存', () => { void saveNoteDocument() }, 'primary small', { 'data-note-save': '', disabled: !state.notes.dirty }) : null,
|
|
2382
|
+
),
|
|
2383
|
+
)
|
|
2384
|
+
}
|
|
2385
|
+
|
|
2386
|
+
function noteInfoItem(label, value, title = '', key = '') {
|
|
2387
|
+
return element('span', { class: 'notes-file-info-item', title: title || value, ...(key ? { 'data-note-info': key } : {}) }, element('strong', {}, label), element('span', {}, value))
|
|
2388
|
+
}
|
|
2389
|
+
|
|
2390
|
+
function renderNoteIcon(node, large = false) {
|
|
2391
|
+
const label = node.kind === 'folder' ? '' : noteExtension(node.name)
|
|
2392
|
+
return element('span', { class: `note-node-icon is-${node.kind}${large ? ' is-large' : ''}`, 'data-media-kind': noteMediaKind(node), 'aria-hidden': 'true' }, label)
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2395
|
+
function noteKindLabel(node) {
|
|
2396
|
+
if (node.kind === 'folder') return '目录'
|
|
2397
|
+
if (node.kind === 'document') return 'Markdown 笔记'
|
|
2398
|
+
return `${node.mediaType || '文件'} ${formatBytes(node.size)}`
|
|
2399
|
+
}
|
|
2400
|
+
|
|
2401
|
+
function isMarkdownNote(node) {
|
|
2402
|
+
return node.kind === 'document' || node.mediaType === 'text/markdown' || /\.(md|markdown)$/i.test(node.name)
|
|
2403
|
+
}
|
|
2404
|
+
|
|
2405
|
+
function noteFilePreviewKind(node) {
|
|
2406
|
+
if (node.mediaType?.startsWith('image/')) return 'image'
|
|
2407
|
+
if (node.mediaType === 'application/pdf' || /\.pdf$/i.test(node.name)) return 'pdf'
|
|
2408
|
+
return 'unavailable'
|
|
2409
|
+
}
|
|
2410
|
+
|
|
2411
|
+
async function selectNoteNode(node, options = {}) {
|
|
2412
|
+
if (state.notes.dirty && !await saveNoteDocument()) return
|
|
2413
|
+
const request = ++noteSelectionRequest
|
|
2414
|
+
releaseNoteAsset()
|
|
2415
|
+
state.notes.selectedId = node.id
|
|
2416
|
+
state.notes.selectedNode = node
|
|
2417
|
+
Object.assign(state.notes, { content: '', draft: '', dirty: false })
|
|
2418
|
+
state.notes.loadingNodeId = node.id
|
|
2419
|
+
renderShell()
|
|
2420
|
+
try {
|
|
2421
|
+
const breadcrumbs = await loadNoteBreadcrumbs(node)
|
|
2422
|
+
if (request !== noteSelectionRequest || state.notes.selectedId !== node.id) return
|
|
2423
|
+
state.notes.breadcrumbs = breadcrumbs
|
|
2424
|
+
if (node.kind === 'folder') {
|
|
2425
|
+
Object.assign(state.notes, { content: '', draft: '', dirty: false })
|
|
2426
|
+
state.notes.currentFolderId = node.id
|
|
2427
|
+
if (options.toggleFolder) {
|
|
2428
|
+
if (state.notes.expandedFolders.has(node.id)) state.notes.expandedFolders.delete(node.id)
|
|
2429
|
+
else state.notes.expandedFolders.add(node.id)
|
|
2430
|
+
}
|
|
2431
|
+
await loadNoteChildren(node.id)
|
|
2432
|
+
} else if (node.editable) {
|
|
2433
|
+
const blob = await binaryRequest(`notes/${encodeURIComponent(node.id)}/content`, { responseType: 'blob', accept: node.mediaType || 'text/plain' })
|
|
2434
|
+
const content = await blob.text()
|
|
2435
|
+
if (request !== noteSelectionRequest || state.notes.selectedId !== node.id) return
|
|
2436
|
+
state.notes.content = content
|
|
2437
|
+
state.notes.draft = content
|
|
2438
|
+
state.notes.dirty = false
|
|
2439
|
+
state.notes.currentFolderId = node.parentId
|
|
2440
|
+
} else {
|
|
2441
|
+
state.notes.content = ''
|
|
2442
|
+
state.notes.draft = ''
|
|
2443
|
+
state.notes.dirty = false
|
|
2444
|
+
state.notes.currentFolderId = node.parentId
|
|
2445
|
+
if (noteFilePreviewKind(node) !== 'unavailable') {
|
|
2446
|
+
const blob = await binaryRequest(`notes/${encodeURIComponent(node.id)}/content`, { responseType: 'blob', accept: node.mediaType || 'application/octet-stream' })
|
|
2447
|
+
if (request !== noteSelectionRequest || state.notes.selectedId !== node.id) return
|
|
2448
|
+
state.notes.assetUrl = URL.createObjectURL(blob)
|
|
2449
|
+
}
|
|
2450
|
+
}
|
|
2451
|
+
} catch (error) {
|
|
2452
|
+
if (request === noteSelectionRequest) showToast(friendlyError(error), 'error')
|
|
2453
|
+
} finally {
|
|
2454
|
+
if (request === noteSelectionRequest && state.notes.loadingNodeId === node.id) {
|
|
2455
|
+
state.notes.loadingNodeId = ''
|
|
2456
|
+
renderShell()
|
|
2457
|
+
}
|
|
2458
|
+
}
|
|
2459
|
+
}
|
|
2460
|
+
|
|
2461
|
+
async function loadNoteChildren(parentId, force = false) {
|
|
2462
|
+
const key = parentId || 'root'
|
|
2463
|
+
if (!force && state.notes.loadedFolders.has(key)) return state.notes.children.get(key) || []
|
|
2464
|
+
const params = new URLSearchParams({ limit: '500' })
|
|
2465
|
+
if (parentId) params.set('parentId', parentId)
|
|
2466
|
+
const nodes = await api(`notes?${params}`)
|
|
2467
|
+
state.notes.children.set(key, nodes)
|
|
2468
|
+
state.notes.loadedFolders.add(key)
|
|
2469
|
+
return nodes
|
|
2470
|
+
}
|
|
2471
|
+
|
|
2472
|
+
async function loadNoteBreadcrumbs(node) {
|
|
2473
|
+
const parents = []
|
|
2474
|
+
let parentId = node.parentId
|
|
2475
|
+
while (parentId) {
|
|
2476
|
+
const parent = findCachedNote(parentId) || await api(`notes/${encodeURIComponent(parentId)}`)
|
|
2477
|
+
parents.unshift(parent)
|
|
2478
|
+
parentId = parent.parentId
|
|
2479
|
+
}
|
|
2480
|
+
return node.kind === 'folder' ? [...parents, node] : parents
|
|
2481
|
+
}
|
|
2482
|
+
|
|
2483
|
+
function findCachedNote(id) {
|
|
2484
|
+
for (const nodes of state.notes.children.values()) {
|
|
2485
|
+
const found = nodes.find(node => node.id === id)
|
|
2486
|
+
if (found) return found
|
|
2487
|
+
}
|
|
2488
|
+
return state.notes.selectedNode?.id === id ? state.notes.selectedNode : null
|
|
2489
|
+
}
|
|
2490
|
+
|
|
2491
|
+
async function openNoteRoot() {
|
|
2492
|
+
if (state.notes.dirty && !await saveNoteDocument()) return
|
|
2493
|
+
noteSelectionRequest += 1
|
|
2494
|
+
clearNoteSelection()
|
|
2495
|
+
await loadNoteChildren(null)
|
|
2496
|
+
renderShell()
|
|
2497
|
+
}
|
|
2498
|
+
|
|
2499
|
+
function clearNoteSelection() {
|
|
2500
|
+
releaseNoteAsset()
|
|
2501
|
+
Object.assign(state.notes, { selectedId: '', selectedNode: null, currentFolderId: null, breadcrumbs: [], content: '', draft: '', dirty: false, loadingNodeId: '' })
|
|
2502
|
+
}
|
|
2503
|
+
|
|
2504
|
+
function releaseNoteAsset() {
|
|
2505
|
+
if (!state.notes?.assetUrl) return
|
|
2506
|
+
URL.revokeObjectURL(state.notes.assetUrl)
|
|
2507
|
+
state.notes.assetUrl = ''
|
|
2508
|
+
}
|
|
2509
|
+
|
|
2510
|
+
function scheduleNoteSearch(value) {
|
|
2511
|
+
state.notes.query = value
|
|
2512
|
+
window.clearTimeout(noteSearchTimer)
|
|
2513
|
+
noteSearchTimer = window.setTimeout(async () => {
|
|
2514
|
+
try {
|
|
2515
|
+
state.notes.searchResults = value.trim() ? await api(`notes?q=${encodeURIComponent(value.trim())}&limit=200`) : []
|
|
2516
|
+
renderShell()
|
|
2517
|
+
} catch (error) { showToast(friendlyError(error), 'error') }
|
|
2518
|
+
}, 180)
|
|
2519
|
+
}
|
|
2520
|
+
|
|
2521
|
+
function openCreateNoteFolder(parentId = state.notes.currentFolderId) {
|
|
2522
|
+
const field = formField('目录名称', 'text', '', { maxlength: 255, placeholder: '例如:项目资料' })
|
|
2523
|
+
const form = element('form', { class: 'form-grid' }, field.wrapper)
|
|
2524
|
+
openSheet({
|
|
2525
|
+
title: '新建目录', description: '目录可以继续包含子目录、笔记文档和任意文件。', body: form,
|
|
2526
|
+
primaryLabel: '创建目录', onPrimary: async () => {
|
|
2527
|
+
const node = await api('notes/folders', { method: 'POST', body: { name: field.input.value, parentId } })
|
|
2528
|
+
await loadNoteChildren(parentId, true)
|
|
2529
|
+
if (parentId) state.notes.expandedFolders.add(parentId)
|
|
2530
|
+
await selectNoteNode(node)
|
|
2531
|
+
return true
|
|
2532
|
+
},
|
|
2533
|
+
})
|
|
2534
|
+
field.input.focus()
|
|
2535
|
+
}
|
|
2536
|
+
|
|
2537
|
+
function openCreateNoteDocument(parentId = state.notes.currentFolderId) {
|
|
2538
|
+
const field = formField('文档名称', 'text', '', { maxlength: 255, placeholder: '例如:部署记录' })
|
|
2539
|
+
const form = element('form', { class: 'form-grid' }, field.wrapper)
|
|
2540
|
+
openSheet({
|
|
2541
|
+
title: '新建笔记文档', description: '文档使用 Markdown 编辑,默认不会被 AI 检索或回写。', body: form,
|
|
2542
|
+
primaryLabel: '创建并编辑', onPrimary: async () => {
|
|
2543
|
+
const node = await api('notes/documents', { method: 'POST', body: { name: field.input.value, parentId } })
|
|
2544
|
+
await loadNoteChildren(parentId, true)
|
|
2545
|
+
if (parentId) state.notes.expandedFolders.add(parentId)
|
|
2546
|
+
await selectNoteNode(node)
|
|
2547
|
+
return true
|
|
2548
|
+
},
|
|
2549
|
+
})
|
|
2550
|
+
field.input.focus()
|
|
2551
|
+
}
|
|
2552
|
+
|
|
2553
|
+
function openRenameNoteNode(node) {
|
|
2554
|
+
const field = formField('名称', 'text', node.name, { maxlength: 255 })
|
|
2555
|
+
openSheet({
|
|
2556
|
+
title: '重命名', description: '知识文档中的 @ 引用使用稳定编号,不会因改名失效。', body: element('form', { class: 'form-grid' }, field.wrapper),
|
|
2557
|
+
primaryLabel: '保存名称', onPrimary: async () => {
|
|
2558
|
+
const updated = await api(`notes/${encodeURIComponent(node.id)}`, { method: 'PATCH', body: { name: field.input.value } })
|
|
2559
|
+
await loadNoteChildren(node.parentId, true)
|
|
2560
|
+
if (state.notes.selectedId === node.id) await selectNoteNode(updated)
|
|
2561
|
+
else renderShell()
|
|
2562
|
+
showToast('名称已更新。')
|
|
2563
|
+
return true
|
|
2564
|
+
},
|
|
2565
|
+
})
|
|
2566
|
+
field.input.select()
|
|
2567
|
+
}
|
|
2568
|
+
|
|
2569
|
+
async function copyNoteNode(node) {
|
|
2570
|
+
try {
|
|
2571
|
+
const copy = await api(`notes/${encodeURIComponent(node.id)}/copy`, { method: 'POST', body: {} })
|
|
2572
|
+
await loadNoteChildren(node.parentId, true)
|
|
2573
|
+
renderShell()
|
|
2574
|
+
showToast(`已创建“${copy.name}”。`)
|
|
2575
|
+
} catch (error) { showToast(friendlyError(error), 'error') }
|
|
2576
|
+
}
|
|
2577
|
+
|
|
2578
|
+
async function dropNoteNode(event, parentId) {
|
|
2579
|
+
event.preventDefault()
|
|
2580
|
+
event.stopPropagation()
|
|
2581
|
+
clearNoteDragState()
|
|
2582
|
+
const id = event.dataTransfer.getData('application/x-dsh-note-id')
|
|
2583
|
+
if (!id) {
|
|
2584
|
+
if (hasDragType(event, 'Files')) await importDroppedNotes(event.dataTransfer, parentId)
|
|
2585
|
+
return
|
|
2586
|
+
}
|
|
2587
|
+
const node = findCachedNote(id)
|
|
2588
|
+
if (!node || id === parentId || node.parentId === parentId) return
|
|
2589
|
+
try {
|
|
2590
|
+
await api(`notes/${encodeURIComponent(id)}`, { method: 'PATCH', body: { parentId } })
|
|
2591
|
+
await Promise.all([loadNoteChildren(node.parentId, true), loadNoteChildren(parentId, true)])
|
|
2592
|
+
if (state.notes.selectedId === id) state.notes.selectedNode = await api(`notes/${encodeURIComponent(id)}`)
|
|
2593
|
+
renderShell()
|
|
2594
|
+
showToast('已移动到目标目录。')
|
|
2595
|
+
} catch (error) { showToast(friendlyError(error), 'error') }
|
|
2596
|
+
}
|
|
2597
|
+
|
|
2598
|
+
async function uploadNoteFiles(files, parentId) {
|
|
2599
|
+
const summary = createNoteImportSummary()
|
|
2600
|
+
const plans = files.flatMap(file => createNoteFilePlan(file, summary))
|
|
2601
|
+
if (!plans.length) return showToast('没有可上传的文件,单文件上限为 64 MiB。', 'error')
|
|
2602
|
+
const transfer = beginNoteTransfer('uploading')
|
|
2603
|
+
if (!transfer) return
|
|
2604
|
+
prepareNoteTransfer(transfer, summary)
|
|
2605
|
+
try {
|
|
2606
|
+
await importNotePlans(plans, parentId, transfer)
|
|
2607
|
+
await loadNoteChildren(parentId, true)
|
|
2608
|
+
renderShell()
|
|
2609
|
+
completeNoteTransfer(transfer)
|
|
2610
|
+
showNoteImportResult(summary)
|
|
2611
|
+
} catch (error) {
|
|
2612
|
+
if (transfer.completedItems) await loadNoteChildren(parentId, true).catch(() => {})
|
|
2613
|
+
renderShell()
|
|
2614
|
+
failNoteTransfer(transfer, error)
|
|
2615
|
+
showToast(friendlyError(error), 'error')
|
|
2616
|
+
}
|
|
2617
|
+
}
|
|
2618
|
+
|
|
2619
|
+
async function importDroppedNotes(dataTransfer, parentId) {
|
|
2620
|
+
const payload = captureNoteDropPayload(dataTransfer)
|
|
2621
|
+
if (!payload.entries.length) return uploadNoteFiles(payload.files, parentId)
|
|
2622
|
+
const transfer = beginNoteTransfer('scanning')
|
|
2623
|
+
if (!transfer) return
|
|
2624
|
+
const summary = createNoteImportSummary()
|
|
2625
|
+
try {
|
|
2626
|
+
const plans = []
|
|
2627
|
+
for (const entry of payload.entries) {
|
|
2628
|
+
const plan = await collectNoteEntry(entry, summary, transfer)
|
|
2629
|
+
if (plan) plans.push(plan)
|
|
2630
|
+
}
|
|
2631
|
+
if (!plans.length) {
|
|
2632
|
+
dismissNoteTransfer()
|
|
2633
|
+
showToast('没有可上传的内容,单文件上限为 64 MiB。', 'error')
|
|
2634
|
+
return
|
|
2635
|
+
}
|
|
2636
|
+
prepareNoteTransfer(transfer, summary)
|
|
2637
|
+
await importNotePlans(plans, parentId, transfer)
|
|
2638
|
+
await loadNoteChildren(parentId, true)
|
|
2639
|
+
if (parentId) state.notes.expandedFolders.add(parentId)
|
|
2640
|
+
renderShell()
|
|
2641
|
+
completeNoteTransfer(transfer)
|
|
2642
|
+
showNoteImportResult(summary)
|
|
2643
|
+
} catch (error) {
|
|
2644
|
+
await loadNoteChildren(parentId, true).catch(() => {})
|
|
2645
|
+
renderShell()
|
|
2646
|
+
failNoteTransfer(transfer, error)
|
|
2647
|
+
showToast(friendlyError(error), 'error')
|
|
2648
|
+
}
|
|
2649
|
+
}
|
|
2650
|
+
|
|
2651
|
+
function createNoteImportSummary() {
|
|
2652
|
+
return { files: 0, folders: 0, skipped: 0, bytes: 0 }
|
|
2653
|
+
}
|
|
2654
|
+
|
|
2655
|
+
function createNoteFilePlan(file, summary) {
|
|
2656
|
+
if (file.size > NOTE_MAX_FILE_SIZE) {
|
|
2657
|
+
summary.skipped += 1
|
|
2658
|
+
return []
|
|
2659
|
+
}
|
|
2660
|
+
summary.files += 1
|
|
2661
|
+
summary.bytes += file.size
|
|
2662
|
+
return [{ kind: 'file', name: file.name, file }]
|
|
2663
|
+
}
|
|
2664
|
+
|
|
2665
|
+
function captureNoteDropPayload(dataTransfer) {
|
|
2666
|
+
const files = Array.from(dataTransfer?.files || [])
|
|
2667
|
+
const entries = Array.from(dataTransfer?.items || []).flatMap(item => {
|
|
2668
|
+
if (item.kind !== 'file' || typeof item.webkitGetAsEntry !== 'function') return []
|
|
2669
|
+
try {
|
|
2670
|
+
const entry = item.webkitGetAsEntry()
|
|
2671
|
+
return entry ? [entry] : []
|
|
2672
|
+
} catch {
|
|
2673
|
+
return []
|
|
2674
|
+
}
|
|
2675
|
+
})
|
|
2676
|
+
return { entries, files }
|
|
2677
|
+
}
|
|
2678
|
+
|
|
2679
|
+
async function collectNoteEntry(entry, summary, transfer) {
|
|
2680
|
+
transfer.currentName = entry.name || ''
|
|
2681
|
+
transfer.scannedItems += 1
|
|
2682
|
+
scheduleNoteTransferSync()
|
|
2683
|
+
if (entry.isDirectory) {
|
|
2684
|
+
summary.folders += 1
|
|
2685
|
+
const children = []
|
|
2686
|
+
for (const child of await readNoteDirectoryEntries(entry)) {
|
|
2687
|
+
const plan = await collectNoteEntry(child, summary, transfer)
|
|
2688
|
+
if (plan) children.push(plan)
|
|
2689
|
+
}
|
|
2690
|
+
return { kind: 'folder', name: entry.name, children }
|
|
2691
|
+
}
|
|
2692
|
+
if (!entry.isFile) return null
|
|
2693
|
+
const file = await readNoteFileEntry(entry)
|
|
2694
|
+
return createNoteFilePlan(file, summary)[0] || null
|
|
2695
|
+
}
|
|
2696
|
+
|
|
2697
|
+
async function importNotePlans(plans, parentId, transfer) {
|
|
2698
|
+
for (const plan of plans) {
|
|
2699
|
+
transfer.currentName = plan.name
|
|
2700
|
+
scheduleNoteTransferSync()
|
|
2701
|
+
if (plan.kind === 'folder') {
|
|
2702
|
+
const folder = await api('notes/folders', { method: 'POST', body: { name: plan.name, parentId } })
|
|
2703
|
+
transfer.completedItems += 1
|
|
2704
|
+
scheduleNoteTransferSync()
|
|
2705
|
+
await importNotePlans(plan.children, folder.id, transfer)
|
|
2706
|
+
continue
|
|
2707
|
+
}
|
|
2708
|
+
const settledBytes = transfer.loadedBytes
|
|
2709
|
+
await uploadNoteFile(plan.file, parentId, loaded => {
|
|
2710
|
+
transfer.loadedBytes = settledBytes + Math.min(loaded, plan.file.size)
|
|
2711
|
+
scheduleNoteTransferSync()
|
|
2712
|
+
})
|
|
2713
|
+
transfer.loadedBytes = settledBytes + plan.file.size
|
|
2714
|
+
transfer.completedItems += 1
|
|
2715
|
+
scheduleNoteTransferSync()
|
|
2716
|
+
}
|
|
2717
|
+
}
|
|
2718
|
+
|
|
2719
|
+
async function readNoteDirectoryEntries(entry) {
|
|
2720
|
+
const reader = entry.createReader()
|
|
2721
|
+
const entries = []
|
|
2722
|
+
while (true) {
|
|
2723
|
+
const batch = await new Promise((resolve, reject) => reader.readEntries(resolve, reject))
|
|
2724
|
+
if (!batch.length) return entries
|
|
2725
|
+
entries.push(...batch)
|
|
2726
|
+
}
|
|
2727
|
+
}
|
|
2728
|
+
|
|
2729
|
+
function readNoteFileEntry(entry) {
|
|
2730
|
+
return new Promise((resolve, reject) => entry.file(resolve, reject))
|
|
2731
|
+
}
|
|
2732
|
+
|
|
2733
|
+
async function uploadNoteFile(file, parentId, onProgress) {
|
|
2734
|
+
const params = new URLSearchParams({ name: file.name })
|
|
2735
|
+
if (parentId) params.set('parentId', parentId)
|
|
2736
|
+
return binaryUploadRequest(`notes/files?${params}`, file, {
|
|
2737
|
+
method: 'POST', contentType: file.type || 'application/octet-stream', onProgress,
|
|
2738
|
+
})
|
|
2739
|
+
}
|
|
2740
|
+
|
|
2741
|
+
function beginNoteTransfer(phase) {
|
|
2742
|
+
if (state.notes.transfer && ['scanning', 'uploading'].includes(state.notes.transfer.phase)) {
|
|
2743
|
+
showToast('已有导入任务正在进行,请稍候。', 'error')
|
|
2744
|
+
return null
|
|
2745
|
+
}
|
|
2746
|
+
const transfer = {
|
|
2747
|
+
id: String(++noteTransferSequence), phase, currentName: '', error: '',
|
|
2748
|
+
scannedItems: 0, completedItems: 0, totalItems: 0,
|
|
2749
|
+
loadedBytes: 0, totalBytes: 0, files: 0, folders: 0, skipped: 0,
|
|
2750
|
+
}
|
|
2751
|
+
state.notes.transfer = transfer
|
|
2752
|
+
refreshNoteTransferPanel()
|
|
2753
|
+
return transfer
|
|
2754
|
+
}
|
|
2755
|
+
|
|
2756
|
+
function prepareNoteTransfer(transfer, summary) {
|
|
2757
|
+
transfer.phase = 'uploading'
|
|
2758
|
+
transfer.currentName = ''
|
|
2759
|
+
transfer.totalItems = summary.files + summary.folders
|
|
2760
|
+
transfer.totalBytes = summary.bytes
|
|
2761
|
+
transfer.files = summary.files
|
|
2762
|
+
transfer.folders = summary.folders
|
|
2763
|
+
transfer.skipped = summary.skipped
|
|
2764
|
+
scheduleNoteTransferSync()
|
|
2765
|
+
}
|
|
2766
|
+
|
|
2767
|
+
function completeNoteTransfer(transfer) {
|
|
2768
|
+
if (state.notes.transfer !== transfer) return
|
|
2769
|
+
transfer.phase = 'complete'
|
|
2770
|
+
transfer.currentName = ''
|
|
2771
|
+
transfer.completedItems = transfer.totalItems
|
|
2772
|
+
transfer.loadedBytes = transfer.totalBytes
|
|
2773
|
+
refreshNoteTransferPanel()
|
|
2774
|
+
window.setTimeout(() => {
|
|
2775
|
+
if (state.notes.transfer === transfer && transfer.phase === 'complete') dismissNoteTransfer()
|
|
2776
|
+
}, 2600)
|
|
2777
|
+
}
|
|
2778
|
+
|
|
2779
|
+
function failNoteTransfer(transfer, error) {
|
|
2780
|
+
if (state.notes.transfer !== transfer) return
|
|
2781
|
+
transfer.phase = 'error'
|
|
2782
|
+
transfer.error = friendlyError(error)
|
|
2783
|
+
refreshNoteTransferPanel()
|
|
2784
|
+
}
|
|
2785
|
+
|
|
2786
|
+
function dismissNoteTransfer() {
|
|
2787
|
+
state.notes.transfer = null
|
|
2788
|
+
document.querySelector('.notes-transfer')?.remove()
|
|
2789
|
+
}
|
|
2790
|
+
|
|
2791
|
+
function refreshNoteTransferPanel() {
|
|
2792
|
+
const current = document.querySelector('.notes-transfer')
|
|
2793
|
+
const panel = renderNoteTransfer()
|
|
2794
|
+
if (!panel) {
|
|
2795
|
+
current?.remove()
|
|
2796
|
+
return
|
|
2797
|
+
}
|
|
2798
|
+
if (current) current.replaceWith(panel)
|
|
2799
|
+
else document.querySelector('.notes-workspace')?.append(panel)
|
|
2800
|
+
syncNoteTransferPanel()
|
|
2801
|
+
}
|
|
2802
|
+
|
|
2803
|
+
function scheduleNoteTransferSync() {
|
|
2804
|
+
if (noteTransferFrame) return
|
|
2805
|
+
noteTransferFrame = requestAnimationFrame(() => {
|
|
2806
|
+
noteTransferFrame = 0
|
|
2807
|
+
syncNoteTransferPanel()
|
|
2808
|
+
})
|
|
2809
|
+
}
|
|
2810
|
+
|
|
2811
|
+
function syncNoteTransferPanel() {
|
|
2812
|
+
const transfer = state.notes.transfer
|
|
2813
|
+
const panel = document.querySelector('.notes-transfer')
|
|
2814
|
+
if (!transfer || !panel || panel.dataset.noteTransfer !== transfer.id) return
|
|
2815
|
+
const percent = noteTransferPercent(transfer)
|
|
2816
|
+
panel.dataset.state = transfer.phase
|
|
2817
|
+
panel.style.setProperty('--notes-transfer-progress', String(percent / 100))
|
|
2818
|
+
const title = panel.querySelector('.notes-transfer-title')
|
|
2819
|
+
const detail = panel.querySelector('.notes-transfer-detail')
|
|
2820
|
+
const count = panel.querySelector('.notes-transfer-count')
|
|
2821
|
+
const percentLabel = panel.querySelector('.notes-transfer-percent')
|
|
2822
|
+
const progress = panel.querySelector('.notes-transfer-track')
|
|
2823
|
+
if (title) title.textContent = noteTransferTitle(transfer)
|
|
2824
|
+
if (detail) {
|
|
2825
|
+
detail.textContent = noteTransferDetail(transfer)
|
|
2826
|
+
detail.title = transfer.currentName || ''
|
|
2827
|
+
}
|
|
2828
|
+
if (count) count.textContent = noteTransferCount(transfer)
|
|
2829
|
+
if (percentLabel) percentLabel.textContent = transfer.phase === 'scanning' ? '正在整理' : `${percent}%`
|
|
2830
|
+
if (progress) {
|
|
2831
|
+
if (transfer.phase === 'scanning') progress.removeAttribute('aria-valuenow')
|
|
2832
|
+
else progress.setAttribute('aria-valuenow', String(percent))
|
|
2833
|
+
}
|
|
2834
|
+
}
|
|
2835
|
+
|
|
2836
|
+
function noteTransferPercent(transfer) {
|
|
2837
|
+
if (transfer.phase === 'complete') return 100
|
|
2838
|
+
const value = transfer.totalBytes > 0
|
|
2839
|
+
? transfer.loadedBytes / transfer.totalBytes
|
|
2840
|
+
: transfer.totalItems > 0 ? transfer.completedItems / transfer.totalItems : 0
|
|
2841
|
+
return Math.min(transfer.phase === 'uploading' ? 99 : 100, Math.max(0, Math.round(value * 100)))
|
|
2842
|
+
}
|
|
2843
|
+
|
|
2844
|
+
function noteTransferTitle(transfer) {
|
|
2845
|
+
if (transfer.phase === 'scanning') return '正在整理导入内容'
|
|
2846
|
+
if (transfer.phase === 'complete') return '导入完成'
|
|
2847
|
+
if (transfer.phase === 'error') return '导入未完成'
|
|
2848
|
+
return '正在导入笔记'
|
|
2849
|
+
}
|
|
2850
|
+
|
|
2851
|
+
function noteTransferDetail(transfer) {
|
|
2852
|
+
if (transfer.phase === 'error') return transfer.error || '导入过程中发生错误'
|
|
2853
|
+
if (transfer.phase === 'complete') return `${transfer.files} 个文件、${transfer.folders} 个目录已写入`
|
|
2854
|
+
if (transfer.phase === 'scanning') return transfer.currentName || '正在读取文件和目录…'
|
|
2855
|
+
return transfer.currentName || '准备写入…'
|
|
2856
|
+
}
|
|
2857
|
+
|
|
2858
|
+
function noteTransferCount(transfer) {
|
|
2859
|
+
if (transfer.phase === 'scanning') return `已读取 ${transfer.scannedItems} 项`
|
|
2860
|
+
const count = `${transfer.completedItems} / ${transfer.totalItems} 项`
|
|
2861
|
+
const bytes = transfer.totalBytes ? ` · ${formatBytes(transfer.loadedBytes)} / ${formatBytes(transfer.totalBytes)}` : ''
|
|
2862
|
+
const skipped = transfer.skipped ? ` · 跳过 ${transfer.skipped}` : ''
|
|
2863
|
+
return `${count}${bytes}${skipped}`
|
|
2864
|
+
}
|
|
2865
|
+
|
|
2866
|
+
function showNoteImportResult(summary) {
|
|
2867
|
+
const added = [summary.folders ? `${summary.folders} 个目录` : '', summary.files ? `${summary.files} 个文件` : ''].filter(Boolean).join('、')
|
|
2868
|
+
const skipped = summary.skipped ? `,跳过 ${summary.skipped} 个超过 64 MiB 的文件` : ''
|
|
2869
|
+
showToast(`已添加${added ? ` ${added}` : ''}${skipped}。`)
|
|
2870
|
+
}
|
|
2871
|
+
|
|
2872
|
+
async function saveNoteDocument() {
|
|
2873
|
+
const node = state.notes.selectedNode
|
|
2874
|
+
if (!node || !node.editable || !state.notes.dirty) return true
|
|
2875
|
+
try {
|
|
2876
|
+
const updated = await binaryRequest(`notes/${encodeURIComponent(node.id)}/content`, {
|
|
2877
|
+
method: 'PUT', body: new Blob([state.notes.draft], { type: node.mediaType || 'text/plain' }), contentType: node.mediaType || 'text/plain',
|
|
2878
|
+
})
|
|
2879
|
+
state.notes.selectedNode = updated
|
|
2880
|
+
state.notes.content = state.notes.draft
|
|
2881
|
+
state.notes.dirty = false
|
|
2882
|
+
await loadNoteChildren(node.parentId, true)
|
|
2883
|
+
const size = document.querySelector('[data-note-info="size"] > span')
|
|
2884
|
+
const updatedAt = document.querySelector('[data-note-info="updated"] > span')
|
|
2885
|
+
if (size) size.textContent = formatBytes(updated.size)
|
|
2886
|
+
if (updatedAt) updatedAt.textContent = formatDate(updated.updatedAt)
|
|
2887
|
+
syncNoteEditorChrome()
|
|
2888
|
+
showToast('文件已保存。')
|
|
2889
|
+
return true
|
|
2890
|
+
} catch (error) {
|
|
2891
|
+
showToast(friendlyError(error), 'error')
|
|
2892
|
+
return false
|
|
2893
|
+
}
|
|
2894
|
+
}
|
|
2895
|
+
|
|
2896
|
+
async function downloadNoteFile(node) {
|
|
2897
|
+
try {
|
|
2898
|
+
const blob = await binaryRequest(`notes/${encodeURIComponent(node.id)}/content?download=1`, { responseType: 'blob', accept: node.mediaType || 'application/octet-stream' })
|
|
2899
|
+
const url = URL.createObjectURL(blob)
|
|
2900
|
+
const anchor = element('a', { href: url, download: node.name })
|
|
2901
|
+
document.body.append(anchor); anchor.click(); anchor.remove()
|
|
2902
|
+
window.setTimeout(() => URL.revokeObjectURL(url), 1000)
|
|
2903
|
+
} catch (error) { showToast(friendlyError(error), 'error') }
|
|
2904
|
+
}
|
|
2905
|
+
|
|
2906
|
+
async function copyNoteReference(node) {
|
|
2907
|
+
try { await navigator.clipboard.writeText(noteReference(node)); showToast('笔记引用已复制。') }
|
|
2908
|
+
catch { showToast('复制失败,请手动复制文档编号。', 'error') }
|
|
2909
|
+
}
|
|
2910
|
+
|
|
2911
|
+
async function confirmDeleteNoteNode(node) {
|
|
2912
|
+
try {
|
|
2913
|
+
const references = await api(`notes/${encodeURIComponent(node.id)}/references`)
|
|
2914
|
+
if (references.length) {
|
|
2915
|
+
const names = references.slice(0, 4).map(item => `“${item.documentTitle}”`).join('、')
|
|
2916
|
+
return openModal({
|
|
2917
|
+
title: '仍被知识文档引用', description: node.name,
|
|
2918
|
+
body: element('p', {}, `${names}${references.length > 4 ? `等 ${references.length} 篇文档` : ''}仍在引用这里的内容。请先移除引用,再删除。`),
|
|
2919
|
+
cancelLabel: '知道了',
|
|
2920
|
+
})
|
|
2921
|
+
}
|
|
2922
|
+
openConfirm({
|
|
2923
|
+
title: `删除“${node.name}”?`,
|
|
2924
|
+
message: node.kind === 'folder' ? '目录及其全部子目录和文件会被永久删除。' : '该文档会被永久删除,此操作无法撤销。',
|
|
2925
|
+
confirmLabel: '永久删除', danger: true,
|
|
2926
|
+
onConfirm: async () => {
|
|
2927
|
+
await api(`notes/${encodeURIComponent(node.id)}`, { method: 'DELETE' })
|
|
2928
|
+
await loadNoteChildren(node.parentId, true)
|
|
2929
|
+
if (state.notes.selectedId === node.id || state.notes.breadcrumbs.some(parent => parent.id === node.id)) clearNoteSelection()
|
|
2930
|
+
renderShell(); showToast('已删除。')
|
|
2931
|
+
},
|
|
2932
|
+
})
|
|
2933
|
+
} catch (error) { showToast(friendlyError(error), 'error') }
|
|
2934
|
+
}
|
|
2935
|
+
|
|
2936
|
+
function noteWorkspaceDragEnter(event) {
|
|
2937
|
+
if (!hasDragType(event, 'Files')) return
|
|
2938
|
+
event.preventDefault()
|
|
2939
|
+
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy'
|
|
2940
|
+
event.currentTarget.dataset.dropActive = 'true'
|
|
2941
|
+
}
|
|
2942
|
+
|
|
2943
|
+
function noteWorkspaceDragLeave(event) {
|
|
2944
|
+
if (event.currentTarget.contains(event.relatedTarget)) return
|
|
2945
|
+
clearNoteDragState()
|
|
2946
|
+
}
|
|
2947
|
+
|
|
2948
|
+
function activateNoteFolderDropTarget(event, node) {
|
|
2949
|
+
if (node.kind !== 'folder') return
|
|
2950
|
+
const hasFiles = hasDragType(event, 'Files')
|
|
2951
|
+
if (!hasFiles && !hasDragType(event, 'application/x-dsh-note-id')) return
|
|
2952
|
+
event.preventDefault()
|
|
2953
|
+
event.stopPropagation()
|
|
2954
|
+
if (event.dataTransfer) event.dataTransfer.dropEffect = hasFiles ? 'copy' : 'move'
|
|
2955
|
+
const workspace = document.querySelector('.notes-workspace')
|
|
2956
|
+
if (workspace) workspace.dataset.dropActive = 'false'
|
|
2957
|
+
document.querySelectorAll('.notes-tree-item[data-drop-target="true"]').forEach(target => {
|
|
2958
|
+
if (target !== event.currentTarget) target.dataset.dropTarget = 'false'
|
|
2959
|
+
})
|
|
2960
|
+
event.currentTarget.dataset.dropTarget = 'true'
|
|
2961
|
+
}
|
|
2962
|
+
|
|
2963
|
+
function hasDragType(event, type) {
|
|
2964
|
+
return Array.from(event.dataTransfer?.types || []).includes(type)
|
|
2965
|
+
}
|
|
2966
|
+
|
|
2967
|
+
function clearNoteDragState() {
|
|
2968
|
+
const workspace = document.querySelector('.notes-workspace')
|
|
2969
|
+
if (workspace) workspace.dataset.dropActive = 'false'
|
|
2970
|
+
document.querySelectorAll('.notes-tree-item[data-drop-target="true"]').forEach(node => { node.dataset.dropTarget = 'false' })
|
|
2971
|
+
document.querySelectorAll('.notes-tree-item[data-dragging="true"]').forEach(node => { node.dataset.dragging = 'false' })
|
|
2972
|
+
}
|
|
2973
|
+
|
|
2974
|
+
function installNoteDragRecovery() {
|
|
2975
|
+
window.addEventListener('drop', clearNoteDragState, true)
|
|
2976
|
+
window.addEventListener('dragend', clearNoteDragState, true)
|
|
2977
|
+
window.addEventListener('blur', clearNoteDragState)
|
|
2978
|
+
document.addEventListener('dragleave', event => {
|
|
2979
|
+
if (event.relatedTarget === null) clearNoteDragState()
|
|
2980
|
+
}, true)
|
|
2981
|
+
}
|
|
2982
|
+
|
|
2983
|
+
function noteReference(node) {
|
|
2984
|
+
const label = node.name.replaceAll('\\', '\\\\').replaceAll('[', '\\[').replaceAll(']', '\\]')
|
|
2985
|
+
return `@[${label}](note://${node.id})`
|
|
2986
|
+
}
|
|
2987
|
+
|
|
2988
|
+
function noteMediaKind(node) {
|
|
2989
|
+
if (node.kind === 'folder') return 'folder'
|
|
2990
|
+
if (node.kind === 'document' || node.mediaType?.startsWith('text/')) return 'text'
|
|
2991
|
+
if (node.mediaType?.startsWith('image/')) return 'image'
|
|
2992
|
+
if (node.mediaType === 'application/pdf') return 'pdf'
|
|
2993
|
+
return 'file'
|
|
2994
|
+
}
|
|
2995
|
+
|
|
2996
|
+
function noteExtension(name) {
|
|
2997
|
+
const extension = name.includes('.') ? name.split('.').pop() : 'FILE'
|
|
2998
|
+
return extension.slice(0, 4).toLocaleUpperCase()
|
|
2999
|
+
}
|
|
3000
|
+
|
|
3001
|
+
function shortNoteId(id) {
|
|
3002
|
+
return `${id.slice(0, 11)}…${id.slice(-5)}`
|
|
3003
|
+
}
|
|
3004
|
+
|
|
3005
|
+
function formatBytes(value) {
|
|
3006
|
+
if (value < 1024) return `${value} B`
|
|
3007
|
+
if (value < 1024 * 1024) return `${(value / 1024).toFixed(value < 10 * 1024 ? 1 : 0)} KiB`
|
|
3008
|
+
return `${(value / 1024 / 1024).toFixed(value < 10 * 1024 * 1024 ? 1 : 0)} MiB`
|
|
3009
|
+
}
|
|
3010
|
+
|
|
1487
3011
|
function renderCandidates() {
|
|
1488
3012
|
const statuses = [['pending', '待审核'], ['approved', '已通过'], ['rejected', '已拒绝']]
|
|
1489
3013
|
return element('section', { 'aria-labelledby': 'candidates-heading' },
|
|
@@ -2184,7 +3708,7 @@ function openTokenCreator() {
|
|
|
2184
3708
|
return { permission, input, node: element('label', { class: 'check-option' }, input, permission) }
|
|
2185
3709
|
})
|
|
2186
3710
|
const body = element('form', { class: 'form-grid' }, name.wrapper,
|
|
2187
|
-
element('div', { class: 'field span-2' }, element('label', {}, '权限'), element('div', { class: 'check-grid' }, checkboxes.map(item => item.node)), element('span', { class: 'field-hint' }, '普通客户端建议只授予 read + propose。')))
|
|
3711
|
+
element('div', { class: 'field span-2' }, element('label', {}, '权限'), element('div', { class: 'check-grid' }, checkboxes.map(item => item.node)), element('span', { class: 'field-hint' }, '普通客户端建议只授予 read + propose。write 是当前中央服务的全局写权限,同时允许直接写入、管理知识库、挂载和笔记;仅在确实需要时授予。')))
|
|
2188
3712
|
name.wrapper.classList.add('span-2')
|
|
2189
3713
|
openModal({ title: '创建客户端令牌', description: '原始令牌只会在创建成功后显示一次。', body, primaryLabel: '创建令牌', onPrimary: async () => {
|
|
2190
3714
|
if (!body.reportValidity()) return false
|
|
@@ -2233,21 +3757,24 @@ function openSheet(options) {
|
|
|
2233
3757
|
return openModal({ ...options, presentation: 'sheet' })
|
|
2234
3758
|
}
|
|
2235
3759
|
|
|
2236
|
-
function openModal({ title, description = '', body, primaryLabel, primaryVariant = 'primary', onPrimary, cancelLabel = '取消', presentation = 'modal' }) {
|
|
3760
|
+
function openModal({ title, description = '', body, primaryLabel, primaryVariant = 'primary', onPrimary, cancelLabel = '取消', presentation = 'modal', onClose }) {
|
|
2237
3761
|
const previouslyFocused = document.activeElement
|
|
2238
3762
|
const isSheet = presentation === 'sheet'
|
|
2239
3763
|
const backdrop = element('div', { class: `dialog-backdrop${isSheet ? ' sheet-backdrop' : ''}` })
|
|
2240
3764
|
const dialog = element('section', { class: `dialog${isSheet ? ' sheet' : ''} ${primaryLabel ? '' : 'narrow'}`.trim(), role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'dialog-title' })
|
|
2241
3765
|
let busy = false
|
|
2242
3766
|
let formDirty = false
|
|
3767
|
+
let closed = false
|
|
2243
3768
|
const close = (explicit = false) => {
|
|
2244
|
-
if (busy) return
|
|
3769
|
+
if (busy || closed) return
|
|
2245
3770
|
if (!explicit && formDirty) {
|
|
2246
3771
|
showToast('表单有未保存的修改,请先保存,或使用“取消”放弃修改。', 'error')
|
|
2247
3772
|
return
|
|
2248
3773
|
}
|
|
2249
3774
|
document.removeEventListener('keydown', onKeyDown)
|
|
3775
|
+
closed = true
|
|
2250
3776
|
backdrop.remove()
|
|
3777
|
+
onClose?.()
|
|
2251
3778
|
if (previouslyFocused instanceof HTMLElement) previouslyFocused.focus()
|
|
2252
3779
|
}
|
|
2253
3780
|
const closeButton = actionButton('×', () => close(), 'ghost', { 'aria-label': '关闭对话框' })
|
|
@@ -2319,9 +3846,10 @@ function friendlyError(error) {
|
|
|
2319
3846
|
|
|
2320
3847
|
window.addEventListener('beforeunload', event => {
|
|
2321
3848
|
const editor = activeDocumentWorkspace()?.view.editor
|
|
2322
|
-
if (!editor?.dirty) return
|
|
3849
|
+
if (!editor?.dirty && !state.notes.dirty) return
|
|
2323
3850
|
event.preventDefault()
|
|
2324
3851
|
event.returnValue = ''
|
|
2325
3852
|
})
|
|
2326
3853
|
|
|
3854
|
+
installNoteDragRecovery()
|
|
2327
3855
|
void installHostThemeBridge().then(() => boot())
|