@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/dist/app.css +1 -1
- package/dist/index.html +1 -1
- package/dist/main.2k0x7rgk.js +396 -0
- package/package.json +1 -1
- package/src/components.tsx +185 -74
- package/dist/main.xkz96gh2.js +0 -396
package/package.json
CHANGED
package/src/components.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
491
|
-
<
|
|
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
|
-
<
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
614
|
-
//
|
|
615
|
-
//
|
|
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={`
|
|
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
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
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-
|
|
638
|
-
<span className={`text-
|
|
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
|
|
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="
|
|
692
|
+
<div className="pb-1 pl-6">
|
|
650
693
|
{fields == null ? (
|
|
651
|
-
<p className="text-
|
|
694
|
+
<p className="text-small text-fg-subtle">loading…</p>
|
|
652
695
|
) : schema.length === 0 ? (
|
|
653
|
-
<p className="text-
|
|
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
|
-
|
|
658
|
-
<
|
|
659
|
-
{saveState === "saving"
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
<span className="text-
|
|
663
|
-
|
|
664
|
-
|
|
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
|
-
//
|
|
676
|
-
//
|
|
677
|
-
|
|
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
|
-
|
|
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-
|
|
682
|
-
<
|
|
683
|
-
|
|
684
|
-
{
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
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
|
);
|