@jxsuite/studio 0.26.1 → 0.27.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/studio",
3
- "version": "0.26.1",
3
+ "version": "0.27.0",
4
4
  "description": "Jx Studio — visual builder for Jx documents",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -32,9 +32,9 @@
32
32
  "dependencies": {
33
33
  "@atlaskit/pragmatic-drag-and-drop": "^1.8.1",
34
34
  "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.2.0",
35
- "@jxsuite/parser": "^0.26.1",
36
- "@jxsuite/runtime": "^0.26.1",
37
- "@jxsuite/schema": "^0.26.1",
35
+ "@jxsuite/parser": "^0.27.0",
36
+ "@jxsuite/runtime": "^0.27.0",
37
+ "@jxsuite/schema": "^0.27.0",
38
38
  "@spectrum-web-components/accordion": "^1.12.1",
39
39
  "@spectrum-web-components/action-bar": "1.12.1",
40
40
  "@spectrum-web-components/action-button": "^1.12.1",
@@ -88,7 +88,7 @@
88
88
  "@types/mdast": "^4.0.4",
89
89
  "@webref/css": "^8.5.8",
90
90
  "@webref/elements": "^2.7.1",
91
- "@webref/idl": "^3.80.0"
91
+ "@webref/idl": "^3.81.0"
92
92
  },
93
93
  "//vue-reactivity": "Exact pin required — must match @jxsuite/runtime to avoid cross-boundary proxy bugs"
94
94
  }
@@ -42,6 +42,21 @@ export function registerPanelDnD(panel: CanvasPanel) {
42
42
  const { canvas, dropLine } = panel;
43
43
  const allEls = canvas.querySelectorAll("*");
44
44
 
45
+ // Drop-target callbacks fire on EVERY target in the stack (innermost → outermost),
46
+ // and every canvas element is a drop target — so the indicator and the drop are
47
+ // driven from the monitor using only the innermost target.
48
+ /** Innermost drop target if it belongs to this panel's canvas, else null */
49
+ const innermostCanvasTarget = (location: {
50
+ current: { dropTargets: { data: Record<string | symbol, unknown>; element: Element }[] };
51
+ }) => {
52
+ const target = location.current.dropTargets[0];
53
+ if (!target) return null;
54
+ const tEl = target.element as HTMLElement;
55
+ const tPath = target.data.path;
56
+ if (!canvas.contains(tEl) || !Array.isArray(tPath)) return null;
57
+ return { el: tEl, path: tPath as JxPath, isLeaf: !!target.data._isVoid };
58
+ };
59
+
45
60
  const monitorCleanup = monitorForElements({
46
61
  onDragStart({ location }) {
47
62
  view.lastDragInput = location.current.input;
@@ -52,8 +67,34 @@ export function registerPanelDnD(panel: CanvasPanel) {
52
67
  },
53
68
  onDrag({ location }) {
54
69
  view.lastDragInput = location.current.input;
70
+ const target = innermostCanvasTarget(location);
71
+ if (target) {
72
+ if (_activeDropEl && _activeDropEl !== target.el) {
73
+ _activeDropEl.classList.remove("canvas-drop-target");
74
+ }
75
+ _activeDropEl = target.el;
76
+ showCanvasDropIndicator(target.el, target.path, target.isLeaf, panel);
77
+ } else if (location.current.dropTargets.length > 0) {
78
+ // Pointer is over a non-canvas target (e.g. a layer row) — hide this panel's
79
+ // indicator. When over dead space (no targets at all) keep the last indicator
80
+ // visible so it persists for the whole drag.
81
+ if (_activeDropEl && canvas.contains(_activeDropEl)) {
82
+ _activeDropEl.classList.remove("canvas-drop-target");
83
+ _activeDropEl = null;
84
+ }
85
+ dropLine.style.display = "none";
86
+ }
55
87
  },
56
- onDrop() {
88
+ onDrop({ source, location }) {
89
+ const target = innermostCanvasTarget(location);
90
+ if (target) {
91
+ const { instruction, targetPath } = getCanvasDropResult(
92
+ target.el,
93
+ target.path,
94
+ target.isLeaf,
95
+ );
96
+ applyDropInstruction(instruction, source.data, targetPath);
97
+ }
57
98
  _activeDropEl?.classList.remove("canvas-drop-target");
58
99
  _activeDropEl = null;
59
100
  for (const p of canvasPanels) {
@@ -90,26 +131,6 @@ export function registerPanelDnD(panel: CanvasPanel) {
90
131
  getData() {
91
132
  return { path: elPath, _isVoid: isLeaf };
92
133
  },
93
- onDragEnter({ location }) {
94
- view.lastDragInput = location.current.input;
95
- if (_activeDropEl && _activeDropEl !== el) {
96
- _activeDropEl.classList.remove("canvas-drop-target");
97
- }
98
- _activeDropEl = el as HTMLElement;
99
- showCanvasDropIndicator(el as HTMLElement, elPath, isLeaf, panel);
100
- },
101
- onDrag({ location }) {
102
- view.lastDragInput = location.current.input;
103
- showCanvasDropIndicator(el as HTMLElement, elPath, isLeaf, panel);
104
- },
105
- onDragLeave() {},
106
- onDrop({ source }) {
107
- dropLine.style.display = "none";
108
- (el as HTMLElement).classList.remove("canvas-drop-target");
109
- _activeDropEl = null;
110
- const { instruction, targetPath } = getCanvasDropResult(el as HTMLElement, elPath, isLeaf);
111
- applyDropInstruction(instruction, source.data, targetPath);
112
- },
113
134
  });
114
135
  view.canvasDndCleanups.push(cleanup);
115
136
  }
package/src/panels/dnd.ts CHANGED
@@ -115,9 +115,8 @@ export function registerLayersDnD() {
115
115
  onDrag({ self }: DragSelfArgs) {
116
116
  showLayerDropGap(row, self.data, container);
117
117
  },
118
- onDragLeave() {
119
- clearLayerDropGap(container);
120
- },
118
+ // No onDragLeave clear — the gap persists while the pointer crosses dead space
119
+ // between rows; the monitor clears it when the drag moves to a non-tree target.
121
120
  onDrop() {
122
121
  clearLayerDropGap(container);
123
122
  },
@@ -128,6 +127,12 @@ export function registerLayersDnD() {
128
127
 
129
128
  // Global monitor
130
129
  const monitorCleanup = monitorForElements({
130
+ onDropTargetChange({ location }: DragMonitorDropArgs) {
131
+ // Clear the layer gap when the drag moves onto a non-tree target (e.g. a canvas
132
+ // element). When there is no target at all, keep the gap so it persists.
133
+ const inner = location.current.dropTargets[0];
134
+ if (inner && !extractInstruction(inner.data)) clearLayerDropGap(container);
135
+ },
131
136
  onDrop({ source, location }: DragMonitorDropArgs) {
132
137
  clearLayerDropGap(container);
133
138
  const target = location.current.dropTargets[0];
@@ -321,6 +326,10 @@ export function applyDropInstruction(
321
326
  const fromPath = srcData.path as JxPath;
322
327
  const targetParent = parentElementPath(targetPath) as JxPath;
323
328
  const targetIdx = childIndex(targetPath) as number;
329
+ // Reordering requires a parent and a numeric index; root-level paths can't be
330
+ // reordered around.
331
+ if (instruction.type !== "make-child" && (!targetParent || typeof targetIdx !== "number"))
332
+ return;
324
333
 
325
334
  switch (instruction.type) {
326
335
  case "reorder-above":
@@ -341,6 +350,8 @@ export function applyDropInstruction(
341
350
  } else if (srcData.type === "block") {
342
351
  const targetParent = parentElementPath(targetPath) as JxPath;
343
352
  const targetIdx = childIndex(targetPath) as number;
353
+ if (instruction.type !== "make-child" && (!targetParent || typeof targetIdx !== "number"))
354
+ return;
344
355
 
345
356
  switch (instruction.type) {
346
357
  case "reorder-above":
@@ -100,6 +100,7 @@ export function mutateInsertNode(
100
100
  nodeDef: JxMutableNode,
101
101
  ) {
102
102
  const parent = getNodeAtPath(tab.doc.document, parentPath);
103
+ if (!parent) return;
103
104
  if (!parent.children) parent.children = [];
104
105
  parent.children.splice(index, 0, structuredClone(nodeDef));
105
106
  }
@@ -159,9 +160,12 @@ export function mutateMoveNode(tab: Tab, fromPath: JxPath, toParentPath: JxPath,
159
160
  const doc = tab.doc.document;
160
161
  const fromParentPath = parentElementPath(fromPath) as JxPath;
161
162
  const fromIdx = childIndex(fromPath) as number;
163
+ if (!fromParentPath || typeof fromIdx !== "number") return;
162
164
  const fromParent = getNodeAtPath(doc, fromParentPath);
163
- const [node] = (fromParent.children as JxMutableNode[]).splice(fromIdx, 1);
164
165
  const toParent = getNodeAtPath(doc, toParentPath);
166
+ if (!fromParent || !Array.isArray(fromParent.children) || !toParent) return;
167
+ const [node] = (fromParent.children as JxMutableNode[]).splice(fromIdx, 1);
168
+ if (node === undefined) return;
165
169
  if (!toParent.children) toParent.children = [];
166
170
  let adjustedIndex = toIndex;
167
171
  if (fromParent === toParent && fromIdx < toIndex) adjustedIndex--;
@@ -6,10 +6,12 @@
6
6
  * files from the project's public/ directory, with thumbnail previews for images.
7
7
  */
8
8
 
9
- import { html, nothing } from "lit-html";
9
+ import { html, render as litRender, nothing } from "lit-html";
10
10
  import { live } from "lit-html/directives/live.js";
11
+ import { ref } from "lit-html/directives/ref.js";
11
12
  import { getPlatform } from "../platform";
12
13
  import { debouncedStyleCommit } from "../store";
14
+ import { getLayerSlot } from "./layers";
13
15
 
14
16
  // ─── Media file cache ────────────────────────────────────────────────────────
15
17
 
@@ -92,6 +94,184 @@ export function invalidateMediaCache() {
92
94
  mediaCacheLoaded = false;
93
95
  }
94
96
 
97
+ // ─── Popover state ───────────────────────────────────────────────────────────
98
+
99
+ /** @type {((val: string) => void) | null} */
100
+ let _popoverOnCommit: ((val: string) => void) | null = null;
101
+
102
+ /** @type {HTMLElement | null} */
103
+ let _popoverAnchorEl: HTMLElement | null = null;
104
+
105
+ /** @type {HTMLInputElement | null} */
106
+ let _popoverFilterEl: HTMLInputElement | null = null;
107
+
108
+ let _popoverFilter = "";
109
+
110
+ function dismissMediaPickerPopover() {
111
+ _popoverFilter = "";
112
+ _popoverOnCommit = null;
113
+ _popoverAnchorEl = null;
114
+ _popoverFilterEl = null;
115
+ document.removeEventListener("keydown", onPopoverKeydown, true);
116
+ document.removeEventListener("mousedown", onPopoverOutsideClick, true);
117
+ litRender(nothing, getLayerSlot("popover", "media-picker"));
118
+ }
119
+
120
+ /** @param {KeyboardEvent} e */
121
+ function onPopoverKeydown(e: KeyboardEvent) {
122
+ if (e.key === "Escape") {
123
+ dismissMediaPickerPopover();
124
+ e.preventDefault();
125
+ e.stopPropagation();
126
+ }
127
+ }
128
+
129
+ /** @param {MouseEvent} e */
130
+ function onPopoverOutsideClick(e: MouseEvent) {
131
+ const host = getLayerSlot("popover", "media-picker");
132
+ if (!host.contains(e.target as Node)) {
133
+ dismissMediaPickerPopover();
134
+ }
135
+ }
136
+
137
+ function renderMediaPickerPopover() {
138
+ const host = getLayerSlot("popover", "media-picker");
139
+ const rect = _popoverAnchorEl?.getBoundingClientRect();
140
+ if (!rect) return;
141
+
142
+ const query = _popoverFilter.toLowerCase();
143
+ const filtered = query
144
+ ? mediaCache.filter(
145
+ (m) => m.path.toLowerCase().includes(query) || m.name.toLowerCase().includes(query),
146
+ )
147
+ : mediaCache;
148
+ const options = filtered.slice(0, 50);
149
+
150
+ // Compute initial position below the anchor
151
+ let left = rect.left;
152
+ let top = rect.bottom + 4;
153
+
154
+ // Estimate popover dimensions for viewport clamping before first paint
155
+ const estimatedWidth = 280;
156
+ const estimatedHeight = Math.min(options.length * 36 + 48, 360);
157
+
158
+ if (left + estimatedWidth > window.innerWidth - 8) {
159
+ left = Math.max(8, window.innerWidth - estimatedWidth - 8);
160
+ }
161
+ if (top + estimatedHeight > window.innerHeight - 8) {
162
+ top = Math.max(8, rect.top - estimatedHeight - 4);
163
+ }
164
+
165
+ let _popoverEl: HTMLElement | null = null;
166
+
167
+ litRender(
168
+ html`
169
+ <sp-popover
170
+ open
171
+ ${ref((el) => {
172
+ _popoverEl = (el as HTMLElement | undefined) || null;
173
+ })}
174
+ style="position:fixed;left:${left}px;top:${top}px;z-index:30;max-height:360px;overflow-y:auto;min-width:240px"
175
+ >
176
+ <input
177
+ class="media-picker-filter"
178
+ type="text"
179
+ placeholder="Search images…"
180
+ autocomplete="off"
181
+ style="display:block;width:100%;box-sizing:border-box;padding:6px 10px;border:none;border-bottom:1px solid var(--border, #444);outline:none;font-size:13px;background:transparent;color:inherit"
182
+ ${ref((el) => {
183
+ _popoverFilterEl = (el as HTMLInputElement | null) || null;
184
+ })}
185
+ @input=${(e: Event) => {
186
+ _popoverFilter = (e.target as HTMLInputElement).value;
187
+ renderMediaPickerPopover();
188
+ }}
189
+ @click=${(e: MouseEvent) => e.stopPropagation()}
190
+ />
191
+ <sp-menu
192
+ style="min-width:220px"
193
+ @change=${(e: Event) => {
194
+ _popoverOnCommit?.((e.target as HTMLInputElement).value);
195
+ dismissMediaPickerPopover();
196
+ }}
197
+ >
198
+ ${options.length
199
+ ? options.map(
200
+ (m) => html`
201
+ <sp-menu-item value=${m.path}>
202
+ ${m.isImage
203
+ ? html`<img
204
+ slot="icon"
205
+ src=${m.path}
206
+ alt=""
207
+ style="width:24px;height:24px;object-fit:cover;border-radius:2px"
208
+ />`
209
+ : nothing}
210
+ ${m.name}
211
+ </sp-menu-item>
212
+ `,
213
+ )
214
+ : html`<sp-menu-item disabled>No matches</sp-menu-item>`}
215
+ ${filtered.length > 50
216
+ ? html`<sp-menu-item disabled>…${filtered.length - 50} more</sp-menu-item>`
217
+ : nothing}
218
+ </sp-menu>
219
+ </sp-popover>
220
+ `,
221
+ host,
222
+ );
223
+
224
+ // Fine-tune position after render using actual measured dimensions
225
+ requestAnimationFrame(() => {
226
+ if (_popoverEl) {
227
+ const popoverRect = _popoverEl.getBoundingClientRect();
228
+ let adjLeft = popoverRect.left;
229
+ let adjTop = popoverRect.top;
230
+ let needsAdjust = false;
231
+
232
+ if (popoverRect.right > window.innerWidth - 4) {
233
+ adjLeft = Math.max(4, window.innerWidth - popoverRect.width - 4);
234
+ needsAdjust = true;
235
+ }
236
+ if (popoverRect.bottom > window.innerHeight - 4) {
237
+ adjTop = Math.max(4, window.innerHeight - popoverRect.height - 4);
238
+ needsAdjust = true;
239
+ }
240
+ if (popoverRect.left < 4) {
241
+ adjLeft = 4;
242
+ needsAdjust = true;
243
+ }
244
+ if (popoverRect.top < 4) {
245
+ adjTop = 4;
246
+ needsAdjust = true;
247
+ }
248
+
249
+ if (needsAdjust) {
250
+ _popoverEl.style.left = `${adjLeft}px`;
251
+ _popoverEl.style.top = `${adjTop}px`;
252
+ }
253
+ }
254
+
255
+ if (_popoverFilterEl) _popoverFilterEl.focus();
256
+ });
257
+ }
258
+
259
+ /**
260
+ * @param {HTMLElement} anchorEl
261
+ * @param {(val: string) => void} onCommit
262
+ */
263
+ function showMediaPickerPopover(anchorEl: HTMLElement, onCommit: (val: string) => void) {
264
+ dismissMediaPickerPopover();
265
+ _popoverOnCommit = onCommit;
266
+ _popoverAnchorEl = anchorEl;
267
+ _popoverFilter = "";
268
+ renderMediaPickerPopover();
269
+ document.addEventListener("keydown", onPopoverKeydown, true);
270
+ requestAnimationFrame(() => {
271
+ document.addEventListener("mousedown", onPopoverOutsideClick, true);
272
+ });
273
+ }
274
+
95
275
  // ─── Render ──────────────────────────────────────────────────────────────────
96
276
 
97
277
  /**
@@ -111,17 +291,6 @@ export function renderMediaPicker(prop: string, value: string, onCommit: (val: s
111
291
  currentValue.slice(currentValue.lastIndexOf(".")).toLowerCase(),
112
292
  );
113
293
 
114
- // Filter media options based on current input
115
- const query = currentValue.toLowerCase();
116
- const filtered = query
117
- ? mediaCache.filter(
118
- (m) => m.path.toLowerCase().includes(query) || m.name.toLowerCase().includes(query),
119
- )
120
- : mediaCache;
121
-
122
- // Limit displayed options
123
- const options = filtered.slice(0, 20);
124
-
125
294
  return html`
126
295
  <div class="media-picker">
127
296
  ${isImage && currentValue
@@ -138,37 +307,17 @@ export function renderMediaPicker(prop: string, value: string, onCommit: (val: s
138
307
  ></sp-textfield>
139
308
  ${mediaCache.length > 0
140
309
  ? html`
141
- <overlay-trigger placement="bottom-end" triggered-by="click">
142
- <sp-action-button size="xs" quiet slot="trigger" title="Browse media">
143
- <sp-icon-image slot="icon"></sp-icon-image>
144
- </sp-action-button>
145
- <sp-popover slot="click-content" class="media-picker-popover">
146
- <sp-menu
147
- @change=${(e: Event) => {
148
- onCommit((e.target as HTMLInputElement).value);
149
- }}
150
- >
151
- ${options.map(
152
- (m) => html`
153
- <sp-menu-item value=${m.path}>
154
- ${m.isImage
155
- ? html`<img
156
- slot="icon"
157
- src=${m.path}
158
- alt=""
159
- style="width:24px;height:24px;object-fit:cover;border-radius:2px"
160
- />`
161
- : nothing}
162
- ${m.name}
163
- </sp-menu-item>
164
- `,
165
- )}
166
- ${filtered.length > 20
167
- ? html`<sp-menu-item disabled>...${filtered.length - 20} more</sp-menu-item>`
168
- : nothing}
169
- </sp-menu>
170
- </sp-popover>
171
- </overlay-trigger>
310
+ <sp-action-button
311
+ size="xs"
312
+ quiet
313
+ title="Browse media"
314
+ @click=${(e: MouseEvent) => {
315
+ loadMediaCache();
316
+ showMediaPickerPopover(e.currentTarget as HTMLElement, onCommit);
317
+ }}
318
+ >
319
+ <sp-icon-image slot="icon"></sp-icon-image>
320
+ </sp-action-button>
172
321
  `
173
322
  : nothing}
174
323
  </div>