canvu-react 0.3.9 → 0.3.11

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/realtime.cjs CHANGED
@@ -102,7 +102,7 @@ function PresenceRemoteLayer({
102
102
  const rootTransform = formatCameraTransform(camera);
103
103
  const overlayStrokePx = 1.25;
104
104
  const LUCIDE_POINTER_VIEWBOX = 24;
105
- const remoteCursorScreenPx = 15;
105
+ const remoteCursorScreenPx = 22;
106
106
  const iconWorldScale = remoteCursorScreenPx / (LUCIDE_POINTER_VIEWBOX * z);
107
107
  return /* @__PURE__ */ jsxRuntime.jsx(
108
108
  "svg",
@@ -166,9 +166,9 @@ function PresenceRemoteLayer({
166
166
  let cursorNode = null;
167
167
  if (cur) {
168
168
  const displayName = peer.displayName;
169
- const labelOffsetX = 10 / z;
170
- const labelOffsetY = 10 / z;
171
- const labelFont = 10 / z;
169
+ const labelOffsetX = 14 / z;
170
+ const labelOffsetY = 14 / z;
171
+ const labelFont = 12 / z;
172
172
  cursorNode = /* @__PURE__ */ jsxRuntime.jsxs("g", { children: [
173
173
  /* @__PURE__ */ jsxRuntime.jsx(
174
174
  "g",
@@ -1744,7 +1744,7 @@ function useRealtimePeerFollow(options) {
1744
1744
  const { viewportRef, sessionPeers, followedPeerId, onFollowEnd } = options;
1745
1745
  const endedPeerIdRef = react.useRef(null);
1746
1746
  const lastAppliedCameraKeyRef = react.useRef(null);
1747
- const [viewportSizeVersion, setViewportSizeVersion] = react.useState(0);
1747
+ const [, setViewportSizeVersion] = react.useState(0);
1748
1748
  react.useEffect(() => {
1749
1749
  if (!followedPeerId) return;
1750
1750
  let animationFrameId = 0;
@@ -1796,16 +1796,27 @@ function useRealtimePeerFollow(options) {
1796
1796
  return;
1797
1797
  }
1798
1798
  lastAppliedCameraKeyRef.current = nextCameraKey;
1799
- }, [
1800
- followedPeerId,
1801
- onFollowEnd,
1802
- sessionPeers,
1803
- viewportRef,
1804
- viewportSizeVersion
1805
- ]);
1799
+ }, [followedPeerId, onFollowEnd, sessionPeers, viewportRef]);
1806
1800
  }
1807
1801
 
1808
1802
  // src/react/plugins/realtime/use-realtime-session.ts
1803
+ var DRAFT_STORAGE_PREFIX = "canvu-realtime-draft:";
1804
+ var DOCUMENT_FLUSH_DEBOUNCE_MS = 120;
1805
+ var DRAFT_PERSIST_DEBOUNCE_MS = 420;
1806
+ function requestWindowIdleCallback(callback, timeout) {
1807
+ if (typeof window.requestIdleCallback === "function") {
1808
+ return window.requestIdleCallback(callback, { timeout });
1809
+ }
1810
+ return window.setTimeout(callback, timeout);
1811
+ }
1812
+ function cancelWindowIdleCallback(handle) {
1813
+ if (handle == null) return;
1814
+ if (typeof window.cancelIdleCallback === "function") {
1815
+ window.cancelIdleCallback(handle);
1816
+ return;
1817
+ }
1818
+ window.clearTimeout(handle);
1819
+ }
1809
1820
  function createClientId() {
1810
1821
  if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
1811
1822
  return crypto.randomUUID();
@@ -1813,6 +1824,7 @@ function createClientId() {
1813
1824
  return `client-${Math.random().toString(36).slice(2, 10)}`;
1814
1825
  }
1815
1826
  function normalizeSocketUrl(input) {
1827
+ if (!input) return "";
1816
1828
  if (input.startsWith("ws://") || input.startsWith("wss://")) return input;
1817
1829
  if (input.startsWith("http://")) return `ws://${input.slice("http://".length)}`;
1818
1830
  if (input.startsWith("https://")) return `wss://${input.slice("https://".length)}`;
@@ -1831,9 +1843,29 @@ function isValidSocketUrl(input) {
1831
1843
  return false;
1832
1844
  }
1833
1845
  }
1846
+ function sanitizeRealtimeItem(item) {
1847
+ if (item.toolKind !== "image") return item;
1848
+ return {
1849
+ ...item,
1850
+ imageBlobId: void 0,
1851
+ imageThumbnailBlobId: void 0,
1852
+ imageRasterHref: void 0,
1853
+ imageThumbnailHref: void 0,
1854
+ childrenSvg: ""
1855
+ };
1856
+ }
1857
+ function sanitizeRealtimeItems(items) {
1858
+ return items.map(sanitizeRealtimeItem);
1859
+ }
1860
+ function sanitizeRealtimeSnapshot(snapshot) {
1861
+ return {
1862
+ ...snapshot,
1863
+ items: sanitizeRealtimeItems(snapshot.items)
1864
+ };
1865
+ }
1834
1866
  function serializeItems(items) {
1835
1867
  try {
1836
- return JSON.stringify(items);
1868
+ return JSON.stringify(sanitizeRealtimeItems(items));
1837
1869
  } catch {
1838
1870
  return null;
1839
1871
  }
@@ -1848,6 +1880,58 @@ function sameSerializedItems(left, right) {
1848
1880
  function nowMs() {
1849
1881
  return Date.now();
1850
1882
  }
1883
+ function draftStorageKey(roomId) {
1884
+ return `${DRAFT_STORAGE_PREFIX}${roomId}`;
1885
+ }
1886
+ function isRealtimeOfflineDraft(value) {
1887
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
1888
+ const record = value;
1889
+ return typeof record.roomId === "string" && typeof record.baseRevision === "number" && typeof record.updatedAt === "number" && Array.isArray(record.items);
1890
+ }
1891
+ function readRealtimeOfflineDraft(roomId) {
1892
+ if (typeof window === "undefined" || !roomId) return null;
1893
+ try {
1894
+ const raw = window.localStorage.getItem(draftStorageKey(roomId));
1895
+ if (!raw) return null;
1896
+ const parsed = JSON.parse(raw);
1897
+ if (!isRealtimeOfflineDraft(parsed)) return null;
1898
+ return {
1899
+ ...parsed,
1900
+ items: sanitizeRealtimeItems(parsed.items)
1901
+ };
1902
+ } catch {
1903
+ return null;
1904
+ }
1905
+ }
1906
+ function writeRealtimeOfflineDraft(draft) {
1907
+ if (typeof window === "undefined") return;
1908
+ if (!draft) return;
1909
+ try {
1910
+ window.localStorage.setItem(
1911
+ draftStorageKey(draft.roomId),
1912
+ JSON.stringify({
1913
+ ...draft,
1914
+ items: sanitizeRealtimeItems(draft.items)
1915
+ })
1916
+ );
1917
+ } catch {
1918
+ }
1919
+ }
1920
+ function removeRealtimeOfflineDraft(roomId) {
1921
+ if (typeof window === "undefined" || !roomId) return;
1922
+ try {
1923
+ window.localStorage.removeItem(draftStorageKey(roomId));
1924
+ } catch {
1925
+ }
1926
+ }
1927
+ function buildDraftSnapshot(draft, clientId) {
1928
+ return {
1929
+ revision: draft.baseRevision,
1930
+ items: draft.items,
1931
+ updatedAt: draft.updatedAt,
1932
+ updatedByClientId: clientId
1933
+ };
1934
+ }
1851
1935
  function getViewportCameraSnapshot(viewport) {
1852
1936
  if (!viewport) return null;
1853
1937
  const camera = viewport.getCamera();
@@ -1861,6 +1945,17 @@ function getViewportCameraSnapshot(viewport) {
1861
1945
  viewportHeight: viewportSize.height
1862
1946
  };
1863
1947
  }
1948
+ function prepareRealtimeItems(items) {
1949
+ try {
1950
+ const sanitizedItems = sanitizeRealtimeItems(items);
1951
+ return {
1952
+ items: sanitizedItems,
1953
+ serialized: JSON.stringify(sanitizedItems)
1954
+ };
1955
+ } catch {
1956
+ return null;
1957
+ }
1958
+ }
1864
1959
  function useRealtimeSession(options) {
1865
1960
  const {
1866
1961
  url,
@@ -1879,6 +1974,10 @@ function useRealtimeSession(options) {
1879
1974
  const reconnectTimerRef = react.useRef(null);
1880
1975
  const heartbeatTimerRef = react.useRef(null);
1881
1976
  const connectTimeoutRef = react.useRef(null);
1977
+ const documentFlushTimerRef = react.useRef(null);
1978
+ const documentFlushIdleRef = react.useRef(null);
1979
+ const draftPersistTimerRef = react.useRef(null);
1980
+ const draftPersistIdleRef = react.useRef(null);
1882
1981
  const manualDisconnectRef = react.useRef(false);
1883
1982
  const retryCountRef = react.useRef(0);
1884
1983
  const currentRevisionRef = react.useRef(0);
@@ -1895,6 +1994,8 @@ function useRealtimeSession(options) {
1895
1994
  const connectionStateRef = react.useRef(
1896
1995
  enabled ? "connecting" : "offline"
1897
1996
  );
1997
+ const localDraftRef = react.useRef(null);
1998
+ const conflictRef = react.useRef(null);
1898
1999
  const onErrorRef = react.useRef(onError);
1899
2000
  onErrorRef.current = onError;
1900
2001
  const [connectSequence, setConnectSequence] = react.useState(0);
@@ -1911,7 +2012,18 @@ function useRealtimeSession(options) {
1911
2012
  });
1912
2013
  const [sessionPeers, setSessionPeers] = react.useState([]);
1913
2014
  const [document2, setDocument] = react.useState(null);
2015
+ const [hasLocalOfflineDraft, setHasLocalOfflineDraft] = react.useState(false);
2016
+ const [hasPendingDocumentSync, setHasPendingDocumentSync] = react.useState(false);
2017
+ const [conflict, setConflict] = react.useState(null);
1914
2018
  connectionStateRef.current = connection.state;
2019
+ const syncState = react.useMemo(() => {
2020
+ if (conflict != null) return "conflicted";
2021
+ if (connection.connected) return "connected";
2022
+ if (connection.state === "reconnecting" || connection.state === "connecting") {
2023
+ return "reconnecting";
2024
+ }
2025
+ return "offline";
2026
+ }, [conflict, connection.connected, connection.state]);
1915
2027
  const clearReconnectTimer = react.useCallback(() => {
1916
2028
  if (reconnectTimerRef.current != null) {
1917
2029
  window.clearTimeout(reconnectTimerRef.current);
@@ -1930,6 +2042,22 @@ function useRealtimeSession(options) {
1930
2042
  connectTimeoutRef.current = null;
1931
2043
  }
1932
2044
  }, []);
2045
+ const clearDocumentFlushSchedule = react.useCallback(() => {
2046
+ if (documentFlushTimerRef.current != null) {
2047
+ window.clearTimeout(documentFlushTimerRef.current);
2048
+ documentFlushTimerRef.current = null;
2049
+ }
2050
+ cancelWindowIdleCallback(documentFlushIdleRef.current);
2051
+ documentFlushIdleRef.current = null;
2052
+ }, []);
2053
+ const clearDraftPersistSchedule = react.useCallback(() => {
2054
+ if (draftPersistTimerRef.current != null) {
2055
+ window.clearTimeout(draftPersistTimerRef.current);
2056
+ draftPersistTimerRef.current = null;
2057
+ }
2058
+ cancelWindowIdleCallback(draftPersistIdleRef.current);
2059
+ draftPersistIdleRef.current = null;
2060
+ }, []);
1933
2061
  const updateConnection = react.useCallback(
1934
2062
  (patch) => {
1935
2063
  setConnection((prev) => {
@@ -1955,6 +2083,84 @@ function useRealtimeSession(options) {
1955
2083
  },
1956
2084
  [notifySubscribers]
1957
2085
  );
2086
+ const setConflictState = react.useCallback(
2087
+ (nextConflict) => {
2088
+ conflictRef.current = nextConflict;
2089
+ setConflict(nextConflict);
2090
+ },
2091
+ []
2092
+ );
2093
+ const setLocalDraft = react.useCallback(
2094
+ (nextDraft) => {
2095
+ localDraftRef.current = nextDraft;
2096
+ setHasLocalOfflineDraft(nextDraft != null);
2097
+ if (nextDraft) return;
2098
+ removeRealtimeOfflineDraft(roomId);
2099
+ },
2100
+ [roomId]
2101
+ );
2102
+ const persistLocalDraft = react.useCallback(() => {
2103
+ clearDraftPersistSchedule();
2104
+ if (!localDraftRef.current) {
2105
+ removeRealtimeOfflineDraft(roomId);
2106
+ return;
2107
+ }
2108
+ writeRealtimeOfflineDraft(localDraftRef.current);
2109
+ }, [clearDraftPersistSchedule, roomId]);
2110
+ const scheduleDraftPersistence = react.useCallback(() => {
2111
+ clearDraftPersistSchedule();
2112
+ draftPersistTimerRef.current = window.setTimeout(() => {
2113
+ draftPersistTimerRef.current = null;
2114
+ draftPersistIdleRef.current = requestWindowIdleCallback(() => {
2115
+ draftPersistIdleRef.current = null;
2116
+ persistLocalDraft();
2117
+ }, DRAFT_PERSIST_DEBOUNCE_MS);
2118
+ }, DRAFT_PERSIST_DEBOUNCE_MS);
2119
+ }, [clearDraftPersistSchedule, persistLocalDraft]);
2120
+ const clearLocalDraft = react.useCallback(() => {
2121
+ setLocalDraft(null);
2122
+ clearDraftPersistSchedule();
2123
+ }, [clearDraftPersistSchedule, setLocalDraft]);
2124
+ const createSelfPeer = react.useCallback(
2125
+ (state) => ({
2126
+ id: peer.id,
2127
+ clientId: clientIdRef.current,
2128
+ peerId: peer.id,
2129
+ roomId,
2130
+ joinedAt: connection.lastConnectedAt ?? nowMs(),
2131
+ lastSeenAt: nowMs(),
2132
+ cursor: lastCursorRef.current,
2133
+ isSelf: true,
2134
+ connectionState: state,
2135
+ ...peer.displayName ? { displayName: peer.displayName } : {},
2136
+ ...peer.color ? { color: peer.color } : {},
2137
+ ...peer.image ? { image: peer.image } : {},
2138
+ ...lastMarkupStrokeRef.current !== void 0 ? { markupStroke: lastMarkupStrokeRef.current ?? null } : {},
2139
+ ...lastCameraRef.current !== void 0 ? { camera: lastCameraRef.current ?? null } : {},
2140
+ ...lastActiveToolRef.current ? { activeTool: lastActiveToolRef.current } : {}
2141
+ }),
2142
+ [
2143
+ connection.lastConnectedAt,
2144
+ peer.color,
2145
+ peer.displayName,
2146
+ peer.id,
2147
+ peer.image,
2148
+ roomId
2149
+ ]
2150
+ );
2151
+ const collapsePeersToSelf = react.useCallback(
2152
+ (state) => {
2153
+ setSessionPeers((prev) => {
2154
+ const selfPeer = prev.find(
2155
+ (peerState) => peerState.clientId === clientIdRef.current
2156
+ );
2157
+ return [
2158
+ selfPeer ? { ...selfPeer, isSelf: true, connectionState: state } : createSelfPeer(state)
2159
+ ];
2160
+ });
2161
+ },
2162
+ [createSelfPeer]
2163
+ );
1958
2164
  const applyPeers = react.useCallback((peers) => {
1959
2165
  const selfClientId = clientIdRef.current;
1960
2166
  setSessionPeers(
@@ -1981,30 +2187,108 @@ function useRealtimeSession(options) {
1981
2187
  [buildClientMessage]
1982
2188
  );
1983
2189
  const flushQueuedDocument = react.useCallback(() => {
2190
+ clearDocumentFlushSchedule();
2191
+ if (conflictRef.current) return;
1984
2192
  const next = queuedItemsRef.current;
1985
2193
  if (!next || outboundInFlightRef.current) return;
1986
- queuedItemsRef.current = null;
1987
2194
  const baseRevision = currentRevisionRef.current;
1988
- const serialized = serializeItems(next);
1989
- outboundInFlightRef.current = { baseRevision, items: next, serialized };
2195
+ const preparedItems = prepareRealtimeItems(next);
2196
+ if (!preparedItems) return;
2197
+ queuedItemsRef.current = null;
2198
+ outboundInFlightRef.current = {
2199
+ baseRevision,
2200
+ items: preparedItems.items,
2201
+ serialized: preparedItems.serialized
2202
+ };
1990
2203
  const didSend = sendRaw({
1991
2204
  type: "document:update",
1992
2205
  roomId,
1993
2206
  clientId: clientIdRef.current,
1994
2207
  baseRevision,
1995
- items: next
2208
+ items: preparedItems.items
1996
2209
  });
1997
2210
  if (!didSend) {
1998
2211
  queuedItemsRef.current = next;
1999
2212
  outboundInFlightRef.current = null;
2213
+ setHasPendingDocumentSync(true);
2000
2214
  }
2001
- }, [roomId, sendRaw]);
2215
+ }, [clearDocumentFlushSchedule, roomId, sendRaw]);
2216
+ const scheduleDocumentFlush = react.useCallback(() => {
2217
+ clearDocumentFlushSchedule();
2218
+ documentFlushTimerRef.current = window.setTimeout(() => {
2219
+ documentFlushTimerRef.current = null;
2220
+ documentFlushIdleRef.current = requestWindowIdleCallback(() => {
2221
+ documentFlushIdleRef.current = null;
2222
+ flushQueuedDocument();
2223
+ }, DOCUMENT_FLUSH_DEBOUNCE_MS);
2224
+ }, DOCUMENT_FLUSH_DEBOUNCE_MS);
2225
+ }, [clearDocumentFlushSchedule, flushQueuedDocument]);
2002
2226
  const queueDocumentSend = react.useCallback(
2003
2227
  (items) => {
2228
+ setLocalDraft({
2229
+ roomId,
2230
+ baseRevision: currentRevisionRef.current,
2231
+ items,
2232
+ updatedAt: nowMs()
2233
+ });
2234
+ scheduleDraftPersistence();
2004
2235
  queuedItemsRef.current = items;
2005
- flushQueuedDocument();
2236
+ setHasPendingDocumentSync(true);
2237
+ if (conflictRef.current) return;
2238
+ scheduleDocumentFlush();
2239
+ },
2240
+ [roomId, scheduleDocumentFlush, scheduleDraftPersistence, setLocalDraft]
2241
+ );
2242
+ const applyDraftSnapshot = react.useCallback(
2243
+ (draft, options2) => {
2244
+ applyDocument(buildDraftSnapshot(draft, clientIdRef.current), options2);
2006
2245
  },
2007
- [flushQueuedDocument]
2246
+ [applyDocument]
2247
+ );
2248
+ const resolveAuthoritativeDocument = react.useCallback(
2249
+ (serverDocument, options2) => {
2250
+ const localDraft = localDraftRef.current;
2251
+ if (!localDraft) {
2252
+ setConflictState(null);
2253
+ setHasPendingDocumentSync(false);
2254
+ applyDocument(serverDocument, options2);
2255
+ return false;
2256
+ }
2257
+ if (sameSerializedItems(localDraft.items, serverDocument.items)) {
2258
+ clearLocalDraft();
2259
+ setHasPendingDocumentSync(false);
2260
+ setConflictState(null);
2261
+ applyDocument(serverDocument, options2);
2262
+ return true;
2263
+ }
2264
+ if (serverDocument.revision === localDraft.baseRevision) {
2265
+ setConflictState(null);
2266
+ applyDraftSnapshot(localDraft, {
2267
+ suppressSubscriberNotify: options2?.suppressSubscriberNotify ?? sameSerializedItems(latestDocumentRef.current?.items, localDraft.items)
2268
+ });
2269
+ outboundInFlightRef.current = null;
2270
+ queuedItemsRef.current = localDraft.items;
2271
+ setHasPendingDocumentSync(true);
2272
+ scheduleDocumentFlush();
2273
+ return true;
2274
+ }
2275
+ setConflictState({
2276
+ serverRevision: serverDocument.revision,
2277
+ serverItems: serverDocument.items,
2278
+ localItems: localDraft.items
2279
+ });
2280
+ applyDraftSnapshot(localDraft, {
2281
+ suppressSubscriberNotify: options2?.suppressSubscriberNotify ?? sameSerializedItems(latestDocumentRef.current?.items, localDraft.items)
2282
+ });
2283
+ return true;
2284
+ },
2285
+ [
2286
+ applyDocument,
2287
+ applyDraftSnapshot,
2288
+ clearLocalDraft,
2289
+ scheduleDocumentFlush,
2290
+ setConflictState
2291
+ ]
2008
2292
  );
2009
2293
  const sendPresenceUpdate = react.useCallback(() => {
2010
2294
  sendRaw({
@@ -2043,17 +2327,108 @@ function useRealtimeSession(options) {
2043
2327
  reconnect,
2044
2328
  updateConnection
2045
2329
  ]);
2330
+ const resolveConflict = react.useCallback(
2331
+ (action) => {
2332
+ const activeConflict = conflictRef.current;
2333
+ if (!activeConflict) return;
2334
+ if (action === "use-server") {
2335
+ clearLocalDraft();
2336
+ queuedItemsRef.current = null;
2337
+ outboundInFlightRef.current = null;
2338
+ setConflictState(null);
2339
+ applyDocument({
2340
+ revision: activeConflict.serverRevision,
2341
+ items: activeConflict.serverItems,
2342
+ updatedAt: nowMs()
2343
+ });
2344
+ return;
2345
+ }
2346
+ const nextDraft = {
2347
+ roomId,
2348
+ baseRevision: activeConflict.serverRevision,
2349
+ items: activeConflict.localItems,
2350
+ updatedAt: nowMs()
2351
+ };
2352
+ setLocalDraft(nextDraft);
2353
+ scheduleDraftPersistence();
2354
+ setConflictState(null);
2355
+ applyDraftSnapshot(nextDraft, {
2356
+ suppressSubscriberNotify: sameSerializedItems(
2357
+ latestDocumentRef.current?.items,
2358
+ nextDraft.items
2359
+ )
2360
+ });
2361
+ currentRevisionRef.current = activeConflict.serverRevision;
2362
+ queuedItemsRef.current = nextDraft.items;
2363
+ outboundInFlightRef.current = null;
2364
+ setHasPendingDocumentSync(true);
2365
+ scheduleDocumentFlush();
2366
+ },
2367
+ [
2368
+ applyDocument,
2369
+ applyDraftSnapshot,
2370
+ clearLocalDraft,
2371
+ roomId,
2372
+ scheduleDocumentFlush,
2373
+ scheduleDraftPersistence,
2374
+ setConflictState,
2375
+ setLocalDraft
2376
+ ]
2377
+ );
2378
+ react.useEffect(() => {
2379
+ if (!roomId) {
2380
+ clearDocumentFlushSchedule();
2381
+ clearDraftPersistSchedule();
2382
+ localDraftRef.current = null;
2383
+ setHasLocalOfflineDraft(false);
2384
+ setHasPendingDocumentSync(false);
2385
+ setConflictState(null);
2386
+ queuedItemsRef.current = null;
2387
+ outboundInFlightRef.current = null;
2388
+ latestDocumentRef.current = null;
2389
+ setDocument(null);
2390
+ currentRevisionRef.current = 0;
2391
+ return;
2392
+ }
2393
+ const localDraft = readRealtimeOfflineDraft(roomId);
2394
+ setLocalDraft(localDraft);
2395
+ setHasPendingDocumentSync(localDraft != null);
2396
+ setConflictState(null);
2397
+ queuedItemsRef.current = localDraft?.items ?? null;
2398
+ outboundInFlightRef.current = null;
2399
+ if (localDraft) {
2400
+ applyDraftSnapshot(localDraft, {
2401
+ suppressSubscriberNotify: sameSerializedItems(
2402
+ latestDocumentRef.current?.items,
2403
+ localDraft.items
2404
+ )
2405
+ });
2406
+ } else {
2407
+ latestDocumentRef.current = null;
2408
+ setDocument(null);
2409
+ currentRevisionRef.current = 0;
2410
+ }
2411
+ }, [
2412
+ applyDraftSnapshot,
2413
+ clearDocumentFlushSchedule,
2414
+ clearDraftPersistSchedule,
2415
+ roomId,
2416
+ setConflictState,
2417
+ setLocalDraft
2418
+ ]);
2046
2419
  react.useEffect(() => {
2047
2420
  if (!enabled) {
2048
2421
  manualDisconnectRef.current = true;
2049
2422
  clearReconnectTimer();
2050
2423
  clearHeartbeatTimer();
2051
2424
  clearConnectTimeout();
2425
+ clearDocumentFlushSchedule();
2052
2426
  wsRef.current?.close();
2053
2427
  wsRef.current = null;
2054
- setSessionPeers([]);
2055
- queuedItemsRef.current = null;
2428
+ queuedItemsRef.current = localDraftRef.current?.items ?? null;
2056
2429
  outboundInFlightRef.current = null;
2430
+ setHasPendingDocumentSync(localDraftRef.current != null);
2431
+ collapsePeersToSelf("offline");
2057
2432
  updateConnection((prev) => ({
2058
2433
  ...prev,
2059
2434
  state: "offline",
@@ -2065,6 +2440,17 @@ function useRealtimeSession(options) {
2065
2440
  }
2066
2441
  manualDisconnectRef.current = false;
2067
2442
  const socketUrl = normalizeSocketUrl(url);
2443
+ if (!socketUrl) {
2444
+ collapsePeersToSelf("offline");
2445
+ updateConnection((prev) => ({
2446
+ ...prev,
2447
+ state: "offline",
2448
+ connected: false,
2449
+ roomId,
2450
+ lastError: null
2451
+ }));
2452
+ return;
2453
+ }
2068
2454
  if (!isValidSocketUrl(socketUrl)) {
2069
2455
  updateConnection((prev) => ({
2070
2456
  ...prev,
@@ -2134,8 +2520,6 @@ function useRealtimeSession(options) {
2134
2520
  }));
2135
2521
  if (parsed.type === "session:welcome") {
2136
2522
  retryCountRef.current = 0;
2137
- const queuedBeforeWelcome = queuedItemsRef.current;
2138
- const shouldPromoteQueuedLocalDraft = queuedBeforeWelcome != null && parsed.document.revision === 0 && parsed.document.items.length === 0;
2139
2523
  updateConnection((prev) => ({
2140
2524
  ...prev,
2141
2525
  state: "connected",
@@ -2146,14 +2530,21 @@ function useRealtimeSession(options) {
2146
2530
  lastError: null
2147
2531
  }));
2148
2532
  applyPeers(parsed.peers);
2149
- applyDocument(parsed.document, {
2150
- suppressSubscriberNotify: shouldPromoteQueuedLocalDraft
2151
- });
2152
- if (!shouldPromoteQueuedLocalDraft) {
2533
+ const handledByDraft = resolveAuthoritativeDocument(
2534
+ sanitizeRealtimeSnapshot(parsed.document),
2535
+ {
2536
+ suppressSubscriberNotify: localDraftRef.current != null && sameSerializedItems(
2537
+ latestDocumentRef.current?.items,
2538
+ localDraftRef.current.items
2539
+ )
2540
+ }
2541
+ );
2542
+ if (!handledByDraft) {
2153
2543
  queuedItemsRef.current = null;
2154
2544
  outboundInFlightRef.current = null;
2545
+ setHasPendingDocumentSync(false);
2155
2546
  }
2156
- flushQueuedDocument();
2547
+ scheduleDocumentFlush();
2157
2548
  return;
2158
2549
  }
2159
2550
  if (parsed.type === "presence:sync") {
@@ -2200,30 +2591,39 @@ function useRealtimeSession(options) {
2200
2591
  const isSelfAck = parsed.document.updatedByClientId === selfClientId;
2201
2592
  if (!isSelfAck) {
2202
2593
  outboundInFlightRef.current = null;
2203
- queuedItemsRef.current = null;
2204
- applyDocument(parsed.document);
2594
+ queuedItemsRef.current = localDraftRef.current?.items ?? null;
2595
+ setHasPendingDocumentSync(queuedItemsRef.current != null);
2596
+ resolveAuthoritativeDocument(sanitizeRealtimeSnapshot(parsed.document));
2205
2597
  return;
2206
2598
  }
2207
2599
  const shouldSuppress = inFlight != null && sameSerializedItems(inFlight.items, parsed.document.items);
2208
2600
  outboundInFlightRef.current = null;
2209
- applyDocument(parsed.document, { suppressSubscriberNotify: shouldSuppress });
2601
+ applyDocument(sanitizeRealtimeSnapshot(parsed.document), {
2602
+ suppressSubscriberNotify: shouldSuppress
2603
+ });
2210
2604
  if (queuedItemsRef.current) {
2211
2605
  if (sameSerializedItems(queuedItemsRef.current, parsed.document.items)) {
2212
2606
  queuedItemsRef.current = null;
2213
2607
  } else {
2214
- flushQueuedDocument();
2608
+ scheduleDocumentFlush();
2215
2609
  }
2216
2610
  }
2611
+ if (!queuedItemsRef.current) {
2612
+ clearLocalDraft();
2613
+ }
2614
+ setHasPendingDocumentSync(queuedItemsRef.current != null);
2615
+ setConflictState(null);
2217
2616
  return;
2218
2617
  }
2219
2618
  if (parsed.type === "document:resync-required") {
2220
2619
  outboundInFlightRef.current = null;
2221
- queuedItemsRef.current = null;
2620
+ queuedItemsRef.current = localDraftRef.current?.items ?? null;
2621
+ setHasPendingDocumentSync(queuedItemsRef.current != null);
2222
2622
  updateConnection((prev) => ({
2223
2623
  ...prev,
2224
2624
  lastError: parsed.reason
2225
2625
  }));
2226
- applyDocument(parsed.document);
2626
+ resolveAuthoritativeDocument(sanitizeRealtimeSnapshot(parsed.document));
2227
2627
  }
2228
2628
  });
2229
2629
  socket.addEventListener("error", () => {
@@ -2239,6 +2639,9 @@ function useRealtimeSession(options) {
2239
2639
  clearHeartbeatTimer();
2240
2640
  clearConnectTimeout();
2241
2641
  wsRef.current = null;
2642
+ collapsePeersToSelf(
2643
+ manualDisconnectRef.current || !enabled ? "offline" : "reconnecting"
2644
+ );
2242
2645
  updateConnection((prev) => ({
2243
2646
  ...prev,
2244
2647
  connected: false,
@@ -2254,6 +2657,7 @@ function useRealtimeSession(options) {
2254
2657
  clearReconnectTimer();
2255
2658
  clearHeartbeatTimer();
2256
2659
  clearConnectTimeout();
2660
+ clearDocumentFlushSchedule();
2257
2661
  socket.close();
2258
2662
  };
2259
2663
  }, [
@@ -2261,19 +2665,24 @@ function useRealtimeSession(options) {
2261
2665
  applyPeers,
2262
2666
  clearConnectTimeout,
2263
2667
  clearHeartbeatTimer,
2668
+ clearLocalDraft,
2264
2669
  clearReconnectTimer,
2670
+ collapsePeersToSelf,
2671
+ clearDocumentFlushSchedule,
2265
2672
  connectSequence,
2266
2673
  connectTimeoutMs,
2267
2674
  enabled,
2268
- flushQueuedDocument,
2269
2675
  heartbeatMs,
2270
2676
  peer.color,
2271
2677
  peer.displayName,
2272
- peer.image,
2273
2678
  peer.id,
2679
+ peer.image,
2680
+ resolveAuthoritativeDocument,
2274
2681
  roomId,
2682
+ scheduleDocumentFlush,
2275
2683
  scheduleReconnect,
2276
2684
  sendRaw,
2685
+ setConflictState,
2277
2686
  updateConnection,
2278
2687
  url
2279
2688
  ]);
@@ -2288,6 +2697,18 @@ function useRealtimeSession(options) {
2288
2697
  )
2289
2698
  );
2290
2699
  }, [connection.state]);
2700
+ react.useEffect(
2701
+ () => () => {
2702
+ clearDocumentFlushSchedule();
2703
+ clearDraftPersistSchedule();
2704
+ },
2705
+ [clearDocumentFlushSchedule, clearDraftPersistSchedule]
2706
+ );
2707
+ const flushDocumentSync = react.useCallback(async () => {
2708
+ persistLocalDraft();
2709
+ if (!connection.connected) return;
2710
+ flushQueuedDocument();
2711
+ }, [connection.connected, flushQueuedDocument, persistLocalDraft]);
2291
2712
  const remoteAdapter = react.useMemo(
2292
2713
  () => ({
2293
2714
  subscribe(onItems) {
@@ -2301,9 +2722,12 @@ function useRealtimeSession(options) {
2301
2722
  },
2302
2723
  send(items) {
2303
2724
  queueDocumentSend(items);
2725
+ },
2726
+ flush() {
2727
+ return flushDocumentSync();
2304
2728
  }
2305
2729
  }),
2306
- [queueDocumentSend]
2730
+ [flushDocumentSync, queueDocumentSend]
2307
2731
  );
2308
2732
  const disconnect = react.useCallback(() => {
2309
2733
  manualDisconnectRef.current = true;
@@ -2317,6 +2741,7 @@ function useRealtimeSession(options) {
2317
2741
  });
2318
2742
  wsRef.current?.close();
2319
2743
  wsRef.current = null;
2744
+ collapsePeersToSelf("offline");
2320
2745
  updateConnection((prev) => ({
2321
2746
  ...prev,
2322
2747
  state: "offline",
@@ -2326,6 +2751,7 @@ function useRealtimeSession(options) {
2326
2751
  clearConnectTimeout,
2327
2752
  clearHeartbeatTimer,
2328
2753
  clearReconnectTimer,
2754
+ collapsePeersToSelf,
2329
2755
  roomId,
2330
2756
  sendRaw,
2331
2757
  updateConnection
@@ -2391,8 +2817,15 @@ function useRealtimeSession(options) {
2391
2817
  remotePresence,
2392
2818
  remoteAdapter,
2393
2819
  document: document2,
2820
+ hasLocalOfflineDraft,
2821
+ hasPendingDocumentSync,
2822
+ syncState,
2823
+ conflict,
2394
2824
  bindViewportPresence,
2395
2825
  syncViewportPresence,
2826
+ resolveConflict,
2827
+ clearLocalDraft,
2828
+ flushDocumentSync,
2396
2829
  disconnect,
2397
2830
  reconnectNow
2398
2831
  };
@@ -2545,6 +2978,75 @@ function realtimeSessionPlugin(options) {
2545
2978
  render: () => /* @__PURE__ */ jsxRuntime.jsx(RealtimeSessionPanel, { ...options })
2546
2979
  };
2547
2980
  }
2981
+ function useRealtimeCanvasDocument(options) {
2982
+ const {
2983
+ session,
2984
+ items,
2985
+ onItemsChange,
2986
+ normalizeItems,
2987
+ hydrateItems,
2988
+ enabled = true
2989
+ } = options;
2990
+ const [loading, setLoading] = react.useState(false);
2991
+ const lastAppliedRevisionRef = react.useRef(null);
2992
+ const realtimeEnabled = enabled && session != null;
2993
+ const applyIncomingItems = react.useCallback(
2994
+ async (nextItems) => {
2995
+ const normalizedItems = normalizeItems ? normalizeItems(nextItems) : [...nextItems];
2996
+ if (!hydrateItems) return normalizedItems;
2997
+ return await hydrateItems(normalizedItems);
2998
+ },
2999
+ [hydrateItems, normalizeItems]
3000
+ );
3001
+ const handleItemsChange = react.useCallback(
3002
+ (nextItems) => {
3003
+ if (!enabled) {
3004
+ onItemsChange?.(normalizeItems ? normalizeItems(nextItems) : nextItems);
3005
+ return;
3006
+ }
3007
+ const normalizedItems = normalizeItems ? normalizeItems(nextItems) : nextItems;
3008
+ onItemsChange?.(normalizedItems);
3009
+ session?.remoteAdapter.send?.(normalizedItems);
3010
+ },
3011
+ [enabled, normalizeItems, onItemsChange, session]
3012
+ );
3013
+ react.useEffect(() => {
3014
+ if (!realtimeEnabled || !onItemsChange || !session?.document) return;
3015
+ if (session.document.updatedByClientId === session.connection.clientId) return;
3016
+ if (lastAppliedRevisionRef.current === session.document.revision) return;
3017
+ let cancelled = false;
3018
+ setLoading(true);
3019
+ void applyIncomingItems(session.document.items).then((resolvedItems) => {
3020
+ if (cancelled) return;
3021
+ lastAppliedRevisionRef.current = session.document?.revision ?? null;
3022
+ onItemsChange(resolvedItems);
3023
+ }).finally(() => {
3024
+ if (cancelled) return;
3025
+ setLoading(false);
3026
+ });
3027
+ return () => {
3028
+ cancelled = true;
3029
+ };
3030
+ }, [applyIncomingItems, realtimeEnabled, onItemsChange, session]);
3031
+ return react.useMemo(
3032
+ () => ({
3033
+ items,
3034
+ onItemsChange: onItemsChange ? handleItemsChange : void 0,
3035
+ loading,
3036
+ saving: session?.hasPendingDocumentSync ?? false,
3037
+ hasLocalOfflineDraft: session?.hasLocalOfflineDraft ?? false,
3038
+ syncState: session?.syncState ?? "offline",
3039
+ conflict: session?.conflict ?? null,
3040
+ resolveConflict: session?.resolveConflict ?? (() => {
3041
+ }),
3042
+ clearLocalDraft: session?.clearLocalDraft ?? (() => {
3043
+ }),
3044
+ flush: session?.flushDocumentSync ?? (async () => {
3045
+ })
3046
+ }),
3047
+ [handleItemsChange, items, loading, onItemsChange, session]
3048
+ );
3049
+ }
2548
3050
 
2549
3051
  exports.PresenceRemoteLayer = PresenceRemoteLayer;
2550
3052
  exports.REALTIME_COMMENT_TOOL = REALTIME_COMMENT_TOOL;
@@ -2563,6 +3065,7 @@ exports.realtimeCollaborationPlugin = realtimeCollaborationPlugin;
2563
3065
  exports.realtimeCommentsPlugin = realtimeCommentsPlugin;
2564
3066
  exports.realtimeSessionPlugin = realtimeSessionPlugin;
2565
3067
  exports.remoteMarkupStrokeFromPlacementPreview = remoteMarkupStrokeFromPlacementPreview;
3068
+ exports.useRealtimeCanvasDocument = useRealtimeCanvasDocument;
2566
3069
  exports.useRealtimeComments = useRealtimeComments;
2567
3070
  exports.useRealtimePeerFollow = useRealtimePeerFollow;
2568
3071
  exports.useRealtimeSession = useRealtimeSession;