@exccoder/dsh-media-workbench 0.0.0-stage → 0.2.2

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.
@@ -0,0 +1,513 @@
1
+ import { api, streamWrite } from '../lib/api.js'
2
+ import { $, $$, debounce, downloadUrl, esc, fmtTime, wordCount } from '../lib/dom.js'
3
+ import { icon } from '../lib/icons.js'
4
+ import { app, t } from '../lib/state.js'
5
+ import { ACTIONS, LENGTHS, POVS, TONES } from '../lib/constants.js'
6
+
7
+ export function mount(root) {
8
+ const prefs = app.state?.preferences ?? {}
9
+ let activeDocId = sessionStorage.getItem('media-workbench-modern:doc') || null
10
+ let tone = prefs.writingTone ?? '自然叙述'
11
+ let pov = prefs.writingPov ?? '第三人称'
12
+ let length = prefs.writingLength ?? 'medium'
13
+ let action = 'continue'
14
+ let query = ''
15
+ let dirty = false
16
+ let saving = null
17
+ let selection = { start: 0, end: 0, text: '' }
18
+ let streamController = null
19
+ let resultText = ''
20
+ let resultLabel = ''
21
+ let history = []
22
+ let focusMode = false
23
+
24
+ root.innerHTML = `
25
+ <section class="bench bench-write" id="write-bench">
26
+ <aside class="docs-pane">
27
+ <header class="docs-head">
28
+ <div><span class="eyebrow">${esc(t('作品库', 'LIBRARY'))}</span><h2>${esc(t('写作工作台', 'Writing Studio'))}</h2></div>
29
+ <button class="icon-btn" id="new-doc" title="${esc(t('新建作品', 'New document'))}">${icon('plus', 17)}</button>
30
+ </header>
31
+ <label class="search search-wide">${icon('search', 15)}<input id="doc-search" type="search" placeholder="${esc(t('搜索标题或正文', 'Search title or text'))}"></label>
32
+ <div class="docs-list" id="docs-list"></div>
33
+ <footer class="docs-foot"><span id="docs-total">0</span> ${esc(t('篇作品 · 自动保存在本机', 'documents, saved locally'))}</footer>
34
+ </aside>
35
+
36
+ <section class="editor-pane">
37
+ <div class="editor-top">
38
+ <input id="doc-title" class="title-input" placeholder="${esc(t('未命名作品', 'Untitled'))}" maxlength="120">
39
+ <div class="editor-meta">
40
+ <span id="word-count">0 ${esc(t('字', 'chars'))}</span>
41
+ <span class="dot-sep"></span>
42
+ <span id="save-state">${esc(t('已保存', 'Saved'))}</span>
43
+ <span class="spacer"></span>
44
+ <button class="text-btn" id="export-doc">${icon('download', 14)}${esc(t('导出', 'Export'))}</button>
45
+ <button class="text-btn" id="focus-mode">${icon('eye', 14)}${esc(t('专注', 'Focus'))}</button>
46
+ </div>
47
+ </div>
48
+ <div class="editor-wrap">
49
+ <textarea id="doc-content" spellcheck="false" placeholder="${esc(t('从这里开始写。写到卡住时,看右边。', 'Start here. When you get stuck, look right.'))}"></textarea>
50
+ </div>
51
+ <div class="editor-foot">
52
+ <span id="selection-info">${esc(t('选中文字可以只改这一段', 'Select text to edit just that part'))}</span>
53
+ <span class="editor-hint">${esc(t('自动保存', 'Autosave'))} · ${esc(t('Markdown', 'Markdown'))}</span>
54
+ </div>
55
+ </section>
56
+
57
+ <aside class="ai-pane">
58
+ <header class="ai-head">
59
+ <div><span class="eyebrow">${esc(t('写作搭档', 'AI PARTNER'))}</span><h2>${esc(t('接着写', 'Keep writing'))}</h2></div>
60
+ <span class="model-chip" id="ai-model"></span>
61
+ </header>
62
+ <div class="ai-scroll">
63
+ <div class="ai-section">
64
+ <span class="field-label">${esc(t('想做什么', 'Action'))}</span>
65
+ <div class="ai-actions" id="ai-actions">
66
+ ${ACTIONS.map(item => `<button type="button" class="ai-action" data-action="${item.id}"><b>${esc(item.label)}</b><small>${esc(item.hint)}</small></button>`).join('')}
67
+ </div>
68
+ </div>
69
+ <div class="ai-section">
70
+ <span class="field-label">${esc(t('文风', 'Voice'))}</span>
71
+ <div class="chips chips-tight" id="tones">
72
+ ${TONES.map(item => `<button type="button" class="chip" data-tone="${esc(item)}">${esc(item)}</button>`).join('')}
73
+ </div>
74
+ <div class="row-2">
75
+ <select class="select" id="pov">${POVS.map(item => `<option value="${esc(item)}" ${item === pov ? 'selected' : ''}>${esc(item)}</option>`).join('')}</select>
76
+ <select class="select" id="length">${LENGTHS.map(item => `<option value="${item.id}" ${item.id === length ? 'selected' : ''}>${esc(item.label)}</option>`).join('')}</select>
77
+ </div>
78
+ </div>
79
+ <label class="field">
80
+ <span class="field-label">${esc(t('补充指令', 'Note'))}<em>${esc(t('可选', 'optional'))}</em></span>
81
+ <textarea id="instruction" rows="2" maxlength="600" placeholder="${esc(t('例如:把第三段改得更克制;用第一人称重写', 'e.g. make paragraph three colder; rewrite in first person'))}"></textarea>
82
+ </label>
83
+ <label class="check-line"><input type="checkbox" id="use-selection"> <span>${esc(t('只处理选中文字', 'Only the selection'))}</span></label>
84
+ <button class="btn btn-primary btn-block" id="ai-run">${icon('wandSpark', 16)}<span>${esc(t('开始写', 'Write'))}</span></button>
85
+
86
+ <div class="ai-result" id="ai-result" hidden>
87
+ <div class="ai-result-head">
88
+ <b id="result-label">${esc(t('草稿', 'Draft'))}</b>
89
+ <span class="spacer"></span>
90
+ <button class="text-btn" id="ai-stop" hidden>${icon('stop', 13)}${esc(t('停止', 'Stop'))}</button>
91
+ </div>
92
+ <div class="ai-result-body" id="result-body"></div>
93
+ <div class="ai-result-actions">
94
+ <button class="btn btn-small btn-primary" data-apply="append">${esc(t('追加到正文', 'Append'))}</button>
95
+ <button class="btn btn-small btn-ghost" data-apply="replace">${esc(t('替换', 'Replace'))}</button>
96
+ <button class="btn btn-small btn-ghost" data-apply="insert">${esc(t('插入光标', 'Insert'))}</button>
97
+ <button class="btn btn-small btn-ghost" data-apply="copy">${esc(t('复制', 'Copy'))}</button>
98
+ <button class="btn btn-small btn-ghost danger-quiet" data-apply="discard">${esc(t('丢弃', 'Discard'))}</button>
99
+ </div>
100
+ </div>
101
+
102
+ <div class="ai-history" id="ai-history"></div>
103
+ </div>
104
+ </aside>
105
+ </section>
106
+ `
107
+
108
+ const titleEl = $('#doc-title')
109
+ const contentEl = $('#doc-content')
110
+ const listEl = $('#docs-list')
111
+
112
+ const saveLater = debounce(() => { void save() }, 900)
113
+
114
+ // ---- 初始化当前文档 ----
115
+ function docs() {
116
+ return app.state?.docs ?? []
117
+ }
118
+
119
+ function currentDoc() {
120
+ return docs().find(doc => doc.id === activeDocId) ?? null
121
+ }
122
+
123
+ function loadDoc(doc, { focus = false } = {}) {
124
+ activeDocId = doc?.id ?? null
125
+ if (doc) sessionStorage.setItem('media-workbench-modern:doc', doc.id)
126
+ titleEl.value = doc?.title ?? ''
127
+ contentEl.value = doc?.content ?? ''
128
+ dirty = false
129
+ paintSave('saved')
130
+ updateCounts()
131
+ updateSelection()
132
+ paintDocs()
133
+ if (focus) titleEl.focus()
134
+ }
135
+
136
+ // 首屏:优先上次打开的,其次最新一篇;都没有就留空等用户输入
137
+ const initial = currentDoc() ?? docs()[0] ?? null
138
+ if (!activeDocId && initial) activeDocId = initial.id
139
+ loadDoc(initial)
140
+
141
+ titleEl.addEventListener('input', () => { dirty = true; paintSave('dirty'); saveLater() })
142
+ contentEl.addEventListener('input', () => {
143
+ dirty = true
144
+ paintSave('dirty')
145
+ updateCounts()
146
+ saveLater()
147
+ })
148
+ contentEl.addEventListener('select', updateSelection)
149
+ contentEl.addEventListener('keyup', updateSelection)
150
+ contentEl.addEventListener('mouseup', updateSelection)
151
+ contentEl.addEventListener('click', updateSelection)
152
+
153
+ titleEl.addEventListener('keydown', event => {
154
+ if (event.key === 'Enter') { event.preventDefault(); contentEl.focus() }
155
+ })
156
+
157
+ $('#new-doc').addEventListener('click', async () => {
158
+ await save(true)
159
+ try {
160
+ const data = await api('document/save', { title: t('未命名作品', 'Untitled'), content: '' })
161
+ app.state.docs.unshift(data.doc)
162
+ loadDoc(data.doc, { focus: true })
163
+ paintDocs()
164
+ app.toast(t('新作品已建好', 'New document created'), 'success')
165
+ } catch (err) { app.toast(err.message, 'error') }
166
+ })
167
+
168
+ $('#doc-search').addEventListener('input', () => { query = $('#doc-search').value.trim().toLowerCase(); paintDocs() })
169
+
170
+ listEl.addEventListener('click', async event => {
171
+ const item = event.target.closest('[data-doc]')
172
+ const act = event.target.closest('[data-doc-act]')
173
+ if (act) {
174
+ event.stopPropagation()
175
+ const id = act.dataset.id
176
+ const kind = act.dataset.docAct
177
+ if (kind === 'delete') {
178
+ const ok = await app.confirm({ title: t('删除这篇作品?', 'Delete this document?'), message: t('删除后无法从工作台恢复。', 'This cannot be undone in the workbench.'), confirmText: t('删除', 'Delete'), danger: true })
179
+ if (!ok) return
180
+ try {
181
+ await api('document/action', { id, action: 'delete' })
182
+ app.state.docs = app.state.docs.filter(doc => doc.id !== id)
183
+ if (activeDocId === id) loadDoc(app.state.docs[0] ?? null)
184
+ paintDocs()
185
+ app.toast(t('已删除', 'Deleted'), 'success')
186
+ } catch (err) { app.toast(err.message, 'error') }
187
+ return
188
+ }
189
+ if (kind === 'duplicate') {
190
+ try {
191
+ const data = await api('document/action', { id, action: 'duplicate' })
192
+ app.state.docs.unshift(data.doc)
193
+ paintDocs()
194
+ app.toast(t('副本已创建', 'Duplicated'), 'success')
195
+ } catch (err) { app.toast(err.message, 'error') }
196
+ }
197
+ return
198
+ }
199
+ if (!item) return
200
+ const id = item.dataset.doc
201
+ if (id === activeDocId) return
202
+ await save(true)
203
+ const doc = docs().find(row => row.id === id)
204
+ if (doc) loadDoc(doc)
205
+ })
206
+
207
+ $('#export-doc').addEventListener('click', () => {
208
+ const title = titleEl.value.trim() || t('未命名作品', 'Untitled')
209
+ const body = `# ${title}\n\n${contentEl.value}`
210
+ const blob = new Blob([body], { type: 'text/markdown;charset=utf-8' })
211
+ const url = URL.createObjectURL(blob)
212
+ downloadUrl(url, `${title.replace(/[/\\:*?"<>|]/g, '_')}.md`)
213
+ setTimeout(() => URL.revokeObjectURL(url), 3000)
214
+ })
215
+
216
+ $('#focus-mode').addEventListener('click', () => {
217
+ focusMode = !focusMode
218
+ $('#write-bench').classList.toggle('is-focus', focusMode)
219
+ $('#focus-mode').innerHTML = `${icon('eye', 14)}${focusMode ? t('退出专注', 'Exit focus') : t('专注', 'Focus')}`
220
+ contentEl.focus()
221
+ })
222
+
223
+ $('#tones').addEventListener('click', event => {
224
+ const btn = event.target.closest('[data-tone]')
225
+ if (!btn) return
226
+ tone = btn.dataset.tone
227
+ paintTone()
228
+ })
229
+ $('#pov').addEventListener('change', () => { pov = $('#pov').value })
230
+ $('#length').addEventListener('change', () => { length = $('#length').value })
231
+ $('#use-selection').addEventListener('change', updateSelection)
232
+ $('#ai-actions').addEventListener('click', event => {
233
+ const btn = event.target.closest('[data-action]')
234
+ if (!btn) return
235
+ action = btn.dataset.action
236
+ paintAction()
237
+ if (!streamController) run()
238
+ })
239
+ $('#ai-run').addEventListener('click', run)
240
+ $('#ai-stop').addEventListener('click', () => { streamController?.abort() })
241
+
242
+ $('#ai-result').addEventListener('click', async event => {
243
+ const btn = event.target.closest('[data-apply]')
244
+ if (!btn) return
245
+ const mode = btn.dataset.apply
246
+ if (mode === 'discard') { hideResult(); return }
247
+ if (mode === 'copy') {
248
+ try { await navigator.clipboard.writeText(resultText); app.toast(t('已复制', 'Copied'), 'success') } catch { app.toast(t('复制失败', 'Copy failed'), 'error') }
249
+ return
250
+ }
251
+ if (!resultText) return
252
+ if (mode === 'replace' && !selection.text) {
253
+ const ok = await app.confirm({ title: t('替换整篇正文?', 'Replace the whole manuscript?'), message: t('当前正文会被这段草稿替换。', 'The current text will be replaced by this draft.'), confirmText: t('替换', 'Replace'), danger: true })
254
+ if (!ok) return
255
+ }
256
+ applyText(mode)
257
+ hideResult()
258
+ })
259
+
260
+ function paintTone() {
261
+ $$('#tones [data-tone]').forEach(btn => btn.classList.toggle('is-on', btn.dataset.tone === tone))
262
+ }
263
+ paintTone()
264
+
265
+ function paintAction() {
266
+ $$('#ai-actions [data-action]').forEach(btn => btn.classList.toggle('is-on', btn.dataset.action === action))
267
+ const hints = {
268
+ rewrite: t('例如:把语气改得更克制,去掉形容词', 'e.g. colder tone, fewer adjectives'),
269
+ custom: t('写下你想怎么改', 'Describe the change'),
270
+ outline: t('可选:想强调什么', 'Optional: what to emphasize'),
271
+ title: t('可选:面向谁、什么平台', 'Optional: audience or platform'),
272
+ }
273
+ $('#instruction').placeholder = hints[action] ?? t('可选:补充你的要求', 'Optional: any extra direction')
274
+ }
275
+ paintAction()
276
+
277
+ function updateCounts() {
278
+ const chars = [...contentEl.value].length
279
+ const words = wordCount(contentEl.value)
280
+ $('#word-count').textContent = app.lang === 'en' ? `${words} words` : `${chars} 字`
281
+ }
282
+
283
+ function updateSelection() {
284
+ const start = contentEl.selectionStart ?? 0
285
+ const end = contentEl.selectionEnd ?? 0
286
+ selection = { start, end, text: contentEl.value.slice(start, end) }
287
+ const info = $('#selection-info')
288
+ if (selection.text) {
289
+ info.textContent = t(`已选中 ${[...selection.text].length} 字 · 可以只改这一段`, `${[...selection.text].length} chars selected`)
290
+ } else {
291
+ info.textContent = t('选中文字可以只改这一段', 'Select text to edit just that part')
292
+ }
293
+ const checkbox = $('#use-selection')
294
+ if (!selection.text && checkbox.checked) checkbox.checked = false
295
+ if (selection.text && !checkbox.checked && document.activeElement === contentEl) checkbox.checked = false
296
+ }
297
+
298
+ function paintSave(mode) {
299
+ const el = $('#save-state')
300
+ if (!el) return
301
+ if (mode === 'saving') { el.textContent = t('正在保存…', 'Saving…'); el.className = 'save-state is-saving' }
302
+ else if (mode === 'dirty') { el.textContent = t('未保存', 'Unsaved'); el.className = 'save-state is-dirty' }
303
+ else if (mode === 'error') { el.textContent = t('保存失败', 'Save failed'); el.className = 'save-state is-error' }
304
+ else { el.textContent = t('已保存', 'Saved'); el.className = 'save-state' }
305
+ }
306
+
307
+ function paintDocs() {
308
+ const all = docs()
309
+ const filtered = all.filter(doc => !query || `${doc.title}\n${doc.content}`.toLowerCase().includes(query))
310
+ $('#docs-total').textContent = String(all.length)
311
+ if (!filtered.length) {
312
+ listEl.innerHTML = `<div class="docs-empty">${esc(all.length ? t('没有匹配的作品', 'No matching documents') : t('还没有作品,点右上角新建。', 'No documents yet. Create one above.'))}</div>`
313
+ return
314
+ }
315
+ listEl.innerHTML = filtered.map(doc => {
316
+ const isOn = doc.id === activeDocId
317
+ const excerpt = doc.content.replace(/\s+/g, ' ').slice(0, 46)
318
+ return `<article class="doc-item ${isOn ? 'is-on' : ''}" data-doc="${doc.id}" tabindex="0" role="button">
319
+ <div class="doc-item-top"><b>${esc(doc.title || t('未命名作品', 'Untitled'))}</b><time>${esc(fmtTime(doc.updatedAt))}</time></div>
320
+ <p>${esc(excerpt || t('还没有内容', 'Empty'))}</p>
321
+ <div class="doc-item-actions">
322
+ <button type="button" data-doc-act="duplicate" data-id="${doc.id}" title="${esc(t('创建副本', 'Duplicate'))}">${icon('duplicate', 12)}</button>
323
+ <button type="button" data-doc-act="delete" data-id="${doc.id}" title="${esc(t('删除', 'Delete'))}">${icon('trash', 12)}</button>
324
+ </div>
325
+ </article>`
326
+ }).join('')
327
+ }
328
+
329
+ async function save(force = false) {
330
+ if (!force && !dirty) return null
331
+ const title = titleEl.value.trim() || t('未命名作品', 'Untitled')
332
+ const content = contentEl.value
333
+ paintSave('saving')
334
+ try {
335
+ const data = await api('document/save', { id: activeDocId ?? '', title, content })
336
+ activeDocId = data.doc.id
337
+ sessionStorage.setItem('media-workbench-modern:doc', activeDocId)
338
+ const index = app.state.docs.findIndex(doc => doc.id === activeDocId)
339
+ if (index >= 0) app.state.docs[index] = data.doc
340
+ else app.state.docs.unshift(data.doc)
341
+ dirty = false
342
+ paintSave('saved')
343
+ paintDocs()
344
+ return data.doc
345
+ } catch (err) {
346
+ paintSave('error')
347
+ app.toast(err.message, 'error')
348
+ return null
349
+ }
350
+ }
351
+
352
+ function applyText(mode) {
353
+ if (mode === 'append') {
354
+ const base = contentEl.value.replace(/\s+$/, '')
355
+ contentEl.value = base ? `${base}\n\n${resultText}` : resultText
356
+ } else if (mode === 'replace') {
357
+ if (selection.text) contentEl.setRangeText(resultText, selection.start, selection.end, 'end')
358
+ else contentEl.value = resultText
359
+ } else if (mode === 'insert') {
360
+ const at = contentEl.selectionStart ?? contentEl.value.length
361
+ contentEl.setRangeText(resultText, at, at, 'end')
362
+ }
363
+ contentEl.dispatchEvent(new Event('input'))
364
+ contentEl.focus()
365
+ }
366
+
367
+ function hideResult() {
368
+ resultText = ''
369
+ $('#ai-result').hidden = true
370
+ $('#result-body').textContent = ''
371
+ }
372
+
373
+ function paintResultLabel() {
374
+ const item = ACTIONS.find(row => row.id === action)
375
+ $('#result-label').textContent = `${item?.label ?? t('草稿', 'Draft')} · ${fmtTime(new Date().toISOString())}`
376
+ }
377
+
378
+ async function run() {
379
+ if (streamController) { app.toast(t('正在写,等这一段落完', 'Still writing — wait for it to finish'), 'info'); return }
380
+ if (!app.model) {
381
+ app.toast(t('当前没有写作模型。先在 DSH 里选一个模型。', 'No writing model. Choose one in DSH first.'), 'error')
382
+ return
383
+ }
384
+ if (action === 'rewrite' && !$('#instruction').value.trim() && !selection.text) {
385
+ // 允许直接改写,但给一句默认指令
386
+ }
387
+ const useSelection = $('#use-selection').checked
388
+ if (useSelection && !selection.text) {
389
+ app.toast(t('先选中一段文字', 'Select some text first'), 'error')
390
+ return
391
+ }
392
+ if (!contentEl.value.trim() && action !== 'outline' && action !== 'title' && action !== 'custom') {
393
+ app.toast(t('正文还是空的,先写一句', 'The manuscript is empty — write a line first'), 'error')
394
+ contentEl.focus()
395
+ return
396
+ }
397
+ const controller = new AbortController()
398
+ streamController = controller
399
+ resultText = ''
400
+ const result = $('#ai-result')
401
+ result.hidden = false
402
+ const body = $('#result-body')
403
+ body.textContent = ''
404
+ body.classList.add('is-streaming')
405
+ paintResultLabel()
406
+ $('#ai-stop').hidden = false
407
+ $('#ai-run').disabled = true
408
+ $('#ai-run').querySelector('span').textContent = t('正在写…', 'Writing…')
409
+ try {
410
+ const data = await streamWrite({
411
+ action,
412
+ title: titleEl.value,
413
+ content: contentEl.value,
414
+ selection: useSelection ? selection.text : '',
415
+ instruction: $('#instruction').value.trim(),
416
+ tone,
417
+ pov,
418
+ length,
419
+ lang: app.lang,
420
+ }, {
421
+ signal: controller.signal,
422
+ onDelta: (_delta, full) => {
423
+ resultText = full
424
+ body.textContent = full
425
+ body.scrollTop = body.scrollHeight
426
+ },
427
+ })
428
+ resultText = data.text || resultText
429
+ body.textContent = resultText
430
+ history.unshift({ action, text: resultText, at: new Date().toISOString(), model: data.model })
431
+ history = history.slice(0, 6)
432
+ paintHistory()
433
+ app.toast(t('写完了', 'Draft ready'), 'success')
434
+ } catch (err) {
435
+ if (controller.signal.aborted) {
436
+ app.toast(t('已停止', 'Stopped'), 'info')
437
+ } else {
438
+ body.textContent = err.message
439
+ body.classList.add('is-error')
440
+ app.toast(err.message, 'error')
441
+ }
442
+ } finally {
443
+ streamController = null
444
+ body.classList.remove('is-streaming')
445
+ $('#ai-stop').hidden = true
446
+ $('#ai-run').disabled = false
447
+ $('#ai-run').querySelector('span').textContent = t('开始写', 'Write')
448
+ if (!resultText) result.hidden = true
449
+ }
450
+ }
451
+
452
+ function paintHistory() {
453
+ const host = $('#ai-history')
454
+ if (!history.length) { host.innerHTML = ''; return }
455
+ host.innerHTML = `<span class="field-label">${esc(t('刚才的草稿', 'Recent drafts'))}</span>` + history.map((row, index) => {
456
+ const label = ACTIONS.find(item => item.id === row.action)?.label ?? t('草稿', 'Draft')
457
+ return `<button type="button" class="history-row" data-history="${index}"><b>${esc(label)}</b><span>${esc(row.text.replace(/\s+/g, ' ').slice(0, 40))}</span><time>${esc(fmtTime(row.at))}</time></button>`
458
+ }).join('')
459
+ host.querySelectorAll('[data-history]').forEach(btn => btn.addEventListener('click', () => {
460
+ const row = history[Number(btn.dataset.history)]
461
+ if (!row) return
462
+ resultText = row.text
463
+ action = row.action
464
+ paintAction()
465
+ paintResultLabel()
466
+ $('#ai-result').hidden = false
467
+ $('#result-body').textContent = row.text
468
+ $('#result-body').classList.remove('is-error')
469
+ }))
470
+ }
471
+
472
+ function paintModel() {
473
+ const chip = $('#ai-model')
474
+ if (app.model) {
475
+ chip.textContent = app.model.label
476
+ chip.className = 'model-chip is-ok'
477
+ } else {
478
+ chip.textContent = t('未连接模型', 'No model')
479
+ chip.className = 'model-chip is-off'
480
+ }
481
+ }
482
+
483
+ // 离开页面时尽量保存
484
+ const onBeforeUnload = () => {
485
+ if (!dirty) return
486
+ navigator.sendBeacon?.('/media-workbench-modern/api/document/save', new Blob([JSON.stringify({ id: activeDocId ?? '', title: titleEl.value, content: contentEl.value })], { type: 'application/json' }))
487
+ }
488
+ window.addEventListener('beforeunload', onBeforeUnload)
489
+
490
+ function update() {
491
+ paintModel()
492
+ paintDocs()
493
+ // 文档可能被外部修改:只在当前没有输入时同步标题
494
+ if (!dirty && activeDocId) {
495
+ const doc = currentDoc()
496
+ if (doc && document.activeElement !== titleEl && document.activeElement !== contentEl) {
497
+ if (titleEl.value !== doc.title) titleEl.value = doc.title
498
+ if (contentEl.value !== doc.content) { contentEl.value = doc.content; updateCounts() }
499
+ }
500
+ }
501
+ }
502
+
503
+ paintModel()
504
+ paintDocs()
505
+
506
+ return {
507
+ update,
508
+ unmount() {
509
+ window.removeEventListener('beforeunload', onBeforeUnload)
510
+ if (dirty) { void save(true) }
511
+ },
512
+ }
513
+ }