@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.
- package/dist/{chunk-Q3T23HVW.js → chunk-2DDWWTRX.js} +5 -5
- package/dist/chunk-2G2S36SY.js +13 -0
- package/dist/{chunk-S55OZ7F2.js → chunk-3LNKOLZP.js} +4 -0
- package/dist/{chunk-SWIHUIFT.js → chunk-4SIWBWKZ.js} +12 -2
- package/dist/{chunk-SWEVWC5K.js → chunk-BAQYZSFF.js} +22 -22
- package/dist/{chunk-IFHRHV7S.js → chunk-EMGLZQCM.js} +1 -1
- package/dist/{chunk-NKVZGPR6.js → chunk-K4JD6FD3.js} +1 -1
- package/dist/{chunk-SFKYUKVN.js → chunk-MK4POMLZ.js} +1 -1
- package/dist/{chunk-7T2WQSWF.js → chunk-NS3UTJ3E.js} +11 -11
- package/dist/chunk-REM2NH7K.js +44 -0
- package/dist/{chunk-T7MP7ANP.js → chunk-UA6OCJJK.js} +2 -2
- package/dist/{chunk-ZRWGV5O6.js → chunk-Z5IYWUW3.js} +3 -3
- package/dist/collab/provider-registry.d.ts +4 -0
- package/dist/collab/provider-registry.d.ts.map +1 -0
- package/dist/collab/provider-registry.js +9 -0
- package/dist/collab/rebase.d.ts +37 -0
- package/dist/collab/rebase.d.ts.map +1 -0
- package/dist/collab/rebase.js +64 -0
- package/dist/collab/recovery.d.ts +89 -0
- package/dist/collab/recovery.d.ts.map +1 -0
- package/dist/collab/types.d.ts +93 -0
- package/dist/collab/types.d.ts.map +1 -0
- package/dist/collab/types.js +19 -0
- package/dist/components/editor/AudienceIndicator.d.ts +3 -1
- package/dist/components/editor/AudienceIndicator.d.ts.map +1 -1
- package/dist/components/editor/ConflictDialog.d.ts +14 -0
- package/dist/components/editor/ConflictDialog.d.ts.map +1 -0
- package/dist/components/editor/IndicatorPill.d.ts +4 -1
- package/dist/components/editor/IndicatorPill.d.ts.map +1 -1
- package/dist/components/editor/LockBadge.d.ts +7 -0
- package/dist/components/editor/LockBadge.d.ts.map +1 -0
- package/dist/components/editor/PresenceAvatars.d.ts +9 -0
- package/dist/components/editor/PresenceAvatars.d.ts.map +1 -0
- package/dist/components/editor/SectionWrapper.d.ts +1 -1
- package/dist/components/editor/SectionWrapper.d.ts.map +1 -1
- package/dist/components/editor/StatusIndicator.d.ts +3 -1
- package/dist/components/editor/StatusIndicator.d.ts.map +1 -1
- package/dist/components/editor/index.js +95 -47
- package/dist/components/primitives/index.js +5 -5
- package/dist/components/sections/brand-guide.js +5 -5
- package/dist/components/sections/register-schemas.js +11 -11
- package/dist/components/sections/register.js +11 -11
- package/dist/components/shell/EditorShell.d.ts +3 -1
- package/dist/components/shell/EditorShell.d.ts.map +1 -1
- package/dist/components/shell/MediaBreadcrumb.d.ts +10 -0
- package/dist/components/shell/MediaBreadcrumb.d.ts.map +1 -0
- package/dist/components/shell/MediaFolderTile.d.ts +15 -0
- package/dist/components/shell/MediaFolderTile.d.ts.map +1 -0
- package/dist/components/shell/MediaFolderTree.d.ts +20 -0
- package/dist/components/shell/MediaFolderTree.d.ts.map +1 -0
- package/dist/components/shell/MediaLibraryModal.d.ts +15 -3
- package/dist/components/shell/MediaLibraryModal.d.ts.map +1 -1
- package/dist/components/shell/MoveToFolderDialog.d.ts +16 -0
- package/dist/components/shell/MoveToFolderDialog.d.ts.map +1 -0
- package/dist/hooks/useCollabSession.d.ts +23 -0
- package/dist/hooks/useCollabSession.d.ts.map +1 -0
- package/dist/hooks/useEditorPersistence.d.ts +21 -1
- package/dist/hooks/useEditorPersistence.d.ts.map +1 -1
- package/dist/hooks/useEditorPublish.d.ts +39 -2
- package/dist/hooks/useEditorPublish.d.ts.map +1 -1
- package/dist/hooks/useMediaFolderDnd.d.ts +27 -0
- package/dist/hooks/useMediaFolderDnd.d.ts.map +1 -0
- package/dist/hooks/useMediaPipeline.d.ts +14 -2
- package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +51 -25
- package/dist/lib/dexie.d.ts +12 -0
- package/dist/lib/dexie.d.ts.map +1 -1
- package/dist/lib/dexie.js +27 -0
- package/dist/lib/index.js +12 -12
- package/dist/lib/media-dnd.d.ts +26 -0
- package/dist/lib/media-dnd.d.ts.map +1 -0
- package/dist/lib/media-folders.d.ts +59 -0
- package/dist/lib/media-folders.d.ts.map +1 -0
- package/dist/lib/registry.d.ts +10 -0
- package/dist/lib/registry.d.ts.map +1 -1
- package/dist/media/folder-drop.d.ts +22 -0
- package/dist/media/folder-drop.d.ts.map +1 -0
- package/dist/media/index.js +6 -2
- package/dist/media/types.d.ts +1 -1
- package/dist/media/types.d.ts.map +1 -1
- package/dist/media/utils.d.ts +7 -0
- package/dist/media/utils.d.ts.map +1 -1
- package/dist/schemas/index.js +5 -3
- package/dist/schemas/media.d.ts +11 -0
- package/dist/schemas/media.d.ts.map +1 -1
- package/dist/types/database.d.ts +70 -0
- package/dist/types/database.d.ts.map +1 -1
- package/package.json +13 -1
- package/src/collab/provider-registry.ts +14 -0
- package/src/collab/rebase.ts +140 -0
- package/src/collab/recovery.ts +149 -0
- package/src/collab/types.ts +96 -0
- package/src/components/editor/AudienceIndicator.tsx +4 -1
- package/src/components/editor/ConflictDialog.tsx +61 -0
- package/src/components/editor/IndicatorPill.tsx +12 -6
- package/src/components/editor/LockBadge.tsx +31 -0
- package/src/components/editor/PresenceAvatars.tsx +53 -0
- package/src/components/editor/SectionWrapper.tsx +60 -29
- package/src/components/editor/StatusIndicator.tsx +4 -0
- package/src/components/shell/EditorShell.tsx +581 -22
- package/src/components/shell/MediaBreadcrumb.tsx +77 -0
- package/src/components/shell/MediaFolderTile.tsx +141 -0
- package/src/components/shell/MediaFolderTree.tsx +127 -0
- package/src/components/shell/MediaLibraryModal.tsx +611 -229
- package/src/components/shell/MoveToFolderDialog.tsx +78 -0
- package/src/hooks/useCollabSession.ts +220 -0
- package/src/hooks/useEditorPersistence.ts +104 -1
- package/src/hooks/useEditorPublish.ts +216 -71
- package/src/hooks/useMediaFolderDnd.ts +129 -0
- package/src/hooks/useMediaPipeline.ts +162 -6
- package/src/index.ts +2 -0
- package/src/lib/dexie.ts +44 -0
- package/src/lib/media-dnd.ts +62 -0
- package/src/lib/media-folders.ts +232 -0
- package/src/lib/registry.ts +8 -0
- package/src/media/folder-drop.ts +62 -0
- package/src/media/types.ts +2 -0
- package/src/media/utils.ts +10 -0
- package/src/schemas/media.ts +13 -0
- 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
|
-
|
|
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
|
}
|