@upstart.gg/vite-plugins 0.1.67 → 0.1.69

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 (39) hide show
  1. package/dist/upstart-editor-api.d.ts +2 -0
  2. package/dist/upstart-editor-api.d.ts.map +1 -1
  3. package/dist/upstart-editor-api.js +67 -9
  4. package/dist/upstart-editor-api.js.map +1 -1
  5. package/dist/vite-plugin-upstart-attrs.d.ts.map +1 -1
  6. package/dist/vite-plugin-upstart-attrs.js +18 -1
  7. package/dist/vite-plugin-upstart-attrs.js.map +1 -1
  8. package/dist/vite-plugin-upstart-editor/runtime/click-handler.d.ts.map +1 -1
  9. package/dist/vite-plugin-upstart-editor/runtime/click-handler.js +30 -16
  10. package/dist/vite-plugin-upstart-editor/runtime/click-handler.js.map +1 -1
  11. package/dist/vite-plugin-upstart-editor/runtime/dom-guard.js +45 -0
  12. package/dist/vite-plugin-upstart-editor/runtime/dom-guard.js.map +1 -0
  13. package/dist/vite-plugin-upstart-editor/runtime/error-handler.d.ts +7 -1
  14. package/dist/vite-plugin-upstart-editor/runtime/error-handler.d.ts.map +1 -1
  15. package/dist/vite-plugin-upstart-editor/runtime/error-handler.js +41 -2
  16. package/dist/vite-plugin-upstart-editor/runtime/error-handler.js.map +1 -1
  17. package/dist/vite-plugin-upstart-editor/runtime/image-pick.js +32 -0
  18. package/dist/vite-plugin-upstart-editor/runtime/image-pick.js.map +1 -0
  19. package/dist/vite-plugin-upstart-editor/runtime/index.d.ts.map +1 -1
  20. package/dist/vite-plugin-upstart-editor/runtime/index.js +7 -0
  21. package/dist/vite-plugin-upstart-editor/runtime/index.js.map +1 -1
  22. package/dist/vite-plugin-upstart-editor/runtime/text-editor.js +6 -0
  23. package/dist/vite-plugin-upstart-editor/runtime/text-editor.js.map +1 -1
  24. package/dist/vite-plugin-upstart-editor/runtime/types.d.ts +9 -0
  25. package/dist/vite-plugin-upstart-editor/runtime/types.d.ts.map +1 -1
  26. package/package.json +3 -3
  27. package/src/tests/dom-guard.test.ts +100 -0
  28. package/src/tests/image-pick.test.ts +50 -0
  29. package/src/tests/upstart-editor-api.test.ts +75 -0
  30. package/src/tests/vite-plugin-upstart-attrs.test.ts +21 -0
  31. package/src/upstart-editor-api.ts +74 -8
  32. package/src/vite-plugin-upstart-attrs.ts +44 -1
  33. package/src/vite-plugin-upstart-editor/runtime/click-handler.ts +29 -19
  34. package/src/vite-plugin-upstart-editor/runtime/dom-guard.ts +67 -0
  35. package/src/vite-plugin-upstart-editor/runtime/error-handler.ts +47 -1
  36. package/src/vite-plugin-upstart-editor/runtime/image-pick.ts +40 -0
  37. package/src/vite-plugin-upstart-editor/runtime/index.ts +8 -0
  38. package/src/vite-plugin-upstart-editor/runtime/text-editor.ts +14 -0
  39. package/src/vite-plugin-upstart-editor/runtime/types.ts +6 -0
@@ -352,6 +352,13 @@ export const payloadEditClassName = z.object({
352
352
  action: z.literal("editClassName"),
353
353
  id: z.string().min(1),
354
354
  className: z.string(),
355
+ /**
356
+ * className currently rendered in the preview for this element. Registry ids are
357
+ * `file:offset`, so an earlier edit in the same file followed by a rebuild renames the ids
358
+ * while the preview (not reloaded) keeps the old ones: this lets the server find the element
359
+ * again by its content instead of failing on the stale id.
360
+ */
361
+ previousClassName: z.string().optional(),
355
362
  });
356
363
 
357
364
  export type PayloadEditClassName = z.infer<typeof payloadEditClassName>;
@@ -654,7 +661,7 @@ export class UpstartEditorAPI {
654
661
  if (!parsed.success) {
655
662
  return { success: false, error: `Invalid payload: ${parsed.error.message}` };
656
663
  }
657
- const { id, className: newClassName } = parsed.data;
664
+ const { id, className: newClassName, previousClassName } = parsed.data;
658
665
  if (!this.registry) {
659
666
  try {
660
667
  await this.loadRegistry();
@@ -663,16 +670,75 @@ export class UpstartEditorAPI {
663
670
  }
664
671
  }
665
672
 
666
- const entry = this.registry!.elements[id];
667
- if (!entry) {
668
- return { success: false, error: `Element ${id} not found in registry` };
669
- }
673
+ const resolved = await this.resolveClassNameEntry(id, previousClassName);
674
+ if ("error" in resolved) return { success: false, error: resolved.error };
675
+ return this.applyEdit(resolved.id, resolved.entry, newClassName);
676
+ }
670
677
 
671
- if (entry.type !== "className") {
672
- return { success: false, error: `Element ${id} is not a className element (type: ${entry.type})` };
678
+ /**
679
+ * Find the registry entry to edit for a className write. The id is authoritative when its
680
+ * entry still holds the className the preview shows. Otherwise the id is stale — an earlier
681
+ * edit in the same file shifted the offsets and a rebuild regenerated the registry with new
682
+ * ids, while the preview kept the old ones — and the element is looked up by its current
683
+ * className: first among the registry entries of the file (nearest to the old offset), then
684
+ * by a raw search of the source. Without `previousClassName` the old behaviour applies.
685
+ */
686
+ private async resolveClassNameEntry(
687
+ id: string,
688
+ previousClassName: string | undefined,
689
+ ): Promise<{ id: string; entry: EditableEntry } | { error: string }> {
690
+ const elements = this.registry!.elements;
691
+ const entry = elements[id];
692
+ if (entry && entry.type !== "className") {
693
+ return { error: `Element ${id} is not a className element (type: ${entry.type})` };
694
+ }
695
+ if (previousClassName === undefined || (entry && entry.originalContent === previousClassName)) {
696
+ return entry ? { id, entry } : { error: `Element ${id} not found in registry` };
697
+ }
698
+
699
+ const sep = id.lastIndexOf(":");
700
+ const file = sep === -1 ? (entry?.file ?? "") : id.slice(0, sep);
701
+ const wantedOffset = sep === -1 ? 0 : Number(id.slice(sep + 1)) || 0;
702
+ const byDistance = (a: number, b: number) => Math.abs(a - wantedOffset) - Math.abs(b - wantedOffset);
703
+
704
+ // Same className in the (regenerated) registry, nearest to where the element used to be.
705
+ const candidates = Object.entries(elements)
706
+ .filter(([, e]) => e.file === file && e.type === "className" && e.originalContent === previousClassName)
707
+ .sort(([, a], [, b]) => byDistance(a.startOffset, b.startOffset));
708
+ if (candidates[0]) return { id: candidates[0][0], entry: candidates[0][1] };
709
+
710
+ // Registry stale as well: look for the literal in the source itself.
711
+ if (file && previousClassName !== "") {
712
+ try {
713
+ const code = await fs.readFile(path.join(this.projectRoot, file), "utf-8");
714
+ const starts: number[] = [];
715
+ for (let i = code.indexOf(previousClassName); i !== -1; i = code.indexOf(previousClassName, i + 1)) {
716
+ const before = code[i - 1];
717
+ const after = code[i + previousClassName.length];
718
+ if ((before === '"' || before === "'" || before === "`") && after === before) starts.push(i);
719
+ }
720
+ starts.sort(byDistance);
721
+ if (starts[0] !== undefined) {
722
+ const found: EditableEntry = {
723
+ file,
724
+ type: "className",
725
+ startOffset: starts[0],
726
+ endOffset: starts[0] + previousClassName.length,
727
+ originalContent: previousClassName,
728
+ context: entry?.context ?? { parentTag: "unknown" },
729
+ };
730
+ // Registered under the id the preview uses so follow-up edits resolve directly.
731
+ elements[id] = found;
732
+ return { id, entry: found };
733
+ }
734
+ } catch {
735
+ // Unreadable file: fall through to the registry entry (if any).
736
+ }
673
737
  }
674
738
 
675
- return this.applyEdit(id, entry, newClassName);
739
+ // The preview's className may include runtime-added classes: trust the entry, which
740
+ // applyEdit still validates against the file content.
741
+ return entry ? { id, entry } : { error: `Element ${id} not found in registry` };
676
742
  }
677
743
 
678
744
  /**
@@ -330,7 +330,50 @@ export function transformWithOxc(code: string, filePath: string) {
330
330
  ? buildI18nAttributeValue(jsxNode, allI18nKeys, state.code, state, state.constants)
331
331
  : null;
332
332
 
333
- if (hasI18n && i18nAttrValue && (!isMixed || i18nAffixes)) {
333
+ // A single <Trans> next to element siblings without text (an <Icon>, an <svg>…): the
334
+ // plain editor replaces the whole content of the editable element, so making the
335
+ // container editable would destroy the icon, and React would later fight over it
336
+ // (Iconify swaps its placeholder <span> for the <svg> once the icon data is loaded).
337
+ // Wrap the <Trans> in its own editable <span> instead; the container stays untouched.
338
+ const directTransNodes = jsxNode.children.filter(
339
+ (c) =>
340
+ c.type === "JSXElement" && detectTransComponent(c as JSXElement, state.code, state.constants),
341
+ ) as JSXElement[];
342
+ const elementSiblings = jsxNode.children.filter(
343
+ (c) =>
344
+ c.type === "JSXElement" && !detectTransComponent(c as JSXElement, state.code, state.constants),
345
+ );
346
+ const transToWrap =
347
+ hasI18n &&
348
+ i18nAttrValue &&
349
+ !isMixed &&
350
+ transChildren.length === 1 &&
351
+ tCallChildren.length === 0 &&
352
+ directTransNodes.length === 1 &&
353
+ elementSiblings.length > 0 &&
354
+ hasRange(directTransNodes[0])
355
+ ? directTransNodes[0]
356
+ : null;
357
+
358
+ if (transToWrap) {
359
+ const wrapperHash = hashContent(state.code.slice(transToWrap.start, transToWrap.end));
360
+ const loopSuffixes = state.loopStack.map((l) => (l.indexName ? `\${${l.indexName}}` : "0"));
361
+ const wrapperAttrs = [
362
+ loopSuffixes.length > 0
363
+ ? `data-upstart-hash={\`${wrapperHash}-${loopSuffixes.join("-")}\`}`
364
+ : `data-upstart-hash="${wrapperHash}"`,
365
+ 'data-upstart-editable-text="true"',
366
+ 'data-upstart-editable-text-mode="plain"',
367
+ `data-upstart-i18n=${i18nAttrValue}`,
368
+ ];
369
+ const wrapperValueKeys = [...new Set(allI18nKeys.flatMap((t) => t.valueKeys ?? []))];
370
+ if (wrapperValueKeys.length > 0) {
371
+ wrapperAttrs.push(`data-i18n-values="${wrapperValueKeys.join(",")}"`);
372
+ }
373
+ state.s.appendLeft(transToWrap.start, `<span ${wrapperAttrs.join(" ")}>`);
374
+ state.s.appendRight(transToWrap.end, "</span>");
375
+ modified = true;
376
+ } else if (hasI18n && i18nAttrValue && (!isMixed || i18nAffixes)) {
334
377
  attributes.push('data-upstart-editable-text="true"');
335
378
  attributes.push('data-upstart-editable-text-mode="plain"');
336
379
 
@@ -1,4 +1,5 @@
1
1
  import { getCurrentMode, getSelection, toggleSelection } from "./state.js";
2
+ import { pickImagesForClick, type Point } from "./image-pick.js";
2
3
  import { sendToParent } from "./utils.js";
3
4
  import type { EditableImageRef, SelectionRef } from "./types.js";
4
5
 
@@ -59,26 +60,33 @@ function toImageRef(node: HTMLElement): EditableImageRef {
59
60
  };
60
61
  }
61
62
 
63
+ function editableImagesIn(el: HTMLElement): HTMLElement[] {
64
+ const found = new Set<HTMLElement>();
65
+ if (el.dataset.upstartImageId) found.add(el);
66
+ for (const node of el.querySelectorAll<HTMLElement>("[data-upstart-image-id]")) {
67
+ found.add(node);
68
+ }
69
+ return Array.from(found);
70
+ }
71
+
62
72
  /**
63
- * Collect editable images (elements carrying data-upstart-image-id) from the
64
- * clicked element's nearest subtree. Starting at `start`, we climb ancestors
65
- * until we find a subtree that contains at least one editable image. This lets
66
- * us surface a background image (e.g. a hero <img> covered by overlays) even
67
- * when the click lands on a sibling overlay rather than the image itself.
73
+ * Collect the editable images (elements carrying data-upstart-image-id) relevant to a click
74
+ * on `start`: the images inside the clicked element, or, when it has none, an ancestor's image
75
+ * lying under the click point (a hero <img> covered by an overlay). See pickImagesForClick for
76
+ * the exact rules — in particular an ancestor's images are never surfaced when the click is
77
+ * not visually on them, so clicking a title never opens the logo next to it.
68
78
  */
69
- function collectImages(start: HTMLElement): EditableImageRef[] {
70
- let el: HTMLElement | null = start;
71
- for (let depth = 0; el && el !== document.body && depth < 8; depth++, el = el.parentElement) {
72
- const found = new Set<HTMLElement>();
73
- if (el.dataset.upstartImageId) found.add(el);
74
- for (const node of el.querySelectorAll<HTMLElement>("[data-upstart-image-id]")) {
75
- found.add(node);
76
- }
77
- if (found.size > 0) {
78
- return Array.from(found).map(toImageRef);
79
+ function collectImages(start: HTMLElement, point?: Point): EditableImageRef[] {
80
+ const withRect = (node: HTMLElement) => ({ node, rect: node.getBoundingClientRect() });
81
+ const own = editableImagesIn(start).map(withRect);
82
+ const ancestorLevels: { node: HTMLElement; rect: DOMRect }[][] = [];
83
+ if (own.length === 0) {
84
+ let el = start.parentElement;
85
+ for (let depth = 0; el && el !== document.body && depth < 8; depth++, el = el.parentElement) {
86
+ ancestorLevels.push(editableImagesIn(el).map(withRect));
79
87
  }
80
88
  }
81
- return [];
89
+ return pickImagesForClick(own, ancestorLevels, point).map(({ node }) => toImageRef(node));
82
90
  }
83
91
 
84
92
  /**
@@ -209,6 +217,8 @@ function handleClick(event: MouseEvent): void {
209
217
  console.warn("[Upstart Editor] Click target is not an HTMLElement");
210
218
  return;
211
219
  }
220
+ // Viewport coordinates of the click, to tell which image (if any) was visually hit.
221
+ const clickPoint: Point = { x: event.clientX, y: event.clientY };
212
222
 
213
223
  if (mode === "select") {
214
224
  handleSelectClick(event, target);
@@ -264,7 +274,7 @@ function handleClick(event: MouseEvent): void {
264
274
  themeColors,
265
275
  bounds: { top: 0, left: 0, width: 0, height: 0, right: 0, bottom: 0 },
266
276
  tagName: editableEl.tagName.toLowerCase(),
267
- images: collectImages(editableEl),
277
+ images: collectImages(editableEl, clickPoint),
268
278
  });
269
279
  }
270
280
  }
@@ -299,7 +309,7 @@ function handleClick(event: MouseEvent): void {
299
309
  themeColors: {},
300
310
  bounds: { top: 0, left: 0, width: 0, height: 0, right: 0, bottom: 0 },
301
311
  tagName: datasourceEl.tagName.toLowerCase(),
302
- images: collectImages(datasourceEl),
312
+ images: collectImages(datasourceEl, clickPoint),
303
313
  });
304
314
  return;
305
315
  }
@@ -377,7 +387,7 @@ function handleClick(event: MouseEvent): void {
377
387
  bounds,
378
388
  viewportWidth: window.innerWidth,
379
389
  tagName: element.tagName.toLowerCase(),
380
- images: collectImages(element),
390
+ images: collectImages(element, clickPoint),
381
391
  });
382
392
 
383
393
  console.log("[Upstart Editor] Element clicked:", componentName, hash);
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Makes React's DOM reconciliation survive the editor's own DOM mutations.
3
+ *
4
+ * In edit mode the text editor replaces the children of React-managed elements with a
5
+ * TipTap view. When React later re-renders one of those subtrees (an interaction of the
6
+ * site, a revalidation, a language switch…) it references child nodes that are gone and
7
+ * `insertBefore` / `removeChild` throw `NotFoundError`, which unmounts the whole page into
8
+ * the site's error boundary. Tolerating those two calls keeps the page alive: an insert
9
+ * whose reference node is gone becomes an append, and removing a node that is no longer a
10
+ * child is a no-op. The first time it happens `onDesync` is called so the editor can offer
11
+ * a refresh, as the preview may now differ slightly from the source.
12
+ */
13
+ export interface DomLike {
14
+ insertBefore: <T extends Node>(this: Node, node: T, child: Node | null) => T;
15
+ removeChild: <T extends Node>(this: Node, child: T) => T;
16
+ }
17
+
18
+ function describeNode(node: Node | null): string {
19
+ if (!node) return "null";
20
+ if (node.nodeType === 3) return `#text "${(node.textContent ?? "").slice(0, 40)}"`;
21
+ const el = node as Element;
22
+ if (!el.tagName) return node.nodeName;
23
+ const id = el.id ? `#${el.id}` : "";
24
+ const cls =
25
+ typeof el.className === "string" && el.className
26
+ ? `.${el.className.split(/\s+/).slice(0, 3).join(".")}`
27
+ : "";
28
+ const hash = (el as HTMLElement).dataset?.upstartHash
29
+ ? ` [hash=${(el as HTMLElement).dataset.upstartHash}]`
30
+ : "";
31
+ return `<${el.tagName.toLowerCase()}${id}${cls}>${hash}`;
32
+ }
33
+
34
+ export function installDomGuard(proto: DomLike, onDesync: () => void): void {
35
+ const originalInsertBefore = proto.insertBefore;
36
+ const originalRemoveChild = proto.removeChild;
37
+ let reported = false;
38
+ // Every tolerated call is logged with its stack: that is what tells which update (React
39
+ // reconciliation, a library…) touched an element the editor had modified. Open the
40
+ // preview iframe's console context in the devtools to read it.
41
+ const report = (operation: string, parent: Node, node: Node, child: Node | null) => {
42
+ console.warn(
43
+ `[Upstart Editor] DOM out of sync: ${operation} on ${describeNode(parent)} with ${describeNode(node)}` +
44
+ (child ? ` before ${describeNode(child)} (no longer a child)` : " (no longer a child)"),
45
+ { parent, node, child, stack: new Error("DOM out of sync").stack },
46
+ );
47
+ if (reported) return;
48
+ reported = true;
49
+ onDesync();
50
+ };
51
+
52
+ proto.insertBefore = function <T extends Node>(this: Node, node: T, child: Node | null): T {
53
+ if (child && child.parentNode !== this) {
54
+ report("insertBefore", this, node, child);
55
+ return originalInsertBefore.call(this, node, null) as T;
56
+ }
57
+ return originalInsertBefore.call(this, node, child) as T;
58
+ };
59
+
60
+ proto.removeChild = function <T extends Node>(this: Node, child: T): T {
61
+ if (child.parentNode !== this) {
62
+ report("removeChild", this, child, null);
63
+ return child;
64
+ }
65
+ return originalRemoveChild.call(this, child) as T;
66
+ };
67
+ }
@@ -1,12 +1,58 @@
1
1
  import { sendToParent } from "./utils.js";
2
2
 
3
+ /** Errors thrown by React's DOM reconciliation once the editor mutated its nodes. */
4
+ const RECONCILIATION_ERROR_RE =
5
+ /insertBefore|removeChild|appendChild|Minified React error #(?:418|422|423|425)/;
6
+
7
+ const OVERLAY_ID = "upstart-preview-recovery";
8
+
9
+ /**
10
+ * Full-screen notice inside the preview asking the user to refresh, shown when the page
11
+ * could not be kept alive (React crashed into the site's error boundary). Reloading the
12
+ * iframe re-syncs the DOM with the source and the editor's registry.
13
+ */
14
+ export function showRecoveryOverlay(): void {
15
+ if (document.getElementById(OVERLAY_ID)) return;
16
+ const overlay = document.createElement("div");
17
+ overlay.id = OVERLAY_ID;
18
+ overlay.setAttribute("role", "alertdialog");
19
+ overlay.style.cssText =
20
+ "position: fixed; inset: 0; z-index: 2147483647; display: flex; align-items: center; justify-content: center; " +
21
+ "background: rgba(17, 17, 27, 0.72); font-family: system-ui, sans-serif; color: #111;";
22
+ const card = document.createElement("div");
23
+ card.style.cssText =
24
+ "max-width: 24rem; margin: 1rem; padding: 1.5rem; border-radius: 0.75rem; background: #fff; " +
25
+ "box-shadow: 0 20px 50px rgba(0,0,0,0.3); text-align: center;";
26
+ const title = document.createElement("p");
27
+ title.textContent = "The preview needs a refresh";
28
+ title.style.cssText = "margin: 0 0 0.5rem; font-size: 1.1rem; font-weight: 600;";
29
+ const text = document.createElement("p");
30
+ text.textContent = "Your changes are saved. Refresh the preview to keep editing.";
31
+ text.style.cssText = "margin: 0 0 1.25rem; font-size: 0.9rem; color: #555;";
32
+ const button = document.createElement("button");
33
+ button.type = "button";
34
+ button.textContent = "Refresh";
35
+ button.style.cssText =
36
+ "padding: 0.6rem 1.4rem; border: 0; border-radius: 9999px; background: #7270c6; color: #fff; " +
37
+ "font-weight: 600; font-size: 0.95rem; cursor: pointer;";
38
+ button.addEventListener("click", () => window.location.reload());
39
+ card.append(title, text, button);
40
+ overlay.append(card);
41
+ (document.body ?? document.documentElement).append(overlay);
42
+ }
43
+
3
44
  export function initErrorHandler() {
4
45
  // Global error handler for uncaught errors in the editor
5
46
  window.addEventListener("error", (event) => {
6
47
  console.error("[Upstart Editor] Uncaught error in editor:", event.error);
7
48
  sendToParent({
8
49
  type: "editor-error",
9
- error: event.error,
50
+ error: String(event.error?.message ?? event.message ?? event.error),
10
51
  });
52
+ const message = String(event.error?.message ?? event.message ?? "");
53
+ if (RECONCILIATION_ERROR_RE.test(message)) {
54
+ sendToParent({ type: "editor-desync", fatal: true });
55
+ showRecoveryOverlay();
56
+ }
11
57
  });
12
58
  }
@@ -0,0 +1,40 @@
1
+ export type Point = { x: number; y: number };
2
+ export type Rect = { top: number; left: number; width: number; height: number };
3
+
4
+ export function rectContainsPoint(rect: Rect, point: Point): boolean {
5
+ return (
6
+ point.x >= rect.left &&
7
+ point.x <= rect.left + rect.width &&
8
+ point.y >= rect.top &&
9
+ point.y <= rect.top + rect.height
10
+ );
11
+ }
12
+
13
+ /**
14
+ * Images to surface in the inspector for a click. Pure so it can be unit-tested without a DOM.
15
+ *
16
+ * - `own` are the editable images inside the clicked element (the element itself included):
17
+ * they belong to what was clicked, so they are all candidates. When the click lands on one
18
+ * of them, only that one is kept, so a card with several images doesn't open them all when
19
+ * the user clearly targeted one.
20
+ * - `ancestorLevels` are the images of each successive ancestor (nearest first), used only for
21
+ * the "hero covered by an overlay" case: the click is on a sibling overlay, but visually on
22
+ * the image. Only images under the click point qualify, otherwise clicking a navbar title
23
+ * would surface the logo, and clicking any element would surface every image on the page.
24
+ */
25
+ export function pickImagesForClick<T extends { rect: Rect }>(
26
+ own: T[],
27
+ ancestorLevels: T[][],
28
+ point: Point | undefined,
29
+ ): T[] {
30
+ if (own.length > 0) {
31
+ const underPoint = point ? own.filter((img) => rectContainsPoint(img.rect, point)) : [];
32
+ return underPoint.length > 0 ? underPoint : own;
33
+ }
34
+ if (!point) return [];
35
+ for (const level of ancestorLevels) {
36
+ const underPoint = level.filter((img) => rectContainsPoint(img.rect, point));
37
+ if (underPoint.length > 0) return underPoint;
38
+ }
39
+ return [];
40
+ }
@@ -1,6 +1,7 @@
1
1
  import { initClickHandler } from "./click-handler.js";
2
2
  import { initHoverOverlay, hideOverlays } from "./hover-overlay.js";
3
3
  import { initErrorHandler } from "./error-handler.js";
4
+ import { installDomGuard } from "./dom-guard.js";
4
5
  import { initTextEditor, activateAllEditors, destroyAllActiveEditors } from "./text-editor.js";
5
6
  import { initArrayControls, refreshArrayControls, hideArrayControls } from "./array-controls.js";
6
7
  import { initFormGuard } from "./form-guard.js";
@@ -146,6 +147,10 @@ export function initUpstartEditor(): void {
146
147
  i18next.getResource(i18next.language, namespace, key) as string | undefined
147
148
  : undefined;
148
149
 
150
+ // Marks the document as running inside the editor preview (the site's error boundary
151
+ // uses it to offer a refresh instead of a raw error).
152
+ document.documentElement.setAttribute("data-upstart-editor", "");
153
+ installDomGuard(Node.prototype, () => sendToParent({ type: "editor-desync", fatal: false }));
149
154
  initTextEditor(getRawI18nTemplate ? { getRawI18nTemplate } : {});
150
155
  initClickHandler();
151
156
  initHoverOverlay();
@@ -195,6 +200,9 @@ function handleParentMessage(event: MessageEvent): void {
195
200
  if (el) {
196
201
  el.className = message.className;
197
202
  }
203
+ // No `previewCSS` at all means "patch the className only": the editor uses it after a save
204
+ // to sync the DOM with the file without touching the current CSS preview.
205
+ if (message.previewCSS === undefined) return;
198
206
  const STYLE_ID = "upstart-preview-style";
199
207
  let styleEl = document.getElementById(STYLE_ID) as HTMLStyleElement | null;
200
208
  if (message.previewCSS) {
@@ -335,11 +335,25 @@ export function destroyAllActiveEditors(): void {
335
335
  }
336
336
  }
337
337
 
338
+ /** Descendants the plain editor cannot reproduce: taking over the element would destroy them. */
339
+ const PLAIN_EDITOR_FOREIGN_CHILDREN = "svg, img, picture, video, iframe, canvas";
340
+
338
341
  function setupEditableElement(element: HTMLElement, options: Required<UpstartEditorOptions>): void {
339
342
  const hash = getEditableHash(element);
340
343
  if (!hash || activeEditors.has(element)) {
341
344
  return;
342
345
  }
346
+ // Older builds may still mark a container holding an icon next to its text as editable:
347
+ // the plain editor would wipe the icon and React would later fight over it. Leave such
348
+ // elements alone (the current plugin wraps the text in its own <span> instead).
349
+ if (getEditorMode(element, options) === "plain" && element.querySelector(PLAIN_EDITOR_FOREIGN_CHILDREN)) {
350
+ console.warn(
351
+ "[Upstart Editor] Skipping text editor on an element that also holds media/icons:",
352
+ element.dataset.upstartHash,
353
+ element,
354
+ );
355
+ return;
356
+ }
343
357
 
344
358
  cacheStyles(element);
345
359
  activateEditor(element, hash, options);
@@ -109,6 +109,12 @@ export type EditorMessage =
109
109
  | { type: "editor-route"; routeId: string }
110
110
  | { type: "scroll-position"; x: number; y: number }
111
111
  | { type: "editor-error"; error: string }
112
+ /**
113
+ * React's DOM reconciliation hit nodes the editor had replaced. `fatal: false`: the page was
114
+ * kept alive by the DOM guard (a refresh is advisable); `fatal: true`: the page crashed and
115
+ * the preview shows its recovery overlay.
116
+ */
117
+ | { type: "editor-desync"; fatal: boolean }
112
118
  | { type: "selection-changed"; selection: SelectionRef[] }
113
119
  | {
114
120
  type: "element-clicked";