@upstart.gg/vite-plugins 0.1.68 → 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.
- 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/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 +41 -2
- package/dist/vite-plugin-upstart-editor/runtime/error-handler.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 +7 -0
- package/dist/vite-plugin-upstart-editor/runtime/index.js.map +1 -1
- package/dist/vite-plugin-upstart-editor/runtime/text-editor.js +6 -0
- package/dist/vite-plugin-upstart-editor/runtime/text-editor.js.map +1 -1
- package/dist/vite-plugin-upstart-editor/runtime/types.d.ts +9 -0
- 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/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/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 +47 -1
- package/src/vite-plugin-upstart-editor/runtime/image-pick.ts +40 -0
- package/src/vite-plugin-upstart-editor/runtime/index.ts +8 -0
- package/src/vite-plugin-upstart-editor/runtime/text-editor.ts +14 -0
- 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
|
|
667
|
-
if (
|
|
668
|
-
|
|
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
|
-
|
|
672
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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,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";
|