@upstart.gg/vite-plugins 0.1.41 → 0.1.42

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 (37) hide show
  1. package/dist/upstart-editor-api.d.ts +59 -1
  2. package/dist/upstart-editor-api.d.ts.map +1 -1
  3. package/dist/upstart-editor-api.js +273 -4
  4. package/dist/upstart-editor-api.js.map +1 -1
  5. package/dist/vite-plugin-upstart-attrs.d.ts +2 -1
  6. package/dist/vite-plugin-upstart-attrs.d.ts.map +1 -1
  7. package/dist/vite-plugin-upstart-attrs.js +91 -4
  8. package/dist/vite-plugin-upstart-attrs.js.map +1 -1
  9. package/dist/vite-plugin-upstart-editor/runtime/array-controls.js +342 -0
  10. package/dist/vite-plugin-upstart-editor/runtime/array-controls.js.map +1 -0
  11. package/dist/vite-plugin-upstart-editor/runtime/click-handler.d.ts.map +1 -1
  12. package/dist/vite-plugin-upstart-editor/runtime/click-handler.js +29 -2
  13. package/dist/vite-plugin-upstart-editor/runtime/click-handler.js.map +1 -1
  14. package/dist/vite-plugin-upstart-editor/runtime/form-guard.js +80 -0
  15. package/dist/vite-plugin-upstart-editor/runtime/form-guard.js.map +1 -0
  16. package/dist/vite-plugin-upstart-editor/runtime/hover-overlay.d.ts.map +1 -1
  17. package/dist/vite-plugin-upstart-editor/runtime/hover-overlay.js +2 -1
  18. package/dist/vite-plugin-upstart-editor/runtime/hover-overlay.js.map +1 -1
  19. package/dist/vite-plugin-upstart-editor/runtime/index.d.ts.map +1 -1
  20. package/dist/vite-plugin-upstart-editor/runtime/index.js +26 -4
  21. package/dist/vite-plugin-upstart-editor/runtime/index.js.map +1 -1
  22. package/dist/vite-plugin-upstart-editor/runtime/text-editor.d.ts.map +1 -1
  23. package/dist/vite-plugin-upstart-editor/runtime/text-editor.js +40 -36
  24. package/dist/vite-plugin-upstart-editor/runtime/text-editor.js.map +1 -1
  25. package/dist/vite-plugin-upstart-editor/runtime/types.d.ts +20 -4
  26. package/dist/vite-plugin-upstart-editor/runtime/types.d.ts.map +1 -1
  27. package/package.json +3 -3
  28. package/src/tests/vite-plugin-upstart-attrs.test.ts +412 -0
  29. package/src/upstart-editor-api.ts +314 -5
  30. package/src/vite-plugin-upstart-attrs.ts +154 -4
  31. package/src/vite-plugin-upstart-editor/runtime/array-controls.ts +478 -0
  32. package/src/vite-plugin-upstart-editor/runtime/click-handler.ts +43 -0
  33. package/src/vite-plugin-upstart-editor/runtime/form-guard.ts +121 -0
  34. package/src/vite-plugin-upstart-editor/runtime/hover-overlay.ts +6 -1
  35. package/src/vite-plugin-upstart-editor/runtime/index.ts +20 -4
  36. package/src/vite-plugin-upstart-editor/runtime/text-editor.ts +49 -58
  37. package/src/vite-plugin-upstart-editor/runtime/types.ts +31 -4
@@ -0,0 +1,478 @@
1
+ import { getCurrentMode } from "./state.js";
2
+ import { sendToParent } from "./utils.js";
3
+
4
+ // Editor-only controls for inline-array .map() lists. Add/delete are staged LOCALLY
5
+ // (no server round-trip per action): "×" marks an item for removal, "+" inserts an
6
+ // editable draft pill, and a green "✓" below the "+" commits the whole batch at once
7
+ // via a single `arraySet` edit + rebuild. Per-item text editing stays live; arraySet
8
+ // reads the current item texts from the DOM so live edits are preserved.
9
+
10
+ const DEFAULT_NEW_ITEM = "New item";
11
+ const DRAFT_ATTR = "data-upstart-draft-item";
12
+
13
+ // Layout of the "+"/"✓" stack placed to the right of a list.
14
+ const BTN_SIZE = 22;
15
+ const STACK_GAP = 6; // vertical gap between "+" and "✓"
16
+ const BUTTON_MARGIN = 12; // left margin between the item and the buttons
17
+
18
+ let layer: HTMLDivElement | null = null;
19
+ let observer: MutationObserver | null = null;
20
+ let resizeObserver: ResizeObserver | null = null;
21
+ let rafId: number | null = null;
22
+ let isInitialized = false;
23
+ let isMutatingDom = false;
24
+
25
+ // Items observed for size changes (e.g. text edits widen a pill) so the "×" stays
26
+ // anchored to the item's corner. WeakSet keeps observe() calls idempotent.
27
+ const observedForResize = new WeakSet<Element>();
28
+
29
+ // arrayId -> set of original item indices currently marked for deletion.
30
+ const pendingDeletes = new Map<string, Set<number>>();
31
+
32
+ // In-progress drag (reorder). Tracked at document level so it survives overlay
33
+ // rebuilds; the overlay is frozen while a drag is active.
34
+ let drag: { arrayId: string; node: HTMLElement } | null = null;
35
+
36
+ // The list item currently hovered — its per-item "×"/"⠿" controls are the only
37
+ // ones shown (others stay hidden until hovered). buttonOwner lets a button keep its
38
+ // item "hovered" while the pointer sits on the button (which overlays the corner).
39
+ let hoveredItem: HTMLElement | null = null;
40
+ const buttonOwner = new WeakMap<HTMLElement, HTMLElement>();
41
+
42
+ export function initArrayControls(): void {
43
+ if (typeof document === "undefined" || isInitialized) return;
44
+ isInitialized = true;
45
+
46
+ window.addEventListener("scroll", scheduleRefresh, { passive: true, capture: true });
47
+ window.addEventListener("resize", scheduleRefresh, { passive: true });
48
+ document.addEventListener("mouseover", onHoverMove, { passive: true });
49
+
50
+ observer = new MutationObserver((mutations) => {
51
+ if (isMutatingDom) return;
52
+ for (const m of mutations) {
53
+ const t = m.target as Node;
54
+ if (layer && (t === layer || layer.contains(t))) continue;
55
+ scheduleRefresh();
56
+ return;
57
+ }
58
+ });
59
+ observer.observe(document.body, { childList: true, subtree: true });
60
+
61
+ if (typeof ResizeObserver !== "undefined") {
62
+ resizeObserver = new ResizeObserver(() => scheduleRefresh());
63
+ }
64
+ }
65
+
66
+ // Append a per-item button and record which item it belongs to, so hovering the
67
+ // button (which overlays the item's corner) keeps that item considered hovered.
68
+ function appendOwned(root: HTMLElement, btn: HTMLElement, owner: HTMLElement): void {
69
+ buttonOwner.set(btn, owner);
70
+ root.appendChild(btn);
71
+ }
72
+
73
+ // Track the hovered list item; refresh when it changes so its controls toggle.
74
+ function onHoverMove(e: MouseEvent): void {
75
+ if (getCurrentMode() !== "edit") return;
76
+ const owner = hoverOwner(e.target as HTMLElement | null);
77
+ if (owner !== hoveredItem) {
78
+ hoveredItem = owner;
79
+ scheduleRefresh();
80
+ }
81
+ }
82
+
83
+ // The list item under the pointer: either the item itself (or a descendant), or a
84
+ // per-item control button that belongs to one.
85
+ function hoverOwner(target: HTMLElement | null): HTMLElement | null {
86
+ if (!target) return null;
87
+ const item = target.closest<HTMLElement>(`[data-upstart-array-id]:not([${DRAFT_ATTR}]), [${DRAFT_ATTR}]`);
88
+ if (item) return item;
89
+ if (layer?.contains(target)) {
90
+ const owner = buttonOwner.get(target);
91
+ if (owner && document.contains(owner)) return owner;
92
+ }
93
+ return null;
94
+ }
95
+
96
+ // Observe an item once so the overlay re-anchors when the item resizes.
97
+ function observeResize(el: Element): void {
98
+ if (resizeObserver && !observedForResize.has(el)) {
99
+ observedForResize.add(el);
100
+ resizeObserver.observe(el);
101
+ }
102
+ }
103
+
104
+ export function refreshArrayControls(): void {
105
+ if (typeof document === "undefined") return;
106
+
107
+ if (getCurrentMode() !== "edit") {
108
+ hideArrayControls();
109
+ return;
110
+ }
111
+
112
+ // While dragging we move list nodes live; keep the overlay frozen and rebuild it
113
+ // once on drop (the drag is driven by document-level listeners, not the overlay).
114
+ if (drag) return;
115
+
116
+ isMutatingDom = true;
117
+ try {
118
+ const root = ensureLayer();
119
+ root.replaceChildren();
120
+
121
+ // Group original (non-draft) items by array id, ordered by loop index.
122
+ const groups = new Map<string, HTMLElement[]>();
123
+ for (const el of document.querySelectorAll<HTMLElement>("[data-upstart-array-id]")) {
124
+ if (el.hasAttribute(DRAFT_ATTR)) continue;
125
+ const id = el.dataset.upstartArrayId;
126
+ if (!id) continue;
127
+ (groups.get(id) ?? groups.set(id, []).get(id)!).push(el);
128
+ }
129
+
130
+ for (const [arrayId, items] of groups) {
131
+ items.sort((a, b) => itemIndex(a) - itemIndex(b));
132
+ const deletes = pendingDeletes.get(arrayId) ?? new Set<number>();
133
+ const drafts = draftPillsFor(arrayId);
134
+ const ordered = orderedItemsFor(arrayId);
135
+ // Reordering only makes sense with more than one item.
136
+ const draggable = ordered.length > 1;
137
+
138
+ // Original items: per-item controls show only on hover; a marked item always
139
+ // keeps its restore "×" (it has pointer-events:none, so it can't be hovered).
140
+ for (const item of items) {
141
+ observeResize(item);
142
+ const idx = itemIndex(item);
143
+ const marked = deletes.has(idx);
144
+ applyDeletionStyle(item, marked);
145
+ if (!marked && item !== hoveredItem) continue;
146
+ const rect = item.getBoundingClientRect();
147
+ if (draggable && !marked) appendOwned(root, makeDragHandle(arrayId, item, rect), item);
148
+ appendOwned(root, makeItemDeleteButton(arrayId, idx, marked, rect), item);
149
+ }
150
+
151
+ // Draft (added) pills: controls show on hover; each has its own "×".
152
+ for (const pill of drafts) {
153
+ observeResize(pill);
154
+ if (pill !== hoveredItem) continue;
155
+ const rect = pill.getBoundingClientRect();
156
+ if (draggable) appendOwned(root, makeDragHandle(arrayId, pill, rect), pill);
157
+ appendOwned(root, makeDraftRemoveButton(pill, rect), pill);
158
+ }
159
+
160
+ // "+" (and "✓" when there are pending changes) after the last item in DOM
161
+ // order, as a vertically-centred stack with a small left margin.
162
+ const anchor = ordered[ordered.length - 1];
163
+ if (!anchor) continue;
164
+ const a = anchor.getBoundingClientRect();
165
+
166
+ const finalCount = items.length - deletes.size + drafts.length;
167
+ const dirty = deletes.size > 0 || drafts.length > 0 || isReordered(arrayId);
168
+ const showApply = dirty && finalCount >= 1;
169
+
170
+ const stackHeight = showApply ? BTN_SIZE * 2 + STACK_GAP : BTN_SIZE;
171
+ const left = a.right + window.scrollX + BUTTON_MARGIN;
172
+ const top = a.top + window.scrollY + (a.height - stackHeight) / 2;
173
+
174
+ root.appendChild(makeAddButton(arrayId, top, left));
175
+ if (showApply) {
176
+ root.appendChild(makeApplyButton(arrayId, top + BTN_SIZE + STACK_GAP, left));
177
+ }
178
+ }
179
+ } finally {
180
+ isMutatingDom = false;
181
+ }
182
+ }
183
+
184
+ export function hideArrayControls(): void {
185
+ if (layer) layer.replaceChildren();
186
+ }
187
+
188
+ // ---------------------------------------------------------------------------
189
+ // DOM helpers
190
+ // ---------------------------------------------------------------------------
191
+
192
+ function ensureLayer(): HTMLDivElement {
193
+ if (layer) return layer;
194
+ layer = document.createElement("div");
195
+ layer.id = "upstart-array-controls";
196
+ layer.style.cssText =
197
+ "position: absolute; top: 0; left: 0; width: 0; height: 0; " +
198
+ "pointer-events: none; z-index: 2147483646;";
199
+ document.body.appendChild(layer);
200
+ return layer;
201
+ }
202
+
203
+ function itemIndex(el: HTMLElement): number {
204
+ const n = el.dataset.upstartArrayIndex ? Number(el.dataset.upstartArrayIndex) : Number.NaN;
205
+ return Number.isFinite(n) ? n : 0;
206
+ }
207
+
208
+ function originalItemsFor(arrayId: string): HTMLElement[] {
209
+ return [
210
+ ...document.querySelectorAll<HTMLElement>(
211
+ `[data-upstart-array-id="${cssAttr(arrayId)}"]:not([${DRAFT_ATTR}])`,
212
+ ),
213
+ ].sort((a, b) => itemIndex(a) - itemIndex(b));
214
+ }
215
+
216
+ function draftPillsFor(arrayId: string): HTMLElement[] {
217
+ return [...document.querySelectorAll<HTMLElement>(`[${DRAFT_ATTR}="${cssAttr(arrayId)}"]`)];
218
+ }
219
+
220
+ // All items of an array (originals + drafts) in DOM order — this is the order the
221
+ // list is rendered in and the order applied to the source on ✓.
222
+ function orderedItemsFor(arrayId: string): HTMLElement[] {
223
+ const sel = `[data-upstart-array-id="${cssAttr(arrayId)}"]:not([${DRAFT_ATTR}]), [${DRAFT_ATTR}="${cssAttr(arrayId)}"]`;
224
+ return [...document.querySelectorAll<HTMLElement>(sel)];
225
+ }
226
+
227
+ // True when the original items no longer appear in their source order (i.e. the
228
+ // user has dragged at least one item), so the ✓ apply button should be offered.
229
+ function isReordered(arrayId: string): boolean {
230
+ let prev = -1;
231
+ for (const el of orderedItemsFor(arrayId)) {
232
+ if (el.hasAttribute(DRAFT_ATTR)) continue;
233
+ const idx = itemIndex(el);
234
+ if (idx < prev) return true;
235
+ prev = idx;
236
+ }
237
+ return false;
238
+ }
239
+
240
+ function cssAttr(value: string): string {
241
+ return value.replace(/["\\]/g, "\\$&");
242
+ }
243
+
244
+ function textOf(el: HTMLElement): string {
245
+ return (el.textContent ?? "").trim();
246
+ }
247
+
248
+ function applyDeletionStyle(item: HTMLElement, marked: boolean): void {
249
+ if (marked) {
250
+ item.style.opacity = "0.4";
251
+ item.style.textDecoration = "line-through";
252
+ item.style.pointerEvents = "none";
253
+ } else {
254
+ item.style.removeProperty("opacity");
255
+ item.style.removeProperty("text-decoration");
256
+ item.style.removeProperty("pointer-events");
257
+ }
258
+ }
259
+
260
+ // ---------------------------------------------------------------------------
261
+ // Buttons
262
+ // ---------------------------------------------------------------------------
263
+
264
+ // Geometric SVG icons — centred by the flex container + viewBox, so they don't
265
+ // depend on the host page's font metrics (which left text glyphs mis-aligned).
266
+ const ICON_PATHS: Record<string, string> = {
267
+ plus: '<path d="M12 5v14M5 12h14"/>',
268
+ cross: '<path d="M6 6 18 18M18 6 6 18"/>',
269
+ check: '<path d="M5 12.5 10 17.5 19 7"/>',
270
+ restore: '<path d="M3 4v5h5"/><path d="M3.5 9a8.5 8.5 0 1 0 2.2-3.8L3 9"/>',
271
+ grip: '<circle cx="9" cy="6" r="1.5"/><circle cx="15" cy="6" r="1.5"/><circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/><circle cx="9" cy="18" r="1.5"/><circle cx="15" cy="18" r="1.5"/>',
272
+ };
273
+
274
+ function setIcon(btn: HTMLElement, name: keyof typeof ICON_PATHS, px: number): void {
275
+ const dots = name === "grip";
276
+ btn.innerHTML =
277
+ `<svg width="${px}" height="${px}" viewBox="0 0 24 24" ` +
278
+ `fill="${dots ? "currentColor" : "none"}" stroke="${dots ? "none" : "currentColor"}" ` +
279
+ 'stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" ' +
280
+ `style="display:block;pointer-events:none">${ICON_PATHS[name]}</svg>`;
281
+ }
282
+
283
+ function baseButton(top: number, left: number, size: number, bg: string): HTMLButtonElement {
284
+ const btn = document.createElement("button");
285
+ btn.type = "button";
286
+ btn.style.cssText =
287
+ `position: absolute; top: ${top}px; left: ${left}px; width: ${size}px; height: ${size}px; ` +
288
+ "padding: 0; display: flex; align-items: center; justify-content: center; " +
289
+ `background: ${bg}; color: #fff; border: 1px solid #fff; border-radius: 50%; ` +
290
+ "line-height: 1; cursor: pointer; pointer-events: auto; box-shadow: 0 1px 3px rgba(0,0,0,0.3);";
291
+ return btn;
292
+ }
293
+
294
+ function makeItemDeleteButton(
295
+ arrayId: string,
296
+ index: number,
297
+ marked: boolean,
298
+ rect: DOMRect,
299
+ ): HTMLButtonElement {
300
+ const btn = baseButton(rect.top + window.scrollY - 8, rect.right + window.scrollX - 8, 16, "#ef4444");
301
+ setIcon(btn, marked ? "restore" : "cross", 10);
302
+ btn.title = marked ? "Keep item" : "Remove item";
303
+ btn.addEventListener("click", (e) => {
304
+ e.preventDefault();
305
+ e.stopPropagation();
306
+ const set = pendingDeletes.get(arrayId) ?? new Set<number>();
307
+ if (set.has(index)) set.delete(index);
308
+ else set.add(index);
309
+ if (set.size) pendingDeletes.set(arrayId, set);
310
+ else pendingDeletes.delete(arrayId);
311
+ refreshArrayControls();
312
+ });
313
+ return btn;
314
+ }
315
+
316
+ function makeDraftRemoveButton(pill: HTMLElement, rect: DOMRect): HTMLButtonElement {
317
+ const btn = baseButton(rect.top + window.scrollY - 8, rect.right + window.scrollX - 8, 16, "#ef4444");
318
+ setIcon(btn, "cross", 10);
319
+ btn.title = "Remove item";
320
+ btn.addEventListener("click", (e) => {
321
+ e.preventDefault();
322
+ e.stopPropagation();
323
+ pill.remove();
324
+ refreshArrayControls();
325
+ });
326
+ return btn;
327
+ }
328
+
329
+ function makeAddButton(arrayId: string, top: number, left: number): HTMLButtonElement {
330
+ const btn = baseButton(top, left, 22, "#7270c6");
331
+ setIcon(btn, "plus", 14);
332
+ btn.title = "Add item";
333
+ btn.addEventListener("click", (e) => {
334
+ e.preventDefault();
335
+ e.stopPropagation();
336
+ addDraftPill(arrayId);
337
+ });
338
+ return btn;
339
+ }
340
+
341
+ function makeApplyButton(arrayId: string, top: number, left: number): HTMLButtonElement {
342
+ const btn = baseButton(top, left, 22, "#16a34a");
343
+ setIcon(btn, "check", 13);
344
+ btn.title = "Apply changes";
345
+ btn.addEventListener("click", (e) => {
346
+ e.preventDefault();
347
+ e.stopPropagation();
348
+ applyArray(arrayId);
349
+ });
350
+ return btn;
351
+ }
352
+
353
+ // Grip centred on the item's top edge (away from the neighbour's "×" corners);
354
+ // press and drag to reorder.
355
+ function makeDragHandle(arrayId: string, node: HTMLElement, rect: DOMRect): HTMLButtonElement {
356
+ const top = rect.top + window.scrollY - 8;
357
+ const left = rect.left + window.scrollX + rect.width / 2 - 8;
358
+ const btn = baseButton(top, left, 16, "#6b7280");
359
+ setIcon(btn, "grip", 12);
360
+ btn.title = "Drag to reorder";
361
+ btn.style.cursor = "grab";
362
+ btn.addEventListener("pointerdown", (e) => {
363
+ e.preventDefault();
364
+ e.stopPropagation();
365
+ startDrag(arrayId, node);
366
+ });
367
+ return btn;
368
+ }
369
+
370
+ // ---------------------------------------------------------------------------
371
+ // Drag to reorder
372
+ // ---------------------------------------------------------------------------
373
+
374
+ function startDrag(arrayId: string, node: HTMLElement): void {
375
+ drag = { arrayId, node };
376
+ node.style.opacity = "0.5";
377
+ document.body.style.userSelect = "none";
378
+ // Drive the drag from document level so it survives overlay rebuilds.
379
+ document.addEventListener("pointermove", onDragMove);
380
+ document.addEventListener("pointerup", onDragEnd, { once: true });
381
+ }
382
+
383
+ function onDragMove(e: PointerEvent): void {
384
+ if (!drag) return;
385
+ const target = itemUnderPointer(drag.arrayId, e.clientX, e.clientY, drag.node);
386
+ if (!target) return;
387
+ const r = target.getBoundingClientRect();
388
+ // Insert before/after based on the pointer vs the target's horizontal midpoint.
389
+ if (e.clientX > r.left + r.width / 2) target.after(drag.node);
390
+ else target.before(drag.node);
391
+ }
392
+
393
+ function onDragEnd(): void {
394
+ document.removeEventListener("pointermove", onDragMove);
395
+ document.body.style.removeProperty("user-select");
396
+ if (drag) {
397
+ drag.node.style.removeProperty("opacity");
398
+ drag = null;
399
+ }
400
+ refreshArrayControls();
401
+ }
402
+
403
+ // The array item (original or draft) whose box contains the pointer, excluding the
404
+ // node being dragged.
405
+ function itemUnderPointer(arrayId: string, x: number, y: number, exclude: HTMLElement): HTMLElement | null {
406
+ for (const el of orderedItemsFor(arrayId)) {
407
+ if (el === exclude) continue;
408
+ const r = el.getBoundingClientRect();
409
+ if (x >= r.left && x <= r.right && y >= r.top && y <= r.bottom) return el;
410
+ }
411
+ return null;
412
+ }
413
+
414
+ // ---------------------------------------------------------------------------
415
+ // Actions
416
+ // ---------------------------------------------------------------------------
417
+
418
+ function addDraftPill(arrayId: string): void {
419
+ const template = originalItemsFor(arrayId)[0] ?? draftPillsFor(arrayId)[0];
420
+ if (!template?.parentElement) return;
421
+
422
+ const pill = template.cloneNode(false) as HTMLElement;
423
+ // Clone keeps the pill styling (class); strip identity/editor attrs so it is not
424
+ // picked up by the text editor, click handler, hover overlay or the array query.
425
+ for (const attr of [...pill.attributes]) {
426
+ if (attr.name.startsWith("data-upstart-") || attr.name === "contenteditable") {
427
+ pill.removeAttribute(attr.name);
428
+ }
429
+ }
430
+ pill.removeAttribute("style");
431
+ pill.setAttribute(DRAFT_ATTR, arrayId);
432
+ pill.setAttribute("contenteditable", "true");
433
+ pill.textContent = DEFAULT_NEW_ITEM;
434
+
435
+ // Insert after the current last pill of this array (drafts go at the end).
436
+ const last = draftPillsFor(arrayId).pop() ?? originalItemsFor(arrayId).pop();
437
+ if (last && last.parentElement === template.parentElement) {
438
+ last.after(pill);
439
+ } else {
440
+ template.parentElement.appendChild(pill);
441
+ }
442
+
443
+ // Focus + select so the user can type the name immediately.
444
+ pill.focus();
445
+ const range = document.createRange();
446
+ range.selectNodeContents(pill);
447
+ const sel = window.getSelection();
448
+ sel?.removeAllRanges();
449
+ sel?.addRange(range);
450
+
451
+ refreshArrayControls();
452
+ }
453
+
454
+ function applyArray(arrayId: string): void {
455
+ const deletes = pendingDeletes.get(arrayId) ?? new Set<number>();
456
+ const items: string[] = [];
457
+
458
+ // DOM order = the order the user sees (incl. any drag reordering).
459
+ for (const el of orderedItemsFor(arrayId)) {
460
+ if (!el.hasAttribute(DRAFT_ATTR) && deletes.has(itemIndex(el))) continue;
461
+ items.push(textOf(el));
462
+ }
463
+
464
+ if (items.length === 0) return;
465
+
466
+ // The server rewrites the whole array, rebuilds + swaps, then the editor reloads
467
+ // the iframe — which discards the local draft state, so just clear our marks.
468
+ pendingDeletes.delete(arrayId);
469
+ sendToParent({ type: "text-edit", payload: { action: "arraySet", arrayId, items } });
470
+ }
471
+
472
+ function scheduleRefresh(): void {
473
+ if (rafId !== null) return;
474
+ rafId = requestAnimationFrame(() => {
475
+ rafId = null;
476
+ refreshArrayControls();
477
+ });
478
+ }
@@ -26,6 +26,41 @@ const DAISY_VAR_NAMES = [
26
26
  "error-content",
27
27
  ];
28
28
 
29
+ interface EditableImageRef {
30
+ id: string;
31
+ src: string;
32
+ alt: string;
33
+ }
34
+
35
+ /**
36
+ * Collect editable images (elements carrying data-upstart-image-id) from the
37
+ * clicked element's nearest subtree. Starting at `start`, we climb ancestors
38
+ * until we find a subtree that contains at least one editable image. This lets
39
+ * us surface a background image (e.g. a hero <img> covered by overlays) even
40
+ * when the click lands on a sibling overlay rather than the image itself.
41
+ */
42
+ function collectImages(start: HTMLElement): EditableImageRef[] {
43
+ let el: HTMLElement | null = start;
44
+ for (let depth = 0; el && el !== document.body && depth < 8; depth++, el = el.parentElement) {
45
+ const found = new Set<HTMLElement>();
46
+ if (el.dataset.upstartImageId) found.add(el);
47
+ for (const node of el.querySelectorAll<HTMLElement>("[data-upstart-image-id]")) {
48
+ found.add(node);
49
+ }
50
+ if (found.size > 0) {
51
+ return Array.from(found).map((node) => {
52
+ const img = node as HTMLImageElement;
53
+ return {
54
+ id: node.dataset.upstartImageId as string,
55
+ src: img.currentSrc || img.src || "",
56
+ alt: img.alt || "",
57
+ };
58
+ });
59
+ }
60
+ }
61
+ return [];
62
+ }
63
+
29
64
  /**
30
65
  * Initialize click handler for className editing.
31
66
  */
@@ -64,6 +99,12 @@ function handleClick(event: MouseEvent): void {
64
99
  return;
65
100
  }
66
101
 
102
+ // Editor-only chrome (the array ×/+/✓ controls) and locally-staged draft list
103
+ // items handle their own clicks and must never be treated as element selection.
104
+ if (target.closest("#upstart-array-controls, [data-upstart-draft-item]")) {
105
+ return;
106
+ }
107
+
67
108
  if (target.closest(".upstart-editor-bubble-menu")) {
68
109
  console.info("[Upstart Editor] Click ignored: target is inside the bubble menu");
69
110
  return;
@@ -139,6 +180,7 @@ function handleClick(event: MouseEvent): void {
139
180
  recordId: datasourceEl.dataset.upstartRecordId,
140
181
  themeColors: {},
141
182
  bounds: { top: 0, left: 0, width: 0, height: 0, right: 0, bottom: 0 },
183
+ images: collectImages(datasourceEl),
142
184
  });
143
185
  return;
144
186
  }
@@ -215,6 +257,7 @@ function handleClick(event: MouseEvent): void {
215
257
  themeColors,
216
258
  bounds,
217
259
  viewportWidth: window.innerWidth,
260
+ images: collectImages(element),
218
261
  });
219
262
 
220
263
  console.log("[Upstart Editor] Element clicked:", componentName, hash);
@@ -0,0 +1,121 @@
1
+ let isInitialized = false;
2
+
3
+ // Methods that don't mutate data and must keep working: GET forms (search /
4
+ // navigation) and method="dialog" (e.g. a modal's close/backdrop buttons).
5
+ const SAFE_METHODS = new Set(["get", "dialog"]);
6
+
7
+ const TOOLTIP_MESSAGE = "This action isn’t available in the editor preview";
8
+ const TOOLTIP_DURATION_MS = 2500;
9
+
10
+ // Popover API renders in the browser top layer, so the tooltip sits above modal
11
+ // <dialog>s opened with showModal() (which also live in the top layer and beat any
12
+ // z-index). Fall back to absolute positioning where it isn't supported.
13
+ const SUPPORTS_POPOVER = typeof HTMLElement !== "undefined" && "showPopover" in HTMLElement.prototype;
14
+
15
+ let tooltip: HTMLDivElement | null = null;
16
+ let tooltipTimer: number | null = null;
17
+
18
+ /**
19
+ * Block mutating form submissions inside the previewed site so the user can't
20
+ * trigger data changes (which only hit the ephemeral preview DB and never persist)
21
+ * from the editor. This matters most in PREVIEW mode, where the site's buttons are
22
+ * live — in EDIT mode a click edits the button's text instead of submitting.
23
+ *
24
+ * We block the `submit` event rather than disabling buttons: one place covers native
25
+ * POST forms, React Router <Form>, button clicks and Enter-key submits. The
26
+ * capture-phase listener + stopImmediatePropagation runs before React's delegated
27
+ * handler, so React Router never processes the submission; preventDefault blocks a
28
+ * plain form's POST. GET and dialog submissions are left untouched. When a mutating
29
+ * submission is blocked, a tooltip on the triggering button explains why.
30
+ */
31
+ export function initFormGuard(): void {
32
+ if (typeof document === "undefined" || isInitialized) {
33
+ return;
34
+ }
35
+ isInitialized = true;
36
+
37
+ document.addEventListener(
38
+ "submit",
39
+ (event: SubmitEvent) => {
40
+ const form = event.target as HTMLFormElement | null;
41
+ const method = (
42
+ event.submitter?.getAttribute("formmethod") ||
43
+ form?.getAttribute("method") ||
44
+ "get"
45
+ ).toLowerCase();
46
+
47
+ if (SAFE_METHODS.has(method)) {
48
+ return;
49
+ }
50
+
51
+ event.preventDefault();
52
+ event.stopImmediatePropagation();
53
+ console.debug("[Upstart Editor] Blocked a mutating form submission in the editor:", method);
54
+
55
+ const anchor = (event.submitter as HTMLElement | null) ?? form;
56
+ if (anchor) showBlockedTooltip(anchor);
57
+ },
58
+ true,
59
+ );
60
+ }
61
+
62
+ function showBlockedTooltip(anchor: HTMLElement): void {
63
+ const tip = ensureTooltip();
64
+ const rect = anchor.getBoundingClientRect();
65
+
66
+ if (SUPPORTS_POPOVER) {
67
+ // Popover is position:fixed → viewport coordinates (no scroll offset).
68
+ tip.style.left = `${rect.left + rect.width / 2}px`;
69
+ tip.style.top = `${rect.top - 8}px`;
70
+ try {
71
+ tip.hidePopover();
72
+ } catch {}
73
+ try {
74
+ tip.showPopover();
75
+ } catch {}
76
+ } else {
77
+ tip.style.left = `${rect.left + window.scrollX + rect.width / 2}px`;
78
+ tip.style.top = `${rect.top + window.scrollY - 8}px`;
79
+ tip.style.opacity = "1";
80
+ }
81
+
82
+ if (tooltipTimer !== null) {
83
+ clearTimeout(tooltipTimer);
84
+ }
85
+ tooltipTimer = window.setTimeout(() => {
86
+ tooltipTimer = null;
87
+ if (!tooltip) return;
88
+ if (SUPPORTS_POPOVER) {
89
+ try {
90
+ tooltip.hidePopover();
91
+ } catch {}
92
+ } else {
93
+ tooltip.style.opacity = "0";
94
+ }
95
+ }, TOOLTIP_DURATION_MS);
96
+ }
97
+
98
+ function ensureTooltip(): HTMLDivElement {
99
+ if (tooltip) return tooltip;
100
+ tooltip = document.createElement("div");
101
+ tooltip.id = "upstart-form-guard-tooltip";
102
+ tooltip.textContent = TOOLTIP_MESSAGE;
103
+
104
+ let css =
105
+ "background: #111827; color: #fff; font-size: 12px; line-height: 1.3; " +
106
+ "padding: 6px 10px; border-radius: 6px; max-width: 240px; text-align: center; " +
107
+ "box-shadow: 0 4px 12px rgba(0,0,0,0.3); pointer-events: none; white-space: normal; " +
108
+ "transform: translate(-50%, -100%); z-index: 2147483647;";
109
+
110
+ if (SUPPORTS_POPOVER) {
111
+ tooltip.setAttribute("popover", "manual");
112
+ // Neutralise the UA popover centering so our top/left apply.
113
+ css += "position: fixed; margin: 0; inset: auto; overflow: visible; border: 0;";
114
+ } else {
115
+ css += "position: absolute; opacity: 0; transition: opacity 0.15s ease;";
116
+ }
117
+
118
+ tooltip.style.cssText = css;
119
+ document.body.appendChild(tooltip);
120
+ return tooltip;
121
+ }
@@ -25,6 +25,7 @@ export function initHoverOverlay(): void {
25
25
  style.id = "upstart-hover-edit-styles";
26
26
  style.textContent = [
27
27
  ":root[data-upstart-edit-mode] [data-upstart-classname-id]:not([data-upstart-editable-text='true']),",
28
+ ":root[data-upstart-edit-mode] [data-upstart-image-id],",
28
29
  ":root[data-upstart-edit-mode] [data-upstart-datasource][data-upstart-record-id]:not([data-upstart-editable-text='true']),",
29
30
  ":root[data-upstart-edit-mode] [data-upstart-editor-active] {",
30
31
  " transition: outline 150ms, outline-offset 150ms;",
@@ -69,7 +70,11 @@ export function hideOverlays(): void {
69
70
 
70
71
  function isEligible(el: HTMLElement): boolean {
71
72
  if (el.dataset.upstartEditableText === "true") return false;
72
- return !!(el.dataset.upstartClassnameId || (el.dataset.upstartDatasource && el.dataset.upstartRecordId));
73
+ return !!(
74
+ el.dataset.upstartClassnameId ||
75
+ el.dataset.upstartImageId ||
76
+ (el.dataset.upstartDatasource && el.dataset.upstartRecordId)
77
+ );
73
78
  }
74
79
 
75
80
  function clearEditableHover(): void {