@pramen/cms-editor 0.0.42 → 0.0.44

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pramen/cms-editor",
3
- "version": "0.0.42",
3
+ "version": "0.0.44",
4
4
  "description": "Visual block/page editor for @pramen/cms — a standalone React SPA that talks to the CMS handlers over HTTP.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -394,11 +394,20 @@ export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange
394
394
  const patchRegion = (region: string, fn: (list: RenderedBlock[]) => RenderedBlock[]) =>
395
395
  setAssembled((prev) => (prev ? { ...prev, regions: { ...prev.regions, [region]: fn(prev.regions[region] ?? []) } } : prev));
396
396
 
397
- const addBlock = async (region: string, slug: string) => {
397
+ // Add a block, optionally AT an index (`at`) rather than appended — the Notion-style
398
+ // insert-between. addBlock always appends server-side, so when `at` lands mid-list we
399
+ // follow up with a reorderRegion to move the new placement into place.
400
+ const addBlock = async (region: string, slug: string, at?: number) => {
398
401
  // Optimistic: show a placeholder immediately (no wait for the round trip), then
399
402
  // reconcile with the persisted row addBlock echoes — or roll it back on failure.
400
403
  const tempId = `tmp:${crypto.randomUUID()}`;
401
- patchRegion(region, (list) => [...list, { id: tempId, block_id: tempId, block_type: slug, title: null, fields: {}, is_shared: false, pending: true }]);
404
+ const existingIds = (assembled?.regions[region] ?? []).map((b) => b.id);
405
+ const index = at != null && at >= 0 && at < existingIds.length ? at : existingIds.length;
406
+ patchRegion(region, (list) => {
407
+ const next = [...list];
408
+ next.splice(index, 0, { id: tempId, block_id: tempId, block_type: slug, title: null, fields: {}, is_shared: false, pending: true });
409
+ return next;
410
+ });
402
411
  try {
403
412
  const { block, placement } = await api.call<{
404
413
  block: { id: string; title?: string | null; fields?: Record<string, unknown> | null };
@@ -413,6 +422,13 @@ export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange
413
422
  is_shared: Boolean(placement.isShared),
414
423
  };
415
424
  patchRegion(region, (list) => list.map((b) => (b.id === tempId ? rb : b)));
425
+ // Inserted mid-list: the server appended, so persist the intended order (this
426
+ // reloads, like a drag-reorder does).
427
+ if (index < existingIds.length) {
428
+ const order = [...existingIds];
429
+ order.splice(index, 0, rb.id);
430
+ await reorder(region, order);
431
+ }
416
432
  } catch (e) {
417
433
  patchRegion(region, (list) => list.filter((b) => b.id !== tempId));
418
434
  setErr(errMsg(e));
@@ -481,37 +497,44 @@ export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange
481
497
  </div>
482
498
  </div>
483
499
 
484
- <div className="overflow-auto py-1.5">
500
+ {/* The canvas: one inline document. `pl-8` reserves the left gutter that each
501
+ block's drag handle occupies on hover. Regions are titled sections. */}
502
+ <div className="overflow-auto py-1.5 pl-8 pr-2">
485
503
  {err ? <Banner>{err}</Banner> : null}
486
504
  {regions.map((r) => {
487
505
  const blocks = assembled?.regions[r.name] ?? [];
488
506
  const allowed = r.allowedTypes && r.allowedTypes.length ? r.allowedTypes : blockTypes.map((b) => b.slug);
489
507
  return (
490
- <div className="mb-8" key={r.name}>
491
- <h3 className="m-0 mb-3 text-[22px] font-normal text-fg">{r.label ?? r.name}</h3>
492
- {blocks.length === 0 ? <p className="mb-3 text-sm text-fg-subtle">No blocks yet — add one below.</p> : null}
508
+ <div className="mb-10" key={r.name}>
509
+ <div className="mb-1 text-caption font-medium uppercase tracking-wide text-fg-subtle">{r.label ?? r.name}</div>
493
510
  {blocks.map((b, i) => (
494
- <BlockCard
495
- key={b.id}
496
- api={api}
497
- block={b}
498
- blockType={btBySlug.get(b.block_type)}
499
- isFirst={i === 0}
500
- isLast={i === blocks.length - 1}
501
- onMove={(d) => reorderMove(blocks, r.name, i, d, reorder)}
502
- onRemove={() => removeBlock(b)}
503
- onPatch={patchBlockFields}
504
- onDirtyChange={reportDirty}
505
- onError={setErr}
506
- dragging={drag?.region === r.name && drag.from === i}
507
- isOver={!!drag && drag.region === r.name && drag.over === i && drag.from !== i}
508
- onDragStartBlock={() => beginDrag(r.name, i)}
509
- onDragOverBlock={() => hoverDrag(r.name, i)}
510
- onDropBlock={() => dropDrag(r.name)}
511
- onDragEndBlock={cancelDrag}
512
- />
511
+ <div key={b.id}>
512
+ {/* Between-blocks insert point — a hover "+" that adds AT index i. */}
513
+ <Inserter compact allowed={allowed} btBySlug={btBySlug} onAdd={(slug) => addBlock(r.name, slug, i)} />
514
+ <BlockCard
515
+ api={api}
516
+ block={b}
517
+ blockType={btBySlug.get(b.block_type)}
518
+ isFirst={i === 0}
519
+ isLast={i === blocks.length - 1}
520
+ onMove={(d) => reorderMove(blocks, r.name, i, d, reorder)}
521
+ onRemove={() => removeBlock(b)}
522
+ onPatch={patchBlockFields}
523
+ onDirtyChange={reportDirty}
524
+ onError={setErr}
525
+ dragging={drag?.region === r.name && drag.from === i}
526
+ isOver={!!drag && drag.region === r.name && drag.over === i && drag.from !== i}
527
+ onDragStartBlock={() => beginDrag(r.name, i)}
528
+ onDragOverBlock={() => hoverDrag(r.name, i)}
529
+ onDropBlock={() => dropDrag(r.name)}
530
+ onDragEndBlock={cancelDrag}
531
+ />
532
+ </div>
513
533
  ))}
514
- <AddBlock allowed={allowed} btBySlug={btBySlug} onAdd={(slug) => addBlock(r.name, slug)} />
534
+ {/* End inserter appends. */}
535
+ <div className="mt-1">
536
+ <Inserter allowed={allowed} btBySlug={btBySlug} onAdd={(slug) => addBlock(r.name, slug)} />
537
+ </div>
515
538
  </div>
516
539
  );
517
540
  })}
@@ -581,7 +604,9 @@ function BlockCard({ api, block, blockType, isFirst, isLast, onMove, onRemove, o
581
604
 
582
605
  const dirty = fields != null && JSON.stringify(fields) !== saved.current;
583
606
 
584
- // Explicit save — the ONLY thing that writes. No autosave, no save-on-blur, no save-on-unmount.
607
+ // Writes the block. Fired by the debounced autosave below AND by the manual "Save
608
+ // now" button (which also serves as the retry path if an autosave fails). Idempotent:
609
+ // no-ops when nothing changed since the last persist.
585
610
  const save = useCallback(async () => {
586
611
  if (block.pending || fields == null) return;
587
612
  const snapshot = JSON.stringify(fields);
@@ -599,6 +624,20 @@ function BlockCard({ api, block, blockType, isFirst, isLast, onMove, onRemove, o
599
624
  }
600
625
  }, [api, blockId, placementId, fields, onPatch, onError, block.pending]);
601
626
 
627
+ // Debounced autosave — persist ~800ms after the last edit (Notion-style). Keyed on
628
+ // `fields`, so it fires only on an actual edit: a failed save leaves `fields`
629
+ // unchanged and does NOT auto-retry (no hot loop) — the next edit, or the manual
630
+ // Save button, retries. The editor's unsaved guard still covers the debounce window
631
+ // if you navigate away mid-edit.
632
+ const saveRef = useRef(save);
633
+ saveRef.current = save;
634
+ useEffect(() => {
635
+ if (fields == null || block.pending) return;
636
+ if (JSON.stringify(fields) === saved.current) return; // not dirty
637
+ const t = setTimeout(() => void saveRef.current(), 800);
638
+ return () => clearTimeout(t);
639
+ }, [fields, block.pending]);
640
+
602
641
  const change = (next: Record<string, unknown>) => setFields(next);
603
642
 
604
643
  // Report dirty state up (for the editor's leave/unload guard); clear it on unmount so a
@@ -610,60 +649,62 @@ function BlockCard({ api, block, blockType, isFirst, isLast, onMove, onRemove, o
610
649
  const name = blockType?.name ?? block.block_type;
611
650
 
612
651
  return (
613
- // The card is the drop target; the handle is the only draggable element, so
614
- // dragging never fights the inline contentEditable text selection. The handle's drag
615
- // image is set to the whole card so you drag a preview of the block, not just the grip.
652
+ // One inline document row (no card chrome). The row is the drop target; the
653
+ // handle in the hover gutter is the only draggable element, so dragging never
654
+ // fights the inline text selection. Its drag image is the whole row.
616
655
  <div
617
656
  ref={cardRef}
618
- className={`mb-2.5 rounded-panel border bg-surface-card transition-colors ${isOver ? "border-brand-green" : "border-border"} ${dragging ? "opacity-40" : ""} ${block.pending ? "pointer-events-none opacity-60" : ""}`}
657
+ className={`group relative rounded-lg px-2 py-1 transition-colors ${isOver ? "ring-2 ring-brand-green" : ""} ${dragging ? "opacity-40" : ""} ${block.pending ? "pointer-events-none opacity-60" : ""}`}
619
658
  onDragOver={(e) => { e.preventDefault(); onDragOverBlock(); }}
620
659
  onDrop={(e) => { e.preventDefault(); onDropBlock(); }}
621
660
  >
622
- <div className="flex items-center gap-2.5 px-3.5 py-2.5">
623
- <span
624
- draggable
625
- onDragStart={(e) => {
626
- if (cardRef.current) e.dataTransfer.setDragImage(cardRef.current, 12, 12);
627
- e.dataTransfer.effectAllowed = "move";
628
- e.dataTransfer.setData("text/plain", "");
629
- onDragStartBlock();
630
- }}
631
- onDragEnd={onDragEndBlock}
632
- className="shrink-0 cursor-grab select-none px-0.5 text-fg-subtle hover:text-fg active:cursor-grabbing"
633
- title="Drag to reorder"
634
- >⠿</span>
661
+ {/* Left gutter drag handle, revealed on hover, sitting in the canvas's pl-8. */}
662
+ <span
663
+ draggable
664
+ onDragStart={(e) => {
665
+ if (cardRef.current) e.dataTransfer.setDragImage(cardRef.current, 12, 12);
666
+ e.dataTransfer.effectAllowed = "move";
667
+ e.dataTransfer.setData("text/plain", "");
668
+ onDragStartBlock();
669
+ }}
670
+ onDragEnd={onDragEndBlock}
671
+ className="absolute -left-6 top-1.5 cursor-grab select-none px-1 text-fg-subtle opacity-0 transition-opacity hover:text-fg group-hover:opacity-100 active:cursor-grabbing"
672
+ title="Drag to reorder"
673
+ >⠿</span>
674
+
675
+ {/* Header — subtle type label + state + actions, mostly revealed on hover. */}
676
+ <div className="flex items-center gap-2 opacity-60 transition-opacity group-hover:opacity-100">
635
677
  <button type="button" className="w-4 shrink-0 text-fg-subtle hover:text-fg" title={collapsed ? "Expand" : "Collapse"} onClick={() => setCollapsed((c) => !c)}>{collapsed ? "▸" : "▾"}</button>
636
- <span className="font-medium text-fg">{name}</span>
637
- {block.is_shared ? <span className="text-[11px] text-accent-strong">shared</span> : null}
638
- <span className={`text-[11px] ${dirty && saveState !== "saving" ? "text-accent-strong" : "text-fg-subtle"}`}>{saveState === "saving" ? "saving…" : saveState === "saved" ? "saved ✓" : dirty ? "● unsaved" : ""}</span>
678
+ <span className="text-caption font-medium uppercase tracking-wide text-fg-subtle">{name}</span>
679
+ {block.is_shared ? <span className="text-caption text-accent-strong">shared</span> : null}
680
+ <span className={`text-caption ${dirty && saveState !== "saving" ? "text-accent-strong" : "text-fg-subtle"}`}>{saveState === "saving" ? "saving…" : saveState === "saved" ? "saved ✓" : dirty ? "● unsaved" : ""}</span>
639
681
  <span className="flex-1" />
640
682
  <Button variant="ghost" size="sm" isDisabled={isFirst} onPress={() => onMove(-1)}>↑</Button>
641
683
  <Button variant="ghost" size="sm" isDisabled={isLast} onPress={() => onMove(1)}>↓</Button>
642
684
  <Button variant="ghost" size="sm" className="text-danger" onPress={onRemove}>✕</Button>
643
685
  </div>
686
+
644
687
  {collapsed ? (
645
- <div className="cursor-pointer truncate px-3.5 pb-3 text-sm text-fg-subtle" onClick={() => setCollapsed(false)}>
688
+ <div className="cursor-pointer truncate pb-1 pl-6 text-small text-fg-subtle" onClick={() => setCollapsed(false)}>
646
689
  {fields == null ? "…" : blockPreview(fields) || <span className="italic">empty</span>}
647
690
  </div>
648
691
  ) : (
649
- <div className="border-t border-border px-3.5 py-3.5">
692
+ <div className="pb-1 pl-6">
650
693
  {fields == null ? (
651
- <p className="text-sm text-fg-subtle">loading…</p>
694
+ <p className="text-small text-fg-subtle">loading…</p>
652
695
  ) : schema.length === 0 ? (
653
- <p className="text-sm text-fg-subtle">This block has no editable fields.</p>
696
+ <p className="text-small text-fg-subtle">This block has no editable fields.</p>
654
697
  ) : (
655
698
  <>
656
699
  <FieldForm schema={schema} value={fields} onChange={change} api={api} />
657
- <div className="mt-3.5 flex items-center gap-2 border-t border-border pt-3">
658
- <Button size="sm" onPress={() => void save()} isDisabled={!dirty || saveState === "saving" || block.pending}>
659
- {saveState === "saving" ? "Saving…" : "Save"}
660
- </Button>
661
- {dirty ? (
662
- <span className="text-[11px] text-accent-strong">Unsaved changes</span>
663
- ) : saveState === "saved" ? (
664
- <span className="text-[11px] text-fg-subtle">Saved ✓</span>
665
- ) : null}
666
- </div>
700
+ {dirty || saveState === "saving" ? (
701
+ <div className="mt-2 flex items-center gap-2">
702
+ <Button variant="secondary" size="sm" onPress={() => void save()} isDisabled={!dirty || saveState === "saving" || block.pending}>
703
+ {saveState === "saving" ? "Saving…" : "Save now"}
704
+ </Button>
705
+ <span className="text-caption text-fg-subtle">{saveState === "saving" ? "" : "Autosaving…"}</span>
706
+ </div>
707
+ ) : null}
667
708
  </>
668
709
  )}
669
710
  </div>
@@ -672,21 +713,91 @@ function BlockCard({ api, block, blockType, isFirst, isLast, onMove, onRemove, o
672
713
  );
673
714
  }
674
715
 
675
- // "+ Add block" reveals a compact picker of the region's allowed types (by friendly name),
676
- // replacing the always-on row of every type as a wall of buttons.
677
- function AddBlock({ allowed, btBySlug, onAdd }: { allowed: string[]; btBySlug: Map<string, BlockType>; onAdd: (slug: string) => void }) {
716
+ // Notion-style block inserter the page-canvas analogue of the BlockEditor's `/`
717
+ // palette. A slim "+ Add block" affordance expands into a searchable list of the
718
+ // region's allowed block types (type to filter; ↑/↓ + Enter to pick, Esc/blur to
719
+ // close). Insertion appends to the region; reorder via drag to position.
720
+ function Inserter({ allowed, btBySlug, onAdd, compact }: { allowed: string[]; btBySlug: Map<string, BlockType>; onAdd: (slug: string) => void; compact?: boolean }) {
678
721
  const [open, setOpen] = useState(false);
679
- if (!open) return <Button variant="secondary" size="sm" onPress={() => setOpen(true)}>+ Add block</Button>;
722
+ const [q, setQ] = useState("");
723
+ const [idx, setIdx] = useState(0);
724
+ const inputRef = useRef<HTMLInputElement>(null);
725
+
726
+ const items = useMemo(() => {
727
+ const list = allowed.map((slug) => ({ slug, name: btBySlug.get(slug)?.name ?? slug }));
728
+ const s = q.trim().toLowerCase();
729
+ return s ? list.filter((x) => x.name.toLowerCase().includes(s) || x.slug.toLowerCase().includes(s)) : list;
730
+ }, [allowed, btBySlug, q]);
731
+
732
+ useEffect(() => { if (open) inputRef.current?.focus(); }, [open]);
733
+ useEffect(() => { setIdx(0); }, [q]);
734
+
735
+ const close = () => { setOpen(false); setQ(""); };
736
+ const pick = (slug?: string) => { if (slug) onAdd(slug); close(); };
737
+
738
+ if (!open) {
739
+ // Compact: a thin between-blocks divider that reveals a centered "+" on hover.
740
+ if (compact) {
741
+ return (
742
+ <button
743
+ type="button"
744
+ onClick={() => setOpen(true)}
745
+ aria-label="Insert block here"
746
+ className="group/ins flex h-3 w-full items-center justify-center opacity-0 transition-opacity hover:opacity-100"
747
+ >
748
+ <span className="flex h-full w-full items-center">
749
+ <span className="h-px flex-1 bg-brand-green/40" />
750
+ <span className="mx-1 rounded bg-brand-green/15 px-1 text-caption leading-none text-brand-green">+</span>
751
+ <span className="h-px flex-1 bg-brand-green/40" />
752
+ </span>
753
+ </button>
754
+ );
755
+ }
756
+ return (
757
+ <button
758
+ type="button"
759
+ onClick={() => setOpen(true)}
760
+ className="flex w-full items-center gap-2 rounded-lg px-1 py-1.5 text-left text-small text-fg-subtle opacity-70 transition-colors hover:bg-surface-muted hover:text-fg-muted hover:opacity-100"
761
+ >
762
+ <span className="text-base leading-none">+</span>
763
+ <span>Add block</span>
764
+ </button>
765
+ );
766
+ }
680
767
  return (
681
- <div className="rounded-panel border border-border bg-surface-card p-2.5">
682
- <div className="mb-1.5 px-1 text-[11px] text-fg-subtle">Add a block</div>
683
- <div className="flex flex-wrap gap-1.5">
684
- {allowed.map((slug) => (
685
- <Button key={slug} variant="ghost" size="sm" onPress={() => { onAdd(slug); setOpen(false); }}>{btBySlug.get(slug)?.name ?? slug}</Button>
686
- ))}
687
- </div>
688
- <div className="mt-1.5 text-right">
689
- <Button variant="ghost" size="sm" className="text-fg-subtle" onPress={() => setOpen(false)}>cancel</Button>
768
+ <div className="rounded-lg border border-border bg-surface-card p-1 shadow-md">
769
+ <input
770
+ ref={inputRef}
771
+ value={q}
772
+ placeholder="Filter blocks…"
773
+ spellCheck={false}
774
+ className="mb-1 w-full rounded-md bg-surface px-2.5 py-1.5 text-small text-fg outline-none placeholder:text-fg-subtle"
775
+ onChange={(e) => setQ(e.target.value)}
776
+ onBlur={() => setTimeout(close, 120)}
777
+ onKeyDown={(e) => {
778
+ if (e.key === "Escape") close();
779
+ else if (e.key === "ArrowDown") { e.preventDefault(); setIdx((n) => (n + 1) % Math.max(items.length, 1)); }
780
+ else if (e.key === "ArrowUp") { e.preventDefault(); setIdx((n) => (n - 1 + Math.max(items.length, 1)) % Math.max(items.length, 1)); }
781
+ else if (e.key === "Enter") { e.preventDefault(); pick(items[idx]?.slug); }
782
+ }}
783
+ />
784
+ <div className="max-h-64 overflow-auto">
785
+ {items.length === 0 ? (
786
+ <div className="px-2.5 py-2 text-small text-fg-subtle">No matching block types</div>
787
+ ) : (
788
+ items.map((x, n) => (
789
+ <button
790
+ key={x.slug}
791
+ type="button"
792
+ onMouseEnter={() => setIdx(n)}
793
+ onMouseDown={(e) => e.preventDefault()}
794
+ onClick={() => pick(x.slug)}
795
+ className={`flex w-full items-center rounded-md px-2.5 py-1.5 text-left text-small ${n === idx ? "bg-surface-muted text-fg" : "text-fg-muted hover:bg-surface-muted"}`}
796
+ >
797
+ {x.name}
798
+ </button>
799
+ ))
800
+ )}
690
801
  </div>
691
802
  </div>
692
803
  );