@pramen/cms-editor 0.0.41 → 0.0.43
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 +2 -1
- package/dist/fonts/README.md +18 -0
- package/dist/fonts/nc-fontina-variable.woff2 +0 -0
- package/dist/fonts.css +45 -0
- package/dist/index.html +2 -2
- package/dist/main.2k0x7rgk.js +396 -0
- package/package.json +14 -5
- package/src/app.css +12 -3
- package/src/components.tsx +209 -90
- package/src/fields.tsx +40 -119
- package/src/routes/_layout.tsx +61 -25
- package/dist/main.0jsv1nns.js +0 -251
- package/dist/tokens.css +0 -68
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pramen/cms-editor",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.43",
|
|
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": {
|
|
@@ -20,17 +20,26 @@
|
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
22
|
"@buzola/router": "^0.0.12",
|
|
23
|
-
"@podoba/react": "^0.0.
|
|
24
|
-
"@podoba/tokens": "^0.0.
|
|
25
|
-
"@podoba/tailwind": "^0.0.
|
|
23
|
+
"@podoba/react": "^0.0.32",
|
|
24
|
+
"@podoba/tokens": "^0.0.32",
|
|
25
|
+
"@podoba/tailwind": "^0.0.32",
|
|
26
|
+
"@tiptap/react": "^3.27.4",
|
|
27
|
+
"@tiptap/pm": "^3.27.4",
|
|
28
|
+
"@tiptap/starter-kit": "^3.27.4",
|
|
29
|
+
"@tiptap/extension-placeholder": "^3.27.4",
|
|
30
|
+
"@tiptap/extension-highlight": "^3.27.4",
|
|
31
|
+
"@tiptap/extension-task-list": "^3.27.4",
|
|
32
|
+
"@tiptap/extension-task-item": "^3.27.4",
|
|
26
33
|
"react": "^19.0.0",
|
|
27
34
|
"react-dom": "^19.0.0"
|
|
28
35
|
},
|
|
29
36
|
"devDependencies": {
|
|
30
37
|
"@buzola/bun-plugin": "^0.0.12",
|
|
31
38
|
"@buzola/codegen": "^0.0.12",
|
|
39
|
+
"@tailwindcss/cli": "^4.0.0",
|
|
40
|
+
"@tailwindcss/typography": "^0.5.16",
|
|
32
41
|
"@types/react": "^19.0.0",
|
|
33
42
|
"@types/react-dom": "^19.0.0",
|
|
34
|
-
"tailwindcss": "^
|
|
43
|
+
"tailwindcss": "^4.0.0"
|
|
35
44
|
}
|
|
36
45
|
}
|
package/src/app.css
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
-
|
|
2
|
-
@tailwind
|
|
3
|
-
|
|
1
|
+
/* Design system = podoba (Tailwind v4, CSS-first). Order matters: Tailwind first,
|
|
2
|
+
* then @podoba/tokens' CSS variables, then @podoba/tailwind's `@theme inline` that
|
|
3
|
+
* binds those vars to utilities (bg-surface, text-fg-muted, rounded-panel, …). */
|
|
4
|
+
@import "tailwindcss";
|
|
5
|
+
@import "@podoba/tokens/variables.css";
|
|
6
|
+
@import "@podoba/tailwind";
|
|
7
|
+
|
|
8
|
+
/* v4 auto-detects this app's sources, but node_modules is ignored by default —
|
|
9
|
+
* @podoba/react ships TS source with class strings the compiler must scan, so name
|
|
10
|
+
* it explicitly (paths are relative to this file). */
|
|
11
|
+
@source "./**/*.{ts,tsx}";
|
|
12
|
+
@source "../node_modules/@podoba/react/src/**/*.{ts,tsx}";
|
package/src/components.tsx
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// route modules under `routes/` are thin adapters: they pull `api`/`me`/`setError` from
|
|
3
3
|
// the app context and wire URL params + navigation into these components.
|
|
4
4
|
|
|
5
|
-
import { Button, Input, Textarea } from "@podoba/react";
|
|
5
|
+
import { Button, Heading, Input, ModalDialog, ModalOverlay, ModalSurface, Textarea } from "@podoba/react";
|
|
6
6
|
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
|
7
7
|
import { Api, ApiError } from "./api";
|
|
8
8
|
import { FieldForm } from "./fields";
|
|
@@ -33,7 +33,7 @@ function Hero({ lead, em, children }: { lead: string; em: string; children?: Rea
|
|
|
33
33
|
function Cta({ text, em, children }: { text: string; em: string; children: ReactNode }) {
|
|
34
34
|
return (
|
|
35
35
|
<div className="flex min-w-[360px] items-center gap-4 rounded-full bg-brand-green py-3 pl-7 pr-3 max-[820px]:min-w-0">
|
|
36
|
-
<span className="text-
|
|
36
|
+
<span className="text-callout text-fg-on-brand">
|
|
37
37
|
{text} <span className="font-semibold">{em}</span>
|
|
38
38
|
</span>
|
|
39
39
|
{children}
|
|
@@ -46,32 +46,38 @@ function Section({ children }: { children: ReactNode }) {
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
function Pill({ status, children }: { status?: string; children: ReactNode }) {
|
|
49
|
+
// Token-only colors (no hardcoded hex) so status pills flip correctly under the
|
|
50
|
+
// podoba dark theme. `fg-on-brand` is the AA-safe ink on the fixed brand surfaces.
|
|
49
51
|
const tone =
|
|
50
52
|
status === "published" || status === "active"
|
|
51
|
-
? "bg-brand-green text-
|
|
53
|
+
? "bg-brand-green text-fg-on-brand border-transparent"
|
|
52
54
|
: status === "in_review" || status === "review"
|
|
53
|
-
? "bg-accent-yellow text-
|
|
55
|
+
? "bg-accent-yellow text-fg-on-brand border-transparent"
|
|
54
56
|
: status === "rejected" || status === "archived" || status === "inactive"
|
|
55
57
|
? "border-danger text-danger bg-surface-card"
|
|
56
58
|
: "border-border text-fg-muted bg-surface-card";
|
|
57
|
-
return <span className={`inline-block whitespace-nowrap rounded-full border px-2.5 py-0.5 text-
|
|
59
|
+
return <span className={`inline-block whitespace-nowrap rounded-full border px-2.5 py-0.5 text-caption font-medium ${tone}`}>{children}</span>;
|
|
58
60
|
}
|
|
59
61
|
|
|
62
|
+
// Modal chrome on podoba's shared overlay: focus trap, Esc-to-close, backdrop
|
|
63
|
+
// dismiss, the token blur-scrim and enter/exit animation — all for free. The caller
|
|
64
|
+
// API (conditionally mounted + `onClose`) is preserved, so call sites don't change.
|
|
60
65
|
function Modal({ onClose, wide, children }: { onClose: () => void; wide?: boolean; children: ReactNode }) {
|
|
61
66
|
return (
|
|
62
|
-
<
|
|
63
|
-
<
|
|
64
|
-
className=
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
{children}
|
|
68
|
-
</div>
|
|
69
|
-
</div>
|
|
67
|
+
<ModalOverlay isOpen isDismissable onOpenChange={(open) => !open && onClose()}>
|
|
68
|
+
<ModalSurface className={`w-full px-9 py-8 ${wide ? "max-w-[680px]" : "max-w-[520px]"}`}>
|
|
69
|
+
<ModalDialog className="max-h-[86vh] overflow-auto outline-none">{children}</ModalDialog>
|
|
70
|
+
</ModalSurface>
|
|
71
|
+
</ModalOverlay>
|
|
70
72
|
);
|
|
71
73
|
}
|
|
72
74
|
|
|
73
75
|
function ModalTitle({ children }: { children: ReactNode }) {
|
|
74
|
-
return
|
|
76
|
+
return (
|
|
77
|
+
<Heading level="1" className="mb-5 font-normal">
|
|
78
|
+
{children}
|
|
79
|
+
</Heading>
|
|
80
|
+
);
|
|
75
81
|
}
|
|
76
82
|
|
|
77
83
|
function KV({ children, className }: { children: ReactNode; className?: string }) {
|
|
@@ -79,12 +85,14 @@ function KV({ children, className }: { children: ReactNode; className?: string }
|
|
|
79
85
|
}
|
|
80
86
|
|
|
81
87
|
function Card({ children }: { children: ReactNode }) {
|
|
82
|
-
|
|
88
|
+
// rounded-lg is the unified podoba card radius (the 1.1rem soft-panel radius was retired).
|
|
89
|
+
return <div className="overflow-auto rounded-lg border border-border bg-surface-card p-6">{children}</div>;
|
|
83
90
|
}
|
|
84
91
|
|
|
85
92
|
function Banner({ ok, children }: { ok?: boolean; children: ReactNode }) {
|
|
93
|
+
// Token-only colors so the banner reads correctly in dark mode too.
|
|
86
94
|
return (
|
|
87
|
-
<div className={`my-2 rounded-lg border px-3.5 py-2.5 text-
|
|
95
|
+
<div className={`my-2 rounded-lg border px-3.5 py-2.5 text-small ${ok ? "border-brand-green bg-brand-green/20 text-fg" : "border-danger bg-surface-card text-danger"}`}>{children}</div>
|
|
88
96
|
);
|
|
89
97
|
}
|
|
90
98
|
|
|
@@ -386,11 +394,20 @@ export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange
|
|
|
386
394
|
const patchRegion = (region: string, fn: (list: RenderedBlock[]) => RenderedBlock[]) =>
|
|
387
395
|
setAssembled((prev) => (prev ? { ...prev, regions: { ...prev.regions, [region]: fn(prev.regions[region] ?? []) } } : prev));
|
|
388
396
|
|
|
389
|
-
|
|
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) => {
|
|
390
401
|
// Optimistic: show a placeholder immediately (no wait for the round trip), then
|
|
391
402
|
// reconcile with the persisted row addBlock echoes — or roll it back on failure.
|
|
392
403
|
const tempId = `tmp:${crypto.randomUUID()}`;
|
|
393
|
-
|
|
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
|
+
});
|
|
394
411
|
try {
|
|
395
412
|
const { block, placement } = await api.call<{
|
|
396
413
|
block: { id: string; title?: string | null; fields?: Record<string, unknown> | null };
|
|
@@ -405,6 +422,13 @@ export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange
|
|
|
405
422
|
is_shared: Boolean(placement.isShared),
|
|
406
423
|
};
|
|
407
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
|
+
}
|
|
408
432
|
} catch (e) {
|
|
409
433
|
patchRegion(region, (list) => list.filter((b) => b.id !== tempId));
|
|
410
434
|
setErr(errMsg(e));
|
|
@@ -473,37 +497,44 @@ export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange
|
|
|
473
497
|
</div>
|
|
474
498
|
</div>
|
|
475
499
|
|
|
476
|
-
|
|
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">
|
|
477
503
|
{err ? <Banner>{err}</Banner> : null}
|
|
478
504
|
{regions.map((r) => {
|
|
479
505
|
const blocks = assembled?.regions[r.name] ?? [];
|
|
480
506
|
const allowed = r.allowedTypes && r.allowedTypes.length ? r.allowedTypes : blockTypes.map((b) => b.slug);
|
|
481
507
|
return (
|
|
482
|
-
<div className="mb-
|
|
483
|
-
<
|
|
484
|
-
{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>
|
|
485
510
|
{blocks.map((b, i) => (
|
|
486
|
-
<
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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>
|
|
505
533
|
))}
|
|
506
|
-
|
|
534
|
+
{/* End inserter — appends. */}
|
|
535
|
+
<div className="mt-1">
|
|
536
|
+
<Inserter allowed={allowed} btBySlug={btBySlug} onAdd={(slug) => addBlock(r.name, slug)} />
|
|
537
|
+
</div>
|
|
507
538
|
</div>
|
|
508
539
|
);
|
|
509
540
|
})}
|
|
@@ -573,7 +604,9 @@ function BlockCard({ api, block, blockType, isFirst, isLast, onMove, onRemove, o
|
|
|
573
604
|
|
|
574
605
|
const dirty = fields != null && JSON.stringify(fields) !== saved.current;
|
|
575
606
|
|
|
576
|
-
//
|
|
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.
|
|
577
610
|
const save = useCallback(async () => {
|
|
578
611
|
if (block.pending || fields == null) return;
|
|
579
612
|
const snapshot = JSON.stringify(fields);
|
|
@@ -591,6 +624,20 @@ function BlockCard({ api, block, blockType, isFirst, isLast, onMove, onRemove, o
|
|
|
591
624
|
}
|
|
592
625
|
}, [api, blockId, placementId, fields, onPatch, onError, block.pending]);
|
|
593
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
|
+
|
|
594
641
|
const change = (next: Record<string, unknown>) => setFields(next);
|
|
595
642
|
|
|
596
643
|
// Report dirty state up (for the editor's leave/unload guard); clear it on unmount so a
|
|
@@ -602,60 +649,62 @@ function BlockCard({ api, block, blockType, isFirst, isLast, onMove, onRemove, o
|
|
|
602
649
|
const name = blockType?.name ?? block.block_type;
|
|
603
650
|
|
|
604
651
|
return (
|
|
605
|
-
//
|
|
606
|
-
//
|
|
607
|
-
//
|
|
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.
|
|
608
655
|
<div
|
|
609
656
|
ref={cardRef}
|
|
610
|
-
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" : ""}`}
|
|
611
658
|
onDragOver={(e) => { e.preventDefault(); onDragOverBlock(); }}
|
|
612
659
|
onDrop={(e) => { e.preventDefault(); onDropBlock(); }}
|
|
613
660
|
>
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
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">
|
|
627
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>
|
|
628
|
-
<span className="font-medium text-fg">{name}</span>
|
|
629
|
-
{block.is_shared ? <span className="text-
|
|
630
|
-
<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>
|
|
631
681
|
<span className="flex-1" />
|
|
632
682
|
<Button variant="ghost" size="sm" isDisabled={isFirst} onPress={() => onMove(-1)}>↑</Button>
|
|
633
683
|
<Button variant="ghost" size="sm" isDisabled={isLast} onPress={() => onMove(1)}>↓</Button>
|
|
634
684
|
<Button variant="ghost" size="sm" className="text-danger" onPress={onRemove}>✕</Button>
|
|
635
685
|
</div>
|
|
686
|
+
|
|
636
687
|
{collapsed ? (
|
|
637
|
-
<div className="cursor-pointer truncate
|
|
688
|
+
<div className="cursor-pointer truncate pb-1 pl-6 text-small text-fg-subtle" onClick={() => setCollapsed(false)}>
|
|
638
689
|
{fields == null ? "…" : blockPreview(fields) || <span className="italic">empty</span>}
|
|
639
690
|
</div>
|
|
640
691
|
) : (
|
|
641
|
-
<div className="
|
|
692
|
+
<div className="pb-1 pl-6">
|
|
642
693
|
{fields == null ? (
|
|
643
|
-
<p className="text-
|
|
694
|
+
<p className="text-small text-fg-subtle">loading…</p>
|
|
644
695
|
) : schema.length === 0 ? (
|
|
645
|
-
<p className="text-
|
|
696
|
+
<p className="text-small text-fg-subtle">This block has no editable fields.</p>
|
|
646
697
|
) : (
|
|
647
698
|
<>
|
|
648
699
|
<FieldForm schema={schema} value={fields} onChange={change} api={api} />
|
|
649
|
-
|
|
650
|
-
<
|
|
651
|
-
{saveState === "saving"
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
<span className="text-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
) : null}
|
|
658
|
-
</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}
|
|
659
708
|
</>
|
|
660
709
|
)}
|
|
661
710
|
</div>
|
|
@@ -664,21 +713,91 @@ function BlockCard({ api, block, blockType, isFirst, isLast, onMove, onRemove, o
|
|
|
664
713
|
);
|
|
665
714
|
}
|
|
666
715
|
|
|
667
|
-
//
|
|
668
|
-
//
|
|
669
|
-
|
|
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 }) {
|
|
670
721
|
const [open, setOpen] = useState(false);
|
|
671
|
-
|
|
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
|
+
}
|
|
672
767
|
return (
|
|
673
|
-
<div className="rounded-
|
|
674
|
-
<
|
|
675
|
-
|
|
676
|
-
{
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
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
|
+
)}
|
|
682
801
|
</div>
|
|
683
802
|
</div>
|
|
684
803
|
);
|