@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.
- package/dist/upstart-editor-api.d.ts +58 -1
- package/dist/upstart-editor-api.d.ts.map +1 -1
- package/dist/upstart-editor-api.js +224 -1
- package/dist/upstart-editor-api.js.map +1 -1
- package/dist/vite-plugin-upstart-attrs.d.ts +1 -1
- package/dist/vite-plugin-upstart-attrs.d.ts.map +1 -1
- package/dist/vite-plugin-upstart-attrs.js +65 -2
- package/dist/vite-plugin-upstart-attrs.js.map +1 -1
- package/dist/vite-plugin-upstart-editor/runtime/array-controls.js +2 -24
- package/dist/vite-plugin-upstart-editor/runtime/array-controls.js.map +1 -1
- package/dist/vite-plugin-upstart-editor/runtime/click-handler.js +7 -1
- package/dist/vite-plugin-upstart-editor/runtime/click-handler.js.map +1 -1
- package/dist/vite-plugin-upstart-editor/runtime/controls-ui.js +33 -0
- package/dist/vite-plugin-upstart-editor/runtime/controls-ui.js.map +1 -0
- package/dist/vite-plugin-upstart-editor/runtime/index.d.ts.map +1 -1
- package/dist/vite-plugin-upstart-editor/runtime/index.js +23 -0
- package/dist/vite-plugin-upstart-editor/runtime/index.js.map +1 -1
- package/dist/vite-plugin-upstart-editor/runtime/labels.d.ts +5 -0
- package/dist/vite-plugin-upstart-editor/runtime/labels.d.ts.map +1 -1
- package/dist/vite-plugin-upstart-editor/runtime/labels.js +5 -0
- package/dist/vite-plugin-upstart-editor/runtime/labels.js.map +1 -1
- package/dist/vite-plugin-upstart-editor/runtime/move-controls.js +434 -0
- package/dist/vite-plugin-upstart-editor/runtime/move-controls.js.map +1 -0
- package/dist/vite-plugin-upstart-editor/runtime/types.d.ts +6 -3
- package/dist/vite-plugin-upstart-editor/runtime/types.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/tests/move-controls.test.ts +86 -0
- package/src/tests/upstart-editor-api-move-element.test.ts +220 -0
- package/src/tests/vite-plugin-upstart-attrs.test.ts +165 -4
- package/src/upstart-editor-api.ts +263 -0
- package/src/vite-plugin-upstart-attrs.ts +93 -2
- package/src/vite-plugin-upstart-editor/runtime/array-controls.ts +2 -40
- package/src/vite-plugin-upstart-editor/runtime/click-handler.ts +17 -3
- package/src/vite-plugin-upstart-editor/runtime/controls-ui.ts +48 -0
- package/src/vite-plugin-upstart-editor/runtime/index.ts +31 -0
- package/src/vite-plugin-upstart-editor/runtime/labels.ts +10 -0
- package/src/vite-plugin-upstart-editor/runtime/move-controls.ts +536 -0
- 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
|
|