@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.
- package/dist/upstart-editor-api.d.ts +2 -0
- package/dist/upstart-editor-api.d.ts.map +1 -1
- package/dist/upstart-editor-api.js +67 -9
- package/dist/upstart-editor-api.js.map +1 -1
- package/dist/vite-plugin-upstart-attrs.d.ts.map +1 -1
- package/dist/vite-plugin-upstart-attrs.js +18 -1
- package/dist/vite-plugin-upstart-attrs.js.map +1 -1
- package/dist/vite-plugin-upstart-editor/runtime/array-controls.js +7 -7
- package/dist/vite-plugin-upstart-editor/runtime/array-controls.js.map +1 -1
- package/dist/vite-plugin-upstart-editor/runtime/click-handler.d.ts.map +1 -1
- package/dist/vite-plugin-upstart-editor/runtime/click-handler.js +30 -16
- package/dist/vite-plugin-upstart-editor/runtime/click-handler.js.map +1 -1
- package/dist/vite-plugin-upstart-editor/runtime/dom-guard.js +45 -0
- package/dist/vite-plugin-upstart-editor/runtime/dom-guard.js.map +1 -0
- package/dist/vite-plugin-upstart-editor/runtime/error-handler.d.ts +7 -1
- package/dist/vite-plugin-upstart-editor/runtime/error-handler.d.ts.map +1 -1
- package/dist/vite-plugin-upstart-editor/runtime/error-handler.js +42 -2
- package/dist/vite-plugin-upstart-editor/runtime/error-handler.js.map +1 -1
- package/dist/vite-plugin-upstart-editor/runtime/form-guard.js +2 -2
- package/dist/vite-plugin-upstart-editor/runtime/form-guard.js.map +1 -1
- package/dist/vite-plugin-upstart-editor/runtime/image-pick.js +32 -0
- package/dist/vite-plugin-upstart-editor/runtime/image-pick.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 +9 -1
- package/dist/vite-plugin-upstart-editor/runtime/index.js.map +1 -1
- package/dist/vite-plugin-upstart-editor/runtime/labels.d.ts +32 -0
- package/dist/vite-plugin-upstart-editor/runtime/labels.d.ts.map +1 -0
- package/dist/vite-plugin-upstart-editor/runtime/labels.js +45 -0
- package/dist/vite-plugin-upstart-editor/runtime/labels.js.map +1 -0
- package/dist/vite-plugin-upstart-editor/runtime/text-editor.d.ts.map +1 -1
- package/dist/vite-plugin-upstart-editor/runtime/text-editor.js +26 -14
- package/dist/vite-plugin-upstart-editor/runtime/text-editor.js.map +1 -1
- package/dist/vite-plugin-upstart-editor/runtime/types.d.ts +14 -1
- package/dist/vite-plugin-upstart-editor/runtime/types.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/tests/dom-guard.test.ts +100 -0
- package/src/tests/editor-labels.test.ts +40 -0
- package/src/tests/image-pick.test.ts +50 -0
- package/src/tests/upstart-editor-api.test.ts +75 -0
- package/src/tests/vite-plugin-upstart-attrs.test.ts +21 -0
- package/src/upstart-editor-api.ts +74 -8
- package/src/vite-plugin-upstart-attrs.ts +44 -1
- package/src/vite-plugin-upstart-editor/runtime/array-controls.ts +7 -7
- package/src/vite-plugin-upstart-editor/runtime/click-handler.ts +29 -19
- package/src/vite-plugin-upstart-editor/runtime/dom-guard.ts +67 -0
- package/src/vite-plugin-upstart-editor/runtime/error-handler.ts +48 -1
- package/src/vite-plugin-upstart-editor/runtime/form-guard.ts +3 -2
- package/src/vite-plugin-upstart-editor/runtime/image-pick.ts +40 -0
- package/src/vite-plugin-upstart-editor/runtime/index.ts +11 -0
- package/src/vite-plugin-upstart-editor/runtime/labels.ts +77 -0
- package/src/vite-plugin-upstart-editor/runtime/text-editor.ts +39 -15
- 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)
|
|
64
|
-
* clicked element
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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 =
|
|
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:
|
|
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
|
|
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:
|
|
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:
|
|
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[] = [
|
|
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;
|
|
970
|
+
const BUBBLE_MENU_GROUPS: { command: string; icon: string; label: keyof RuntimeLabels }[][] = [
|
|
948
971
|
[
|
|
949
|
-
{ command: "bold", icon: "format-bold",
|
|
950
|
-
{ command: "italic", icon: "format-italic",
|
|
951
|
-
{ command: "strike", icon: "format-strikethrough",
|
|
952
|
-
{ command: "code", icon: "code-tags",
|
|
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",
|
|
956
|
-
{ command: "blockquote", icon: "format-quote-close",
|
|
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",
|
|
960
|
-
{ command: "orderedList", icon: "format-list-numbered",
|
|
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,
|
|
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.
|