@upstart.gg/vite-plugins 0.1.68 → 0.1.70

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 (52) 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/array-controls.js +7 -7
  9. package/dist/vite-plugin-upstart-editor/runtime/array-controls.js.map +1 -1
  10. package/dist/vite-plugin-upstart-editor/runtime/click-handler.d.ts.map +1 -1
  11. package/dist/vite-plugin-upstart-editor/runtime/click-handler.js +30 -16
  12. package/dist/vite-plugin-upstart-editor/runtime/click-handler.js.map +1 -1
  13. package/dist/vite-plugin-upstart-editor/runtime/dom-guard.js +45 -0
  14. package/dist/vite-plugin-upstart-editor/runtime/dom-guard.js.map +1 -0
  15. package/dist/vite-plugin-upstart-editor/runtime/error-handler.d.ts +7 -1
  16. package/dist/vite-plugin-upstart-editor/runtime/error-handler.d.ts.map +1 -1
  17. package/dist/vite-plugin-upstart-editor/runtime/error-handler.js +42 -2
  18. package/dist/vite-plugin-upstart-editor/runtime/error-handler.js.map +1 -1
  19. package/dist/vite-plugin-upstart-editor/runtime/form-guard.js +2 -2
  20. package/dist/vite-plugin-upstart-editor/runtime/form-guard.js.map +1 -1
  21. package/dist/vite-plugin-upstart-editor/runtime/image-pick.js +32 -0
  22. package/dist/vite-plugin-upstart-editor/runtime/image-pick.js.map +1 -0
  23. package/dist/vite-plugin-upstart-editor/runtime/index.d.ts.map +1 -1
  24. package/dist/vite-plugin-upstart-editor/runtime/index.js +9 -1
  25. package/dist/vite-plugin-upstart-editor/runtime/index.js.map +1 -1
  26. package/dist/vite-plugin-upstart-editor/runtime/labels.d.ts +32 -0
  27. package/dist/vite-plugin-upstart-editor/runtime/labels.d.ts.map +1 -0
  28. package/dist/vite-plugin-upstart-editor/runtime/labels.js +45 -0
  29. package/dist/vite-plugin-upstart-editor/runtime/labels.js.map +1 -0
  30. package/dist/vite-plugin-upstart-editor/runtime/text-editor.d.ts.map +1 -1
  31. package/dist/vite-plugin-upstart-editor/runtime/text-editor.js +26 -14
  32. package/dist/vite-plugin-upstart-editor/runtime/text-editor.js.map +1 -1
  33. package/dist/vite-plugin-upstart-editor/runtime/types.d.ts +14 -1
  34. package/dist/vite-plugin-upstart-editor/runtime/types.d.ts.map +1 -1
  35. package/package.json +3 -3
  36. package/src/tests/dom-guard.test.ts +100 -0
  37. package/src/tests/editor-labels.test.ts +40 -0
  38. package/src/tests/image-pick.test.ts +50 -0
  39. package/src/tests/upstart-editor-api.test.ts +75 -0
  40. package/src/tests/vite-plugin-upstart-attrs.test.ts +21 -0
  41. package/src/upstart-editor-api.ts +74 -8
  42. package/src/vite-plugin-upstart-attrs.ts +44 -1
  43. package/src/vite-plugin-upstart-editor/runtime/array-controls.ts +7 -7
  44. package/src/vite-plugin-upstart-editor/runtime/click-handler.ts +29 -19
  45. package/src/vite-plugin-upstart-editor/runtime/dom-guard.ts +67 -0
  46. package/src/vite-plugin-upstart-editor/runtime/error-handler.ts +48 -1
  47. package/src/vite-plugin-upstart-editor/runtime/form-guard.ts +3 -2
  48. package/src/vite-plugin-upstart-editor/runtime/image-pick.ts +40 -0
  49. package/src/vite-plugin-upstart-editor/runtime/index.ts +11 -0
  50. package/src/vite-plugin-upstart-editor/runtime/labels.ts +77 -0
  51. package/src/vite-plugin-upstart-editor/runtime/text-editor.ts +39 -15
  52. package/src/vite-plugin-upstart-editor/runtime/types.ts +12 -1
@@ -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,4 +1,46 @@
1
1
  import { sendToParent } from "./utils.js";
2
+ import { getLabels } from "./labels.js";
3
+
4
+ /** Errors thrown by React's DOM reconciliation once the editor mutated its nodes. */
5
+ const RECONCILIATION_ERROR_RE =
6
+ /insertBefore|removeChild|appendChild|Minified React error #(?:418|422|423|425)/;
7
+
8
+ const OVERLAY_ID = "upstart-preview-recovery";
9
+
10
+ /**
11
+ * Full-screen notice inside the preview asking the user to refresh, shown when the page
12
+ * could not be kept alive (React crashed into the site's error boundary). Reloading the
13
+ * iframe re-syncs the DOM with the source and the editor's registry.
14
+ */
15
+ export function showRecoveryOverlay(): void {
16
+ if (document.getElementById(OVERLAY_ID)) return;
17
+ const overlay = document.createElement("div");
18
+ overlay.id = OVERLAY_ID;
19
+ overlay.setAttribute("role", "alertdialog");
20
+ overlay.style.cssText =
21
+ "position: fixed; inset: 0; z-index: 2147483647; display: flex; align-items: center; justify-content: center; " +
22
+ "background: rgba(17, 17, 27, 0.72); font-family: system-ui, sans-serif; color: #111;";
23
+ const card = document.createElement("div");
24
+ card.style.cssText =
25
+ "max-width: 24rem; margin: 1rem; padding: 1.5rem; border-radius: 0.75rem; background: #fff; " +
26
+ "box-shadow: 0 20px 50px rgba(0,0,0,0.3); text-align: center;";
27
+ const title = document.createElement("p");
28
+ title.textContent = getLabels().recoveryTitle;
29
+ title.style.cssText = "margin: 0 0 0.5rem; font-size: 1.1rem; font-weight: 600;";
30
+ const text = document.createElement("p");
31
+ text.textContent = getLabels().recoveryText;
32
+ text.style.cssText = "margin: 0 0 1.25rem; font-size: 0.9rem; color: #555;";
33
+ const button = document.createElement("button");
34
+ button.type = "button";
35
+ button.textContent = getLabels().refresh;
36
+ button.style.cssText =
37
+ "padding: 0.6rem 1.4rem; border: 0; border-radius: 9999px; background: #7270c6; color: #fff; " +
38
+ "font-weight: 600; font-size: 0.95rem; cursor: pointer;";
39
+ button.addEventListener("click", () => window.location.reload());
40
+ card.append(title, text, button);
41
+ overlay.append(card);
42
+ (document.body ?? document.documentElement).append(overlay);
43
+ }
2
44
 
3
45
  export function initErrorHandler() {
4
46
  // Global error handler for uncaught errors in the editor
@@ -6,7 +48,12 @@ export function initErrorHandler() {
6
48
  console.error("[Upstart Editor] Uncaught error in editor:", event.error);
7
49
  sendToParent({
8
50
  type: "editor-error",
9
- error: event.error,
51
+ error: String(event.error?.message ?? event.message ?? event.error),
10
52
  });
53
+ const message = String(event.error?.message ?? event.message ?? "");
54
+ if (RECONCILIATION_ERROR_RE.test(message)) {
55
+ sendToParent({ type: "editor-desync", fatal: true });
56
+ showRecoveryOverlay();
57
+ }
11
58
  });
12
59
  }
@@ -1,10 +1,11 @@
1
+ import { getLabels } from "./labels.js";
2
+
1
3
  let isInitialized = false;
2
4
 
3
5
  // Methods that don't mutate data and must keep working: GET forms (search /
4
6
  // navigation) and method="dialog" (e.g. a modal's close/backdrop buttons).
5
7
  const SAFE_METHODS = new Set(["get", "dialog"]);
6
8
 
7
- const TOOLTIP_MESSAGE = "This action isn’t available in the editor preview";
8
9
  const TOOLTIP_DURATION_MS = 2500;
9
10
 
10
11
  // Popover API renders in the browser top layer, so the tooltip sits above modal
@@ -99,7 +100,7 @@ function ensureTooltip(): HTMLDivElement {
99
100
  if (tooltip) return tooltip;
100
101
  tooltip = document.createElement("div");
101
102
  tooltip.id = "upstart-form-guard-tooltip";
102
- tooltip.textContent = TOOLTIP_MESSAGE;
103
+ tooltip.textContent = getLabels().formGuard;
103
104
 
104
105
  let css =
105
106
  "background: #111827; color: #fff; font-size: 12px; line-height: 1.3; " +
@@ -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,11 +1,13 @@
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";
7
8
  import { sendToParent } from "./utils.js";
8
9
  import { getCurrentMode, setCurrentMode, clearSelection, removeSelection, getSelection } from "./state.js";
10
+ import { setLabels } from "./labels.js";
9
11
  import type { EditorMode, UpstartParentMessage } from "./types.js";
10
12
 
11
13
  let isInitialized = false;
@@ -146,6 +148,10 @@ export function initUpstartEditor(): void {
146
148
  i18next.getResource(i18next.language, namespace, key) as string | undefined
147
149
  : undefined;
148
150
 
151
+ // Marks the document as running inside the editor preview (the site's error boundary
152
+ // uses it to offer a refresh instead of a raw error).
153
+ document.documentElement.setAttribute("data-upstart-editor", "");
154
+ installDomGuard(Node.prototype, () => sendToParent({ type: "editor-desync", fatal: false }));
149
155
  initTextEditor(getRawI18nTemplate ? { getRawI18nTemplate } : {});
150
156
  initClickHandler();
151
157
  initHoverOverlay();
@@ -195,6 +201,9 @@ function handleParentMessage(event: MessageEvent): void {
195
201
  if (el) {
196
202
  el.className = message.className;
197
203
  }
204
+ // No `previewCSS` at all means "patch the className only": the editor uses it after a save
205
+ // to sync the DOM with the file without touching the current CSS preview.
206
+ if (message.previewCSS === undefined) return;
198
207
  const STYLE_ID = "upstart-preview-style";
199
208
  let styleEl = document.getElementById(STYLE_ID) as HTMLStyleElement | null;
200
209
  if (message.previewCSS) {
@@ -240,6 +249,8 @@ function handleParentMessage(event: MessageEvent): void {
240
249
  const el = removeSelection(message.key);
241
250
  el?.removeAttribute("data-upstart-selected");
242
251
  sendToParent({ type: "selection-changed", selection: getSelection() });
252
+ } else if (message.type === "set-labels") {
253
+ setLabels(message.labels);
243
254
  }
244
255
  }
245
256
 
@@ -0,0 +1,77 @@
1
+ /**
2
+ * User-facing labels of the editor runtime. The runtime runs inside the previewed site,
3
+ * which has no access to the dashboard's i18n catalogs: the editor sends the translated
4
+ * labels through a `set-labels` message once the runtime is ready, and everything the
5
+ * runtime renders reads them lazily via {@link getLabels}. English is the fallback.
6
+ */
7
+ export interface RuntimeLabels {
8
+ newItem: string;
9
+ keepItem: string;
10
+ removeItem: string;
11
+ addItem: string;
12
+ applyChanges: string;
13
+ dragToReorder: string;
14
+ clickToEdit: string;
15
+ startTyping: string;
16
+ recoveryTitle: string;
17
+ recoveryText: string;
18
+ refresh: string;
19
+ formGuard: string;
20
+ bold: string;
21
+ italic: string;
22
+ strikethrough: string;
23
+ inlineCode: string;
24
+ heading: string;
25
+ blockquote: string;
26
+ bulletList: string;
27
+ orderedList: string;
28
+ }
29
+
30
+ export const DEFAULT_LABELS: Readonly<RuntimeLabels> = Object.freeze({
31
+ newItem: "New item",
32
+ keepItem: "Keep item",
33
+ removeItem: "Remove item",
34
+ addItem: "Add item",
35
+ applyChanges: "Apply changes",
36
+ dragToReorder: "Drag to reorder",
37
+ clickToEdit: "Click to edit...",
38
+ startTyping: "Start typing...",
39
+ recoveryTitle: "The preview needs a refresh",
40
+ recoveryText: "Your changes are saved. Refresh the preview to keep editing.",
41
+ refresh: "Refresh",
42
+ formGuard: "This action isn’t available in the editor preview",
43
+ bold: "Bold",
44
+ italic: "Italic",
45
+ strikethrough: "Strikethrough",
46
+ inlineCode: "Inline Code",
47
+ heading: "Heading",
48
+ blockquote: "Blockquote",
49
+ bulletList: "Bullet List",
50
+ orderedList: "Ordered List",
51
+ });
52
+
53
+ let labels: RuntimeLabels = { ...DEFAULT_LABELS };
54
+
55
+ /** Current labels (translated once the editor sent them, English before that). */
56
+ export function getLabels(): Readonly<RuntimeLabels> {
57
+ return labels;
58
+ }
59
+
60
+ /**
61
+ * Override labels with the ones received from the editor. Unknown keys and non-string
62
+ * values are ignored so a malformed message can never blank a control.
63
+ */
64
+ export function setLabels(input: unknown): void {
65
+ if (typeof input !== "object" || input === null) return;
66
+ const next: RuntimeLabels = { ...labels };
67
+ for (const key of Object.keys(DEFAULT_LABELS) as (keyof RuntimeLabels)[]) {
68
+ const value = (input as Record<string, unknown>)[key];
69
+ if (typeof value === "string" && value.length > 0) next[key] = value;
70
+ }
71
+ labels = next;
72
+ }
73
+
74
+ /** Back to the English defaults (used by tests). */
75
+ export function resetLabels(): void {
76
+ labels = { ...DEFAULT_LABELS };
77
+ }
@@ -4,6 +4,7 @@ import Placeholder from "@tiptap/extension-placeholder";
4
4
  import StarterKit from "@tiptap/starter-kit";
5
5
  import { sendToParent } from "./utils.js";
6
6
  import type { EditorInstance, TextEditorMode, UpstartEditorOptions } from "./types.js";
7
+ import { getLabels, type RuntimeLabels } from "./labels.js";
7
8
 
8
9
  /**
9
10
  * Custom Document node for inline elements (e.g. <a>, <span>, headings).
@@ -247,6 +248,11 @@ const styleCache = new WeakMap<
247
248
  { outline: string; outlineOffset: string; cursor: string; transition: string }
248
249
  >();
249
250
  let resolvedOptions: Required<UpstartEditorOptions> = DEFAULT_OPTIONS;
251
+
252
+ /** The site may override the placeholder; the default one follows the editor's language. */
253
+ function placeholderFor(options: Required<UpstartEditorOptions>): string {
254
+ return options.placeholder === DEFAULT_OPTIONS.placeholder ? getLabels().startTyping : options.placeholder;
255
+ }
250
256
  let shortcutsRegistered = false;
251
257
  let domObserver: MutationObserver | null = null;
252
258
  let reactivateTimer: number | null = null;
@@ -335,11 +341,25 @@ export function destroyAllActiveEditors(): void {
335
341
  }
336
342
  }
337
343
 
344
+ /** Descendants the plain editor cannot reproduce: taking over the element would destroy them. */
345
+ const PLAIN_EDITOR_FOREIGN_CHILDREN = "svg, img, picture, video, iframe, canvas";
346
+
338
347
  function setupEditableElement(element: HTMLElement, options: Required<UpstartEditorOptions>): void {
339
348
  const hash = getEditableHash(element);
340
349
  if (!hash || activeEditors.has(element)) {
341
350
  return;
342
351
  }
352
+ // Older builds may still mark a container holding an icon next to its text as editable:
353
+ // the plain editor would wipe the icon and React would later fight over it. Leave such
354
+ // elements alone (the current plugin wraps the text in its own <span> instead).
355
+ if (getEditorMode(element, options) === "plain" && element.querySelector(PLAIN_EDITOR_FOREIGN_CHILDREN)) {
356
+ console.warn(
357
+ "[Upstart Editor] Skipping text editor on an element that also holds media/icons:",
358
+ element.dataset.upstartHash,
359
+ element,
360
+ );
361
+ return;
362
+ }
343
363
 
344
364
  cacheStyles(element);
345
365
  activateEditor(element, hash, options);
@@ -429,7 +449,7 @@ function createPlainTextEditor(element: HTMLElement, options: Required<UpstartEd
429
449
  horizontalRule: false,
430
450
  }),
431
451
  Placeholder.configure({
432
- placeholder: "Click to edit...",
452
+ placeholder: getLabels().clickToEdit,
433
453
  }),
434
454
  ],
435
455
  content,
@@ -462,7 +482,7 @@ function createRichTextEditor(element: HTMLElement, options: Required<UpstartEdi
462
482
  const extensions: Extension[] = [
463
483
  StarterKit,
464
484
  Placeholder.configure({
465
- placeholder: options.placeholder,
485
+ placeholder: placeholderFor(options),
466
486
  }),
467
487
  ];
468
488
 
@@ -522,7 +542,7 @@ function createInlineRichTextEditor(element: HTMLElement, options: Required<Upst
522
542
  horizontalRule: false,
523
543
  }),
524
544
  Placeholder.configure({
525
- placeholder: "Click to edit...",
545
+ placeholder: getLabels().clickToEdit,
526
546
  }),
527
547
  ];
528
548
 
@@ -579,7 +599,7 @@ function createDirectEditor(element: HTMLElement, options: Required<UpstartEdito
579
599
  codeBlock: false,
580
600
  horizontalRule: false,
581
601
  }),
582
- Placeholder.configure({ placeholder: "Click to edit..." }),
602
+ Placeholder.configure({ placeholder: getLabels().clickToEdit }),
583
603
  ];
584
604
 
585
605
  if (options.bubbleMenu) {
@@ -615,7 +635,10 @@ function createRichPanelEditor(element: HTMLElement, options: Required<UpstartEd
615
635
  let hasChanged = false;
616
636
 
617
637
  const bubbleMenuElement = createBubbleMenuElement("inline");
618
- const extensions: Extension[] = [StarterKit, Placeholder.configure({ placeholder: options.placeholder })];
638
+ const extensions: Extension[] = [
639
+ StarterKit,
640
+ Placeholder.configure({ placeholder: placeholderFor(options) }),
641
+ ];
619
642
 
620
643
  if (options.bubbleMenu) {
621
644
  extensions.push(BubbleMenu.configure({ element: bubbleMenuElement }));
@@ -944,20 +967,20 @@ function restoreStyles(element: HTMLElement): void {
944
967
  element.style.transition = cached.transition;
945
968
  }
946
969
 
947
- const BUBBLE_MENU_GROUPS: { command: string; icon: string; title: string }[][] = [
970
+ const BUBBLE_MENU_GROUPS: { command: string; icon: string; label: keyof RuntimeLabels }[][] = [
948
971
  [
949
- { command: "bold", icon: "format-bold", title: "Bold" },
950
- { command: "italic", icon: "format-italic", title: "Italic" },
951
- { command: "strike", icon: "format-strikethrough", title: "Strikethrough" },
952
- { command: "code", icon: "code-tags", title: "Inline Code" },
972
+ { command: "bold", icon: "format-bold", label: "bold" },
973
+ { command: "italic", icon: "format-italic", label: "italic" },
974
+ { command: "strike", icon: "format-strikethrough", label: "strikethrough" },
975
+ { command: "code", icon: "code-tags", label: "inlineCode" },
953
976
  ],
954
977
  [
955
- { command: "heading", icon: "format-header-1", title: "Heading" },
956
- { command: "blockquote", icon: "format-quote-close", title: "Blockquote" },
978
+ { command: "heading", icon: "format-header-1", label: "heading" },
979
+ { command: "blockquote", icon: "format-quote-close", label: "blockquote" },
957
980
  ],
958
981
  [
959
- { command: "bulletList", icon: "format-list-bulleted", title: "Bullet List" },
960
- { command: "orderedList", icon: "format-list-numbered", title: "Ordered List" },
982
+ { command: "bulletList", icon: "format-list-bulleted", label: "bulletList" },
983
+ { command: "orderedList", icon: "format-list-numbered", label: "orderedList" },
961
984
  ],
962
985
  ];
963
986
 
@@ -982,7 +1005,8 @@ function createBubbleMenuElement(mode: "block" | "inline" = "block"): HTMLDivEle
982
1005
  menu.appendChild(separator);
983
1006
  }
984
1007
 
985
- for (const { command, icon, title } of group) {
1008
+ for (const { command, icon, label } of group) {
1009
+ const title = getLabels()[label];
986
1010
  const button = document.createElement("button");
987
1011
  button.type = "button";
988
1012
  button.dataset.command = command;
@@ -6,6 +6,9 @@ import type {
6
6
  PayloadArrayItemDelete,
7
7
  PayloadArraySet,
8
8
  } from "~/upstart-editor-api";
9
+ import type { RuntimeLabels } from "./labels.js";
10
+
11
+ export type { RuntimeLabels } from "./labels.js";
9
12
 
10
13
  /**
11
14
  * Editor mode
@@ -109,6 +112,12 @@ export type EditorMessage =
109
112
  | { type: "editor-route"; routeId: string }
110
113
  | { type: "scroll-position"; x: number; y: number }
111
114
  | { type: "editor-error"; error: string }
115
+ /**
116
+ * React's DOM reconciliation hit nodes the editor had replaced. `fatal: false`: the page was
117
+ * kept alive by the DOM guard (a refresh is advisable); `fatal: true`: the page crashed and
118
+ * the preview shows its recovery overlay.
119
+ */
120
+ | { type: "editor-desync"; fatal: boolean }
112
121
  | { type: "selection-changed"; selection: SelectionRef[] }
113
122
  | {
114
123
  type: "element-clicked";
@@ -140,7 +149,9 @@ export type ParentMessage =
140
149
  | { type: "request-scroll-position" }
141
150
  | { type: "restore-scroll-position"; x: number; y: number }
142
151
  | { type: "clear-selection" }
143
- | { type: "deselect"; key: string };
152
+ | { type: "deselect"; key: string }
153
+ /** Translated labels for the controls the runtime renders (see `runtime/labels.ts`). */
154
+ | { type: "set-labels"; labels: Partial<RuntimeLabels> };
144
155
 
145
156
  /**
146
157
  * Message wrapper sent via postMessage from iframe.