@jxsuite/studio 0.10.2 → 0.13.0

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 (53) hide show
  1. package/README.md +82 -0
  2. package/dist/studio.js +5114 -3424
  3. package/dist/studio.js.map +65 -49
  4. package/package.json +6 -3
  5. package/src/browse/browse.js +27 -3
  6. package/src/canvas/canvas-diff.js +184 -0
  7. package/src/canvas/canvas-helpers.js +10 -14
  8. package/src/canvas/canvas-live-render.js +136 -17
  9. package/src/canvas/canvas-render.js +154 -21
  10. package/src/canvas/canvas-utils.js +21 -23
  11. package/src/editor/component-inline-edit.js +54 -41
  12. package/src/editor/content-inline-edit.js +46 -47
  13. package/src/editor/context-menu.js +63 -39
  14. package/src/editor/convert-to-component.js +11 -14
  15. package/src/editor/insertion-helper.js +8 -10
  16. package/src/editor/shortcuts.js +69 -39
  17. package/src/files/components.js +15 -4
  18. package/src/files/files.js +72 -24
  19. package/src/panels/activity-bar.js +29 -7
  20. package/src/panels/block-action-bar.js +104 -80
  21. package/src/panels/canvas-dnd.js +132 -50
  22. package/src/panels/dnd.js +32 -28
  23. package/src/panels/editors.js +7 -14
  24. package/src/panels/elements-panel.js +9 -3
  25. package/src/panels/events-panel.js +44 -39
  26. package/src/panels/git-panel.js +45 -3
  27. package/src/panels/layers-panel.js +16 -11
  28. package/src/panels/left-panel.js +108 -43
  29. package/src/panels/overlays.js +97 -35
  30. package/src/panels/panel-events.js +18 -11
  31. package/src/panels/properties-panel.js +467 -98
  32. package/src/panels/right-panel.js +85 -37
  33. package/src/panels/shared.js +0 -22
  34. package/src/panels/signals-panel.js +125 -54
  35. package/src/panels/statusbar.js +23 -8
  36. package/src/panels/style-inputs.js +4 -2
  37. package/src/panels/style-panel.js +128 -105
  38. package/src/panels/stylebook-panel.js +42 -17
  39. package/src/panels/tab-strip.js +124 -0
  40. package/src/panels/toolbar.js +39 -9
  41. package/src/reactivity.js +28 -0
  42. package/src/settings/content-types-editor.js +78 -8
  43. package/src/settings/defs-editor.js +56 -7
  44. package/src/settings/schema-field-ui.js +99 -11
  45. package/src/site-context.js +105 -0
  46. package/src/state.js +0 -456
  47. package/src/store.js +105 -124
  48. package/src/studio.js +112 -121
  49. package/src/tabs/tab.js +153 -0
  50. package/src/tabs/transact.js +406 -0
  51. package/src/ui/spectrum.js +2 -0
  52. package/src/view.js +3 -0
  53. package/src/workspace/workspace.js +89 -0
@@ -6,18 +6,9 @@
6
6
  import { html, render as litRender, nothing } from "lit-html";
7
7
  import { draggable } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
8
8
 
9
- import {
10
- getState,
11
- update,
12
- updateSession,
13
- renderOnly,
14
- selectNode,
15
- moveNode,
16
- getNodeAtPath,
17
- nodeLabel,
18
- parentElementPath,
19
- childIndex,
20
- } from "../store.js";
9
+ import { getNodeAtPath, nodeLabel, parentElementPath, childIndex } from "../store.js";
10
+ import { activeTab } from "../workspace/workspace.js";
11
+ import { transactDoc, mutateMoveNode } from "../tabs/transact.js";
21
12
  import { view } from "../view.js";
22
13
  import { isEditing, getActiveElement, getInlineActions } from "../editor/inline-edit.js";
23
14
  import { toggleInlineFormat, isTagActiveInSelection } from "../editor/inline-format.js";
@@ -25,7 +16,21 @@ import { componentRegistry } from "../files/components.js";
25
16
  import { convertToComponent } from "../editor/convert-to-component.js";
26
17
  import { findCanvasElement, getActivePanel } from "../canvas/canvas-helpers.js";
27
18
 
28
- /** @type {any} */
19
+ /**
20
+ * @typedef {import("../state.js").StudioState} StudioState
21
+ *
22
+ * @typedef {import("../state.js").JxPath} JxPath
23
+ *
24
+ * @typedef {{ command: string; tag: string; label: string; icon: string; shortcut?: string }} InlineAction
25
+ */
26
+
27
+ /**
28
+ * @type {{
29
+ * getCanvasMode: () => string;
30
+ * navigateToComponent: (path: string) => void;
31
+ * createFloatingContainer: () => HTMLElement;
32
+ * } | null}
33
+ */
29
34
  let _ctx = null;
30
35
 
31
36
  /**
@@ -33,8 +38,8 @@ let _ctx = null;
33
38
  *
34
39
  * @param {{
35
40
  * getCanvasMode: () => string;
36
- * navigateToComponent: Function;
37
- * createFloatingContainer: Function;
41
+ * navigateToComponent: (path: string) => void;
42
+ * createFloatingContainer: () => HTMLElement;
38
43
  * }} ctx
39
44
  */
40
45
  export function initBlockActionBar(ctx) {
@@ -45,7 +50,7 @@ export function initBlockActionBar(ctx) {
45
50
  }
46
51
 
47
52
  /** Pre-built icon templates for inline format buttons (avoids unsafeStatic) */
48
- const formatIconMap = /** @type {Record<string, any>} */ ({
53
+ const formatIconMap = /** @type {Record<string, import("lit-html").TemplateResult>} */ ({
49
54
  "sp-icon-text-bold": html`<sp-icon-text-bold slot="icon"></sp-icon-text-bold>`,
50
55
  "sp-icon-text-italic": html`<sp-icon-text-italic slot="icon"></sp-icon-text-italic>`,
51
56
  "sp-icon-text-underline": html`<sp-icon-text-underline slot="icon"></sp-icon-text-underline>`,
@@ -63,11 +68,11 @@ const formatIconMap = /** @type {Record<string, any>} */ ({
63
68
  /**
64
69
  * Prevent the bar from stealing focus from contenteditable
65
70
  *
66
- * @param {any} e
71
+ * @param {MouseEvent} e
67
72
  */
68
73
  function onBarMousedown(e) {
69
- if (e.target.closest("sp-textfield")) return;
70
- if (e.target.closest(".bar-drag-handle")) return;
74
+ if (/** @type {HTMLElement} */ (e.target).closest("sp-textfield")) return;
75
+ if (/** @type {HTMLElement} */ (e.target).closest(".bar-drag-handle")) return;
71
76
  e.preventDefault();
72
77
  }
73
78
 
@@ -78,42 +83,48 @@ function captureSelectionRange() {
78
83
  }
79
84
 
80
85
  /**
81
- * @param {any} e
82
- * @param {any} action
86
+ * @param {MouseEvent} e
87
+ * @param {InlineAction} action
83
88
  */
84
89
  function onFormatClick(e, action) {
85
90
  e.stopPropagation();
86
91
  if (action.command === "link") {
87
- showLinkPopover(e.target.closest("sp-action-button"));
92
+ showLinkPopover(
93
+ /** @type {HTMLElement} */ (
94
+ /** @type {HTMLElement} */ (e.target).closest("sp-action-button")
95
+ ),
96
+ );
88
97
  } else if (view.savedRange) {
89
- const sel = /** @type {any} */ (window.getSelection());
98
+ const sel = window.getSelection();
90
99
  const anchor = view.savedRange.startContainer;
91
100
  const editableRoot = (
92
- anchor?.nodeType === Node.ELEMENT_NODE ? anchor : anchor?.parentElement
101
+ anchor?.nodeType === Node.ELEMENT_NODE
102
+ ? /** @type {Element} */ (anchor)
103
+ : anchor?.parentElement
93
104
  )?.closest("[contenteditable]");
94
105
  if (editableRoot) {
95
- editableRoot.focus();
96
- sel.removeAllRanges();
97
- sel.addRange(view.savedRange);
106
+ /** @type {HTMLElement} */ (editableRoot).focus();
107
+ sel?.removeAllRanges();
108
+ sel?.addRange(view.savedRange);
98
109
  applyInlineFormat(action);
99
110
  }
100
111
  }
101
112
  }
102
113
 
103
114
  function renderParentSelector() {
104
- const S = getState();
105
- const pPath = parentElementPath(S.selection);
115
+ const tab = activeTab.value;
116
+ if (!tab?.session.selection) return nothing;
117
+ const pPath = parentElementPath(tab.session.selection);
106
118
  if (!pPath) return nothing;
107
- const parentNode = getNodeAtPath(S.document, pPath);
119
+ const parentNode = getNodeAtPath(tab.doc.document, pPath);
108
120
  return html`
109
121
  <sp-action-button
110
122
  size="xs"
111
123
  quiet
112
124
  title="Select parent: ${nodeLabel(parentNode)}"
113
- @click=${(/** @type {any} */ e) => {
125
+ @click=${(/** @type {MouseEvent} */ e) => {
114
126
  e.stopPropagation();
115
- const S = getState();
116
- update(selectNode(S, pPath));
127
+ activeTab.value.session.selection = pPath;
117
128
  }}
118
129
  >
119
130
  <sp-icon-back slot="icon"></sp-icon-back>
@@ -122,10 +133,12 @@ function renderParentSelector() {
122
133
  }
123
134
 
124
135
  function renderMoveArrows() {
125
- const S = getState();
126
- const idx = /** @type {number} */ (childIndex(S.selection));
127
- const pPath = parentElementPath(S.selection);
128
- const parentNode = getNodeAtPath(S.document, /** @type {any} */ (pPath));
136
+ const tab = activeTab.value;
137
+ if (!tab?.session.selection) return nothing;
138
+ const sel = tab.session.selection;
139
+ const idx = /** @type {number} */ (childIndex(sel));
140
+ const pPath = parentElementPath(sel);
141
+ const parentNode = getNodeAtPath(tab.doc.document, /** @type {JxPath} */ (pPath));
129
142
  const siblings = parentNode?.children;
130
143
  return html`
131
144
  <sp-action-button
@@ -133,7 +146,7 @@ function renderMoveArrows() {
133
146
  quiet
134
147
  title="Move up"
135
148
  ?disabled=${idx <= 0}
136
- @click=${(/** @type {any} */ e) => {
149
+ @click=${(/** @type {MouseEvent} */ e) => {
137
150
  e.stopPropagation();
138
151
  moveSelectionUp();
139
152
  }}
@@ -145,7 +158,7 @@ function renderMoveArrows() {
145
158
  quiet
146
159
  title="Move down"
147
160
  ?disabled=${!siblings || idx >= siblings.length - 1}
148
- @click=${(/** @type {any} */ e) => {
161
+ @click=${(/** @type {MouseEvent} */ e) => {
149
162
  e.stopPropagation();
150
163
  moveSelectionDown();
151
164
  }}
@@ -158,10 +171,10 @@ function renderMoveArrows() {
158
171
  /**
159
172
  * Apply an inline format action.
160
173
  *
161
- * @param {any} action
174
+ * @param {InlineAction} action
162
175
  */
163
176
  function applyInlineFormat(action) {
164
- /** @type {Record<string, any>} */
177
+ /** @type {Record<string, string>} */
165
178
  const cmdToTag = {
166
179
  bold: "strong",
167
180
  italic: "em",
@@ -190,19 +203,22 @@ export function dismissBlockActionBar() {
190
203
  if (view.blockActionBarEl) litRender(nothing, view.blockActionBarEl);
191
204
  }
192
205
 
193
- /** @param {any} anchorBtn */
206
+ /** @param {HTMLElement} anchorBtn */
194
207
  function showLinkPopover(anchorBtn) {
195
208
  litRender(nothing, view.linkPopoverHost);
196
209
 
197
210
  const sel = window.getSelection();
198
- /** @type {any} */
211
+ /** @type {HTMLAnchorElement | null} */
199
212
  let existingLink = null;
200
213
  if (sel?.rangeCount) {
201
- /** @type {any} */
214
+ /** @type {Node | null} */
202
215
  let node = sel.anchorNode;
203
216
  while (node && node !== document.body) {
204
- if (node.nodeType === Node.ELEMENT_NODE && node.tagName.toLowerCase() === "a") {
205
- existingLink = node;
217
+ if (
218
+ node.nodeType === Node.ELEMENT_NODE &&
219
+ /** @type {Element} */ (node).tagName.toLowerCase() === "a"
220
+ ) {
221
+ existingLink = /** @type {HTMLAnchorElement} */ (node);
206
222
  break;
207
223
  }
208
224
  node = node.parentNode;
@@ -212,8 +228,10 @@ function showLinkPopover(anchorBtn) {
212
228
  const rect = anchorBtn.getBoundingClientRect();
213
229
 
214
230
  const onApply = () => {
215
- const field = view.linkPopoverHost.querySelector("sp-textfield");
216
- const url = /** @type {any} */ (field)?.value;
231
+ const field = /** @type {HTMLInputElement | null} */ (
232
+ view.linkPopoverHost.querySelector("sp-textfield")
233
+ );
234
+ const url = field?.value || "";
217
235
  if (existingLink) {
218
236
  existingLink.setAttribute("href", url);
219
237
  } else if (url) {
@@ -224,6 +242,7 @@ function showLinkPopover(anchorBtn) {
224
242
  };
225
243
 
226
244
  const onRemove = () => {
245
+ if (!existingLink?.parentNode) return;
227
246
  const frag = document.createDocumentFragment();
228
247
  while (existingLink.firstChild) frag.appendChild(existingLink.firstChild);
229
248
  existingLink.parentNode.replaceChild(frag, existingLink);
@@ -231,7 +250,7 @@ function showLinkPopover(anchorBtn) {
231
250
  renderBlockActionBar();
232
251
  };
233
252
 
234
- const onKeydown = (/** @type {any} */ e) => {
253
+ const onKeydown = (/** @type {KeyboardEvent} */ e) => {
235
254
  if (e.key === "Enter") onApply();
236
255
  else if (e.key === "Escape") {
237
256
  litRender(nothing, view.linkPopoverHost);
@@ -273,32 +292,33 @@ function showLinkPopover(anchorBtn) {
273
292
 
274
293
  /** Move the selected node up (swap with previous sibling). */
275
294
  function moveSelectionUp() {
276
- const S = getState();
277
- if (!S.selection || S.selection.length < 2) return;
278
- const idx = /** @type {number} */ (childIndex(S.selection));
295
+ const tab = activeTab.value;
296
+ if (!tab?.session.selection || tab.session.selection.length < 2) return;
297
+ const sel = tab.session.selection;
298
+ const idx = /** @type {number} */ (childIndex(sel));
279
299
  if (idx <= 0) return;
280
- const pPath = /** @type {any} */ (parentElementPath(S.selection));
281
- update(moveNode(S, S.selection, pPath, idx - 1));
282
- updateSession({ selection: [...pPath, "children", idx - 1] });
283
- renderOnly("overlays");
300
+ const pPath = /** @type {JxPath} */ (parentElementPath(sel));
301
+ transactDoc(tab, (t) => mutateMoveNode(t, sel, pPath, idx - 1));
302
+ tab.session.selection = [...pPath, "children", idx - 1];
284
303
  }
285
304
 
286
305
  /** Move the selected node down (swap with next sibling). */
287
306
  function moveSelectionDown() {
288
- const S = getState();
289
- if (!S.selection || S.selection.length < 2) return;
290
- const idx = /** @type {number} */ (childIndex(S.selection));
291
- const pPath = /** @type {any} */ (parentElementPath(S.selection));
292
- const parentNode = getNodeAtPath(S.document, pPath);
307
+ const tab = activeTab.value;
308
+ if (!tab?.session.selection || tab.session.selection.length < 2) return;
309
+ const sel = tab.session.selection;
310
+ const idx = /** @type {number} */ (childIndex(sel));
311
+ const pPath = /** @type {JxPath} */ (parentElementPath(sel));
312
+ const parentNode = getNodeAtPath(tab.doc.document, pPath);
293
313
  const siblings = parentNode?.children;
294
314
  if (!siblings || idx >= siblings.length - 1) return;
295
- update(moveNode(S, S.selection, pPath, idx + 2));
296
- updateSession({ selection: [...pPath, "children", idx + 1] });
297
- renderOnly("overlays");
315
+ transactDoc(tab, (t) => mutateMoveNode(t, sel, pPath, idx + 2));
316
+ tab.session.selection = [...pPath, "children", idx + 1];
298
317
  }
299
318
 
300
319
  /** Render the unified block action bar above the selected element. */
301
320
  export function renderBlockActionBar() {
321
+ if (!_ctx) return;
302
322
  if (!view.blockActionBarEl) {
303
323
  view.blockActionBarEl = _ctx.createFloatingContainer();
304
324
  }
@@ -308,21 +328,22 @@ export function renderBlockActionBar() {
308
328
  view.selDragCleanup = null;
309
329
  }
310
330
 
311
- const S = getState();
331
+ const tab = activeTab.value;
312
332
  const canvasMode = _ctx.getCanvasMode();
313
333
 
314
- if (!S.selection || (canvasMode !== "design" && canvasMode !== "edit")) {
334
+ if (!tab?.session.selection || (canvasMode !== "design" && canvasMode !== "edit")) {
315
335
  litRender(nothing, view.blockActionBarEl);
316
336
  return;
317
337
  }
318
338
 
339
+ const selection = tab.session.selection;
319
340
  const activePanel = getActivePanel();
320
341
  if (!activePanel) {
321
342
  litRender(nothing, view.blockActionBarEl);
322
343
  return;
323
344
  }
324
- const el = findCanvasElement(S.selection, activePanel.canvas);
325
- const node = el && getNodeAtPath(S.document, S.selection);
345
+ const el = findCanvasElement(selection, activePanel.canvas);
346
+ const node = el && getNodeAtPath(tab.doc.document, selection);
326
347
  if (!el || !node) {
327
348
  litRender(nothing, view.blockActionBarEl);
328
349
  return;
@@ -347,28 +368,31 @@ export function renderBlockActionBar() {
347
368
  style="left:${elRect.left}px; top:${topPos}px"
348
369
  @mousedown=${onBarMousedown}
349
370
  >
350
- ${S.selection.length >= 2 ? renderParentSelector() : nothing}
371
+ ${selection.length >= 2 ? renderParentSelector() : nothing}
351
372
 
352
373
  <span class="bar-tag">${node.$id || (node.tagName ?? "div")}</span>
353
374
 
354
- ${S.selection.length >= 2
375
+ ${selection.length >= 2
355
376
  ? html`<span class="bar-drag-handle" title="Drag to reorder">⡇</span>`
356
377
  : nothing}
357
- ${S.selection.length >= 2 ? renderMoveArrows() : nothing}
358
- ${S.selection.length >= 2 && node.tagName
378
+ ${selection.length >= 2 ? renderMoveArrows() : nothing}
379
+ ${selection.length >= 2 && node.tagName
359
380
  ? (() => {
360
381
  const isComp =
361
382
  node.tagName.includes("-") &&
362
- componentRegistry.some((/** @type {any} */ c) => c.tagName === node.tagName);
383
+ componentRegistry.some(
384
+ (/** @type {{ tagName: string }} */ c) => c.tagName === node.tagName,
385
+ );
363
386
  if (isComp) {
364
387
  const comp = componentRegistry.find(
365
- (/** @type {any} */ c) => c.tagName === node.tagName,
388
+ (/** @type {{ tagName: string; path: string }} */ c) =>
389
+ c.tagName === node.tagName,
366
390
  );
367
391
  return html`<sp-action-button
368
392
  size="xs"
369
393
  quiet
370
394
  title="Edit Component"
371
- @click=${() => _ctx.navigateToComponent(comp.path)}
395
+ @click=${() => _ctx?.navigateToComponent(/** @type {string} */ (comp?.path))}
372
396
  ><sp-icon-edit slot="icon" size="xs"></sp-icon-edit
373
397
  ></sp-action-button>`;
374
398
  }
@@ -376,7 +400,7 @@ export function renderBlockActionBar() {
376
400
  size="xs"
377
401
  quiet
378
402
  title="Convert to Component"
379
- @click=${() => convertToComponent(S)}
403
+ @click=${() => convertToComponent()}
380
404
  ><sp-icon-box slot="icon" size="xs"></sp-icon-box
381
405
  ></sp-action-button>`;
382
406
  })()
@@ -398,7 +422,7 @@ export function renderBlockActionBar() {
398
422
  value=${action.tag}
399
423
  title="${action.label}${action.shortcut ? ` (${action.shortcut})` : ""}"
400
424
  @mousedown=${captureSelectionRange}
401
- @click=${(/** @type {any} */ e) => onFormatClick(e, action)}
425
+ @click=${(/** @type {MouseEvent} */ e) => onFormatClick(e, action)}
402
426
  >
403
427
  ${formatIconMap[action.icon] ?? nothing}
404
428
  </sp-action-button>
@@ -422,8 +446,8 @@ export function renderBlockActionBar() {
422
446
  bar.style.left = `${Math.max(0, window.innerWidth - barRect.width)}px`;
423
447
  }
424
448
  // Attach drag handle
425
- const currentS = getState();
426
- if (currentS.selection?.length >= 2) {
449
+ const currentTab = activeTab.value;
450
+ if (currentTab?.session.selection && currentTab.session.selection.length >= 2) {
427
451
  const handle = bar.querySelector(".bar-drag-handle");
428
452
  if (handle) {
429
453
  if (view.selDragCleanup) {
@@ -432,7 +456,7 @@ export function renderBlockActionBar() {
432
456
  }
433
457
  view.selDragCleanup = draggable({
434
458
  element: handle,
435
- getInitialData: () => ({ type: "tree-node", path: getState().selection }),
459
+ getInitialData: () => ({ type: "tree-node", path: activeTab.value?.session.selection }),
436
460
  });
437
461
  }
438
462
  }
@@ -20,19 +20,39 @@ import { view } from "../view.js";
20
20
  import { applyDropInstruction } from "../panels/dnd.js";
21
21
  import { effectiveZoom } from "../canvas/canvas-helpers.js";
22
22
 
23
+ /**
24
+ * @typedef {{
25
+ * canvas: HTMLElement;
26
+ * overlayClk: HTMLElement;
27
+ * overlay: HTMLElement;
28
+ * viewport: HTMLElement;
29
+ * dropLine: HTMLElement;
30
+ * }} CanvasPanel
31
+ *
32
+ * @typedef {(string | number)[]} JxPath
33
+ *
34
+ * @typedef {{ type: "reorder-above" | "reorder-below" | "make-child" }} DropInstruction
35
+ *
36
+ * @typedef {{ instruction: DropInstruction; referenceEl: HTMLElement; targetPath: JxPath }} DropResult
37
+ */
38
+
39
+ /** @type {HTMLElement | null} */
40
+ let _activeDropEl = null;
41
+
23
42
  /**
24
43
  * Register all canvas elements in a panel as DnD drop targets.
25
44
  *
26
- * @param {any} panel
45
+ * @param {CanvasPanel} panel
27
46
  */
28
47
  export function registerPanelDnD(panel) {
29
48
  const { canvas, dropLine } = panel;
30
49
  const allEls = canvas.querySelectorAll("*");
31
50
 
32
51
  const monitorCleanup = monitorForElements({
33
- onDragStart() {
52
+ onDragStart({ location }) {
53
+ view.lastDragInput = location.current.input;
34
54
  for (const el of canvas.querySelectorAll("*")) {
35
- /** @type {any} */ (el).style.pointerEvents = "auto";
55
+ /** @type {HTMLElement} */ (el).style.pointerEvents = "auto";
36
56
  }
37
57
  for (const p of canvasPanels) p.overlayClk.style.pointerEvents = "none";
38
58
  },
@@ -40,10 +60,12 @@ export function registerPanelDnD(panel) {
40
60
  view.lastDragInput = location.current.input;
41
61
  },
42
62
  onDrop() {
63
+ _activeDropEl?.classList.remove("canvas-drop-target");
64
+ _activeDropEl = null;
43
65
  for (const p of canvasPanels) p.dropLine.style.display = "none";
44
66
  view.lastDragInput = null;
45
67
  for (const el of canvas.querySelectorAll("*")) {
46
- /** @type {any} */ (el).style.pointerEvents = "none";
68
+ /** @type {HTMLElement} */ (el).style.pointerEvents = "none";
47
69
  }
48
70
  for (const p of canvasPanels) p.overlayClk.style.pointerEvents = "";
49
71
  },
@@ -56,34 +78,45 @@ export function registerPanelDnD(panel) {
56
78
  if (!elPath) continue;
57
79
 
58
80
  const node = getNodeAtPath(S.document, elPath);
59
- const isVoid = VOID_ELEMENTS.has((node?.tagName || "div").toLowerCase());
81
+ const tag = (node?.tagName || "div").toLowerCase();
82
+ const hasElementChildren = node?.children?.some(
83
+ (/** @type {unknown} */ c) => c != null && typeof c === "object",
84
+ );
85
+ const isLeaf = VOID_ELEMENTS.has(tag) || !hasElementChildren;
60
86
 
61
87
  const cleanup = dropTargetForElements({
62
- element: el,
88
+ element: /** @type {HTMLElement} */ (el),
63
89
  canDrop({ source }) {
64
- const srcPath = source.data.path;
65
- if (srcPath && isAncestor(/** @type {any} */ (srcPath), elPath)) return false;
90
+ const srcPath = /** @type {JxPath | undefined} */ (source.data.path);
91
+ if (srcPath && isAncestor(srcPath, elPath)) return false;
66
92
  return true;
67
93
  },
68
94
  getData() {
69
- return { path: elPath, _isVoid: isVoid };
70
- },
71
- onDragEnter() {
72
- showCanvasDropIndicator(el, elPath, isVoid, panel);
95
+ return { path: elPath, _isVoid: isLeaf };
73
96
  },
74
- onDrag() {
75
- showCanvasDropIndicator(el, elPath, isVoid, panel);
97
+ onDragEnter({ location }) {
98
+ view.lastDragInput = location.current.input;
99
+ if (_activeDropEl && _activeDropEl !== el) {
100
+ _activeDropEl.classList.remove("canvas-drop-target");
101
+ }
102
+ _activeDropEl = /** @type {HTMLElement} */ (el);
103
+ showCanvasDropIndicator(/** @type {HTMLElement} */ (el), elPath, isLeaf, panel);
76
104
  },
77
- onDragLeave() {
78
- dropLine.style.display = "none";
79
- el.classList.remove("canvas-drop-target");
105
+ onDrag({ location }) {
106
+ view.lastDragInput = location.current.input;
107
+ showCanvasDropIndicator(/** @type {HTMLElement} */ (el), elPath, isLeaf, panel);
80
108
  },
109
+ onDragLeave() {},
81
110
  onDrop({ source }) {
82
111
  dropLine.style.display = "none";
83
- el.classList.remove("canvas-drop-target");
84
- const instruction = getCanvasDropInstruction(el, elPath, isVoid);
85
- if (!instruction) return;
86
- applyDropInstruction(instruction, source.data, elPath);
112
+ /** @type {HTMLElement} */ (el).classList.remove("canvas-drop-target");
113
+ _activeDropEl = null;
114
+ const { instruction, targetPath } = getCanvasDropResult(
115
+ /** @type {HTMLElement} */ (el),
116
+ elPath,
117
+ isLeaf,
118
+ );
119
+ applyDropInstruction(instruction, source.data, targetPath);
87
120
  },
88
121
  });
89
122
  view.canvasDndCleanups.push(cleanup);
@@ -91,49 +124,98 @@ export function registerPanelDnD(panel) {
91
124
  }
92
125
 
93
126
  /**
94
- * @param {any} el
95
- * @param {any} elPath
96
- * @param {any} isVoid
127
+ * @param {HTMLElement} el
128
+ * @param {JxPath} elPath
129
+ * @param {boolean} isLeaf
130
+ * @returns {DropResult}
97
131
  */
98
- function getCanvasDropInstruction(el, elPath, isVoid) {
99
- const rect = el.getBoundingClientRect();
100
- if (!view.lastDragInput) return null;
132
+ function getCanvasDropResult(el, elPath, isLeaf) {
133
+ if (!view.lastDragInput)
134
+ return { instruction: { type: "make-child" }, referenceEl: el, targetPath: elPath };
101
135
  const y = view.lastDragInput.clientY;
136
+
137
+ if (elPath.length === 0) {
138
+ const children = /** @type {HTMLElement[]} */ (Array.from(el.children));
139
+ if (children.length === 0)
140
+ return { instruction: { type: "make-child" }, referenceEl: el, targetPath: elPath };
141
+ return nearestChildEdge(children, y, elPath);
142
+ }
143
+
144
+ const rect = el.getBoundingClientRect();
102
145
  const relY = (y - rect.top) / rect.height;
103
146
 
104
- if (elPath.length === 0) return { type: "make-child" };
105
- if (isVoid) return relY < 0.5 ? { type: "reorder-above" } : { type: "reorder-below" };
106
- if (relY < 0.25) return { type: "reorder-above" };
107
- if (relY > 0.75) return { type: "reorder-below" };
108
- return { type: "make-child" };
147
+ if (isLeaf) {
148
+ const instruction =
149
+ relY < 0.5
150
+ ? { type: /** @type {const} */ ("reorder-above") }
151
+ : { type: /** @type {const} */ ("reorder-below") };
152
+ return { instruction, referenceEl: el, targetPath: elPath };
153
+ }
154
+
155
+ if (relY < 0.25)
156
+ return { instruction: { type: "reorder-above" }, referenceEl: el, targetPath: elPath };
157
+ if (relY > 0.75)
158
+ return { instruction: { type: "reorder-below" }, referenceEl: el, targetPath: elPath };
159
+ return { instruction: { type: "make-child" }, referenceEl: el, targetPath: elPath };
109
160
  }
110
161
 
111
162
  /**
112
- * @param {any} el
113
- * @param {any} elPath
114
- * @param {any} isVoid
115
- * @param {any} panel
163
+ * Find the nearest child edge to the cursor and return the appropriate instruction along with the
164
+ * reference child element and its path.
165
+ *
166
+ * @param {HTMLElement[]} children
167
+ * @param {number} cursorY
168
+ * @param {JxPath} parentPath
169
+ * @returns {DropResult}
116
170
  */
117
- function showCanvasDropIndicator(el, elPath, isVoid, panel) {
118
- const instruction = getCanvasDropInstruction(el, elPath, isVoid);
119
- const { dropLine, viewport } = panel;
120
- if (!instruction) {
121
- dropLine.style.display = "none";
122
- return;
171
+ function nearestChildEdge(children, cursorY, parentPath) {
172
+ let closestDist = Infinity;
173
+ let instruction = /** @type {DropInstruction} */ ({ type: "reorder-below" });
174
+ let closestIdx = children.length - 1;
175
+
176
+ for (let i = 0; i < children.length; i++) {
177
+ const rect = children[i].getBoundingClientRect();
178
+ const topDist = Math.abs(cursorY - rect.top);
179
+ const bottomDist = Math.abs(cursorY - rect.bottom);
180
+
181
+ if (topDist < closestDist) {
182
+ closestDist = topDist;
183
+ instruction = { type: "reorder-above" };
184
+ closestIdx = i;
185
+ }
186
+ if (bottomDist < closestDist) {
187
+ closestDist = bottomDist;
188
+ instruction = { type: "reorder-below" };
189
+ closestIdx = i;
190
+ }
123
191
  }
124
192
 
193
+ const childPath = [...parentPath, "children", closestIdx];
194
+ return { instruction, referenceEl: children[closestIdx], targetPath: childPath };
195
+ }
196
+
197
+ /**
198
+ * @param {HTMLElement} el
199
+ * @param {JxPath} elPath
200
+ * @param {boolean} isLeaf
201
+ * @param {CanvasPanel} panel
202
+ */
203
+ function showCanvasDropIndicator(el, elPath, isLeaf, panel) {
204
+ const { instruction, referenceEl } = getCanvasDropResult(el, elPath, isLeaf);
205
+ const { dropLine, viewport } = panel;
206
+
125
207
  const scale = effectiveZoom();
126
208
  const wrapRect = viewport.getBoundingClientRect();
127
- const elRect = el.getBoundingClientRect();
128
- const left = (elRect.left - wrapRect.left + viewport.scrollLeft) / scale;
129
- const width = elRect.width / scale;
209
+ const refRect = referenceEl.getBoundingClientRect();
210
+ const left = (refRect.left - wrapRect.left + viewport.scrollLeft) / scale;
211
+ const width = refRect.width / scale;
130
212
 
131
213
  if (instruction.type === "make-child") {
132
214
  dropLine.style.display = "block";
133
- dropLine.style.top = `${(elRect.top - wrapRect.top + viewport.scrollTop) / scale}px`;
215
+ dropLine.style.top = `${(refRect.top - wrapRect.top + viewport.scrollTop) / scale}px`;
134
216
  dropLine.style.left = `${left}px`;
135
217
  dropLine.style.width = `${width}px`;
136
- dropLine.style.height = `${elRect.height / scale}px`;
218
+ dropLine.style.height = `${refRect.height / scale}px`;
137
219
  dropLine.className = "canvas-drop-indicator inside";
138
220
  el.classList.add("canvas-drop-target");
139
221
  return;
@@ -142,13 +224,13 @@ function showCanvasDropIndicator(el, elPath, isVoid, panel) {
142
224
  el.classList.remove("canvas-drop-target");
143
225
  const top =
144
226
  instruction.type === "reorder-above"
145
- ? (elRect.top - wrapRect.top + viewport.scrollTop) / scale
146
- : (elRect.bottom - wrapRect.top + viewport.scrollTop) / scale;
227
+ ? (refRect.top - wrapRect.top + viewport.scrollTop) / scale
228
+ : (refRect.bottom - wrapRect.top + viewport.scrollTop) / scale;
147
229
 
148
230
  dropLine.style.display = "block";
149
231
  dropLine.style.top = `${top}px`;
150
232
  dropLine.style.left = `${left}px`;
151
233
  dropLine.style.width = `${width}px`;
152
- dropLine.style.height = "2px";
234
+ dropLine.style.height = "";
153
235
  dropLine.className = "canvas-drop-indicator line";
154
236
  }