canvu-react 0.3.10 → 0.3.12

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
@@ -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,102 @@ 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 };
1990
- const didSend = sendRaw({
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
+ };
2203
+ const didSend = sendRawRef.current({
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]);
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
+ scheduleDocumentFlushRef.current();
2239
+ },
2240
+ [roomId, scheduleDraftPersistence, setLocalDraft]
2241
+ );
2242
+ const applyDraftSnapshot = react.useCallback(
2243
+ (draft, options2) => {
2244
+ applyDocument(buildDraftSnapshot(draft, clientIdRef.current), options2);
2245
+ },
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
+ clearLocalDraftRef.current();
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;
2006
2284
  },
2007
- [flushQueuedDocument]
2285
+ [applyDocument, applyDraftSnapshot, scheduleDocumentFlush, setConflictState]
2008
2286
  );
2009
2287
  const sendPresenceUpdate = react.useCallback(() => {
2010
2288
  sendRaw({
@@ -2043,18 +2321,129 @@ function useRealtimeSession(options) {
2043
2321
  reconnect,
2044
2322
  updateConnection
2045
2323
  ]);
2324
+ const resolveConflict = react.useCallback(
2325
+ (action) => {
2326
+ const activeConflict = conflictRef.current;
2327
+ if (!activeConflict) return;
2328
+ if (action === "use-server") {
2329
+ clearLocalDraft();
2330
+ queuedItemsRef.current = null;
2331
+ outboundInFlightRef.current = null;
2332
+ setConflictState(null);
2333
+ applyDocument({
2334
+ revision: activeConflict.serverRevision,
2335
+ items: activeConflict.serverItems,
2336
+ updatedAt: nowMs()
2337
+ });
2338
+ return;
2339
+ }
2340
+ const nextDraft = {
2341
+ roomId,
2342
+ baseRevision: activeConflict.serverRevision,
2343
+ items: activeConflict.localItems,
2344
+ updatedAt: nowMs()
2345
+ };
2346
+ setLocalDraft(nextDraft);
2347
+ scheduleDraftPersistence();
2348
+ setConflictState(null);
2349
+ applyDraftSnapshot(nextDraft, {
2350
+ suppressSubscriberNotify: sameSerializedItems(
2351
+ latestDocumentRef.current?.items,
2352
+ nextDraft.items
2353
+ )
2354
+ });
2355
+ currentRevisionRef.current = activeConflict.serverRevision;
2356
+ queuedItemsRef.current = nextDraft.items;
2357
+ outboundInFlightRef.current = null;
2358
+ setHasPendingDocumentSync(true);
2359
+ scheduleDocumentFlush();
2360
+ },
2361
+ [
2362
+ applyDocument,
2363
+ applyDraftSnapshot,
2364
+ clearLocalDraft,
2365
+ roomId,
2366
+ scheduleDocumentFlush,
2367
+ scheduleDraftPersistence,
2368
+ setConflictState,
2369
+ setLocalDraft
2370
+ ]
2371
+ );
2372
+ const setConflictStateRef = react.useRef(setConflictState);
2373
+ setConflictStateRef.current = setConflictState;
2374
+ const updateConnectionRef = react.useRef(updateConnection);
2375
+ updateConnectionRef.current = updateConnection;
2376
+ const applyDocumentRef = react.useRef(applyDocument);
2377
+ applyDocumentRef.current = applyDocument;
2378
+ const clearLocalDraftRef = react.useRef(clearLocalDraft);
2379
+ clearLocalDraftRef.current = clearLocalDraft;
2380
+ const collapsePeersToSelfRef = react.useRef(collapsePeersToSelf);
2381
+ collapsePeersToSelfRef.current = collapsePeersToSelf;
2382
+ const applyPeersRef = react.useRef(applyPeers);
2383
+ applyPeersRef.current = applyPeers;
2384
+ const resolveAuthoritativeDocumentRef = react.useRef(resolveAuthoritativeDocument);
2385
+ resolveAuthoritativeDocumentRef.current = resolveAuthoritativeDocument;
2386
+ const scheduleDocumentFlushRef = react.useRef(scheduleDocumentFlush);
2387
+ scheduleDocumentFlushRef.current = scheduleDocumentFlush;
2388
+ const scheduleReconnectRef = react.useRef(scheduleReconnect);
2389
+ scheduleReconnectRef.current = scheduleReconnect;
2390
+ const sendRawRef = react.useRef(sendRaw);
2391
+ sendRawRef.current = sendRaw;
2392
+ react.useEffect(() => {
2393
+ if (!roomId) {
2394
+ clearDocumentFlushSchedule();
2395
+ clearDraftPersistSchedule();
2396
+ localDraftRef.current = null;
2397
+ setHasLocalOfflineDraft(false);
2398
+ setHasPendingDocumentSync(false);
2399
+ setConflictState(null);
2400
+ queuedItemsRef.current = null;
2401
+ outboundInFlightRef.current = null;
2402
+ latestDocumentRef.current = null;
2403
+ setDocument(null);
2404
+ currentRevisionRef.current = 0;
2405
+ return;
2406
+ }
2407
+ const localDraft = readRealtimeOfflineDraft(roomId);
2408
+ setLocalDraft(localDraft);
2409
+ setHasPendingDocumentSync(localDraft != null);
2410
+ setConflictState(null);
2411
+ queuedItemsRef.current = localDraft?.items ?? null;
2412
+ outboundInFlightRef.current = null;
2413
+ if (localDraft) {
2414
+ applyDraftSnapshot(localDraft, {
2415
+ suppressSubscriberNotify: sameSerializedItems(
2416
+ latestDocumentRef.current?.items,
2417
+ localDraft.items
2418
+ )
2419
+ });
2420
+ } else {
2421
+ latestDocumentRef.current = null;
2422
+ setDocument(null);
2423
+ currentRevisionRef.current = 0;
2424
+ }
2425
+ }, [
2426
+ applyDraftSnapshot,
2427
+ clearDocumentFlushSchedule,
2428
+ clearDraftPersistSchedule,
2429
+ roomId,
2430
+ setConflictState,
2431
+ setLocalDraft
2432
+ ]);
2046
2433
  react.useEffect(() => {
2047
2434
  if (!enabled) {
2048
2435
  manualDisconnectRef.current = true;
2049
2436
  clearReconnectTimer();
2050
2437
  clearHeartbeatTimer();
2051
2438
  clearConnectTimeout();
2439
+ clearDocumentFlushSchedule();
2052
2440
  wsRef.current?.close();
2053
2441
  wsRef.current = null;
2054
- setSessionPeers([]);
2055
- queuedItemsRef.current = null;
2442
+ queuedItemsRef.current = localDraftRef.current?.items ?? null;
2056
2443
  outboundInFlightRef.current = null;
2057
- updateConnection((prev) => ({
2444
+ setHasPendingDocumentSync(localDraftRef.current != null);
2445
+ collapsePeersToSelfRef.current("offline");
2446
+ updateConnectionRef.current((prev) => ({
2058
2447
  ...prev,
2059
2448
  state: "offline",
2060
2449
  connected: false,
@@ -2065,8 +2454,19 @@ function useRealtimeSession(options) {
2065
2454
  }
2066
2455
  manualDisconnectRef.current = false;
2067
2456
  const socketUrl = normalizeSocketUrl(url);
2457
+ if (!socketUrl) {
2458
+ collapsePeersToSelfRef.current("offline");
2459
+ updateConnectionRef.current((prev) => ({
2460
+ ...prev,
2461
+ state: "offline",
2462
+ connected: false,
2463
+ roomId,
2464
+ lastError: null
2465
+ }));
2466
+ return;
2467
+ }
2068
2468
  if (!isValidSocketUrl(socketUrl)) {
2069
- updateConnection((prev) => ({
2469
+ updateConnectionRef.current((prev) => ({
2070
2470
  ...prev,
2071
2471
  state: "error",
2072
2472
  connected: false,
@@ -2077,7 +2477,7 @@ function useRealtimeSession(options) {
2077
2477
  return;
2078
2478
  }
2079
2479
  let disposed = false;
2080
- updateConnection((prev) => ({
2480
+ updateConnectionRef.current((prev) => ({
2081
2481
  ...prev,
2082
2482
  state: retryCountRef.current > 0 ? "reconnecting" : "connecting",
2083
2483
  connected: false,
@@ -2095,7 +2495,7 @@ function useRealtimeSession(options) {
2095
2495
  socket.addEventListener("open", () => {
2096
2496
  if (disposed) return;
2097
2497
  clearConnectTimeout();
2098
- sendRaw({
2498
+ sendRawRef.current({
2099
2499
  type: "session:join",
2100
2500
  roomId,
2101
2501
  peer: {
@@ -2108,7 +2508,7 @@ function useRealtimeSession(options) {
2108
2508
  });
2109
2509
  clearHeartbeatTimer();
2110
2510
  heartbeatTimerRef.current = window.setInterval(() => {
2111
- sendRaw({
2511
+ sendRawRef.current({
2112
2512
  type: "session:ping",
2113
2513
  roomId,
2114
2514
  clientId: clientIdRef.current,
@@ -2128,15 +2528,13 @@ function useRealtimeSession(options) {
2128
2528
  }
2129
2529
  const parsed = parseRealtimeServerMessage(payload);
2130
2530
  if (!parsed) return;
2131
- updateConnection((prev) => ({
2531
+ updateConnectionRef.current((prev) => ({
2132
2532
  ...prev,
2133
2533
  lastMessageAt: nowMs()
2134
2534
  }));
2135
2535
  if (parsed.type === "session:welcome") {
2136
2536
  retryCountRef.current = 0;
2137
- const queuedBeforeWelcome = queuedItemsRef.current;
2138
- const shouldPromoteQueuedLocalDraft = queuedBeforeWelcome != null && parsed.document.revision === 0 && parsed.document.items.length === 0;
2139
- updateConnection((prev) => ({
2537
+ updateConnectionRef.current((prev) => ({
2140
2538
  ...prev,
2141
2539
  state: "connected",
2142
2540
  connected: true,
@@ -2145,19 +2543,26 @@ function useRealtimeSession(options) {
2145
2543
  lastConnectedAt: nowMs(),
2146
2544
  lastError: null
2147
2545
  }));
2148
- applyPeers(parsed.peers);
2149
- applyDocument(parsed.document, {
2150
- suppressSubscriberNotify: shouldPromoteQueuedLocalDraft
2151
- });
2152
- if (!shouldPromoteQueuedLocalDraft) {
2546
+ applyPeersRef.current(parsed.peers);
2547
+ const handledByDraft = resolveAuthoritativeDocumentRef.current(
2548
+ sanitizeRealtimeSnapshot(parsed.document),
2549
+ {
2550
+ suppressSubscriberNotify: localDraftRef.current != null && sameSerializedItems(
2551
+ latestDocumentRef.current?.items,
2552
+ localDraftRef.current.items
2553
+ )
2554
+ }
2555
+ );
2556
+ if (!handledByDraft) {
2153
2557
  queuedItemsRef.current = null;
2154
2558
  outboundInFlightRef.current = null;
2559
+ setHasPendingDocumentSync(false);
2155
2560
  }
2156
- flushQueuedDocument();
2561
+ scheduleDocumentFlushRef.current();
2157
2562
  return;
2158
2563
  }
2159
2564
  if (parsed.type === "presence:sync") {
2160
- applyPeers(parsed.peers);
2565
+ applyPeersRef.current(parsed.peers);
2161
2566
  return;
2162
2567
  }
2163
2568
  if (parsed.type === "session:peer-joined") {
@@ -2179,14 +2584,14 @@ function useRealtimeSession(options) {
2179
2584
  return;
2180
2585
  }
2181
2586
  if (parsed.type === "session:pong") {
2182
- updateConnection((prev) => ({
2587
+ updateConnectionRef.current((prev) => ({
2183
2588
  ...prev,
2184
2589
  lastPongAt: parsed.serverTime
2185
2590
  }));
2186
2591
  return;
2187
2592
  }
2188
2593
  if (parsed.type === "session:error") {
2189
- updateConnection((prev) => ({
2594
+ updateConnectionRef.current((prev) => ({
2190
2595
  ...prev,
2191
2596
  state: prev.connected ? prev.state : "error",
2192
2597
  lastError: parsed.message
@@ -2200,35 +2605,48 @@ function useRealtimeSession(options) {
2200
2605
  const isSelfAck = parsed.document.updatedByClientId === selfClientId;
2201
2606
  if (!isSelfAck) {
2202
2607
  outboundInFlightRef.current = null;
2203
- queuedItemsRef.current = null;
2204
- applyDocument(parsed.document);
2608
+ queuedItemsRef.current = localDraftRef.current?.items ?? null;
2609
+ setHasPendingDocumentSync(queuedItemsRef.current != null);
2610
+ resolveAuthoritativeDocumentRef.current(
2611
+ sanitizeRealtimeSnapshot(parsed.document)
2612
+ );
2205
2613
  return;
2206
2614
  }
2207
2615
  const shouldSuppress = inFlight != null && sameSerializedItems(inFlight.items, parsed.document.items);
2208
2616
  outboundInFlightRef.current = null;
2209
- applyDocument(parsed.document, { suppressSubscriberNotify: shouldSuppress });
2617
+ applyDocumentRef.current(sanitizeRealtimeSnapshot(parsed.document), {
2618
+ suppressSubscriberNotify: shouldSuppress
2619
+ });
2210
2620
  if (queuedItemsRef.current) {
2211
2621
  if (sameSerializedItems(queuedItemsRef.current, parsed.document.items)) {
2212
2622
  queuedItemsRef.current = null;
2213
2623
  } else {
2214
- flushQueuedDocument();
2624
+ scheduleDocumentFlushRef.current();
2215
2625
  }
2216
2626
  }
2627
+ if (!queuedItemsRef.current) {
2628
+ clearLocalDraftRef.current();
2629
+ }
2630
+ setHasPendingDocumentSync(queuedItemsRef.current != null);
2631
+ setConflictStateRef.current(null);
2217
2632
  return;
2218
2633
  }
2219
2634
  if (parsed.type === "document:resync-required") {
2220
2635
  outboundInFlightRef.current = null;
2221
- queuedItemsRef.current = null;
2222
- updateConnection((prev) => ({
2636
+ queuedItemsRef.current = localDraftRef.current?.items ?? null;
2637
+ setHasPendingDocumentSync(queuedItemsRef.current != null);
2638
+ updateConnectionRef.current((prev) => ({
2223
2639
  ...prev,
2224
2640
  lastError: parsed.reason
2225
2641
  }));
2226
- applyDocument(parsed.document);
2642
+ resolveAuthoritativeDocumentRef.current(
2643
+ sanitizeRealtimeSnapshot(parsed.document)
2644
+ );
2227
2645
  }
2228
2646
  });
2229
2647
  socket.addEventListener("error", () => {
2230
2648
  if (disposed) return;
2231
- updateConnection((prev) => ({
2649
+ updateConnectionRef.current((prev) => ({
2232
2650
  ...prev,
2233
2651
  state: prev.connected ? prev.state : "error",
2234
2652
  lastError: "Falha de conex\xE3o websocket."
@@ -2239,14 +2657,17 @@ function useRealtimeSession(options) {
2239
2657
  clearHeartbeatTimer();
2240
2658
  clearConnectTimeout();
2241
2659
  wsRef.current = null;
2242
- updateConnection((prev) => ({
2660
+ collapsePeersToSelfRef.current(
2661
+ manualDisconnectRef.current || !enabled ? "offline" : "reconnecting"
2662
+ );
2663
+ updateConnectionRef.current((prev) => ({
2243
2664
  ...prev,
2244
2665
  connected: false,
2245
2666
  clientId: prev.clientId,
2246
2667
  state: manualDisconnectRef.current || !enabled ? "offline" : prev.state
2247
2668
  }));
2248
2669
  if (!manualDisconnectRef.current && enabled) {
2249
- scheduleReconnect();
2670
+ scheduleReconnectRef.current();
2250
2671
  }
2251
2672
  });
2252
2673
  return () => {
@@ -2254,27 +2675,23 @@ function useRealtimeSession(options) {
2254
2675
  clearReconnectTimer();
2255
2676
  clearHeartbeatTimer();
2256
2677
  clearConnectTimeout();
2678
+ clearDocumentFlushSchedule();
2257
2679
  socket.close();
2258
2680
  };
2259
2681
  }, [
2260
- applyDocument,
2261
- applyPeers,
2262
2682
  clearConnectTimeout,
2683
+ clearDocumentFlushSchedule,
2263
2684
  clearHeartbeatTimer,
2264
2685
  clearReconnectTimer,
2265
2686
  connectSequence,
2266
2687
  connectTimeoutMs,
2267
2688
  enabled,
2268
- flushQueuedDocument,
2269
2689
  heartbeatMs,
2270
2690
  peer.color,
2271
2691
  peer.displayName,
2272
- peer.image,
2273
2692
  peer.id,
2693
+ peer.image,
2274
2694
  roomId,
2275
- scheduleReconnect,
2276
- sendRaw,
2277
- updateConnection,
2278
2695
  url
2279
2696
  ]);
2280
2697
  react.useEffect(() => {
@@ -2288,6 +2705,18 @@ function useRealtimeSession(options) {
2288
2705
  )
2289
2706
  );
2290
2707
  }, [connection.state]);
2708
+ react.useEffect(
2709
+ () => () => {
2710
+ clearDocumentFlushSchedule();
2711
+ clearDraftPersistSchedule();
2712
+ },
2713
+ [clearDocumentFlushSchedule, clearDraftPersistSchedule]
2714
+ );
2715
+ const flushDocumentSync = react.useCallback(async () => {
2716
+ persistLocalDraft();
2717
+ if (!connection.connected) return;
2718
+ flushQueuedDocument();
2719
+ }, [connection.connected, flushQueuedDocument, persistLocalDraft]);
2291
2720
  const remoteAdapter = react.useMemo(
2292
2721
  () => ({
2293
2722
  subscribe(onItems) {
@@ -2301,9 +2730,12 @@ function useRealtimeSession(options) {
2301
2730
  },
2302
2731
  send(items) {
2303
2732
  queueDocumentSend(items);
2733
+ },
2734
+ flush() {
2735
+ return flushDocumentSync();
2304
2736
  }
2305
2737
  }),
2306
- [queueDocumentSend]
2738
+ [flushDocumentSync, queueDocumentSend]
2307
2739
  );
2308
2740
  const disconnect = react.useCallback(() => {
2309
2741
  manualDisconnectRef.current = true;
@@ -2317,6 +2749,7 @@ function useRealtimeSession(options) {
2317
2749
  });
2318
2750
  wsRef.current?.close();
2319
2751
  wsRef.current = null;
2752
+ collapsePeersToSelf("offline");
2320
2753
  updateConnection((prev) => ({
2321
2754
  ...prev,
2322
2755
  state: "offline",
@@ -2326,6 +2759,7 @@ function useRealtimeSession(options) {
2326
2759
  clearConnectTimeout,
2327
2760
  clearHeartbeatTimer,
2328
2761
  clearReconnectTimer,
2762
+ collapsePeersToSelf,
2329
2763
  roomId,
2330
2764
  sendRaw,
2331
2765
  updateConnection
@@ -2391,8 +2825,15 @@ function useRealtimeSession(options) {
2391
2825
  remotePresence,
2392
2826
  remoteAdapter,
2393
2827
  document: document2,
2828
+ hasLocalOfflineDraft,
2829
+ hasPendingDocumentSync,
2830
+ syncState,
2831
+ conflict,
2394
2832
  bindViewportPresence,
2395
2833
  syncViewportPresence,
2834
+ resolveConflict,
2835
+ clearLocalDraft,
2836
+ flushDocumentSync,
2396
2837
  disconnect,
2397
2838
  reconnectNow
2398
2839
  };
@@ -2545,6 +2986,75 @@ function realtimeSessionPlugin(options) {
2545
2986
  render: () => /* @__PURE__ */ jsxRuntime.jsx(RealtimeSessionPanel, { ...options })
2546
2987
  };
2547
2988
  }
2989
+ function useRealtimeCanvasDocument(options) {
2990
+ const {
2991
+ session,
2992
+ items,
2993
+ onItemsChange,
2994
+ normalizeItems,
2995
+ hydrateItems,
2996
+ enabled = true
2997
+ } = options;
2998
+ const [loading, setLoading] = react.useState(false);
2999
+ const lastAppliedRevisionRef = react.useRef(null);
3000
+ const realtimeEnabled = enabled && session != null;
3001
+ const applyIncomingItems = react.useCallback(
3002
+ async (nextItems) => {
3003
+ const normalizedItems = normalizeItems ? normalizeItems(nextItems) : [...nextItems];
3004
+ if (!hydrateItems) return normalizedItems;
3005
+ return await hydrateItems(normalizedItems);
3006
+ },
3007
+ [hydrateItems, normalizeItems]
3008
+ );
3009
+ const handleItemsChange = react.useCallback(
3010
+ (nextItems) => {
3011
+ if (!enabled) {
3012
+ onItemsChange?.(normalizeItems ? normalizeItems(nextItems) : nextItems);
3013
+ return;
3014
+ }
3015
+ const normalizedItems = normalizeItems ? normalizeItems(nextItems) : nextItems;
3016
+ onItemsChange?.(normalizedItems);
3017
+ session?.remoteAdapter.send?.(normalizedItems);
3018
+ },
3019
+ [enabled, normalizeItems, onItemsChange, session]
3020
+ );
3021
+ react.useEffect(() => {
3022
+ if (!realtimeEnabled || !onItemsChange || !session?.document) return;
3023
+ if (session.document.updatedByClientId === session.connection.clientId) return;
3024
+ if (lastAppliedRevisionRef.current === session.document.revision) return;
3025
+ let cancelled = false;
3026
+ setLoading(true);
3027
+ void applyIncomingItems(session.document.items).then((resolvedItems) => {
3028
+ if (cancelled) return;
3029
+ lastAppliedRevisionRef.current = session.document?.revision ?? null;
3030
+ onItemsChange(resolvedItems);
3031
+ }).finally(() => {
3032
+ if (cancelled) return;
3033
+ setLoading(false);
3034
+ });
3035
+ return () => {
3036
+ cancelled = true;
3037
+ };
3038
+ }, [applyIncomingItems, realtimeEnabled, onItemsChange, session]);
3039
+ return react.useMemo(
3040
+ () => ({
3041
+ items,
3042
+ onItemsChange: onItemsChange ? handleItemsChange : void 0,
3043
+ loading,
3044
+ saving: session?.hasPendingDocumentSync ?? false,
3045
+ hasLocalOfflineDraft: session?.hasLocalOfflineDraft ?? false,
3046
+ syncState: session?.syncState ?? "offline",
3047
+ conflict: session?.conflict ?? null,
3048
+ resolveConflict: session?.resolveConflict ?? (() => {
3049
+ }),
3050
+ clearLocalDraft: session?.clearLocalDraft ?? (() => {
3051
+ }),
3052
+ flush: session?.flushDocumentSync ?? (async () => {
3053
+ })
3054
+ }),
3055
+ [handleItemsChange, items, loading, onItemsChange, session]
3056
+ );
3057
+ }
2548
3058
 
2549
3059
  exports.PresenceRemoteLayer = PresenceRemoteLayer;
2550
3060
  exports.REALTIME_COMMENT_TOOL = REALTIME_COMMENT_TOOL;
@@ -2563,6 +3073,7 @@ exports.realtimeCollaborationPlugin = realtimeCollaborationPlugin;
2563
3073
  exports.realtimeCommentsPlugin = realtimeCommentsPlugin;
2564
3074
  exports.realtimeSessionPlugin = realtimeSessionPlugin;
2565
3075
  exports.remoteMarkupStrokeFromPlacementPreview = remoteMarkupStrokeFromPlacementPreview;
3076
+ exports.useRealtimeCanvasDocument = useRealtimeCanvasDocument;
2566
3077
  exports.useRealtimeComments = useRealtimeComments;
2567
3078
  exports.useRealtimePeerFollow = useRealtimePeerFollow;
2568
3079
  exports.useRealtimeSession = useRealtimeSession;