@upstart.gg/vite-plugins 0.1.84 → 0.1.85

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/upstart-editor-api.d.ts +58 -1
  2. package/dist/upstart-editor-api.d.ts.map +1 -1
  3. package/dist/upstart-editor-api.js +224 -1
  4. package/dist/upstart-editor-api.js.map +1 -1
  5. package/dist/vite-plugin-upstart-attrs.d.ts +1 -1
  6. package/dist/vite-plugin-upstart-attrs.d.ts.map +1 -1
  7. package/dist/vite-plugin-upstart-attrs.js +65 -2
  8. package/dist/vite-plugin-upstart-attrs.js.map +1 -1
  9. package/dist/vite-plugin-upstart-editor/runtime/array-controls.js +2 -24
  10. package/dist/vite-plugin-upstart-editor/runtime/array-controls.js.map +1 -1
  11. package/dist/vite-plugin-upstart-editor/runtime/click-handler.js +7 -1
  12. package/dist/vite-plugin-upstart-editor/runtime/click-handler.js.map +1 -1
  13. package/dist/vite-plugin-upstart-editor/runtime/controls-ui.js +33 -0
  14. package/dist/vite-plugin-upstart-editor/runtime/controls-ui.js.map +1 -0
  15. package/dist/vite-plugin-upstart-editor/runtime/index.d.ts.map +1 -1
  16. package/dist/vite-plugin-upstart-editor/runtime/index.js +23 -0
  17. package/dist/vite-plugin-upstart-editor/runtime/index.js.map +1 -1
  18. package/dist/vite-plugin-upstart-editor/runtime/labels.d.ts +5 -0
  19. package/dist/vite-plugin-upstart-editor/runtime/labels.d.ts.map +1 -1
  20. package/dist/vite-plugin-upstart-editor/runtime/labels.js +5 -0
  21. package/dist/vite-plugin-upstart-editor/runtime/labels.js.map +1 -1
  22. package/dist/vite-plugin-upstart-editor/runtime/move-controls.js +434 -0
  23. package/dist/vite-plugin-upstart-editor/runtime/move-controls.js.map +1 -0
  24. package/dist/vite-plugin-upstart-editor/runtime/types.d.ts +6 -3
  25. package/dist/vite-plugin-upstart-editor/runtime/types.d.ts.map +1 -1
  26. package/package.json +3 -3
  27. package/src/tests/move-controls.test.ts +86 -0
  28. package/src/tests/upstart-editor-api-move-element.test.ts +220 -0
  29. package/src/tests/vite-plugin-upstart-attrs.test.ts +165 -4
  30. package/src/upstart-editor-api.ts +263 -0
  31. package/src/vite-plugin-upstart-attrs.ts +93 -2
  32. package/src/vite-plugin-upstart-editor/runtime/array-controls.ts +2 -40
  33. package/src/vite-plugin-upstart-editor/runtime/click-handler.ts +17 -3
  34. package/src/vite-plugin-upstart-editor/runtime/controls-ui.ts +48 -0
  35. package/src/vite-plugin-upstart-editor/runtime/index.ts +31 -0
  36. package/src/vite-plugin-upstart-editor/runtime/labels.ts +10 -0
  37. package/src/vite-plugin-upstart-editor/runtime/move-controls.ts +536 -0
  38. package/src/vite-plugin-upstart-editor/runtime/types.ts +7 -2
@@ -0,0 +1,220 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from "vitest";
2
+ import fs from "fs/promises";
3
+ import path from "path";
4
+ import os from "os";
5
+ import { UpstartEditorAPI, mapOffset, moveRange } from "../upstart-editor-api";
6
+ import { transformWithOxc, getRegistry, clearRegistry } from "../vite-plugin-upstart-attrs";
7
+
8
+ const FILE = "app/components/Hero.tsx";
9
+
10
+ const HERO = `export function Hero() {
11
+ return (
12
+ <section className="hero">
13
+ <h1>Welcome</h1>
14
+ <p>Some text</p>
15
+ <div className="media">
16
+ <img src="/images/hero.webp" alt="" />
17
+ <span>Caption</span>
18
+ </div>
19
+ </section>
20
+ );
21
+ }
22
+ `;
23
+
24
+ type Position = "before" | "after";
25
+
26
+ describe("moveElements", () => {
27
+ let tempDir: string;
28
+ let api: UpstartEditorAPI;
29
+
30
+ // Run the attrs plugin on `code`, register it in the API and write the file; returns the
31
+ // movable element ids keyed by tag name.
32
+ async function setup(code: string): Promise<Record<string, string>> {
33
+ clearRegistry();
34
+ transformWithOxc(code, FILE);
35
+ const ids: Record<string, string> = {};
36
+ for (const [id, entry] of Object.entries(getRegistry())) {
37
+ if (entry.type !== "element") continue;
38
+ const tag = entry.originalContent.match(/^<([a-z0-9]+)/)?.[1];
39
+ if (tag) ids[tag] = id;
40
+ }
41
+ await fs.mkdir(path.join(tempDir, "app", "components"), { recursive: true });
42
+ await fs.writeFile(path.join(tempDir, FILE), code);
43
+ api.setRegistry({ version: 1, generatedAt: "", elements: getRegistry() });
44
+ return ids;
45
+ }
46
+
47
+ const read = () => fs.readFile(path.join(tempDir, FILE), "utf-8");
48
+ const move = (...moves: [id: string, targetId: string, position: Position][]) =>
49
+ api.moveElements({
50
+ action: "moveElements",
51
+ moves: moves.map(([id, targetId, position]) => ({ id, targetId, position })),
52
+ });
53
+
54
+ beforeEach(async () => {
55
+ tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "upstart-move-"));
56
+ api = new UpstartEditorAPI(tempDir, path.join(tempDir, "registry.json"));
57
+ });
58
+
59
+ afterEach(async () => {
60
+ clearRegistry();
61
+ await fs.rm(tempDir, { recursive: true, force: true });
62
+ });
63
+
64
+ test("moves an element after a sibling", async () => {
65
+ const ids = await setup(HERO);
66
+ const result = await move([ids.h1, ids.p, "after"]);
67
+ expect(result).toEqual({ success: true, filePaths: [path.join(tempDir, FILE)] });
68
+ expect(await read()).toBe(
69
+ HERO.replace("<h1>Welcome</h1>\n <p>Some text</p>", "<p>Some text</p>\n <h1>Welcome</h1>"),
70
+ );
71
+ });
72
+
73
+ test("moves an element before a sibling", async () => {
74
+ const ids = await setup(HERO);
75
+ expect((await move([ids.p, ids.h1, "before"])).success).toBe(true);
76
+ expect(await read()).toContain("<p>Some text</p>\n <h1>Welcome</h1>");
77
+ });
78
+
79
+ test("re-indents an element moved to another nesting level", async () => {
80
+ const ids = await setup(HERO);
81
+ expect((await move([ids.img, ids.h1, "before"])).success).toBe(true);
82
+ const code = await read();
83
+ expect(code).toContain(
84
+ '<section className="hero">\n <img src="/images/hero.webp" alt="" />\n <h1>',
85
+ );
86
+ expect(code).toContain('<div className="media">\n <span>Caption</span>\n </div>');
87
+ });
88
+
89
+ test("applies several moves in order, tracking the elements an earlier move shifted", async () => {
90
+ const ids = await setup(HERO);
91
+ const result = await move(
92
+ [ids.img, ids.h1, "before"],
93
+ [ids.h1, ids.p, "after"],
94
+ [ids.img, ids.h1, "after"],
95
+ );
96
+ expect(result.success).toBe(true);
97
+ expect(await read()).toContain(
98
+ '<section className="hero">\n <p>Some text</p>\n <h1>Welcome</h1>\n <img src="/images/hero.webp" alt="" />\n <div className="media">',
99
+ );
100
+ });
101
+
102
+ test("a move undone by a later one leaves the file unchanged and reports no file", async () => {
103
+ const ids = await setup(HERO);
104
+ const result = await move([ids.h1, ids.p, "after"], [ids.h1, ids.p, "before"]);
105
+ expect(result).toEqual({ success: true, filePaths: [] });
106
+ expect(await read()).toBe(HERO);
107
+ });
108
+
109
+ test("moving an element next to itself leaves the file unchanged", async () => {
110
+ const ids = await setup(HERO);
111
+ expect(await move([ids.p, ids.h1, "after"])).toEqual({ success: true, filePaths: [] });
112
+ expect(await read()).toBe(HERO);
113
+ });
114
+
115
+ test("finds an element whose offsets shifted after an earlier edit", async () => {
116
+ const ids = await setup(HERO);
117
+ await fs.writeFile(path.join(tempDir, FILE), `// edited\n${HERO}`);
118
+ expect((await move([ids.h1, ids.p, "after"])).success).toBe(true);
119
+ expect(await read()).toContain("<p>Some text</p>\n <h1>Welcome</h1>");
120
+ });
121
+
122
+ test("refuses elements from different files and writes nothing", async () => {
123
+ const ids = await setup(HERO);
124
+ const elements = getRegistry();
125
+ elements[ids.p] = { ...elements[ids.p], file: "app/components/Other.tsx" };
126
+ api.setRegistry({ version: 1, generatedAt: "", elements });
127
+ expect((await move([ids.h1, ids.img, "after"], [ids.h1, ids.p, "after"])).success).toBe(false);
128
+ expect(await read()).toBe(HERO);
129
+ });
130
+
131
+ test("refuses ids that are not movable elements", async () => {
132
+ const ids = await setup(HERO);
133
+ const classNameId = Object.entries(getRegistry()).find(([, e]) => e.type === "className")![0];
134
+ expect((await move([classNameId, ids.h1, "after"])).success).toBe(false);
135
+ });
136
+
137
+ test("refuses to move an element into itself", async () => {
138
+ const code = `export function A() {
139
+ return (
140
+ <div>
141
+ <p>
142
+ Hello <strong>world</strong>
143
+ </p>
144
+ </div>
145
+ );
146
+ }
147
+ `;
148
+ await fs.mkdir(path.join(tempDir, "app", "components"), { recursive: true });
149
+ await fs.writeFile(path.join(tempDir, FILE), code);
150
+ const outer = code.slice(code.indexOf("<p>"), code.indexOf("</p>") + 4);
151
+ api.setRegistry({
152
+ version: 1,
153
+ generatedAt: "",
154
+ elements: {
155
+ outer: {
156
+ file: FILE,
157
+ type: "element",
158
+ startOffset: code.indexOf("<p>"),
159
+ endOffset: code.indexOf("<p>") + outer.length,
160
+ originalContent: outer,
161
+ context: { parentTag: "div" },
162
+ },
163
+ inner: {
164
+ file: FILE,
165
+ type: "element",
166
+ startOffset: code.indexOf("<strong>"),
167
+ endOffset: code.indexOf("</strong>") + 9,
168
+ originalContent: "<strong>world</strong>",
169
+ context: { parentTag: "p" },
170
+ },
171
+ },
172
+ });
173
+ expect((await move(["outer", "inner", "after"])).success).toBe(false);
174
+ expect(await read()).toBe(code);
175
+ });
176
+ });
177
+
178
+ describe("moveRange", () => {
179
+ const range = (code: string, part: string) => {
180
+ const start = code.indexOf(part);
181
+ return { start, end: start + part.length };
182
+ };
183
+
184
+ test("moves inline elements as-is", () => {
185
+ const code = "<p><a>1</a> <b>2</b></p>";
186
+ const edit = moveRange(code, range(code, "<b>2</b>"), range(code, "<a>1</a>"), "before");
187
+ expect(edit.code).toBe("<p><b>2</b><a>1</a> </p>");
188
+ expect(edit.code.slice(edit.moved.start, edit.moved.end)).toBe("<b>2</b>");
189
+ });
190
+
191
+ test("moves an element after the last line of the file", () => {
192
+ const code = " <a />\n <b />";
193
+ const edit = moveRange(code, range(code, "<a />"), range(code, "<b />"), "after");
194
+ expect(edit.code).toBe(" <b />\n <a />");
195
+ expect(edit.code.slice(edit.moved.start, edit.moved.end)).toBe("<a />");
196
+ });
197
+
198
+ test("moves a multi-line element and keeps its inner indentation", () => {
199
+ const code = " <ul>\n <li>x</li>\n </ul>\n <p>y</p>\n";
200
+ const edit = moveRange(
201
+ code,
202
+ range(code, "<ul>\n <li>x</li>\n </ul>"),
203
+ range(code, "<p>y</p>"),
204
+ "after",
205
+ );
206
+ expect(edit.code).toBe(" <p>y</p>\n <ul>\n <li>x</li>\n </ul>\n");
207
+ expect(edit.code.slice(edit.moved.start, edit.moved.end)).toBe("<ul>\n <li>x</li>\n </ul>");
208
+ });
209
+
210
+ test("mapOffset follows the elements around the move", () => {
211
+ const code = "<a />\n<b />\n<c />\n<d />\n";
212
+ const edit = moveRange(code, range(code, "<a />"), range(code, "<c />"), "after");
213
+ expect(edit.code).toBe("<b />\n<c />\n<a />\n<d />\n");
214
+ for (const tag of ["<b />", "<c />", "<d />"]) {
215
+ const r = range(code, tag);
216
+ const start = mapOffset(edit, r.start, false);
217
+ expect(edit.code.slice(start, mapOffset(edit, r.end, true))).toBe(tag);
218
+ }
219
+ });
220
+ });
@@ -1581,10 +1581,10 @@ describe("upstart-editor-vite-plugin", () => {
1581
1581
  const registry = getRegistry();
1582
1582
  const entries = Object.values(registry);
1583
1583
 
1584
- // 2 text (spans) + 2 className (spans); the outer div is NOT almost-leaf because
1585
- // its <span> children have attributes (className), which disqualifies them as
1586
- // simple inline formatting wrappers.
1587
- expect(entries.length).toBe(4);
1584
+ // 2 text (spans) + 2 className (spans) + 2 movable elements (spans); the outer div is
1585
+ // NOT almost-leaf because its <span> children have attributes (className), which
1586
+ // disqualifies them as simple inline formatting wrappers.
1587
+ expect(entries.length).toBe(6);
1588
1588
 
1589
1589
  const textEntries = entries.filter((e) => e.type === "text");
1590
1590
  const classEntries = entries.filter((e) => e.type === "className");
@@ -1596,6 +1596,167 @@ describe("upstart-editor-vite-plugin", () => {
1596
1596
  });
1597
1597
  });
1598
1598
 
1599
+ describe("Movable elements (drag & drop)", () => {
1600
+ const moveIdOf = (out: string, text: string) =>
1601
+ out.match(new RegExp(`data-upstart-move-id="([^"]+)"[^>]*>${text}<`))?.[1];
1602
+
1603
+ test("marks text and image elements that are direct JSX children", () => {
1604
+ const code = `export function Hero() {
1605
+ return (
1606
+ <section>
1607
+ <h1>Title</h1>
1608
+ <p>Body</p>
1609
+ <img src="/a.webp" alt="" />
1610
+ </section>
1611
+ );
1612
+ }`;
1613
+ const out = transform(code)!;
1614
+ expect(out.match(/data-upstart-move-id=/g)?.length).toBe(3);
1615
+ // The <section> root is not a text/image element and has no JSX parent.
1616
+ expect(out).not.toMatch(/<section[^>]*data-upstart-move-id/);
1617
+ const id = moveIdOf(out, "Title")!;
1618
+ const entry = getRegistry()[id];
1619
+ expect(entry.type).toBe("element");
1620
+ expect(entry.originalContent).toBe("<h1>Title</h1>");
1621
+ expect(code.slice(entry.startOffset, entry.endOffset)).toBe("<h1>Title</h1>");
1622
+ });
1623
+
1624
+ test("does not mark elements in loops, conditionals or components", () => {
1625
+ const code = `export function List({ show }) {
1626
+ return (
1627
+ <div>
1628
+ {["a", "b"].map((x, i) => <span key={i}>{x}</span>)}
1629
+ {show && <p>Maybe</p>}
1630
+ {show ? <h2>Yes</h2> : <h2>No</h2>}
1631
+ <Title>Component</Title>
1632
+ </div>
1633
+ );
1634
+ }`;
1635
+ const out = transform(code)!;
1636
+ expect(out).not.toContain("data-upstart-move-id");
1637
+ });
1638
+
1639
+ test("marks whole blocks, not only texts and images", () => {
1640
+ const code = `export function Page() {
1641
+ return (
1642
+ <main>
1643
+ <section className="a">
1644
+ <div className="card">
1645
+ <h2>Title</h2>
1646
+ <p>Text</p>
1647
+ </div>
1648
+ <a href="/more">More</a>
1649
+ </section>
1650
+ <ul>
1651
+ <li>One</li>
1652
+ <li>Two</li>
1653
+ </ul>
1654
+ </main>
1655
+ );
1656
+ }`;
1657
+ const out = transform(code)!;
1658
+ for (const tag of ["section", "div", "h2", "ul", "li"]) {
1659
+ expect(out).toMatch(new RegExp(`<${tag}[^>]*data-upstart-move-id=`));
1660
+ }
1661
+ expect(out).not.toMatch(/<main[^>]*data-upstart-move-id/);
1662
+ const sectionId = out.match(/<section[^>]*data-upstart-move-id="([^"]+)"/)![1];
1663
+ expect(getRegistry()[sectionId].originalContent).toMatch(/^<section className="a">[\s\S]*<\/section>$/);
1664
+ });
1665
+
1666
+ test("does not mark the only content of an HTML element: its parent moves instead", () => {
1667
+ const code = `export function Page() {
1668
+ return (
1669
+ <main>
1670
+ <section className="outer">
1671
+ <div className="wrapper">
1672
+ <div className="container">
1673
+ <h2>Title</h2>
1674
+ <p>Body</p>
1675
+ </div>
1676
+ </div>
1677
+ </section>
1678
+ <figure>
1679
+ {/* comment */}
1680
+ <img src="/a.webp" alt="" />
1681
+ </figure>
1682
+ <p>
1683
+ Hello <strong>you</strong>
1684
+ </p>
1685
+ <p>{name}<em>!</em></p>
1686
+ <Card>
1687
+ <h3>Inside a component</h3>
1688
+ </Card>
1689
+ </main>
1690
+ );
1691
+ }`;
1692
+ const out = transform(code)!;
1693
+ const movable = (tag: string) => new RegExp(`<${tag}[ >][^>]*data-upstart-move-id=`);
1694
+ // section > wrapper > container: only the section carries a handle for the chain.
1695
+ expect(out).toMatch(movable("section"));
1696
+ expect(out).not.toMatch(/className="wrapper"[^>]*data-upstart-move-id/);
1697
+ expect(out).not.toMatch(/className="container"[^>]*data-upstart-move-id/);
1698
+ // Siblings are not "only content".
1699
+ expect(out).toMatch(movable("h2"));
1700
+ expect(out).toMatch(movable("p"));
1701
+ // A comment does not count as content: the <img> is the figure's only content.
1702
+ expect(out).toMatch(movable("figure"));
1703
+ expect(out).not.toMatch(movable("img"));
1704
+ // Text or an expression next to the element keeps it movable.
1705
+ expect(out).toMatch(movable("strong"));
1706
+ expect(out).toMatch(movable("em"));
1707
+ // The child of a component stays movable.
1708
+ expect(out).toMatch(movable("h3"));
1709
+ });
1710
+
1711
+ test("does not mark document structure, SVG internals, Trans slots or <br>", () => {
1712
+ const code = `export function Layout() {
1713
+ return (
1714
+ <html>
1715
+ <head>
1716
+ <meta charSet="utf-8" />
1717
+ <title>Site</title>
1718
+ </head>
1719
+ <body>
1720
+ <p>
1721
+ Hello<br />world
1722
+ </p>
1723
+ <svg viewBox="0 0 10 10">
1724
+ <path d="M0 0" />
1725
+ </svg>
1726
+ <span>
1727
+ <Trans i18nKey="x" components={{ b: <b /> }}>
1728
+ <b>bold</b>
1729
+ </Trans>
1730
+ </span>
1731
+ </body>
1732
+ </html>
1733
+ );
1734
+ }`;
1735
+ const out = transform(code)!;
1736
+ for (const tag of ["html", "head", "meta", "title", "body", "br", "path", "b"]) {
1737
+ expect(out).not.toMatch(new RegExp(`<${tag}[ /][^>]*data-upstart-move-id=`));
1738
+ }
1739
+ // The <p>, the <svg> itself and the <span> stay movable.
1740
+ for (const tag of ["p", "svg", "span"]) {
1741
+ expect(out).toMatch(new RegExp(`<${tag}[^>]*data-upstart-move-id=`));
1742
+ }
1743
+ });
1744
+
1745
+ test("gives each component of a file its own scope", () => {
1746
+ const code = `export function A() {
1747
+ return <div><p>One</p><p>Uno</p></div>;
1748
+ }
1749
+ export function B() {
1750
+ return <div><p>Two</p><p>Dos</p></div>;
1751
+ }`;
1752
+ const out = transform(code)!;
1753
+ const scopes = [...new Set([...out.matchAll(/data-upstart-move-scope="([^"]+)"/g)].map((m) => m[1]))];
1754
+ expect(scopes).toHaveLength(2);
1755
+ // Scope = start offset of the enclosing function (after "export ").
1756
+ expect(scopes[0]).toBe("test.tsx:7");
1757
+ });
1758
+ });
1759
+
1599
1760
  describe("Integration Tests", () => {
1600
1761
  test("should transform real-world component correctly", () => {
1601
1762
  const code = `
@@ -341,6 +341,150 @@ function printMetaTag(fieldName: string, value: string): string {
341
341
  return `<meta name="${fieldName}" ${attribute} />`;
342
342
  }
343
343
 
344
+ interface SourceRange {
345
+ start: number;
346
+ end: number;
347
+ }
348
+
349
+ /** A tracked element: its current range and the source it is expected to hold there. */
350
+ interface TrackedElement extends SourceRange {
351
+ content: string;
352
+ }
353
+
354
+ /**
355
+ * Current range of an element in `code`: `hint` when the source still matches there, otherwise
356
+ * the occurrence of its source nearest to `hint` (an earlier edit shifted it), and as a last
357
+ * resort a whitespace-insensitive match (a move re-indented it).
358
+ */
359
+ function locateElement(code: string, hint: TrackedElement): TrackedElement | null {
360
+ const { start, end, content } = hint;
361
+ if (code.slice(start, end) === content) return hint;
362
+ if (content.trim() === "") return null;
363
+ const nearest = (a: number, b: number) => Math.abs(a - start) < Math.abs(b - start);
364
+ let best = -1;
365
+ for (let i = code.indexOf(content); i !== -1; i = code.indexOf(content, i + 1)) {
366
+ if (best === -1 || nearest(i, best)) best = i;
367
+ }
368
+ if (best !== -1) return { start: best, end: best + content.length, content };
369
+
370
+ const pattern = content
371
+ .trim()
372
+ .split(/\s+/)
373
+ .map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
374
+ .join("\\s+");
375
+ let found: TrackedElement | null = null;
376
+ for (const m of code.matchAll(new RegExp(pattern, "g"))) {
377
+ if (!found || nearest(m.index, found.start)) {
378
+ found = { start: m.index, end: m.index + m[0].length, content: m[0] };
379
+ }
380
+ }
381
+ return found;
382
+ }
383
+
384
+ /**
385
+ * The full lines a range occupies (from the line start to just after its trailing newline)
386
+ * when nothing else than whitespace shares those lines, plus the indentation of the first one.
387
+ */
388
+ function ownLines(code: string, range: SourceRange): { start: number; end: number; indent: string } | null {
389
+ const lineStart = code.lastIndexOf("\n", range.start - 1) + 1;
390
+ const indent = code.slice(lineStart, range.start);
391
+ if (indent.trim() !== "") return null;
392
+ const newline = code.indexOf("\n", range.end);
393
+ const lineEnd = newline === -1 ? code.length : newline;
394
+ if (code.slice(range.end, lineEnd).trim() !== "") return null;
395
+ return { start: lineStart, end: newline === -1 ? code.length : newline + 1, indent };
396
+ }
397
+
398
+ /**
399
+ * The result of moving a range: the new code, plus what changed so the offsets of other
400
+ * elements can be mapped onto it (see `mapOffset`). Offsets are in the ORIGINAL code, except
401
+ * `moved`, which is the new range of the moved element.
402
+ */
403
+ export interface MoveEdit {
404
+ code: string;
405
+ /** Removed source range. */
406
+ removeStart: number;
407
+ removeEnd: number;
408
+ /** Where the moved text was inserted, and its length. */
409
+ insertAt: number;
410
+ insertLength: number;
411
+ moved: SourceRange;
412
+ }
413
+
414
+ /**
415
+ * Move the `src` range before or after the `tgt` range (`tgt` must not be inside `src`).
416
+ * Elements alone on their lines are moved as whole lines, re-indented to the target's level;
417
+ * inline elements are moved as-is. Inserting at the far edge of the target keeps the target's
418
+ * own range untouched even when the source sits inside it (e.g. a <span> out of its <p>).
419
+ */
420
+ export function moveRange(
421
+ code: string,
422
+ src: SourceRange,
423
+ tgt: SourceRange,
424
+ position: "before" | "after",
425
+ ): MoveEdit {
426
+ const srcLines = ownLines(code, src);
427
+ const tgtLines = ownLines(code, tgt);
428
+ let removeStart = src.start;
429
+ let removeEnd = src.end;
430
+ let insertAt = position === "before" ? tgt.start : tgt.end;
431
+ let inserted = code.slice(src.start, src.end);
432
+ let elementOffset = 0;
433
+ let elementLength = inserted.length;
434
+
435
+ if (srcLines && tgtLines) {
436
+ // Re-indent every line of the element from its own indentation to the target's.
437
+ const body = inserted
438
+ .split("\n")
439
+ .map((line, i) => {
440
+ if (i === 0) return line;
441
+ return line.startsWith(srcLines.indent) ? tgtLines.indent + line.slice(srcLines.indent.length) : line;
442
+ })
443
+ .join("\n");
444
+ removeStart = srcLines.start;
445
+ removeEnd = srcLines.end;
446
+ elementOffset = tgtLines.indent.length;
447
+ elementLength = body.length;
448
+ if (position === "before") {
449
+ insertAt = tgtLines.start;
450
+ inserted = `${tgtLines.indent}${body}\n`;
451
+ } else {
452
+ insertAt = tgtLines.end;
453
+ const atEofWithoutNewline = tgtLines.end === code.length && !code.endsWith("\n");
454
+ inserted = atEofWithoutNewline ? `\n${tgtLines.indent}${body}` : `${tgtLines.indent}${body}\n`;
455
+ if (atEofWithoutNewline) elementOffset += 1;
456
+ }
457
+ }
458
+
459
+ const removed = removeEnd - removeStart;
460
+ const next =
461
+ insertAt <= removeStart
462
+ ? code.slice(0, insertAt) + inserted + code.slice(insertAt, removeStart) + code.slice(removeEnd)
463
+ : code.slice(0, removeStart) + code.slice(removeEnd, insertAt) + inserted + code.slice(insertAt);
464
+ const insertedAtInNewCode = insertAt <= removeStart ? insertAt : insertAt - removed;
465
+ const movedStart = insertedAtInNewCode + elementOffset;
466
+ return {
467
+ code: next,
468
+ removeStart,
469
+ removeEnd,
470
+ insertAt,
471
+ insertLength: inserted.length,
472
+ moved: { start: movedStart, end: movedStart + elementLength },
473
+ };
474
+ }
475
+
476
+ /**
477
+ * Where an offset of the original code (outside the removed range) lands after `edit`.
478
+ * A start sitting exactly at the insertion point is pushed after the inserted text, an end
479
+ * sitting there stays before it.
480
+ */
481
+ export function mapOffset(edit: MoveEdit, offset: number, isEnd: boolean): number {
482
+ let mapped = offset;
483
+ if (offset >= edit.removeEnd) mapped -= edit.removeEnd - edit.removeStart;
484
+ if (isEnd ? offset > edit.insertAt : offset >= edit.insertAt) mapped += edit.insertLength;
485
+ return mapped;
486
+ }
487
+
344
488
  /** Start of the line `offset` sits on, so a removed tag doesn't leave a blank line behind. */
345
489
  function lineRangeStart(code: string, offset: number): number {
346
490
  const lineStart = code.lastIndexOf("\n", offset);
@@ -422,6 +566,27 @@ export const payloadArraySet = z.object({
422
566
 
423
567
  export type PayloadArraySet = z.infer<typeof payloadArraySet>;
424
568
 
569
+ // Move JSX elements (registry entries of type "element") before or after other elements of the
570
+ // same file — the editor's drag & drop. Moves are staged in the preview and applied together,
571
+ // in order, when the user confirms. Ids are "<relativeFile>:<elementStart>".
572
+ export const payloadMoveElements = z.object({
573
+ action: z.literal("moveElements"),
574
+ moves: z
575
+ .array(
576
+ z.object({
577
+ id: z.string().min(1),
578
+ targetId: z.string().min(1),
579
+ position: z.enum(["before", "after"]),
580
+ }),
581
+ )
582
+ .min(1)
583
+ .max(200),
584
+ });
585
+
586
+ export type PayloadMoveElements = z.infer<typeof payloadMoveElements>;
587
+
588
+ export type MoveElementsResult = { success: true; filePaths: string[] } | { success: false; error: string };
589
+
425
590
  // React-router route id, as reported by the client runtime (e.g. "routes/_layout._index",
426
591
  // "root"). Resolved to `<projectRoot>/app/<routeId>.<ext>` — see `resolveRouteFile`.
427
592
  const routeIdSchema = z
@@ -938,6 +1103,104 @@ export class UpstartEditorAPI {
938
1103
  }
939
1104
  }
940
1105
 
1106
+ /**
1107
+ * Apply a batch of element moves (editor drag & drop), in order. Every element involved is
1108
+ * tracked across the moves, so a later move finds elements an earlier one shifted. Files are
1109
+ * only written when every move succeeded and every result still parses.
1110
+ */
1111
+ async moveElements(params: PayloadMoveElements): Promise<MoveElementsResult> {
1112
+ const parsed = payloadMoveElements.safeParse(params);
1113
+ if (!parsed.success) {
1114
+ return { success: false, error: `Invalid payload: ${parsed.error.message}` };
1115
+ }
1116
+ if (!this.registry) {
1117
+ try {
1118
+ await this.loadRegistry();
1119
+ } catch (err) {
1120
+ return { success: false, error: `Failed to load registry: ${err}` };
1121
+ }
1122
+ }
1123
+ const elements = this.registry!.elements;
1124
+
1125
+ const codes = new Map<string, string>();
1126
+ const tracked = new Map<string, TrackedElement>();
1127
+ try {
1128
+ for (const { id, targetId, position } of parsed.data.moves) {
1129
+ if (id === targetId) return { success: false, error: "Cannot move an element onto itself" };
1130
+ const source = elements[id];
1131
+ const target = elements[targetId];
1132
+ if (source?.type !== "element")
1133
+ return { success: false, error: `Element ${id} not found in registry` };
1134
+ if (target?.type !== "element") {
1135
+ return { success: false, error: `Element ${targetId} not found in registry` };
1136
+ }
1137
+ if (source.file !== target.file) {
1138
+ return { success: false, error: "Elements can only be moved within the same file" };
1139
+ }
1140
+
1141
+ const file = source.file;
1142
+ let code = codes.get(file);
1143
+ if (code === undefined) {
1144
+ code = await fs.readFile(path.join(this.projectRoot, file), "utf-8");
1145
+ codes.set(file, code);
1146
+ // Track every movable element of the file: any of them may be shifted by a move.
1147
+ for (const [elementId, entry] of Object.entries(elements)) {
1148
+ if (entry.type !== "element" || entry.file !== file) continue;
1149
+ tracked.set(elementId, {
1150
+ start: entry.startOffset,
1151
+ end: entry.endOffset,
1152
+ content: entry.originalContent,
1153
+ });
1154
+ }
1155
+ }
1156
+
1157
+ const src = locateElement(code, tracked.get(id)!);
1158
+ const tgt = locateElement(code, tracked.get(targetId)!);
1159
+ if (!src || !tgt) {
1160
+ return { success: false, error: `Element not found in ${file}. The file may have been modified.` };
1161
+ }
1162
+ if (tgt.start >= src.start && tgt.end <= src.end) {
1163
+ return { success: false, error: "Cannot move an element inside itself" };
1164
+ }
1165
+
1166
+ const edit = moveRange(code, src, tgt, position);
1167
+ codes.set(file, edit.code);
1168
+ for (const [elementId, el] of tracked) {
1169
+ if (elementId === id) continue;
1170
+ if (elements[elementId].file !== file) continue;
1171
+ if (el.start >= src.start && el.end <= src.end) {
1172
+ // Inside the moved element: shifted along with it (re-indentation is caught up
1173
+ // by the whitespace-insensitive lookup of locateElement).
1174
+ const delta = edit.moved.start - src.start;
1175
+ tracked.set(elementId, { ...el, start: el.start + delta, end: el.end + delta });
1176
+ } else {
1177
+ tracked.set(elementId, {
1178
+ ...el,
1179
+ start: mapOffset(edit, el.start, false),
1180
+ end: mapOffset(edit, el.end, true),
1181
+ });
1182
+ }
1183
+ }
1184
+ tracked.set(id, { ...edit.moved, content: edit.code.slice(edit.moved.start, edit.moved.end) });
1185
+ }
1186
+
1187
+ const changed: [filePath: string, code: string][] = [];
1188
+ for (const [file, code] of codes) {
1189
+ const filePath = path.join(this.projectRoot, file);
1190
+ if (code === (await fs.readFile(filePath, "utf-8"))) continue;
1191
+ if (parseSync(file, code, { sourceType: "module" }).errors.length > 0) {
1192
+ return { success: false, error: `Moving the elements would produce invalid code in ${file}` };
1193
+ }
1194
+ changed.push([filePath, code]);
1195
+ }
1196
+ for (const [filePath, code] of changed) await fs.writeFile(filePath, code);
1197
+ if (changed.length > 0) this.registry = null;
1198
+ return { success: true, filePaths: changed.map(([filePath]) => filePath) };
1199
+ } catch (err) {
1200
+ return { success: false, error: String(err) };
1201
+ }
1202
+ }
1203
+
941
1204
  /**
942
1205
  * Read the page metadata shown in the browser tab and in search results.
943
1206
  *