@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.
- package/dist/studio.css +3676 -0
- package/dist/studio.js +188743 -0
- package/dist/studio.js.map +1448 -0
- package/package.json +67 -0
- package/src/editor/context-menu.js +144 -0
- package/src/editor/inline-edit.js +597 -0
- package/src/editor/inline-format.js +572 -0
- package/src/editor/shortcuts.js +275 -0
- package/src/editor/slash-menu.js +167 -0
- package/src/files/components.js +40 -0
- package/src/files/file-ops.js +195 -0
- package/src/files/files.js +569 -0
- package/src/markdown/md-allowlist.js +101 -0
- package/src/markdown/md-convert.js +491 -0
- package/src/panels/activity-bar.js +69 -0
- package/src/panels/data-explorer.js +181 -0
- package/src/panels/events-panel.js +235 -0
- package/src/panels/imports-panel.js +427 -0
- package/src/panels/signals-panel.js +1093 -0
- package/src/panels/statusbar.js +56 -0
- package/src/platform.js +31 -0
- package/src/platforms/devserver.js +293 -0
- package/src/services/cem-export.js +130 -0
- package/src/services/code-services.js +98 -0
- package/src/site-context.js +122 -0
- package/src/state.js +744 -0
- package/src/store.js +332 -0
- package/src/studio.js +7692 -0
- package/src/ui/icons.js +83 -0
- package/src/ui/jx-styled-combobox.js +142 -0
- package/src/ui/spectrum.js +238 -0
- package/src/utils/studio-utils.js +185 -0
|
@@ -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
|
+
}
|