@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,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
+ }