@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
@@ -70,7 +70,8 @@ export interface EditableSegment {
70
70
  // Registry entry for editable elements (text, rich-text, className, mixed-text)
71
71
  export interface EditableEntry {
72
72
  file: string;
73
- type: "text" | "rich-text" | "className" | "mixed-text" | "image";
73
+ // "element": the full source range of a JSX element the editor can move (drag & drop).
74
+ type: "text" | "rich-text" | "className" | "mixed-text" | "image" | "element";
74
75
  startOffset: number;
75
76
  endOffset: number;
76
77
  originalContent: string;
@@ -120,6 +121,8 @@ interface TransformState {
120
121
  * `function Hero({ heroImageSrc = "/images/hero.webp" })`.
121
122
  */
122
123
  paramDefaults: Map<string, StringLiteralNode>[];
124
+ /** Start offsets of the enclosing functions, innermost last — used as the drag & drop scope. */
125
+ functionStack: number[];
123
126
  }
124
127
 
125
128
  type StringLiteralNode = NodeWithRange & { type: "Literal"; value: string };
@@ -290,13 +293,14 @@ export function transformWithOxc(code: string, filePath: string) {
290
293
  tFunctions: new Map(),
291
294
  constantNodes: new Map(),
292
295
  paramDefaults: [],
296
+ functionStack: [],
293
297
  };
294
298
 
295
299
  let modified = false;
296
300
 
297
301
  // Use zimmerframe to walk and transform the AST
298
302
  walk(ast.program as Program, state, {
299
- _(node: Node, { state, next }: { state: TransformState; next: () => void }) {
303
+ _(node: Node, { state, next, path }: { state: TransformState; next: () => void; path: Node[] }) {
300
304
  // Track useTranslation() calls to resolve t() function namespaces
301
305
  if (node.type === "VariableDeclaration") {
302
306
  trackUseTranslation(node, state);
@@ -326,7 +330,9 @@ export function transformWithOxc(code: string, filePath: string) {
326
330
  node.type === "ArrowFunctionExpression"
327
331
  ) {
328
332
  state.paramDefaults.push(collectParamStringDefaults((node as any).params ?? []));
333
+ state.functionStack.push(hasRange(node) ? node.start : -1);
329
334
  next();
335
+ state.functionStack.pop();
330
336
  state.paramDefaults.pop();
331
337
  return;
332
338
  }
@@ -715,6 +721,44 @@ export function transformWithOxc(code: string, filePath: string) {
715
721
  }
716
722
  }
717
723
 
724
+ // Movable element (editor drag & drop): any HTML element that is a direct child of
725
+ // another JSX element, so moving its source range keeps valid JSX. Elements rendered in
726
+ // a loop or conditionally are never movable, nor components (their DOM may not carry
727
+ // the attributes) and what lives in <head>, an <svg> or a <Trans>. The only content of an
728
+ // HTML element is not movable either: its parent is what gets moved (one handle per
729
+ // visual block, and no empty wrapper left behind).
730
+ const scopeStart = state.functionStack[state.functionStack.length - 1];
731
+ const parent = path[path.length - 1];
732
+ if (
733
+ tagName &&
734
+ /^[a-z]/.test(tagName) &&
735
+ !NON_MOVABLE_TAGS.has(tagName) &&
736
+ !isInsideNonMovableContainer(path) &&
737
+ !isOnlyContentOfHtmlElement(jsxNode, parent) &&
738
+ state.loopStack.length === 0 &&
739
+ scopeStart !== undefined &&
740
+ scopeStart >= 0 &&
741
+ (parent?.type === "JSXElement" || parent?.type === "JSXFragment") &&
742
+ hasRange(jsxNode)
743
+ ) {
744
+ const id = generateId(state.filePath, jsxNode);
745
+ editableRegistry.set(id, {
746
+ file: state.filePath,
747
+ type: "element",
748
+ startOffset: jsxNode.start,
749
+ endOffset: jsxNode.end,
750
+ originalContent: state.code.slice(jsxNode.start, jsxNode.end),
751
+ context: {
752
+ parentTag:
753
+ parent.type === "JSXElement"
754
+ ? (getJSXElementName(parent.openingElement) ?? "unknown")
755
+ : "fragment",
756
+ },
757
+ });
758
+ attributes.push(`data-upstart-move-id="${escapeProp(id)}"`);
759
+ attributes.push(`data-upstart-move-scope="${escapeProp(`${state.filePath}:${scopeStart}`)}"`);
760
+ }
761
+
718
762
  // Inject attributes if any
719
763
  if (insertPos !== -1 && attributes.length > 0) {
720
764
  const attrString = " " + attributes.join(" ");
@@ -737,6 +781,53 @@ export function transformWithOxc(code: string, filePath: string) {
737
781
  };
738
782
  }
739
783
 
784
+ // Elements the editor never offers to move (document structure, invisible or meaningless alone).
785
+ const NON_MOVABLE_TAGS = new Set([
786
+ "html",
787
+ "head",
788
+ "body",
789
+ "meta",
790
+ "link",
791
+ "script",
792
+ "style",
793
+ "title",
794
+ "base",
795
+ "br",
796
+ "wbr",
797
+ "source",
798
+ "track",
799
+ "option",
800
+ "optgroup",
801
+ ]);
802
+
803
+ // Ancestors whose children are not movable: <head> content, SVG internals, <Trans> slots.
804
+ const NON_MOVABLE_CONTAINERS = new Set(["head", "svg", "Trans"]);
805
+
806
+ function isInsideNonMovableContainer(path: Node[]): boolean {
807
+ return path.some((node) => {
808
+ if (node.type !== "JSXElement") return false;
809
+ const name = getJSXElementName((node as JSXElement).openingElement);
810
+ return name !== null && NON_MOVABLE_CONTAINERS.has(name);
811
+ });
812
+ }
813
+
814
+ /**
815
+ * True when `node` is the only meaningful child of an HTML element: no sibling element,
816
+ * non-blank text or expression. Children of components are not concerned (a component is
817
+ * never movable itself, so its child stays the way to move that content).
818
+ */
819
+ function isOnlyContentOfHtmlElement(node: JSXElement, parent: Node | undefined): boolean {
820
+ if (parent?.type !== "JSXElement") return false;
821
+ const parentName = getJSXElementName((parent as JSXElement).openingElement);
822
+ if (!parentName || !/^[a-z]/.test(parentName)) return false;
823
+ return (parent as JSXElement).children.every((child) => {
824
+ if (child === node) return true;
825
+ if (child.type === "JSXText") return (child as { value: string }).value.trim() === "";
826
+ if (child.type === "JSXExpressionContainer") return child.expression.type === "JSXEmptyExpression";
827
+ return false;
828
+ });
829
+ }
830
+
740
831
  // Helper: Get JSX element name
741
832
  function getJSXElementName(opening: JSXOpeningElement): string | null {
742
833
  if (opening.name.type === "JSXIdentifier") {
@@ -1,6 +1,7 @@
1
1
  import { getCurrentMode } from "./state.js";
2
2
  import { sendToParent } from "./utils.js";
3
3
  import { getLabels } from "./labels.js";
4
+ import { baseButton, createControlsLayer, cssAttr, setIcon } from "./controls-ui.js";
4
5
 
5
6
  // Editor-only controls for inline-array .map() lists. Add/delete are staged LOCALLY
6
7
  // (no server round-trip per action): "×" marks an item for removal, "+" inserts an
@@ -191,12 +192,7 @@ export function hideArrayControls(): void {
191
192
 
192
193
  function ensureLayer(): HTMLDivElement {
193
194
  if (layer) return layer;
194
- layer = document.createElement("div");
195
- layer.id = "upstart-array-controls";
196
- layer.style.cssText =
197
- "position: absolute; top: 0; left: 0; width: 0; height: 0; " +
198
- "pointer-events: none; z-index: 2147483646;";
199
- document.body.appendChild(layer);
195
+ layer = createControlsLayer("upstart-array-controls");
200
196
  return layer;
201
197
  }
202
198
 
@@ -237,10 +233,6 @@ function isReordered(arrayId: string): boolean {
237
233
  return false;
238
234
  }
239
235
 
240
- function cssAttr(value: string): string {
241
- return value.replace(/["\\]/g, "\\$&");
242
- }
243
-
244
236
  function textOf(el: HTMLElement): string {
245
237
  return (el.textContent ?? "").trim();
246
238
  }
@@ -261,36 +253,6 @@ function applyDeletionStyle(item: HTMLElement, marked: boolean): void {
261
253
  // Buttons
262
254
  // ---------------------------------------------------------------------------
263
255
 
264
- // Geometric SVG icons — centred by the flex container + viewBox, so they don't
265
- // depend on the host page's font metrics (which left text glyphs mis-aligned).
266
- const ICON_PATHS: Record<string, string> = {
267
- plus: '<path d="M12 5v14M5 12h14"/>',
268
- cross: '<path d="M6 6 18 18M18 6 6 18"/>',
269
- check: '<path d="M5 12.5 10 17.5 19 7"/>',
270
- restore: '<path d="M3 4v5h5"/><path d="M3.5 9a8.5 8.5 0 1 0 2.2-3.8L3 9"/>',
271
- grip: '<circle cx="9" cy="6" r="1.5"/><circle cx="15" cy="6" r="1.5"/><circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/><circle cx="9" cy="18" r="1.5"/><circle cx="15" cy="18" r="1.5"/>',
272
- };
273
-
274
- function setIcon(btn: HTMLElement, name: keyof typeof ICON_PATHS, px: number): void {
275
- const dots = name === "grip";
276
- btn.innerHTML =
277
- `<svg width="${px}" height="${px}" viewBox="0 0 24 24" ` +
278
- `fill="${dots ? "currentColor" : "none"}" stroke="${dots ? "none" : "currentColor"}" ` +
279
- 'stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" ' +
280
- `style="display:block;pointer-events:none">${ICON_PATHS[name]}</svg>`;
281
- }
282
-
283
- function baseButton(top: number, left: number, size: number, bg: string): HTMLButtonElement {
284
- const btn = document.createElement("button");
285
- btn.type = "button";
286
- btn.style.cssText =
287
- `position: absolute; top: ${top}px; left: ${left}px; width: ${size}px; height: ${size}px; ` +
288
- "padding: 0; display: flex; align-items: center; justify-content: center; " +
289
- `background: ${bg}; color: #fff; border: 1px solid #fff; border-radius: 50%; ` +
290
- "line-height: 1; cursor: pointer; pointer-events: auto; box-shadow: 0 1px 3px rgba(0,0,0,0.3);";
291
- return btn;
292
- }
293
-
294
256
  function makeItemDeleteButton(
295
257
  arrayId: string,
296
258
  index: number,
@@ -206,6 +206,15 @@ function handleSelectClick(event: MouseEvent, target: HTMLElement): void {
206
206
 
207
207
  function handleClick(event: MouseEvent): void {
208
208
  const mode = getCurrentMode();
209
+ if (mode === "move") {
210
+ // The page is inert while moving elements: following a link or submitting a form would
211
+ // reload the page and drop the staged moves. The move controls handle their own clicks.
212
+ const target = event.target as HTMLElement | null;
213
+ if (target?.closest("#upstart-move-controls, #upstart-move-bar")) return;
214
+ event.preventDefault();
215
+ event.stopPropagation();
216
+ return;
217
+ }
209
218
  if (mode !== "edit" && mode !== "select") {
210
219
  return;
211
220
  }
@@ -225,9 +234,14 @@ function handleClick(event: MouseEvent): void {
225
234
  return;
226
235
  }
227
236
 
228
- // Editor-only chrome (the array ×/+/✓ controls) and locally-staged draft list
229
- // items handle their own clicks and must never be treated as element selection.
230
- if (target.closest("#upstart-array-controls, [data-upstart-draft-item]")) {
237
+ // Editor-only chrome (the array ×/+/✓ controls, the move handles and pending-moves bar)
238
+ // and locally-staged draft list items handle their own clicks and must never be treated
239
+ // as element selection.
240
+ if (
241
+ target.closest(
242
+ "#upstart-array-controls, #upstart-move-controls, #upstart-move-bar, [data-upstart-draft-item]",
243
+ )
244
+ ) {
231
245
  return;
232
246
  }
233
247
 
@@ -0,0 +1,48 @@
1
+ // Shared building blocks of the editor-only controls (array list buttons, move handles).
2
+
3
+ // Geometric SVG icons — centred by the flex container + viewBox, so they don't
4
+ // depend on the host page's font metrics (which left text glyphs mis-aligned).
5
+ const ICON_PATHS = {
6
+ plus: '<path d="M12 5v14M5 12h14"/>',
7
+ cross: '<path d="M6 6 18 18M18 6 6 18"/>',
8
+ check: '<path d="M5 12.5 10 17.5 19 7"/>',
9
+ restore: '<path d="M3 4v5h5"/><path d="M3.5 9a8.5 8.5 0 1 0 2.2-3.8L3 9"/>',
10
+ grip: '<circle cx="9" cy="6" r="1.5"/><circle cx="15" cy="6" r="1.5"/><circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/><circle cx="9" cy="18" r="1.5"/><circle cx="15" cy="18" r="1.5"/>',
11
+ } as const;
12
+
13
+ export type IconName = keyof typeof ICON_PATHS;
14
+
15
+ export function setIcon(btn: HTMLElement, name: IconName, px: number): void {
16
+ const dots = name === "grip";
17
+ btn.innerHTML =
18
+ `<svg width="${px}" height="${px}" viewBox="0 0 24 24" ` +
19
+ `fill="${dots ? "currentColor" : "none"}" stroke="${dots ? "none" : "currentColor"}" ` +
20
+ 'stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" ' +
21
+ `style="display:block;pointer-events:none">${ICON_PATHS[name]}</svg>`;
22
+ }
23
+
24
+ export function baseButton(top: number, left: number, size: number, bg: string): HTMLButtonElement {
25
+ const btn = document.createElement("button");
26
+ btn.type = "button";
27
+ btn.style.cssText =
28
+ `position: absolute; top: ${top}px; left: ${left}px; width: ${size}px; height: ${size}px; ` +
29
+ "padding: 0; display: flex; align-items: center; justify-content: center; " +
30
+ `background: ${bg}; color: #fff; border: 1px solid #fff; border-radius: 50%; ` +
31
+ "line-height: 1; cursor: pointer; pointer-events: auto; box-shadow: 0 1px 3px rgba(0,0,0,0.3);";
32
+ return btn;
33
+ }
34
+
35
+ /** Absolutely positioned, click-through layer appended to <body> that hosts editor controls. */
36
+ export function createControlsLayer(id: string): HTMLDivElement {
37
+ const layer = document.createElement("div");
38
+ layer.id = id;
39
+ layer.style.cssText =
40
+ "position: absolute; top: 0; left: 0; width: 0; height: 0; " +
41
+ "pointer-events: none; z-index: 2147483646;";
42
+ document.body.appendChild(layer);
43
+ return layer;
44
+ }
45
+
46
+ export function cssAttr(value: string): string {
47
+ return value.replace(/["\\]/g, "\\$&");
48
+ }
@@ -4,6 +4,12 @@ import { initErrorHandler } from "./error-handler.js";
4
4
  import { installDomGuard } from "./dom-guard.js";
5
5
  import { initTextEditor, activateAllEditors, destroyAllActiveEditors } from "./text-editor.js";
6
6
  import { initArrayControls, refreshArrayControls, hideArrayControls } from "./array-controls.js";
7
+ import {
8
+ initMoveControls,
9
+ refreshMoveControls,
10
+ hideMoveControls,
11
+ onMovesApplyFailed,
12
+ } from "./move-controls.js";
7
13
  import { initFormGuard } from "./form-guard.js";
8
14
  import { sendToParent } from "./utils.js";
9
15
  import { getCurrentMode, setCurrentMode, clearSelection, removeSelection, getSelection } from "./state.js";
@@ -22,13 +28,20 @@ export function setMode(mode: EditorMode): void {
22
28
  console.log(`[Upstart Editor] Setting mode to: ${mode}`);
23
29
  if (mode === "edit") {
24
30
  disableSelectMode();
31
+ disableMoveMode();
25
32
  enableEditMode();
26
33
  } else if (mode === "select") {
27
34
  disableEditMode();
35
+ disableMoveMode();
28
36
  enableSelectMode();
37
+ } else if (mode === "move") {
38
+ disableEditMode();
39
+ disableSelectMode();
40
+ enableMoveMode();
29
41
  } else {
30
42
  disableEditMode();
31
43
  disableSelectMode();
44
+ disableMoveMode();
32
45
  }
33
46
  }
34
47
 
@@ -156,6 +169,7 @@ export function initUpstartEditor(): void {
156
169
  initClickHandler();
157
170
  initHoverOverlay();
158
171
  initArrayControls();
172
+ initMoveControls();
159
173
  initFormGuard();
160
174
  initErrorHandler();
161
175
  initRouteReporter();
@@ -266,6 +280,8 @@ function handleParentMessage(event: MessageEvent): void {
266
280
  sendToParent({ type: "selection-changed", selection: getSelection() });
267
281
  } else if (message.type === "set-labels") {
268
282
  setLabels(message.labels);
283
+ } else if (message.type === "moves-apply-failed") {
284
+ onMovesApplyFailed();
269
285
  }
270
286
  }
271
287
 
@@ -303,6 +319,21 @@ function enableSelectMode(): void {
303
319
  document.documentElement.setAttribute("data-upstart-select-mode", "");
304
320
  }
305
321
 
322
+ function enableMoveMode(): void {
323
+ console.log("[Upstart Editor] Move mode enabled");
324
+ document.documentElement.setAttribute("data-upstart-move-mode", "");
325
+ refreshMoveControls();
326
+ }
327
+
328
+ /**
329
+ * Staged moves are kept when leaving the mode (the moved elements stay where they were
330
+ * dropped); the pending-moves bar comes back with the mode.
331
+ */
332
+ function disableMoveMode(): void {
333
+ document.documentElement.removeAttribute("data-upstart-move-mode");
334
+ hideMoveControls();
335
+ }
336
+
306
337
  function disableSelectMode(): void {
307
338
  document.documentElement.removeAttribute("data-upstart-select-mode");
308
339
  hideOverlays();
@@ -11,6 +11,11 @@ export interface RuntimeLabels {
11
11
  addItem: string;
12
12
  applyChanges: string;
13
13
  dragToReorder: string;
14
+ dragToMove: string;
15
+ pendingMoves: string;
16
+ undoMove: string;
17
+ discardMoves: string;
18
+ applyMoves: string;
14
19
  clickToEdit: string;
15
20
  startTyping: string;
16
21
  recoveryTitle: string;
@@ -34,6 +39,11 @@ export const DEFAULT_LABELS: Readonly<RuntimeLabels> = Object.freeze({
34
39
  addItem: "Add item",
35
40
  applyChanges: "Apply changes",
36
41
  dragToReorder: "Drag to reorder",
42
+ dragToMove: "Drag to move",
43
+ pendingMoves: "Moves not saved yet:",
44
+ undoMove: "Undo",
45
+ discardMoves: "Discard all",
46
+ applyMoves: "Apply",
37
47
  clickToEdit: "Click to edit...",
38
48
  startTyping: "Start typing...",
39
49
  recoveryTitle: "The preview needs a refresh",