@kumwe/studio-rich-text 0.1.0-alpha.10

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 (38) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +22 -0
  3. package/THIRD_PARTY_NOTICES.md +19 -0
  4. package/dist/first-party-tools.d.ts +173 -0
  5. package/dist/first-party-tools.d.ts.map +1 -0
  6. package/dist/first-party-tools.js +1012 -0
  7. package/dist/first-party-tools.js.map +1 -0
  8. package/dist/index.d.ts +62 -0
  9. package/dist/index.d.ts.map +1 -0
  10. package/dist/index.js +648 -0
  11. package/dist/index.js.map +1 -0
  12. package/dist/markdown.d.ts +14 -0
  13. package/dist/markdown.d.ts.map +1 -0
  14. package/dist/markdown.js +272 -0
  15. package/dist/markdown.js.map +1 -0
  16. package/dist/profiles.d.ts +19 -0
  17. package/dist/profiles.d.ts.map +1 -0
  18. package/dist/profiles.js +87 -0
  19. package/dist/profiles.js.map +1 -0
  20. package/dist/safe-html.d.ts +16 -0
  21. package/dist/safe-html.d.ts.map +1 -0
  22. package/dist/safe-html.js +249 -0
  23. package/dist/safe-html.js.map +1 -0
  24. package/dist/strict-csp-surface.d.ts +10 -0
  25. package/dist/strict-csp-surface.d.ts.map +1 -0
  26. package/dist/strict-csp-surface.js +298 -0
  27. package/dist/strict-csp-surface.js.map +1 -0
  28. package/dist/studio-rich-text-editor.d.ts +55 -0
  29. package/dist/studio-rich-text-editor.d.ts.map +1 -0
  30. package/dist/studio-rich-text-editor.js +121 -0
  31. package/dist/studio-rich-text-editor.js.map +1 -0
  32. package/package.json +42 -0
  33. package/third-party-licenses/codex-notifier-1.1.2.txt +21 -0
  34. package/third-party-licenses/codex-tooltip-1.0.6.txt +7 -0
  35. package/third-party-licenses/editorjs__caret-1.1.0.txt +21 -0
  36. package/third-party-licenses/editorjs__dom-1.1.0.txt +21 -0
  37. package/third-party-licenses/editorjs__editorjs-2.31.6.txt +190 -0
  38. package/third-party-licenses/editorjs__helpers-1.2.2.txt +21 -0
@@ -0,0 +1,1012 @@
1
+ export const STUDIO_EDITOR_JS_TOOL_NAMES = Object.freeze([
2
+ 'callout',
3
+ 'checklist',
4
+ 'code',
5
+ 'delimiter',
6
+ 'header',
7
+ 'list',
8
+ 'paragraph',
9
+ 'quote',
10
+ 'table',
11
+ ]);
12
+ export function studioEditorJsTools() {
13
+ return Object.freeze({
14
+ callout: StudioCalloutTool,
15
+ checklist: StudioChecklistTool,
16
+ code: StudioCodeTool,
17
+ delimiter: StudioDelimiterTool,
18
+ header: StudioHeaderTool,
19
+ list: StudioListTool,
20
+ paragraph: StudioParagraphTool,
21
+ quote: StudioQuoteTool,
22
+ table: StudioTableTool,
23
+ });
24
+ }
25
+ export function toStudioEditorJsBlocks(document) {
26
+ return document.content.map((node) => ({
27
+ data: { node: structuredClone(node) },
28
+ type: toolName(node),
29
+ }));
30
+ }
31
+ export function fromStudioEditorJsBlocks(value) {
32
+ if (!isRecord(value) || !Array.isArray(value.blocks)) {
33
+ throw new TypeError('Editor surface returned an invalid block collection.');
34
+ }
35
+ const content = value.blocks.map((block, index) => {
36
+ if (!isRecord(block) ||
37
+ !STUDIO_EDITOR_JS_TOOL_NAMES.includes(block.type) ||
38
+ !isRecord(block.data) ||
39
+ !isRecord(block.data.node)) {
40
+ throw new TypeError(`Editor block ${index} is not a Studio first-party block.`);
41
+ }
42
+ const node = structuredClone(block.data.node);
43
+ if (toolName(node) !== block.type) {
44
+ throw new TypeError(`Editor block ${index} has a mismatched Studio node type.`);
45
+ }
46
+ return node;
47
+ });
48
+ return { content: content.length > 0 ? content : [{ type: 'paragraph' }], type: 'doc' };
49
+ }
50
+ function toolName(node) {
51
+ switch (node.type) {
52
+ case 'heading':
53
+ return 'header';
54
+ case 'blockquote':
55
+ return 'quote';
56
+ case 'horizontalRule':
57
+ return 'delimiter';
58
+ case 'bulletList':
59
+ case 'orderedList':
60
+ return 'list';
61
+ case 'checklist':
62
+ return 'checklist';
63
+ case 'table':
64
+ return 'table';
65
+ case 'callout':
66
+ return 'callout';
67
+ case 'codeBlock':
68
+ return 'code';
69
+ case 'paragraph':
70
+ return 'paragraph';
71
+ default:
72
+ throw new TypeError(`Node type "${node.type}" has no first-party Editor.js tool.`);
73
+ }
74
+ }
75
+ class InlineToolBase {
76
+ static isReadOnlySupported = true;
77
+ node;
78
+ readOnly;
79
+ field;
80
+ constructor(options, fallback) {
81
+ this.node = structuredClone(options.data?.node ?? fallback);
82
+ this.readOnly = options.readOnly === true;
83
+ }
84
+ renderInline(label, content) {
85
+ const field = document.createElement('div');
86
+ field.className = 'studio-rich-text-field';
87
+ field.contentEditable = this.readOnly ? 'false' : 'true';
88
+ field.setAttribute('aria-label', label);
89
+ field.setAttribute('role', 'textbox');
90
+ field.setAttribute('aria-multiline', 'true');
91
+ field.spellcheck = true;
92
+ for (const inline of content)
93
+ appendInline(field, inline);
94
+ field.addEventListener('paste', pastePlainText);
95
+ this.field = field;
96
+ return field;
97
+ }
98
+ saveInline(original) {
99
+ return this.field === undefined
100
+ ? structuredClone([...original])
101
+ : preserveInlineRepresentation(original, readInline(this.field));
102
+ }
103
+ }
104
+ export class StudioParagraphTool extends InlineToolBase {
105
+ static isReadOnlySupported = true;
106
+ static toolbox = { icon: '¶', title: 'Paragraph' };
107
+ constructor(options) {
108
+ super(options, { type: 'paragraph' });
109
+ }
110
+ render() {
111
+ return this.renderInline('Paragraph', this.node.content ?? []);
112
+ }
113
+ save() {
114
+ const node = structuredClone(this.node);
115
+ const content = this.saveInline(node.content ?? []);
116
+ if (!sameCanonical(node.content ?? [], content))
117
+ node.content = content;
118
+ return { node };
119
+ }
120
+ }
121
+ export class StudioHeaderTool extends InlineToolBase {
122
+ static isReadOnlySupported = true;
123
+ static toolbox = { icon: 'H', title: 'Heading' };
124
+ #level;
125
+ constructor(options) {
126
+ super(options, { attrs: { level: 2 }, type: 'heading' });
127
+ }
128
+ render() {
129
+ const group = editorGroup('Heading');
130
+ const level = document.createElement('select');
131
+ const selectedLevel = this.node.attrs?.level === 3 || this.node.attrs?.level === 4 ? this.node.attrs.level : 2;
132
+ level.setAttribute('aria-label', 'Heading level');
133
+ level.disabled = this.readOnly;
134
+ for (const value of [2, 3, 4]) {
135
+ const option = document.createElement('option');
136
+ option.value = String(value);
137
+ option.textContent = `Heading ${value}`;
138
+ option.selected = selectedLevel === value;
139
+ level.append(option);
140
+ }
141
+ // happy-dom does not consistently preserve pre-append option.selected state.
142
+ // Set the select itself after its options exist so no-op saves retain the
143
+ // canonical heading level in both browser and headless DOM implementations.
144
+ level.value = String(selectedLevel);
145
+ this.#level = level;
146
+ group.append(level, this.renderInline('Heading text', this.node.content ?? []));
147
+ return group;
148
+ }
149
+ save() {
150
+ const node = structuredClone(this.node);
151
+ const level = Number(this.#level?.value ?? this.node.attrs?.level ?? 2);
152
+ if (level !== Number(this.node.attrs?.level ?? 2))
153
+ node.attrs = { level };
154
+ const content = this.saveInline(node.content ?? []);
155
+ if (!sameCanonical(node.content ?? [], content))
156
+ node.content = content;
157
+ return { node };
158
+ }
159
+ }
160
+ export class StudioQuoteTool extends InlineToolBase {
161
+ static isReadOnlySupported = true;
162
+ static toolbox = { icon: '“', title: 'Quote' };
163
+ constructor(options) {
164
+ super(options, { content: [{ type: 'paragraph' }], type: 'blockquote' });
165
+ }
166
+ render() {
167
+ return this.renderInline('Quotation', editableBlockContent(this.node.content ?? []));
168
+ }
169
+ save() {
170
+ const node = structuredClone(this.node);
171
+ const content = editableBlockContent(node.content ?? []);
172
+ node.content = mergeEditableBlockContent(node.content ?? [], this.saveInline(content));
173
+ return { node };
174
+ }
175
+ }
176
+ export class StudioDelimiterTool {
177
+ static isReadOnlySupported = true;
178
+ static toolbox = { icon: '—', title: 'Separator' };
179
+ render() {
180
+ const separator = document.createElement('hr');
181
+ separator.setAttribute('aria-label', 'Separator');
182
+ return separator;
183
+ }
184
+ save() {
185
+ return { node: { type: 'horizontalRule' } };
186
+ }
187
+ }
188
+ export class StudioCalloutTool extends InlineToolBase {
189
+ static isReadOnlySupported = true;
190
+ static toolbox = { icon: '!', title: 'Callout' };
191
+ #tone;
192
+ constructor(options) {
193
+ super(options, {
194
+ attrs: { tone: 'info' },
195
+ content: [{ type: 'paragraph' }],
196
+ type: 'callout',
197
+ });
198
+ }
199
+ render() {
200
+ const group = editorGroup('Callout');
201
+ this.#tone = selectControl('Callout tone', ['info', 'success', 'warning', 'danger'], stringAttribute(this.node.attrs?.tone, 'info'), this.readOnly);
202
+ group.append(this.#tone, this.renderInline('Callout text', editableBlockContent(this.node.content ?? [])));
203
+ return group;
204
+ }
205
+ save() {
206
+ const node = structuredClone(this.node);
207
+ node.attrs = { tone: this.#tone?.value ?? 'info' };
208
+ const content = editableBlockContent(node.content ?? []);
209
+ node.content = mergeEditableBlockContent(node.content ?? [], this.saveInline(content));
210
+ return { node };
211
+ }
212
+ }
213
+ export class StudioCodeTool {
214
+ static isReadOnlySupported = true;
215
+ static toolbox = { icon: '</>', title: 'Code' };
216
+ #node;
217
+ #readOnly;
218
+ #language;
219
+ #source;
220
+ constructor(options) {
221
+ this.#node = structuredClone(options.data?.node ?? { attrs: { language: 'text' }, text: '', type: 'codeBlock' });
222
+ this.#readOnly = options.readOnly === true;
223
+ }
224
+ render() {
225
+ const group = editorGroup('Code sample');
226
+ this.#language = textInput('Code language', stringAttribute(this.#node.attrs?.language, 'text'), this.#readOnly);
227
+ this.#language.pattern = '[A-Za-z0-9][A-Za-z0-9+_.#-]{0,63}';
228
+ this.#language.maxLength = 64;
229
+ this.#source = document.createElement('textarea');
230
+ this.#source.setAttribute('aria-label', 'Inert code source');
231
+ this.#source.disabled = this.#readOnly;
232
+ this.#source.rows = 8;
233
+ this.#source.value = this.#node.text ?? '';
234
+ group.append(this.#language, this.#source);
235
+ return group;
236
+ }
237
+ save() {
238
+ const language = this.#language?.value.trim() ?? 'text';
239
+ return {
240
+ node: {
241
+ attrs: {
242
+ language: /^[A-Za-z0-9][A-Za-z0-9+_.#-]{0,63}$/u.test(language) ? language : 'text',
243
+ },
244
+ text: this.#source?.value ?? '',
245
+ type: 'codeBlock',
246
+ },
247
+ };
248
+ }
249
+ }
250
+ export class StudioListTool {
251
+ static isReadOnlySupported = true;
252
+ static toolbox = { icon: '•', title: 'List' };
253
+ #readOnly;
254
+ #node;
255
+ #rows;
256
+ #root;
257
+ constructor(options) {
258
+ const node = structuredClone(options.data?.node ?? {
259
+ content: [{ content: [{ type: 'paragraph' }], type: 'listItem' }],
260
+ type: 'bulletList',
261
+ });
262
+ this.#node = node;
263
+ this.#readOnly = options.readOnly === true;
264
+ this.#rows = flattenList(node);
265
+ }
266
+ render() {
267
+ this.#root = editorGroup('List');
268
+ this.#renderRows();
269
+ return this.#root;
270
+ }
271
+ save() {
272
+ this.#syncRows();
273
+ return { node: structuredClone(this.#node) };
274
+ }
275
+ #renderRows() {
276
+ const root = this.#root;
277
+ if (root === undefined)
278
+ return;
279
+ root.replaceChildren();
280
+ const style = selectControl('List style', ['bullet', 'ordered'], this.#node.type === 'orderedList' ? 'ordered' : 'bullet', this.#readOnly);
281
+ style.addEventListener('change', () => {
282
+ this.#syncRows();
283
+ const ordered = style.value === 'ordered';
284
+ const start = orderedListStart(this.#node);
285
+ this.#node.type = ordered ? 'orderedList' : 'bulletList';
286
+ if (ordered && start !== 1)
287
+ this.#node.attrs = { start };
288
+ else
289
+ delete this.#node.attrs;
290
+ this.#renderRows();
291
+ });
292
+ root.append(style);
293
+ if (this.#node.type === 'orderedList') {
294
+ const start = textInput('Ordered list start', String(orderedListStart(this.#node)), this.#readOnly);
295
+ start.type = 'number';
296
+ start.min = '1';
297
+ start.max = '1000000';
298
+ start.addEventListener('change', () => {
299
+ const value = Math.max(1, Math.min(1_000_000, Number(start.value) || 1));
300
+ if (value === orderedListStart(this.#node))
301
+ return;
302
+ if (value === 1)
303
+ delete this.#node.attrs;
304
+ else
305
+ this.#node.attrs = { start: value };
306
+ });
307
+ root.append(start);
308
+ }
309
+ this.#rows = flattenList(this.#node);
310
+ const list = document.createElement('ol');
311
+ list.setAttribute('aria-label', 'List items');
312
+ for (const [index, row] of this.#rows.entries()) {
313
+ const item = document.createElement('li');
314
+ item.dataset.index = String(index);
315
+ item.dataset.studioDepth = String(row.depth);
316
+ item.setAttribute('aria-level', String(row.depth + 1));
317
+ const field = inlineField(`List item ${index + 1}`, row.editableBlock.content ?? [], this.#readOnly);
318
+ field.dataset.listText = String(index);
319
+ item.append(field);
320
+ if (!this.#readOnly) {
321
+ item.append(rowButton('Move item up', () => this.#move(index, -1), !canMoveListRow(row, -1)), rowButton('Move item down', () => this.#move(index, 1), !canMoveListRow(row, 1)), rowButton('Indent item', () => this.#indent(index), !canIndentListRow(row)), rowButton('Outdent item', () => this.#outdent(index), row.ownerItem === undefined), rowButton('Remove item', () => this.#remove(index), !canRemoveListRow(row, this.#node)));
322
+ }
323
+ list.append(item);
324
+ }
325
+ root.append(list);
326
+ if (!this.#readOnly)
327
+ root.append(rowButton('Add list item', () => this.#add()));
328
+ }
329
+ #syncRows() {
330
+ for (const field of this.#root?.querySelectorAll('[data-list-text]') ?? []) {
331
+ const index = Number(field.dataset.listText);
332
+ const row = this.#rows[index];
333
+ if (row === undefined)
334
+ continue;
335
+ const content = preserveInlineRepresentation(row.editableBlock.content ?? [], readInline(field));
336
+ if (row.syntheticEditable) {
337
+ if (content.length > 0) {
338
+ row.editableBlock.content = content;
339
+ row.item.content = [row.editableBlock, ...(row.item.content ?? [])];
340
+ row.syntheticEditable = false;
341
+ }
342
+ }
343
+ else if (!sameCanonical(row.editableBlock.content ?? [], content)) {
344
+ row.editableBlock.content = content;
345
+ }
346
+ }
347
+ }
348
+ #add() {
349
+ this.#syncRows();
350
+ if (this.#rows.length < 500)
351
+ this.#node.content = [
352
+ ...(this.#node.content ?? []),
353
+ {
354
+ content: [{ type: 'paragraph' }],
355
+ type: 'listItem',
356
+ },
357
+ ];
358
+ this.#renderRows();
359
+ }
360
+ #indent(index) {
361
+ this.#syncRows();
362
+ const row = this.#rows[index];
363
+ if (row === undefined || !canIndentListRow(row))
364
+ return;
365
+ const siblings = row.parentList.content ?? [];
366
+ const itemIndex = siblings.indexOf(row.item);
367
+ const previous = siblings[itemIndex - 1];
368
+ if (previous === undefined)
369
+ return;
370
+ siblings.splice(itemIndex, 1);
371
+ const existing = previous.content?.at(-1);
372
+ const nested = existing?.type === row.parentList.type
373
+ ? existing
374
+ : {
375
+ ...(row.parentList.type === 'orderedList' && row.parentList.attrs !== undefined
376
+ ? { attrs: structuredClone(row.parentList.attrs) }
377
+ : {}),
378
+ content: [],
379
+ type: row.parentList.type,
380
+ };
381
+ if (nested !== existing)
382
+ previous.content = [...(previous.content ?? []), nested];
383
+ nested.content = [...(nested.content ?? []), row.item];
384
+ this.#renderRows();
385
+ }
386
+ #outdent(index) {
387
+ this.#syncRows();
388
+ const row = this.#rows[index];
389
+ if (row?.ownerItem === undefined || row.parentListParent === undefined) {
390
+ return;
391
+ }
392
+ const siblings = row.parentList.content ?? [];
393
+ const itemIndex = siblings.indexOf(row.item);
394
+ if (itemIndex < 0)
395
+ return;
396
+ const trailing = siblings.splice(itemIndex + 1);
397
+ siblings.splice(itemIndex, 1);
398
+ if (trailing.length > 0) {
399
+ row.item.content = [
400
+ ...(row.item.content ?? []),
401
+ {
402
+ ...(row.parentList.type === 'orderedList' && row.parentList.attrs !== undefined
403
+ ? { attrs: structuredClone(row.parentList.attrs) }
404
+ : {}),
405
+ content: trailing,
406
+ type: row.parentList.type,
407
+ },
408
+ ];
409
+ }
410
+ if (siblings.length === 0)
411
+ removeListFromItem(row.ownerItem, row.parentList);
412
+ const parentSiblings = row.parentListParent.content ?? [];
413
+ const ownerIndex = parentSiblings.indexOf(row.ownerItem);
414
+ if (ownerIndex < 0)
415
+ return;
416
+ parentSiblings.splice(ownerIndex + 1, 0, row.item);
417
+ this.#renderRows();
418
+ }
419
+ #move(index, delta) {
420
+ this.#syncRows();
421
+ const row = this.#rows[index];
422
+ if (row === undefined || !canMoveListRow(row, delta))
423
+ return;
424
+ const siblings = row.parentList.content ?? [];
425
+ const itemIndex = siblings.indexOf(row.item);
426
+ const [item] = siblings.splice(itemIndex, 1);
427
+ if (item !== undefined)
428
+ siblings.splice(itemIndex + delta, 0, item);
429
+ this.#renderRows();
430
+ }
431
+ #remove(index) {
432
+ this.#syncRows();
433
+ const row = this.#rows[index];
434
+ if (row === undefined || !canRemoveListRow(row, this.#node))
435
+ return;
436
+ const siblings = row.parentList.content ?? [];
437
+ const itemIndex = siblings.indexOf(row.item);
438
+ if (itemIndex < 0)
439
+ return;
440
+ siblings.splice(itemIndex, 1);
441
+ if (siblings.length === 0 && row.ownerItem !== undefined) {
442
+ removeListFromItem(row.ownerItem, row.parentList);
443
+ }
444
+ this.#renderRows();
445
+ }
446
+ }
447
+ export class StudioChecklistTool {
448
+ static isReadOnlySupported = true;
449
+ static toolbox = { icon: '☑', title: 'Checklist' };
450
+ #readOnly;
451
+ #initialRows;
452
+ #node;
453
+ #root;
454
+ #rows;
455
+ constructor(options) {
456
+ this.#readOnly = options.readOnly === true;
457
+ this.#node = structuredClone(options.data?.node ?? {
458
+ content: [{ attrs: { checked: false, level: 0 }, type: 'checklistItem' }],
459
+ type: 'checklist',
460
+ });
461
+ const content = this.#node.content ?? [];
462
+ this.#rows =
463
+ content.length > 0
464
+ ? content.map((item) => ({
465
+ checked: item.attrs?.checked === true,
466
+ content: structuredClone(item.content ?? []),
467
+ contentPresent: item.content !== undefined,
468
+ depth: Number(item.attrs?.level ?? 0),
469
+ }))
470
+ : [
471
+ {
472
+ checked: false,
473
+ content: [],
474
+ contentPresent: false,
475
+ depth: 0,
476
+ },
477
+ ];
478
+ this.#initialRows = structuredClone(this.#rows);
479
+ }
480
+ render() {
481
+ this.#root = editorGroup('Checklist');
482
+ this.#renderRows();
483
+ return this.#root;
484
+ }
485
+ save() {
486
+ this.#syncRows();
487
+ if (sameCanonical(this.#rows, this.#initialRows)) {
488
+ return { node: structuredClone(this.#node) };
489
+ }
490
+ return {
491
+ node: {
492
+ content: this.#rows.map((row) => ({
493
+ attrs: { checked: row.checked, level: row.depth },
494
+ ...(row.contentPresent || row.content.length > 0
495
+ ? { content: structuredClone(row.content) }
496
+ : {}),
497
+ type: 'checklistItem',
498
+ })),
499
+ type: 'checklist',
500
+ },
501
+ };
502
+ }
503
+ #renderRows() {
504
+ const root = this.#root;
505
+ if (root === undefined)
506
+ return;
507
+ root.replaceChildren();
508
+ for (const [index, row] of this.#rows.entries()) {
509
+ const group = editorGroup(`Checklist item ${index + 1}`);
510
+ group.dataset.studioDepth = String(row.depth);
511
+ group.setAttribute('aria-level', String(row.depth + 1));
512
+ const checked = document.createElement('input');
513
+ checked.type = 'checkbox';
514
+ checked.checked = row.checked;
515
+ checked.disabled = this.#readOnly;
516
+ checked.dataset.checkState = String(index);
517
+ checked.setAttribute('aria-label', `Checklist item ${index + 1} complete`);
518
+ const field = inlineField(`Checklist item ${index + 1}`, row.content, this.#readOnly);
519
+ field.dataset.checkText = String(index);
520
+ field.addEventListener('input', () => {
521
+ row.contentPresent = true;
522
+ });
523
+ group.append(checked, field);
524
+ if (!this.#readOnly) {
525
+ group.append(rowButton('Move item up', () => this.#move(index, -1), index === 0), rowButton('Move item down', () => this.#move(index, 1), index === this.#rows.length - 1), rowButton('Indent item', () => this.#indent(index, 1), row.depth >= 4 || index === 0), rowButton('Outdent item', () => this.#indent(index, -1), row.depth === 0), rowButton('Remove item', () => this.#remove(index), this.#rows.length === 1));
526
+ }
527
+ root.append(group);
528
+ }
529
+ if (!this.#readOnly)
530
+ root.append(rowButton('Add checklist item', () => this.#add()));
531
+ }
532
+ #syncRows() {
533
+ for (const field of this.#root?.querySelectorAll('[data-check-text]') ?? []) {
534
+ const row = this.#rows[Number(field.dataset.checkText)];
535
+ if (row !== undefined) {
536
+ row.content = preserveInlineRepresentation(row.content, readInline(field));
537
+ }
538
+ }
539
+ for (const input of this.#root?.querySelectorAll('[data-check-state]') ??
540
+ []) {
541
+ const row = this.#rows[Number(input.dataset.checkState)];
542
+ if (row !== undefined)
543
+ row.checked = input.checked;
544
+ }
545
+ }
546
+ #add() {
547
+ this.#syncRows();
548
+ if (this.#rows.length < 500)
549
+ this.#rows.push({
550
+ checked: false,
551
+ content: [],
552
+ contentPresent: false,
553
+ depth: 0,
554
+ });
555
+ this.#renderRows();
556
+ }
557
+ #indent(index, delta) {
558
+ this.#syncRows();
559
+ const row = this.#rows[index];
560
+ if (row !== undefined)
561
+ row.depth = Math.max(0, Math.min(4, row.depth + delta));
562
+ this.#renderRows();
563
+ }
564
+ #move(index, delta) {
565
+ this.#syncRows();
566
+ const target = index + delta;
567
+ if (target >= 0 && target < this.#rows.length) {
568
+ const [row] = this.#rows.splice(index, 1);
569
+ if (row !== undefined)
570
+ this.#rows.splice(target, 0, row);
571
+ }
572
+ this.#renderRows();
573
+ }
574
+ #remove(index) {
575
+ this.#syncRows();
576
+ if (this.#rows.length > 1)
577
+ this.#rows.splice(index, 1);
578
+ this.#renderRows();
579
+ }
580
+ }
581
+ export class StudioTableTool {
582
+ static isReadOnlySupported = true;
583
+ static toolbox = { icon: '▦', title: 'Table' };
584
+ #readOnly;
585
+ #initialCells;
586
+ #initialHeader;
587
+ #node;
588
+ #cells;
589
+ #header;
590
+ #root;
591
+ constructor(options) {
592
+ this.#readOnly = options.readOnly === true;
593
+ this.#node = structuredClone(options.data?.node ?? {
594
+ attrs: { header: false },
595
+ content: [
596
+ {
597
+ content: [{ type: 'tableCell' }, { type: 'tableCell' }],
598
+ type: 'tableRow',
599
+ },
600
+ {
601
+ content: [{ type: 'tableCell' }, { type: 'tableCell' }],
602
+ type: 'tableRow',
603
+ },
604
+ ],
605
+ type: 'table',
606
+ });
607
+ this.#header = this.#node.attrs?.header === true;
608
+ this.#cells = (this.#node.content ?? []).map((row) => (row.content ?? []).map((cell) => ({
609
+ content: structuredClone(cell.content ?? []),
610
+ contentPresent: cell.content !== undefined,
611
+ })));
612
+ this.#initialHeader = this.#header;
613
+ this.#initialCells = structuredClone(this.#cells);
614
+ }
615
+ render() {
616
+ this.#root = editorGroup('Table');
617
+ this.#renderTable();
618
+ return this.#root;
619
+ }
620
+ save() {
621
+ this.#syncCells();
622
+ if (this.#header === this.#initialHeader && sameCanonical(this.#cells, this.#initialCells)) {
623
+ return { node: structuredClone(this.#node) };
624
+ }
625
+ return {
626
+ node: {
627
+ attrs: { header: this.#header },
628
+ content: this.#cells.map((row) => ({
629
+ content: row.map((cell) => ({
630
+ ...(cell.contentPresent || cell.content.length > 0
631
+ ? { content: structuredClone(cell.content) }
632
+ : {}),
633
+ type: 'tableCell',
634
+ })),
635
+ type: 'tableRow',
636
+ })),
637
+ type: 'table',
638
+ },
639
+ };
640
+ }
641
+ #renderTable() {
642
+ const root = this.#root;
643
+ if (root === undefined)
644
+ return;
645
+ root.replaceChildren();
646
+ const header = document.createElement('input');
647
+ header.type = 'checkbox';
648
+ header.checked = this.#header;
649
+ header.disabled = this.#readOnly;
650
+ header.setAttribute('aria-label', 'Use first row as table header');
651
+ header.addEventListener('change', () => {
652
+ this.#header = header.checked;
653
+ });
654
+ root.append(header);
655
+ const table = document.createElement('table');
656
+ table.setAttribute('aria-label', 'Table data');
657
+ for (const [rowIndex, row] of this.#cells.entries()) {
658
+ const tr = document.createElement('tr');
659
+ for (const [columnIndex, value] of row.entries()) {
660
+ const cell = document.createElement(rowIndex === 0 && this.#header ? 'th' : 'td');
661
+ const field = inlineField(`Row ${rowIndex + 1}, column ${columnIndex + 1}`, value.content, this.#readOnly);
662
+ field.dataset.tableCell = `${rowIndex}:${columnIndex}`;
663
+ field.addEventListener('input', () => {
664
+ value.contentPresent = true;
665
+ });
666
+ cell.append(field);
667
+ tr.append(cell);
668
+ }
669
+ table.append(tr);
670
+ }
671
+ root.append(table);
672
+ if (!this.#readOnly) {
673
+ root.append(rowButton('Add table row', () => this.#resize(1, 0), this.#cells.length >= 200), rowButton('Remove table row', () => this.#resize(-1, 0), this.#cells.length <= 1), rowButton('Add table column', () => this.#resize(0, 1), (this.#cells[0]?.length ?? 0) >= 50), rowButton('Remove table column', () => this.#resize(0, -1), (this.#cells[0]?.length ?? 0) <= 1));
674
+ }
675
+ }
676
+ #resize(rows, columns) {
677
+ this.#syncCells();
678
+ if (rows > 0 && this.#cells.length < 200)
679
+ this.#cells.push(Array.from({ length: this.#cells[0]?.length ?? 1 }, () => ({
680
+ content: [],
681
+ contentPresent: false,
682
+ })));
683
+ if (rows < 0 && this.#cells.length > 1)
684
+ this.#cells.pop();
685
+ if (columns > 0 && (this.#cells[0]?.length ?? 0) < 50)
686
+ for (const row of this.#cells)
687
+ row.push({ content: [], contentPresent: false });
688
+ if (columns < 0 && (this.#cells[0]?.length ?? 0) > 1)
689
+ for (const row of this.#cells)
690
+ row.pop();
691
+ this.#renderTable();
692
+ }
693
+ #syncCells() {
694
+ for (const field of this.#root?.querySelectorAll('[data-table-cell]') ?? []) {
695
+ const [row, column] = (field.dataset.tableCell ?? '').split(':').map(Number);
696
+ const targetRow = row === undefined ? undefined : this.#cells[row];
697
+ const targetCell = column === undefined ? undefined : targetRow?.[column];
698
+ if (targetCell !== undefined) {
699
+ targetCell.content = preserveInlineRepresentation(targetCell.content, readInline(field));
700
+ }
701
+ }
702
+ }
703
+ }
704
+ /** Editor.js inline tool for a bounded semantic highlight tone. */
705
+ export class StudioMarkerTool {
706
+ static isInline = true;
707
+ static sanitize = { mark: { 'data-studio-tone': true } };
708
+ #button;
709
+ #tone = 'accent';
710
+ checkState(selection) {
711
+ const mark = closestMark(selection.anchorNode);
712
+ const active = mark !== undefined;
713
+ this.#button?.setAttribute('aria-pressed', String(active));
714
+ return active;
715
+ }
716
+ render() {
717
+ const button = document.createElement('button');
718
+ button.type = 'button';
719
+ button.textContent = 'Highlight';
720
+ button.setAttribute('aria-label', 'Toggle semantic highlight');
721
+ button.setAttribute('aria-pressed', 'false');
722
+ this.#button = button;
723
+ return button;
724
+ }
725
+ renderActions() {
726
+ const select = selectControl('Highlight tone', ['accent', 'info', 'success', 'warning', 'danger'], this.#tone, false);
727
+ select.addEventListener('change', () => {
728
+ this.#tone = select.value;
729
+ });
730
+ return select;
731
+ }
732
+ surround(range) {
733
+ const active = closestMark(range.commonAncestorContainer);
734
+ if (active !== undefined) {
735
+ const parent = active.parentNode;
736
+ while (active.firstChild !== null)
737
+ parent?.insertBefore(active.firstChild, active);
738
+ active.remove();
739
+ return;
740
+ }
741
+ if (range.collapsed)
742
+ return;
743
+ const mark = document.createElement('mark');
744
+ mark.dataset.studioTone = this.#tone;
745
+ mark.append(range.extractContents());
746
+ range.insertNode(mark);
747
+ }
748
+ }
749
+ function appendInline(parent, node) {
750
+ if (node.type === 'hardBreak') {
751
+ parent.appendChild(document.createElement('br'));
752
+ return;
753
+ }
754
+ if (node.type !== 'text' || (node.text ?? '').length === 0)
755
+ return;
756
+ let child = document.createTextNode(node.text ?? '');
757
+ for (const mark of [...(node.marks ?? [])].reverse()) {
758
+ const element = document.createElement(markElement(mark));
759
+ if (mark.type === 'highlight')
760
+ element.dataset.studioTone = stringAttribute(mark.attrs?.tone, 'accent');
761
+ element.append(child);
762
+ child = element;
763
+ }
764
+ parent.appendChild(child);
765
+ }
766
+ function markElement(mark) {
767
+ if (mark.type === 'bold')
768
+ return 'strong';
769
+ if (mark.type === 'italic')
770
+ return 'em';
771
+ if (mark.type === 'strike')
772
+ return 's';
773
+ if (mark.type === 'code')
774
+ return 'code';
775
+ return 'mark';
776
+ }
777
+ function readInline(parent) {
778
+ const result = [];
779
+ const visit = (node, marks) => {
780
+ if (node.nodeType === Node.TEXT_NODE) {
781
+ const text = node.nodeValue ?? '';
782
+ if (text.length > 0)
783
+ result.push({ ...(marks.length > 0 ? { marks } : {}), text, type: 'text' });
784
+ return;
785
+ }
786
+ if (!(node instanceof Element))
787
+ return;
788
+ if (node.localName === 'br') {
789
+ result.push({ type: 'hardBreak' });
790
+ return;
791
+ }
792
+ const next = [...marks];
793
+ const mark = canonicalMark(node);
794
+ if (mark !== undefined && !next.some((item) => item.type === mark.type)) {
795
+ if (mark.type === 'code')
796
+ next.splice(0, next.length, mark);
797
+ else if (!next.some((item) => item.type === 'code'))
798
+ next.push(mark);
799
+ }
800
+ for (const child of node.childNodes)
801
+ visit(child, next);
802
+ };
803
+ for (const child of parent.childNodes)
804
+ visit(child, []);
805
+ return result;
806
+ }
807
+ function preserveInlineRepresentation(original, rendered) {
808
+ return sameCanonical(projectInline(original), projectInline(rendered))
809
+ ? structuredClone([...original])
810
+ : rendered;
811
+ }
812
+ function projectInline(content) {
813
+ const projection = [];
814
+ for (const node of content) {
815
+ if (node.type === 'hardBreak') {
816
+ projection.push({ kind: 'hard-break' });
817
+ continue;
818
+ }
819
+ if (node.type !== 'text')
820
+ continue;
821
+ const marks = (node.marks ?? [])
822
+ .map((mark) => {
823
+ if (mark.type !== 'highlight')
824
+ return mark.type;
825
+ const tone = mark.attrs?.tone;
826
+ return `${mark.type}:${typeof tone === 'string' ? tone : ''}`;
827
+ })
828
+ .sort();
829
+ const previous = projection.at(-1);
830
+ if (previous?.kind === 'text' && sameCanonical(previous.marks, marks)) {
831
+ previous.text += node.text ?? '';
832
+ }
833
+ else {
834
+ projection.push({ kind: 'text', marks, text: node.text ?? '' });
835
+ }
836
+ }
837
+ return projection;
838
+ }
839
+ function canonicalMark(element) {
840
+ if (element.localName === 'strong' || element.localName === 'b')
841
+ return { type: 'bold' };
842
+ if (element.localName === 'em' || element.localName === 'i')
843
+ return { type: 'italic' };
844
+ if (element.localName === 's' || element.localName === 'del')
845
+ return { type: 'strike' };
846
+ if (element.localName === 'code')
847
+ return { type: 'code' };
848
+ if (element.localName === 'mark') {
849
+ const tone = element.getAttribute('data-studio-tone');
850
+ return {
851
+ attrs: {
852
+ tone: ['accent', 'danger', 'info', 'success', 'warning'].includes(tone ?? '')
853
+ ? (tone ?? 'accent')
854
+ : 'accent',
855
+ },
856
+ type: 'highlight',
857
+ };
858
+ }
859
+ return undefined;
860
+ }
861
+ function pastePlainText(event) {
862
+ event.preventDefault();
863
+ const text = event.clipboardData?.getData('text/plain') ?? '';
864
+ const selection = globalThis.getSelection();
865
+ if (selection === null || selection.rangeCount === 0)
866
+ return;
867
+ const range = selection.getRangeAt(0);
868
+ range.deleteContents();
869
+ range.insertNode(document.createTextNode(text.slice(0, 250_000)));
870
+ range.collapse(false);
871
+ }
872
+ function flattenList(node, depth = 0, ownerItem, parentListParent) {
873
+ const rows = [];
874
+ for (const item of node.content ?? []) {
875
+ const existingEditable = (item.content ?? []).find((block) => block.type === 'paragraph' || block.type === 'heading');
876
+ const editableBlock = existingEditable ?? { type: 'paragraph' };
877
+ rows.push({
878
+ depth,
879
+ editableBlock,
880
+ item,
881
+ ...(ownerItem === undefined ? {} : { ownerItem }),
882
+ parentList: node,
883
+ ...(parentListParent === undefined ? {} : { parentListParent }),
884
+ syntheticEditable: existingEditable === undefined,
885
+ });
886
+ for (const nested of item.content ?? []) {
887
+ if (nested.type === 'bulletList' || nested.type === 'orderedList')
888
+ rows.push(...flattenList(nested, depth + 1, item, node));
889
+ }
890
+ }
891
+ return rows;
892
+ }
893
+ function orderedListStart(node) {
894
+ const value = Number(node.attrs?.start ?? 1);
895
+ return Number.isSafeInteger(value) && value >= 1 && value <= 1_000_000 ? value : 1;
896
+ }
897
+ function canMoveListRow(row, delta) {
898
+ const siblings = row.parentList.content ?? [];
899
+ const index = siblings.indexOf(row.item);
900
+ return index >= 0 && index + delta >= 0 && index + delta < siblings.length;
901
+ }
902
+ function canIndentListRow(row) {
903
+ if (row.depth >= 4)
904
+ return false;
905
+ const siblings = row.parentList.content ?? [];
906
+ return siblings.indexOf(row.item) > 0;
907
+ }
908
+ function canRemoveListRow(row, root) {
909
+ return row.parentList !== root || (root.content?.length ?? 0) > 1;
910
+ }
911
+ function removeListFromItem(item, list) {
912
+ item.content = (item.content ?? []).filter((block) => block !== list);
913
+ }
914
+ function editableBlockContent(blocks) {
915
+ return (blocks.find((block) => block.type === 'paragraph' || block.type === 'heading')?.content ?? []);
916
+ }
917
+ function mergeEditableBlockContent(blocks, content) {
918
+ const result = structuredClone([...blocks]);
919
+ const index = result.findIndex((block) => block.type === 'paragraph' || block.type === 'heading');
920
+ if (index < 0) {
921
+ if (content.length > 0)
922
+ result.unshift({ content: structuredClone([...content]), type: 'paragraph' });
923
+ return result;
924
+ }
925
+ const block = result[index];
926
+ if (block !== undefined && !sameCanonical(block.content ?? [], content)) {
927
+ block.content = structuredClone([...content]);
928
+ }
929
+ return result;
930
+ }
931
+ function inlineField(label, content, readOnly) {
932
+ const field = document.createElement('div');
933
+ field.className = 'studio-rich-text-field';
934
+ field.contentEditable = readOnly ? 'false' : 'true';
935
+ field.setAttribute('aria-label', label);
936
+ field.setAttribute('aria-multiline', 'true');
937
+ field.setAttribute('role', 'textbox');
938
+ field.spellcheck = true;
939
+ for (const inline of content)
940
+ appendInline(field, inline);
941
+ field.addEventListener('paste', pastePlainText);
942
+ return field;
943
+ }
944
+ function sameCanonical(left, right) {
945
+ if (Object.is(left, right))
946
+ return true;
947
+ if (Array.isArray(left) || Array.isArray(right)) {
948
+ return (Array.isArray(left) &&
949
+ Array.isArray(right) &&
950
+ left.length === right.length &&
951
+ left.every((value, index) => sameCanonical(value, right[index])));
952
+ }
953
+ if (!isRecord(left) || !isRecord(right))
954
+ return false;
955
+ const leftKeys = Object.keys(left).sort();
956
+ const rightKeys = Object.keys(right).sort();
957
+ return (leftKeys.length === rightKeys.length &&
958
+ leftKeys.every((key, index) => key === rightKeys[index] && sameCanonical(left[key], right[key])));
959
+ }
960
+ function editorGroup(label) {
961
+ const group = document.createElement('div');
962
+ group.setAttribute('aria-label', label);
963
+ group.setAttribute('role', 'group');
964
+ return group;
965
+ }
966
+ function textInput(label, value, readOnly) {
967
+ const input = document.createElement('input');
968
+ input.type = 'text';
969
+ input.setAttribute('aria-label', label);
970
+ input.disabled = readOnly;
971
+ input.value = value;
972
+ return input;
973
+ }
974
+ function selectControl(label, values, selected, readOnly) {
975
+ const select = document.createElement('select');
976
+ select.setAttribute('aria-label', label);
977
+ select.disabled = readOnly;
978
+ for (const value of values) {
979
+ const option = document.createElement('option');
980
+ option.value = value;
981
+ option.textContent = value;
982
+ option.selected = value === selected;
983
+ select.append(option);
984
+ }
985
+ select.value = selected;
986
+ return select;
987
+ }
988
+ function rowButton(label, action, disabled = false) {
989
+ const button = document.createElement('button');
990
+ button.type = 'button';
991
+ button.textContent = label;
992
+ button.setAttribute('aria-label', label);
993
+ button.disabled = disabled;
994
+ button.addEventListener('click', action);
995
+ return button;
996
+ }
997
+ function closestMark(node) {
998
+ let candidate = node instanceof HTMLElement ? node : node?.parentElement;
999
+ while (candidate !== null && candidate !== undefined) {
1000
+ if (candidate.localName === 'mark')
1001
+ return candidate;
1002
+ candidate = candidate.parentElement ?? undefined;
1003
+ }
1004
+ return undefined;
1005
+ }
1006
+ function isRecord(value) {
1007
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
1008
+ }
1009
+ function stringAttribute(value, fallback) {
1010
+ return typeof value === 'string' ? value : fallback;
1011
+ }
1012
+ //# sourceMappingURL=first-party-tools.js.map