@particle-academy/fancy-cms-ui 0.0.1-dev.9 → 0.1.1

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/editor.cjs CHANGED
@@ -225,6 +225,13 @@ function useEditor(initialDoc) {
225
225
  [state, apply, undo, redo, select, replace]
226
226
  );
227
227
  }
228
+ function useLatestRef(value) {
229
+ const ref = react.useRef(value);
230
+ react.useEffect(() => {
231
+ ref.current = value;
232
+ });
233
+ return ref;
234
+ }
228
235
 
229
236
  // src/document/types.ts
230
237
  function isBinding(v) {
@@ -361,10 +368,13 @@ ${inner}
361
368
  }
362
369
  return blocks.join("\n\n") + (blocks.length ? "\n" : "");
363
370
  }
364
- function literal(v) {
365
- if (v === void 0 || v === null) return "";
366
- if (isBinding(v)) return "";
367
- return String(v);
371
+ function getPath(data, path) {
372
+ return path.split(".").reduce((o, k) => o == null ? void 0 : o[k], data);
373
+ }
374
+ function resolveValue(v, data) {
375
+ if (v === void 0 || v === null) return v;
376
+ if (isBinding(v)) return getPath(data, v.$bind);
377
+ return v;
368
378
  }
369
379
  var defaultRegistry = {
370
380
  section: ({ children }) => children,
@@ -372,18 +382,29 @@ var defaultRegistry = {
372
382
  grid: ({ children }) => children,
373
383
  frame: ({ children }) => children,
374
384
  shape: ({ children }) => children,
375
- text: ({ node }) => literal(node.props.content),
376
- heading: ({ node }) => literal(node.props.content) || "Heading",
377
- button: ({ node }) => {
378
- const label2 = literal(node.props.label) || literal(node.props.content) || "Button";
379
- const variant = literal(node.props.variant) || "primary";
385
+ card: ({ children }) => children,
386
+ text: ({ node, text }) => text(node.props.content),
387
+ heading: ({ node, text }) => text(node.props.content) || "Heading",
388
+ // Rich text — inline-formatted HTML (gradient spans, links, <code>, …) that
389
+ // plain `text` can't express. The author owns the markup (it's their content).
390
+ richtext: ({ node, text }) => /* @__PURE__ */ jsxRuntime.jsx("div", { dangerouslySetInnerHTML: { __html: text(node.props.html) } }),
391
+ link: ({ node, text }) => /* @__PURE__ */ jsxRuntime.jsx("a", { href: text(node.props.href) || "#", style: { color: "inherit", textDecoration: "underline" }, children: text(node.props.content) || "link" }),
392
+ divider: () => /* @__PURE__ */ jsxRuntime.jsx("hr", { style: { border: "none", borderTop: "1px solid #e2e8f0", margin: 0 } }),
393
+ callout: ({ node, text }) => {
394
+ const tone = { info: "#3b82f6", success: "#10b981", warning: "#f59e0b", danger: "#ef4444" }[text(node.props.variant)] ?? "#3b82f6";
395
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { style: { display: "flex", gap: 10, padding: "12px 14px", borderRadius: 10, background: `${tone}14`, borderLeft: `3px solid ${tone}` }, children: /* @__PURE__ */ jsxRuntime.jsx("span", { style: { fontSize: 13 }, children: text(node.props.content) || "Callout" }) });
396
+ },
397
+ code: ({ node, text }) => /* @__PURE__ */ jsxRuntime.jsx("pre", { style: { margin: 0, padding: "12px 14px", borderRadius: 10, background: "#0b1220", color: "#e2e8f0", fontFamily: "ui-monospace, monospace", fontSize: 13, overflowX: "auto" }, children: /* @__PURE__ */ jsxRuntime.jsx("code", { children: text(node.props.content) }) }),
398
+ button: ({ node, text }) => {
399
+ const label2 = text(node.props.label) || text(node.props.content) || "Button";
400
+ const variant = text(node.props.variant) || "primary";
380
401
  return /* @__PURE__ */ jsxRuntime.jsx("span", { "data-cms-button": variant, style: buttonStyle(variant), children: label2 });
381
402
  },
382
- image: ({ node }) => /* @__PURE__ */ jsxRuntime.jsx(
403
+ image: ({ node, text }) => /* @__PURE__ */ jsxRuntime.jsx(
383
404
  "img",
384
405
  {
385
- src: literal(node.props.src),
386
- alt: literal(node.props.alt),
406
+ src: text(node.props.src),
407
+ alt: text(node.props.alt),
387
408
  style: { width: "100%", height: "100%", objectFit: "cover", display: "block" }
388
409
  }
389
410
  )
@@ -405,30 +426,34 @@ function buttonStyle(variant) {
405
426
  if (variant === "outline") return { ...base, background: "transparent", color: "#7c3aed", border: "1px solid #7c3aed" };
406
427
  return { ...base, background: "#7c3aed", color: "#fff", border: "1px solid #7c3aed" };
407
428
  }
408
- function RenderNode({ doc, id, registry = defaultRegistry }) {
429
+ function RenderNode({ doc, id, registry = defaultRegistry, data }) {
409
430
  const node = doc.nodes[id];
410
431
  if (!node) return null;
411
- const kids = childrenOf(doc, id).map((child) => /* @__PURE__ */ jsxRuntime.jsx(RenderNode, { doc, id: child.id, registry }, child.id));
432
+ if (node.type === "repeater") {
433
+ const arr = resolveValue(node.props.items, data);
434
+ const items = Array.isArray(arr) ? arr : [];
435
+ const template = childrenOf(doc, id)[0];
436
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { "data-cms": node.id, className: node.className, children: template ? items.map((item, index) => /* @__PURE__ */ jsxRuntime.jsx(RenderNode, { doc, id: template.id, registry, data: { ...data, item, index } }, index)) : null });
437
+ }
438
+ const kids = childrenOf(doc, id).map((child) => /* @__PURE__ */ jsxRuntime.jsx(RenderNode, { doc, id: child.id, registry, data }, child.id));
439
+ const resolve = (v) => resolveValue(v, data);
440
+ const text = (v) => {
441
+ const r = resolve(v);
442
+ return r === void 0 || r === null ? "" : String(r);
443
+ };
412
444
  const renderer = registry[node.type];
413
- const inner = renderer ? renderer({ node, doc, children: kids }) : node.island ? null : kids;
414
- return /* @__PURE__ */ jsxRuntime.jsx(
415
- "div",
416
- {
417
- "data-cms": node.id,
418
- "data-cms-island": node.island ? node.type : void 0,
419
- className: node.className,
420
- children: inner
421
- }
422
- );
445
+ const inner = renderer ? renderer({ node, doc, children: kids, data, resolve, text }) : node.island ? null : kids;
446
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { "data-cms": node.id, "data-cms-island": node.island ? node.type : void 0, className: node.className, children: inner });
423
447
  }
424
448
  function CmsPage({
425
449
  doc,
426
450
  registry = defaultRegistry,
451
+ data,
427
452
  includeStyles = true
428
453
  }) {
429
454
  return /* @__PURE__ */ jsxRuntime.jsxs(react.Fragment, { children: [
430
455
  includeStyles ? /* @__PURE__ */ jsxRuntime.jsx("style", { "data-cms-styles": "", children: emitDocCss(doc) }) : null,
431
- doc.sections.map((id) => /* @__PURE__ */ jsxRuntime.jsx(RenderNode, { doc, id, registry }, id))
456
+ doc.sections.map((id) => /* @__PURE__ */ jsxRuntime.jsx(RenderNode, { doc, id, registry, data }, id))
432
457
  ] });
433
458
  }
434
459
  function Canvas({ doc, selection, onSelect, apply }) {
@@ -740,13 +765,14 @@ function Inspector({ doc, selection, apply }) {
740
765
  function Editor({ defaultValue, onChange }) {
741
766
  const ed = useEditor(defaultValue);
742
767
  const first = react.useRef(true);
768
+ const onChangeRef = useLatestRef(onChange);
743
769
  react.useEffect(() => {
744
770
  if (first.current) {
745
771
  first.current = false;
746
772
  return;
747
773
  }
748
- onChange?.(ed.state.doc);
749
- }, [ed.state.doc, onChange]);
774
+ onChangeRef.current?.(ed.state.doc);
775
+ }, [ed.state.doc, onChangeRef]);
750
776
  const shell = {
751
777
  display: "grid",
752
778
  gridTemplateColumns: "220px 1fr 300px",
@@ -802,7 +828,7 @@ function Toolbar({ ed }) {
802
828
  }
803
829
  );
804
830
  }
805
- var CONTAINER_TYPES = /* @__PURE__ */ new Set(["section", "frame", "stack", "grid", "shape"]);
831
+ var CONTAINER_TYPES = /* @__PURE__ */ new Set(["section", "frame", "stack", "grid", "shape", "card", "device"]);
806
832
  function NodeInspector({
807
833
  node,
808
834
  transform,
@@ -816,9 +842,9 @@ function NodeInspector({
816
842
  }) {
817
843
  const base = node.style.base ?? {};
818
844
  const setT = (patch) => onTransform({ ...transform, ...patch });
819
- const handled = /* @__PURE__ */ new Set(["content", "label"]);
845
+ const handled = /* @__PURE__ */ new Set(["content", "label", "html"]);
820
846
  const extraKeys = Object.keys(node.props).filter(
821
- (k) => !handled.has(k) && ["string", "number", "boolean"].includes(typeof node.props[k])
847
+ (k) => !handled.has(k) && (["string", "number", "boolean"].includes(typeof node.props[k]) || isBinding(node.props[k]))
822
848
  );
823
849
  return /* @__PURE__ */ jsxRuntime.jsxs("div", { style: panel, onPointerDown: (e) => e.stopPropagation(), children: [
824
850
  /* @__PURE__ */ jsxRuntime.jsxs("div", { style: headRow, children: [
@@ -831,14 +857,11 @@ function NodeInspector({
831
857
  /* @__PURE__ */ jsxRuntime.jsx("button", { type: "button", style: iconBtn, title: "Close", onClick: onClose, children: "\u2715" })
832
858
  ] }),
833
859
  /* @__PURE__ */ jsxRuntime.jsxs("div", { style: body, children: [
834
- "content" in node.props ? /* @__PURE__ */ jsxRuntime.jsx(Group, { label: "Content", children: /* @__PURE__ */ jsxRuntime.jsx(
835
- "textarea",
836
- {
837
- style: { ...input2, minHeight: 64, resize: "vertical", lineHeight: 1.4 },
838
- value: String(node.props.content ?? ""),
839
- onChange: (e) => onProps({ content: e.target.value })
840
- }
841
- ) }) : null,
860
+ "content" in node.props || node.type === "richtext" ? /* @__PURE__ */ jsxRuntime.jsxs("p", { style: { fontSize: 11, color: "var(--fg-3, #94a3b8)", margin: "0 0 12px", lineHeight: 1.45, paddingBottom: 12, borderBottom: "1px solid #1e293b" }, children: [
861
+ "\u270E Edit the text ",
862
+ /* @__PURE__ */ jsxRuntime.jsx("strong", { children: "directly on the page" }),
863
+ " \u2014 click in and type. This panel is for layout, style, and bindings."
864
+ ] }) : null,
842
865
  "label" in node.props ? /* @__PURE__ */ jsxRuntime.jsx(Group, { label: "Label", children: /* @__PURE__ */ jsxRuntime.jsx("input", { style: input2, value: String(node.props.label ?? ""), onChange: (e) => onProps({ label: e.target.value }) }) }) : null,
843
866
  extraKeys.length ? /* @__PURE__ */ jsxRuntime.jsx(Group, { label: "Properties", children: extraKeys.map((k) => /* @__PURE__ */ jsxRuntime.jsx(PropField, { name: k, value: node.props[k], onChange: (v) => onProps({ [k]: v }) }, k)) }) : null,
844
867
  /* @__PURE__ */ jsxRuntime.jsxs(Group, { label: "Position & size", children: [
@@ -943,24 +966,50 @@ function Row({ children }) {
943
966
  return /* @__PURE__ */ jsxRuntime.jsx("div", { style: { display: "flex", gap: 8 }, children });
944
967
  }
945
968
  function PropField({ name, value, onChange }) {
969
+ const bound = isBinding(value);
970
+ const link = /* @__PURE__ */ jsxRuntime.jsx(
971
+ "button",
972
+ {
973
+ type: "button",
974
+ title: bound ? "Unbind (use a literal value)" : "Bind to data (e.g. item.name)",
975
+ onClick: () => onChange(bound ? "" : { $bind: "" }),
976
+ style: { background: "none", border: "none", cursor: "pointer", padding: 0, fontSize: 11, color: bound ? "#a78bfa" : "#64748b" },
977
+ children: "\u{1F517}"
978
+ }
979
+ );
980
+ if (bound) {
981
+ return /* @__PURE__ */ jsxRuntime.jsxs("label", { style: field, children: [
982
+ /* @__PURE__ */ jsxRuntime.jsxs("span", { style: { ...fieldLabel, display: "flex", justifyContent: "space-between", alignItems: "center" }, children: [
983
+ name,
984
+ " ",
985
+ link
986
+ ] }),
987
+ /* @__PURE__ */ jsxRuntime.jsx("input", { style: { ...input2, borderColor: "#7c3aed" }, placeholder: "data path \xB7 e.g. item.name", value: value.$bind, onChange: (e) => onChange({ $bind: e.target.value }) })
988
+ ] });
989
+ }
946
990
  if (typeof value === "boolean") {
947
991
  return /* @__PURE__ */ jsxRuntime.jsxs("label", { style: { display: "flex", alignItems: "center", gap: 8, fontSize: 12 }, children: [
948
992
  /* @__PURE__ */ jsxRuntime.jsx("input", { type: "checkbox", checked: value, onChange: (e) => onChange(e.target.checked) }),
949
- /* @__PURE__ */ jsxRuntime.jsx("span", { style: { textTransform: "capitalize" }, children: name })
993
+ /* @__PURE__ */ jsxRuntime.jsx("span", { style: { textTransform: "capitalize", flex: 1 }, children: name }),
994
+ link
950
995
  ] });
951
996
  }
952
997
  if (name === "variant") {
953
- return /* @__PURE__ */ jsxRuntime.jsx(Sel, { label: name, value: String(value), options: [["primary", "Primary"], ["ghost", "Ghost"], ["outline", "Outline"]], onChange });
998
+ return /* @__PURE__ */ jsxRuntime.jsx(Sel, { label: name, value: String(value ?? ""), options: [["primary", "Primary"], ["ghost", "Ghost"], ["outline", "Outline"]], onChange });
954
999
  }
955
1000
  const isNum = typeof value === "number";
956
1001
  return /* @__PURE__ */ jsxRuntime.jsxs("label", { style: field, children: [
957
- /* @__PURE__ */ jsxRuntime.jsx("span", { style: fieldLabel, children: name }),
1002
+ /* @__PURE__ */ jsxRuntime.jsxs("span", { style: { ...fieldLabel, display: "flex", justifyContent: "space-between", alignItems: "center" }, children: [
1003
+ name,
1004
+ " ",
1005
+ link
1006
+ ] }),
958
1007
  /* @__PURE__ */ jsxRuntime.jsx(
959
1008
  "input",
960
1009
  {
961
1010
  style: input2,
962
1011
  type: isNum ? "number" : "text",
963
- value: String(value),
1012
+ value: String(value ?? ""),
964
1013
  onChange: (e) => onChange(isNum ? Number(e.target.value) : e.target.value)
965
1014
  }
966
1015
  )
@@ -1144,9 +1193,11 @@ function snapshotSubtree(doc, id) {
1144
1193
  return { rootId: id, nodes };
1145
1194
  }
1146
1195
  var Z2 = 2147483e3;
1196
+ var SNAP = 6;
1147
1197
  function EditablePage({
1148
1198
  doc,
1149
1199
  registry = defaultRegistry,
1200
+ data,
1150
1201
  timelineDock,
1151
1202
  transforms,
1152
1203
  onNodeTransform,
@@ -1163,7 +1214,8 @@ function EditablePage({
1163
1214
  const [box, setBox] = react.useState(null);
1164
1215
  const { selection } = ed.state;
1165
1216
  const selNode = selection ? ed.state.doc.nodes[selection] : null;
1166
- react.useEffect(() => onSelect?.(selection), [selection, onSelect]);
1217
+ const onSelectRef = useLatestRef(onSelect);
1218
+ react.useEffect(() => onSelectRef.current?.(selection), [selection, onSelectRef]);
1167
1219
  react.useEffect(() => {
1168
1220
  const onKey = (e) => setRevealed(e.ctrlKey && e.shiftKey);
1169
1221
  const onBlur = () => setRevealed(false);
@@ -1185,13 +1237,15 @@ function EditablePage({
1185
1237
  applyTransform(el, t);
1186
1238
  });
1187
1239
  }, [transforms, ed.state.doc]);
1240
+ const onProgressRef = useLatestRef(onProgress);
1241
+ const hasProgress = !!onProgress;
1188
1242
  react.useEffect(() => {
1189
1243
  const spacer = spacerRef.current;
1190
- if (!spacer || !onProgress) return;
1244
+ if (!spacer || !hasProgress) return;
1191
1245
  const compute = () => {
1192
1246
  const len2 = spacer.offsetHeight - window.innerHeight;
1193
1247
  const top = spacer.getBoundingClientRect().top;
1194
- onProgress(len2 > 0 ? Math.min(1, Math.max(0, -top / len2)) : 0);
1248
+ onProgressRef.current?.(len2 > 0 ? Math.min(1, Math.max(0, -top / len2)) : 0);
1195
1249
  };
1196
1250
  compute();
1197
1251
  window.addEventListener("scroll", compute, { passive: true });
@@ -1200,7 +1254,7 @@ function EditablePage({
1200
1254
  window.removeEventListener("scroll", compute);
1201
1255
  window.removeEventListener("resize", compute);
1202
1256
  };
1203
- }, [onProgress, frames]);
1257
+ }, [hasProgress, onProgressRef, frames]);
1204
1258
  const measureBox = react.useCallback(() => {
1205
1259
  if (!editing || !selection || !pageRef.current) return setBox(null);
1206
1260
  const el = pageRef.current.querySelector(`[data-cms="${cssEscape2(selection)}"]`);
@@ -1218,13 +1272,17 @@ function EditablePage({
1218
1272
  };
1219
1273
  }, [measureBox, ed.state.doc]);
1220
1274
  react.useEffect(() => {
1221
- if (!editing || !selection || !pageRef.current || selNode?.type !== "text") return;
1222
- const el = pageRef.current.querySelector(`[data-cms="${cssEscape2(selection)}"]`);
1223
- if (!el) return;
1275
+ const t = selNode?.type;
1276
+ const editable = t === "text" || t === "heading" || t === "richtext";
1277
+ if (!editing || !selection || !pageRef.current || !editable) return;
1278
+ const wrapper = pageRef.current.querySelector(`[data-cms="${cssEscape2(selection)}"]`);
1279
+ if (!wrapper) return;
1280
+ const isRich = t === "richtext";
1281
+ const el = (isRich ? wrapper.firstElementChild : wrapper) ?? wrapper;
1224
1282
  el.setAttribute("contenteditable", "true");
1225
1283
  el.style.outline = "none";
1226
1284
  el.focus();
1227
- const commit = () => ed.apply({ t: "set_props", id: selection, patch: { content: el.innerText } });
1285
+ const commit = () => ed.apply({ t: "set_props", id: selection, patch: isRich ? { html: el.innerHTML } : { content: el.innerText } });
1228
1286
  el.addEventListener("blur", commit);
1229
1287
  return () => {
1230
1288
  el.removeEventListener("blur", commit);
@@ -1249,11 +1307,84 @@ function EditablePage({
1249
1307
  const order = keyBetween(siblings.length ? siblings[siblings.length - 1].order : null, null);
1250
1308
  const id = `n${doc2.seq + 1}-${Math.floor(performance.now())}`;
1251
1309
  const def = ADD_DEFAULTS[kind];
1252
- ed.apply({ t: "insert_node", node: { id, type: def.type, parent, order, props: { ...def.props }, style: { base: { ...def.style } } } });
1310
+ ed.apply({ t: "insert_node", node: { id, type: def.type, parent, order, layout: def.layout, props: { ...def.props }, style: { base: { ...def.style } } } });
1253
1311
  ed.select(id);
1254
1312
  },
1255
1313
  [ed, selNode]
1256
1314
  );
1315
+ react.useCallback(
1316
+ (kind, targetId) => {
1317
+ const doc2 = ed.state.doc;
1318
+ const target = targetId ? doc2.nodes[targetId] : null;
1319
+ const parent = target ? CONTAINER_TYPES2.has(target.type) ? target.id : target.parent : doc2.sections[doc2.sections.length - 1] ?? null;
1320
+ const siblings = childrenOf(doc2, parent);
1321
+ const order = keyBetween(siblings.length ? siblings[siblings.length - 1].order : null, null);
1322
+ const id = `n${doc2.seq + 1}-${Math.floor(performance.now())}`;
1323
+ const def = ADD_DEFAULTS[kind];
1324
+ ed.apply({ t: "insert_node", node: { id, type: def.type, parent, order, layout: def.layout, props: { ...def.props }, style: { base: { ...def.style } } } });
1325
+ ed.select(id);
1326
+ },
1327
+ [ed]
1328
+ );
1329
+ const [paletteOpen, setPaletteOpen] = react.useState(false);
1330
+ const [draggingEl, setDraggingEl] = react.useState(false);
1331
+ react.useEffect(() => {
1332
+ if (!editing) {
1333
+ setPaletteOpen(false);
1334
+ return;
1335
+ }
1336
+ const onMove = (e) => {
1337
+ if (draggingEl) return;
1338
+ if (e.clientX >= window.innerWidth - 26) setPaletteOpen(true);
1339
+ else if (e.clientX < window.innerWidth - 236) setPaletteOpen(false);
1340
+ };
1341
+ window.addEventListener("mousemove", onMove);
1342
+ return () => window.removeEventListener("mousemove", onMove);
1343
+ }, [editing, draggingEl]);
1344
+ const [dropHint, setDropHint] = react.useState(null);
1345
+ const computeDropTarget = (clientX, clientY) => {
1346
+ const el = document.elementFromPoint(clientX, clientY)?.closest("[data-cms]");
1347
+ if (!el) return { id: null, edge: "inside", rect: null };
1348
+ const id = el.getAttribute("data-cms");
1349
+ const node = ed.state.doc.nodes[id];
1350
+ const r = el.getBoundingClientRect();
1351
+ const rect = { x: r.left, y: r.top, w: r.width, h: r.height };
1352
+ if (node && CONTAINER_TYPES2.has(node.type)) return { id, edge: "inside", rect };
1353
+ return { id, edge: clientY < r.top + r.height / 2 ? "before" : "after", rect };
1354
+ };
1355
+ const onPageDragOver = (e) => {
1356
+ e.preventDefault();
1357
+ e.dataTransfer.dropEffect = "copy";
1358
+ setDropHint(computeDropTarget(e.clientX, e.clientY));
1359
+ };
1360
+ const insertRelative = (kind, hint) => {
1361
+ const doc2 = ed.state.doc;
1362
+ let parent;
1363
+ let order;
1364
+ if (!hint.id || hint.edge === "inside") {
1365
+ parent = hint.id ?? (doc2.sections[doc2.sections.length - 1] ?? null);
1366
+ const sib = childrenOf(doc2, parent);
1367
+ order = keyBetween(sib.length ? sib[sib.length - 1].order : null, null);
1368
+ } else {
1369
+ const target = doc2.nodes[hint.id];
1370
+ parent = target.parent;
1371
+ const sib = childrenOf(doc2, parent);
1372
+ const idx = sib.findIndex((n) => n.id === hint.id);
1373
+ order = hint.edge === "before" ? keyBetween(idx > 0 ? sib[idx - 1].order : null, target.order) : keyBetween(target.order, idx < sib.length - 1 ? sib[idx + 1].order : null);
1374
+ }
1375
+ const id = `n${doc2.seq + 1}-${Math.floor(performance.now())}`;
1376
+ const def = ADD_DEFAULTS[kind];
1377
+ ed.apply({ t: "insert_node", node: { id, type: def.type, parent, order, layout: def.layout, props: { ...def.props }, style: { base: { ...def.style } } } });
1378
+ ed.select(id);
1379
+ };
1380
+ const onPageDrop = (e) => {
1381
+ const kind = e.dataTransfer.getData("application/x-cms-element");
1382
+ if (!kind) return;
1383
+ e.preventDefault();
1384
+ insertRelative(kind, computeDropTarget(e.clientX, e.clientY));
1385
+ setDropHint(null);
1386
+ setDraggingEl(false);
1387
+ };
1257
1388
  const setStyle = (patch) => selection && ed.apply({ t: "set_style", id: selection, breakpoint: "base", patch });
1258
1389
  const setProps = (patch) => selection && ed.apply({ t: "set_props", id: selection, patch });
1259
1390
  const setLayout = (layout) => selection && ed.apply({ t: "set_layout", id: selection, layout });
@@ -1321,8 +1452,23 @@ function EditablePage({
1321
1452
  if (el) ed.select(el.getAttribute("data-cms"));
1322
1453
  else ed.select(null);
1323
1454
  };
1324
- const page = /* @__PURE__ */ jsxRuntime.jsx("div", { ref: pageRef, onClickCapture, onContextMenu: onContextMenuSelect, children: /* @__PURE__ */ jsxRuntime.jsx(CmsPage, { doc: ed.state.doc, registry }) });
1325
- const pageShell = pinned ? /* @__PURE__ */ jsxRuntime.jsx("div", { ref: spacerRef, style: { height: `${Math.max(1, frames) * 100}vh` }, children: /* @__PURE__ */ jsxRuntime.jsx("div", { style: { position: "sticky", top: 0, height: "100vh", overflow: "hidden" }, children: page }) }) : page;
1455
+ const page = /* @__PURE__ */ jsxRuntime.jsx(
1456
+ "div",
1457
+ {
1458
+ ref: pageRef,
1459
+ onClickCapture,
1460
+ onContextMenu: onContextMenuSelect,
1461
+ onDragOver: editing ? onPageDragOver : void 0,
1462
+ onDragLeave: editing ? () => setDropHint(null) : void 0,
1463
+ onDrop: editing ? onPageDrop : void 0,
1464
+ children: /* @__PURE__ */ jsxRuntime.jsx(CmsPage, { doc: ed.state.doc, registry, data })
1465
+ }
1466
+ );
1467
+ const pageShell = pinned ? /* @__PURE__ */ jsxRuntime.jsx("div", { ref: spacerRef, style: { height: `${Math.max(1, frames) * 100}vh` }, children: /* @__PURE__ */ jsxRuntime.jsx("div", { style: { position: "sticky", top: 0, height: "100vh", overflow: "hidden" }, children: page }) }) : frames > 1 ? (
1468
+ // Normal flow, but "expanded" — extra scroll length below the content so a
1469
+ // page can be made longer than its content (the + Frame control).
1470
+ /* @__PURE__ */ jsxRuntime.jsx("div", { style: { minHeight: `${frames * 100}vh` }, children: page })
1471
+ ) : page;
1326
1472
  return /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { position: "relative" }, children: [
1327
1473
  editing ? /* @__PURE__ */ jsxRuntime.jsxs(reactFancy.ContextMenu, { children: [
1328
1474
  /* @__PURE__ */ jsxRuntime.jsx(reactFancy.ContextMenu.Trigger, { children: pageShell }),
@@ -1384,16 +1530,101 @@ function EditablePage({
1384
1530
  onAdd: addNode
1385
1531
  }
1386
1532
  ) : null,
1533
+ editing && dropHint?.rect ? /* @__PURE__ */ jsxRuntime.jsx(DropIndicator, { hint: dropHint }) : null,
1534
+ editing ? /* @__PURE__ */ jsxRuntime.jsx(ElementPalette, { open: paletteOpen, onDragChange: setDraggingEl }) : null,
1387
1535
  editing && timelineDock ? /* @__PURE__ */ jsxRuntime.jsx("div", { style: { position: "fixed", left: 0, right: 0, bottom: 0, zIndex: Z2 }, children: timelineDock }) : null
1388
1536
  ] });
1389
1537
  }
1390
- var CONTAINER_TYPES2 = /* @__PURE__ */ new Set(["section", "frame", "stack", "grid", "shape"]);
1538
+ function DropIndicator({
1539
+ hint
1540
+ }) {
1541
+ const r = hint.rect;
1542
+ if (!r) return null;
1543
+ const color = "#ec4899";
1544
+ if (hint.edge === "inside") {
1545
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { style: { position: "fixed", left: r.x, top: r.y, width: r.w, height: r.h, outline: `2px dashed ${color}`, outlineOffset: -2, borderRadius: 6, pointerEvents: "none", zIndex: Z2 + 4 } });
1546
+ }
1547
+ const y = hint.edge === "before" ? r.y : r.y + r.h;
1548
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { style: { position: "fixed", left: r.x, top: y - 1.5, width: r.w, height: 3, background: color, borderRadius: 2, boxShadow: "0 0 0 1px #fff", pointerEvents: "none", zIndex: Z2 + 4 } });
1549
+ }
1550
+ function ElementPalette({ open, onDragChange }) {
1551
+ return /* @__PURE__ */ jsxRuntime.jsxs(
1552
+ "div",
1553
+ {
1554
+ style: {
1555
+ position: "fixed",
1556
+ top: 64,
1557
+ bottom: 96,
1558
+ right: 0,
1559
+ width: 216,
1560
+ transform: open ? "translateX(0)" : "translateX(100%)",
1561
+ transition: "transform 0.18s ease",
1562
+ background: "#0b1220",
1563
+ color: "#e2e8f0",
1564
+ borderLeft: "1px solid #1e293b",
1565
+ borderTopLeftRadius: 12,
1566
+ borderBottomLeftRadius: 12,
1567
+ boxShadow: "-18px 0 48px -20px rgba(0,0,0,0.6)",
1568
+ zIndex: Z2 + 2,
1569
+ fontFamily: "system-ui, sans-serif",
1570
+ padding: 12,
1571
+ overflowY: "auto"
1572
+ },
1573
+ children: [
1574
+ /* @__PURE__ */ jsxRuntime.jsx("div", { style: { fontSize: 11, letterSpacing: "0.06em", textTransform: "uppercase", opacity: 0.5, marginBottom: 10 }, children: "Elements" }),
1575
+ /* @__PURE__ */ jsxRuntime.jsx("div", { style: { display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }, children: ADD_MENU.map((m) => /* @__PURE__ */ jsxRuntime.jsxs(
1576
+ "div",
1577
+ {
1578
+ draggable: true,
1579
+ onDragStart: (e) => {
1580
+ e.dataTransfer.setData("application/x-cms-element", m.kind);
1581
+ e.dataTransfer.effectAllowed = "copy";
1582
+ onDragChange(true);
1583
+ },
1584
+ onDragEnd: () => onDragChange(false),
1585
+ title: `Drag "${m.label}" onto the page`,
1586
+ style: {
1587
+ display: "flex",
1588
+ flexDirection: "column",
1589
+ alignItems: "center",
1590
+ justifyContent: "center",
1591
+ gap: 4,
1592
+ height: 56,
1593
+ borderRadius: 8,
1594
+ background: "#0f172a",
1595
+ border: "1px solid #1e293b",
1596
+ cursor: "grab",
1597
+ fontSize: 12,
1598
+ userSelect: "none"
1599
+ },
1600
+ children: [
1601
+ /* @__PURE__ */ jsxRuntime.jsx("span", { style: { fontSize: 16, opacity: 0.7 }, children: "\uFF0B" }),
1602
+ m.label
1603
+ ]
1604
+ },
1605
+ m.kind
1606
+ )) }),
1607
+ /* @__PURE__ */ jsxRuntime.jsx("p", { style: { fontSize: 10.5, opacity: 0.45, marginTop: 12, lineHeight: 1.4 }, children: "Drag an Element onto the page. It drops into the container you release over." })
1608
+ ]
1609
+ }
1610
+ );
1611
+ }
1612
+ var CONTAINER_TYPES2 = /* @__PURE__ */ new Set(["section", "frame", "stack", "grid", "shape", "card", "device", "repeater"]);
1391
1613
  var ADD_DEFAULTS = {
1392
1614
  text: { type: "text", props: { content: "New text" }, style: { color: "inherit" } },
1393
1615
  heading: { type: "heading", props: { content: "New heading" }, style: { fontSize: { value: 28, unit: "px" }, fontWeight: 700 } },
1394
1616
  button: { type: "button", props: { label: "Button", href: "#", variant: "primary" }, style: {} },
1617
+ link: { type: "link", props: { content: "link text", href: "#" }, style: { color: "#7c3aed" } },
1395
1618
  image: { type: "image", props: { src: "", alt: "" }, style: {} },
1396
- box: { type: "frame", props: {}, style: { padding: { value: 16, unit: "px" } } }
1619
+ box: { type: "frame", props: {}, style: { padding: { value: 16, unit: "px" } } },
1620
+ stack: { type: "stack", props: {}, style: { gap: { value: 12, unit: "px" } }, layout: "stack" },
1621
+ grid: { type: "grid", props: {}, style: { gap: { value: 12, unit: "px" } }, layout: "grid" },
1622
+ card: { type: "card", props: {}, style: { padding: { value: 16, unit: "px" }, radius: { value: 12, unit: "px" }, border: "1px solid #e2e8f0" } },
1623
+ callout: { type: "callout", props: { content: "Heads up \u2014 this is a callout.", variant: "info" }, style: {} },
1624
+ divider: { type: "divider", props: {}, style: {} },
1625
+ code: { type: "code", props: { content: "npm install @particle-academy/react-fancy", lang: "bash" }, style: {} },
1626
+ richtext: { type: "richtext", props: { html: "<p>Rich <strong>text</strong> with <em>inline</em> formatting.</p>" }, style: {} },
1627
+ repeater: { type: "repeater", props: { items: "" }, style: { gap: { value: 12, unit: "px" } }, layout: "stack" }
1397
1628
  };
1398
1629
  var GRIPS = [
1399
1630
  { key: "nw", north: true, west: true, cursor: "nwse-resize" },
@@ -1414,20 +1645,55 @@ function SelectionOverlay({
1414
1645
  onLive
1415
1646
  }) {
1416
1647
  const drag = react.useRef(null);
1648
+ const targets = react.useRef({ vx: [], hy: [] });
1649
+ const [guides, setGuides] = react.useState({ x: null, y: null });
1417
1650
  const apply = (t) => {
1418
1651
  const el = getEl();
1419
1652
  if (el) applyTransform(el, t);
1420
1653
  };
1654
+ const snap = (cands, lines) => {
1655
+ let best = { delta: 0, at: null, dist: SNAP + 1 };
1656
+ for (const line of lines) {
1657
+ for (const c of cands) {
1658
+ const diff = line - c;
1659
+ if (Math.abs(diff) < best.dist) best = { delta: diff, at: line, dist: Math.abs(diff) };
1660
+ }
1661
+ }
1662
+ return best.dist <= SNAP ? { delta: best.delta, at: best.at } : { delta: 0, at: null };
1663
+ };
1421
1664
  const startMove = (e) => {
1422
1665
  e.stopPropagation();
1423
1666
  e.preventDefault();
1424
1667
  e.currentTarget.setPointerCapture(e.pointerId);
1425
1668
  drag.current = { x: e.clientX, y: e.clientY, w: box.w, h: box.h, live: { ...base } };
1669
+ const sel = getEl();
1670
+ const vx = /* @__PURE__ */ new Set();
1671
+ const hy = /* @__PURE__ */ new Set();
1672
+ document.querySelectorAll("[data-cms]").forEach((el) => {
1673
+ if (sel && (el === sel || sel.contains(el) || el.contains(sel))) return;
1674
+ const r = el.getBoundingClientRect();
1675
+ vx.add(r.left).add(r.left + r.width / 2).add(r.right);
1676
+ hy.add(r.top).add(r.top + r.height / 2).add(r.bottom);
1677
+ });
1678
+ targets.current = { vx: [...vx], hy: [...hy] };
1426
1679
  };
1427
1680
  const moveMove = (e) => {
1428
1681
  const d = drag.current;
1429
1682
  if (!d) return;
1430
- d.live = { ...base, x: (base.x ?? 0) + (e.clientX - d.x), y: (base.y ?? 0) + (e.clientY - d.y) };
1683
+ let dx = e.clientX - d.x;
1684
+ let dy = e.clientY - d.y;
1685
+ if (!e.altKey) {
1686
+ const l = box.x + dx;
1687
+ const t = box.y + dy;
1688
+ const sx = snap([l, l + box.w / 2, l + box.w], targets.current.vx);
1689
+ const sy = snap([t, t + box.h / 2, t + box.h], targets.current.hy);
1690
+ dx += sx.delta;
1691
+ dy += sy.delta;
1692
+ setGuides({ x: sx.at, y: sy.at });
1693
+ } else if (guides.x !== null || guides.y !== null) {
1694
+ setGuides({ x: null, y: null });
1695
+ }
1696
+ d.live = { ...base, x: (base.x ?? 0) + dx, y: (base.y ?? 0) + dy };
1431
1697
  apply(d.live);
1432
1698
  onLive();
1433
1699
  };
@@ -1462,6 +1728,7 @@ function SelectionOverlay({
1462
1728
  const d = drag.current;
1463
1729
  if (d) onTransform(d.live);
1464
1730
  drag.current = null;
1731
+ setGuides({ x: null, y: null });
1465
1732
  };
1466
1733
  const outline = {
1467
1734
  position: "fixed",
@@ -1491,6 +1758,8 @@ function SelectionOverlay({
1491
1758
  return { left: cx - 6, top: cy - 6, cursor: g.cursor };
1492
1759
  };
1493
1760
  return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1761
+ guides.x !== null ? /* @__PURE__ */ jsxRuntime.jsx("div", { style: { position: "fixed", left: guides.x, top: 0, bottom: 0, width: 1, background: "#ec4899", zIndex: Z2 + 3, pointerEvents: "none" } }) : null,
1762
+ guides.y !== null ? /* @__PURE__ */ jsxRuntime.jsx("div", { style: { position: "fixed", top: guides.y, left: 0, right: 0, height: 1, background: "#ec4899", zIndex: Z2 + 3, pointerEvents: "none" } }) : null,
1494
1763
  /* @__PURE__ */ jsxRuntime.jsx("div", { style: outline }),
1495
1764
  movable ? /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1496
1765
  /* @__PURE__ */ jsxRuntime.jsx(
@@ -1555,11 +1824,20 @@ function FloatingToolbar({
1555
1824
  ] });
1556
1825
  }
1557
1826
  var ADD_MENU = [
1558
- { kind: "text", label: "Text" },
1559
1827
  { kind: "heading", label: "Heading" },
1828
+ { kind: "text", label: "Text" },
1560
1829
  { kind: "button", label: "Button" },
1830
+ { kind: "link", label: "Link" },
1561
1831
  { kind: "image", label: "Image" },
1562
- { kind: "box", label: "Box" }
1832
+ { kind: "card", label: "Card" },
1833
+ { kind: "callout", label: "Callout" },
1834
+ { kind: "stack", label: "Stack" },
1835
+ { kind: "grid", label: "Grid" },
1836
+ { kind: "box", label: "Box" },
1837
+ { kind: "divider", label: "Divider" },
1838
+ { kind: "code", label: "Code" },
1839
+ { kind: "richtext", label: "Rich text" },
1840
+ { kind: "repeater", label: "Repeater" }
1563
1841
  ];
1564
1842
  function EditBar({
1565
1843
  editing,
@@ -1577,7 +1855,7 @@ function EditBar({
1577
1855
  /* @__PURE__ */ jsxRuntime.jsx("button", { type: "button", style: { ...btn, background: editing ? "#8b5cf6" : "transparent" }, onClick: onToggle, children: editing ? "\u25CF Editing" : "Edit" }),
1578
1856
  editing ? /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1579
1857
  /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { position: "relative" }, children: [
1580
- /* @__PURE__ */ jsxRuntime.jsx("button", { type: "button", style: { ...btn, background: open ? "#1e293b" : "transparent" }, onClick: () => setOpen((v) => !v), title: "Add an element", children: "\uFF0B Add \u25BE" }),
1858
+ /* @__PURE__ */ jsxRuntime.jsx("button", { type: "button", style: { ...btn, background: open ? "#1e293b" : "transparent" }, onClick: () => setOpen((v) => !v), title: "Add an Element (or drag from the right-edge palette)", children: "\uFF0B Element \u25BE" }),
1581
1859
  open ? /* @__PURE__ */ jsxRuntime.jsx("div", { style: { position: "absolute", top: "calc(100% + 6px)", right: 0, background: "#0f172a", border: "1px solid #1e293b", borderRadius: 10, padding: 4, display: "flex", flexDirection: "column", minWidth: 132, boxShadow: "0 12px 32px -10px rgba(0,0,0,0.6)" }, children: ADD_MENU.map((m) => /* @__PURE__ */ jsxRuntime.jsx(
1582
1860
  "button",
1583
1861
  {
@@ -1615,7 +1893,7 @@ function EditMenuItems({
1615
1893
  return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1616
1894
  editable ? /* @__PURE__ */ jsxRuntime.jsx(reactFancy.ContextMenu.Item, { onClick: onEditText, children: "Edit text" }) : null,
1617
1895
  /* @__PURE__ */ jsxRuntime.jsxs(reactFancy.ContextMenu.Sub, { children: [
1618
- /* @__PURE__ */ jsxRuntime.jsx(reactFancy.ContextMenu.SubTrigger, { children: isContainer ? "Add inside" : "Add" }),
1896
+ /* @__PURE__ */ jsxRuntime.jsx(reactFancy.ContextMenu.SubTrigger, { children: isContainer ? "Add Element inside" : "Add Element" }),
1619
1897
  /* @__PURE__ */ jsxRuntime.jsx(reactFancy.ContextMenu.SubContent, { className: "!z-[2147483647]", children: ADD_MENU.map((m) => /* @__PURE__ */ jsxRuntime.jsx(reactFancy.ContextMenu.Item, { onClick: () => onAdd(m.kind), children: m.label }, m.kind)) })
1620
1898
  ] }),
1621
1899
  node ? /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [