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.
- package/CHANGELOG.md +9 -0
- package/docs/DommaFeatures.md +13 -1
- package/package.json +1 -1
- package/plugins/_lib/admin/games/games.css +1 -1
- package/plugins/_lib/admin/games/kit.js +329 -124
- package/plugins/_lib/games/http.js +11 -1
- package/plugins/_lib/games/plays.js +28 -7
- package/plugins/crossword/CLAUDE.md +37 -0
- package/plugins/crossword/admin/css/index.css +18 -1
- package/plugins/crossword/admin/views/crossword.js +337 -205
- package/plugins/crossword/docs/guide.md +53 -0
- package/plugins/crossword/inject/body.html +1 -1
- package/plugins/crossword/plugin.js +38 -5
- package/plugins/crossword/plugin.json +4 -3
- package/plugins/crossword/public/crossword.css +36 -1
- package/plugins/crossword/public/crossword.js +468 -6
- package/plugins/docs/CLAUDE.md +318 -0
- package/plugins/docs/admin/css/docs.css +783 -0
- package/plugins/docs/admin/lib/api.js +340 -0
- package/plugins/docs/admin/lib/convert.js +482 -0
- package/plugins/docs/admin/lib/drafts.js +115 -0
- package/plugins/docs/admin/lib/editor.js +851 -0
- package/plugins/docs/admin/lib/find-replace.js +426 -0
- package/plugins/docs/admin/lib/folders.js +203 -0
- package/plugins/docs/admin/lib/panel-guard.js +73 -0
- package/plugins/docs/admin/lib/pdf-view.js +126 -0
- package/plugins/docs/admin/lib/send.js +156 -0
- package/plugins/docs/admin/lib/settings.js +142 -0
- package/plugins/docs/admin/lib/share.js +323 -0
- package/plugins/docs/admin/lib/shortcuts.js +44 -0
- package/plugins/docs/admin/lib/templates.js +194 -0
- package/plugins/docs/admin/lib/versions.js +192 -0
- package/plugins/docs/admin/templates/docs.html +92 -0
- package/plugins/docs/admin/templates/editor.html +86 -0
- package/plugins/docs/admin/views/docs.js +777 -0
- package/plugins/docs/admin/views/editor.js +978 -0
- package/plugins/docs/config.js +24 -0
- package/plugins/docs/docs/guide.md +270 -0
- package/plugins/docs/lib/extensions.js +213 -0
- package/plugins/docs/lib/importer.js +72 -0
- package/plugins/docs/lib/pdf-worker.js +48 -0
- package/plugins/docs/lib/pdf.js +222 -0
- package/plugins/docs/lib/store.js +748 -0
- package/plugins/docs/plugin.js +1108 -0
- package/plugins/docs/plugin.json +89 -0
- package/plugins/docs/plugin.public.js +114 -0
- package/plugins/docs/templates/public-doc.html +36 -0
- package/plugins/docs/tests/_env.js +10 -0
- package/plugins/docs/tests/_pdf.js +73 -0
- package/plugins/docs/tests/convert.test.js +95 -0
- package/plugins/docs/tests/drafts.test.js +82 -0
- package/plugins/docs/tests/pdf.test.js +86 -0
- package/plugins/docs/tests/plugin.test.js +816 -0
- package/plugins/docs/tests/shortcuts.test.js +30 -0
- package/plugins/free-tier.lock.json +147 -1
- package/plugins/garage/CLAUDE.md +51 -0
- package/plugins/garage/admin/css/garage.css +73 -0
- package/plugins/garage/admin/lib/garage.js +236 -0
- package/plugins/garage/admin/templates/garage.html +86 -0
- package/plugins/garage/admin/views/garage.js +581 -0
- package/plugins/garage/collections/garage-vehicles/schema.json +101 -0
- package/plugins/garage/config.js +18 -0
- package/plugins/garage/docs/guide.md +118 -0
- package/plugins/garage/plugin.js +471 -0
- package/plugins/garage/plugin.json +68 -0
- package/plugins/garage/tests/access.test.js +134 -0
- package/plugins/garage/tests/garage.test.js +106 -0
- package/plugins/messages/CLAUDE.md +230 -0
- package/plugins/messages/admin/css/messages.css +117 -0
- package/plugins/messages/admin/lib/messages.js +452 -0
- package/plugins/messages/admin/templates/messages.html +76 -0
- package/plugins/messages/admin/views/messages.js +1219 -0
- package/plugins/messages/collections/user-message-threads/schema.json +22 -0
- package/plugins/messages/collections/user-messages/schema.json +23 -0
- package/plugins/messages/config.js +14 -0
- package/plugins/messages/docs/guide.md +176 -0
- package/plugins/messages/lib/call.js +138 -0
- package/plugins/messages/plugin.js +994 -0
- package/plugins/messages/plugin.json +59 -0
- package/plugins/messages/plugin.public.js +52 -0
- package/plugins/messages/templates/call.html +6 -0
- package/plugins/messages/tests/after-send.test.js +22 -0
- package/plugins/messages/tests/api.test.js +187 -0
- package/plugins/messages/tests/call.test.js +497 -0
- package/plugins/messages/tests/drafts.test.js +25 -0
- package/plugins/messages/tests/messages.test.js +167 -0
- package/plugins/quiz/CLAUDE.md +43 -0
- package/plugins/quiz/admin/css/index.css +13 -1
- package/plugins/quiz/admin/views/quiz.js +423 -245
- package/plugins/quiz/docs/guide.md +61 -0
- package/plugins/quiz/inject/body.html +1 -1
- package/plugins/quiz/plugin.js +39 -5
- package/plugins/quiz/plugin.json +4 -3
- package/plugins/quiz/public/quiz.css +36 -1
- package/plugins/quiz/public/quiz.js +298 -6
- package/plugins/theme-roller/CLAUDE.md +65 -0
- package/plugins/theme-roller/admin/css/theme-roller.css +43 -0
- package/plugins/theme-roller/admin/lib/prompt.js +22 -0
- package/plugins/theme-roller/admin/lib/themes.js +196 -0
- package/plugins/theme-roller/admin/lib/view.js +88 -0
- package/plugins/theme-roller/admin/templates/theme-roller.html +73 -0
- package/plugins/theme-roller/admin/views/theme-roller-view.js +639 -0
- package/plugins/theme-roller/config.js +1 -0
- package/plugins/theme-roller/docs/guide.md +104 -0
- package/plugins/theme-roller/plugin.js +263 -0
- package/plugins/theme-roller/plugin.json +42 -0
- package/plugins/theme-roller/tests/api.test.js +161 -0
- package/plugins/theme-roller/tests/prompt-values.test.js +22 -0
- package/plugins/theme-roller/tests/themes.test.js +77 -0
- package/plugins/theme-roller/tests/view.test.js +55 -0
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Find and replace over whichever surface the editor is using.
|
|
3
|
+
*
|
|
4
|
+
* A textarea and a contenteditable have nothing in common at the DOM level,
|
|
5
|
+
* so both are reduced to the same thing first: the document's plain text plus
|
|
6
|
+
* a way to turn an offset in that text back into a position on the surface.
|
|
7
|
+
* Everything below then works in offsets and never asks which mode it is in.
|
|
8
|
+
*
|
|
9
|
+
* Searching is over TEXT, never over markup. Matching the HTML would let a
|
|
10
|
+
* search for "span" hit every tag in the document and a replace corrupt it,
|
|
11
|
+
* and a person searching a document means the words they can see.
|
|
12
|
+
*
|
|
13
|
+
* @module docs/admin/lib/find-replace
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Guards a replace-all whose replacement contains its own search term.
|
|
18
|
+
* Nothing legitimate needs more passes than this; a loop does.
|
|
19
|
+
*/
|
|
20
|
+
const MAX_REPLACEMENTS = 5000;
|
|
21
|
+
|
|
22
|
+
export class FindReplace {
|
|
23
|
+
constructor() {
|
|
24
|
+
this.editor = null;
|
|
25
|
+
this.bar = null;
|
|
26
|
+
this.findInput = null;
|
|
27
|
+
this.replaceInput = null;
|
|
28
|
+
this.caseInput = null;
|
|
29
|
+
this.status = null;
|
|
30
|
+
// Where the next "Find next" starts looking, so repeated presses walk
|
|
31
|
+
// the document instead of landing on the same match.
|
|
32
|
+
this._cursor = 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {object} editor a DocEditor
|
|
37
|
+
* @returns {this}
|
|
38
|
+
*/
|
|
39
|
+
setEditor(editor) {
|
|
40
|
+
this.editor = editor;
|
|
41
|
+
return this;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Show the bar inside the editor pane, or focus it if it is already there.
|
|
46
|
+
*
|
|
47
|
+
* @param {HTMLElement} editorPane
|
|
48
|
+
* @returns {void}
|
|
49
|
+
*/
|
|
50
|
+
show(editorPane) {
|
|
51
|
+
if (!editorPane) return;
|
|
52
|
+
|
|
53
|
+
if (!this.bar || !this.bar.isConnected) {
|
|
54
|
+
this.bar = this._buildBar();
|
|
55
|
+
const content = editorPane.querySelector('#doc-editor-content');
|
|
56
|
+
if (content) editorPane.insertBefore(this.bar, content);
|
|
57
|
+
else editorPane.prepend(this.bar);
|
|
58
|
+
if (typeof Domma !== 'undefined' && Domma.icons) Domma.icons.scan(this.bar);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
this._cursor = 0;
|
|
62
|
+
this.findInput.focus();
|
|
63
|
+
this.findInput.select();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @returns {void}
|
|
68
|
+
*/
|
|
69
|
+
hide() {
|
|
70
|
+
this.bar?.remove();
|
|
71
|
+
this.bar = null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// -----------------------------------------------------------------------
|
|
75
|
+
// Searching
|
|
76
|
+
// -----------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Select the next match after the cursor, wrapping once.
|
|
80
|
+
*
|
|
81
|
+
* @returns {boolean} true if something was found
|
|
82
|
+
*/
|
|
83
|
+
findNext() {
|
|
84
|
+
const term = this.findInput.value;
|
|
85
|
+
if (!term) return false;
|
|
86
|
+
|
|
87
|
+
const surface = this.editor?.getSurface();
|
|
88
|
+
if (!surface) return false;
|
|
89
|
+
|
|
90
|
+
const text = this._text();
|
|
91
|
+
const index = this._indexOf(text, term, this._cursor);
|
|
92
|
+
|
|
93
|
+
if (index === -1) {
|
|
94
|
+
// One wrap, then give up - a second wrap is the same search again.
|
|
95
|
+
const wrapped = this._indexOf(text, term, 0);
|
|
96
|
+
if (wrapped === -1) {
|
|
97
|
+
this._say('No matches');
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
this._cursor = wrapped;
|
|
101
|
+
this._selectRange(wrapped, term.length);
|
|
102
|
+
this._say('Wrapped to the first match');
|
|
103
|
+
this._cursor = wrapped + term.length;
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
this._selectRange(index, term.length);
|
|
108
|
+
this._cursor = index + term.length;
|
|
109
|
+
this._say('');
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Replace the current selection when it is a match, then move on.
|
|
115
|
+
*
|
|
116
|
+
* @returns {void}
|
|
117
|
+
*/
|
|
118
|
+
replaceCurrent() {
|
|
119
|
+
const term = this.findInput.value;
|
|
120
|
+
if (!term) return;
|
|
121
|
+
|
|
122
|
+
const replacement = this.replaceInput.value;
|
|
123
|
+
const text = this._text();
|
|
124
|
+
|
|
125
|
+
// The caret sits just past the last match, so that is where the match
|
|
126
|
+
// being looked at starts.
|
|
127
|
+
const start = Math.max(0, this._cursor - term.length);
|
|
128
|
+
const candidate = text.slice(start, start + term.length);
|
|
129
|
+
const matches = this.caseInput.checked
|
|
130
|
+
? candidate === term
|
|
131
|
+
: candidate.toLowerCase() === term.toLowerCase();
|
|
132
|
+
|
|
133
|
+
if (!matches) {
|
|
134
|
+
// Nothing selected yet: find one first, so the button always does
|
|
135
|
+
// something rather than silently doing nothing on first press.
|
|
136
|
+
this.findNext();
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (this.editor.readOnly) { E.toast('This document is open read only.', {type: 'info'}); return; }
|
|
141
|
+
if (this.editor.suggesting) { E.toast('Replace is off while suggesting - change the words in the page instead.', {type: 'info'}); return; }
|
|
142
|
+
this._replaceRange(start, term.length, replacement);
|
|
143
|
+
this._cursor = start + replacement.length;
|
|
144
|
+
this.editor.notifyChanged();
|
|
145
|
+
this.findNext();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* @returns {void}
|
|
150
|
+
*/
|
|
151
|
+
replaceAll() {
|
|
152
|
+
if (this.editor?.readOnly) { E.toast('This document is open read only.', {type: 'info'}); return; }
|
|
153
|
+
if (this.editor?.suggesting) { E.toast('Replace is off while suggesting - change the words in the page instead.', {type: 'info'}); return; }
|
|
154
|
+
const term = this.findInput.value;
|
|
155
|
+
if (!term) return;
|
|
156
|
+
|
|
157
|
+
const replacement = this.replaceInput.value;
|
|
158
|
+
let from = 0;
|
|
159
|
+
let count = 0;
|
|
160
|
+
|
|
161
|
+
while (count < MAX_REPLACEMENTS) {
|
|
162
|
+
const index = this._indexOf(this._text(), term, from);
|
|
163
|
+
if (index === -1) break;
|
|
164
|
+
this._replaceRange(index, term.length, replacement);
|
|
165
|
+
// Resume past what was just written, or replacing "a" with "aa"
|
|
166
|
+
// never terminates.
|
|
167
|
+
from = index + replacement.length;
|
|
168
|
+
count++;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (count) this.editor.notifyChanged();
|
|
172
|
+
this._cursor = 0;
|
|
173
|
+
this._say(count ? `Replaced ${count}` : 'No matches');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// -----------------------------------------------------------------------
|
|
177
|
+
// Surface abstraction
|
|
178
|
+
// -----------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* @returns {string} the surface's plain text
|
|
182
|
+
*/
|
|
183
|
+
_text() {
|
|
184
|
+
const surface = this.editor.getSurface();
|
|
185
|
+
return this.editor.isMarkdown() ? surface.value : this._collect(surface).text;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Walk a contenteditable's text nodes once, recording where each one
|
|
190
|
+
* starts in the concatenated string. That map is what lets an offset in
|
|
191
|
+
* the flat text become a Range in the tree.
|
|
192
|
+
*
|
|
193
|
+
* @param {HTMLElement} root
|
|
194
|
+
* @returns {{text: string, nodes: {node: Text, start: number}[]}}
|
|
195
|
+
*/
|
|
196
|
+
_collect(root) {
|
|
197
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
198
|
+
const nodes = [];
|
|
199
|
+
let text = '';
|
|
200
|
+
|
|
201
|
+
let node = walker.nextNode();
|
|
202
|
+
while (node) {
|
|
203
|
+
nodes.push({node, start: text.length});
|
|
204
|
+
text += node.nodeValue;
|
|
205
|
+
node = walker.nextNode();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return {text, nodes};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* @param {string} haystack
|
|
213
|
+
* @param {string} needle
|
|
214
|
+
* @param {number} from
|
|
215
|
+
* @returns {number}
|
|
216
|
+
*/
|
|
217
|
+
_indexOf(haystack, needle, from) {
|
|
218
|
+
if (this.caseInput.checked) return haystack.indexOf(needle, from);
|
|
219
|
+
return haystack.toLowerCase().indexOf(needle.toLowerCase(), from);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Turn a flat-text offset and length into a live DOM Range.
|
|
224
|
+
*
|
|
225
|
+
* A match may begin in one text node and end in another - "hello" split
|
|
226
|
+
* by a stray <b> - which is exactly why this returns a Range rather than
|
|
227
|
+
* a single node and offset.
|
|
228
|
+
*
|
|
229
|
+
* @param {number} start
|
|
230
|
+
* @param {number} length
|
|
231
|
+
* @returns {Range|null}
|
|
232
|
+
*/
|
|
233
|
+
_rangeFor(start, length) {
|
|
234
|
+
const {nodes} = this._collect(this.editor.getSurface());
|
|
235
|
+
const end = start + length;
|
|
236
|
+
|
|
237
|
+
let startNode = null;
|
|
238
|
+
let startOffset = 0;
|
|
239
|
+
let endNode = null;
|
|
240
|
+
let endOffset = 0;
|
|
241
|
+
|
|
242
|
+
for (const entry of nodes) {
|
|
243
|
+
const nodeEnd = entry.start + entry.node.nodeValue.length;
|
|
244
|
+
if (startNode === null && start < nodeEnd) {
|
|
245
|
+
startNode = entry.node;
|
|
246
|
+
startOffset = start - entry.start;
|
|
247
|
+
}
|
|
248
|
+
if (end <= nodeEnd) {
|
|
249
|
+
endNode = entry.node;
|
|
250
|
+
endOffset = end - entry.start;
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (!startNode || !endNode) return null;
|
|
256
|
+
|
|
257
|
+
const range = document.createRange();
|
|
258
|
+
range.setStart(startNode, startOffset);
|
|
259
|
+
range.setEnd(endNode, endOffset);
|
|
260
|
+
return range;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* @param {number} start
|
|
265
|
+
* @param {number} length
|
|
266
|
+
* @returns {void}
|
|
267
|
+
*/
|
|
268
|
+
_selectRange(start, length) {
|
|
269
|
+
const surface = this.editor.getSurface();
|
|
270
|
+
|
|
271
|
+
if (this.editor.isMarkdown()) {
|
|
272
|
+
surface.focus();
|
|
273
|
+
surface.setSelectionRange(start, start + length);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const range = this._rangeFor(start, length);
|
|
278
|
+
if (!range) return;
|
|
279
|
+
|
|
280
|
+
const selection = window.getSelection();
|
|
281
|
+
selection.removeAllRanges();
|
|
282
|
+
selection.addRange(range);
|
|
283
|
+
|
|
284
|
+
// Bring it into view without stealing focus from the find box, so
|
|
285
|
+
// Enter keeps stepping through matches.
|
|
286
|
+
const rect = range.getBoundingClientRect();
|
|
287
|
+
const bounds = surface.getBoundingClientRect();
|
|
288
|
+
if (rect.bottom > bounds.bottom || rect.top < bounds.top) {
|
|
289
|
+
surface.scrollTop += rect.top - bounds.top - bounds.height / 2;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* @param {number} start
|
|
295
|
+
* @param {number} length
|
|
296
|
+
* @param {string} replacement
|
|
297
|
+
* @returns {void}
|
|
298
|
+
*/
|
|
299
|
+
_replaceRange(start, length, replacement) {
|
|
300
|
+
const surface = this.editor.getSurface();
|
|
301
|
+
// Reading only (a viewer, or someone else is editing): find, never replace.
|
|
302
|
+
if (this.editor.readOnly) throw new Error('read only');
|
|
303
|
+
|
|
304
|
+
if (this.editor.isMarkdown()) {
|
|
305
|
+
surface.setRangeText(replacement, start, start + length, 'end');
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const range = this._rangeFor(start, length);
|
|
310
|
+
if (!range) return;
|
|
311
|
+
|
|
312
|
+
range.deleteContents();
|
|
313
|
+
// An empty replacement must not insert an empty text node: it would
|
|
314
|
+
// add an entry to the walk that shifts nothing and confuses nothing,
|
|
315
|
+
// but it accumulates one per replacement over a long document.
|
|
316
|
+
if (replacement) range.insertNode(document.createTextNode(replacement));
|
|
317
|
+
surface.normalize();
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// -----------------------------------------------------------------------
|
|
321
|
+
// The bar
|
|
322
|
+
// -----------------------------------------------------------------------
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* @returns {HTMLElement}
|
|
326
|
+
*/
|
|
327
|
+
_buildBar() {
|
|
328
|
+
const bar = document.createElement('div');
|
|
329
|
+
bar.className = 'docs-find';
|
|
330
|
+
|
|
331
|
+
this.findInput = document.createElement('input');
|
|
332
|
+
this.findInput.type = 'text';
|
|
333
|
+
this.findInput.className = 'form-input form-input-sm';
|
|
334
|
+
this.findInput.placeholder = 'Find';
|
|
335
|
+
this.findInput.setAttribute('aria-label', 'Find');
|
|
336
|
+
// A new search term starts from the top, not from wherever the last
|
|
337
|
+
// one left the caret.
|
|
338
|
+
this.findInput.addEventListener('input', () => {
|
|
339
|
+
this._cursor = 0;
|
|
340
|
+
this._say('');
|
|
341
|
+
});
|
|
342
|
+
this.findInput.addEventListener('keydown', (e) => {
|
|
343
|
+
if (e.key === 'Enter') {
|
|
344
|
+
e.preventDefault();
|
|
345
|
+
this.findNext();
|
|
346
|
+
} else if (e.key === 'Escape') {
|
|
347
|
+
e.preventDefault();
|
|
348
|
+
this.hide();
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
bar.appendChild(this.findInput);
|
|
352
|
+
|
|
353
|
+
this.replaceInput = document.createElement('input');
|
|
354
|
+
this.replaceInput.type = 'text';
|
|
355
|
+
this.replaceInput.className = 'form-input form-input-sm';
|
|
356
|
+
this.replaceInput.placeholder = 'Replace with';
|
|
357
|
+
this.replaceInput.setAttribute('aria-label', 'Replace with');
|
|
358
|
+
this.replaceInput.addEventListener('keydown', (e) => {
|
|
359
|
+
if (e.key === 'Escape') {
|
|
360
|
+
e.preventDefault();
|
|
361
|
+
this.hide();
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
bar.appendChild(this.replaceInput);
|
|
365
|
+
|
|
366
|
+
bar.appendChild(this._button('Next', () => this.findNext(), 'btn-outline'));
|
|
367
|
+
bar.appendChild(this._button('Replace', () => this.replaceCurrent(), 'btn-outline'));
|
|
368
|
+
bar.appendChild(this._button('All', () => this.replaceAll(), 'btn-outline'));
|
|
369
|
+
|
|
370
|
+
const caseLabel = document.createElement('label');
|
|
371
|
+
caseLabel.className = 'docs-find__case';
|
|
372
|
+
this.caseInput = document.createElement('input');
|
|
373
|
+
this.caseInput.type = 'checkbox';
|
|
374
|
+
caseLabel.appendChild(this.caseInput);
|
|
375
|
+
caseLabel.appendChild(document.createTextNode('Match case'));
|
|
376
|
+
this.caseInput.addEventListener('change', () => {
|
|
377
|
+
this._cursor = 0;
|
|
378
|
+
});
|
|
379
|
+
bar.appendChild(caseLabel);
|
|
380
|
+
|
|
381
|
+
this.status = document.createElement('span');
|
|
382
|
+
this.status.className = 'docs-find__status';
|
|
383
|
+
this.status.setAttribute('role', 'status');
|
|
384
|
+
bar.appendChild(this.status);
|
|
385
|
+
|
|
386
|
+
const close = document.createElement('button');
|
|
387
|
+
close.type = 'button';
|
|
388
|
+
close.className = 'btn btn-sm btn-ghost docs-find__close';
|
|
389
|
+
close.title = 'Close find and replace';
|
|
390
|
+
close.setAttribute('aria-label', 'Close find and replace');
|
|
391
|
+
const icon = document.createElement('span');
|
|
392
|
+
icon.dataset.icon = 'x';
|
|
393
|
+
icon.dataset.iconSize = '14';
|
|
394
|
+
close.appendChild(icon);
|
|
395
|
+
close.addEventListener('click', () => this.hide());
|
|
396
|
+
bar.appendChild(close);
|
|
397
|
+
|
|
398
|
+
return bar;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* @param {string} text
|
|
403
|
+
* @param {Function} handler
|
|
404
|
+
* @param {string} variant
|
|
405
|
+
* @returns {HTMLElement}
|
|
406
|
+
*/
|
|
407
|
+
_button(text, handler, variant) {
|
|
408
|
+
const btn = document.createElement('button');
|
|
409
|
+
btn.type = 'button';
|
|
410
|
+
btn.className = `btn btn-sm ${variant}`;
|
|
411
|
+
btn.textContent = text;
|
|
412
|
+
// mousedown would fire before the contenteditable selection settles;
|
|
413
|
+
// click is right here because the buttons work from offsets, not from
|
|
414
|
+
// whatever happens to be selected.
|
|
415
|
+
btn.addEventListener('click', handler);
|
|
416
|
+
return btn;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* @param {string} message
|
|
421
|
+
* @returns {void}
|
|
422
|
+
*/
|
|
423
|
+
_say(message) {
|
|
424
|
+
if (this.status) this.status.textContent = message;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The folder pane.
|
|
3
|
+
*
|
|
4
|
+
* Folders are a flat list on the wire, each carrying a `parentId`, so the
|
|
5
|
+
* tree is assembled here rather than on the server. A cycle in that data -
|
|
6
|
+
* two folders naming each other as parent - would otherwise recurse until the
|
|
7
|
+
* tab dies, so `buildTree` only ever visits a folder once.
|
|
8
|
+
*
|
|
9
|
+
* "All documents" is not a folder. It is the null selection, drawn as a row
|
|
10
|
+
* because a person needs somewhere to click to get back out of a folder.
|
|
11
|
+
*
|
|
12
|
+
* This module owns the data and the edits. Drawing is the screen's: it binds
|
|
13
|
+
* `rows()` with M.applyBindings, so a rename or a delete here only has to say
|
|
14
|
+
* that something changed (`onChange`) and the pane follows.
|
|
15
|
+
*
|
|
16
|
+
* @module docs/admin/lib/folders
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import {api, promptWith} from './api.js';
|
|
20
|
+
|
|
21
|
+
export class FolderManager {
|
|
22
|
+
/**
|
|
23
|
+
* @param {object} [options]
|
|
24
|
+
* @param {Function} [options.onFolderSelect] called with the folder id, or null for all
|
|
25
|
+
* @param {Function} [options.onChange] called after the list is reloaded
|
|
26
|
+
*/
|
|
27
|
+
constructor(options = {}) {
|
|
28
|
+
this.onFolderSelect = options.onFolderSelect || (() => {});
|
|
29
|
+
this.onChange = options.onChange || (() => {});
|
|
30
|
+
this.folders = [];
|
|
31
|
+
this.currentFolderId = null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Fetch the folder list. A failure leaves the previous list in place -
|
|
36
|
+
* an empty pane would read as "you have no folders".
|
|
37
|
+
*
|
|
38
|
+
* @returns {Promise<object[]>}
|
|
39
|
+
*/
|
|
40
|
+
async load() {
|
|
41
|
+
try {
|
|
42
|
+
const folders = await api('/folders');
|
|
43
|
+
this.folders = Array.isArray(folders) ? folders : [];
|
|
44
|
+
} catch (err) {
|
|
45
|
+
E.toast(err.message || 'Failed to load folders', {type: 'error'});
|
|
46
|
+
}
|
|
47
|
+
this.onChange(this.folders);
|
|
48
|
+
return this.folders;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @param {string|null} id
|
|
53
|
+
* @returns {object|null}
|
|
54
|
+
*/
|
|
55
|
+
byId(id) {
|
|
56
|
+
return this.folders.find(f => f.id === id) || null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The tree flattened into drawing order, "All documents" first. Each row
|
|
61
|
+
* carries its depth, so the pane is one keyed list rather than a nest.
|
|
62
|
+
*
|
|
63
|
+
* @returns {{id: string|null, key: string, name: string, depth: number, icon: string}[]}
|
|
64
|
+
*/
|
|
65
|
+
rows() {
|
|
66
|
+
const out = [{id: null, key: '__all', name: 'All documents', depth: 0, icon: 'folder-open'}];
|
|
67
|
+
const walk = (nodes, depth) => nodes.forEach(node => {
|
|
68
|
+
out.push({id: node.id, key: node.id, name: node.name || 'Untitled', depth, icon: 'folder'});
|
|
69
|
+
walk(node.children, depth + 1);
|
|
70
|
+
});
|
|
71
|
+
walk(this.buildTree(), 0);
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Arrange the flat list into roots and children.
|
|
77
|
+
*
|
|
78
|
+
* A folder whose parent is missing - deleted, or owned by someone else
|
|
79
|
+
* under user scoping - is drawn at the root rather than dropped, because
|
|
80
|
+
* a folder you cannot see is a folder you cannot empty.
|
|
81
|
+
*
|
|
82
|
+
* @returns {object[]}
|
|
83
|
+
*/
|
|
84
|
+
buildTree() {
|
|
85
|
+
const nodes = new Map();
|
|
86
|
+
this.folders.forEach(folder => nodes.set(folder.id, {...folder, children: []}));
|
|
87
|
+
|
|
88
|
+
const roots = [];
|
|
89
|
+
nodes.forEach(node => {
|
|
90
|
+
const parent = node.parentId ? nodes.get(node.parentId) : null;
|
|
91
|
+
if (parent && parent !== node && !this._isAncestor(node, parent, nodes)) {
|
|
92
|
+
parent.children.push(node);
|
|
93
|
+
} else {
|
|
94
|
+
roots.push(node);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const byName = (a, b) => (a.name || '').localeCompare(b.name || '');
|
|
99
|
+
roots.sort(byName);
|
|
100
|
+
nodes.forEach(node => node.children.sort(byName));
|
|
101
|
+
return roots;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Select a folder and tell the screen.
|
|
106
|
+
*
|
|
107
|
+
* @param {string|null} id
|
|
108
|
+
* @returns {void}
|
|
109
|
+
*/
|
|
110
|
+
select(id) {
|
|
111
|
+
this.currentFolderId = id;
|
|
112
|
+
this.onFolderSelect(id);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Ask for a name and create a folder.
|
|
117
|
+
*
|
|
118
|
+
* @param {string|null} [parentId] defaults to the current selection
|
|
119
|
+
* @returns {Promise<object|null>} the new folder, or null if cancelled
|
|
120
|
+
*/
|
|
121
|
+
async createFolder(parentId = this.currentFolderId) {
|
|
122
|
+
const name = await E.prompt('Folder name', {
|
|
123
|
+
title: parentId ? 'New subfolder' : 'New folder',
|
|
124
|
+
inputPlaceholder: 'e.g. Meeting notes'
|
|
125
|
+
});
|
|
126
|
+
if (!name || !name.trim()) return null;
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
const folder = await api('/folders', 'POST', {name: name.trim(), parentId});
|
|
130
|
+
await this.load();
|
|
131
|
+
return folder;
|
|
132
|
+
} catch (err) {
|
|
133
|
+
E.toast(err.message || 'Failed to create folder', {type: 'error'});
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* @param {object} folder
|
|
140
|
+
* @returns {Promise<void>}
|
|
141
|
+
*/
|
|
142
|
+
async renameFolder(folder) {
|
|
143
|
+
const name = await promptWith('Folder name', {title: 'Rename folder'}, folder.name || '');
|
|
144
|
+
if (!name || !name.trim() || name.trim() === folder.name) return;
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
await api(`/folders/${encodeURIComponent(folder.id)}`, 'PUT', {name: name.trim()});
|
|
148
|
+
await this.load();
|
|
149
|
+
} catch (err) {
|
|
150
|
+
E.toast(err.message || 'Failed to rename folder', {type: 'error'});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Delete a folder. The server moves its documents back to the root, so
|
|
156
|
+
* the confirmation says so rather than implying the documents go too.
|
|
157
|
+
*
|
|
158
|
+
* @param {object} folder
|
|
159
|
+
* @returns {Promise<void>}
|
|
160
|
+
*/
|
|
161
|
+
async deleteFolder(folder) {
|
|
162
|
+
const confirmed = await E.confirm(
|
|
163
|
+
'Delete this folder? Its documents move back to All documents.',
|
|
164
|
+
{title: 'Delete folder', confirmText: 'Delete'}
|
|
165
|
+
);
|
|
166
|
+
if (!confirmed) return;
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
await api(`/folders/${encodeURIComponent(folder.id)}`, 'DELETE');
|
|
170
|
+
// Standing on a folder that no longer exists would leave the list
|
|
171
|
+
// filtered by a dead id and looking permanently empty.
|
|
172
|
+
if (this.currentFolderId === folder.id) this.currentFolderId = null;
|
|
173
|
+
await this.load();
|
|
174
|
+
this.onFolderSelect(this.currentFolderId);
|
|
175
|
+
E.toast('Folder deleted', {type: 'success'});
|
|
176
|
+
} catch (err) {
|
|
177
|
+
E.toast(err.message || 'Failed to delete folder', {type: 'error'});
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// -----------------------------------------------------------------------
|
|
182
|
+
// Internals
|
|
183
|
+
// -----------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Would attaching `node` under `parent` close a loop?
|
|
187
|
+
*
|
|
188
|
+
* @param {object} node
|
|
189
|
+
* @param {object} parent
|
|
190
|
+
* @param {Map} nodes
|
|
191
|
+
* @returns {boolean}
|
|
192
|
+
*/
|
|
193
|
+
_isAncestor(node, parent, nodes) {
|
|
194
|
+
const seen = new Set();
|
|
195
|
+
let cursor = parent;
|
|
196
|
+
while (cursor) {
|
|
197
|
+
if (cursor === node || seen.has(cursor.id)) return true;
|
|
198
|
+
seen.add(cursor.id);
|
|
199
|
+
cursor = cursor.parentId ? nodes.get(cursor.parentId) : null;
|
|
200
|
+
}
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Docs - ask before a slideover with unsaved changes closes (its button,
|
|
3
|
+
* Escape, the backdrop). The same helper as Sheets' (plugins cannot share a
|
|
4
|
+
* file): Domma's close() is wrapped once; a second close while the question is
|
|
5
|
+
* open is ignored; once the panel is really closing nothing asks again
|
|
6
|
+
* (onceClosed's later destroy() calls close() argument-less). `close(true)`
|
|
7
|
+
* closes without asking. Also: one panel of a kind at a time (claimPanel), and
|
|
8
|
+
* a panel that closes with the screen that opened it (tieToScreen).
|
|
9
|
+
*
|
|
10
|
+
* Call it AFTER kit.onceClosed(so, …): both wrap close(), and onceClosed's
|
|
11
|
+
* wrapper tears the panel down whatever an inner wrapper decided - so the
|
|
12
|
+
* question has to be the outer one.
|
|
13
|
+
*
|
|
14
|
+
* @module docs/admin/lib/panel-guard
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {object} so - an E.slideover
|
|
19
|
+
* @param {() => boolean} dirty
|
|
20
|
+
* @param {string} [question]
|
|
21
|
+
* @param {() => boolean} [busy] - true while the panel's work is on its way (a send): a close then is
|
|
22
|
+
* ignored, because the panel closes itself when it lands (2.10)
|
|
23
|
+
* @returns {{done: () => void}} call done() once the work is saved or sent, so the close does not ask
|
|
24
|
+
*/
|
|
25
|
+
export function guardClose(so, dirty, question = 'Discard the changes you have not saved?', busy = () => false) {
|
|
26
|
+
const close = so.close.bind(so);
|
|
27
|
+
let finished = false;
|
|
28
|
+
let asking = false;
|
|
29
|
+
so.close = async (force) => {
|
|
30
|
+
if (!finished && force !== true) {
|
|
31
|
+
if (busy()) return undefined;
|
|
32
|
+
if (dirty()) {
|
|
33
|
+
if (asking) return undefined;
|
|
34
|
+
asking = true;
|
|
35
|
+
const ok = await E.confirm(question);
|
|
36
|
+
asking = false;
|
|
37
|
+
if (!ok) return undefined;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
finished = true;
|
|
41
|
+
return close();
|
|
42
|
+
};
|
|
43
|
+
return {done: () => { finished = true; }};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* One panel of a kind at a time (2.10): a second click while it is opening or
|
|
48
|
+
* open does nothing. Returns the release - call it when the panel has closed,
|
|
49
|
+
* or when opening it failed (or it can never open again until a reload) - or
|
|
50
|
+
* null when one is already claimed.
|
|
51
|
+
*
|
|
52
|
+
* @param {string} kind
|
|
53
|
+
* @returns {(() => void)|null}
|
|
54
|
+
*/
|
|
55
|
+
const claimed = new Set();
|
|
56
|
+
export function claimPanel(kind) {
|
|
57
|
+
if (claimed.has(kind)) return null;
|
|
58
|
+
claimed.add(kind);
|
|
59
|
+
let done = false;
|
|
60
|
+
return () => { if (!done) { done = true; claimed.delete(kind); } };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Tie a panel to the screen that opened it: it closes (without asking) when
|
|
65
|
+
* the screen goes. `life` is {alive(), add(fn) => remove}; absent = no tie.
|
|
66
|
+
*
|
|
67
|
+
* @returns {() => void} untie - call it when the panel closes by itself
|
|
68
|
+
*/
|
|
69
|
+
export function tieToScreen(so, life) {
|
|
70
|
+
if (!life?.add) return () => {};
|
|
71
|
+
const off = life.add(() => { try { so.close(true); } catch { /* gone */ } });
|
|
72
|
+
return typeof off === 'function' ? off : () => {};
|
|
73
|
+
}
|