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.js CHANGED
@@ -1742,7 +1742,7 @@ function useRealtimePeerFollow(options) {
1742
1742
  const { viewportRef, sessionPeers, followedPeerId, onFollowEnd } = options;
1743
1743
  const endedPeerIdRef = useRef(null);
1744
1744
  const lastAppliedCameraKeyRef = useRef(null);
1745
- const [viewportSizeVersion, setViewportSizeVersion] = useState(0);
1745
+ const [, setViewportSizeVersion] = useState(0);
1746
1746
  useEffect(() => {
1747
1747
  if (!followedPeerId) return;
1748
1748
  let animationFrameId = 0;
@@ -1794,16 +1794,27 @@ function useRealtimePeerFollow(options) {
1794
1794
  return;
1795
1795
  }
1796
1796
  lastAppliedCameraKeyRef.current = nextCameraKey;
1797
- }, [
1798
- followedPeerId,
1799
- onFollowEnd,
1800
- sessionPeers,
1801
- viewportRef,
1802
- viewportSizeVersion
1803
- ]);
1797
+ }, [followedPeerId, onFollowEnd, sessionPeers, viewportRef]);
1804
1798
  }
1805
1799
 
1806
1800
  // src/react/plugins/realtime/use-realtime-session.ts
1801
+ var DRAFT_STORAGE_PREFIX = "canvu-realtime-draft:";
1802
+ var DOCUMENT_FLUSH_DEBOUNCE_MS = 120;
1803
+ var DRAFT_PERSIST_DEBOUNCE_MS = 420;
1804
+ function requestWindowIdleCallback(callback, timeout) {
1805
+ if (typeof window.requestIdleCallback === "function") {
1806
+ return window.requestIdleCallback(callback, { timeout });
1807
+ }
1808
+ return window.setTimeout(callback, timeout);
1809
+ }
1810
+ function cancelWindowIdleCallback(handle) {
1811
+ if (handle == null) return;
1812
+ if (typeof window.cancelIdleCallback === "function") {
1813
+ window.cancelIdleCallback(handle);
1814
+ return;
1815
+ }
1816
+ window.clearTimeout(handle);
1817
+ }
1807
1818
  function createClientId() {
1808
1819
  if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
1809
1820
  return crypto.randomUUID();
@@ -1811,6 +1822,7 @@ function createClientId() {
1811
1822
  return `client-${Math.random().toString(36).slice(2, 10)}`;
1812
1823
  }
1813
1824
  function normalizeSocketUrl(input) {
1825
+ if (!input) return "";
1814
1826
  if (input.startsWith("ws://") || input.startsWith("wss://")) return input;
1815
1827
  if (input.startsWith("http://")) return `ws://${input.slice("http://".length)}`;
1816
1828
  if (input.startsWith("https://")) return `wss://${input.slice("https://".length)}`;
@@ -1829,9 +1841,29 @@ function isValidSocketUrl(input) {
1829
1841
  return false;
1830
1842
  }
1831
1843
  }
1844
+ function sanitizeRealtimeItem(item) {
1845
+ if (item.toolKind !== "image") return item;
1846
+ return {
1847
+ ...item,
1848
+ imageBlobId: void 0,
1849
+ imageThumbnailBlobId: void 0,
1850
+ imageRasterHref: void 0,
1851
+ imageThumbnailHref: void 0,
1852
+ childrenSvg: ""
1853
+ };
1854
+ }
1855
+ function sanitizeRealtimeItems(items) {
1856
+ return items.map(sanitizeRealtimeItem);
1857
+ }
1858
+ function sanitizeRealtimeSnapshot(snapshot) {
1859
+ return {
1860
+ ...snapshot,
1861
+ items: sanitizeRealtimeItems(snapshot.items)
1862
+ };
1863
+ }
1832
1864
  function serializeItems(items) {
1833
1865
  try {
1834
- return JSON.stringify(items);
1866
+ return JSON.stringify(sanitizeRealtimeItems(items));
1835
1867
  } catch {
1836
1868
  return null;
1837
1869
  }
@@ -1846,6 +1878,58 @@ function sameSerializedItems(left, right) {
1846
1878
  function nowMs() {
1847
1879
  return Date.now();
1848
1880
  }
1881
+ function draftStorageKey(roomId) {
1882
+ return `${DRAFT_STORAGE_PREFIX}${roomId}`;
1883
+ }
1884
+ function isRealtimeOfflineDraft(value) {
1885
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
1886
+ const record = value;
1887
+ return typeof record.roomId === "string" && typeof record.baseRevision === "number" && typeof record.updatedAt === "number" && Array.isArray(record.items);
1888
+ }
1889
+ function readRealtimeOfflineDraft(roomId) {
1890
+ if (typeof window === "undefined" || !roomId) return null;
1891
+ try {
1892
+ const raw = window.localStorage.getItem(draftStorageKey(roomId));
1893
+ if (!raw) return null;
1894
+ const parsed = JSON.parse(raw);
1895
+ if (!isRealtimeOfflineDraft(parsed)) return null;
1896
+ return {
1897
+ ...parsed,
1898
+ items: sanitizeRealtimeItems(parsed.items)
1899
+ };
1900
+ } catch {
1901
+ return null;
1902
+ }
1903
+ }
1904
+ function writeRealtimeOfflineDraft(draft) {
1905
+ if (typeof window === "undefined") return;
1906
+ if (!draft) return;
1907
+ try {
1908
+ window.localStorage.setItem(
1909
+ draftStorageKey(draft.roomId),
1910
+ JSON.stringify({
1911
+ ...draft,
1912
+ items: sanitizeRealtimeItems(draft.items)
1913
+ })
1914
+ );
1915
+ } catch {
1916
+ }
1917
+ }
1918
+ function removeRealtimeOfflineDraft(roomId) {
1919
+ if (typeof window === "undefined" || !roomId) return;
1920
+ try {
1921
+ window.localStorage.removeItem(draftStorageKey(roomId));
1922
+ } catch {
1923
+ }
1924
+ }
1925
+ function buildDraftSnapshot(draft, clientId) {
1926
+ return {
1927
+ revision: draft.baseRevision,
1928
+ items: draft.items,
1929
+ updatedAt: draft.updatedAt,
1930
+ updatedByClientId: clientId
1931
+ };
1932
+ }
1849
1933
  function getViewportCameraSnapshot(viewport) {
1850
1934
  if (!viewport) return null;
1851
1935
  const camera = viewport.getCamera();
@@ -1859,6 +1943,17 @@ function getViewportCameraSnapshot(viewport) {
1859
1943
  viewportHeight: viewportSize.height
1860
1944
  };
1861
1945
  }
1946
+ function prepareRealtimeItems(items) {
1947
+ try {
1948
+ const sanitizedItems = sanitizeRealtimeItems(items);
1949
+ return {
1950
+ items: sanitizedItems,
1951
+ serialized: JSON.stringify(sanitizedItems)
1952
+ };
1953
+ } catch {
1954
+ return null;
1955
+ }
1956
+ }
1862
1957
  function useRealtimeSession(options) {
1863
1958
  const {
1864
1959
  url,
@@ -1877,6 +1972,10 @@ function useRealtimeSession(options) {
1877
1972
  const reconnectTimerRef = useRef(null);
1878
1973
  const heartbeatTimerRef = useRef(null);
1879
1974
  const connectTimeoutRef = useRef(null);
1975
+ const documentFlushTimerRef = useRef(null);
1976
+ const documentFlushIdleRef = useRef(null);
1977
+ const draftPersistTimerRef = useRef(null);
1978
+ const draftPersistIdleRef = useRef(null);
1880
1979
  const manualDisconnectRef = useRef(false);
1881
1980
  const retryCountRef = useRef(0);
1882
1981
  const currentRevisionRef = useRef(0);
@@ -1893,6 +1992,8 @@ function useRealtimeSession(options) {
1893
1992
  const connectionStateRef = useRef(
1894
1993
  enabled ? "connecting" : "offline"
1895
1994
  );
1995
+ const localDraftRef = useRef(null);
1996
+ const conflictRef = useRef(null);
1896
1997
  const onErrorRef = useRef(onError);
1897
1998
  onErrorRef.current = onError;
1898
1999
  const [connectSequence, setConnectSequence] = useState(0);
@@ -1909,7 +2010,18 @@ function useRealtimeSession(options) {
1909
2010
  });
1910
2011
  const [sessionPeers, setSessionPeers] = useState([]);
1911
2012
  const [document2, setDocument] = useState(null);
2013
+ const [hasLocalOfflineDraft, setHasLocalOfflineDraft] = useState(false);
2014
+ const [hasPendingDocumentSync, setHasPendingDocumentSync] = useState(false);
2015
+ const [conflict, setConflict] = useState(null);
1912
2016
  connectionStateRef.current = connection.state;
2017
+ const syncState = useMemo(() => {
2018
+ if (conflict != null) return "conflicted";
2019
+ if (connection.connected) return "connected";
2020
+ if (connection.state === "reconnecting" || connection.state === "connecting") {
2021
+ return "reconnecting";
2022
+ }
2023
+ return "offline";
2024
+ }, [conflict, connection.connected, connection.state]);
1913
2025
  const clearReconnectTimer = useCallback(() => {
1914
2026
  if (reconnectTimerRef.current != null) {
1915
2027
  window.clearTimeout(reconnectTimerRef.current);
@@ -1928,6 +2040,22 @@ function useRealtimeSession(options) {
1928
2040
  connectTimeoutRef.current = null;
1929
2041
  }
1930
2042
  }, []);
2043
+ const clearDocumentFlushSchedule = useCallback(() => {
2044
+ if (documentFlushTimerRef.current != null) {
2045
+ window.clearTimeout(documentFlushTimerRef.current);
2046
+ documentFlushTimerRef.current = null;
2047
+ }
2048
+ cancelWindowIdleCallback(documentFlushIdleRef.current);
2049
+ documentFlushIdleRef.current = null;
2050
+ }, []);
2051
+ const clearDraftPersistSchedule = useCallback(() => {
2052
+ if (draftPersistTimerRef.current != null) {
2053
+ window.clearTimeout(draftPersistTimerRef.current);
2054
+ draftPersistTimerRef.current = null;
2055
+ }
2056
+ cancelWindowIdleCallback(draftPersistIdleRef.current);
2057
+ draftPersistIdleRef.current = null;
2058
+ }, []);
1931
2059
  const updateConnection = useCallback(
1932
2060
  (patch) => {
1933
2061
  setConnection((prev) => {
@@ -1953,6 +2081,84 @@ function useRealtimeSession(options) {
1953
2081
  },
1954
2082
  [notifySubscribers]
1955
2083
  );
2084
+ const setConflictState = useCallback(
2085
+ (nextConflict) => {
2086
+ conflictRef.current = nextConflict;
2087
+ setConflict(nextConflict);
2088
+ },
2089
+ []
2090
+ );
2091
+ const setLocalDraft = useCallback(
2092
+ (nextDraft) => {
2093
+ localDraftRef.current = nextDraft;
2094
+ setHasLocalOfflineDraft(nextDraft != null);
2095
+ if (nextDraft) return;
2096
+ removeRealtimeOfflineDraft(roomId);
2097
+ },
2098
+ [roomId]
2099
+ );
2100
+ const persistLocalDraft = useCallback(() => {
2101
+ clearDraftPersistSchedule();
2102
+ if (!localDraftRef.current) {
2103
+ removeRealtimeOfflineDraft(roomId);
2104
+ return;
2105
+ }
2106
+ writeRealtimeOfflineDraft(localDraftRef.current);
2107
+ }, [clearDraftPersistSchedule, roomId]);
2108
+ const scheduleDraftPersistence = useCallback(() => {
2109
+ clearDraftPersistSchedule();
2110
+ draftPersistTimerRef.current = window.setTimeout(() => {
2111
+ draftPersistTimerRef.current = null;
2112
+ draftPersistIdleRef.current = requestWindowIdleCallback(() => {
2113
+ draftPersistIdleRef.current = null;
2114
+ persistLocalDraft();
2115
+ }, DRAFT_PERSIST_DEBOUNCE_MS);
2116
+ }, DRAFT_PERSIST_DEBOUNCE_MS);
2117
+ }, [clearDraftPersistSchedule, persistLocalDraft]);
2118
+ const clearLocalDraft = useCallback(() => {
2119
+ setLocalDraft(null);
2120
+ clearDraftPersistSchedule();
2121
+ }, [clearDraftPersistSchedule, setLocalDraft]);
2122
+ const createSelfPeer = useCallback(
2123
+ (state) => ({
2124
+ id: peer.id,
2125
+ clientId: clientIdRef.current,
2126
+ peerId: peer.id,
2127
+ roomId,
2128
+ joinedAt: connection.lastConnectedAt ?? nowMs(),
2129
+ lastSeenAt: nowMs(),
2130
+ cursor: lastCursorRef.current,
2131
+ isSelf: true,
2132
+ connectionState: state,
2133
+ ...peer.displayName ? { displayName: peer.displayName } : {},
2134
+ ...peer.color ? { color: peer.color } : {},
2135
+ ...peer.image ? { image: peer.image } : {},
2136
+ ...lastMarkupStrokeRef.current !== void 0 ? { markupStroke: lastMarkupStrokeRef.current ?? null } : {},
2137
+ ...lastCameraRef.current !== void 0 ? { camera: lastCameraRef.current ?? null } : {},
2138
+ ...lastActiveToolRef.current ? { activeTool: lastActiveToolRef.current } : {}
2139
+ }),
2140
+ [
2141
+ connection.lastConnectedAt,
2142
+ peer.color,
2143
+ peer.displayName,
2144
+ peer.id,
2145
+ peer.image,
2146
+ roomId
2147
+ ]
2148
+ );
2149
+ const collapsePeersToSelf = useCallback(
2150
+ (state) => {
2151
+ setSessionPeers((prev) => {
2152
+ const selfPeer = prev.find(
2153
+ (peerState) => peerState.clientId === clientIdRef.current
2154
+ );
2155
+ return [
2156
+ selfPeer ? { ...selfPeer, isSelf: true, connectionState: state } : createSelfPeer(state)
2157
+ ];
2158
+ });
2159
+ },
2160
+ [createSelfPeer]
2161
+ );
1956
2162
  const applyPeers = useCallback((peers) => {
1957
2163
  const selfClientId = clientIdRef.current;
1958
2164
  setSessionPeers(
@@ -1979,30 +2185,102 @@ function useRealtimeSession(options) {
1979
2185
  [buildClientMessage]
1980
2186
  );
1981
2187
  const flushQueuedDocument = useCallback(() => {
2188
+ clearDocumentFlushSchedule();
2189
+ if (conflictRef.current) return;
1982
2190
  const next = queuedItemsRef.current;
1983
2191
  if (!next || outboundInFlightRef.current) return;
1984
- queuedItemsRef.current = null;
1985
2192
  const baseRevision = currentRevisionRef.current;
1986
- const serialized = serializeItems(next);
1987
- outboundInFlightRef.current = { baseRevision, items: next, serialized };
1988
- const didSend = sendRaw({
2193
+ const preparedItems = prepareRealtimeItems(next);
2194
+ if (!preparedItems) return;
2195
+ queuedItemsRef.current = null;
2196
+ outboundInFlightRef.current = {
2197
+ baseRevision,
2198
+ items: preparedItems.items,
2199
+ serialized: preparedItems.serialized
2200
+ };
2201
+ const didSend = sendRawRef.current({
1989
2202
  type: "document:update",
1990
2203
  roomId,
1991
2204
  clientId: clientIdRef.current,
1992
2205
  baseRevision,
1993
- items: next
2206
+ items: preparedItems.items
1994
2207
  });
1995
2208
  if (!didSend) {
1996
2209
  queuedItemsRef.current = next;
1997
2210
  outboundInFlightRef.current = null;
2211
+ setHasPendingDocumentSync(true);
1998
2212
  }
1999
- }, [roomId, sendRaw]);
2213
+ }, [clearDocumentFlushSchedule, roomId]);
2214
+ const scheduleDocumentFlush = useCallback(() => {
2215
+ clearDocumentFlushSchedule();
2216
+ documentFlushTimerRef.current = window.setTimeout(() => {
2217
+ documentFlushTimerRef.current = null;
2218
+ documentFlushIdleRef.current = requestWindowIdleCallback(() => {
2219
+ documentFlushIdleRef.current = null;
2220
+ flushQueuedDocument();
2221
+ }, DOCUMENT_FLUSH_DEBOUNCE_MS);
2222
+ }, DOCUMENT_FLUSH_DEBOUNCE_MS);
2223
+ }, [clearDocumentFlushSchedule, flushQueuedDocument]);
2000
2224
  const queueDocumentSend = useCallback(
2001
2225
  (items) => {
2226
+ setLocalDraft({
2227
+ roomId,
2228
+ baseRevision: currentRevisionRef.current,
2229
+ items,
2230
+ updatedAt: nowMs()
2231
+ });
2232
+ scheduleDraftPersistence();
2002
2233
  queuedItemsRef.current = items;
2003
- flushQueuedDocument();
2234
+ setHasPendingDocumentSync(true);
2235
+ if (conflictRef.current) return;
2236
+ scheduleDocumentFlushRef.current();
2237
+ },
2238
+ [roomId, scheduleDraftPersistence, setLocalDraft]
2239
+ );
2240
+ const applyDraftSnapshot = useCallback(
2241
+ (draft, options2) => {
2242
+ applyDocument(buildDraftSnapshot(draft, clientIdRef.current), options2);
2243
+ },
2244
+ [applyDocument]
2245
+ );
2246
+ const resolveAuthoritativeDocument = useCallback(
2247
+ (serverDocument, options2) => {
2248
+ const localDraft = localDraftRef.current;
2249
+ if (!localDraft) {
2250
+ setConflictState(null);
2251
+ setHasPendingDocumentSync(false);
2252
+ applyDocument(serverDocument, options2);
2253
+ return false;
2254
+ }
2255
+ if (sameSerializedItems(localDraft.items, serverDocument.items)) {
2256
+ clearLocalDraftRef.current();
2257
+ setHasPendingDocumentSync(false);
2258
+ setConflictState(null);
2259
+ applyDocument(serverDocument, options2);
2260
+ return true;
2261
+ }
2262
+ if (serverDocument.revision === localDraft.baseRevision) {
2263
+ setConflictState(null);
2264
+ applyDraftSnapshot(localDraft, {
2265
+ suppressSubscriberNotify: options2?.suppressSubscriberNotify ?? sameSerializedItems(latestDocumentRef.current?.items, localDraft.items)
2266
+ });
2267
+ outboundInFlightRef.current = null;
2268
+ queuedItemsRef.current = localDraft.items;
2269
+ setHasPendingDocumentSync(true);
2270
+ scheduleDocumentFlush();
2271
+ return true;
2272
+ }
2273
+ setConflictState({
2274
+ serverRevision: serverDocument.revision,
2275
+ serverItems: serverDocument.items,
2276
+ localItems: localDraft.items
2277
+ });
2278
+ applyDraftSnapshot(localDraft, {
2279
+ suppressSubscriberNotify: options2?.suppressSubscriberNotify ?? sameSerializedItems(latestDocumentRef.current?.items, localDraft.items)
2280
+ });
2281
+ return true;
2004
2282
  },
2005
- [flushQueuedDocument]
2283
+ [applyDocument, applyDraftSnapshot, scheduleDocumentFlush, setConflictState]
2006
2284
  );
2007
2285
  const sendPresenceUpdate = useCallback(() => {
2008
2286
  sendRaw({
@@ -2041,18 +2319,129 @@ function useRealtimeSession(options) {
2041
2319
  reconnect,
2042
2320
  updateConnection
2043
2321
  ]);
2322
+ const resolveConflict = useCallback(
2323
+ (action) => {
2324
+ const activeConflict = conflictRef.current;
2325
+ if (!activeConflict) return;
2326
+ if (action === "use-server") {
2327
+ clearLocalDraft();
2328
+ queuedItemsRef.current = null;
2329
+ outboundInFlightRef.current = null;
2330
+ setConflictState(null);
2331
+ applyDocument({
2332
+ revision: activeConflict.serverRevision,
2333
+ items: activeConflict.serverItems,
2334
+ updatedAt: nowMs()
2335
+ });
2336
+ return;
2337
+ }
2338
+ const nextDraft = {
2339
+ roomId,
2340
+ baseRevision: activeConflict.serverRevision,
2341
+ items: activeConflict.localItems,
2342
+ updatedAt: nowMs()
2343
+ };
2344
+ setLocalDraft(nextDraft);
2345
+ scheduleDraftPersistence();
2346
+ setConflictState(null);
2347
+ applyDraftSnapshot(nextDraft, {
2348
+ suppressSubscriberNotify: sameSerializedItems(
2349
+ latestDocumentRef.current?.items,
2350
+ nextDraft.items
2351
+ )
2352
+ });
2353
+ currentRevisionRef.current = activeConflict.serverRevision;
2354
+ queuedItemsRef.current = nextDraft.items;
2355
+ outboundInFlightRef.current = null;
2356
+ setHasPendingDocumentSync(true);
2357
+ scheduleDocumentFlush();
2358
+ },
2359
+ [
2360
+ applyDocument,
2361
+ applyDraftSnapshot,
2362
+ clearLocalDraft,
2363
+ roomId,
2364
+ scheduleDocumentFlush,
2365
+ scheduleDraftPersistence,
2366
+ setConflictState,
2367
+ setLocalDraft
2368
+ ]
2369
+ );
2370
+ const setConflictStateRef = useRef(setConflictState);
2371
+ setConflictStateRef.current = setConflictState;
2372
+ const updateConnectionRef = useRef(updateConnection);
2373
+ updateConnectionRef.current = updateConnection;
2374
+ const applyDocumentRef = useRef(applyDocument);
2375
+ applyDocumentRef.current = applyDocument;
2376
+ const clearLocalDraftRef = useRef(clearLocalDraft);
2377
+ clearLocalDraftRef.current = clearLocalDraft;
2378
+ const collapsePeersToSelfRef = useRef(collapsePeersToSelf);
2379
+ collapsePeersToSelfRef.current = collapsePeersToSelf;
2380
+ const applyPeersRef = useRef(applyPeers);
2381
+ applyPeersRef.current = applyPeers;
2382
+ const resolveAuthoritativeDocumentRef = useRef(resolveAuthoritativeDocument);
2383
+ resolveAuthoritativeDocumentRef.current = resolveAuthoritativeDocument;
2384
+ const scheduleDocumentFlushRef = useRef(scheduleDocumentFlush);
2385
+ scheduleDocumentFlushRef.current = scheduleDocumentFlush;
2386
+ const scheduleReconnectRef = useRef(scheduleReconnect);
2387
+ scheduleReconnectRef.current = scheduleReconnect;
2388
+ const sendRawRef = useRef(sendRaw);
2389
+ sendRawRef.current = sendRaw;
2390
+ useEffect(() => {
2391
+ if (!roomId) {
2392
+ clearDocumentFlushSchedule();
2393
+ clearDraftPersistSchedule();
2394
+ localDraftRef.current = null;
2395
+ setHasLocalOfflineDraft(false);
2396
+ setHasPendingDocumentSync(false);
2397
+ setConflictState(null);
2398
+ queuedItemsRef.current = null;
2399
+ outboundInFlightRef.current = null;
2400
+ latestDocumentRef.current = null;
2401
+ setDocument(null);
2402
+ currentRevisionRef.current = 0;
2403
+ return;
2404
+ }
2405
+ const localDraft = readRealtimeOfflineDraft(roomId);
2406
+ setLocalDraft(localDraft);
2407
+ setHasPendingDocumentSync(localDraft != null);
2408
+ setConflictState(null);
2409
+ queuedItemsRef.current = localDraft?.items ?? null;
2410
+ outboundInFlightRef.current = null;
2411
+ if (localDraft) {
2412
+ applyDraftSnapshot(localDraft, {
2413
+ suppressSubscriberNotify: sameSerializedItems(
2414
+ latestDocumentRef.current?.items,
2415
+ localDraft.items
2416
+ )
2417
+ });
2418
+ } else {
2419
+ latestDocumentRef.current = null;
2420
+ setDocument(null);
2421
+ currentRevisionRef.current = 0;
2422
+ }
2423
+ }, [
2424
+ applyDraftSnapshot,
2425
+ clearDocumentFlushSchedule,
2426
+ clearDraftPersistSchedule,
2427
+ roomId,
2428
+ setConflictState,
2429
+ setLocalDraft
2430
+ ]);
2044
2431
  useEffect(() => {
2045
2432
  if (!enabled) {
2046
2433
  manualDisconnectRef.current = true;
2047
2434
  clearReconnectTimer();
2048
2435
  clearHeartbeatTimer();
2049
2436
  clearConnectTimeout();
2437
+ clearDocumentFlushSchedule();
2050
2438
  wsRef.current?.close();
2051
2439
  wsRef.current = null;
2052
- setSessionPeers([]);
2053
- queuedItemsRef.current = null;
2440
+ queuedItemsRef.current = localDraftRef.current?.items ?? null;
2054
2441
  outboundInFlightRef.current = null;
2055
- updateConnection((prev) => ({
2442
+ setHasPendingDocumentSync(localDraftRef.current != null);
2443
+ collapsePeersToSelfRef.current("offline");
2444
+ updateConnectionRef.current((prev) => ({
2056
2445
  ...prev,
2057
2446
  state: "offline",
2058
2447
  connected: false,
@@ -2063,8 +2452,19 @@ function useRealtimeSession(options) {
2063
2452
  }
2064
2453
  manualDisconnectRef.current = false;
2065
2454
  const socketUrl = normalizeSocketUrl(url);
2455
+ if (!socketUrl) {
2456
+ collapsePeersToSelfRef.current("offline");
2457
+ updateConnectionRef.current((prev) => ({
2458
+ ...prev,
2459
+ state: "offline",
2460
+ connected: false,
2461
+ roomId,
2462
+ lastError: null
2463
+ }));
2464
+ return;
2465
+ }
2066
2466
  if (!isValidSocketUrl(socketUrl)) {
2067
- updateConnection((prev) => ({
2467
+ updateConnectionRef.current((prev) => ({
2068
2468
  ...prev,
2069
2469
  state: "error",
2070
2470
  connected: false,
@@ -2075,7 +2475,7 @@ function useRealtimeSession(options) {
2075
2475
  return;
2076
2476
  }
2077
2477
  let disposed = false;
2078
- updateConnection((prev) => ({
2478
+ updateConnectionRef.current((prev) => ({
2079
2479
  ...prev,
2080
2480
  state: retryCountRef.current > 0 ? "reconnecting" : "connecting",
2081
2481
  connected: false,
@@ -2093,7 +2493,7 @@ function useRealtimeSession(options) {
2093
2493
  socket.addEventListener("open", () => {
2094
2494
  if (disposed) return;
2095
2495
  clearConnectTimeout();
2096
- sendRaw({
2496
+ sendRawRef.current({
2097
2497
  type: "session:join",
2098
2498
  roomId,
2099
2499
  peer: {
@@ -2106,7 +2506,7 @@ function useRealtimeSession(options) {
2106
2506
  });
2107
2507
  clearHeartbeatTimer();
2108
2508
  heartbeatTimerRef.current = window.setInterval(() => {
2109
- sendRaw({
2509
+ sendRawRef.current({
2110
2510
  type: "session:ping",
2111
2511
  roomId,
2112
2512
  clientId: clientIdRef.current,
@@ -2126,15 +2526,13 @@ function useRealtimeSession(options) {
2126
2526
  }
2127
2527
  const parsed = parseRealtimeServerMessage(payload);
2128
2528
  if (!parsed) return;
2129
- updateConnection((prev) => ({
2529
+ updateConnectionRef.current((prev) => ({
2130
2530
  ...prev,
2131
2531
  lastMessageAt: nowMs()
2132
2532
  }));
2133
2533
  if (parsed.type === "session:welcome") {
2134
2534
  retryCountRef.current = 0;
2135
- const queuedBeforeWelcome = queuedItemsRef.current;
2136
- const shouldPromoteQueuedLocalDraft = queuedBeforeWelcome != null && parsed.document.revision === 0 && parsed.document.items.length === 0;
2137
- updateConnection((prev) => ({
2535
+ updateConnectionRef.current((prev) => ({
2138
2536
  ...prev,
2139
2537
  state: "connected",
2140
2538
  connected: true,
@@ -2143,19 +2541,26 @@ function useRealtimeSession(options) {
2143
2541
  lastConnectedAt: nowMs(),
2144
2542
  lastError: null
2145
2543
  }));
2146
- applyPeers(parsed.peers);
2147
- applyDocument(parsed.document, {
2148
- suppressSubscriberNotify: shouldPromoteQueuedLocalDraft
2149
- });
2150
- if (!shouldPromoteQueuedLocalDraft) {
2544
+ applyPeersRef.current(parsed.peers);
2545
+ const handledByDraft = resolveAuthoritativeDocumentRef.current(
2546
+ sanitizeRealtimeSnapshot(parsed.document),
2547
+ {
2548
+ suppressSubscriberNotify: localDraftRef.current != null && sameSerializedItems(
2549
+ latestDocumentRef.current?.items,
2550
+ localDraftRef.current.items
2551
+ )
2552
+ }
2553
+ );
2554
+ if (!handledByDraft) {
2151
2555
  queuedItemsRef.current = null;
2152
2556
  outboundInFlightRef.current = null;
2557
+ setHasPendingDocumentSync(false);
2153
2558
  }
2154
- flushQueuedDocument();
2559
+ scheduleDocumentFlushRef.current();
2155
2560
  return;
2156
2561
  }
2157
2562
  if (parsed.type === "presence:sync") {
2158
- applyPeers(parsed.peers);
2563
+ applyPeersRef.current(parsed.peers);
2159
2564
  return;
2160
2565
  }
2161
2566
  if (parsed.type === "session:peer-joined") {
@@ -2177,14 +2582,14 @@ function useRealtimeSession(options) {
2177
2582
  return;
2178
2583
  }
2179
2584
  if (parsed.type === "session:pong") {
2180
- updateConnection((prev) => ({
2585
+ updateConnectionRef.current((prev) => ({
2181
2586
  ...prev,
2182
2587
  lastPongAt: parsed.serverTime
2183
2588
  }));
2184
2589
  return;
2185
2590
  }
2186
2591
  if (parsed.type === "session:error") {
2187
- updateConnection((prev) => ({
2592
+ updateConnectionRef.current((prev) => ({
2188
2593
  ...prev,
2189
2594
  state: prev.connected ? prev.state : "error",
2190
2595
  lastError: parsed.message
@@ -2198,35 +2603,48 @@ function useRealtimeSession(options) {
2198
2603
  const isSelfAck = parsed.document.updatedByClientId === selfClientId;
2199
2604
  if (!isSelfAck) {
2200
2605
  outboundInFlightRef.current = null;
2201
- queuedItemsRef.current = null;
2202
- applyDocument(parsed.document);
2606
+ queuedItemsRef.current = localDraftRef.current?.items ?? null;
2607
+ setHasPendingDocumentSync(queuedItemsRef.current != null);
2608
+ resolveAuthoritativeDocumentRef.current(
2609
+ sanitizeRealtimeSnapshot(parsed.document)
2610
+ );
2203
2611
  return;
2204
2612
  }
2205
2613
  const shouldSuppress = inFlight != null && sameSerializedItems(inFlight.items, parsed.document.items);
2206
2614
  outboundInFlightRef.current = null;
2207
- applyDocument(parsed.document, { suppressSubscriberNotify: shouldSuppress });
2615
+ applyDocumentRef.current(sanitizeRealtimeSnapshot(parsed.document), {
2616
+ suppressSubscriberNotify: shouldSuppress
2617
+ });
2208
2618
  if (queuedItemsRef.current) {
2209
2619
  if (sameSerializedItems(queuedItemsRef.current, parsed.document.items)) {
2210
2620
  queuedItemsRef.current = null;
2211
2621
  } else {
2212
- flushQueuedDocument();
2622
+ scheduleDocumentFlushRef.current();
2213
2623
  }
2214
2624
  }
2625
+ if (!queuedItemsRef.current) {
2626
+ clearLocalDraftRef.current();
2627
+ }
2628
+ setHasPendingDocumentSync(queuedItemsRef.current != null);
2629
+ setConflictStateRef.current(null);
2215
2630
  return;
2216
2631
  }
2217
2632
  if (parsed.type === "document:resync-required") {
2218
2633
  outboundInFlightRef.current = null;
2219
- queuedItemsRef.current = null;
2220
- updateConnection((prev) => ({
2634
+ queuedItemsRef.current = localDraftRef.current?.items ?? null;
2635
+ setHasPendingDocumentSync(queuedItemsRef.current != null);
2636
+ updateConnectionRef.current((prev) => ({
2221
2637
  ...prev,
2222
2638
  lastError: parsed.reason
2223
2639
  }));
2224
- applyDocument(parsed.document);
2640
+ resolveAuthoritativeDocumentRef.current(
2641
+ sanitizeRealtimeSnapshot(parsed.document)
2642
+ );
2225
2643
  }
2226
2644
  });
2227
2645
  socket.addEventListener("error", () => {
2228
2646
  if (disposed) return;
2229
- updateConnection((prev) => ({
2647
+ updateConnectionRef.current((prev) => ({
2230
2648
  ...prev,
2231
2649
  state: prev.connected ? prev.state : "error",
2232
2650
  lastError: "Falha de conex\xE3o websocket."
@@ -2237,14 +2655,17 @@ function useRealtimeSession(options) {
2237
2655
  clearHeartbeatTimer();
2238
2656
  clearConnectTimeout();
2239
2657
  wsRef.current = null;
2240
- updateConnection((prev) => ({
2658
+ collapsePeersToSelfRef.current(
2659
+ manualDisconnectRef.current || !enabled ? "offline" : "reconnecting"
2660
+ );
2661
+ updateConnectionRef.current((prev) => ({
2241
2662
  ...prev,
2242
2663
  connected: false,
2243
2664
  clientId: prev.clientId,
2244
2665
  state: manualDisconnectRef.current || !enabled ? "offline" : prev.state
2245
2666
  }));
2246
2667
  if (!manualDisconnectRef.current && enabled) {
2247
- scheduleReconnect();
2668
+ scheduleReconnectRef.current();
2248
2669
  }
2249
2670
  });
2250
2671
  return () => {
@@ -2252,27 +2673,23 @@ function useRealtimeSession(options) {
2252
2673
  clearReconnectTimer();
2253
2674
  clearHeartbeatTimer();
2254
2675
  clearConnectTimeout();
2676
+ clearDocumentFlushSchedule();
2255
2677
  socket.close();
2256
2678
  };
2257
2679
  }, [
2258
- applyDocument,
2259
- applyPeers,
2260
2680
  clearConnectTimeout,
2681
+ clearDocumentFlushSchedule,
2261
2682
  clearHeartbeatTimer,
2262
2683
  clearReconnectTimer,
2263
2684
  connectSequence,
2264
2685
  connectTimeoutMs,
2265
2686
  enabled,
2266
- flushQueuedDocument,
2267
2687
  heartbeatMs,
2268
2688
  peer.color,
2269
2689
  peer.displayName,
2270
- peer.image,
2271
2690
  peer.id,
2691
+ peer.image,
2272
2692
  roomId,
2273
- scheduleReconnect,
2274
- sendRaw,
2275
- updateConnection,
2276
2693
  url
2277
2694
  ]);
2278
2695
  useEffect(() => {
@@ -2286,6 +2703,18 @@ function useRealtimeSession(options) {
2286
2703
  )
2287
2704
  );
2288
2705
  }, [connection.state]);
2706
+ useEffect(
2707
+ () => () => {
2708
+ clearDocumentFlushSchedule();
2709
+ clearDraftPersistSchedule();
2710
+ },
2711
+ [clearDocumentFlushSchedule, clearDraftPersistSchedule]
2712
+ );
2713
+ const flushDocumentSync = useCallback(async () => {
2714
+ persistLocalDraft();
2715
+ if (!connection.connected) return;
2716
+ flushQueuedDocument();
2717
+ }, [connection.connected, flushQueuedDocument, persistLocalDraft]);
2289
2718
  const remoteAdapter = useMemo(
2290
2719
  () => ({
2291
2720
  subscribe(onItems) {
@@ -2299,9 +2728,12 @@ function useRealtimeSession(options) {
2299
2728
  },
2300
2729
  send(items) {
2301
2730
  queueDocumentSend(items);
2731
+ },
2732
+ flush() {
2733
+ return flushDocumentSync();
2302
2734
  }
2303
2735
  }),
2304
- [queueDocumentSend]
2736
+ [flushDocumentSync, queueDocumentSend]
2305
2737
  );
2306
2738
  const disconnect = useCallback(() => {
2307
2739
  manualDisconnectRef.current = true;
@@ -2315,6 +2747,7 @@ function useRealtimeSession(options) {
2315
2747
  });
2316
2748
  wsRef.current?.close();
2317
2749
  wsRef.current = null;
2750
+ collapsePeersToSelf("offline");
2318
2751
  updateConnection((prev) => ({
2319
2752
  ...prev,
2320
2753
  state: "offline",
@@ -2324,6 +2757,7 @@ function useRealtimeSession(options) {
2324
2757
  clearConnectTimeout,
2325
2758
  clearHeartbeatTimer,
2326
2759
  clearReconnectTimer,
2760
+ collapsePeersToSelf,
2327
2761
  roomId,
2328
2762
  sendRaw,
2329
2763
  updateConnection
@@ -2389,8 +2823,15 @@ function useRealtimeSession(options) {
2389
2823
  remotePresence,
2390
2824
  remoteAdapter,
2391
2825
  document: document2,
2826
+ hasLocalOfflineDraft,
2827
+ hasPendingDocumentSync,
2828
+ syncState,
2829
+ conflict,
2392
2830
  bindViewportPresence,
2393
2831
  syncViewportPresence,
2832
+ resolveConflict,
2833
+ clearLocalDraft,
2834
+ flushDocumentSync,
2394
2835
  disconnect,
2395
2836
  reconnectNow
2396
2837
  };
@@ -2543,7 +2984,76 @@ function realtimeSessionPlugin(options) {
2543
2984
  render: () => /* @__PURE__ */ jsx(RealtimeSessionPanel, { ...options })
2544
2985
  };
2545
2986
  }
2987
+ function useRealtimeCanvasDocument(options) {
2988
+ const {
2989
+ session,
2990
+ items,
2991
+ onItemsChange,
2992
+ normalizeItems,
2993
+ hydrateItems,
2994
+ enabled = true
2995
+ } = options;
2996
+ const [loading, setLoading] = useState(false);
2997
+ const lastAppliedRevisionRef = useRef(null);
2998
+ const realtimeEnabled = enabled && session != null;
2999
+ const applyIncomingItems = useCallback(
3000
+ async (nextItems) => {
3001
+ const normalizedItems = normalizeItems ? normalizeItems(nextItems) : [...nextItems];
3002
+ if (!hydrateItems) return normalizedItems;
3003
+ return await hydrateItems(normalizedItems);
3004
+ },
3005
+ [hydrateItems, normalizeItems]
3006
+ );
3007
+ const handleItemsChange = useCallback(
3008
+ (nextItems) => {
3009
+ if (!enabled) {
3010
+ onItemsChange?.(normalizeItems ? normalizeItems(nextItems) : nextItems);
3011
+ return;
3012
+ }
3013
+ const normalizedItems = normalizeItems ? normalizeItems(nextItems) : nextItems;
3014
+ onItemsChange?.(normalizedItems);
3015
+ session?.remoteAdapter.send?.(normalizedItems);
3016
+ },
3017
+ [enabled, normalizeItems, onItemsChange, session]
3018
+ );
3019
+ useEffect(() => {
3020
+ if (!realtimeEnabled || !onItemsChange || !session?.document) return;
3021
+ if (session.document.updatedByClientId === session.connection.clientId) return;
3022
+ if (lastAppliedRevisionRef.current === session.document.revision) return;
3023
+ let cancelled = false;
3024
+ setLoading(true);
3025
+ void applyIncomingItems(session.document.items).then((resolvedItems) => {
3026
+ if (cancelled) return;
3027
+ lastAppliedRevisionRef.current = session.document?.revision ?? null;
3028
+ onItemsChange(resolvedItems);
3029
+ }).finally(() => {
3030
+ if (cancelled) return;
3031
+ setLoading(false);
3032
+ });
3033
+ return () => {
3034
+ cancelled = true;
3035
+ };
3036
+ }, [applyIncomingItems, realtimeEnabled, onItemsChange, session]);
3037
+ return useMemo(
3038
+ () => ({
3039
+ items,
3040
+ onItemsChange: onItemsChange ? handleItemsChange : void 0,
3041
+ loading,
3042
+ saving: session?.hasPendingDocumentSync ?? false,
3043
+ hasLocalOfflineDraft: session?.hasLocalOfflineDraft ?? false,
3044
+ syncState: session?.syncState ?? "offline",
3045
+ conflict: session?.conflict ?? null,
3046
+ resolveConflict: session?.resolveConflict ?? (() => {
3047
+ }),
3048
+ clearLocalDraft: session?.clearLocalDraft ?? (() => {
3049
+ }),
3050
+ flush: session?.flushDocumentSync ?? (async () => {
3051
+ })
3052
+ }),
3053
+ [handleItemsChange, items, loading, onItemsChange, session]
3054
+ );
3055
+ }
2546
3056
 
2547
- export { PresenceRemoteLayer, REALTIME_COMMENT_TOOL, RealtimeCommentsOverlay, RealtimeSessionPanel, createRealtimeCommentAvatarDataUrl, createRealtimeCommentDraftItem, createRealtimeCommentItem, defaultPresenceColorForId, getRealtimeCommentData, isRealtimeCommentDraftItem, isRealtimeCommentItem, parseRealtimeClientMessage, parseRealtimeServerMessage, realtimeCollaborationPlugin, realtimeCommentsPlugin, realtimeSessionPlugin, remoteMarkupStrokeFromPlacementPreview, useRealtimeComments, useRealtimePeerFollow, useRealtimeSession, withRealtimeCommentTool };
3057
+ export { PresenceRemoteLayer, REALTIME_COMMENT_TOOL, RealtimeCommentsOverlay, RealtimeSessionPanel, createRealtimeCommentAvatarDataUrl, createRealtimeCommentDraftItem, createRealtimeCommentItem, defaultPresenceColorForId, getRealtimeCommentData, isRealtimeCommentDraftItem, isRealtimeCommentItem, parseRealtimeClientMessage, parseRealtimeServerMessage, realtimeCollaborationPlugin, realtimeCommentsPlugin, realtimeSessionPlugin, remoteMarkupStrokeFromPlacementPreview, useRealtimeCanvasDocument, useRealtimeComments, useRealtimePeerFollow, useRealtimeSession, withRealtimeCommentTool };
2548
3058
  //# sourceMappingURL=realtime.js.map
2549
3059
  //# sourceMappingURL=realtime.js.map