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,851 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The document editing surface.
|
|
3
|
+
*
|
|
4
|
+
* One mode since 2.0: a contenteditable region whose content is stored as
|
|
5
|
+
* HTML. Markdown is a way of typing here (shortcuts), not a storage format.
|
|
6
|
+
* Everything above this module - autosave, find and replace, version restore,
|
|
7
|
+
* the Outline - calls getValue/setValue/onChange/getSurface, so none of it
|
|
8
|
+
* has to know how the surface works.
|
|
9
|
+
*
|
|
10
|
+
* There is deliberately no third-party editor. A plugin is copied to every
|
|
11
|
+
* site that installs it, and a dependency it cannot vendor breaks on the site
|
|
12
|
+
* with no network egress. `document.execCommand` is formally deprecated and
|
|
13
|
+
* still the only way to get rich editing (with a working undo stack) out of a
|
|
14
|
+
* contenteditable without shipping a framework. Every call is guarded: where
|
|
15
|
+
* a browser drops one, that button stops working and typing does not.
|
|
16
|
+
*
|
|
17
|
+
* Tables and checklists are plain DOM work on top, and tell the listeners.
|
|
18
|
+
*
|
|
19
|
+
* @module docs/admin/lib/editor
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import {markdownTrigger} from '/plugins/docs/admin/lib/shortcuts.js';
|
|
23
|
+
import {sanitizeHtml, promptWith} from '/plugins/docs/admin/lib/api.js';
|
|
24
|
+
|
|
25
|
+
/** Icons Domma does not ship, drawn here (16px, currentColor). */
|
|
26
|
+
const SVG = {
|
|
27
|
+
'align-left': '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M3 6h18M3 12h12M3 18h16"/></svg>',
|
|
28
|
+
'align-center': '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M3 6h18M6 12h12M4 18h16"/></svg>',
|
|
29
|
+
'align-right': '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M3 6h18M9 12h12M5 18h16"/></svg>',
|
|
30
|
+
table: '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="1"/><path d="M3 10h18M3 15h18M9 4v16M15 4v16"/></svg>',
|
|
31
|
+
highlight: '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 11l-5 5v3h3l5-5M14 6l4 4M9 11l5-5 4 4-5 5z"/><path d="M3 22h18" stroke-width="3" stroke="#f5c518"/></svg>'
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** The toolbar: groups of tools. `cmd` goes to execCommand; `block` wraps in a block tag; `action` is ours. */
|
|
35
|
+
const TOOLS = [
|
|
36
|
+
[{kind: 'blocks'}],
|
|
37
|
+
[
|
|
38
|
+
{icon: 'undo', title: 'Undo (Ctrl+Z)', cmd: 'undo'},
|
|
39
|
+
{icon: 'redo', title: 'Redo (Ctrl+Y)', cmd: 'redo'}
|
|
40
|
+
],
|
|
41
|
+
[
|
|
42
|
+
{icon: 'bold', title: 'Bold (Ctrl+B)', cmd: 'bold', state: 'bold'},
|
|
43
|
+
{icon: 'italic', title: 'Italic (Ctrl+I)', cmd: 'italic', state: 'italic'},
|
|
44
|
+
{icon: 'underline', title: 'Underline (Ctrl+U)', cmd: 'underline', state: 'underline'},
|
|
45
|
+
{icon: 'strikethrough', title: 'Strikethrough', cmd: 'strikeThrough', state: 'strikeThrough'},
|
|
46
|
+
{svg: 'highlight', title: 'Highlight', action: 'highlight'},
|
|
47
|
+
{icon: 'code', title: 'Inline code', action: 'inlineCode'}
|
|
48
|
+
],
|
|
49
|
+
[
|
|
50
|
+
{svg: 'align-left', title: 'Align left', cmd: 'justifyLeft', state: 'justifyLeft'},
|
|
51
|
+
{svg: 'align-center', title: 'Centre', cmd: 'justifyCenter', state: 'justifyCenter'},
|
|
52
|
+
{svg: 'align-right', title: 'Align right', cmd: 'justifyRight', state: 'justifyRight'}
|
|
53
|
+
],
|
|
54
|
+
[
|
|
55
|
+
{icon: 'list-bullet', title: 'Bulleted list', cmd: 'insertUnorderedList'},
|
|
56
|
+
{icon: 'list-numbered', title: 'Numbered list', cmd: 'insertOrderedList'},
|
|
57
|
+
{icon: 'check-square', title: 'Checklist', action: 'checklist'},
|
|
58
|
+
{icon: 'quote', title: 'Quote', block: 'blockquote'}
|
|
59
|
+
],
|
|
60
|
+
[
|
|
61
|
+
{icon: 'link', title: 'Link (Ctrl+K)', action: 'link'},
|
|
62
|
+
{icon: 'image', title: 'Image', action: 'image'},
|
|
63
|
+
{svg: 'table', title: 'Table', action: 'table'},
|
|
64
|
+
{icon: 'minus', title: 'Horizontal line', cmd: 'insertHorizontalRule'},
|
|
65
|
+
{icon: 'clear-format', title: 'Clear formatting', cmd: 'removeFormat'}
|
|
66
|
+
]
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
const BLOCKS = [['p', 'Normal text'], ['h1', 'Heading 1'], ['h2', 'Heading 2'], ['h3', 'Heading 3'], ['h4', 'Heading 4'], ['pre', 'Code block'], ['blockquote', 'Quote']];
|
|
70
|
+
|
|
71
|
+
/** The actions applyNamed (2.8) offers add-ons - the same tools the toolbar applies. */
|
|
72
|
+
const NAMED_TOOLS = {
|
|
73
|
+
bold: {cmd: 'bold'},
|
|
74
|
+
italic: {cmd: 'italic'},
|
|
75
|
+
underline: {cmd: 'underline'},
|
|
76
|
+
strike: {cmd: 'strikeThrough'},
|
|
77
|
+
highlight: {action: 'highlight'},
|
|
78
|
+
inlineCode: {action: 'inlineCode'},
|
|
79
|
+
link: {action: 'link'},
|
|
80
|
+
clearFormat: {cmd: 'removeFormat'},
|
|
81
|
+
bulletList: {cmd: 'insertUnorderedList'},
|
|
82
|
+
numberedList: {cmd: 'insertOrderedList'},
|
|
83
|
+
checklist: {action: 'checklist'},
|
|
84
|
+
quote: {block: 'blockquote'}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** Sanitise HTML on its way into the surface (see api.js sanitizeHtml). */
|
|
88
|
+
const safeHtml = (html) => sanitizeHtml(html);
|
|
89
|
+
|
|
90
|
+
const BLOCK_SEL = 'p,div,h1,h2,h3,h4,h5,h6,li,pre,blockquote,td,th';
|
|
91
|
+
|
|
92
|
+
export class DocEditor {
|
|
93
|
+
/**
|
|
94
|
+
* @param {HTMLElement} surfaceHost where the page goes
|
|
95
|
+
* @param {HTMLElement} toolbarHost where the toolbar goes
|
|
96
|
+
* @param {object} [options]
|
|
97
|
+
* @param {Function} [options.pickImage] async () => url|null (the Media picker)
|
|
98
|
+
* @param {Function} [options.uploadImage] async (File) => url|null
|
|
99
|
+
*/
|
|
100
|
+
constructor(surfaceHost, toolbarHost, options = {}) {
|
|
101
|
+
this.host = surfaceHost;
|
|
102
|
+
this.toolbarHost = toolbarHost;
|
|
103
|
+
this.options = options;
|
|
104
|
+
this.surface = null;
|
|
105
|
+
this.toolbar = null;
|
|
106
|
+
this.readOnly = false;
|
|
107
|
+
this.suggesting = false;
|
|
108
|
+
this._listeners = [];
|
|
109
|
+
this._quiet = false;
|
|
110
|
+
this._cleanup = [];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Build the toolbar and the surface. Safe to call once. */
|
|
114
|
+
init() {
|
|
115
|
+
if (this.surface) return this;
|
|
116
|
+
this.toolbar = this._buildToolbar();
|
|
117
|
+
this.toolbarHost.replaceChildren(this.toolbar);
|
|
118
|
+
this.surface = this._buildSurface();
|
|
119
|
+
this.host.replaceChildren(this.surface);
|
|
120
|
+
try { document.execCommand('defaultParagraphSeparator', false, 'p'); } catch { /* old engine */ }
|
|
121
|
+
if (typeof Domma !== 'undefined' && Domma.icons) Domma.icons.scan(this.toolbar);
|
|
122
|
+
const onSel = () => this._syncToolbar();
|
|
123
|
+
document.addEventListener('selectionchange', onSel);
|
|
124
|
+
this._cleanup.push(() => document.removeEventListener('selectionchange', onSel));
|
|
125
|
+
return this;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
destroy() {
|
|
129
|
+
this._cleanup.forEach(fn => { try { fn(); } catch { /* gone */ } });
|
|
130
|
+
this._cleanup = [];
|
|
131
|
+
this._listeners = [];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Kept for find and replace, which once had a Markdown surface to handle. */
|
|
135
|
+
isMarkdown() { return false; }
|
|
136
|
+
|
|
137
|
+
getSurface() { return this.surface; }
|
|
138
|
+
|
|
139
|
+
getValue() { return this.surface ? this.surface.innerHTML : ''; }
|
|
140
|
+
|
|
141
|
+
/** Replace the content without reporting a change. */
|
|
142
|
+
setValue(value) {
|
|
143
|
+
if (!this.surface) return this;
|
|
144
|
+
this._quiet = true;
|
|
145
|
+
this.surface.innerHTML = safeHtml(value || '');
|
|
146
|
+
this._quiet = false;
|
|
147
|
+
return this;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Read-only: nothing typed, no toolbar. Viewers, a document someone else
|
|
152
|
+
* is editing, and a document in Trash all open this way.
|
|
153
|
+
*/
|
|
154
|
+
setReadOnly(on) {
|
|
155
|
+
this.readOnly = Boolean(on);
|
|
156
|
+
if (this.surface) {
|
|
157
|
+
this.surface.contentEditable = on ? 'false' : 'true';
|
|
158
|
+
this.surface.setAttribute('aria-readonly', on ? 'true' : 'false');
|
|
159
|
+
}
|
|
160
|
+
this._syncTools();
|
|
161
|
+
return this;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Suggesting (2.5, Docs Pro): the text stays editable, but everything that
|
|
166
|
+
* changes layout or formatting is off - the toolbar, Markdown shortcuts,
|
|
167
|
+
* Tab, the table menu, Docs' own paste and drop - so an add-on can turn
|
|
168
|
+
* each change into a suggestion from the browser's beforeinput events.
|
|
169
|
+
*/
|
|
170
|
+
setSuggesting(on) {
|
|
171
|
+
this.suggesting = Boolean(on);
|
|
172
|
+
this.surface?.classList.toggle('is-suggesting', this.suggesting);
|
|
173
|
+
this._syncTools();
|
|
174
|
+
return this;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
_syncTools() {
|
|
178
|
+
const off = this.readOnly || this.suggesting;
|
|
179
|
+
this.toolbar?.classList.toggle('is-disabled', off);
|
|
180
|
+
this.toolbar?.querySelectorAll('button, select').forEach(b => { b.disabled = off; });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** @returns {Function} unsubscribe */
|
|
184
|
+
onChange(callback) {
|
|
185
|
+
if (typeof callback !== 'function') return () => {};
|
|
186
|
+
this._listeners.push(callback);
|
|
187
|
+
return () => {
|
|
188
|
+
const i = this._listeners.indexOf(callback);
|
|
189
|
+
if (i > -1) this._listeners.splice(i, 1);
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
focus() { this.surface?.focus(); return this; }
|
|
194
|
+
|
|
195
|
+
/** Tell the listeners the content moved (find and replace edits the surface directly). */
|
|
196
|
+
notifyChanged() { this._emitChange(); }
|
|
197
|
+
|
|
198
|
+
/** Plain text of the document as shown - for word and character counts. */
|
|
199
|
+
text() { return this.surface ? this.surface.innerText || '' : ''; }
|
|
200
|
+
|
|
201
|
+
/** The heading elements, in order - the Outline scrolls to these. */
|
|
202
|
+
headings() {
|
|
203
|
+
return this.surface ? [...this.surface.querySelectorAll('h1,h2,h3,h4')] : [];
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ------------------------------------------------------------ internals
|
|
207
|
+
|
|
208
|
+
_emitChange() {
|
|
209
|
+
if (this._quiet) return;
|
|
210
|
+
this._listeners.forEach(fn => {
|
|
211
|
+
try { fn(); } catch (err) { console.error('[docs] change listener failed', err); }
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
_buildToolbar() {
|
|
216
|
+
const bar = document.createElement('div');
|
|
217
|
+
bar.className = 'docs-toolbar';
|
|
218
|
+
bar.setAttribute('role', 'toolbar');
|
|
219
|
+
bar.setAttribute('aria-label', 'Formatting');
|
|
220
|
+
TOOLS.forEach((group) => {
|
|
221
|
+
const g = document.createElement('div');
|
|
222
|
+
g.className = 'docs-toolbar__group';
|
|
223
|
+
for (const tool of group) {
|
|
224
|
+
if (tool.kind === 'blocks') { g.appendChild(this._buildBlockSelect()); continue; }
|
|
225
|
+
const btn = document.createElement('button');
|
|
226
|
+
btn.type = 'button';
|
|
227
|
+
btn.className = 'docs-tool';
|
|
228
|
+
btn.title = tool.title;
|
|
229
|
+
btn.setAttribute('aria-label', tool.title);
|
|
230
|
+
if (tool.state) btn.dataset.state = tool.state;
|
|
231
|
+
if (tool.svg) btn.innerHTML = SVG[tool.svg];
|
|
232
|
+
else {
|
|
233
|
+
const i = document.createElement('span');
|
|
234
|
+
i.dataset.icon = tool.icon;
|
|
235
|
+
i.dataset.iconSize = '16';
|
|
236
|
+
btn.appendChild(i);
|
|
237
|
+
}
|
|
238
|
+
// mousedown, not click: a click blurs the surface first and a
|
|
239
|
+
// lost selection is a command applied to nothing.
|
|
240
|
+
btn.addEventListener('mousedown', (e) => {
|
|
241
|
+
e.preventDefault();
|
|
242
|
+
if (!this.readOnly && !this.suggesting) this._applyTool(tool);
|
|
243
|
+
});
|
|
244
|
+
// Keyboard users still get the button.
|
|
245
|
+
btn.addEventListener('keydown', (e) => {
|
|
246
|
+
if ((e.key === 'Enter' || e.key === ' ') && !this.readOnly && !this.suggesting) { e.preventDefault(); this._applyTool(tool); }
|
|
247
|
+
});
|
|
248
|
+
g.appendChild(btn);
|
|
249
|
+
}
|
|
250
|
+
bar.appendChild(g);
|
|
251
|
+
});
|
|
252
|
+
return bar;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
_buildBlockSelect() {
|
|
256
|
+
const sel = document.createElement('select');
|
|
257
|
+
sel.className = 'docs-tool-select';
|
|
258
|
+
sel.title = 'Text style';
|
|
259
|
+
sel.setAttribute('aria-label', 'Text style');
|
|
260
|
+
for (const [tag, label] of BLOCKS) {
|
|
261
|
+
const o = document.createElement('option');
|
|
262
|
+
o.value = tag;
|
|
263
|
+
o.textContent = label;
|
|
264
|
+
sel.appendChild(o);
|
|
265
|
+
}
|
|
266
|
+
// Remember the selection before the dropdown takes focus.
|
|
267
|
+
let saved = null;
|
|
268
|
+
sel.addEventListener('mousedown', () => { saved = this._saveRange(); });
|
|
269
|
+
sel.addEventListener('focus', () => { saved = saved || this._saveRange(); });
|
|
270
|
+
sel.addEventListener('change', () => {
|
|
271
|
+
this._restoreRange(saved);
|
|
272
|
+
saved = null;
|
|
273
|
+
this._exec('formatBlock', `<${sel.value}>`);
|
|
274
|
+
this._emitChange();
|
|
275
|
+
});
|
|
276
|
+
this.blockSelect = sel;
|
|
277
|
+
return sel;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
_buildSurface() {
|
|
281
|
+
const el = document.createElement('div');
|
|
282
|
+
el.className = 'docs-surface';
|
|
283
|
+
el.contentEditable = 'true';
|
|
284
|
+
el.spellcheck = true;
|
|
285
|
+
el.setAttribute('role', 'textbox');
|
|
286
|
+
el.setAttribute('aria-multiline', 'true');
|
|
287
|
+
el.setAttribute('aria-label', 'Document');
|
|
288
|
+
el.dataset.placeholder = 'Start writing - type # and a space for a heading, - for a list, [] for a checklist';
|
|
289
|
+
|
|
290
|
+
el.addEventListener('input', () => {
|
|
291
|
+
this._syncEmpty();
|
|
292
|
+
this._emitChange();
|
|
293
|
+
});
|
|
294
|
+
el.addEventListener('keydown', (e) => this._onKeydown(e));
|
|
295
|
+
el.addEventListener('paste', (e) => this._onPaste(e));
|
|
296
|
+
el.addEventListener('drop', (e) => this._onDrop(e));
|
|
297
|
+
// A checklist item ticks when its box (the marker to the left) is clicked.
|
|
298
|
+
el.addEventListener('mousedown', (e) => {
|
|
299
|
+
const li = e.target.closest?.('ul[data-type="checklist"] > li');
|
|
300
|
+
if (!li || !el.contains(li) || this.readOnly || this.suggesting) return;
|
|
301
|
+
const r = li.getBoundingClientRect();
|
|
302
|
+
if (e.clientX < r.left + 2 || e.clientX > r.left + 26) return;
|
|
303
|
+
e.preventDefault();
|
|
304
|
+
li.dataset.checked = li.dataset.checked === 'true' ? 'false' : 'true';
|
|
305
|
+
this._emitChange();
|
|
306
|
+
});
|
|
307
|
+
// Links in an editable surface do nothing on click; Ctrl+click opens.
|
|
308
|
+
el.addEventListener('click', (e) => {
|
|
309
|
+
const a = e.target.closest?.('a[href]');
|
|
310
|
+
if (a && (e.ctrlKey || e.metaKey || this.readOnly)) {
|
|
311
|
+
e.preventDefault();
|
|
312
|
+
window.open(a.href, '_blank', 'noopener');
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
return el;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
_syncEmpty() {
|
|
319
|
+
const empty = !this.surface.textContent.trim() && !this.surface.querySelector('img,table,hr,li');
|
|
320
|
+
this.surface.classList.toggle('is-empty', empty);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Toolbar buttons show what the caret is in. */
|
|
324
|
+
_syncToolbar() {
|
|
325
|
+
if (!this.surface || !this.toolbar) return;
|
|
326
|
+
const sel = document.getSelection();
|
|
327
|
+
if (!sel?.anchorNode || !this.surface.contains(sel.anchorNode)) return;
|
|
328
|
+
this.toolbar.querySelectorAll('[data-state]').forEach((b) => {
|
|
329
|
+
let on = false;
|
|
330
|
+
try { on = document.queryCommandState(b.dataset.state); } catch { /* unsupported */ }
|
|
331
|
+
b.classList.toggle('is-on', on);
|
|
332
|
+
b.setAttribute('aria-pressed', on ? 'true' : 'false');
|
|
333
|
+
});
|
|
334
|
+
const block = this._currentBlock();
|
|
335
|
+
const tag = block?.closest('h1,h2,h3,h4,pre,blockquote')?.tagName.toLowerCase() || 'p';
|
|
336
|
+
if (this.blockSelect && this.blockSelect.value !== tag && document.activeElement !== this.blockSelect) {
|
|
337
|
+
this.blockSelect.value = BLOCKS.some(([t]) => t === tag) ? tag : 'p';
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
_currentBlock() {
|
|
342
|
+
const sel = document.getSelection();
|
|
343
|
+
let node = sel?.anchorNode;
|
|
344
|
+
if (!node || !this.surface.contains(node)) return null;
|
|
345
|
+
if (node.nodeType === 3) node = node.parentElement;
|
|
346
|
+
const block = node.closest(BLOCK_SEL);
|
|
347
|
+
return block && this.surface.contains(block) ? block : null;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
_saveRange() {
|
|
351
|
+
const sel = document.getSelection();
|
|
352
|
+
if (!sel?.rangeCount) return null;
|
|
353
|
+
const r = sel.getRangeAt(0);
|
|
354
|
+
return this.surface.contains(r.commonAncestorContainer) ? r.cloneRange() : null;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
_restoreRange(range) {
|
|
358
|
+
this.surface.focus();
|
|
359
|
+
if (!range) return;
|
|
360
|
+
const sel = document.getSelection();
|
|
361
|
+
sel.removeAllRanges();
|
|
362
|
+
sel.addRange(range);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Text from the start of the caret's block to the caret. */
|
|
366
|
+
_textBeforeCaret(block) {
|
|
367
|
+
const sel = document.getSelection();
|
|
368
|
+
if (!sel?.rangeCount || !sel.isCollapsed) return null;
|
|
369
|
+
const r = document.createRange();
|
|
370
|
+
r.setStart(block || this.surface, 0);
|
|
371
|
+
r.setEnd(sel.anchorNode, sel.anchorOffset);
|
|
372
|
+
return r.toString();
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
_onKeydown(e) {
|
|
376
|
+
if (this.readOnly || this.suggesting) return;
|
|
377
|
+
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'k') {
|
|
378
|
+
e.preventDefault();
|
|
379
|
+
this._insertLink();
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
// Tab indents a list item; in a table it moves between cells.
|
|
383
|
+
if (e.key === 'Tab') {
|
|
384
|
+
const cell = this._currentBlock()?.closest('td,th');
|
|
385
|
+
if (cell) { e.preventDefault(); this._moveCell(cell, e.shiftKey ? -1 : 1); return; }
|
|
386
|
+
if (this._currentBlock()?.closest('li')) {
|
|
387
|
+
e.preventDefault();
|
|
388
|
+
this._exec(e.shiftKey ? 'outdent' : 'indent');
|
|
389
|
+
this._emitChange();
|
|
390
|
+
}
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
// A new checklist item starts unticked, whatever the one above says.
|
|
394
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
395
|
+
const li = this._currentBlock()?.closest('ul[data-type="checklist"] > li');
|
|
396
|
+
if (li) setTimeout(() => {
|
|
397
|
+
const now = this._currentBlock()?.closest('ul[data-type="checklist"] > li');
|
|
398
|
+
if (now && now !== li) now.dataset.checked = 'false';
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
if (e.key !== ' ' && e.key !== 'Enter') return;
|
|
402
|
+
if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) return;
|
|
403
|
+
const block = this._lineBlock();
|
|
404
|
+
if (!block) return;
|
|
405
|
+
const before = this._textBeforeCaret(block);
|
|
406
|
+
if (before === null) return;
|
|
407
|
+
const hit = markdownTrigger(before, e.key);
|
|
408
|
+
if (!hit) return;
|
|
409
|
+
e.preventDefault();
|
|
410
|
+
this._applyShortcut(block, hit);
|
|
411
|
+
this._emitChange();
|
|
412
|
+
this._syncToolbar();
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* The paragraph the caret is on, as a direct child of the surface - made
|
|
417
|
+
* into a <p> first when the line is bare text (a contenteditable leaves
|
|
418
|
+
* its first line unwrapped). Null inside lists, tables, code and quotes:
|
|
419
|
+
* shortcuts only start new blocks from plain paragraphs.
|
|
420
|
+
*/
|
|
421
|
+
_lineBlock() {
|
|
422
|
+
const sel = document.getSelection();
|
|
423
|
+
let node = sel?.anchorNode;
|
|
424
|
+
if (!node || !sel.isCollapsed || !this.surface.contains(node)) return null;
|
|
425
|
+
if (node === this.surface) node = this.surface.childNodes[Math.max(0, sel.anchorOffset - 1)] || this.surface.firstChild;
|
|
426
|
+
if (!node) return null;
|
|
427
|
+
let top = node;
|
|
428
|
+
while (top && top.parentNode !== this.surface) top = top.parentNode;
|
|
429
|
+
if (!top) return null;
|
|
430
|
+
if (top.nodeType === 1 && /^(P|DIV)$/.test(top.tagName)) return top;
|
|
431
|
+
if (top.nodeType === 1 && /^(UL|OL|TABLE|PRE|BLOCKQUOTE|H[1-6]|HR|FIGURE)$/.test(top.tagName)) return null;
|
|
432
|
+
// Bare inline content: wrap the run around the caret in a paragraph.
|
|
433
|
+
const inline = (n) => n && (n.nodeType === 3 || (n.nodeType === 1 && !/^(P|DIV|UL|OL|TABLE|PRE|BLOCKQUOTE|H[1-6]|HR|FIGURE|BR)$/.test(n.tagName)));
|
|
434
|
+
const run = [top];
|
|
435
|
+
let prev = top.previousSibling;
|
|
436
|
+
while (inline(prev)) { run.unshift(prev); prev = prev.previousSibling; }
|
|
437
|
+
let next = top.nextSibling;
|
|
438
|
+
while (inline(next)) { run.push(next); next = next.nextSibling; }
|
|
439
|
+
const anchor = sel.anchorNode;
|
|
440
|
+
const offset = sel.anchorOffset;
|
|
441
|
+
const p = document.createElement('p');
|
|
442
|
+
run[0].before(p);
|
|
443
|
+
run.forEach(n => p.appendChild(n));
|
|
444
|
+
if (p.contains(anchor)) {
|
|
445
|
+
const r = document.createRange();
|
|
446
|
+
r.setStart(anchor, offset);
|
|
447
|
+
r.collapse(true);
|
|
448
|
+
sel.removeAllRanges();
|
|
449
|
+
sel.addRange(r);
|
|
450
|
+
}
|
|
451
|
+
return p;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** Remove the first `n` characters of a block's text, across its text nodes. */
|
|
455
|
+
_stripStart(block, n) {
|
|
456
|
+
const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT);
|
|
457
|
+
let left = n;
|
|
458
|
+
let t;
|
|
459
|
+
while (left > 0 && (t = walker.nextNode())) {
|
|
460
|
+
const take = Math.min(left, t.data.length);
|
|
461
|
+
t.data = t.data.slice(take);
|
|
462
|
+
left -= take;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** Put the caret at the start (or end) of `el`. */
|
|
467
|
+
_caretIn(el, atEnd = false) {
|
|
468
|
+
const r = document.createRange();
|
|
469
|
+
r.selectNodeContents(el);
|
|
470
|
+
r.collapse(!atEnd);
|
|
471
|
+
const sel = document.getSelection();
|
|
472
|
+
sel.removeAllRanges();
|
|
473
|
+
sel.addRange(r);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Turn the paragraph into what the shortcut asked for. Plain DOM work,
|
|
478
|
+
* not execCommand: a list made by execCommand inside a freshly emptied
|
|
479
|
+
* paragraph comes out nested in the <p>, which nothing can then edit.
|
|
480
|
+
*/
|
|
481
|
+
_applyShortcut(block, {length, action}) {
|
|
482
|
+
this._stripStart(block, length);
|
|
483
|
+
const empty = !block.textContent.replace(/ /g, ' ').trim() && !block.querySelector('img');
|
|
484
|
+
const fill = (el) => {
|
|
485
|
+
if (empty) el.innerHTML = '<br>';
|
|
486
|
+
else while (block.firstChild) el.appendChild(block.firstChild);
|
|
487
|
+
return el;
|
|
488
|
+
};
|
|
489
|
+
let target;
|
|
490
|
+
switch (action.type) {
|
|
491
|
+
case 'block': {
|
|
492
|
+
target = fill(document.createElement(action.tag));
|
|
493
|
+
block.replaceWith(target);
|
|
494
|
+
break;
|
|
495
|
+
}
|
|
496
|
+
case 'ul':
|
|
497
|
+
case 'ol':
|
|
498
|
+
case 'checklist': {
|
|
499
|
+
const list = document.createElement(action.type === 'ol' ? 'ol' : 'ul');
|
|
500
|
+
const li = fill(document.createElement('li'));
|
|
501
|
+
if (action.type === 'checklist') { list.dataset.type = 'checklist'; li.dataset.checked = 'false'; }
|
|
502
|
+
list.appendChild(li);
|
|
503
|
+
block.replaceWith(list);
|
|
504
|
+
target = li;
|
|
505
|
+
break;
|
|
506
|
+
}
|
|
507
|
+
case 'hr': {
|
|
508
|
+
const hr = document.createElement('hr');
|
|
509
|
+
const p = document.createElement('p');
|
|
510
|
+
p.innerHTML = '<br>';
|
|
511
|
+
block.replaceWith(hr, p);
|
|
512
|
+
target = p;
|
|
513
|
+
break;
|
|
514
|
+
}
|
|
515
|
+
default: return;
|
|
516
|
+
}
|
|
517
|
+
this._caretIn(target, !empty);
|
|
518
|
+
this._syncEmpty();
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
_applyTool(tool) {
|
|
522
|
+
if (tool.action) {
|
|
523
|
+
switch (tool.action) {
|
|
524
|
+
case 'link': return this._insertLink();
|
|
525
|
+
case 'image': return this._insertImage();
|
|
526
|
+
case 'table': return this._insertTable();
|
|
527
|
+
case 'checklist': this._checklist(); break;
|
|
528
|
+
case 'highlight': this._highlight(); break;
|
|
529
|
+
case 'inlineCode': this._inlineCode(); break;
|
|
530
|
+
default: break;
|
|
531
|
+
}
|
|
532
|
+
} else if (tool.block) {
|
|
533
|
+
const inside = this._currentBlock()?.closest(tool.block);
|
|
534
|
+
this._exec('formatBlock', inside ? '<p>' : `<${tool.block}>`);
|
|
535
|
+
} else if (tool.cmd) {
|
|
536
|
+
this._exec(tool.cmd);
|
|
537
|
+
}
|
|
538
|
+
this._emitChange();
|
|
539
|
+
this._syncToolbar();
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/** Toggle a checklist: a bulleted list marked as one, every item unticked. */
|
|
543
|
+
_checklist() {
|
|
544
|
+
const inList = this._currentBlock()?.closest('ul');
|
|
545
|
+
if (inList && inList.dataset.type === 'checklist') {
|
|
546
|
+
delete inList.dataset.type;
|
|
547
|
+
inList.querySelectorAll(':scope > li').forEach(li => li.removeAttribute('data-checked'));
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
if (!inList) this._exec('insertUnorderedList');
|
|
551
|
+
const ul = this._currentBlock()?.closest('ul');
|
|
552
|
+
if (!ul) return;
|
|
553
|
+
ul.dataset.type = 'checklist';
|
|
554
|
+
ul.querySelectorAll(':scope > li').forEach(li => { if (!li.dataset.checked) li.dataset.checked = 'false'; });
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
_highlight() {
|
|
558
|
+
let on = false;
|
|
559
|
+
try { on = /rgb\(255, 243, 163\)|#fff3a3/i.test(document.queryCommandValue('hiliteColor') || document.queryCommandValue('backColor')); } catch { /* unsupported */ }
|
|
560
|
+
this._exec('styleWithCSS', true);
|
|
561
|
+
if (!this._exec('hiliteColor', on ? 'transparent' : '#fff3a3')) this._exec('backColor', on ? 'transparent' : '#fff3a3');
|
|
562
|
+
this._exec('styleWithCSS', false);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
_inlineCode() {
|
|
566
|
+
const sel = document.getSelection();
|
|
567
|
+
if (!sel?.rangeCount) return;
|
|
568
|
+
const inCode = (sel.anchorNode?.nodeType === 3 ? sel.anchorNode.parentElement : sel.anchorNode)?.closest?.('code');
|
|
569
|
+
if (inCode && this.surface.contains(inCode) && !inCode.closest('pre')) {
|
|
570
|
+
inCode.replaceWith(document.createTextNode(inCode.textContent));
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
const text = sel.toString();
|
|
574
|
+
if (!text) return;
|
|
575
|
+
const esc = text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
576
|
+
this._exec('insertHTML', `<code>${esc}</code>`);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
async _insertLink() {
|
|
580
|
+
const saved = this._saveRange();
|
|
581
|
+
const current = (saved?.commonAncestorContainer?.nodeType === 3 ? saved.commonAncestorContainer.parentElement : saved?.commonAncestorContainer)?.closest?.('a');
|
|
582
|
+
const url = await promptWith('Link address', {title: current ? 'Edit link' : 'Insert link', inputPlaceholder: 'https://example.com'}, current?.getAttribute('href') || '');
|
|
583
|
+
this._restoreRange(saved);
|
|
584
|
+
if (url === null || url === undefined) return;
|
|
585
|
+
const href = String(url).trim();
|
|
586
|
+
if (!href) { this._exec('unlink'); this._emitChange(); return; }
|
|
587
|
+
if (!/^(https?:|mailto:|tel:|\/|#)/i.test(href)) {
|
|
588
|
+
E.toast('Links must start with https://, mailto:, tel: or /', {type: 'warning'});
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
if (saved && saved.collapsed && !current) {
|
|
592
|
+
const esc = href.replace(/&/g, '&').replace(/</g, '<').replace(/"/g, '"');
|
|
593
|
+
this._exec('insertHTML', `<a href="${esc}">${esc}</a>`);
|
|
594
|
+
} else {
|
|
595
|
+
this._exec('createLink', href);
|
|
596
|
+
}
|
|
597
|
+
this._emitChange();
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
async _insertImage() {
|
|
601
|
+
const saved = this._saveRange();
|
|
602
|
+
let url = null;
|
|
603
|
+
if (typeof this.options.pickImage === 'function') {
|
|
604
|
+
try { url = await this.options.pickImage(); } catch { url = null; }
|
|
605
|
+
} else {
|
|
606
|
+
url = await E.prompt('Image address', {title: 'Insert image', inputPlaceholder: 'https://… or /media/…'});
|
|
607
|
+
}
|
|
608
|
+
this._restoreRange(saved);
|
|
609
|
+
if (!url) return;
|
|
610
|
+
this._insertImageUrl(url);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
_insertImageUrl(url, alt = '') {
|
|
614
|
+
const src = String(url).trim();
|
|
615
|
+
if (!/^(https?:\/\/|\/)/i.test(src)) return;
|
|
616
|
+
const esc = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/"/g, '"');
|
|
617
|
+
this._exec('insertHTML', `<img src="${esc(src)}" alt="${esc(alt)}">`);
|
|
618
|
+
this._emitChange();
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
async _insertTable() {
|
|
622
|
+
const saved = this._saveRange();
|
|
623
|
+
const answer = await E.prompt('Columns x rows, e.g. 3x4', {title: 'Insert table', inputValue: '3x3'});
|
|
624
|
+
this._restoreRange(saved);
|
|
625
|
+
const m = /^\s*(\d{1,2})\s*[x×*, ]\s*(\d{1,3})\s*$/i.exec(String(answer || ''));
|
|
626
|
+
if (!m) return;
|
|
627
|
+
const cols = Math.min(20, Math.max(1, Number(m[1])));
|
|
628
|
+
const rows = Math.min(200, Math.max(1, Number(m[2])));
|
|
629
|
+
const head = `<tr>${'<th><br></th>'.repeat(cols)}</tr>`;
|
|
630
|
+
const body = `<tr>${'<td><br></td>'.repeat(cols)}</tr>`.repeat(Math.max(0, rows - 1));
|
|
631
|
+
this._exec('insertHTML', `<table><thead>${head}</thead><tbody>${body}</tbody></table><p><br></p>`);
|
|
632
|
+
this._emitChange();
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/** Menu items for the table under an element, for the editor's right-click menu; [] when not in a table. */
|
|
636
|
+
tableMenu(target) {
|
|
637
|
+
const cell = target?.closest?.('td,th');
|
|
638
|
+
if (!cell || !this.surface.contains(cell) || this.readOnly || this.suggesting) return [];
|
|
639
|
+
const row = cell.parentElement;
|
|
640
|
+
const table = cell.closest('table');
|
|
641
|
+
const col = [...row.children].indexOf(cell);
|
|
642
|
+
const done = () => this._emitChange();
|
|
643
|
+
const newRow = (like) => {
|
|
644
|
+
const tr = document.createElement('tr');
|
|
645
|
+
[...like.children].forEach(() => { const td = document.createElement('td'); td.innerHTML = '<br>'; tr.appendChild(td); });
|
|
646
|
+
return tr;
|
|
647
|
+
};
|
|
648
|
+
return [
|
|
649
|
+
{label: 'Row above', icon: 'chevron-up', action: () => { row.parentElement.insertBefore(newRow(row), row); done(); }},
|
|
650
|
+
{label: 'Row below', icon: 'chevron-down', action: () => {
|
|
651
|
+
const tr = newRow(row);
|
|
652
|
+
if (row.parentElement.tagName === 'THEAD') {
|
|
653
|
+
let tb = table.querySelector('tbody');
|
|
654
|
+
if (!tb) { tb = document.createElement('tbody'); table.appendChild(tb); }
|
|
655
|
+
tb.insertBefore(tr, tb.firstChild);
|
|
656
|
+
} else row.after(tr);
|
|
657
|
+
done();
|
|
658
|
+
}},
|
|
659
|
+
{label: 'Column left', icon: 'chevron-left', action: () => { this._addColumn(table, col, 0); done(); }},
|
|
660
|
+
{label: 'Column right', icon: 'chevron-right', action: () => { this._addColumn(table, col, 1); done(); }},
|
|
661
|
+
{type: 'divider'},
|
|
662
|
+
{label: 'Delete row', icon: 'minus', action: () => {
|
|
663
|
+
if (table.querySelectorAll('tr').length <= 1) table.remove(); else row.remove();
|
|
664
|
+
done();
|
|
665
|
+
}},
|
|
666
|
+
{label: 'Delete column', icon: 'minus', action: () => {
|
|
667
|
+
if (row.children.length <= 1) table.remove();
|
|
668
|
+
else table.querySelectorAll('tr').forEach(tr => tr.children[col]?.remove());
|
|
669
|
+
done();
|
|
670
|
+
}},
|
|
671
|
+
{label: 'Delete table', icon: 'trash', danger: true, action: () => { table.remove(); done(); }}
|
|
672
|
+
];
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
_addColumn(table, col, offset) {
|
|
676
|
+
table.querySelectorAll('tr').forEach((tr) => {
|
|
677
|
+
const ref = tr.children[col];
|
|
678
|
+
const cell = document.createElement(tr.parentElement.tagName === 'THEAD' ? 'th' : 'td');
|
|
679
|
+
cell.innerHTML = '<br>';
|
|
680
|
+
if (!ref) tr.appendChild(cell);
|
|
681
|
+
else if (offset) ref.after(cell);
|
|
682
|
+
else ref.before(cell);
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
_moveCell(cell, dir) {
|
|
687
|
+
const cells = [...cell.closest('table').querySelectorAll('th,td')];
|
|
688
|
+
const next = cells[cells.indexOf(cell) + dir];
|
|
689
|
+
if (!next) return;
|
|
690
|
+
const r = document.createRange();
|
|
691
|
+
r.selectNodeContents(next);
|
|
692
|
+
r.collapse(false);
|
|
693
|
+
const sel = document.getSelection();
|
|
694
|
+
sel.removeAllRanges();
|
|
695
|
+
sel.addRange(r);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* A paste carries the source page's markup - fonts, colours, tracking
|
|
700
|
+
* spans - so it is sanitised. Pasted image files go to the Media library
|
|
701
|
+
* when that is possible, rather than into the document as data.
|
|
702
|
+
*/
|
|
703
|
+
_onPaste(e) {
|
|
704
|
+
// Suggesting: the browser's own paste goes on, as a beforeinput the add-on turns into a suggestion.
|
|
705
|
+
if (this.readOnly || this.suggesting) return;
|
|
706
|
+
const clip = e.clipboardData;
|
|
707
|
+
if (!clip) return;
|
|
708
|
+
const files = [...(clip.files || [])].filter(f => /^image\//.test(f.type));
|
|
709
|
+
if (files.length && typeof this.options.uploadImage === 'function') {
|
|
710
|
+
e.preventDefault();
|
|
711
|
+
this._uploadAll(files);
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
e.preventDefault();
|
|
715
|
+
const html = clip.getData('text/html');
|
|
716
|
+
if (html && window.DOMPurify?.sanitize) this._exec('insertHTML', cleanPaste(sanitizeHtml(html)));
|
|
717
|
+
else this._exec('insertText', clip.getData('text/plain'));
|
|
718
|
+
this._emitChange();
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
_onDrop(e) {
|
|
722
|
+
if (this.readOnly) return;
|
|
723
|
+
if (this.suggesting) { e.preventDefault(); return; }
|
|
724
|
+
const files = [...(e.dataTransfer?.files || [])].filter(f => /^image\//.test(f.type));
|
|
725
|
+
if (!files.length || typeof this.options.uploadImage !== 'function') return;
|
|
726
|
+
e.preventDefault();
|
|
727
|
+
const pos = document.caretRangeFromPoint?.(e.clientX, e.clientY);
|
|
728
|
+
if (pos) { const sel = document.getSelection(); sel.removeAllRanges(); sel.addRange(pos); }
|
|
729
|
+
this._uploadAll(files);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
async _uploadAll(files) {
|
|
733
|
+
const saved = this._saveRange();
|
|
734
|
+
for (const f of files) {
|
|
735
|
+
let url = null;
|
|
736
|
+
try { url = await this.options.uploadImage(f); } catch (err) { E.toast(err.message || 'Could not upload the image', {type: 'error'}); }
|
|
737
|
+
if (url) { this._restoreRange(saved); this._insertImageUrl(url, f.name.replace(/\.[^.]+$/, '')); }
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// ----------------------------------------------------- for add-ons (2.4)
|
|
742
|
+
|
|
743
|
+
/** The selection inside the page, kept so a dialog can put it back. */
|
|
744
|
+
saveRange() { return this._saveRange(); }
|
|
745
|
+
restoreRange(range) { this._restoreRange(range); }
|
|
746
|
+
|
|
747
|
+
/** The selected text, or '' (only inside the page). */
|
|
748
|
+
selectedText(range = this._saveRange()) { return range ? range.toString() : ''; }
|
|
749
|
+
|
|
750
|
+
/** Insert HTML (already safe) at the caret or over the selection. */
|
|
751
|
+
insertHtml(html, range = null) {
|
|
752
|
+
if (this.readOnly) return false;
|
|
753
|
+
if (range) this._restoreRange(range);
|
|
754
|
+
const ok = this._exec('insertHTML', html);
|
|
755
|
+
this._emitChange();
|
|
756
|
+
return ok;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* Wrap the selection in a <span> with the given data-* attributes (a
|
|
761
|
+
* comment's anchor). Returns false when there is no selection in the page
|
|
762
|
+
* (a selection across elements is marked piece by piece, all with the same attributes).
|
|
763
|
+
*/
|
|
764
|
+
wrapSelection(attrs = {}, range = this._saveRange()) {
|
|
765
|
+
if (this.readOnly || !range || range.collapsed || !this.surface?.contains(range.commonAncestorContainer)) return false;
|
|
766
|
+
const span = document.createElement('span');
|
|
767
|
+
for (const [k, v] of Object.entries(attrs)) if (/^data-[a-z0-9-]+$/.test(k)) span.setAttribute(k, String(v));
|
|
768
|
+
try {
|
|
769
|
+
range.surroundContents(span);
|
|
770
|
+
} catch {
|
|
771
|
+
// Across bold, a link or paragraphs: each piece of text in the selection gets its own span
|
|
772
|
+
// with the same attributes - together they are the one mark.
|
|
773
|
+
const root = range.commonAncestorContainer;
|
|
774
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
|
|
775
|
+
acceptNode: (n) => (range.intersectsNode(n) && n.nodeValue.trim() ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT)
|
|
776
|
+
});
|
|
777
|
+
const pieces = [];
|
|
778
|
+
while (walker.nextNode()) {
|
|
779
|
+
const n = walker.currentNode;
|
|
780
|
+
const s = n === range.startContainer ? range.startOffset : 0;
|
|
781
|
+
const e = n === range.endContainer ? range.endOffset : n.nodeValue.length;
|
|
782
|
+
if (e > s) pieces.push({n, s, e});
|
|
783
|
+
}
|
|
784
|
+
if (!pieces.length) return false;
|
|
785
|
+
for (const {n, s, e} of pieces) {
|
|
786
|
+
if (e < n.nodeValue.length) n.splitText(e);
|
|
787
|
+
const mid = s > 0 ? n.splitText(s) : n;
|
|
788
|
+
const wrap = span.cloneNode(false);
|
|
789
|
+
mid.parentNode.insertBefore(wrap, mid);
|
|
790
|
+
wrap.appendChild(mid);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
this._emitChange();
|
|
794
|
+
return true;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* Apply a named editing action at a saved selection - the context-editing
|
|
799
|
+
* channel for add-ons (2.8). Refused read-only and while suggesting (the
|
|
800
|
+
* toolbar is off there so every change arrives through beforeinput).
|
|
801
|
+
* Returns false when the name is unknown or the editor refused.
|
|
802
|
+
*/
|
|
803
|
+
applyNamed(name, range = null) {
|
|
804
|
+
const tool = NAMED_TOOLS[name];
|
|
805
|
+
if (!tool || this.readOnly || this.suggesting) return false;
|
|
806
|
+
if (range) this._restoreRange(range);
|
|
807
|
+
this._applyTool(tool);
|
|
808
|
+
return true;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/** Take away a wrapping added by wrapSelection (a comment deleted). */
|
|
812
|
+
unwrap(selector) {
|
|
813
|
+
if (this.readOnly) return 0;
|
|
814
|
+
let n = 0;
|
|
815
|
+
this.surface.querySelectorAll(selector).forEach((el) => { el.replaceWith(...el.childNodes); n++; });
|
|
816
|
+
if (n) this._emitChange();
|
|
817
|
+
return n;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
_exec(command, value) {
|
|
821
|
+
if (command !== 'styleWithCSS') this.surface?.focus();
|
|
822
|
+
try {
|
|
823
|
+
return document.execCommand(command, false, value);
|
|
824
|
+
} catch (err) {
|
|
825
|
+
console.warn(`[docs] editor command "${command}" is unavailable`, err);
|
|
826
|
+
return false;
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* Pasted HTML keeps its structure and loses the source page's presentation:
|
|
833
|
+
* classes, ids and inline styles (other than alignment) go.
|
|
834
|
+
*
|
|
835
|
+
* @param {string} html already sanitised
|
|
836
|
+
* @returns {string}
|
|
837
|
+
*/
|
|
838
|
+
export function cleanPaste(html) {
|
|
839
|
+
const t = document.createElement('template');
|
|
840
|
+
t.innerHTML = html;
|
|
841
|
+
t.content.querySelectorAll('*').forEach((el) => {
|
|
842
|
+
const align = el.style?.textAlign;
|
|
843
|
+
for (const a of [...el.attributes]) {
|
|
844
|
+
const keep = ['href', 'src', 'alt', 'title', 'colspan', 'rowspan', 'start', 'data-type', 'data-checked'];
|
|
845
|
+
if (!keep.includes(a.name)) el.removeAttribute(a.name);
|
|
846
|
+
}
|
|
847
|
+
if (align && /^(left|right|center|justify)$/.test(align)) el.style.textAlign = align;
|
|
848
|
+
if (/^(SPAN|FONT)$/.test(el.tagName) && !el.attributes.length) el.replaceWith(...el.childNodes);
|
|
849
|
+
});
|
|
850
|
+
return t.innerHTML;
|
|
851
|
+
}
|