@drawnagency/primitives 0.1.76 → 0.1.78

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/dist/{chunk-Q3T23HVW.js → chunk-2DDWWTRX.js} +5 -5
  2. package/dist/chunk-2G2S36SY.js +13 -0
  3. package/dist/{chunk-S55OZ7F2.js → chunk-3LNKOLZP.js} +4 -0
  4. package/dist/{chunk-SWIHUIFT.js → chunk-4SIWBWKZ.js} +12 -2
  5. package/dist/{chunk-SWEVWC5K.js → chunk-BAQYZSFF.js} +22 -22
  6. package/dist/{chunk-IFHRHV7S.js → chunk-EMGLZQCM.js} +1 -1
  7. package/dist/{chunk-NKVZGPR6.js → chunk-K4JD6FD3.js} +1 -1
  8. package/dist/{chunk-SFKYUKVN.js → chunk-MK4POMLZ.js} +1 -1
  9. package/dist/{chunk-7T2WQSWF.js → chunk-NS3UTJ3E.js} +11 -11
  10. package/dist/chunk-REM2NH7K.js +44 -0
  11. package/dist/{chunk-T7MP7ANP.js → chunk-UA6OCJJK.js} +2 -2
  12. package/dist/{chunk-ZRWGV5O6.js → chunk-Z5IYWUW3.js} +3 -3
  13. package/dist/collab/provider-registry.d.ts +4 -0
  14. package/dist/collab/provider-registry.d.ts.map +1 -0
  15. package/dist/collab/provider-registry.js +9 -0
  16. package/dist/collab/rebase.d.ts +37 -0
  17. package/dist/collab/rebase.d.ts.map +1 -0
  18. package/dist/collab/rebase.js +64 -0
  19. package/dist/collab/recovery.d.ts +89 -0
  20. package/dist/collab/recovery.d.ts.map +1 -0
  21. package/dist/collab/types.d.ts +93 -0
  22. package/dist/collab/types.d.ts.map +1 -0
  23. package/dist/collab/types.js +19 -0
  24. package/dist/components/editor/AudienceIndicator.d.ts +3 -1
  25. package/dist/components/editor/AudienceIndicator.d.ts.map +1 -1
  26. package/dist/components/editor/ConflictDialog.d.ts +14 -0
  27. package/dist/components/editor/ConflictDialog.d.ts.map +1 -0
  28. package/dist/components/editor/IndicatorPill.d.ts +4 -1
  29. package/dist/components/editor/IndicatorPill.d.ts.map +1 -1
  30. package/dist/components/editor/LockBadge.d.ts +7 -0
  31. package/dist/components/editor/LockBadge.d.ts.map +1 -0
  32. package/dist/components/editor/PresenceAvatars.d.ts +9 -0
  33. package/dist/components/editor/PresenceAvatars.d.ts.map +1 -0
  34. package/dist/components/editor/SectionWrapper.d.ts +1 -1
  35. package/dist/components/editor/SectionWrapper.d.ts.map +1 -1
  36. package/dist/components/editor/StatusIndicator.d.ts +3 -1
  37. package/dist/components/editor/StatusIndicator.d.ts.map +1 -1
  38. package/dist/components/editor/index.js +95 -47
  39. package/dist/components/primitives/index.js +5 -5
  40. package/dist/components/sections/brand-guide.js +5 -5
  41. package/dist/components/sections/register-schemas.js +11 -11
  42. package/dist/components/sections/register.js +11 -11
  43. package/dist/components/shell/EditorShell.d.ts +3 -1
  44. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  45. package/dist/components/shell/MediaBreadcrumb.d.ts +10 -0
  46. package/dist/components/shell/MediaBreadcrumb.d.ts.map +1 -0
  47. package/dist/components/shell/MediaFolderTile.d.ts +15 -0
  48. package/dist/components/shell/MediaFolderTile.d.ts.map +1 -0
  49. package/dist/components/shell/MediaFolderTree.d.ts +20 -0
  50. package/dist/components/shell/MediaFolderTree.d.ts.map +1 -0
  51. package/dist/components/shell/MediaLibraryModal.d.ts +15 -3
  52. package/dist/components/shell/MediaLibraryModal.d.ts.map +1 -1
  53. package/dist/components/shell/MoveToFolderDialog.d.ts +16 -0
  54. package/dist/components/shell/MoveToFolderDialog.d.ts.map +1 -0
  55. package/dist/hooks/useCollabSession.d.ts +23 -0
  56. package/dist/hooks/useCollabSession.d.ts.map +1 -0
  57. package/dist/hooks/useEditorPersistence.d.ts +21 -1
  58. package/dist/hooks/useEditorPersistence.d.ts.map +1 -1
  59. package/dist/hooks/useEditorPublish.d.ts +39 -2
  60. package/dist/hooks/useEditorPublish.d.ts.map +1 -1
  61. package/dist/hooks/useMediaFolderDnd.d.ts +27 -0
  62. package/dist/hooks/useMediaFolderDnd.d.ts.map +1 -0
  63. package/dist/hooks/useMediaPipeline.d.ts +14 -2
  64. package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
  65. package/dist/index.d.ts +2 -0
  66. package/dist/index.d.ts.map +1 -1
  67. package/dist/index.js +51 -25
  68. package/dist/lib/dexie.d.ts +12 -0
  69. package/dist/lib/dexie.d.ts.map +1 -1
  70. package/dist/lib/dexie.js +27 -0
  71. package/dist/lib/index.js +12 -12
  72. package/dist/lib/media-dnd.d.ts +26 -0
  73. package/dist/lib/media-dnd.d.ts.map +1 -0
  74. package/dist/lib/media-folders.d.ts +59 -0
  75. package/dist/lib/media-folders.d.ts.map +1 -0
  76. package/dist/lib/registry.d.ts +10 -0
  77. package/dist/lib/registry.d.ts.map +1 -1
  78. package/dist/media/folder-drop.d.ts +22 -0
  79. package/dist/media/folder-drop.d.ts.map +1 -0
  80. package/dist/media/index.js +6 -2
  81. package/dist/media/types.d.ts +1 -1
  82. package/dist/media/types.d.ts.map +1 -1
  83. package/dist/media/utils.d.ts +7 -0
  84. package/dist/media/utils.d.ts.map +1 -1
  85. package/dist/schemas/index.js +5 -3
  86. package/dist/schemas/media.d.ts +11 -0
  87. package/dist/schemas/media.d.ts.map +1 -1
  88. package/dist/types/database.d.ts +70 -0
  89. package/dist/types/database.d.ts.map +1 -1
  90. package/package.json +13 -1
  91. package/src/collab/provider-registry.ts +14 -0
  92. package/src/collab/rebase.ts +140 -0
  93. package/src/collab/recovery.ts +149 -0
  94. package/src/collab/types.ts +96 -0
  95. package/src/components/editor/AudienceIndicator.tsx +4 -1
  96. package/src/components/editor/ConflictDialog.tsx +61 -0
  97. package/src/components/editor/IndicatorPill.tsx +12 -6
  98. package/src/components/editor/LockBadge.tsx +31 -0
  99. package/src/components/editor/PresenceAvatars.tsx +53 -0
  100. package/src/components/editor/SectionWrapper.tsx +60 -29
  101. package/src/components/editor/StatusIndicator.tsx +4 -0
  102. package/src/components/shell/EditorShell.tsx +581 -22
  103. package/src/components/shell/MediaBreadcrumb.tsx +77 -0
  104. package/src/components/shell/MediaFolderTile.tsx +141 -0
  105. package/src/components/shell/MediaFolderTree.tsx +127 -0
  106. package/src/components/shell/MediaLibraryModal.tsx +611 -229
  107. package/src/components/shell/MoveToFolderDialog.tsx +78 -0
  108. package/src/hooks/useCollabSession.ts +220 -0
  109. package/src/hooks/useEditorPersistence.ts +104 -1
  110. package/src/hooks/useEditorPublish.ts +216 -71
  111. package/src/hooks/useMediaFolderDnd.ts +129 -0
  112. package/src/hooks/useMediaPipeline.ts +162 -6
  113. package/src/index.ts +2 -0
  114. package/src/lib/dexie.ts +44 -0
  115. package/src/lib/media-dnd.ts +62 -0
  116. package/src/lib/media-folders.ts +232 -0
  117. package/src/lib/registry.ts +8 -0
  118. package/src/media/folder-drop.ts +62 -0
  119. package/src/media/types.ts +2 -0
  120. package/src/media/utils.ts +10 -0
  121. package/src/schemas/media.ts +13 -0
  122. package/src/types/database.ts +55 -0
@@ -0,0 +1,78 @@
1
+ import { useState } from "react";
2
+ import { Folder, LayoutGrid } from "lucide-react";
3
+ import { cn } from "../../lib/cn";
4
+ import { Button } from "../shared/Button";
5
+ import type { MediaFolder } from "../../media/types";
6
+ import { buildFolderTree, type FolderTreeNode } from "../../lib/media-folders";
7
+
8
+ export interface MoveToFolderDialogProps {
9
+ folders: Record<string, MediaFolder>;
10
+ /** Targets to disable (moving a folder: its own descendant set). */
11
+ disabledIds?: Set<string>;
12
+ /**
13
+ * Target that is a no-op (the items' current folder), disabled too.
14
+ * Omit (undefined) when there is no single no-op target — e.g. a
15
+ * heterogeneous selection — and nothing is disabled on that account.
16
+ */
17
+ currentTarget?: string | null;
18
+ onMove: (targetFolderId: string | null) => void;
19
+ onClose: () => void;
20
+ }
21
+
22
+ /** Sentinel for "no pick yet" — distinct from picking root (null). */
23
+ const UNPICKED = Symbol("unpicked");
24
+
25
+ export function MoveToFolderDialog({
26
+ folders, disabledIds, currentTarget, onMove, onClose,
27
+ }: MoveToFolderDialogProps) {
28
+ const [picked, setPicked] = useState<string | null | typeof UNPICKED>(UNPICKED);
29
+ const tree = buildFolderTree(folders);
30
+
31
+ const row = (target: string | null, label: string, depth: number, icon: React.ReactNode) => {
32
+ const disabled = target === currentTarget || (target !== null && disabledIds?.has(target) === true);
33
+ const active = picked !== UNPICKED && picked === target;
34
+ return (
35
+ <button
36
+ key={target ?? "__root__"}
37
+ type="button"
38
+ disabled={disabled}
39
+ onClick={() => setPicked(target)}
40
+ className={cn(
41
+ "flex w-full items-center gap-1.5 rounded px-2 py-1.5 text-left text-sm",
42
+ disabled ? "cursor-not-allowed text-base-contrast-light/50" : "cursor-pointer hover:bg-base-accent",
43
+ active && "bg-primary/10 font-medium text-primary",
44
+ )}
45
+ style={{ paddingLeft: `${8 + depth * 14}px` }}
46
+ >
47
+ {icon}
48
+ <span className="min-w-0 flex-1 truncate">{label}</span>
49
+ </button>
50
+ );
51
+ };
52
+
53
+ const renderNodes = (nodes: FolderTreeNode[], depth: number): React.ReactNode[] =>
54
+ nodes.flatMap((node) => [
55
+ row(node.id, node.name, depth, <Folder size={14} className="shrink-0 text-base-contrast-light" />),
56
+ ...renderNodes(node.children, depth + 1),
57
+ ]);
58
+
59
+ return (
60
+ <div className="flex flex-col gap-3">
61
+ <div className="max-h-64 overflow-y-auto rounded-lg border border-base-200 p-1.5">
62
+ {row(null, "All media", 0, <LayoutGrid size={14} className="shrink-0 text-base-contrast-light" />)}
63
+ {renderNodes(tree, 0)}
64
+ </div>
65
+ <div className="flex justify-end gap-2">
66
+ <Button variant="secondary" size="sm" onClick={onClose}>Cancel</Button>
67
+ <Button
68
+ variant="primary"
69
+ size="sm"
70
+ disabled={picked === UNPICKED}
71
+ onClick={() => { if (picked !== UNPICKED) { onMove(picked); onClose(); } }}
72
+ >
73
+ Move
74
+ </Button>
75
+ </div>
76
+ </div>
77
+ );
78
+ }
@@ -0,0 +1,220 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import type {
3
+ CollabBootstrap, CollabPeer, CollabProvider, CollabSession, CollabStatus,
4
+ IndexUpdateMessage, SavedMessage, SectionUpdateMessage, TakeoverMessage,
5
+ } from "../collab/types";
6
+
7
+ const REJOIN_DELAYS_MS = [5_000, 15_000, 30_000, 60_000];
8
+
9
+ export interface UseCollabSessionArgs {
10
+ provider: CollabProvider | null;
11
+ bootstrap: CollabBootstrap | null;
12
+ onRemoteSectionUpdate: (msg: SectionUpdateMessage) => void;
13
+ onRemoteIndexUpdate: (msg: IndexUpdateMessage) => void;
14
+ onRemoteSaved: (msg: SavedMessage) => void;
15
+ onTakenOver: (msg: TakeoverMessage) => void;
16
+ }
17
+
18
+ export interface UseCollabSessionResult {
19
+ status: CollabStatus;
20
+ peers: CollabPeer[];
21
+ locks: Map<string, CollabPeer>;
22
+ heldByMe: Set<string>;
23
+ requestEdit(sectionId: string): Promise<boolean>;
24
+ takeover(sectionId: string): Promise<boolean>;
25
+ setFocus(sectionId: string | null): void;
26
+ releaseAfterSave(sectionIds: string[]): void;
27
+ broadcastSectionUpdate(sectionId: string, content: unknown): void;
28
+ broadcastIndexUpdate(index: unknown): void;
29
+ }
30
+
31
+ export function useCollabSession(args: UseCollabSessionArgs): UseCollabSessionResult {
32
+ const { provider, bootstrap } = args;
33
+ const [status, setStatus] = useState<CollabStatus>(provider && bootstrap ? "connecting" : "offline");
34
+ const [peers, setPeers] = useState<CollabPeer[]>([]);
35
+ const [heldByMe, setHeldByMe] = useState<Set<string>>(new Set());
36
+ const sessionRef = useRef<CollabSession | null>(null);
37
+ const heldRef = useRef<Set<string>>(new Set());
38
+ useEffect(() => { heldRef.current = heldByMe; }, [heldByMe]);
39
+
40
+ // Stale-closure-proof callback refs: the channel subscription outlives renders.
41
+ const cbRef = useRef(args);
42
+ useEffect(() => { cbRef.current = args; });
43
+
44
+ // The connect effect keys on bootstrap's scalar identity fields (see the
45
+ // dependency-array note below), so its closure freezes the bootstrap object
46
+ // captured at mount. Rejoins must read the freshest object through this ref:
47
+ // a provider can rotate credentials riding along on the bootstrap blob
48
+ // without changing the identity fields, and reconnecting with the original
49
+ // (expired) blob would fail forever.
50
+ const bootstrapRef = useRef(bootstrap);
51
+ useEffect(() => { bootstrapRef.current = bootstrap; });
52
+
53
+ useEffect(() => {
54
+ if (!provider || !bootstrap) return;
55
+ let cancelled = false;
56
+ let attempt = 0;
57
+ let timer: ReturnType<typeof setTimeout> | null = null;
58
+ let joining = false;
59
+
60
+ async function join() {
61
+ // `joining` guards against concurrent joins: if a rejoin timer fires
62
+ // while another join is still awaiting connect, doing nothing is
63
+ // correct — the in-flight join will either succeed or schedule its own
64
+ // rejoin when it settles.
65
+ if (cancelled || joining) return;
66
+ const bs = bootstrapRef.current;
67
+ if (!bs) return;
68
+ joining = true;
69
+ setStatus("connecting");
70
+ let session: CollabSession | null = null;
71
+ try {
72
+ session = await provider!.connect(bs, {
73
+ onPeers: (p) => { if (!cancelled) setPeers(p); },
74
+ onSectionUpdate: (m) => { if (!cancelled) cbRef.current.onRemoteSectionUpdate(m); },
75
+ onIndexUpdate: (m) => { if (!cancelled) cbRef.current.onRemoteIndexUpdate(m); },
76
+ onSaved: (m) => { if (!cancelled) cbRef.current.onRemoteSaved(m); },
77
+ onTakeover: (m) => {
78
+ if (cancelled) return;
79
+ setHeldByMe((prev) => {
80
+ const next = new Set(prev);
81
+ next.delete(m.sectionId);
82
+ return next;
83
+ });
84
+ cbRef.current.onTakenOver(m);
85
+ },
86
+ onStatus: (s) => {
87
+ if (cancelled) return;
88
+ setStatus(s);
89
+ if (s === "offline") scheduleRejoin();
90
+ },
91
+ });
92
+ } catch (err) {
93
+ console.warn("Collab connect failed:", err);
94
+ }
95
+ joining = false;
96
+ if (cancelled) { void session?.disconnect().catch(() => {}); return; }
97
+ if (!session) { scheduleRejoin(); return; }
98
+ // A rejoin scheduled while this join was in flight (e.g. a spurious
99
+ // offline event during connect) is now moot — drop it.
100
+ if (timer) { clearTimeout(timer); timer = null; }
101
+ // Clear per-session lock state before installing the new session:
102
+ // scheduleRejoin's disconnect() released the old session's DB rows and
103
+ // presence, so any heldByMe carried over is a phantom hold. Without this,
104
+ // requestEdit would early-return true (heldRef hit) and never re-acquire
105
+ // the lock on the fresh session — broadcasting for sections we no longer
106
+ // hold. The shell's edit-intent path re-acquires naturally on next focus.
107
+ // Also drop any stale in-flight acquire dedup entries bound to the dead
108
+ // session.
109
+ setHeldByMe((prev) => (prev.size === 0 ? prev : new Set()));
110
+ heldRef.current = new Set();
111
+ pendingAcquiresRef.current.clear();
112
+ sessionRef.current = session;
113
+ attempt = 0;
114
+ setStatus("connected");
115
+ }
116
+
117
+ function scheduleRejoin() {
118
+ if (cancelled) return;
119
+ // Tear down the old session before rejoining: transient provider errors
120
+ // (e.g. Supabase CHANNEL_ERROR / TIMED_OUT) can emit "offline" without
121
+ // the underlying channel actually closing. Leaving that zombie
122
+ // subscription live would double presence — and double locks — once the
123
+ // rejoin lands a second session for the same user.
124
+ const old = sessionRef.current;
125
+ sessionRef.current = null;
126
+ if (old) void old.disconnect().catch(() => {});
127
+ setStatus("offline");
128
+ // Repeated offline events must not stack timers: two live timers would
129
+ // race two concurrent joins into two sessions, and the orphaned timer
130
+ // would survive unmount (the cleanup only clears the tracked one).
131
+ if (timer) clearTimeout(timer);
132
+ const delay = REJOIN_DELAYS_MS[Math.min(attempt, REJOIN_DELAYS_MS.length - 1)];
133
+ attempt += 1;
134
+ timer = setTimeout(() => { timer = null; void join(); }, delay);
135
+ }
136
+
137
+ void join();
138
+ return () => {
139
+ cancelled = true;
140
+ if (timer) clearTimeout(timer);
141
+ void sessionRef.current?.disconnect().catch(() => {});
142
+ sessionRef.current = null;
143
+ };
144
+ // Depend on bootstrap's identifying scalar fields rather than the object
145
+ // reference: callers commonly pass a freshly-literal bootstrap object on
146
+ // every render (e.g. derived from props/state), which would otherwise
147
+ // make this effect re-run — and thus reconnect — on every render even
148
+ // though the connection identity hasn't changed.
149
+ // eslint-disable-next-line react-hooks/exhaustive-deps
150
+ }, [provider, bootstrap?.siteId, bootstrap?.userId, bootstrap?.email]);
151
+
152
+ const selfId = bootstrap?.userId ?? null;
153
+ const locks = useMemo(() => {
154
+ const map = new Map<string, CollabPeer>();
155
+ for (const peer of peers) {
156
+ if (peer.userId === selfId) continue;
157
+ for (const id of peer.heldSectionIds) map.set(id, peer);
158
+ }
159
+ return map;
160
+ }, [peers, selfId]);
161
+
162
+ // In-flight acquireLock RPCs by sectionId — two rapid requestEdit calls for
163
+ // the same section share one RPC instead of firing two.
164
+ const pendingAcquiresRef = useRef(new Map<string, Promise<boolean>>());
165
+
166
+ const requestEdit = useCallback(async (sectionId: string): Promise<boolean> => {
167
+ const session = sessionRef.current;
168
+ if (!session) return true; // solo mode
169
+ if (heldRef.current.has(sectionId)) return true;
170
+ // `locks` can lag live presence by one emission; a stale entry only ever
171
+ // false-denies here (by design) — the acquireLock RPC below stays the
172
+ // authoritative safety net, so a stale view never falsely grants.
173
+ if (locks.has(sectionId)) return false;
174
+ const pending = pendingAcquiresRef.current.get(sectionId);
175
+ if (pending) return pending;
176
+ const attempt = (async () => {
177
+ try {
178
+ const ok = await session.acquireLock(sectionId);
179
+ if (ok) setHeldByMe((prev) => new Set(prev).add(sectionId));
180
+ return ok;
181
+ } finally {
182
+ pendingAcquiresRef.current.delete(sectionId);
183
+ }
184
+ })();
185
+ pendingAcquiresRef.current.set(sectionId, attempt);
186
+ return attempt;
187
+ }, [locks]);
188
+
189
+ const takeover = useCallback(async (sectionId: string): Promise<boolean> => {
190
+ const session = sessionRef.current;
191
+ if (!session) return true;
192
+ const ok = await session.acquireLock(sectionId, { steal: true });
193
+ if (ok) setHeldByMe((prev) => new Set(prev).add(sectionId));
194
+ return ok;
195
+ }, []);
196
+
197
+ const releaseAfterSave = useCallback((sectionIds: string[]) => {
198
+ if (sectionIds.length === 0) return;
199
+ setHeldByMe((prev) => {
200
+ const next = new Set(prev);
201
+ for (const id of sectionIds) next.delete(id);
202
+ return next;
203
+ });
204
+ void sessionRef.current?.releaseLocks(sectionIds);
205
+ }, []);
206
+
207
+ const setFocus = useCallback((sectionId: string | null) => {
208
+ sessionRef.current?.setFocus(sectionId);
209
+ }, []);
210
+
211
+ const broadcastSectionUpdate = useCallback((sectionId: string, content: unknown) => {
212
+ sessionRef.current?.broadcastSectionUpdate(sectionId, content);
213
+ }, []);
214
+
215
+ const broadcastIndexUpdate = useCallback((index: unknown) => {
216
+ sessionRef.current?.broadcastIndexUpdate(index);
217
+ }, []);
218
+
219
+ return { status, peers, locks, heldByMe, requestEdit, takeover, setFocus, releaseAfterSave, broadcastSectionUpdate, broadcastIndexUpdate };
220
+ }
@@ -7,15 +7,51 @@ interface PersistenceState {
7
7
  pendingSections: Map<string, SectionContent>;
8
8
  indexDirty: boolean;
9
9
  configDirty: boolean;
10
+ // Durable "unsaved index structure exists" signal. Unlike indexDirty — which is
11
+ // flush-PENDING state and self-clears ~500ms after an edit — this mirrors the
12
+ // lifetime of the Dexie siteIndex row: set on every structural edit, NOT
13
+ // cleared by the flush, cleared only when the shell says the structure was
14
+ // committed or discarded. The collaboration layer reads it as "this editor has
15
+ // structural work a remote index must not clobber".
16
+ unsavedIndex: boolean;
17
+ // Monotonic edit-generation counter, bumped on every markSectionDirty /
18
+ // markIndexDirty / removeSection. A save captures the generation at save
19
+ // begin (its "token"); the post-save clears are token-guarded so edits made
20
+ // WHILE the save was in flight (generation > token — the save never carried
21
+ // them) survive every clear. This mirrors discardSavedChanges' updatedAt
22
+ // compare-and-clear semantic for the Dexie rows, but for the in-memory state.
23
+ generation: number;
24
+ // Generation of the most recent structural (index) edit.
25
+ indexGeneration: number;
26
+ // Per-section generation of the most recent content edit.
27
+ sectionGenerations: Map<string, number>;
10
28
  }
11
29
 
12
- export function useEditorPersistence(siteIndexRef: React.RefObject<SiteIndex>) {
30
+ interface PersistenceOptions {
31
+ /** Broadcast piggyback: called after a successful debounced flush with exactly
32
+ * what was persisted — the flushed section entries and whether the index was
33
+ * part of this flush. The collaboration layer forwards held-lock section
34
+ * updates + index updates to peers from here. Stored in a ref so its identity
35
+ * can churn without recreating the debounce machinery. */
36
+ onFlush?: (entries: { sectionId: string; content: SectionContent }[], indexFlushed: boolean) => void;
37
+ }
38
+
39
+ export function useEditorPersistence(
40
+ siteIndexRef: React.RefObject<SiteIndex>,
41
+ options?: PersistenceOptions,
42
+ ) {
13
43
  const state = useRef<PersistenceState>({
14
44
  pendingSections: new Map(),
15
45
  indexDirty: false,
16
46
  configDirty: false,
47
+ unsavedIndex: false,
48
+ generation: 0,
49
+ indexGeneration: 0,
50
+ sectionGenerations: new Map(),
17
51
  });
18
52
  const flushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
53
+ const onFlushRef = useRef(options?.onFlush);
54
+ useEffect(() => { onFlushRef.current = options?.onFlush; });
19
55
 
20
56
  const flushToDexie = useCallback(async () => {
21
57
  const s = state.current;
@@ -36,6 +72,9 @@ export function useEditorPersistence(siteIndexRef: React.RefObject<SiteIndex>) {
36
72
  entries,
37
73
  wasIndexDirty ? siteIndexRef.current : undefined,
38
74
  );
75
+ // Fire only after a successful persist — broadcasting an edit that failed
76
+ // to persist would let peers diverge from what this editor can recover.
77
+ onFlushRef.current?.(entries, wasIndexDirty);
39
78
  } catch (err) {
40
79
  // Restore: merge back any entries that weren't re-dirtied during the await
41
80
  for (const { sectionId, content } of entries) {
@@ -89,6 +128,7 @@ export function useEditorPersistence(siteIndexRef: React.RefObject<SiteIndex>) {
89
128
  const markSectionDirty = useCallback(
90
129
  (sectionId: string, content: SectionContent) => {
91
130
  state.current.pendingSections.set(sectionId, content);
131
+ state.current.sectionGenerations.set(sectionId, ++state.current.generation);
92
132
  scheduleFlush();
93
133
  },
94
134
  [scheduleFlush],
@@ -96,6 +136,8 @@ export function useEditorPersistence(siteIndexRef: React.RefObject<SiteIndex>) {
96
136
 
97
137
  const markIndexDirty = useCallback(() => {
98
138
  state.current.indexDirty = true;
139
+ state.current.unsavedIndex = true;
140
+ state.current.indexGeneration = ++state.current.generation;
99
141
  scheduleFlush();
100
142
  }, [scheduleFlush]);
101
143
 
@@ -106,12 +148,32 @@ export function useEditorPersistence(siteIndexRef: React.RefObject<SiteIndex>) {
106
148
  const removeSection = useCallback((sectionId: string) => {
107
149
  state.current.pendingSections.delete(sectionId);
108
150
  state.current.indexDirty = true;
151
+ state.current.unsavedIndex = true;
152
+ state.current.indexGeneration = ++state.current.generation;
153
+ state.current.sectionGenerations.delete(sectionId);
109
154
  deleteSectionRows([sectionId]).catch((err) => {
110
155
  console.error("Failed to delete section row from Dexie:", err);
111
156
  });
112
157
  scheduleFlush();
113
158
  }, [scheduleFlush]);
114
159
 
160
+ // Drop a pending in-memory edit WITHOUT touching the index or the Dexie row.
161
+ // Used when another editor takes over a section: the victim's un-flushed
162
+ // keystroke must not re-persist on the next flush (removeSection is wrong for
163
+ // this — it also dirties the index, which would broadcast/persist a structural
164
+ // "change" that never happened). The caller deletes the Dexie row separately.
165
+ const dropPendingSection = useCallback((sectionId: string) => {
166
+ state.current.pendingSections.delete(sectionId);
167
+ // The edit is discarded entirely — its generation record goes with it.
168
+ state.current.sectionGenerations.delete(sectionId);
169
+ }, []);
170
+
171
+ // Whether a section has an un-flushed in-memory edit (pending Dexie write).
172
+ const hasPendingSection = useCallback(
173
+ (sectionId: string) => state.current.pendingSections.has(sectionId),
174
+ [],
175
+ );
176
+
115
177
  const persistConfig = useCallback(async (config: SiteConfig) => {
116
178
  state.current.configDirty = true;
117
179
  await persistSiteConfig(config);
@@ -126,15 +188,56 @@ export function useEditorPersistence(siteIndexRef: React.RefObject<SiteIndex>) {
126
188
 
127
189
  const isConfigDirty = useCallback(() => state.current.configDirty, []);
128
190
  const clearConfigDirty = useCallback(() => { state.current.configDirty = false; }, []);
191
+ // Reflects whether an index change is pending flush to Dexie. NOTE: this is
192
+ // flush-window state only (clears ~500ms after the edit) — collaboration
193
+ // guards must use hasUnsavedIndex() instead, which tracks unsaved structure
194
+ // durably until save/discard.
195
+ const isIndexDirty = useCallback(() => state.current.indexDirty, []);
196
+
197
+ // Durable unsaved-structure signal (see PersistenceState.unsavedIndex).
198
+ // - hasUnsavedIndex: the collab guard for both the inbound index-update apply
199
+ // and the saved-event rebase's localIndexDirty input.
200
+ // - clearUnsavedIndex: shell calls it exactly where saved/discarded index
201
+ // state is cleared (save success, discard). When a generation token is
202
+ // passed (save success), the clear NO-OPS if any structural edit happened
203
+ // AFTER the token — that edit was made mid-save and the save never carried
204
+ // it. Tokenless (discard) clears unconditionally.
205
+ // - noteUnsavedIndex: shell calls it on the restore path — a Dexie siteIndex
206
+ // row restored at load IS unsaved structural work, even though no
207
+ // markIndexDirty ran this session.
208
+ const hasUnsavedIndex = useCallback(() => state.current.unsavedIndex, []);
209
+ const clearUnsavedIndex = useCallback((upToGeneration?: number) => {
210
+ if (upToGeneration !== undefined && state.current.indexGeneration > upToGeneration) return;
211
+ state.current.unsavedIndex = false;
212
+ }, []);
213
+ const noteUnsavedIndex = useCallback(() => { state.current.unsavedIndex = true; }, []);
214
+
215
+ // The save-begin capture point for the generation guard.
216
+ const currentGeneration = useCallback(() => state.current.generation, []);
217
+ // Whether a section was (re-)dirtied after the given token — i.e. carries an
218
+ // edit the save that captured the token never sent.
219
+ const sectionDirtiedAfter = useCallback(
220
+ (sectionId: string, generation: number) =>
221
+ (state.current.sectionGenerations.get(sectionId) ?? 0) > generation,
222
+ [],
223
+ );
129
224
 
130
225
  return {
131
226
  markSectionDirty,
132
227
  markIndexDirty,
133
228
  removeSection,
229
+ dropPendingSection,
230
+ hasPendingSection,
134
231
  persistConfig,
135
232
  flushNow: flushToDexie,
136
233
  cancelPendingFlush,
137
234
  isConfigDirty,
138
235
  clearConfigDirty,
236
+ isIndexDirty,
237
+ hasUnsavedIndex,
238
+ clearUnsavedIndex,
239
+ noteUnsavedIndex,
240
+ currentGeneration,
241
+ sectionDirtiedAfter,
139
242
  };
140
243
  }