@jxsuite/studio 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,572 @@
1
+ /**
2
+ * Inline-format.js — Inline formatting engine for contenteditable editing
3
+ *
4
+ * Handles toggling inline formatting (bold, italic, code, etc.) with proper wrap/unwrap logic, DOM
5
+ * normalization, and whitespace management.
6
+ */
7
+
8
+ /** Tags considered inline formatting wrappers */
9
+ const FORMAT_TAGS = new Set([
10
+ "strong",
11
+ "em",
12
+ "b",
13
+ "i",
14
+ "u",
15
+ "del",
16
+ "s",
17
+ "strike",
18
+ "code",
19
+ "sub",
20
+ "sup",
21
+ "span",
22
+ ]);
23
+
24
+ /**
25
+ * Check whether `tag` is currently active on both ends of the selection. Walks from anchor and
26
+ * focus nodes up to editableRoot looking for the tag. Returns false if selection is outside
27
+ * editableRoot or in plaintext-only mode.
28
+ *
29
+ * @param {string} tag
30
+ * @param {HTMLElement | null} editableRoot
31
+ * @returns {boolean}
32
+ */
33
+ export function isTagActiveInSelection(tag, editableRoot) {
34
+ if (!editableRoot) return false;
35
+ if (editableRoot.contentEditable === "plaintext-only") return false;
36
+ const sel = window.getSelection();
37
+ if (!sel || !sel.rangeCount) return false;
38
+
39
+ const anchor = sel.anchorNode;
40
+ const focus = sel.focusNode;
41
+ if (!anchor || !focus) return false;
42
+ if (!editableRoot.contains(anchor) || !editableRoot.contains(focus)) return false;
43
+
44
+ /**
45
+ * @param {Node | null} node
46
+ * @returns {boolean}
47
+ */
48
+ const hasTag = (node) => {
49
+ while (node && node !== editableRoot) {
50
+ if (
51
+ node.nodeType === Node.ELEMENT_NODE &&
52
+ /** @type {Element} */ (node).tagName.toLowerCase() === tag
53
+ )
54
+ return true;
55
+ node = node.parentNode;
56
+ }
57
+ return false;
58
+ };
59
+
60
+ return hasTag(anchor) && hasTag(focus);
61
+ }
62
+
63
+ /**
64
+ * Toggle an inline format tag on/off for the current selection. If the tag is active → unwrap. If
65
+ * not → wrap.
66
+ *
67
+ * @param {string} tag
68
+ * @param {HTMLElement | null} editableRoot
69
+ */
70
+ export function toggleInlineFormat(tag, editableRoot) {
71
+ if (!editableRoot) return;
72
+ const sel = window.getSelection();
73
+ if (!sel || !sel.rangeCount) return;
74
+ const range = sel.getRangeAt(0);
75
+ if (range.collapsed) return;
76
+ if (!editableRoot.contains(range.commonAncestorContainer)) return;
77
+
78
+ // Expand selection to fully include any partially-selected ${...} expressions
79
+ expandRangeToTemplateExpressions(range);
80
+
81
+ // Find all elements matching `tag` that intersect the selection
82
+ const matches = findIntersectingElements(tag, range, editableRoot);
83
+
84
+ if (matches.length > 0) {
85
+ unwrapTagInRange(tag, range, editableRoot, matches);
86
+ } else {
87
+ wrapRangeInTag(tag, range, editableRoot);
88
+ }
89
+
90
+ normalizeInlineContent(editableRoot);
91
+ }
92
+
93
+ /**
94
+ * Find all elements with the given tag that intersect the selection range.
95
+ *
96
+ * @param {string} tag
97
+ * @param {Range} range
98
+ * @param {HTMLElement} root
99
+ * @returns {Element[]}
100
+ */
101
+ function findIntersectingElements(tag, range, root) {
102
+ /** @type {Element[]} */
103
+ const matches = [];
104
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, {
105
+ /**
106
+ * @param {Node} node
107
+ * @returns {number}
108
+ */
109
+ acceptNode(node) {
110
+ if (
111
+ /** @type {Element} */ (node).tagName.toLowerCase() === tag &&
112
+ range.intersectsNode(node)
113
+ ) {
114
+ return NodeFilter.FILTER_ACCEPT;
115
+ }
116
+ return NodeFilter.FILTER_SKIP;
117
+ },
118
+ });
119
+ while (walker.nextNode()) {
120
+ matches.push(/** @type {Element} */ (walker.currentNode));
121
+ }
122
+ return matches;
123
+ }
124
+
125
+ /**
126
+ * Unwrap all instances of `tag` within the range. Processes in reverse document order to preserve
127
+ * earlier offsets.
128
+ *
129
+ * @param {string} tag
130
+ * @param {Range} range
131
+ * @param {HTMLElement} editableRoot
132
+ * @param {Element[]} matches
133
+ */
134
+ function unwrapTagInRange(tag, range, editableRoot, matches) {
135
+ // Process in reverse so DOM mutations don't shift later nodes
136
+ for (let i = matches.length - 1; i >= 0; i--) {
137
+ const el = matches[i];
138
+ unwrapElement(el);
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Replace an element with its children (unwrap).
144
+ *
145
+ * @param {Element} el
146
+ */
147
+ function unwrapElement(el) {
148
+ const parent = el.parentNode;
149
+ if (!parent) return;
150
+ const frag = document.createDocumentFragment();
151
+ while (el.firstChild) {
152
+ frag.appendChild(el.firstChild);
153
+ }
154
+ parent.replaceChild(frag, el);
155
+ }
156
+
157
+ /**
158
+ * Wrap the current selection range in a new element of the given tag. Handles whitespace:
159
+ * leading/trailing whitespace stays outside the wrapper.
160
+ *
161
+ * @param {string} tag
162
+ * @param {Range} range
163
+ * @param {HTMLElement} _editableRoot
164
+ */
165
+ function wrapRangeInTag(tag, range, _editableRoot) {
166
+ const contents = range.extractContents();
167
+
168
+ // Trim leading whitespace from the fragment
169
+ const leadingWS = trimLeadingWhitespace(contents);
170
+ // Trim trailing whitespace from the fragment
171
+ const trailingWS = trimTrailingWhitespace(contents);
172
+
173
+ // If nothing left after trimming, re-insert everything and bail
174
+ if (!contents.hasChildNodes()) {
175
+ if (leadingWS) range.insertNode(document.createTextNode(leadingWS));
176
+ if (trailingWS) {
177
+ const t = document.createTextNode(trailingWS);
178
+ range.collapse(false);
179
+ range.insertNode(t);
180
+ }
181
+ return;
182
+ }
183
+
184
+ const wrapper = document.createElement(tag);
185
+ wrapper.appendChild(contents);
186
+
187
+ // Build the insertion fragment: [leadingWS] [wrapper] [trailingWS]
188
+ const frag = document.createDocumentFragment();
189
+ if (leadingWS) frag.appendChild(document.createTextNode(leadingWS));
190
+ frag.appendChild(wrapper);
191
+ if (trailingWS) frag.appendChild(document.createTextNode(trailingWS));
192
+
193
+ range.insertNode(frag);
194
+
195
+ // Restore selection around the wrapper's contents
196
+ const sel = window.getSelection();
197
+ if (sel) {
198
+ sel.removeAllRanges();
199
+ const newRange = document.createRange();
200
+ newRange.selectNodeContents(wrapper);
201
+ sel.addRange(newRange);
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Remove and return leading whitespace from a document fragment. Only trims if the first node is a
207
+ * text node starting with whitespace.
208
+ *
209
+ * @param {DocumentFragment} frag
210
+ * @returns {string | null}
211
+ */
212
+ function trimLeadingWhitespace(frag) {
213
+ const first = frag.firstChild;
214
+ if (!first || first.nodeType !== Node.TEXT_NODE) return null;
215
+ const text = first.textContent ?? "";
216
+ const match = text.match(/^(\s+)/);
217
+ if (!match) return null;
218
+ const ws = match[1];
219
+ if (text.length === ws.length) {
220
+ // Entire node is whitespace — remove it
221
+ frag.removeChild(first);
222
+ } else {
223
+ first.textContent = text.slice(ws.length);
224
+ }
225
+ return ws;
226
+ }
227
+
228
+ /**
229
+ * Remove and return trailing whitespace from a document fragment. Only trims if the last node is a
230
+ * text node ending with whitespace.
231
+ *
232
+ * @param {DocumentFragment} frag
233
+ * @returns {string | null}
234
+ */
235
+ function trimTrailingWhitespace(frag) {
236
+ const last = frag.lastChild;
237
+ if (!last || last.nodeType !== Node.TEXT_NODE) return null;
238
+ const text = last.textContent ?? "";
239
+ const match = text.match(/(\s+)$/);
240
+ if (!match) return null;
241
+ const ws = match[1];
242
+ if (text.length === ws.length) {
243
+ frag.removeChild(last);
244
+ } else {
245
+ last.textContent = text.slice(0, -ws.length);
246
+ }
247
+ return ws;
248
+ }
249
+
250
+ // ─── Normalization ─────────────────────────────────────────────────────────────
251
+
252
+ /**
253
+ * Normalize the inline content of an editable root. Merges adjacent same-tag siblings, collapses
254
+ * redundant nesting, removes empty inline elements, and lifts edge whitespace. Runs to
255
+ * fixed-point.
256
+ *
257
+ * @param {HTMLElement | null} root
258
+ */
259
+ export function normalizeInlineContent(root) {
260
+ if (!root) return;
261
+ let changed = true;
262
+ let iterations = 0;
263
+ while (changed && iterations < 10) {
264
+ changed = false;
265
+ iterations++;
266
+
267
+ // 1. Merge adjacent text nodes
268
+ root.normalize();
269
+
270
+ // 2. Merge adjacent same-tag siblings
271
+ if (mergeAdjacentSiblings(root)) changed = true;
272
+
273
+ // 3. Collapse redundant nesting (strong > strong → strong)
274
+ if (collapseRedundantNesting(root)) changed = true;
275
+
276
+ // 4. Remove empty inline elements
277
+ if (removeEmptyInlines(root)) changed = true;
278
+
279
+ // 5. Lift edge whitespace out of inline wrappers
280
+ if (liftEdgeWhitespace(root)) changed = true;
281
+
282
+ // 6. Unwrap bare <span> elements (no class, style, or attributes)
283
+ if (unwrapBareSpans(root)) changed = true;
284
+ }
285
+ }
286
+
287
+ /**
288
+ * Merge adjacent sibling elements with the same tag name. E.g.,
289
+ * <strong>a</strong><strong>b</strong> → <strong>ab</strong>
290
+ *
291
+ * @param {HTMLElement} root
292
+ * @returns {boolean}
293
+ */
294
+ function mergeAdjacentSiblings(root) {
295
+ let changed = false;
296
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
297
+ /** @type {[Element, Element][]} */
298
+ const toMerge = [];
299
+
300
+ // Collect pairs first to avoid mutation during walk
301
+ while (walker.nextNode()) {
302
+ const el = /** @type {Element} */ (walker.currentNode);
303
+ const next = el.nextSibling;
304
+ if (
305
+ next &&
306
+ next.nodeType === Node.ELEMENT_NODE &&
307
+ /** @type {Element} */ (next).tagName === el.tagName &&
308
+ FORMAT_TAGS.has(el.tagName.toLowerCase()) &&
309
+ attributesMatch(el, /** @type {Element} */ (next))
310
+ ) {
311
+ toMerge.push([el, /** @type {Element} */ (next)]);
312
+ }
313
+ }
314
+
315
+ // Process in reverse to preserve earlier offsets
316
+ for (let i = toMerge.length - 1; i >= 0; i--) {
317
+ const [el, next] = toMerge[i];
318
+ // Move all children from next into el
319
+ while (next.firstChild) {
320
+ el.appendChild(next.firstChild);
321
+ }
322
+ next.remove();
323
+ changed = true;
324
+ }
325
+ return changed;
326
+ }
327
+
328
+ /**
329
+ * Collapse redundant nesting where a parent and its only child share the same tag. E.g.,
330
+ * <strong><strong>x</strong></strong> → <strong>x</strong>
331
+ *
332
+ * @param {HTMLElement} root
333
+ * @returns {boolean}
334
+ */
335
+ function collapseRedundantNesting(root) {
336
+ let changed = false;
337
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
338
+ /** @type {Element[]} */
339
+ const toCollapse = [];
340
+
341
+ while (walker.nextNode()) {
342
+ const el = /** @type {Element} */ (walker.currentNode);
343
+ if (!FORMAT_TAGS.has(el.tagName.toLowerCase())) continue;
344
+ // Check if only child is an element with the same tag
345
+ if (
346
+ el.childNodes.length === 1 &&
347
+ el.firstChild !== null &&
348
+ el.firstChild.nodeType === Node.ELEMENT_NODE &&
349
+ /** @type {Element} */ (el.firstChild).tagName === el.tagName
350
+ ) {
351
+ toCollapse.push(el);
352
+ }
353
+ }
354
+
355
+ for (const el of toCollapse) {
356
+ const inner = /** @type {Element} */ (el.firstChild);
357
+ // Replace outer with inner's children
358
+ while (inner.firstChild) {
359
+ el.insertBefore(inner.firstChild, inner);
360
+ }
361
+ el.removeChild(inner);
362
+ changed = true;
363
+ }
364
+ return changed;
365
+ }
366
+
367
+ /**
368
+ * Remove empty inline elements.
369
+ *
370
+ * @param {HTMLElement} root
371
+ * @returns {boolean}
372
+ */
373
+ function removeEmptyInlines(root) {
374
+ let changed = false;
375
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
376
+ /** @type {Element[]} */
377
+ const toRemove = [];
378
+
379
+ while (walker.nextNode()) {
380
+ const el = /** @type {Element} */ (walker.currentNode);
381
+ if (!FORMAT_TAGS.has(el.tagName.toLowerCase())) continue;
382
+ if (el.childNodes.length === 0 && !el.textContent) {
383
+ toRemove.push(el);
384
+ }
385
+ }
386
+
387
+ for (const el of toRemove) {
388
+ el.remove();
389
+ changed = true;
390
+ }
391
+ return changed;
392
+ }
393
+
394
+ /**
395
+ * Lift leading/trailing whitespace out of inline wrapper elements. E.g., <strong> text </strong> →
396
+ * " "<strong>text</strong>" "
397
+ *
398
+ * @param {HTMLElement} root
399
+ * @returns {boolean}
400
+ */
401
+ function liftEdgeWhitespace(root) {
402
+ let changed = false;
403
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
404
+ /** @type {{ type: string; el: Element; ws: string }[]} */
405
+ const ops = [];
406
+
407
+ while (walker.nextNode()) {
408
+ const el = /** @type {Element} */ (walker.currentNode);
409
+ if (!FORMAT_TAGS.has(el.tagName.toLowerCase())) continue;
410
+ if (el === root) continue;
411
+
412
+ const first = el.firstChild;
413
+ if (first && first.nodeType === Node.TEXT_NODE) {
414
+ const text = first.textContent ?? "";
415
+ const m = text.match(/^(\s+)/);
416
+ if (m && text.length > m[1].length) {
417
+ ops.push({ type: "lift-leading", el, ws: m[1] });
418
+ }
419
+ }
420
+
421
+ const last = el.lastChild;
422
+ if (last && last.nodeType === Node.TEXT_NODE && last !== first) {
423
+ const text = last.textContent ?? "";
424
+ const m = text.match(/(\s+)$/);
425
+ if (m && text.length > m[1].length) {
426
+ ops.push({ type: "lift-trailing", el, ws: m[1] });
427
+ }
428
+ }
429
+ }
430
+
431
+ for (const op of ops) {
432
+ if (op.type === "lift-leading") {
433
+ const firstChild = /** @type {Text} */ (op.el.firstChild);
434
+ firstChild.textContent = (firstChild.textContent ?? "").slice(op.ws.length);
435
+ op.el.parentNode?.insertBefore(document.createTextNode(op.ws), op.el);
436
+ changed = true;
437
+ } else if (op.type === "lift-trailing") {
438
+ const lastChild = /** @type {Text} */ (op.el.lastChild);
439
+ lastChild.textContent = (lastChild.textContent ?? "").slice(0, -op.ws.length);
440
+ op.el.parentNode?.insertBefore(document.createTextNode(op.ws), op.el.nextSibling);
441
+ changed = true;
442
+ }
443
+ }
444
+ return changed;
445
+ }
446
+
447
+ /**
448
+ * Unwrap bare <span> elements that have no class, style, or meaningful attributes. These are
449
+ * semantically empty wrappers left over from formatting operations.
450
+ *
451
+ * @param {HTMLElement} root
452
+ * @returns {boolean}
453
+ */
454
+ function unwrapBareSpans(root) {
455
+ let changed = false;
456
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
457
+ /** @type {Element[]} */
458
+ const toUnwrap = [];
459
+
460
+ while (walker.nextNode()) {
461
+ const el = /** @type {Element} */ (walker.currentNode);
462
+ if (el.tagName.toLowerCase() !== "span") continue;
463
+ if (el === root) continue;
464
+ // Keep spans with class, style, or any attributes
465
+ if (el.attributes.length > 0) continue;
466
+ toUnwrap.push(el);
467
+ }
468
+
469
+ for (const el of toUnwrap) {
470
+ unwrapElement(el);
471
+ changed = true;
472
+ }
473
+ return changed;
474
+ }
475
+
476
+ /**
477
+ * Check if two elements have matching attributes (for merge eligibility). For simple formatting
478
+ * tags, attributes don't matter. For <a>, href must match.
479
+ *
480
+ * @param {Element} a
481
+ * @param {Element} b
482
+ * @returns {boolean}
483
+ */
484
+ function attributesMatch(a, b) {
485
+ const tag = a.tagName.toLowerCase();
486
+ if (tag === "a") {
487
+ return a.getAttribute("href") === b.getAttribute("href");
488
+ }
489
+ // For simple format tags, always match
490
+ return true;
491
+ }
492
+
493
+ // ─── Template expression preservation ─────────────────────────────────────────
494
+
495
+ /**
496
+ * Expand a Range so that it fully includes any `${...}` template expressions that are partially
497
+ * selected. Template expressions are atomic in Jx — if split across inline elements, the template
498
+ * string breaks.
499
+ *
500
+ * Scans the text content of the start and end containers for `${...}` patterns and adjusts the
501
+ * range boundaries outward to include the complete expression.
502
+ *
503
+ * @param {Range} range
504
+ */
505
+ export function expandRangeToTemplateExpressions(range) {
506
+ expandBoundary(range, true); // start
507
+ expandBoundary(range, false); // end
508
+ }
509
+
510
+ /**
511
+ * Expand one boundary (start or end) of a range to avoid splitting a ${...}. `isStart` = true
512
+ * adjusts startContainer/startOffset, `isStart` = false adjusts endContainer/endOffset.
513
+ *
514
+ * @param {Range} range
515
+ * @param {boolean} isStart
516
+ */
517
+ function expandBoundary(range, isStart) {
518
+ const node = isStart ? range.startContainer : range.endContainer;
519
+ const offset = isStart ? range.startOffset : range.endOffset;
520
+ if (node.nodeType !== Node.TEXT_NODE) return;
521
+
522
+ const text = node.textContent ?? "";
523
+
524
+ // Find all ${...} expression spans in this text node (supporting nested braces)
525
+ const exprs = findTemplateExpressions(text);
526
+ for (const expr of exprs) {
527
+ // expr = { start, end } — character indices of "$" and the closing "}" + 1
528
+ if (isStart) {
529
+ // If the range starts inside this expression, move it to include the whole expr
530
+ if (offset > expr.start && offset < expr.end) {
531
+ range.setStart(node, expr.start);
532
+ }
533
+ } else {
534
+ // If the range ends inside this expression, expand to include the whole expr
535
+ if (offset > expr.start && offset < expr.end) {
536
+ range.setEnd(node, expr.end);
537
+ }
538
+ }
539
+ }
540
+ }
541
+
542
+ /**
543
+ * Find all `${...}` expression spans in a string, handling nested braces. Returns array of { start,
544
+ * end } where start is the index of '$' and end is one past the closing '}'.
545
+ *
546
+ * @param {string} text
547
+ * @returns {{ start: number; end: number }[]}
548
+ */
549
+ export function findTemplateExpressions(text) {
550
+ /** @type {{ start: number; end: number }[]} */
551
+ const results = [];
552
+ let i = 0;
553
+ while (i < text.length - 1) {
554
+ if (text[i] === "$" && text[i + 1] === "{") {
555
+ const start = i;
556
+ let depth = 1;
557
+ let j = i + 2;
558
+ while (j < text.length && depth > 0) {
559
+ if (text[j] === "{") depth++;
560
+ else if (text[j] === "}") depth--;
561
+ j++;
562
+ }
563
+ if (depth === 0) {
564
+ results.push({ start, end: j });
565
+ i = j;
566
+ continue;
567
+ }
568
+ }
569
+ i++;
570
+ }
571
+ return results;
572
+ }