domma-cms 1.8.32 → 1.8.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/docs/DommaFeatures.md +13 -1
  3. package/package.json +1 -1
  4. package/plugins/_lib/admin/games/games.css +1 -1
  5. package/plugins/_lib/admin/games/kit.js +329 -124
  6. package/plugins/_lib/games/http.js +11 -1
  7. package/plugins/_lib/games/plays.js +28 -7
  8. package/plugins/crossword/CLAUDE.md +37 -0
  9. package/plugins/crossword/admin/css/index.css +18 -1
  10. package/plugins/crossword/admin/views/crossword.js +337 -205
  11. package/plugins/crossword/docs/guide.md +53 -0
  12. package/plugins/crossword/inject/body.html +1 -1
  13. package/plugins/crossword/plugin.js +38 -5
  14. package/plugins/crossword/plugin.json +4 -3
  15. package/plugins/crossword/public/crossword.css +36 -1
  16. package/plugins/crossword/public/crossword.js +468 -6
  17. package/plugins/docs/CLAUDE.md +318 -0
  18. package/plugins/docs/admin/css/docs.css +783 -0
  19. package/plugins/docs/admin/lib/api.js +340 -0
  20. package/plugins/docs/admin/lib/convert.js +482 -0
  21. package/plugins/docs/admin/lib/drafts.js +115 -0
  22. package/plugins/docs/admin/lib/editor.js +851 -0
  23. package/plugins/docs/admin/lib/find-replace.js +426 -0
  24. package/plugins/docs/admin/lib/folders.js +203 -0
  25. package/plugins/docs/admin/lib/panel-guard.js +73 -0
  26. package/plugins/docs/admin/lib/pdf-view.js +126 -0
  27. package/plugins/docs/admin/lib/send.js +156 -0
  28. package/plugins/docs/admin/lib/settings.js +142 -0
  29. package/plugins/docs/admin/lib/share.js +323 -0
  30. package/plugins/docs/admin/lib/shortcuts.js +44 -0
  31. package/plugins/docs/admin/lib/templates.js +194 -0
  32. package/plugins/docs/admin/lib/versions.js +192 -0
  33. package/plugins/docs/admin/templates/docs.html +92 -0
  34. package/plugins/docs/admin/templates/editor.html +86 -0
  35. package/plugins/docs/admin/views/docs.js +777 -0
  36. package/plugins/docs/admin/views/editor.js +978 -0
  37. package/plugins/docs/config.js +24 -0
  38. package/plugins/docs/docs/guide.md +270 -0
  39. package/plugins/docs/lib/extensions.js +213 -0
  40. package/plugins/docs/lib/importer.js +72 -0
  41. package/plugins/docs/lib/pdf-worker.js +48 -0
  42. package/plugins/docs/lib/pdf.js +222 -0
  43. package/plugins/docs/lib/store.js +748 -0
  44. package/plugins/docs/plugin.js +1108 -0
  45. package/plugins/docs/plugin.json +89 -0
  46. package/plugins/docs/plugin.public.js +114 -0
  47. package/plugins/docs/templates/public-doc.html +36 -0
  48. package/plugins/docs/tests/_env.js +10 -0
  49. package/plugins/docs/tests/_pdf.js +73 -0
  50. package/plugins/docs/tests/convert.test.js +95 -0
  51. package/plugins/docs/tests/drafts.test.js +82 -0
  52. package/plugins/docs/tests/pdf.test.js +86 -0
  53. package/plugins/docs/tests/plugin.test.js +816 -0
  54. package/plugins/docs/tests/shortcuts.test.js +30 -0
  55. package/plugins/free-tier.lock.json +147 -1
  56. package/plugins/garage/CLAUDE.md +51 -0
  57. package/plugins/garage/admin/css/garage.css +73 -0
  58. package/plugins/garage/admin/lib/garage.js +236 -0
  59. package/plugins/garage/admin/templates/garage.html +86 -0
  60. package/plugins/garage/admin/views/garage.js +581 -0
  61. package/plugins/garage/collections/garage-vehicles/schema.json +101 -0
  62. package/plugins/garage/config.js +18 -0
  63. package/plugins/garage/docs/guide.md +118 -0
  64. package/plugins/garage/plugin.js +471 -0
  65. package/plugins/garage/plugin.json +68 -0
  66. package/plugins/garage/tests/access.test.js +134 -0
  67. package/plugins/garage/tests/garage.test.js +106 -0
  68. package/plugins/messages/CLAUDE.md +230 -0
  69. package/plugins/messages/admin/css/messages.css +117 -0
  70. package/plugins/messages/admin/lib/messages.js +452 -0
  71. package/plugins/messages/admin/templates/messages.html +76 -0
  72. package/plugins/messages/admin/views/messages.js +1219 -0
  73. package/plugins/messages/collections/user-message-threads/schema.json +22 -0
  74. package/plugins/messages/collections/user-messages/schema.json +23 -0
  75. package/plugins/messages/config.js +14 -0
  76. package/plugins/messages/docs/guide.md +176 -0
  77. package/plugins/messages/lib/call.js +138 -0
  78. package/plugins/messages/plugin.js +994 -0
  79. package/plugins/messages/plugin.json +59 -0
  80. package/plugins/messages/plugin.public.js +52 -0
  81. package/plugins/messages/templates/call.html +6 -0
  82. package/plugins/messages/tests/after-send.test.js +22 -0
  83. package/plugins/messages/tests/api.test.js +187 -0
  84. package/plugins/messages/tests/call.test.js +497 -0
  85. package/plugins/messages/tests/drafts.test.js +25 -0
  86. package/plugins/messages/tests/messages.test.js +167 -0
  87. package/plugins/quiz/CLAUDE.md +43 -0
  88. package/plugins/quiz/admin/css/index.css +13 -1
  89. package/plugins/quiz/admin/views/quiz.js +423 -245
  90. package/plugins/quiz/docs/guide.md +61 -0
  91. package/plugins/quiz/inject/body.html +1 -1
  92. package/plugins/quiz/plugin.js +39 -5
  93. package/plugins/quiz/plugin.json +4 -3
  94. package/plugins/quiz/public/quiz.css +36 -1
  95. package/plugins/quiz/public/quiz.js +298 -6
  96. package/plugins/theme-roller/CLAUDE.md +65 -0
  97. package/plugins/theme-roller/admin/css/theme-roller.css +43 -0
  98. package/plugins/theme-roller/admin/lib/prompt.js +22 -0
  99. package/plugins/theme-roller/admin/lib/themes.js +196 -0
  100. package/plugins/theme-roller/admin/lib/view.js +88 -0
  101. package/plugins/theme-roller/admin/templates/theme-roller.html +73 -0
  102. package/plugins/theme-roller/admin/views/theme-roller-view.js +639 -0
  103. package/plugins/theme-roller/config.js +1 -0
  104. package/plugins/theme-roller/docs/guide.md +104 -0
  105. package/plugins/theme-roller/plugin.js +263 -0
  106. package/plugins/theme-roller/plugin.json +42 -0
  107. package/plugins/theme-roller/tests/api.test.js +161 -0
  108. package/plugins/theme-roller/tests/prompt-values.test.js +22 -0
  109. package/plugins/theme-roller/tests/themes.test.js +77 -0
  110. package/plugins/theme-roller/tests/view.test.js +55 -0
@@ -0,0 +1,978 @@
1
+ /**
2
+ * Docs - one document (#/plugins/docs/d/<id>).
3
+ *
4
+ * The page, its toolbar, an Outline, and everything done to the document:
5
+ * save and autosave, the edit lock, sharing, export, send to, version
6
+ * history, focus mode. Home is admin/views/docs.js.
7
+ *
8
+ * The edit lock: opening a document you may edit takes its lock and keeps it
9
+ * with a heartbeat; anyone else opening it reads, sees who is editing, and is
10
+ * offered Edit now when the lock is free. The owner can take over; the editor
11
+ * who lost it has their unsaved work saved as a copy rather than thrown away.
12
+ *
13
+ * Unsaved work is never lost silently: autosave, Ctrl+S, a leave guard on the
14
+ * router, a draft kept in this browser, and the browser's own prompt on close.
15
+ *
16
+ * @module docs/admin/views/editor
17
+ */
18
+
19
+ import {docsApi, HOME, docHref, esc, icon, loadKit, loadExtensions, newSession, unlockOnUnload, allowDocAttrs, setContentFilter, promptWith} from '/plugins/docs/admin/lib/api.js';
20
+ import {DocEditor} from '/plugins/docs/admin/lib/editor.js';
21
+ import {draftKey, legacyDraftKey, normaliseDraft, makeDraft, draftDiffers, draftQuestion, copyTitle} from '/plugins/docs/admin/lib/drafts.js';
22
+ import {VersionHistory} from '/plugins/docs/admin/lib/versions.js';
23
+ import {DocumentTemplates} from '/plugins/docs/admin/lib/templates.js';
24
+ import {FindReplace} from '/plugins/docs/admin/lib/find-replace.js';
25
+ import {FolderManager} from '/plugins/docs/admin/lib/folders.js';
26
+ import {exportItems, openSendEmail, sendToPage, sendToBlog} from '/plugins/docs/admin/lib/send.js';
27
+ import {openShare} from '/plugins/docs/admin/lib/share.js';
28
+ import {mountPdfViewer, pdfFacts, pdfExportItems, downloadPdf, replacePdf} from '/plugins/docs/admin/lib/pdf-view.js';
29
+
30
+ const SCREEN = /^\/plugins\/docs\/d\/[^/?#]+\/?(\?.*)?$/;
31
+ const pathOf = (p) => String(p || '').split('?')[0].replace(/\/$/, '');
32
+ const HEARTBEAT_MS = 30000;
33
+ const POLL_MS = 15000;
34
+
35
+ const OUTLINE_ROW = `<button type="button" class="{{cls}}" data-heading="{{index}}" title="{{text}}">{{text}}</button>`;
36
+
37
+ const PREFS_KEY = 'docs.editor';
38
+ export function normaliseEditorPrefs(raw) {
39
+ const r = raw && typeof raw === 'object' ? raw : {};
40
+ return {outlineOpen: r.outlineOpen !== false};
41
+ }
42
+
43
+ /** A draft kept in this browser for unsaved work on leaving (admin/lib/drafts.js has the rules). */
44
+ // Drafts are per signed-in person (a shared browser, a shared document): the key carries the user id.
45
+ const signedIn = () => { try { const u = S.get('auth_user'); return String((typeof u === 'string' ? JSON.parse(u) : u)?.id || ''); } catch { return ''; } };
46
+ const dropKey = (key) => { try { S.remove?.(key); S.set?.(key, null); } catch { /* storage blocked */ } };
47
+ function readDraft(id) {
48
+ try {
49
+ // 2.9's owner-less draft: whose it is cannot be known, so it is removed, never offered.
50
+ if (S.get(legacyDraftKey(id)) != null) dropKey(legacyDraftKey(id));
51
+ const me = signedIn();
52
+ return me ? normaliseDraft(S.get(draftKey(id, me)), me) : null;
53
+ } catch { return null; }
54
+ }
55
+ function writeDraft(id, draft) {
56
+ const me = signedIn();
57
+ if (!me || draft?.owner !== me) return; // signed out (or someone else now): keep nothing
58
+ try { S.set(draftKey(id, me), draft); } catch { /* storage blocked */ }
59
+ }
60
+ function clearDraft(id) { const me = signedIn(); if (me) dropKey(draftKey(id, me)); }
61
+
62
+ /**
63
+ * E.confirm, and a way to answer it Cancel from code - Domma's dialog has no
64
+ * close(). The dialog is built synchronously, so its Cancel button is the last
65
+ * one in the page when E.confirm returns. A screen that goes while it asks
66
+ * dismisses it: Cancel is always the keeping answer, so nothing is lost.
67
+ */
68
+ function askDismissable(message, options) {
69
+ // Domma answers OK on Enter anywhere in the page, and OK is "Throw away" here: Enter
70
+ // is swallowed while it asks, and focus starts on the keeping answer.
71
+ const swallowEnter = (e) => { if (e.key === 'Enter') { e.preventDefault(); e.stopImmediatePropagation(); } };
72
+ window.addEventListener('keydown', swallowEnter, true);
73
+ const answer = E.confirm(message, options);
74
+ const cancel = [...document.querySelectorAll('.dm-dialog-cancel')].at(-1) || null;
75
+ setTimeout(() => { try { cancel?.focus(); } catch { /* gone */ } }, 80);
76
+ answer.finally(() => window.removeEventListener('keydown', swallowEnter, true));
77
+ return {answer, dismiss: () => { if (cancel?.isConnected) cancel.click(); }};
78
+ }
79
+
80
+ // ---- One open document at a time, reachable from the router ----
81
+
82
+ /** {path, dirty(), dispose()} of the mounted editor. */
83
+ let active = null;
84
+ let routerWired = false;
85
+
86
+ function wireRouter() {
87
+ if (routerWired || !window.R?.use) return;
88
+ routerWired = true;
89
+ R.use((to, from, next) => {
90
+ const fromPath = pathOf(from?.path);
91
+ const leaving = SCREEN.test(from?.path || '') && pathOf(to?.path) !== fromPath;
92
+ if (!leaving || !active?.dirty()) return next();
93
+ const a = active;
94
+ return E.confirm('This document has changes that are not saved yet. Save them and leave?', {
95
+ title: 'Unsaved changes', confirmText: 'Save and leave', cancelText: 'Stay'
96
+ }).then(async (ok) => {
97
+ if (ok && await a.saveNow()) next();
98
+ else history.replaceState(null, '', `#${from.path}`); // the hash has already moved
99
+ });
100
+ });
101
+ M.subscribe('router:afterChange', ({to} = {}) => {
102
+ if (active && pathOf(to?.path) !== active.path) { const a = active; active = null; a.dispose(); }
103
+ });
104
+ }
105
+
106
+ export const docsEditorView = {
107
+ templateUrl: '/plugins/docs/admin/templates/editor.html',
108
+
109
+ async onMount($container) {
110
+ wireRouter();
111
+ if (active) { const a = active; active = null; a.dispose(); }
112
+ const root = $container.get(0);
113
+ const m = /^#\/plugins\/docs\/d\/([^/?#]+)/.exec(location.hash);
114
+ const id = m ? decodeURIComponent(m[1]) : '';
115
+ const path = `/plugins/docs/d/${m ? m[1] : ''}`;
116
+ const session = newSession();
117
+ const cleanups = [];
118
+ let disposed = false;
119
+ let lockHeld = false;
120
+ // Filled in once the editor exists; the router can reach this view from the first await on.
121
+ let isDirty = () => false;
122
+ let saveNow = async () => true;
123
+ let keepDraft = () => {};
124
+ const onDispose = (fn) => { if (disposed) { try { fn?.(); } catch { /* gone */ } } else cleanups.push(fn); };
125
+ active = {
126
+ path,
127
+ dirty: () => isDirty(),
128
+ saveNow: () => saveNow(),
129
+ dispose() {
130
+ if (disposed) return;
131
+ // Left with unsaved work (the guard was passed some other way): keep a draft.
132
+ try { if (isDirty()) keepDraft(); } catch { /* best effort */ }
133
+ disposed = true;
134
+ if (lockHeld) docsApi.unlock(id, session).catch(() => {});
135
+ lockHeld = false;
136
+ cleanups.splice(0).reverse().forEach(fn => { try { fn?.(); } catch { /* gone */ } });
137
+ }
138
+ };
139
+ const kit = await loadKit();
140
+ if (disposed) return;
141
+ const prefsStore = kit.settingsStore(PREFS_KEY, normaliseEditorPrefs);
142
+ const recount = () => kit.sidebarCount(HOME);
143
+
144
+ /**
145
+ * The document has gone (deleted, or no longer shared) but this browser
146
+ * still holds unsaved work on it: ask, and keep it as a new document
147
+ * of the person's own unless they throw it away. Leaving while asked
148
+ * keeps the draft for next time.
149
+ */
150
+ async function offerOrphanDraft(draft) {
151
+ const q = draftQuestion(draft, null);
152
+ const asked = askDismissable(q.text, q.options);
153
+ onDispose(asked.dismiss);
154
+ const throwAway = await asked.answer;
155
+ if (disposed) return;
156
+ if (throwAway) { clearDraft(id); location.hash = HOME; return; }
157
+ try {
158
+ const made = await docsApi.create({title: copyTitle(draft), content: draft.content});
159
+ clearDraft(id);
160
+ recount();
161
+ E.toast(`Your unsaved changes are now a document of your own: ${made.title}`, {type: 'success', duration: 8000});
162
+ if (!disposed) location.hash = docHref(made.id);
163
+ } catch (err) {
164
+ E.toast(`${err.message || 'Could not make a new document'} - your changes are still kept in this browser.`, {type: 'error'});
165
+ if (!disposed) location.hash = HOME;
166
+ }
167
+ }
168
+
169
+ // ---- Load ----
170
+ // The page shows a skeleton while the document is fetched; the editor's
171
+ // chips and buttons stay hidden (static `hidden`) until bound.
172
+ const loadingSlot = root.querySelector('.docs-ed__loading');
173
+ let site = {};
174
+ let doc;
175
+ try {
176
+ const loads = Promise.all([docsApi.settings.get().catch(() => ({})), docsApi.get(id, session)]);
177
+ const loader = await import('/admin/js/lib/loading.js').catch(() => null);
178
+ [site, doc] = loader?.whileLoading && loadingSlot
179
+ ? await loader.whileLoading(loadingSlot, loads, loader.SHAPES.rows)
180
+ : await loads;
181
+ } catch (err) {
182
+ if (disposed) return;
183
+ if (loadingSlot) loadingSlot.hidden = true;
184
+ // Gone, with work of ours on it still in this browser: offer it back as a new document.
185
+ const draft = /not found/i.test(err.message || '') ? readDraft(id) : null;
186
+ if (draft) { await offerOrphanDraft(draft); return; }
187
+ E.toast(err.message || 'That document could not be opened', {type: 'error'});
188
+ location.hash = HOME;
189
+ return;
190
+ }
191
+ if (loadingSlot) loadingSlot.hidden = true;
192
+ if (disposed || location.hash.indexOf(`#${path}`) !== 0) return; // left while loading
193
+ // A PDF document (2.13) is a file: its body is never edited here, and only add-ons that declare
194
+ // they understand PDF documents are installed in its screen.
195
+ const pdf = doc.format === 'pdf';
196
+ const extensions = (await loadExtensions(site.extensions)).filter(e => !pdf || (Array.isArray(e.kinds) && e.kinds.includes('pdf')));
197
+ if (disposed) return;
198
+
199
+ // ---- State ----
200
+ const current = M.observable(doc);
201
+ const title = M.observable(doc.title || '');
202
+ const savedTitle = M.observable(doc.title || '');
203
+ const contentDirty = M.observable(false);
204
+ const saving = M.observable(false);
205
+ const savedAt = M.observable(null);
206
+ const failed = M.observable(false);
207
+ // 'editing' - this tab holds the lock; 'locked' - someone else does;
208
+ // 'free' - nobody does but we are reading; 'view' - viewer or in Trash.
209
+ const mode = M.observable('view');
210
+ const lockedBy = M.observable('');
211
+ const takeOverAllowed = M.observable(false);
212
+ const lockMine = M.observable(false); // the lock is ours, from another tab
213
+ const me = (() => { try { const u = S.get('auth_user'); return (typeof u === 'string' ? JSON.parse(u) : u)?.id || ''; } catch { return ''; } })();
214
+ /** Note who holds the lock. */
215
+ const holder = (lock) => {
216
+ lockMine.value = Boolean(lock && lock.userId && lock.userId === me);
217
+ lockedBy.value = lock?.name || 'Someone';
218
+ };
219
+ const starred = M.observable(Boolean(doc.starred));
220
+ const prefs = M.observable(prefsStore.read());
221
+ const outlineOpen = M.observable(prefs.peek().outlineOpen && window.innerWidth > 900);
222
+ const focus = M.observable(false);
223
+ const sideOpen = M.observable(false);
224
+ const stats = M.observable({words: 0, chars: 0});
225
+ const outlineRows = M.observable([]);
226
+ onDispose(M.effect(() => prefsStore.write({outlineOpen: outlineOpen.value})));
227
+
228
+ const access = () => current.peek().access;
229
+ // A Suggester edits too - in suggesting mode, which an add-on (Docs Pro) provides and the server checks (2.5).
230
+ const suggests = () => access() === 'suggester' && Boolean(site.suggestions);
231
+ // 2.12: an add-on may freeze it for this person (a protected wiki page) - read only, the reason in a chip.
232
+ const mayEdit = () => !pdf && (access() === 'owner' || access() === 'editor' || suggests()) && !current.peek().trashedAt && !current.peek().frozen;
233
+ const editorRights = () => access() === 'owner' || access() === 'editor';
234
+ let changeSeq = 0;
235
+ // The version the unsaved edits began from: the stored one - or, for a draft
236
+ // picked back up, that draft's own `was` until it is saved.
237
+ let pickedWas = '';
238
+ const editWas = () => pickedWas || current.peek().updatedAt || '';
239
+ // The owner is whoever opened this editor - never whoever is signed in by the time it is written.
240
+ const draftNow = () => makeDraft({owner: me, title: title.peek(), content: editor.getValue(), was: editWas()});
241
+ // Panels opened from here close with the screen; one that is still opening checks this.
242
+ const panelLife = {
243
+ alive: () => !disposed,
244
+ add(fn) { onDispose(fn); return () => { const at = cleanups.indexOf(fn); if (at !== -1) cleanups.splice(at, 1); }; }
245
+ };
246
+
247
+ // Titles compare as the server stores them: spaces collapsed, "Untitled" when empty.
248
+ const normTitle = (t) => String(t ?? '').replace(/\s+/g, ' ').trim() || 'Untitled';
249
+ const dirty = M.computed(() => mode.value === 'editing' && (contentDirty.value || normTitle(title.value) !== normTitle(savedTitle.value)));
250
+ const readOnly = M.computed(() => mode.value !== 'editing');
251
+ const titleLocked = M.computed(() => mode.value !== 'editing' || current.value.access === 'suggester');
252
+
253
+ const data = {
254
+ title, starred, lockedBy, outlineOpen, focus, sideOpen, outlineRows, readOnly, titleLocked,
255
+ isPdf: M.computed(() => current.value.format === 'pdf'),
256
+ pdfText: M.computed(() => (current.value.format === 'pdf' ? `PDF${pdfFacts(current.value.file) ? ` · ${pdfFacts(current.value.file)}` : ''}` : '')),
257
+ pdfName: M.computed(() => String(current.value.file?.name || '')),
258
+ edClass: M.computed(() => `docs-ed${readOnly.value ? ' is-readonly' : ''}${outlineOpen.value && !pdf ? ' has-outline' : ''}${sideOpen.value ? ' has-side' : ''}${pdf ? ' is-pdf' : ''}`),
259
+ starTitle: M.computed(() => (starred.value ? 'Remove star' : 'Star')),
260
+ whereText: M.computed(() => {
261
+ const d = current.value;
262
+ const owner = d.access === 'owner' ? 'My documents' : `${d.ownerName}'s document`;
263
+ return d.shares?.length || d.publicLink ? `${owner} · shared` : owner;
264
+ }),
265
+ saveState: M.computed(() => {
266
+ if (mode.value !== 'editing') return '';
267
+ if (saving.value) return 'Saving…';
268
+ if (failed.value) return 'Not saved';
269
+ if (dirty.value) return 'Unsaved changes';
270
+ return savedAt.value ? `Saved ${D(savedAt.value).format('HH:mm')}` : 'All changes saved';
271
+ }),
272
+ saveClass: M.computed(() => `docs-ed__save${failed.value ? ' is-failed' : dirty.value ? ' is-dirty' : ''}`),
273
+ lockText: M.computed(() => (!lockedBy.value ? '' : lockMine.value ? 'Open for editing in another tab' : `${lockedBy.value} is editing`)),
274
+ takeOverText: M.computed(() => (lockMine.value ? 'Edit here' : 'Take over')),
275
+ canTakeOver: M.computed(() => mode.value === 'locked' && takeOverAllowed.value),
276
+ canEditNow: M.computed(() => mode.value === 'free'),
277
+ viewOnly: M.computed(() => mode.value === 'view' && !current.value.trashedAt && !current.value.frozen && current.value.format !== 'pdf'),
278
+ frozenText: M.computed(() => (current.value.trashedAt ? '' : String(current.value.frozen || ''))),
279
+ inTrash: M.computed(() => Boolean(current.value.trashedAt)),
280
+ canRestore: M.computed(() => Boolean(current.value.trashedAt) && current.value.access === 'owner'),
281
+ // 2.11: no Share for access that comes only through an add-on (the Wiki's editors).
282
+ canShare: M.computed(() => (current.value.access === 'owner' || current.value.access === 'editor') && !current.value.trashedAt
283
+ && !(current.value.outwardRefusal && current.value.access !== 'owner')),
284
+ statsText: M.computed(() => {
285
+ if (current.value.format === 'pdf') return [pdfFacts(current.value.file), current.value.file?.name].filter(Boolean).join(' · ') || 'PDF';
286
+ const {words, chars} = stats.value;
287
+ const mins = Math.max(1, Math.round(words / 230));
288
+ return `${words.toLocaleString()} word${words === 1 ? '' : 's'} · ${chars.toLocaleString()} characters · ${words ? `${mins} min read` : 'empty'}`;
289
+ }),
290
+ editedText: M.computed(() => {
291
+ const d = current.value;
292
+ return d.updatedAt ? `Last edited ${D(d.updatedAt).fromNow()}` : '';
293
+ })
294
+ };
295
+
296
+ // ---- The page ----
297
+ root.querySelector('.docs-outline__list').innerHTML = OUTLINE_ROW;
298
+ const pageEl = root.querySelector('.docs-page');
299
+ // For a PDF the editor runs on a page nobody sees (its save, lock and outline paths stay inert) and the viewer has the real one.
300
+ const page = pdf ? document.createElement('div') : pageEl;
301
+ const toolbarHost = root.querySelector('.docs-ed__toolbar');
302
+ let pdfView = null;
303
+ const editor = new DocEditor(page, toolbarHost, {
304
+ pickImage: async () => {
305
+ const mp = await import('/admin/js/lib/media-picker.js').catch(() => null);
306
+ if (mp?.pickMedia) return mp.pickMedia({title: 'Insert an image'});
307
+ return E.prompt('Image address', {title: 'Insert image', inputPlaceholder: 'https://… or /media/…'});
308
+ },
309
+ uploadImage: async (file) => {
310
+ const mod = await import('/admin/js/api.js').catch(() => null);
311
+ if (!mod?.api?.media?.upload) throw new Error('Images cannot be uploaded from here - use Insert image.');
312
+ const form = new FormData();
313
+ form.append('file', file, file.name || 'pasted-image.png');
314
+ const saved = await mod.api.media.upload(form);
315
+ const one = Array.isArray(saved) ? saved[0] : saved;
316
+ return one?.url || (one?.name ? `/media/${one.name}` : null);
317
+ }
318
+ });
319
+ editor.init();
320
+ onDispose(() => editor.destroy());
321
+
322
+ const findReplace = new FindReplace().setEditor(editor);
323
+ const versionHistory = new VersionHistory();
324
+ const templates = new DocumentTemplates();
325
+
326
+ // Outline and counts follow the content, a moment after typing stops.
327
+ let outlineTimer = null;
328
+ let headingEls = [];
329
+ function refreshOutline() {
330
+ headingEls = editor.headings();
331
+ outlineRows.value = headingEls.map((h, i) => ({
332
+ key: `${i}:${h.tagName}:${h.textContent.slice(0, 40)}`, index: i,
333
+ text: h.textContent.replace(/\s+/g, ' ').trim() || 'Untitled heading',
334
+ cls: `docs-outline__item docs-outline__item--${h.tagName.toLowerCase()}`
335
+ })).filter(r => r.text);
336
+ const text = editor.text().trim();
337
+ stats.value = {words: text ? text.split(/\s+/).length : 0, chars: text.length};
338
+ }
339
+ refreshOutline();
340
+ onDispose(() => clearTimeout(outlineTimer));
341
+
342
+ // ---- Saving ----
343
+ let autosaveTimer = null;
344
+ onDispose(() => clearTimeout(autosaveTimer));
345
+ function scheduleAutosave() {
346
+ const interval = Number(site.autoSaveInterval ?? 30000);
347
+ clearTimeout(autosaveTimer);
348
+ if (!interval) return;
349
+ autosaveTimer = setTimeout(() => save({silent: true}), interval);
350
+ }
351
+
352
+ editor.onChange(() => {
353
+ if (mode.peek() !== 'editing') return;
354
+ changeSeq++;
355
+ contentDirty.value = true;
356
+ failed.value = false;
357
+ scheduleAutosave();
358
+ clearTimeout(outlineTimer);
359
+ outlineTimer = setTimeout(refreshOutline, 400);
360
+ });
361
+ onDispose(M.effect(() => {
362
+ if (normTitle(title.value) !== normTitle(savedTitle.peek()) && mode.peek() === 'editing') scheduleAutosave();
363
+ }));
364
+
365
+ let inflight = null;
366
+ /**
367
+ * Save, waiting for one already running first ("Save and leave"
368
+ * during an autosave must not just give up).
369
+ *
370
+ * @returns {Promise<boolean>} true when everything is saved
371
+ */
372
+ async function save(opts = {}) {
373
+ while (inflight) await inflight.catch(() => false);
374
+ if (disposed) return !dirty.peek();
375
+ inflight = doSave(opts);
376
+ try { return await inflight; } finally { inflight = null; }
377
+ }
378
+
379
+ async function doSave({silent = false} = {}) {
380
+ if (mode.peek() !== 'editing') return !dirty.peek();
381
+ if (!dirty.peek()) return true;
382
+ clearTimeout(autosaveTimer);
383
+ saving.value = true;
384
+ const seq = changeSeq;
385
+ // An untitled document takes its first line as its title, once.
386
+ if (normTitle(title.peek()) === 'Untitled' && normTitle(savedTitle.peek()) === 'Untitled') {
387
+ const first = editor.text().split('\n').map(l => l.trim()).find(Boolean);
388
+ if (first) title.value = first.slice(0, 80);
389
+ }
390
+ const sentTitle = normTitle(title.peek());
391
+ try {
392
+ const updated = await docsApi.update(id, {title: sentTitle, content: editor.getValue(),
393
+ baseUpdatedAt: current.peek().updatedAt}, session);
394
+ savedTitle.value = updated.title;
395
+ // Show the title as stored, unless it was edited while saving.
396
+ if (normTitle(title.peek()) === sentTitle && title.peek() !== updated.title && document.activeElement?.id !== 'docs-ed-title') title.value = updated.title;
397
+ // Only what was sent counts as saved: typing during the request is still unsaved.
398
+ if (changeSeq === seq) contentDirty.value = false;
399
+ current.value = {...current.peek(), ...updated};
400
+ pickedWas = '';
401
+ savedAt.value = new Date().toISOString();
402
+ failed.value = false;
403
+ if (!dirty.peek() && !draftPending) clearDraft(id);
404
+ if (!silent) E.toast('Saved', {type: 'success'});
405
+ return !dirty.peek();
406
+ } catch (err) {
407
+ const msg = err.message || '';
408
+ if (/is editing/i.test(msg)) { await lostLock(); return false; }
409
+ if (/saved by someone else/i.test(msg)) { await conflict(); return false; }
410
+ if (/not found|not edit|permission|forbidden/i.test(msg)) { await lostAccess(msg); return false; }
411
+ failed.value = true;
412
+ E.toast(msg || 'Could not save', {type: 'error'});
413
+ return false;
414
+ } finally {
415
+ saving.value = false;
416
+ }
417
+ }
418
+
419
+ /** Keep unsaved work as the user's own copy; fall back to a draft in this browser. */
420
+ async function keepAsCopy() {
421
+ const content = editor.getValue();
422
+ try {
423
+ return await docsApi.duplicate(id, {title: `${normTitle(title.peek())} (your unsaved changes)`, content});
424
+ } catch {
425
+ writeDraft(id, draftNow());
426
+ return null;
427
+ }
428
+ }
429
+
430
+ /** The document was saved by someone else since this editor loaded it. */
431
+ async function conflict() {
432
+ const unsaved = dirty.peek();
433
+ if (unsaved) {
434
+ const copy = await keepAsCopy();
435
+ if (copy) recount();
436
+ E.toast(copy
437
+ ? `This document was saved by someone else while you worked. Your version is in a copy: ${copy.title}.`
438
+ : 'This document was saved by someone else while you worked. Your version is kept in this browser.', {type: 'warning', duration: 12000});
439
+ }
440
+ contentDirty.value = false;
441
+ await refreshFromServer(true, {replace: true});
442
+ }
443
+
444
+ /** Access taken away (or the document deleted) while editing. */
445
+ async function lostAccess(msg) {
446
+ if (dirty.peek()) writeDraft(id, draftNow());
447
+ contentDirty.value = false;
448
+ title.value = savedTitle.peek();
449
+ lockHeld = false;
450
+ setMode('view');
451
+ E.toast(`You can no longer edit this document${/not found/i.test(msg) ? '' : ` (${msg})`}. Anything unsaved is kept in this browser.`, {type: 'warning', duration: 10000});
452
+ }
453
+
454
+ // ---- The edit lock ----
455
+ let heartbeat = null;
456
+ let poll = null;
457
+ onDispose(() => { clearInterval(heartbeat); clearInterval(poll); });
458
+
459
+ function setMode(next) {
460
+ if (disposed) return;
461
+ mode.value = next;
462
+ lockHeld = next === 'editing';
463
+ editor.setReadOnly(next !== 'editing');
464
+ clearInterval(heartbeat);
465
+ clearInterval(poll);
466
+ if (next === 'editing') heartbeat = setInterval(beat, HEARTBEAT_MS);
467
+ else poll = setInterval(() => refreshFromServer(false, {poll: true}), next === 'view' ? POLL_MS * 2 : POLL_MS);
468
+ }
469
+
470
+ async function acquire(force = false) {
471
+ try {
472
+ const r = await docsApi.lock(id, session, force);
473
+ if (r.ok) { lockedBy.value = ''; lockMine.value = false; return r; }
474
+ holder(r.lock);
475
+ takeOverAllowed.value = Boolean(r.canTakeOver);
476
+ return null;
477
+ } catch (err) {
478
+ E.toast(err.message || 'Could not start editing', {type: 'error'});
479
+ return null;
480
+ }
481
+ }
482
+
483
+ async function beat() {
484
+ if (disposed || mode.peek() !== 'editing') return;
485
+ let r;
486
+ try {
487
+ r = await docsApi.lock(id, session);
488
+ } catch (err) {
489
+ if (/not found|forbidden|permission/i.test(err.message || '')) await lostAccess(err.message || '');
490
+ return; // otherwise offline for a moment - the next beat tries again
491
+ }
492
+ if (disposed || mode.peek() !== 'editing') return;
493
+ if (!r.ok) {
494
+ holder(r.lock);
495
+ takeOverAllowed.value = Boolean(r.canTakeOver);
496
+ await lostLock(r.lock);
497
+ } else if (r.updatedAt && r.updatedAt !== current.peek().updatedAt && !saving.peek()) {
498
+ // Our lock lapsed (a sleeping laptop) and someone saved in between.
499
+ await conflict();
500
+ }
501
+ }
502
+
503
+ /** Someone took the document over: keep what we had as a copy, then read their version. */
504
+ async function lostLock(lock) {
505
+ if (mode.peek() !== 'editing') return;
506
+ if (lock) holder(lock);
507
+ const who = lockMine.peek() ? 'Your other tab' : (lockedBy.peek() || 'Someone');
508
+ const unsaved = dirty.peek();
509
+ if (unsaved) {
510
+ const copy = await keepAsCopy();
511
+ if (copy) recount();
512
+ setMode('locked');
513
+ E.toast(copy
514
+ ? `${who} took over editing. What you had not saved is in a copy: ${copy.title}.`
515
+ : `${who} took over editing. Your unsaved changes are kept in this browser.`, {type: 'warning', duration: 10000});
516
+ } else {
517
+ setMode('locked');
518
+ E.toast(`${who} is editing this document now.`, {type: 'info'});
519
+ }
520
+ contentDirty.value = false;
521
+ title.value = savedTitle.peek();
522
+ await refreshFromServer(true);
523
+ }
524
+
525
+ /** Put a server copy into the page, keeping the reader's scroll position. */
526
+ function showContent(fresh) {
527
+ if (pdf) {
528
+ // A new file (replaced, or a version restored): the viewer reloads; the title and facts follow the record.
529
+ // `_before` is the record as it was (refreshFromServer has already put the new one in `current`).
530
+ const before = fresh._before || {};
531
+ title.value = fresh.title || '';
532
+ savedTitle.value = fresh.title || '';
533
+ if (pdfView && (fresh.file?.sha256 !== before.file?.sha256 || fresh.file?.uploadedAt !== before.file?.uploadedAt)) pdfView.reload();
534
+ return;
535
+ }
536
+ const y = page.parentElement?.scrollTop || 0;
537
+ editor.setValue(fresh.content || '');
538
+ editor._syncEmpty?.();
539
+ pickedWas = ''; // the server's copy is in the page now: later edits begin from it
540
+ title.value = fresh.title || '';
541
+ savedTitle.value = fresh.title || '';
542
+ contentDirty.value = false;
543
+ changeSeq++;
544
+ if (page.parentElement) page.parentElement.scrollTop = y;
545
+ refreshOutline();
546
+ }
547
+
548
+ /**
549
+ * The latest saved version. Readers poll this; `replace` also puts it
550
+ * into the page while editing (after a restore, or after a conflict).
551
+ */
552
+ async function refreshFromServer(force = false, {replace = false, poll = false} = {}) {
553
+ if (disposed) return;
554
+ try {
555
+ const fresh = await docsApi.get(id, session, {poll});
556
+ if (disposed) return;
557
+ const changed = force || fresh.updatedAt !== current.peek().updatedAt || fresh.content !== current.peek().content;
558
+ const before = current.peek();
559
+ current.value = fresh;
560
+ starred.value = Boolean(fresh.starred);
561
+ if (changed && (mode.peek() !== 'editing' || replace)) showContent(pdf ? {...fresh, _before: before} : fresh);
562
+ if (fresh.lock && !fresh.lock.mine && mode.peek() !== 'editing') {
563
+ holder(fresh.lock);
564
+ takeOverAllowed.value = fresh.access === 'owner' || fresh.lock.userId === me || (['owner', 'editor'].includes(fresh.access) && Boolean(fresh.lock.suggester));
565
+ }
566
+ if (mode.peek() === 'locked' && !fresh.lock) { lockedBy.value = ''; setMode(mayEdit() ? 'free' : 'view'); }
567
+ else if (mode.peek() === 'free' && fresh.lock && !fresh.lock.mine) setMode('locked');
568
+ } catch (err) {
569
+ if (/not found/i.test(err.message || '')) {
570
+ E.toast('This document is no longer available to you.', {type: 'warning'});
571
+ location.hash = HOME;
572
+ }
573
+ }
574
+ }
575
+
576
+ async function startEditing(force = false) {
577
+ if (disposed) return;
578
+ if (!mayEdit()) { setMode('view'); return; }
579
+ const r = await acquire(force);
580
+ if (disposed) { if (r) docsApi.unlock(id, session).catch(() => {}); return; }
581
+ if (r) {
582
+ // Take whatever was saved while we waited (or since this page loaded), then edit.
583
+ if (force || mode.peek() !== 'view' || (r.updatedAt && r.updatedAt !== current.peek().updatedAt)) {
584
+ await refreshFromServer(true, {replace: true});
585
+ }
586
+ if (disposed) return;
587
+ setMode('editing');
588
+ // Work left here last time is offered back once this tab can edit (not while someone else does).
589
+ await offerDraft();
590
+ } else {
591
+ setMode('locked');
592
+ }
593
+ }
594
+
595
+ /**
596
+ * Work left unsaved here last time, offered back once - when this tab
597
+ * can edit. OK throws it away; Cancel, Escape or the backdrop put it
598
+ * back (admin/lib/drafts.js draftQuestion). The draft is removed only
599
+ * after an answer: leaving while asked dismisses the question and
600
+ * keeps it. A picked-up draft keeps its own `was` until it is saved.
601
+ */
602
+ let draftAsked = false;
603
+ let draftPending = false; // asking: no save may clear the draft meanwhile
604
+ async function offerDraft() {
605
+ if (draftAsked || disposed || mode.peek() !== 'editing') return;
606
+ const draft = readDraft(id);
607
+ if (!draft) return;
608
+ draftAsked = true;
609
+ if (!draftDiffers(draft, current.peek())) { clearDraft(id); return; }
610
+ const q = draftQuestion(draft, current.peek());
611
+ // The page is locked while it asks: typing now would race the answer.
612
+ draftPending = true;
613
+ root.inert = true;
614
+ const asked = askDismissable(q.text, q.options);
615
+ onDispose(asked.dismiss);
616
+ let throwAway;
617
+ try { throwAway = await asked.answer; } finally { draftPending = false; root.inert = false; }
618
+ if (disposed) return;
619
+ if (throwAway) { clearDraft(id); return; }
620
+ // Lost the lock while asking: ask again once this tab edits.
621
+ if (mode.peek() !== 'editing') { draftAsked = false; return; }
622
+ editor.setValue(draft.content);
623
+ editor._syncEmpty?.();
624
+ if (draft.title) title.value = draft.title;
625
+ pickedWas = draft.was;
626
+ contentDirty.value = true;
627
+ changeSeq++;
628
+ refreshOutline();
629
+ scheduleAutosave();
630
+ }
631
+
632
+ // ---- Actions ----
633
+ const back = () => { location.hash = HOME; };
634
+
635
+ async function toggleStar() {
636
+ try {
637
+ await docsApi.star(id, !starred.peek());
638
+ starred.value = !starred.peek();
639
+ } catch (err) { E.toast(err.message || 'Could not change the star', {type: 'error'}); }
640
+ }
641
+
642
+ async function takeOver() {
643
+ if (lockMine.peek()) { await startEditing(true); return; }
644
+ const ok = await E.confirm(`Take over editing from <strong>${esc(lockedBy.peek())}</strong>? Anything they have not saved is kept for them as a copy.`,
645
+ {title: 'Take over editing', confirmText: 'Take over'});
646
+ if (ok) await startEditing(true);
647
+ }
648
+
649
+ async function share() {
650
+ if (dirty.peek()) await save({silent: true});
651
+ if (disposed) return;
652
+ await openShare({doc: current.peek(), kit, life: panelLife, sections: shareSections, suggestions: Boolean(site.suggestions), onChange: (next) => { current.value = {...current.peek(), ...next}; }});
653
+ }
654
+
655
+ // One Version history at a time; it closes with the screen.
656
+ let historyOpen = false;
657
+ async function showHistory() {
658
+ if (historyOpen) return;
659
+ historyOpen = true;
660
+ let modal = null;
661
+ try {
662
+ if (dirty.peek()) await save({silent: true});
663
+ if (disposed) return;
664
+ modal = await versionHistory.show(id, {session, canRestore: pdf ? editorRights() && !current.peek().trashedAt && !current.peek().frozen : mode.peek() === 'editing' && access() !== 'suggester'});
665
+ if (!modal) return;
666
+ if (disposed) { modal.close(); return; }
667
+ const off = panelLife.add(() => { try { modal.close(); } catch { /* gone */ } });
668
+ modal.element.addEventListener('close', async () => {
669
+ historyOpen = false;
670
+ off();
671
+ if (!disposed && versionHistory.restored) await refreshFromServer(true, {replace: true});
672
+ }, {once: true});
673
+ } finally {
674
+ if (!modal || disposed) historyOpen = false;
675
+ }
676
+ }
677
+
678
+ async function rename() {
679
+ const name = await promptWith('Document title', {title: 'Rename document'}, title.peek());
680
+ if (disposed || name === null || name === undefined || !String(name).trim()) return;
681
+ if (pdf) {
682
+ // No editing mode for a PDF: the title is saved on its own.
683
+ try {
684
+ const updated = await docsApi.update(id, {title: String(name).trim()}, session);
685
+ if (disposed) return;
686
+ current.value = {...current.peek(), ...updated};
687
+ title.value = updated.title;
688
+ savedTitle.value = updated.title;
689
+ } catch (err) { E.toast(err.message || 'Could not rename', {type: 'error'}); }
690
+ return;
691
+ }
692
+ title.value = String(name).trim();
693
+ await save({silent: true});
694
+ }
695
+
696
+ async function makeCopy() {
697
+ if (dirty.peek()) await save({silent: true});
698
+ if (disposed) return;
699
+ try {
700
+ const c = await docsApi.duplicate(id);
701
+ recount();
702
+ if (disposed) { E.toast(`Copy made: ${c.title}`, {type: 'success'}); return; }
703
+ const ok = await E.confirm(`Copy made: <strong>${esc(c.title)}</strong>. Open it?`, {title: 'Copy made', confirmText: 'Open copy', cancelText: 'Stay here'});
704
+ if (ok && !disposed) location.hash = docHref(c.id);
705
+ } catch (err) { E.toast(err.message || 'Could not copy', {type: 'error'}); }
706
+ }
707
+
708
+ async function moveTo(folderId) {
709
+ try {
710
+ const updated = await docsApi.update(id, {folderId}, session);
711
+ if (disposed) return;
712
+ current.value = {...current.peek(), ...updated};
713
+ E.toast('Moved', {type: 'success'});
714
+ } catch (err) { E.toast(err.message || 'Could not move', {type: 'error'}); }
715
+ }
716
+
717
+ async function trash() {
718
+ const ok = await E.confirm('Move this document to Trash? You can restore it from Trash.', {title: 'Move to Trash', confirmText: 'Move to Trash'});
719
+ if (!ok || disposed) return;
720
+ try {
721
+ contentDirty.value = false;
722
+ title.value = savedTitle.peek();
723
+ await docsApi.trash(id);
724
+ recount();
725
+ E.toast('Moved to Trash', {type: 'success'});
726
+ if (!disposed) location.hash = HOME;
727
+ } catch (err) { E.toast(err.message || 'Could not move to Trash', {type: 'error'}); }
728
+ }
729
+
730
+ async function restoreFromTrash() {
731
+ try {
732
+ await docsApi.restore(id);
733
+ recount();
734
+ E.toast('Restored', {type: 'success'});
735
+ if (disposed) return;
736
+ await refreshFromServer(true);
737
+ await startEditing();
738
+ } catch (err) { E.toast(err.message || 'Could not restore', {type: 'error'}); }
739
+ }
740
+
741
+ const snapshot = async () => {
742
+ if (dirty.peek()) await save({silent: true});
743
+ return {...current.peek(), title: title.peek() || 'Untitled', content: editor.getValue()};
744
+ };
745
+
746
+ let folderManager = null;
747
+ async function moveItems() {
748
+ if (!folderManager) { folderManager = new FolderManager(); await folderManager.load(); }
749
+ const here = current.peek().folderId || null;
750
+ return [{id: null, name: 'My documents (no folder)', depth: 0}, ...folderManager.rows().filter(f => f.id !== null)].map(f => ({
751
+ label: `${' '.repeat(f.depth)}${f.name}`, icon: f.id === null ? 'folder-open' : 'folder',
752
+ disabled: f.id === here, action: () => moveTo(f.id)
753
+ }));
754
+ }
755
+ let folderItems = [];
756
+
757
+ /** Everything you can do to a PDF document (2.13): there is no text, so no save, template, print or page. */
758
+ function pdfMenu() {
759
+ const d = current.peek();
760
+ const owner = d.access === 'owner';
761
+ const rights = editorRights() && !d.trashedAt && !d.frozen;
762
+ const items = [];
763
+ if (rights) items.push({label: 'Rename…', icon: 'edit-3', action: rename});
764
+ items.push({label: 'Make a copy', icon: 'copy', action: makeCopy});
765
+ if (owner && !d.trashedAt && folderItems.length) items.push({label: 'Move to', icon: 'folder', submenu: folderItems});
766
+ items.push({type: 'divider'});
767
+ items.push({label: 'Download PDF', icon: 'download', action: () => downloadPdf(d)});
768
+ if (rights) items.push({label: 'Replace the PDF…', icon: 'upload', action: async () => {
769
+ const updated = await replacePdf(d, {session, host: pageEl});
770
+ if (updated && !disposed) { const before = current.peek(); current.value = {...before, ...updated}; showContent({...updated, _before: before}); recount(); }
771
+ }});
772
+ if (rights && !d.outwardRefusal && site.allowSend !== false) {
773
+ items.push({label: 'Send to', icon: 'send', submenu: [{label: 'Email…', icon: 'mail', action: () => openSendEmail({doc: d, kit, life: panelLife, publicLinksAllowed: site.publicLinks !== false})}]});
774
+ }
775
+ if (editorRights()) items.push({label: 'Version history', icon: 'clock', action: showHistory});
776
+ for (const fn of extMenus) {
777
+ try { const extra = fn({doc: d, view: 'editor', editable: false}); if (Array.isArray(extra) && extra.length) items.push({type: 'divider'}, ...extra); } catch { /* an add-on's problem */ }
778
+ }
779
+ if (owner && !d.trashedAt) items.push({type: 'divider'}, {label: 'Move to Trash', icon: 'trash', danger: true, action: trash});
780
+ return items;
781
+ }
782
+
783
+ function moreMenu() {
784
+ if (pdf) return pdfMenu();
785
+ const d = current.peek();
786
+ const owner = d.access === 'owner';
787
+ const editable = mode.peek() === 'editing';
788
+ const editorRights = owner || d.access === 'editor';
789
+ const items = [];
790
+ if (editable) items.push({label: 'Save (Ctrl+S)', icon: 'save', action: () => save()});
791
+ if (owner && !d.trashedAt) items.push({label: 'Rename…', icon: 'edit-3', action: rename});
792
+ items.push({label: 'Make a copy', icon: 'copy', action: makeCopy});
793
+ if (owner && !d.trashedAt && folderItems.length) items.push({label: 'Move to', icon: 'folder', submenu: folderItems});
794
+ items.push({label: 'Save as template…', icon: 'layout', action: async () => templates.saveAsTemplate(editor.getValue())});
795
+ items.push({type: 'divider'});
796
+ items.push({label: 'Download', icon: 'download', submenu: exportItems(snapshot)});
797
+ items.push({label: 'Print or save as PDF…', icon: 'printer', action: () => exportItems(snapshot).at(-1).action()});
798
+ // 2.11: nothing goes out of a document that may not leave Docs now (outwardRefusal).
799
+ if (editorRights && !d.trashedAt && !d.outwardRefusal) {
800
+ items.push({label: 'Send to', icon: 'send', submenu: [
801
+ ...(site.allowSend === false ? [] : [{label: 'Email…', icon: 'mail', action: async () => { const d = await snapshot(); if (!disposed) openSendEmail({doc: d, kit, life: panelLife, publicLinksAllowed: site.publicLinks !== false}); }}]),
802
+ {label: 'A new page…', icon: 'file-text', action: async () => sendToPage(await snapshot())},
803
+ {label: 'A new blog post…', icon: 'feather', action: async () => sendToBlog(await snapshot())}
804
+ ]});
805
+ }
806
+ // History is the editors' (2.8.1): the server answers 403 to anyone else.
807
+ if (editorRights) items.push({label: 'Version history', icon: 'clock', action: showHistory});
808
+ for (const fn of extMenus) {
809
+ try { const extra = fn({doc: current.peek(), view: 'editor', editable}); if (Array.isArray(extra) && extra.length) items.push({type: 'divider'}, ...extra); } catch { /* an add-on's problem */ }
810
+ }
811
+ if (owner && !d.trashedAt) items.push({type: 'divider'}, {label: 'Move to Trash', icon: 'trash', danger: true, action: trash});
812
+ return items;
813
+ }
814
+
815
+ function setFocus(on) {
816
+ focus.value = on;
817
+ document.body.classList.toggle('docs-focus', on);
818
+ }
819
+ onDispose(() => document.body.classList.remove('docs-focus'));
820
+
821
+
822
+ // ---- Add-ons ----
823
+ // Only add-ons that understand this document's kind were kept above (2.13), so nothing here needs filtering.
824
+ const extMenus = [];
825
+ const shareSections = [];
826
+ const side = root.querySelector('.docs-ed__side');
827
+ const host = {
828
+ view: 'editor', api: docsApi, kit, esc, icon, editor, session,
829
+ doc: () => current.peek(),
830
+ isEditing: () => mode.peek() === 'editing',
831
+ reload: () => refreshFromServer(true),
832
+ save: () => save({silent: true}),
833
+ addDocMenu(fn) { if (typeof fn === 'function') extMenus.push(fn); },
834
+ addShareSection(fn) { if (typeof fn === 'function') shareSections.push(fn); },
835
+ /** Show a panel on the right; returns the element to fill. */
836
+ openSide(titleText) {
837
+ side.replaceChildren();
838
+ const head = document.createElement('div');
839
+ head.className = 'docs-ed__side-head';
840
+ const h = document.createElement('span');
841
+ h.textContent = titleText || '';
842
+ const x = document.createElement('button');
843
+ x.type = 'button';
844
+ x.className = 'btn btn-xs btn-ghost';
845
+ x.setAttribute('aria-label', 'Close panel');
846
+ x.innerHTML = icon('x', 14);
847
+ x.addEventListener('click', () => { sideOpen.value = false; side.replaceChildren(); });
848
+ head.append(h, x);
849
+ const body = document.createElement('div');
850
+ body.className = 'docs-ed__side-body';
851
+ side.append(head, body);
852
+ sideOpen.value = true;
853
+ return body;
854
+ },
855
+ closeSide() { sideOpen.value = false; side.replaceChildren(); },
856
+ /** Keep more data-* attributes through the admin's sanitiser (2.4) - e.g. comment anchors. */
857
+ allowAttrs: (list) => allowDocAttrs(list),
858
+ /** Filter the body on its way out - downloads, print, Send to (2.5). Keyed: installing again replaces it. */
859
+ setContentFilter: (key, fn) => setContentFilter(key, fn),
860
+ /** Whether the site offers suggestions, and the viewer's access (2.5). */
861
+ suggestions: Boolean(site.suggestions),
862
+ /** A chip beside the lock and Trash chips; returns the element to fill (2.1). */
863
+ addChip() {
864
+ const chip = document.createElement('span');
865
+ chip.hidden = true;
866
+ root.querySelector('.docs-ed__status-chips')?.appendChild(chip);
867
+ return chip;
868
+ },
869
+ /** Call fn with the document now and whenever it changes - a save, a reload (2.1). */
870
+ watchDoc(fn) { if (typeof fn === 'function') onDispose(M.effect(() => { const d = current.value; try { fn(d); } catch { /* an add-on's problem */ } })); },
871
+ onDispose
872
+ };
873
+ for (const ext of extensions) {
874
+ try { await ext.mod.install?.(host); } catch (err) { console.warn('[docs] add-on install failed', ext.owner, err); }
875
+ }
876
+ if (disposed) return;
877
+
878
+ // ---- Bindings ----
879
+ onDispose(kit.helpPopovers(root, {position: 'bottom'}));
880
+ const more = root.querySelector('.docs-ed__more');
881
+ const outlineList = root.querySelector('.docs-outline__list');
882
+ const bindings = M.applyBindings(data, root, {
883
+ methods: {
884
+ back, toggleStar, takeOver, share, restoreFromTrash,
885
+ editNow: () => startEditing(),
886
+ toggleOutline: () => { outlineOpen.value = !outlineOpen.peek(); },
887
+ toggleFocus: () => setFocus(!focus.peek()),
888
+ find: () => findReplace.show(root.querySelector('.docs-ed__canvas')),
889
+ history: showHistory
890
+ }
891
+ });
892
+ onDispose(() => bindings.dispose());
893
+
894
+ const menus = [
895
+ E.contextMenu(more, {cursor: 'glyph', inherit: false, density: 'compact', items: moreMenu}),
896
+ // Right-click in a table: rows, columns, delete. Anywhere else keeps the browser's own menu (spelling).
897
+ E.contextMenu(page, {
898
+ cursor: 'glyph', match: 'td, th', inherit: false, density: 'compact',
899
+ items: (cell) => editor.tableMenu(cell)
900
+ })
901
+ ];
902
+ onDispose(() => menus.forEach(mn => mn?.destroy?.()));
903
+ more.addEventListener('click', async () => {
904
+ if (current.peek().access === 'owner' && !folderItems.length) folderItems = await moveItems().catch(() => []);
905
+ if (!disposed) kit.openMenuAt(more);
906
+ });
907
+ outlineList.addEventListener('click', (e) => {
908
+ const b = e.target.closest('[data-heading]');
909
+ const h = b && headingEls[Number(b.dataset.heading)];
910
+ if (h) {
911
+ h.scrollIntoView({behavior: 'smooth', block: 'start'});
912
+ if (window.innerWidth < 900) outlineOpen.value = false;
913
+ }
914
+ });
915
+
916
+ // Keys: Ctrl+S saves, Ctrl+F finds, Ctrl+Shift+F focus mode, Escape leaves focus mode.
917
+ const onKey = (e) => {
918
+ const k = e.key.toLowerCase();
919
+ if ((e.ctrlKey || e.metaKey) && !e.shiftKey && k === 's') {
920
+ e.preventDefault();
921
+ save();
922
+ } else if ((e.ctrlKey || e.metaKey) && !e.shiftKey && k === 'f' && root.contains(e.target) && !pdf) {
923
+ e.preventDefault();
924
+ findReplace.show(root.querySelector('.docs-ed__canvas'));
925
+ } else if ((e.ctrlKey || e.metaKey) && e.shiftKey && k === 'f') {
926
+ e.preventDefault();
927
+ setFocus(!focus.peek());
928
+ } else if (e.key === 'Escape' && focus.peek() && !document.querySelector('.dm-modal.is-open, .dm-slideover.is-open')) {
929
+ setFocus(false);
930
+ }
931
+ };
932
+ document.addEventListener('keydown', onKey);
933
+ onDispose(() => document.removeEventListener('keydown', onKey));
934
+
935
+ // Title: Enter goes into the page.
936
+ root.querySelector('#docs-ed-title').addEventListener('keydown', (e) => {
937
+ if (e.key === 'Enter') { e.preventDefault(); editor.focus(); }
938
+ });
939
+
940
+ // Closing the tab: the browser's own prompt for unsaved work, a draft, and the lock let go.
941
+ const onBeforeUnload = (e) => {
942
+ // A save still on its way counts too (2.9): closing then would lose it.
943
+ if (saving.peek() && !dirty.peek()) { e.preventDefault(); e.returnValue = ''; return; }
944
+ if (!dirty.peek()) return;
945
+ writeDraft(id, draftNow());
946
+ e.preventDefault();
947
+ e.returnValue = '';
948
+ };
949
+ const onPageHide = () => { if (lockHeld) unlockOnUnload(id, session); };
950
+ window.addEventListener('beforeunload', onBeforeUnload);
951
+ window.addEventListener('pagehide', onPageHide);
952
+ onDispose(() => { window.removeEventListener('beforeunload', onBeforeUnload); window.removeEventListener('pagehide', onPageHide); });
953
+
954
+ Domma.icons.scan(root);
955
+
956
+ // The document goes in only now, after bindings, help popovers and icon
957
+ // scans: none of them may act on markup inside a person's document.
958
+ if (pdf) {
959
+ // The file, in the browser's own viewer (admin/lib/pdf-view.js); the editor keeps its detached page.
960
+ pdfView = mountPdfViewer(pageEl, {id, title: () => title.peek() || 'Untitled', ticket: () => current.peek().fileTicket || ''});
961
+ onDispose(() => pdfView?.dispose());
962
+ } else {
963
+ editor.setValue(doc.content || '');
964
+ editor._syncEmpty?.();
965
+ refreshOutline();
966
+ }
967
+
968
+ // ---- Hand the router the real state, then start ----
969
+ isDirty = () => dirty.peek();
970
+ saveNow = () => save({silent: true});
971
+ keepDraft = () => writeDraft(id, draftNow());
972
+
973
+ // Editing starts here - and offers back a draft from last time (offerDraft).
974
+ await startEditing();
975
+ if (disposed) return;
976
+ if (mode.peek() === 'editing' && !editor.text().trim()) editor.focus();
977
+ }
978
+ };