@upstart.gg/vite-plugins 0.1.84 → 0.1.85

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 (38) hide show
  1. package/dist/upstart-editor-api.d.ts +58 -1
  2. package/dist/upstart-editor-api.d.ts.map +1 -1
  3. package/dist/upstart-editor-api.js +224 -1
  4. package/dist/upstart-editor-api.js.map +1 -1
  5. package/dist/vite-plugin-upstart-attrs.d.ts +1 -1
  6. package/dist/vite-plugin-upstart-attrs.d.ts.map +1 -1
  7. package/dist/vite-plugin-upstart-attrs.js +65 -2
  8. package/dist/vite-plugin-upstart-attrs.js.map +1 -1
  9. package/dist/vite-plugin-upstart-editor/runtime/array-controls.js +2 -24
  10. package/dist/vite-plugin-upstart-editor/runtime/array-controls.js.map +1 -1
  11. package/dist/vite-plugin-upstart-editor/runtime/click-handler.js +7 -1
  12. package/dist/vite-plugin-upstart-editor/runtime/click-handler.js.map +1 -1
  13. package/dist/vite-plugin-upstart-editor/runtime/controls-ui.js +33 -0
  14. package/dist/vite-plugin-upstart-editor/runtime/controls-ui.js.map +1 -0
  15. package/dist/vite-plugin-upstart-editor/runtime/index.d.ts.map +1 -1
  16. package/dist/vite-plugin-upstart-editor/runtime/index.js +23 -0
  17. package/dist/vite-plugin-upstart-editor/runtime/index.js.map +1 -1
  18. package/dist/vite-plugin-upstart-editor/runtime/labels.d.ts +5 -0
  19. package/dist/vite-plugin-upstart-editor/runtime/labels.d.ts.map +1 -1
  20. package/dist/vite-plugin-upstart-editor/runtime/labels.js +5 -0
  21. package/dist/vite-plugin-upstart-editor/runtime/labels.js.map +1 -1
  22. package/dist/vite-plugin-upstart-editor/runtime/move-controls.js +434 -0
  23. package/dist/vite-plugin-upstart-editor/runtime/move-controls.js.map +1 -0
  24. package/dist/vite-plugin-upstart-editor/runtime/types.d.ts +6 -3
  25. package/dist/vite-plugin-upstart-editor/runtime/types.d.ts.map +1 -1
  26. package/package.json +3 -3
  27. package/src/tests/move-controls.test.ts +86 -0
  28. package/src/tests/upstart-editor-api-move-element.test.ts +220 -0
  29. package/src/tests/vite-plugin-upstart-attrs.test.ts +165 -4
  30. package/src/upstart-editor-api.ts +263 -0
  31. package/src/vite-plugin-upstart-attrs.ts +93 -2
  32. package/src/vite-plugin-upstart-editor/runtime/array-controls.ts +2 -40
  33. package/src/vite-plugin-upstart-editor/runtime/click-handler.ts +17 -3
  34. package/src/vite-plugin-upstart-editor/runtime/controls-ui.ts +48 -0
  35. package/src/vite-plugin-upstart-editor/runtime/index.ts +31 -0
  36. package/src/vite-plugin-upstart-editor/runtime/labels.ts +10 -0
  37. package/src/vite-plugin-upstart-editor/runtime/move-controls.ts +536 -0
  38. package/src/vite-plugin-upstart-editor/runtime/types.ts +7 -2
@@ -0,0 +1,536 @@
1
+ import { getCurrentMode } from "./state.js";
2
+ import { sendToParent } from "./utils.js";
3
+ import { getLabels } from "./labels.js";
4
+ import { hideOverlays } from "./hover-overlay.js";
5
+ import { baseButton, createControlsLayer, cssAttr, setIcon } from "./controls-ui.js";
6
+
7
+ // Editor-only drag & drop of simple elements (texts, images). Movable elements carry
8
+ // `data-upstart-move-id` (their source range in the registry) and `data-upstart-move-scope`
9
+ // (the component they belong to), both injected by vite-plugin-upstart-attrs. A grip shows on
10
+ // the hovered element; dragging it shows an insertion bar before/after another element of the
11
+ // same component at the same level (sibling or cousin). The drop moves the element in the preview DOM only and stages the move: a bar
12
+ // lets the user undo the last move, discard them all, or apply them as a single
13
+ // `moveElements` source edit (then the editor rebuilds and reloads the preview). React keeps
14
+ // working on the moved nodes thanks to the DOM guard.
15
+
16
+ const MOVE_ID_ATTR = "data-upstart-move-id";
17
+ const MOVE_SCOPE_ATTR = "data-upstart-move-scope";
18
+ // Set on the element a hovered handle would move, to outline it.
19
+ const PREVIEW_ATTR = "data-upstart-move-preview";
20
+ // Handles shown at once: the hovered element and its closest movable ancestors (whole blocks).
21
+ const MAX_HANDLES = 3;
22
+ // Handles are larger on touch screens, so a finger can grab them.
23
+ const HANDLE_SIZE_FINE = 16;
24
+ const HANDLE_SIZE_COARSE = 28;
25
+
26
+ function isCoarsePointer(): boolean {
27
+ return typeof matchMedia === "function" && matchMedia("(pointer: coarse)").matches;
28
+ }
29
+
30
+ function handleSize(): number {
31
+ return isCoarsePointer() ? HANDLE_SIZE_COARSE : HANDLE_SIZE_FINE;
32
+ }
33
+
34
+ // How far (px) from a target the pointer may be and still drop next to it.
35
+ const SNAP_DISTANCE = 48;
36
+ // Distance (px) from the viewport edge at which the page scrolls while dragging.
37
+ const AUTO_SCROLL_EDGE = 40;
38
+ const AUTO_SCROLL_STEP = 12;
39
+
40
+ export type DropPosition = "before" | "after";
41
+
42
+ export interface DropTarget {
43
+ element: HTMLElement;
44
+ position: DropPosition;
45
+ /** Insertion axis: "vertical" for stacked blocks, "horizontal" for side-by-side elements. */
46
+ axis: "vertical" | "horizontal";
47
+ }
48
+
49
+ let layer: HTMLDivElement | null = null;
50
+ let isInitialized = false;
51
+ let rafId: number | null = null;
52
+ let hoveredItem: HTMLElement | null = null;
53
+ // Touch screens have no hover: a tap selects the element instead, and it stays outlined.
54
+ let lastPointerType = "mouse";
55
+ let indicator: HTMLDivElement | null = null;
56
+ const buttonOwner = new WeakMap<HTMLElement, HTMLElement>();
57
+
58
+ let drag: { source: HTMLElement; target: DropTarget | null } | null = null;
59
+
60
+ // Staged moves, oldest first. Each one remembers where the node was, so undoing them in
61
+ // reverse order restores the original DOM exactly.
62
+ interface StagedMove {
63
+ node: HTMLElement;
64
+ fromParent: Node;
65
+ fromNext: Node | null;
66
+ id: string;
67
+ targetId: string;
68
+ position: DropPosition;
69
+ }
70
+ const staged: StagedMove[] = [];
71
+ let bar: HTMLDivElement | null = null;
72
+ let applying = false;
73
+
74
+ export function initMoveControls(): void {
75
+ if (typeof document === "undefined" || isInitialized) return;
76
+ isInitialized = true;
77
+ window.addEventListener("scroll", scheduleRefresh, { passive: true, capture: true });
78
+ window.addEventListener("resize", scheduleRefresh, { passive: true });
79
+ document.addEventListener("mouseover", onHoverMove, { passive: true });
80
+ document.addEventListener("pointerdown", onTapSelect, { capture: true, passive: true });
81
+ document.addEventListener("keydown", onUndoKey);
82
+ installStyles();
83
+ }
84
+
85
+ // Stylesheet (not inline styles) so the pending-moves bar can switch to a column layout on
86
+ // narrow viewports.
87
+ function installStyles(): void {
88
+ if (document.getElementById("upstart-move-styles")) return;
89
+ const style = document.createElement("style");
90
+ style.id = "upstart-move-styles";
91
+ style.textContent = `
92
+ [${PREVIEW_ATTR}] { outline: 2px dashed #7270c6 !important; outline-offset: 2px; }
93
+ #upstart-move-bar {
94
+ position: fixed; left: 50%; bottom: 16px; transform: translateX(-50%); z-index: 2147483647;
95
+ display: flex; align-items: center; gap: 8px; padding: 6px 6px 6px 14px; border-radius: 999px;
96
+ background: #1f1d3a; color: #fff; box-shadow: 0 4px 16px rgba(0,0,0,0.3);
97
+ font: 500 13px/1.2 system-ui, -apple-system, sans-serif; white-space: nowrap; pointer-events: auto;
98
+ box-sizing: border-box;
99
+ }
100
+ #upstart-move-bar button {
101
+ padding: 6px 12px; border-radius: 999px; border: 1px solid rgba(255,255,255,0.35);
102
+ background: transparent; color: #fff; font: inherit; cursor: pointer; box-sizing: border-box;
103
+ }
104
+ #upstart-move-bar button[data-primary] { background: #16a34a; border-color: #16a34a; }
105
+ #upstart-move-bar button:disabled { opacity: 0.6; cursor: wait; }
106
+ @media (max-width: 560px) {
107
+ #upstart-move-bar {
108
+ flex-direction: column; align-items: stretch; width: calc(100% - 32px); max-width: 360px;
109
+ padding: 12px; gap: 8px; border-radius: 16px; white-space: normal; text-align: center;
110
+ }
111
+ #upstart-move-bar button { width: 100%; padding: 10px 12px; }
112
+ }
113
+ `;
114
+ document.head.appendChild(style);
115
+ }
116
+
117
+ export function refreshMoveControls(): void {
118
+ if (typeof document === "undefined") return;
119
+ if (getCurrentMode() !== "move") {
120
+ hideMoveControls();
121
+ return;
122
+ }
123
+ // The layer holds the insertion bar while dragging: leave it to the drag handlers.
124
+ if (drag) return;
125
+ renderBar();
126
+
127
+ const root = ensureLayer();
128
+ root.replaceChildren();
129
+ clearPreview();
130
+ if (!hoveredItem || !document.contains(hoveredItem)) return;
131
+ if (lastPointerType !== "mouse") hoveredItem.setAttribute(PREVIEW_ATTR, "");
132
+
133
+ // One handle per element of the chain, at its top-left corner, nudged to the right when
134
+ // it would cover the handle of an element sharing the same corner. Placed from the largest
135
+ // element to the smallest, so handles read outer block → inner element, left to right.
136
+ const size = handleSize();
137
+ const placed: { top: number; left: number }[] = [];
138
+ for (const el of handleChain(hoveredItem).reverse()) {
139
+ const rect = el.getBoundingClientRect();
140
+ const top = rect.top + window.scrollY - size / 2;
141
+ let left = rect.left + window.scrollX - size / 2;
142
+ while (placed.some((p) => Math.abs(p.top - top) < size + 2 && Math.abs(p.left - left) < size + 2)) {
143
+ left += size + 4;
144
+ }
145
+ placed.push({ top, left });
146
+ const btn = makeHandle(el, top, left, size);
147
+ buttonOwner.set(btn, hoveredItem);
148
+ root.appendChild(btn);
149
+ }
150
+ }
151
+
152
+ /**
153
+ * The elements offered for moving when `item` is hovered: itself and its closest movable
154
+ * ancestors, so a whole block can be grabbed from any of its children. Elements alone in
155
+ * their component (nowhere to go) are skipped.
156
+ */
157
+ function handleChain(item: HTMLElement): HTMLElement[] {
158
+ const chain: HTMLElement[] = [];
159
+ for (
160
+ let el: HTMLElement | null = item;
161
+ el && chain.length < MAX_HANDLES;
162
+ el = el.parentElement?.closest<HTMLElement>(`[${MOVE_ID_ATTR}]`) ?? null
163
+ ) {
164
+ if (candidatesFor(el).length > 0) chain.push(el);
165
+ }
166
+ return chain;
167
+ }
168
+
169
+ function clearPreview(): void {
170
+ for (const el of document.querySelectorAll(`[${PREVIEW_ATTR}]`)) el.removeAttribute(PREVIEW_ATTR);
171
+ }
172
+
173
+ export function hideMoveControls(): void {
174
+ if (drag) endDrag(false);
175
+ hoveredItem = null;
176
+ if (layer) layer.replaceChildren();
177
+ clearPreview();
178
+ bar?.remove();
179
+ bar = null;
180
+ }
181
+
182
+ function ensureLayer(): HTMLDivElement {
183
+ if (!layer || !document.contains(layer)) layer = createControlsLayer("upstart-move-controls");
184
+ return layer;
185
+ }
186
+
187
+ // Touch: tapping the page selects the movable element under the finger (or clears the
188
+ // selection), since there is no hover to reveal its handles.
189
+ function onTapSelect(e: PointerEvent): void {
190
+ lastPointerType = e.pointerType;
191
+ if (e.pointerType === "mouse" || getCurrentMode() !== "move" || drag) return;
192
+ const target = e.target as HTMLElement | null;
193
+ if (!target || layer?.contains(target) || bar?.contains(target)) return;
194
+ hoveredItem = target.closest<HTMLElement>(`[${MOVE_ID_ATTR}]`);
195
+ scheduleRefresh();
196
+ }
197
+
198
+ function onHoverMove(e: MouseEvent): void {
199
+ // Compatibility mouse events follow a tap: the tap already chose the element.
200
+ if (getCurrentMode() !== "move" || drag || lastPointerType !== "mouse") return;
201
+ const owner = hoverOwner(e.target as HTMLElement | null);
202
+ if (owner !== hoveredItem) {
203
+ hoveredItem = owner;
204
+ scheduleRefresh();
205
+ }
206
+ }
207
+
208
+ // The movable element under the pointer, or the one owning the hovered handle.
209
+ function hoverOwner(target: HTMLElement | null): HTMLElement | null {
210
+ if (!target) return null;
211
+ if (layer?.contains(target)) {
212
+ const owner = buttonOwner.get(target);
213
+ return owner && document.contains(owner) ? owner : null;
214
+ }
215
+ return target.closest<HTMLElement>(`[${MOVE_ID_ATTR}]`);
216
+ }
217
+
218
+ interface TreeNode {
219
+ parentElement: TreeNode | null;
220
+ }
221
+
222
+ /**
223
+ * True when `el` is at the same level as `source`: a sibling (same container), or a cousin
224
+ * (a child of a container next to the source's own — the other column, the next card).
225
+ */
226
+ export function isSameLevel(source: TreeNode, el: TreeNode): boolean {
227
+ if (el === source) return false;
228
+ const parent = source.parentElement;
229
+ const elParent = el.parentElement;
230
+ if (!parent || !elParent) return false;
231
+ if (elParent === parent) return true;
232
+ // A cousin's container must not be the source itself (that would be a child of it).
233
+ return (
234
+ elParent !== source && elParent.parentElement !== null && elParent.parentElement === parent.parentElement
235
+ );
236
+ }
237
+
238
+ /**
239
+ * Elements `source` can be dropped next to: movable elements of the same component that are
240
+ * at the same level (siblings and cousins, see `isSameLevel`).
241
+ */
242
+ export function candidatesFor(source: HTMLElement): HTMLElement[] {
243
+ const scope = source.getAttribute(MOVE_SCOPE_ATTR);
244
+ if (!scope) return [];
245
+ return [...document.querySelectorAll<HTMLElement>(`[${MOVE_SCOPE_ATTR}="${cssAttr(scope)}"]`)].filter(
246
+ (el) => isSameLevel(source, el),
247
+ );
248
+ }
249
+
250
+ function axisOf(el: HTMLElement): DropTarget["axis"] {
251
+ const display = getComputedStyle(el).display;
252
+ if (display.startsWith("inline")) return "horizontal";
253
+ const parent = el.parentElement;
254
+ if (!parent) return "vertical";
255
+ const style = getComputedStyle(parent);
256
+ if (style.display.includes("grid")) return "horizontal";
257
+ if (style.display.includes("flex") && !style.flexDirection.startsWith("column")) return "horizontal";
258
+ return "vertical";
259
+ }
260
+
261
+ interface Rect {
262
+ left: number;
263
+ top: number;
264
+ right: number;
265
+ bottom: number;
266
+ width: number;
267
+ height: number;
268
+ }
269
+
270
+ /**
271
+ * The item a drop at (x, y) lands next to: the one under the pointer (the smallest, should
272
+ * several overlap), or else the nearest one within SNAP_DISTANCE.
273
+ */
274
+ export function pickNearest<T>(
275
+ items: { item: T; rect: Rect }[],
276
+ x: number,
277
+ y: number,
278
+ ): { item: T; rect: Rect } | null {
279
+ let best: { item: T; rect: Rect; score: number } | null = null;
280
+ for (const { item, rect } of items) {
281
+ if (rect.width === 0 && rect.height === 0) continue;
282
+ const dx = Math.max(rect.left - x, 0, x - rect.right);
283
+ const dy = Math.max(rect.top - y, 0, y - rect.bottom);
284
+ const distance = Math.hypot(dx, dy);
285
+ if (distance > SNAP_DISTANCE) continue;
286
+ const score = distance === 0 ? -1 / Math.max(rect.width * rect.height, 1) : distance;
287
+ if (!best || score < best.score) best = { item, rect, score };
288
+ }
289
+ return best && { item: best.item, rect: best.rect };
290
+ }
291
+
292
+ /** Before or after an element, from the pointer vs the element's middle along the axis. */
293
+ export function dropPosition(rect: Rect, axis: DropTarget["axis"], x: number, y: number): DropPosition {
294
+ const after = axis === "horizontal" ? x > rect.left + rect.width / 2 : y > rect.top + rect.height / 2;
295
+ return after ? "after" : "before";
296
+ }
297
+
298
+ function findDropTarget(source: HTMLElement, x: number, y: number): DropTarget | null {
299
+ const items = candidatesFor(source).map((item) => ({ item, rect: item.getBoundingClientRect() }));
300
+ const best = pickNearest(items, x, y);
301
+ if (!best) return null;
302
+ const axis = axisOf(best.item);
303
+ return { element: best.item, position: dropPosition(best.rect, axis, x, y), axis };
304
+ }
305
+
306
+ function makeHandle(item: HTMLElement, top: number, left: number, size: number): HTMLButtonElement {
307
+ const btn = baseButton(top, left, size, "#7270c6");
308
+ setIcon(btn, "grip", Math.round(size * 0.7));
309
+ btn.title = `${getLabels().dragToMove} (<${item.tagName.toLowerCase()}>)`;
310
+ btn.style.cursor = "grab";
311
+ // Without it, a touch drag is taken for a page scroll: the browser cancels the pointer
312
+ // events, so neither the insertion bar nor the drop would happen.
313
+ btn.style.touchAction = "none";
314
+ btn.addEventListener("pointerenter", () => {
315
+ clearPreview();
316
+ item.setAttribute(PREVIEW_ATTR, "");
317
+ });
318
+ btn.addEventListener("pointerleave", () => item.removeAttribute(PREVIEW_ATTR));
319
+ btn.addEventListener("pointerdown", (e) => {
320
+ e.preventDefault();
321
+ e.stopPropagation();
322
+ // Capture keeps the pointerup (and so the click) on the handle, so releasing the
323
+ // button over the page never reaches the editor's click handler.
324
+ btn.setPointerCapture?.(e.pointerId);
325
+ startDrag(item, btn);
326
+ });
327
+ btn.addEventListener("click", (e) => {
328
+ e.preventDefault();
329
+ e.stopPropagation();
330
+ });
331
+ return btn;
332
+ }
333
+
334
+ function startDrag(source: HTMLElement, handle: HTMLElement): void {
335
+ drag = { source, target: null };
336
+ hideOverlays();
337
+ clearPreview();
338
+ // Keep only the grabbed handle: it holds the pointer capture, so it must stay in the DOM.
339
+ for (const child of [...(layer?.children ?? [])]) if (child !== handle) child.remove();
340
+ source.style.opacity = "0.5";
341
+ document.body.style.userSelect = "none";
342
+ document.documentElement.style.cursor = "grabbing";
343
+ document.addEventListener("pointermove", onDragMove);
344
+ document.addEventListener("pointerup", onDragDrop, { once: true });
345
+ document.addEventListener("pointercancel", onDragCancel, { once: true });
346
+ document.addEventListener("keydown", onDragKey, true);
347
+ }
348
+
349
+ function onDragCancel(): void {
350
+ endDrag(false);
351
+ }
352
+
353
+ function onDragMove(e: PointerEvent): void {
354
+ if (!drag) return;
355
+ if (e.clientY < AUTO_SCROLL_EDGE) window.scrollBy(0, -AUTO_SCROLL_STEP);
356
+ else if (e.clientY > window.innerHeight - AUTO_SCROLL_EDGE) window.scrollBy(0, AUTO_SCROLL_STEP);
357
+ drag.target = findDropTarget(drag.source, e.clientX, e.clientY);
358
+ drawIndicator(drag.target);
359
+ }
360
+
361
+ function onDragDrop(): void {
362
+ endDrag(true);
363
+ }
364
+
365
+ function onDragKey(e: KeyboardEvent): void {
366
+ if (e.key !== "Escape") return;
367
+ e.preventDefault();
368
+ e.stopPropagation();
369
+ endDrag(false);
370
+ }
371
+
372
+ function endDrag(commit: boolean): void {
373
+ document.removeEventListener("pointermove", onDragMove);
374
+ document.removeEventListener("pointerup", onDragDrop);
375
+ document.removeEventListener("pointercancel", onDragCancel);
376
+ document.removeEventListener("keydown", onDragKey, true);
377
+ document.body.style.removeProperty("user-select");
378
+ document.documentElement.style.removeProperty("cursor");
379
+ const current = drag;
380
+ drag = null;
381
+ if (!current) return;
382
+ current.source.style.removeProperty("opacity");
383
+ layer?.replaceChildren();
384
+ indicator = null;
385
+ if (commit && current.target) stageMove(current.source, current.target);
386
+ scheduleRefresh();
387
+ }
388
+
389
+ // Dropping right where the element already is (next to its own neighbour) changes nothing.
390
+ function isNoOp(source: HTMLElement, target: DropTarget): boolean {
391
+ const el = target.element;
392
+ return target.position === "after"
393
+ ? el.nextElementSibling === source
394
+ : el.previousElementSibling === source;
395
+ }
396
+
397
+ function stageMove(source: HTMLElement, target: DropTarget): void {
398
+ const id = source.getAttribute(MOVE_ID_ATTR);
399
+ const targetId = target.element.getAttribute(MOVE_ID_ATTR);
400
+ const fromParent = source.parentNode;
401
+ if (!id || !targetId || !fromParent || isNoOp(source, target)) return;
402
+ staged.push({
403
+ node: source,
404
+ fromParent,
405
+ fromNext: source.nextSibling,
406
+ id,
407
+ targetId,
408
+ position: target.position,
409
+ });
410
+ if (target.position === "after") target.element.after(source);
411
+ else target.element.before(source);
412
+ }
413
+
414
+ /** Undo the last staged move (the DOM goes back to where it was before that drop). */
415
+ export function undoLastMove(): void {
416
+ const move = staged.pop();
417
+ if (!move) return;
418
+ const { node, fromParent, fromNext } = move;
419
+ fromParent.insertBefore(node, fromNext && fromNext.parentNode === fromParent ? fromNext : null);
420
+ scheduleRefresh();
421
+ }
422
+
423
+ export function discardMoves(): void {
424
+ while (staged.length > 0) undoLastMove();
425
+ }
426
+
427
+ /** The staged moves, as sent to the editor on apply. */
428
+ export function getStagedMoves(): { id: string; targetId: string; position: DropPosition }[] {
429
+ return staged.map(({ id, targetId, position }) => ({ id, targetId, position }));
430
+ }
431
+
432
+ function applyMoves(): void {
433
+ if (staged.length === 0 || applying) return;
434
+ // The editor rebuilds then reloads the preview, which resets this runtime. Until then the
435
+ // staged moves are kept, so a failed apply can still be undone.
436
+ applying = true;
437
+ sendToParent({ type: "text-edit", payload: { action: "moveElements", moves: getStagedMoves() } });
438
+ renderBar();
439
+ }
440
+
441
+ /** The editor could not apply the moves: keep them staged and let the user retry or undo. */
442
+ export function onMovesApplyFailed(): void {
443
+ applying = false;
444
+ renderBar();
445
+ }
446
+
447
+ // Cmd/Ctrl+Z undoes the last move, unless the focus is in a form field.
448
+ function onUndoKey(e: KeyboardEvent): void {
449
+ if (staged.length === 0 || applying || getCurrentMode() !== "move") return;
450
+ if (e.key.toLowerCase() !== "z" || !(e.metaKey || e.ctrlKey) || e.shiftKey || e.altKey) return;
451
+ const active = document.activeElement as HTMLElement | null;
452
+ if (active?.isContentEditable || active?.closest("input, textarea, select")) return;
453
+ e.preventDefault();
454
+ undoLastMove();
455
+ }
456
+
457
+ function renderBar(): void {
458
+ // A navigation replaced the page: the staged nodes are gone along with their moves.
459
+ if (staged.some((m) => !document.contains(m.node))) {
460
+ staged.length = 0;
461
+ applying = false;
462
+ }
463
+ if (staged.length === 0) {
464
+ bar?.remove();
465
+ bar = null;
466
+ return;
467
+ }
468
+ if (!bar || !document.contains(bar)) {
469
+ bar = document.createElement("div");
470
+ bar.id = "upstart-move-bar";
471
+ document.body.appendChild(bar);
472
+ }
473
+ const labels = getLabels();
474
+ const text = document.createElement("span");
475
+ text.textContent = `${labels.pendingMoves} ${staged.length}`;
476
+ bar.replaceChildren(
477
+ text,
478
+ barButton(labels.undoMove, false, undoLastMove),
479
+ barButton(labels.discardMoves, false, discardMoves),
480
+ barButton(labels.applyMoves, true, applyMoves),
481
+ );
482
+ }
483
+
484
+ function barButton(label: string, primary: boolean, action: () => void): HTMLButtonElement {
485
+ const btn = document.createElement("button");
486
+ btn.type = "button";
487
+ btn.textContent = label;
488
+ btn.disabled = applying;
489
+ if (primary) btn.setAttribute("data-primary", "");
490
+ btn.addEventListener("click", (e) => {
491
+ e.preventDefault();
492
+ e.stopPropagation();
493
+ action();
494
+ });
495
+ return btn;
496
+ }
497
+
498
+ function drawIndicator(target: DropTarget | null): void {
499
+ indicator?.remove();
500
+ indicator = null;
501
+ if (!target) return;
502
+ const root = ensureLayer();
503
+ const r = target.element.getBoundingClientRect();
504
+ const line = document.createElement("div");
505
+ const thickness = 3;
506
+ const x = window.scrollX;
507
+ const y = window.scrollY;
508
+ const box =
509
+ target.axis === "vertical"
510
+ ? {
511
+ top: (target.position === "before" ? r.top - 4 : r.bottom + 4) + y - thickness / 2,
512
+ left: r.left + x,
513
+ width: r.width,
514
+ height: thickness,
515
+ }
516
+ : {
517
+ top: r.top + y,
518
+ left: (target.position === "before" ? r.left - 4 : r.right + 4) + x - thickness / 2,
519
+ width: thickness,
520
+ height: r.height,
521
+ };
522
+ line.setAttribute("data-upstart-drop-indicator", target.position);
523
+ line.style.cssText =
524
+ `position: absolute; top: ${box.top}px; left: ${box.left}px; width: ${box.width}px; ` +
525
+ `height: ${box.height}px; background: #7270c6; border-radius: 2px; pointer-events: none;`;
526
+ root.appendChild(line);
527
+ indicator = line;
528
+ }
529
+
530
+ function scheduleRefresh(): void {
531
+ if (rafId !== null) return;
532
+ rafId = requestAnimationFrame(() => {
533
+ rafId = null;
534
+ refreshMoveControls();
535
+ });
536
+ }
@@ -5,6 +5,7 @@ import type {
5
5
  PayloadArrayItemAdd,
6
6
  PayloadArrayItemDelete,
7
7
  PayloadArraySet,
8
+ PayloadMoveElements,
8
9
  } from "~/upstart-editor-api";
9
10
  import type { RuntimeLabels } from "./labels.js";
10
11
 
@@ -15,8 +16,9 @@ export type { RuntimeLabels } from "./labels.js";
15
16
  * - preview: navigate the site, no editing
16
17
  * - edit: click an element to edit it (inspector / inline text)
17
18
  * - select: pick multiple elements to attach to the next chat message
19
+ * - move: drag & drop elements, staged in the preview until applied
18
20
  */
19
- export type EditorMode = "preview" | "edit" | "select";
21
+ export type EditorMode = "preview" | "edit" | "select" | "move";
20
22
 
21
23
  /**
22
24
  * A reference to an element picked in "select" mode. Built from the data-upstart-*
@@ -104,7 +106,8 @@ export type EditorMessage =
104
106
  | PayloadEditTextDirect
105
107
  | PayloadArrayItemAdd
106
108
  | PayloadArrayItemDelete
107
- | PayloadArraySet;
109
+ | PayloadArraySet
110
+ | PayloadMoveElements;
108
111
  }
109
112
  | { type: "editor-ready"; path?: string }
110
113
  | { type: "editor-navigated"; path?: string }
@@ -158,6 +161,8 @@ export type ParentMessage =
158
161
  | { type: "restore-scroll-position"; x: number; y: number }
159
162
  | { type: "clear-selection" }
160
163
  | { type: "deselect"; key: string }
164
+ /** Applying the staged element moves failed: they stay staged in the preview. */
165
+ | { type: "moves-apply-failed" }
161
166
  /** Translated labels for the controls the runtime renders (see `runtime/labels.ts`). */
162
167
  | { type: "set-labels"; labels: Partial<RuntimeLabels> };
163
168