@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 { PayloadArrayItemAdd, PayloadArrayItemDelete, PayloadArraySet, PayloadEditText, PayloadEditTextDirect } from "../../upstart-editor-api.js";
|
|
2
|
+
import { RuntimeLabels } from "./labels.js";
|
|
2
3
|
import { Editor } from "@tiptap/core";
|
|
3
4
|
|
|
4
5
|
//#region src/vite-plugin-upstart-editor/runtime/types.d.ts
|
|
@@ -101,6 +102,15 @@ type EditorMessage = {
|
|
|
101
102
|
} | {
|
|
102
103
|
type: "editor-error";
|
|
103
104
|
error: string;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* React's DOM reconciliation hit nodes the editor had replaced. `fatal: false`: the page was
|
|
108
|
+
* kept alive by the DOM guard (a refresh is advisable); `fatal: true`: the page crashed and
|
|
109
|
+
* the preview shows its recovery overlay.
|
|
110
|
+
*/
|
|
111
|
+
| {
|
|
112
|
+
type: "editor-desync";
|
|
113
|
+
fatal: boolean;
|
|
104
114
|
} | {
|
|
105
115
|
type: "selection-changed";
|
|
106
116
|
selection: SelectionRef[];
|
|
@@ -149,6 +159,9 @@ type ParentMessage = {
|
|
|
149
159
|
} | {
|
|
150
160
|
type: "deselect";
|
|
151
161
|
key: string;
|
|
162
|
+
} /** Translated labels for the controls the runtime renders (see `runtime/labels.ts`). */ | {
|
|
163
|
+
type: "set-labels";
|
|
164
|
+
labels: Partial<RuntimeLabels>;
|
|
152
165
|
};
|
|
153
166
|
/**
|
|
154
167
|
* Message wrapper sent via postMessage from iframe.
|
|
@@ -224,5 +237,5 @@ interface UpstartEditorPluginOptions {
|
|
|
224
237
|
autoInject?: boolean;
|
|
225
238
|
}
|
|
226
239
|
//#endregion
|
|
227
|
-
export { EditableImageRef, EditorInstance, EditorMessage, EditorMode, ElementBounds, ParentMessage, SelectionRef, TextEditorMode, UpstartEditorMessage, UpstartEditorOptions, UpstartEditorPluginOptions, UpstartParentMessage };
|
|
240
|
+
export { EditableImageRef, EditorInstance, EditorMessage, EditorMode, ElementBounds, ParentMessage, type RuntimeLabels, SelectionRef, TextEditorMode, UpstartEditorMessage, UpstartEditorOptions, UpstartEditorPluginOptions, UpstartParentMessage };
|
|
228
241
|
//# sourceMappingURL=types.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","names":[],"sources":["../../../src/vite-plugin-upstart-editor/runtime/types.ts"],"mappings":";;;;;;
|
|
1
|
+
{"version":3,"file":"types.d.ts","names":[],"sources":["../../../src/vite-plugin-upstart-editor/runtime/types.ts"],"mappings":";;;;;;AAkBA;;;;;KAAY,UAAA;;;;;;UAOK,YAAA;EACf,GAAA;EACA,IAAA;EACA,QAAA;EACA,aAAA;EACA,QAAA;EACA,IAAA;EAiBU;;;;;EAXV,SAAA;AAAA;;;;;;;;;KAWU,cAAA;;AAiBZ;;UAZiB,aAAA;EACf,GAAA;EACA,IAAA;EACA,KAAA;EACA,MAAA;EACA,KAAA;EACA,MAAA;AAAA;;;;UAMe,cAAA;EACf,MAAA,EAAQ,MAAA;EACR,OAAA,EAAS,WAAA;EACT,IAAA;EACA,IAAA,EAAM,cAAA;AAAA;;;;;UAOS,gBAAA;EACf,EAAA;EACA,GAAA;EACA,GAAA;EAMA;EAJA,WAAA;EACA,gBAAA;EAOA;EALA,KAAA;EACA,MAAA;EACA,YAAA;EACA,aAAA;EAWU;EATV,OAAA;;EAEA,iBAAA;EACA,eAAA;AAAA;;;;KAMU,aAAA;EAEN,IAAA;EACA,OAAA,EACI,eAAA,GACA,qBAAA,GACA,mBAAA,GACA,sBAAA,GACA,eAAA;AAAA;EAEJ,IAAA;EAAsB,IAAA;AAAA;EACtB,IAAA;EAA0B,IAAA;AAAA;EAE1B,IAAA;EAAsB,OAAA;AAAA;EACtB,IAAA;EAAyB,CAAA;EAAW,CAAA;AAAA;EACpC,IAAA;EAAsB,KAAA;AAAA;;;;;;;EAMtB,IAAA;EAAuB,KAAA;AAAA;EACvB,IAAA;EAA2B,SAAA,EAAW,YAAA;AAAA;EAEtC,IAAA;EACA,IAAA;EACA,aAAA;EACA,QAAA;EACA,gBAAA;EACA,WAAA;EACA,YAAA;EACA,QAAA;EACA,WAAA,GAAc,MAAA;EACd,MAAA,EAAQ,aAAA;EACR,aAAA,WAIyB;EAFzB,OAAA,WAQmB;EANnB,MAAA,GAAS,gBAAA;AAAA;;;;KAMH,aAAA;EACN,IAAA;EAAkB,IAAA,EAAM,UAAA;AAAA;EACxB,IAAA;EAA2B,WAAA;EAAqB,SAAA;EAAmB,UAAA;AAAA;EACnE,IAAA;EAAuB,OAAA;EAAiB,GAAA;AAAA;EAExC,IAAA;EAA6B,OAAA;EAAiB,GAAA;AAAA;EAC9C,IAAA;AAAA;EACA,IAAA;EAAiC,CAAA;EAAW,CAAA;AAAA;EAC5C,IAAA;AAAA;EACA,IAAA;EAAkB,GAAA;AAAA;EAElB,IAAA;EAAoB,MAAA,EAAQ,OAAA,CAAQ,aAAA;AAAA;;;AAc1C;UATiB,oBAAA;EACf,MAAA;EACA,IAAA,EAAM,aAAA;EAAA,CACL,GAAA;AAAA;;;;UAMc,oBAAA;EACf,MAAA;EACA,IAAA,EAAM,aAAA;EAAA,CACL,GAAA;AAAA;;;;UAMc,oBAAA;EA8Bf;;;;EAzBA,gBAAA;EAsCoD;;AAMtD;;;EArCE,sBAAA;EAgDU;;;;EA1CV,iBAAA;;;;;EAMA,UAAA;;;;;EAMA,WAAA;;;;;EAMA,aAAA;;;;;;EAOA,kBAAA,IAAsB,SAAA,UAAmB,GAAA;AAAA;;;;UAM1B,0BAAA;;;;;EAKf,OAAA;;;;;EAMA,UAAA;AAAA"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@upstart.gg/vite-plugins",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.70",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"files": [
|
|
6
6
|
"dist",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"oxc-parser": "0.101.0",
|
|
24
24
|
"unplugin": "^2.3.11",
|
|
25
25
|
"zimmerframe": "^1.1.4",
|
|
26
|
-
"@upstart.gg/sdk": "^0.1.
|
|
26
|
+
"@upstart.gg/sdk": "^0.1.70"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
29
|
"@rolldown/binding-linux-arm64-gnu": "1.0.0",
|
|
@@ -118,7 +118,7 @@
|
|
|
118
118
|
},
|
|
119
119
|
"peerDependencies": {
|
|
120
120
|
"zod": "4.3.6",
|
|
121
|
-
"@upstart.gg/sdk": "^0.1.
|
|
121
|
+
"@upstart.gg/sdk": "^0.1.70"
|
|
122
122
|
},
|
|
123
123
|
"author": "Upstart",
|
|
124
124
|
"publishConfig": {
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { describe, expect, test, vi } from "vitest";
|
|
2
|
+
import { installDomGuard, type DomLike } from "../vite-plugin-upstart-editor/runtime/dom-guard";
|
|
3
|
+
|
|
4
|
+
/** Minimal parent/child model standing in for DOM nodes (vitest runs in node here). */
|
|
5
|
+
class FakeNode {
|
|
6
|
+
parentNode: FakeNode | null = null;
|
|
7
|
+
children: FakeNode[] = [];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function makeProto() {
|
|
11
|
+
const insertBefore = vi.fn(function (this: FakeNode, node: FakeNode, child: FakeNode | null) {
|
|
12
|
+
const idx = child ? this.children.indexOf(child) : -1;
|
|
13
|
+
if (child && idx === -1) throw new Error("NotFoundError: insertBefore");
|
|
14
|
+
if (child) this.children.splice(idx, 0, node);
|
|
15
|
+
else this.children.push(node);
|
|
16
|
+
node.parentNode = this;
|
|
17
|
+
return node;
|
|
18
|
+
});
|
|
19
|
+
const removeChild = vi.fn(function (this: FakeNode, child: FakeNode) {
|
|
20
|
+
const idx = this.children.indexOf(child);
|
|
21
|
+
if (idx === -1) throw new Error("NotFoundError: removeChild");
|
|
22
|
+
this.children.splice(idx, 1);
|
|
23
|
+
child.parentNode = null;
|
|
24
|
+
return child;
|
|
25
|
+
});
|
|
26
|
+
return { insertBefore, removeChild } as unknown as DomLike & { insertBefore: typeof insertBefore };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("installDomGuard", () => {
|
|
30
|
+
test("keeps normal inserts and removals untouched and silent", () => {
|
|
31
|
+
const proto = makeProto();
|
|
32
|
+
const onDesync = vi.fn();
|
|
33
|
+
installDomGuard(proto, onDesync);
|
|
34
|
+
const parent = new FakeNode();
|
|
35
|
+
const a = new FakeNode();
|
|
36
|
+
const b = new FakeNode();
|
|
37
|
+
(proto.insertBefore as unknown as (this: FakeNode, n: FakeNode, c: FakeNode | null) => FakeNode).call(
|
|
38
|
+
parent,
|
|
39
|
+
a,
|
|
40
|
+
null,
|
|
41
|
+
);
|
|
42
|
+
(proto.insertBefore as unknown as (this: FakeNode, n: FakeNode, c: FakeNode | null) => FakeNode).call(
|
|
43
|
+
parent,
|
|
44
|
+
b,
|
|
45
|
+
a,
|
|
46
|
+
);
|
|
47
|
+
expect(parent.children).toEqual([b, a]);
|
|
48
|
+
(proto.removeChild as unknown as (this: FakeNode, c: FakeNode) => FakeNode).call(parent, b);
|
|
49
|
+
expect(parent.children).toEqual([a]);
|
|
50
|
+
expect(onDesync).not.toHaveBeenCalled();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("turns an insert before a vanished node into an append and reports once", () => {
|
|
54
|
+
const proto = makeProto();
|
|
55
|
+
const onDesync = vi.fn();
|
|
56
|
+
installDomGuard(proto, onDesync);
|
|
57
|
+
const parent = new FakeNode();
|
|
58
|
+
const existing = new FakeNode();
|
|
59
|
+
parent.children.push(existing);
|
|
60
|
+
existing.parentNode = parent;
|
|
61
|
+
const gone = new FakeNode(); // removed by the editor, React still references it
|
|
62
|
+
const fresh = new FakeNode();
|
|
63
|
+
const insert = proto.insertBefore as unknown as (
|
|
64
|
+
this: FakeNode,
|
|
65
|
+
n: FakeNode,
|
|
66
|
+
c: FakeNode | null,
|
|
67
|
+
) => FakeNode;
|
|
68
|
+
expect(() => insert.call(parent, fresh, gone)).not.toThrow();
|
|
69
|
+
expect(parent.children).toEqual([existing, fresh]);
|
|
70
|
+
expect(() => insert.call(parent, new FakeNode(), gone)).not.toThrow();
|
|
71
|
+
expect(onDesync).toHaveBeenCalledTimes(1);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("logs every tolerated call with its stack so the culprit can be found", () => {
|
|
75
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
76
|
+
const proto = makeProto();
|
|
77
|
+
installDomGuard(proto, () => {});
|
|
78
|
+
const parent = new FakeNode();
|
|
79
|
+
const gone = new FakeNode();
|
|
80
|
+
const remove = proto.removeChild as unknown as (this: FakeNode, c: FakeNode) => FakeNode;
|
|
81
|
+
remove.call(parent, gone);
|
|
82
|
+
remove.call(parent, gone);
|
|
83
|
+
expect(warn).toHaveBeenCalledTimes(2);
|
|
84
|
+
expect(String(warn.mock.calls[0]?.[0])).toContain("DOM out of sync: removeChild");
|
|
85
|
+
expect(warn.mock.calls[0]?.[1]).toMatchObject({ parent, node: gone });
|
|
86
|
+
expect(String((warn.mock.calls[0]?.[1] as { stack: string }).stack)).toContain("DOM out of sync");
|
|
87
|
+
warn.mockRestore();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("ignores the removal of a node that is no longer a child", () => {
|
|
91
|
+
const proto = makeProto();
|
|
92
|
+
const onDesync = vi.fn();
|
|
93
|
+
installDomGuard(proto, onDesync);
|
|
94
|
+
const parent = new FakeNode();
|
|
95
|
+
const gone = new FakeNode();
|
|
96
|
+
const remove = proto.removeChild as unknown as (this: FakeNode, c: FakeNode) => FakeNode;
|
|
97
|
+
expect(remove.call(parent, gone)).toBe(gone);
|
|
98
|
+
expect(onDesync).toHaveBeenCalledTimes(1);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_LABELS,
|
|
4
|
+
getLabels,
|
|
5
|
+
resetLabels,
|
|
6
|
+
setLabels,
|
|
7
|
+
} from "../vite-plugin-upstart-editor/runtime/labels";
|
|
8
|
+
|
|
9
|
+
describe("editor runtime labels", () => {
|
|
10
|
+
afterEach(() => resetLabels());
|
|
11
|
+
|
|
12
|
+
test("starts with the English defaults", () => {
|
|
13
|
+
expect(getLabels()).toEqual(DEFAULT_LABELS);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("overrides only the provided keys", () => {
|
|
17
|
+
setLabels({ addItem: "Ajouter un élément", clickToEdit: "Cliquez pour modifier..." });
|
|
18
|
+
expect(getLabels().addItem).toBe("Ajouter un élément");
|
|
19
|
+
expect(getLabels().clickToEdit).toBe("Cliquez pour modifier...");
|
|
20
|
+
expect(getLabels().removeItem).toBe(DEFAULT_LABELS.removeItem);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("ignores unknown keys, empty strings and non-string values", () => {
|
|
24
|
+
setLabels({ addItem: "", removeItem: 42, unknown: "x" });
|
|
25
|
+
expect(getLabels()).toEqual(DEFAULT_LABELS);
|
|
26
|
+
expect("unknown" in getLabels()).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("ignores malformed payloads", () => {
|
|
30
|
+
setLabels(null);
|
|
31
|
+
setLabels("labels");
|
|
32
|
+
expect(getLabels()).toEqual(DEFAULT_LABELS);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("resetLabels restores the defaults", () => {
|
|
36
|
+
setLabels({ refresh: "Rafraîchir" });
|
|
37
|
+
resetLabels();
|
|
38
|
+
expect(getLabels().refresh).toBe(DEFAULT_LABELS.refresh);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { pickImagesForClick, rectContainsPoint } from "../vite-plugin-upstart-editor/runtime/image-pick";
|
|
3
|
+
|
|
4
|
+
const img = (id: string, left: number, top: number, size = 100) => ({
|
|
5
|
+
id,
|
|
6
|
+
rect: { left, top, width: size, height: size },
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
describe("rectContainsPoint", () => {
|
|
10
|
+
test("includes edges", () => {
|
|
11
|
+
const rect = { left: 10, top: 10, width: 20, height: 20 };
|
|
12
|
+
expect(rectContainsPoint(rect, { x: 10, y: 10 })).toBe(true);
|
|
13
|
+
expect(rectContainsPoint(rect, { x: 30, y: 30 })).toBe(true);
|
|
14
|
+
expect(rectContainsPoint(rect, { x: 31, y: 20 })).toBe(false);
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("pickImagesForClick", () => {
|
|
19
|
+
test("a clicked element without images does not surface a sibling image (navbar title vs logo)", () => {
|
|
20
|
+
const logo = img("logo", 0, 0, 40);
|
|
21
|
+
expect(pickImagesForClick([], [[logo], [logo]], { x: 200, y: 20 })).toEqual([]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("a click visually on a hero covered by an overlay surfaces that hero", () => {
|
|
25
|
+
const hero = img("hero", 0, 0, 800);
|
|
26
|
+
expect(pickImagesForClick([], [[], [hero]], { x: 400, y: 300 })).toEqual([hero]);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("ancestors never surface every image on the page", () => {
|
|
30
|
+
const a = img("a", 0, 0);
|
|
31
|
+
const b = img("b", 500, 500);
|
|
32
|
+
expect(pickImagesForClick([], [[a, b]], { x: 550, y: 550 })).toEqual([b]);
|
|
33
|
+
expect(pickImagesForClick([], [[a, b]], { x: 300, y: 300 })).toEqual([]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("images inside the clicked element are all candidates, narrowed to the one under the click", () => {
|
|
37
|
+
const a = img("a", 0, 0);
|
|
38
|
+
const b = img("b", 200, 0);
|
|
39
|
+
// Click on the container's padding: both images belong to what was clicked.
|
|
40
|
+
expect(pickImagesForClick([a, b], [], { x: 150, y: 50 })).toEqual([a, b]);
|
|
41
|
+
// Click on image b itself.
|
|
42
|
+
expect(pickImagesForClick([a, b], [], { x: 250, y: 50 })).toEqual([b]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("without a click point, own images are returned and ancestors are ignored", () => {
|
|
46
|
+
const a = img("a", 0, 0);
|
|
47
|
+
expect(pickImagesForClick([a], [], undefined)).toEqual([a]);
|
|
48
|
+
expect(pickImagesForClick([], [[a]], undefined)).toEqual([]);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -170,6 +170,81 @@ describe("UpstartEditorAPI", () => {
|
|
|
170
170
|
expect(updatedContent).toBe(`<div className="px-8 py-4 bg-blue-500">Hello</div>`);
|
|
171
171
|
});
|
|
172
172
|
|
|
173
|
+
test("resolves a stale id by previousClassName once the registry was regenerated", async () => {
|
|
174
|
+
// A previous edit enlarged the parent's className, so the rebuild moved the image's
|
|
175
|
+
// className literal (and its `file:offset` id) while the preview still uses the old id.
|
|
176
|
+
const sourceContent = `<div className="wrap overflow-hidden"><img className="img object-cover" /></div>`;
|
|
177
|
+
const sourcePath = path.join(tempDir, "test.tsx");
|
|
178
|
+
await fs.writeFile(sourcePath, sourceContent);
|
|
179
|
+
const imgStart = sourceContent.indexOf("img object-cover");
|
|
180
|
+
const registry: EditableRegistry = {
|
|
181
|
+
version: 1,
|
|
182
|
+
generatedAt: new Date().toISOString(),
|
|
183
|
+
elements: {
|
|
184
|
+
"test.tsx:15": {
|
|
185
|
+
file: "test.tsx",
|
|
186
|
+
type: "className",
|
|
187
|
+
startOffset: 16,
|
|
188
|
+
endOffset: 36,
|
|
189
|
+
originalContent: "wrap overflow-hidden",
|
|
190
|
+
context: { parentTag: "div" },
|
|
191
|
+
},
|
|
192
|
+
[`test.tsx:${imgStart - 1}`]: {
|
|
193
|
+
file: "test.tsx",
|
|
194
|
+
type: "className",
|
|
195
|
+
startOffset: imgStart,
|
|
196
|
+
endOffset: imgStart + "img object-cover".length,
|
|
197
|
+
originalContent: "img object-cover",
|
|
198
|
+
context: { parentTag: "img" },
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
await fs.writeFile(registryPath, JSON.stringify(registry));
|
|
203
|
+
|
|
204
|
+
const result = await api.editClassName({
|
|
205
|
+
action: "editClassName",
|
|
206
|
+
id: "test.tsx:40", // the image's id before the rebuild
|
|
207
|
+
className: "img object-cover object-[50%_30%]",
|
|
208
|
+
previousClassName: "img object-cover",
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
expect(result.success).toBe(true);
|
|
212
|
+
const updated = await fs.readFile(sourcePath, "utf-8");
|
|
213
|
+
expect(updated).toBe(
|
|
214
|
+
`<div className="wrap overflow-hidden"><img className="img object-cover object-[50%_30%]" /></div>`,
|
|
215
|
+
);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("falls back to a source search when neither the id nor the registry know the className", async () => {
|
|
219
|
+
const sourceContent = `<img className="img object-cover" />`;
|
|
220
|
+
const sourcePath = path.join(tempDir, "test.tsx");
|
|
221
|
+
await fs.writeFile(sourcePath, sourceContent);
|
|
222
|
+
const registry: EditableRegistry = { version: 1, generatedAt: new Date().toISOString(), elements: {} };
|
|
223
|
+
await fs.writeFile(registryPath, JSON.stringify(registry));
|
|
224
|
+
|
|
225
|
+
const result = await api.editClassName({
|
|
226
|
+
action: "editClassName",
|
|
227
|
+
id: "test.tsx:15",
|
|
228
|
+
className: "img object-contain",
|
|
229
|
+
previousClassName: "img object-cover",
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
expect(result.success).toBe(true);
|
|
233
|
+
expect(await fs.readFile(sourcePath, "utf-8")).toBe(`<img className="img object-contain" />`);
|
|
234
|
+
// Registered under the preview's id so the next edit resolves directly.
|
|
235
|
+
expect(api.getRegistry()?.elements["test.tsx:15"]?.originalContent).toBe("img object-contain");
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("keeps failing on a stale id without previousClassName", async () => {
|
|
239
|
+
await fs.writeFile(path.join(tempDir, "test.tsx"), `<img className="img" />`);
|
|
240
|
+
const registry: EditableRegistry = { version: 1, generatedAt: new Date().toISOString(), elements: {} };
|
|
241
|
+
await fs.writeFile(registryPath, JSON.stringify(registry));
|
|
242
|
+
|
|
243
|
+
const result = await api.editClassName({ action: "editClassName", id: "test.tsx:15", className: "x" });
|
|
244
|
+
expect(result.success).toBe(false);
|
|
245
|
+
expect(result.error).toContain("not found in registry");
|
|
246
|
+
});
|
|
247
|
+
|
|
173
248
|
test("should fail if element is not className type", async () => {
|
|
174
249
|
const registry: EditableRegistry = {
|
|
175
250
|
version: 1,
|
|
@@ -1569,6 +1569,27 @@ describe("upstart-editor-vite-plugin", () => {
|
|
|
1569
1569
|
expect(result).toContain('data-upstart-editable-text="true"');
|
|
1570
1570
|
});
|
|
1571
1571
|
|
|
1572
|
+
test("wraps a <Trans> in its own editable <span> when an icon sibling would be destroyed", () => {
|
|
1573
|
+
const code = `
|
|
1574
|
+
import { Trans } from "react-i18next";
|
|
1575
|
+
import { Icon } from "@iconify/react";
|
|
1576
|
+
export default function Badge() {
|
|
1577
|
+
return <div className="badge"><Icon icon="lucide:sparkles" /><Trans i18nKey="nav.badge" /></div>;
|
|
1578
|
+
}
|
|
1579
|
+
`;
|
|
1580
|
+
const result = transform(code) ?? "";
|
|
1581
|
+
// The container keeps its hash but is not editable itself…
|
|
1582
|
+
const container = result.match(/<div[^>]*className="badge"[^>]*>/)?.[0] ?? "";
|
|
1583
|
+
expect(container).toContain("data-upstart-hash=");
|
|
1584
|
+
expect(container).not.toContain("data-upstart-editable-text");
|
|
1585
|
+
// …the <Trans> is wrapped in an editable span carrying the i18n attributes.
|
|
1586
|
+
expect(result).toMatch(
|
|
1587
|
+
/<span data-upstart-hash="[a-f0-9]+" data-upstart-editable-text="true" data-upstart-editable-text-mode="plain" data-upstart-i18n="translation:nav\.badge"><Trans i18nKey="nav\.badge" \/><\/span>/,
|
|
1588
|
+
);
|
|
1589
|
+
// The icon is untouched.
|
|
1590
|
+
expect(result).toMatch(/<Icon [^>]*icon="lucide:sparkles"[^>]*\/>/);
|
|
1591
|
+
});
|
|
1592
|
+
|
|
1572
1593
|
test("should promote <Trans> with custom namespace to parent", () => {
|
|
1573
1594
|
const code = `
|
|
1574
1595
|
import { Trans } from "react-i18next";
|
|
@@ -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,5 +1,6 @@
|
|
|
1
1
|
import { getCurrentMode } from "./state.js";
|
|
2
2
|
import { sendToParent } from "./utils.js";
|
|
3
|
+
import { getLabels } from "./labels.js";
|
|
3
4
|
|
|
4
5
|
// Editor-only controls for inline-array .map() lists. Add/delete are staged LOCALLY
|
|
5
6
|
// (no server round-trip per action): "×" marks an item for removal, "+" inserts an
|
|
@@ -7,7 +8,6 @@ import { sendToParent } from "./utils.js";
|
|
|
7
8
|
// via a single `arraySet` edit + rebuild. Per-item text editing stays live; arraySet
|
|
8
9
|
// reads the current item texts from the DOM so live edits are preserved.
|
|
9
10
|
|
|
10
|
-
const DEFAULT_NEW_ITEM = "New item";
|
|
11
11
|
const DRAFT_ATTR = "data-upstart-draft-item";
|
|
12
12
|
|
|
13
13
|
// Layout of the "+"/"✓" stack placed to the right of a list.
|
|
@@ -299,7 +299,7 @@ function makeItemDeleteButton(
|
|
|
299
299
|
): HTMLButtonElement {
|
|
300
300
|
const btn = baseButton(rect.top + window.scrollY - 8, rect.right + window.scrollX - 8, 16, "#ef4444");
|
|
301
301
|
setIcon(btn, marked ? "restore" : "cross", 10);
|
|
302
|
-
btn.title = marked ?
|
|
302
|
+
btn.title = marked ? getLabels().keepItem : getLabels().removeItem;
|
|
303
303
|
btn.addEventListener("click", (e) => {
|
|
304
304
|
e.preventDefault();
|
|
305
305
|
e.stopPropagation();
|
|
@@ -316,7 +316,7 @@ function makeItemDeleteButton(
|
|
|
316
316
|
function makeDraftRemoveButton(pill: HTMLElement, rect: DOMRect): HTMLButtonElement {
|
|
317
317
|
const btn = baseButton(rect.top + window.scrollY - 8, rect.right + window.scrollX - 8, 16, "#ef4444");
|
|
318
318
|
setIcon(btn, "cross", 10);
|
|
319
|
-
btn.title =
|
|
319
|
+
btn.title = getLabels().removeItem;
|
|
320
320
|
btn.addEventListener("click", (e) => {
|
|
321
321
|
e.preventDefault();
|
|
322
322
|
e.stopPropagation();
|
|
@@ -329,7 +329,7 @@ function makeDraftRemoveButton(pill: HTMLElement, rect: DOMRect): HTMLButtonElem
|
|
|
329
329
|
function makeAddButton(arrayId: string, top: number, left: number): HTMLButtonElement {
|
|
330
330
|
const btn = baseButton(top, left, 22, "#7270c6");
|
|
331
331
|
setIcon(btn, "plus", 14);
|
|
332
|
-
btn.title =
|
|
332
|
+
btn.title = getLabels().addItem;
|
|
333
333
|
btn.addEventListener("click", (e) => {
|
|
334
334
|
e.preventDefault();
|
|
335
335
|
e.stopPropagation();
|
|
@@ -341,7 +341,7 @@ function makeAddButton(arrayId: string, top: number, left: number): HTMLButtonEl
|
|
|
341
341
|
function makeApplyButton(arrayId: string, top: number, left: number): HTMLButtonElement {
|
|
342
342
|
const btn = baseButton(top, left, 22, "#16a34a");
|
|
343
343
|
setIcon(btn, "check", 13);
|
|
344
|
-
btn.title =
|
|
344
|
+
btn.title = getLabels().applyChanges;
|
|
345
345
|
btn.addEventListener("click", (e) => {
|
|
346
346
|
e.preventDefault();
|
|
347
347
|
e.stopPropagation();
|
|
@@ -357,7 +357,7 @@ function makeDragHandle(arrayId: string, node: HTMLElement, rect: DOMRect): HTML
|
|
|
357
357
|
const left = rect.left + window.scrollX + rect.width / 2 - 8;
|
|
358
358
|
const btn = baseButton(top, left, 16, "#6b7280");
|
|
359
359
|
setIcon(btn, "grip", 12);
|
|
360
|
-
btn.title =
|
|
360
|
+
btn.title = getLabels().dragToReorder;
|
|
361
361
|
btn.style.cursor = "grab";
|
|
362
362
|
btn.addEventListener("pointerdown", (e) => {
|
|
363
363
|
e.preventDefault();
|
|
@@ -430,7 +430,7 @@ function addDraftPill(arrayId: string): void {
|
|
|
430
430
|
pill.removeAttribute("style");
|
|
431
431
|
pill.setAttribute(DRAFT_ATTR, arrayId);
|
|
432
432
|
pill.setAttribute("contenteditable", "true");
|
|
433
|
-
pill.textContent =
|
|
433
|
+
pill.textContent = getLabels().newItem;
|
|
434
434
|
|
|
435
435
|
// Insert after the current last pill of this array (drafts go at the end).
|
|
436
436
|
const last = draftPillsFor(arrayId).pop() ?? originalItemsFor(arrayId).pop();
|