@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,597 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inline-edit.js — Contenteditable inline editing for content mode
|
|
3
|
+
*
|
|
4
|
+
* Manages the lifecycle of editing text-bearing block elements directly on the canvas. Handles rich
|
|
5
|
+
* text formatting, Enter for new paragraphs, and slash commands for inserting elements.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import elementsMeta from "../../data/elements-meta.json";
|
|
9
|
+
import { toggleInlineFormat, normalizeInlineContent } from "./inline-format.js";
|
|
10
|
+
import {
|
|
11
|
+
showSlashMenu as sharedShowSlashMenu,
|
|
12
|
+
dismissSlashMenu as sharedDismissSlashMenu,
|
|
13
|
+
isSlashMenuOpen,
|
|
14
|
+
} from "./slash-menu.js";
|
|
15
|
+
|
|
16
|
+
// ─── Inline tag set (tags that represent rich text formatting) ─────────────
|
|
17
|
+
|
|
18
|
+
/** Fallback set — used when parent context is unknown */
|
|
19
|
+
const INLINE_TAGS = new Set([
|
|
20
|
+
"em",
|
|
21
|
+
"strong",
|
|
22
|
+
"del",
|
|
23
|
+
"code",
|
|
24
|
+
"a",
|
|
25
|
+
"span",
|
|
26
|
+
"br",
|
|
27
|
+
"img",
|
|
28
|
+
"b",
|
|
29
|
+
"i",
|
|
30
|
+
"u",
|
|
31
|
+
"sub",
|
|
32
|
+
"sup",
|
|
33
|
+
"s",
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
/** Tags that can be edited inline (text-bearing block elements) */
|
|
37
|
+
const EDITABLE_BLOCKS = new Set([
|
|
38
|
+
"h1",
|
|
39
|
+
"h2",
|
|
40
|
+
"h3",
|
|
41
|
+
"h4",
|
|
42
|
+
"h5",
|
|
43
|
+
"h6",
|
|
44
|
+
"p",
|
|
45
|
+
"li",
|
|
46
|
+
"td",
|
|
47
|
+
"th",
|
|
48
|
+
"blockquote",
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
// ─── Context-aware inline scoping ─────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Check if a child tag is inline within the context of a given parent tag. Uses $inlineChildren
|
|
55
|
+
* from elements-meta.json.
|
|
56
|
+
*
|
|
57
|
+
* @param {string} childTag
|
|
58
|
+
* @param {string} parentTag
|
|
59
|
+
* @returns {boolean}
|
|
60
|
+
*/
|
|
61
|
+
export function isInlineInContext(childTag, parentTag) {
|
|
62
|
+
if (!parentTag) return INLINE_TAGS.has(childTag);
|
|
63
|
+
const parentDef = /** @type {Record<string, any>} */ (elementsMeta.$defs)[parentTag];
|
|
64
|
+
if (!parentDef || !parentDef.$inlineChildren) return false;
|
|
65
|
+
return parentDef.$inlineChildren.includes(childTag);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Get the resolved $inlineActions for a given element tag. Follows string references (e.g., "h1" →
|
|
70
|
+
* look up h1's actions).
|
|
71
|
+
*
|
|
72
|
+
* @param {string} tag
|
|
73
|
+
* @returns {any[] | null}
|
|
74
|
+
*/
|
|
75
|
+
export function getInlineActions(tag) {
|
|
76
|
+
const def = /** @type {Record<string, any>} */ (elementsMeta.$defs)[tag];
|
|
77
|
+
if (!def) return null;
|
|
78
|
+
let actions = def.$inlineActions;
|
|
79
|
+
if (typeof actions === "string") {
|
|
80
|
+
const refDef = /** @type {Record<string, any>} */ (elementsMeta.$defs)[actions];
|
|
81
|
+
actions = refDef?.$inlineActions ?? null;
|
|
82
|
+
}
|
|
83
|
+
if (!Array.isArray(actions)) return null;
|
|
84
|
+
return actions;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ─── Editing state ─────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
/** @type {HTMLElement | null} */
|
|
90
|
+
let activeEl = null; // currently contenteditable element
|
|
91
|
+
/** @type {any[] | null} */
|
|
92
|
+
let activePath = null; // JSON path to the active element
|
|
93
|
+
/** @type {((path: any[], children: any, textContent: any) => void) | null} */
|
|
94
|
+
let commitFn = null; // function(path, newChildren, newTextContent) to commit changes
|
|
95
|
+
/** @type {((path: any[], beforeChildren: any, afterChildren: any) => void) | null} */
|
|
96
|
+
let splitFn = null; // function(path, beforeChildren, afterChildren) to split paragraph
|
|
97
|
+
/** @type {((path: any[], elementDef: any) => void) | null} */
|
|
98
|
+
let insertFn = null; // function(path, elementDef) to insert after current block
|
|
99
|
+
/** @type {(() => void) | null} */
|
|
100
|
+
let endFn = null; // function() called when editing stops
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Check if an element is a text-bearing editable block.
|
|
104
|
+
*
|
|
105
|
+
* @param {HTMLElement} el
|
|
106
|
+
* @returns {boolean}
|
|
107
|
+
*/
|
|
108
|
+
export function isEditableBlock(el) {
|
|
109
|
+
return EDITABLE_BLOCKS.has(el.tagName.toLowerCase());
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Check if a node is an inline child. When parentNode is provided, uses context-aware scoping from
|
|
114
|
+
* metadata. Without parent, uses the fallback INLINE_TAGS set.
|
|
115
|
+
*
|
|
116
|
+
* @param {any} node
|
|
117
|
+
* @param {any} [parentNode]
|
|
118
|
+
* @returns {boolean}
|
|
119
|
+
*/
|
|
120
|
+
export function isInlineElement(node, parentNode) {
|
|
121
|
+
if (!node || typeof node !== "object") return false;
|
|
122
|
+
const childTag = (node.tagName ?? "div").toLowerCase();
|
|
123
|
+
if (parentNode) {
|
|
124
|
+
const parentTag = (parentNode.tagName ?? "div").toLowerCase();
|
|
125
|
+
return isInlineInContext(childTag, parentTag);
|
|
126
|
+
}
|
|
127
|
+
return INLINE_TAGS.has(childTag);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Start inline editing on a canvas element.
|
|
132
|
+
*
|
|
133
|
+
* @param {HTMLElement} el - The canvas DOM element to edit
|
|
134
|
+
* @param {any[]} path - JSON path to the element
|
|
135
|
+
* @param {Record<string, any>} callbacks - { onCommit, onSplit, onInsert, onEnd } onCommit(path,
|
|
136
|
+
* children|null, textContent|null) — save inline content onSplit(path, beforeChildren,
|
|
137
|
+
* afterChildren) — Enter key: split block onInsert(path, elementDef) — slash command: insert
|
|
138
|
+
* after onEnd() — called when editing stops (for overlay restoration)
|
|
139
|
+
*/
|
|
140
|
+
export function startEditing(el, path, callbacks) {
|
|
141
|
+
if (activeEl) stopEditing();
|
|
142
|
+
|
|
143
|
+
activeEl = el;
|
|
144
|
+
activePath = path;
|
|
145
|
+
commitFn = callbacks.onCommit;
|
|
146
|
+
splitFn = callbacks.onSplit;
|
|
147
|
+
insertFn = callbacks.onInsert;
|
|
148
|
+
endFn = callbacks.onEnd;
|
|
149
|
+
|
|
150
|
+
// Enable editing
|
|
151
|
+
el.contentEditable = "true";
|
|
152
|
+
el.style.pointerEvents = "auto";
|
|
153
|
+
el.style.outline = "2px solid var(--accent, #4a9eff)";
|
|
154
|
+
el.style.outlineOffset = "1px";
|
|
155
|
+
el.style.cursor = "text";
|
|
156
|
+
el.focus();
|
|
157
|
+
|
|
158
|
+
// Place cursor at end
|
|
159
|
+
const sel = window.getSelection();
|
|
160
|
+
const range = document.createRange();
|
|
161
|
+
range.selectNodeContents(el);
|
|
162
|
+
range.collapse(false);
|
|
163
|
+
if (sel) {
|
|
164
|
+
sel.removeAllRanges();
|
|
165
|
+
sel.addRange(range);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
el.addEventListener("keydown", handleKeydown);
|
|
169
|
+
el.addEventListener("input", handleInput);
|
|
170
|
+
el.addEventListener("blur", handleBlur);
|
|
171
|
+
el.addEventListener("paste", handlePaste);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Stop editing and commit changes. */
|
|
175
|
+
export function stopEditing() {
|
|
176
|
+
if (!activeEl) return;
|
|
177
|
+
|
|
178
|
+
commitChanges();
|
|
179
|
+
sharedDismissSlashMenu();
|
|
180
|
+
|
|
181
|
+
activeEl.contentEditable = "false";
|
|
182
|
+
activeEl.style.pointerEvents = "";
|
|
183
|
+
activeEl.style.outline = "";
|
|
184
|
+
activeEl.style.outlineOffset = "";
|
|
185
|
+
activeEl.style.cursor = "";
|
|
186
|
+
|
|
187
|
+
activeEl.removeEventListener("keydown", handleKeydown);
|
|
188
|
+
activeEl.removeEventListener("input", handleInput);
|
|
189
|
+
activeEl.removeEventListener("blur", handleBlur);
|
|
190
|
+
activeEl.removeEventListener("paste", handlePaste);
|
|
191
|
+
|
|
192
|
+
activeEl = null;
|
|
193
|
+
activePath = null;
|
|
194
|
+
commitFn = null;
|
|
195
|
+
splitFn = null;
|
|
196
|
+
insertFn = null;
|
|
197
|
+
|
|
198
|
+
if (endFn) {
|
|
199
|
+
const fn = endFn;
|
|
200
|
+
endFn = null;
|
|
201
|
+
fn();
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Whether inline editing is currently active.
|
|
207
|
+
*
|
|
208
|
+
* @returns {boolean}
|
|
209
|
+
*/
|
|
210
|
+
export function isEditing() {
|
|
211
|
+
return activeEl !== null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Get the currently editing element.
|
|
216
|
+
*
|
|
217
|
+
* @returns {HTMLElement | null}
|
|
218
|
+
*/
|
|
219
|
+
export function getActiveElement() {
|
|
220
|
+
return activeEl;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ─── Event handlers ────────────────────────────────────────────────────────
|
|
224
|
+
|
|
225
|
+
/** @param {KeyboardEvent} e */
|
|
226
|
+
function handleKeydown(e) {
|
|
227
|
+
if (e.key === "Escape") {
|
|
228
|
+
e.preventDefault();
|
|
229
|
+
e.stopPropagation();
|
|
230
|
+
stopEditing();
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
235
|
+
if (isSlashMenuOpen()) return; // shared slash menu captures Enter
|
|
236
|
+
e.preventDefault();
|
|
237
|
+
e.stopPropagation();
|
|
238
|
+
handleEnterKey();
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Slash command trigger
|
|
243
|
+
if (e.key === "/" && !e.ctrlKey && !e.metaKey) {
|
|
244
|
+
// Check if at start of empty block or after a space/newline
|
|
245
|
+
const sel = window.getSelection();
|
|
246
|
+
if (sel && sel.rangeCount > 0) {
|
|
247
|
+
const range = sel.getRangeAt(0);
|
|
248
|
+
const textBefore = getTextBeforeCursor(range);
|
|
249
|
+
if (textBefore === "" || textBefore.endsWith(" ") || textBefore.endsWith("\n")) {
|
|
250
|
+
// Let the / character be typed, then show menu on next input
|
|
251
|
+
requestAnimationFrame(() => openSlashMenu());
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Rich text shortcuts
|
|
258
|
+
if (e.ctrlKey || e.metaKey) {
|
|
259
|
+
switch (e.key) {
|
|
260
|
+
case "b":
|
|
261
|
+
e.preventDefault();
|
|
262
|
+
toggleInlineFormat("strong", activeEl);
|
|
263
|
+
break;
|
|
264
|
+
case "i":
|
|
265
|
+
e.preventDefault();
|
|
266
|
+
toggleInlineFormat("em", activeEl);
|
|
267
|
+
break;
|
|
268
|
+
case "`":
|
|
269
|
+
e.preventDefault();
|
|
270
|
+
toggleInlineFormat("code", activeEl);
|
|
271
|
+
break;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Dismiss slash menu on non-matching keys
|
|
276
|
+
if (
|
|
277
|
+
isSlashMenuOpen() &&
|
|
278
|
+
!["ArrowUp", "ArrowDown", "Enter", "Backspace", "Delete"].includes(e.key)
|
|
279
|
+
) {
|
|
280
|
+
// Let the input handler deal with filtering
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function handleInput() {
|
|
285
|
+
// Check if slash menu should update or dismiss
|
|
286
|
+
if (isSlashMenuOpen()) {
|
|
287
|
+
updateSlashMenu();
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** @param {FocusEvent} _e */
|
|
292
|
+
function handleBlur(_e) {
|
|
293
|
+
// Don't close if focus moved to slash menu
|
|
294
|
+
if (isSlashMenuOpen()) return;
|
|
295
|
+
|
|
296
|
+
// Delay to allow click events to fire
|
|
297
|
+
setTimeout(() => {
|
|
298
|
+
if (activeEl && document.activeElement !== activeEl) {
|
|
299
|
+
stopEditing();
|
|
300
|
+
}
|
|
301
|
+
}, 150);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** @param {ClipboardEvent} e */
|
|
305
|
+
function handlePaste(e) {
|
|
306
|
+
e.preventDefault();
|
|
307
|
+
// Paste as plain text to avoid foreign HTML
|
|
308
|
+
const text = e.clipboardData?.getData("text/plain") ?? "";
|
|
309
|
+
document.execCommand("insertText", false, text);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ─── Enter key: split paragraph ────────────────────────────────────────────
|
|
313
|
+
|
|
314
|
+
function handleEnterKey() {
|
|
315
|
+
if (!splitFn || !activeEl || !activePath) return;
|
|
316
|
+
|
|
317
|
+
const sel = window.getSelection();
|
|
318
|
+
if (!sel || !sel.rangeCount) return;
|
|
319
|
+
|
|
320
|
+
const range = sel.getRangeAt(0);
|
|
321
|
+
|
|
322
|
+
// Create two ranges: before cursor and after cursor
|
|
323
|
+
const beforeRange = document.createRange();
|
|
324
|
+
beforeRange.setStart(activeEl, 0);
|
|
325
|
+
beforeRange.setEnd(range.startContainer, range.startOffset);
|
|
326
|
+
|
|
327
|
+
const afterRange = document.createRange();
|
|
328
|
+
afterRange.setStart(range.endContainer, range.endOffset);
|
|
329
|
+
afterRange.setEnd(activeEl, activeEl.childNodes.length);
|
|
330
|
+
|
|
331
|
+
// Extract content from both ranges
|
|
332
|
+
const beforeFrag = beforeRange.cloneContents();
|
|
333
|
+
const afterFrag = afterRange.cloneContents();
|
|
334
|
+
|
|
335
|
+
const beforeChildren = fragmentToJx(beforeFrag);
|
|
336
|
+
const afterChildren = fragmentToJx(afterFrag);
|
|
337
|
+
|
|
338
|
+
// Stop editing before mutating state (which will re-render)
|
|
339
|
+
const path = [...activePath];
|
|
340
|
+
activeEl.contentEditable = "false";
|
|
341
|
+
activeEl.removeEventListener("keydown", handleKeydown);
|
|
342
|
+
activeEl.removeEventListener("input", handleInput);
|
|
343
|
+
activeEl.removeEventListener("blur", handleBlur);
|
|
344
|
+
activeEl.removeEventListener("paste", handlePaste);
|
|
345
|
+
activeEl = null;
|
|
346
|
+
|
|
347
|
+
splitFn(path, beforeChildren, afterChildren);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ─── Content sync: DOM → Jx ────────────────────────────────────────────
|
|
351
|
+
|
|
352
|
+
function commitChanges() {
|
|
353
|
+
if (!commitFn || !activeEl || !activePath) return;
|
|
354
|
+
|
|
355
|
+
normalizeInlineContent(activeEl);
|
|
356
|
+
const result = elementToJx(activeEl);
|
|
357
|
+
commitFn(activePath, result.children ?? null, result.textContent ?? null);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Normalize a node's children array: merge adjacent text nodes and fold all-text children into
|
|
362
|
+
* textContent. Returns `{ textContent }` or `{ children }`.
|
|
363
|
+
*
|
|
364
|
+
* @param {{ children?: any[] }} node
|
|
365
|
+
* @returns {{ textContent?: string | null; children?: any[] }}
|
|
366
|
+
*/
|
|
367
|
+
export function normalizeChildren(node) {
|
|
368
|
+
if (!Array.isArray(node.children) || node.children.length === 0) return { textContent: "" };
|
|
369
|
+
|
|
370
|
+
// Step 1: Merge adjacent text nodes
|
|
371
|
+
const merged = [];
|
|
372
|
+
for (const child of node.children) {
|
|
373
|
+
if (
|
|
374
|
+
typeof child === "string" &&
|
|
375
|
+
merged.length > 0 &&
|
|
376
|
+
typeof merged[merged.length - 1] === "string"
|
|
377
|
+
) {
|
|
378
|
+
merged[merged.length - 1] += child;
|
|
379
|
+
} else {
|
|
380
|
+
merged.push(child);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Step 2: If all children are text, fold into textContent
|
|
385
|
+
if (merged.every((/** @type {any} */ c) => typeof c === "string")) {
|
|
386
|
+
return { textContent: merged.join("") };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return { children: merged };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Convert a contenteditable element's content to Jx children/textContent. Returns { textContent }
|
|
394
|
+
* for plain text or { children } for rich content.
|
|
395
|
+
*
|
|
396
|
+
* @param {HTMLElement} el
|
|
397
|
+
* @returns {{ textContent?: string | null; children?: any[] }}
|
|
398
|
+
*/
|
|
399
|
+
function elementToJx(el) {
|
|
400
|
+
const nodes = el.childNodes;
|
|
401
|
+
|
|
402
|
+
// If just a single text node, use textContent
|
|
403
|
+
if (nodes.length === 0) return { textContent: "" };
|
|
404
|
+
if (nodes.length === 1 && nodes[0].nodeType === Node.TEXT_NODE) {
|
|
405
|
+
return { textContent: nodes[0].textContent };
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Mixed content → children array
|
|
409
|
+
/** @type {any[]} */
|
|
410
|
+
const children = [];
|
|
411
|
+
for (const child of nodes) {
|
|
412
|
+
const jsx = domNodeToJx(child);
|
|
413
|
+
if (jsx !== null && jsx !== undefined) children.push(jsx);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Normalize: merge adjacent text nodes + fold all-text to textContent
|
|
417
|
+
return normalizeChildren({ children });
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Convert a DOM node to a Jx element definition.
|
|
422
|
+
*
|
|
423
|
+
* @param {Node} node
|
|
424
|
+
* @returns {any}
|
|
425
|
+
*/
|
|
426
|
+
function domNodeToJx(node) {
|
|
427
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
428
|
+
const text = node.textContent;
|
|
429
|
+
if (!text) return null;
|
|
430
|
+
return text; // Bare string — text node child
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (node.nodeType !== Node.ELEMENT_NODE) return null;
|
|
434
|
+
|
|
435
|
+
const el = /** @type {HTMLElement} */ (node);
|
|
436
|
+
const tag = el.tagName.toLowerCase();
|
|
437
|
+
/** @type {Record<string, any>} */
|
|
438
|
+
const result = { tagName: tag };
|
|
439
|
+
|
|
440
|
+
// Map browser execCommand output to our tag conventions
|
|
441
|
+
/** @type {Record<string, string>} */
|
|
442
|
+
const tagMap = { b: "strong", i: "em", s: "del", strike: "del" };
|
|
443
|
+
if (tagMap[tag]) result.tagName = tagMap[tag];
|
|
444
|
+
|
|
445
|
+
// Attributes
|
|
446
|
+
if (tag === "a" && /** @type {HTMLAnchorElement} */ (el).href) {
|
|
447
|
+
result.attributes = { href: el.getAttribute("href") };
|
|
448
|
+
if (/** @type {HTMLAnchorElement} */ (el).title)
|
|
449
|
+
result.attributes.title = /** @type {HTMLAnchorElement} */ (el).title;
|
|
450
|
+
}
|
|
451
|
+
if (tag === "code") {
|
|
452
|
+
result.textContent = el.textContent;
|
|
453
|
+
return result;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Recurse children
|
|
457
|
+
const childNodes = el.childNodes;
|
|
458
|
+
if (childNodes.length === 0) {
|
|
459
|
+
result.textContent = "";
|
|
460
|
+
} else if (childNodes.length === 1 && childNodes[0].nodeType === Node.TEXT_NODE) {
|
|
461
|
+
result.textContent = childNodes[0].textContent;
|
|
462
|
+
} else {
|
|
463
|
+
result.children = [];
|
|
464
|
+
for (const child of childNodes) {
|
|
465
|
+
const jsx = domNodeToJx(child);
|
|
466
|
+
if (jsx) result.children.push(jsx);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
return result;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Convert a DocumentFragment to a Jx-compatible structure. Returns { textContent } or { children }.
|
|
475
|
+
*
|
|
476
|
+
* @param {DocumentFragment} frag
|
|
477
|
+
* @returns {{ textContent?: string | null; children?: any[] }}
|
|
478
|
+
*/
|
|
479
|
+
function fragmentToJx(frag) {
|
|
480
|
+
const nodes = frag.childNodes;
|
|
481
|
+
if (nodes.length === 0) return { textContent: "" };
|
|
482
|
+
if (nodes.length === 1 && nodes[0].nodeType === Node.TEXT_NODE) {
|
|
483
|
+
return { textContent: nodes[0].textContent };
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** @type {any[]} */
|
|
487
|
+
const children = [];
|
|
488
|
+
for (const child of nodes) {
|
|
489
|
+
const jsx = domNodeToJx(child);
|
|
490
|
+
if (jsx) children.push(jsx);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
if (children.length === 1 && children[0].tagName === "span" && children[0].textContent != null) {
|
|
494
|
+
return { textContent: children[0].textContent };
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
return children.length > 0 ? { children } : { textContent: "" };
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// ─── Rich text helpers ─────────────────────────────────────────────────────
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* @param {Range} range
|
|
504
|
+
* @returns {string}
|
|
505
|
+
*/
|
|
506
|
+
function getTextBeforeCursor(range) {
|
|
507
|
+
const preRange = document.createRange();
|
|
508
|
+
preRange.setStart(/** @type {Node} */ (activeEl), 0);
|
|
509
|
+
preRange.setEnd(range.startContainer, range.startOffset);
|
|
510
|
+
return preRange.toString();
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// ─── Slash command menu (delegates to shared slash-menu.js) ──────────────
|
|
514
|
+
|
|
515
|
+
/** Track the character offset where "/" was typed so we can detect backspace-past-slash */
|
|
516
|
+
let _slashFilterStart = 0;
|
|
517
|
+
|
|
518
|
+
function openSlashMenu() {
|
|
519
|
+
if (!activeEl || !insertFn || !activePath) return;
|
|
520
|
+
|
|
521
|
+
const sel = window.getSelection();
|
|
522
|
+
if (!sel || !sel.rangeCount) return;
|
|
523
|
+
const range = sel.getRangeAt(0);
|
|
524
|
+
_slashFilterStart = getTextBeforeCursor(range).length;
|
|
525
|
+
|
|
526
|
+
sharedShowSlashMenu(activeEl, "", { onSelect: handleSlashSelect });
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function updateSlashMenu() {
|
|
530
|
+
if (!activeEl) return;
|
|
531
|
+
|
|
532
|
+
const sel = window.getSelection();
|
|
533
|
+
if (!sel || !sel.rangeCount) {
|
|
534
|
+
sharedDismissSlashMenu();
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const range = sel.getRangeAt(0);
|
|
539
|
+
const fullText = getTextBeforeCursor(range);
|
|
540
|
+
const slashIdx = fullText.lastIndexOf("/");
|
|
541
|
+
|
|
542
|
+
if (slashIdx < 0 || fullText.length < _slashFilterStart - 1) {
|
|
543
|
+
sharedDismissSlashMenu();
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const filter = fullText.slice(slashIdx + 1).toLowerCase();
|
|
548
|
+
sharedShowSlashMenu(activeEl, filter, { onSelect: handleSlashSelect });
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/** @param {any} cmd */
|
|
552
|
+
function handleSlashSelect(cmd) {
|
|
553
|
+
if (!activeEl || !insertFn || !activePath) return;
|
|
554
|
+
|
|
555
|
+
// Remove the /command text from the element
|
|
556
|
+
const sel = window.getSelection();
|
|
557
|
+
if (sel && sel.rangeCount) {
|
|
558
|
+
const range = sel.getRangeAt(0);
|
|
559
|
+
const fullText = getTextBeforeCursor(range);
|
|
560
|
+
const slashIdx = fullText.lastIndexOf("/");
|
|
561
|
+
if (slashIdx >= 0) {
|
|
562
|
+
const walker = document.createTreeWalker(activeEl, NodeFilter.SHOW_TEXT);
|
|
563
|
+
let charCount = 0;
|
|
564
|
+
/** @type {Text | null} */
|
|
565
|
+
let slashNode = null;
|
|
566
|
+
let slashOffset = 0;
|
|
567
|
+
while (walker.nextNode()) {
|
|
568
|
+
const node = /** @type {Text} */ (walker.currentNode);
|
|
569
|
+
if (charCount + node.length > slashIdx) {
|
|
570
|
+
slashNode = node;
|
|
571
|
+
slashOffset = slashIdx - charCount;
|
|
572
|
+
break;
|
|
573
|
+
}
|
|
574
|
+
charCount += node.length;
|
|
575
|
+
}
|
|
576
|
+
if (slashNode) {
|
|
577
|
+
const delRange = document.createRange();
|
|
578
|
+
delRange.setStart(slashNode, slashOffset);
|
|
579
|
+
delRange.setEnd(range.startContainer, range.startOffset);
|
|
580
|
+
delRange.deleteContents();
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
commitChanges();
|
|
586
|
+
|
|
587
|
+
const path = [...activePath];
|
|
588
|
+
activeEl.contentEditable = "false";
|
|
589
|
+
activeEl.removeEventListener("keydown", handleKeydown);
|
|
590
|
+
activeEl.removeEventListener("input", handleInput);
|
|
591
|
+
activeEl.removeEventListener("blur", handleBlur);
|
|
592
|
+
activeEl.removeEventListener("paste", handlePaste);
|
|
593
|
+
activeEl = null;
|
|
594
|
+
|
|
595
|
+
// Delegate to studio.js callback which builds the element def and inserts it
|
|
596
|
+
insertFn(path, cmd);
|
|
597
|
+
}
|