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.js CHANGED
@@ -100,7 +100,7 @@ function PresenceRemoteLayer({
100
100
  const rootTransform = formatCameraTransform(camera);
101
101
  const overlayStrokePx = 1.25;
102
102
  const LUCIDE_POINTER_VIEWBOX = 24;
103
- const remoteCursorScreenPx = 15;
103
+ const remoteCursorScreenPx = 22;
104
104
  const iconWorldScale = remoteCursorScreenPx / (LUCIDE_POINTER_VIEWBOX * z);
105
105
  return /* @__PURE__ */ jsx(
106
106
  "svg",
@@ -164,9 +164,9 @@ function PresenceRemoteLayer({
164
164
  let cursorNode = null;
165
165
  if (cur) {
166
166
  const displayName = peer.displayName;
167
- const labelOffsetX = 10 / z;
168
- const labelOffsetY = 10 / z;
169
- const labelFont = 10 / z;
167
+ const labelOffsetX = 14 / z;
168
+ const labelOffsetY = 14 / z;
169
+ const labelFont = 12 / z;
170
170
  cursorNode = /* @__PURE__ */ jsxs("g", { children: [
171
171
  /* @__PURE__ */ jsx(
172
172
  "g",
@@ -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,108 @@ 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 };
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
+ };
1988
2201
  const didSend = sendRaw({
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, sendRaw]);
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
+ scheduleDocumentFlush();
2237
+ },
2238
+ [roomId, scheduleDocumentFlush, scheduleDraftPersistence, setLocalDraft]
2239
+ );
2240
+ const applyDraftSnapshot = useCallback(
2241
+ (draft, options2) => {
2242
+ applyDocument(buildDraftSnapshot(draft, clientIdRef.current), options2);
2004
2243
  },
2005
- [flushQueuedDocument]
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
+ clearLocalDraft();
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;
2282
+ },
2283
+ [
2284
+ applyDocument,
2285
+ applyDraftSnapshot,
2286
+ clearLocalDraft,
2287
+ scheduleDocumentFlush,
2288
+ setConflictState
2289
+ ]
2006
2290
  );
2007
2291
  const sendPresenceUpdate = useCallback(() => {
2008
2292
  sendRaw({
@@ -2041,17 +2325,108 @@ function useRealtimeSession(options) {
2041
2325
  reconnect,
2042
2326
  updateConnection
2043
2327
  ]);
2328
+ const resolveConflict = useCallback(
2329
+ (action) => {
2330
+ const activeConflict = conflictRef.current;
2331
+ if (!activeConflict) return;
2332
+ if (action === "use-server") {
2333
+ clearLocalDraft();
2334
+ queuedItemsRef.current = null;
2335
+ outboundInFlightRef.current = null;
2336
+ setConflictState(null);
2337
+ applyDocument({
2338
+ revision: activeConflict.serverRevision,
2339
+ items: activeConflict.serverItems,
2340
+ updatedAt: nowMs()
2341
+ });
2342
+ return;
2343
+ }
2344
+ const nextDraft = {
2345
+ roomId,
2346
+ baseRevision: activeConflict.serverRevision,
2347
+ items: activeConflict.localItems,
2348
+ updatedAt: nowMs()
2349
+ };
2350
+ setLocalDraft(nextDraft);
2351
+ scheduleDraftPersistence();
2352
+ setConflictState(null);
2353
+ applyDraftSnapshot(nextDraft, {
2354
+ suppressSubscriberNotify: sameSerializedItems(
2355
+ latestDocumentRef.current?.items,
2356
+ nextDraft.items
2357
+ )
2358
+ });
2359
+ currentRevisionRef.current = activeConflict.serverRevision;
2360
+ queuedItemsRef.current = nextDraft.items;
2361
+ outboundInFlightRef.current = null;
2362
+ setHasPendingDocumentSync(true);
2363
+ scheduleDocumentFlush();
2364
+ },
2365
+ [
2366
+ applyDocument,
2367
+ applyDraftSnapshot,
2368
+ clearLocalDraft,
2369
+ roomId,
2370
+ scheduleDocumentFlush,
2371
+ scheduleDraftPersistence,
2372
+ setConflictState,
2373
+ setLocalDraft
2374
+ ]
2375
+ );
2376
+ useEffect(() => {
2377
+ if (!roomId) {
2378
+ clearDocumentFlushSchedule();
2379
+ clearDraftPersistSchedule();
2380
+ localDraftRef.current = null;
2381
+ setHasLocalOfflineDraft(false);
2382
+ setHasPendingDocumentSync(false);
2383
+ setConflictState(null);
2384
+ queuedItemsRef.current = null;
2385
+ outboundInFlightRef.current = null;
2386
+ latestDocumentRef.current = null;
2387
+ setDocument(null);
2388
+ currentRevisionRef.current = 0;
2389
+ return;
2390
+ }
2391
+ const localDraft = readRealtimeOfflineDraft(roomId);
2392
+ setLocalDraft(localDraft);
2393
+ setHasPendingDocumentSync(localDraft != null);
2394
+ setConflictState(null);
2395
+ queuedItemsRef.current = localDraft?.items ?? null;
2396
+ outboundInFlightRef.current = null;
2397
+ if (localDraft) {
2398
+ applyDraftSnapshot(localDraft, {
2399
+ suppressSubscriberNotify: sameSerializedItems(
2400
+ latestDocumentRef.current?.items,
2401
+ localDraft.items
2402
+ )
2403
+ });
2404
+ } else {
2405
+ latestDocumentRef.current = null;
2406
+ setDocument(null);
2407
+ currentRevisionRef.current = 0;
2408
+ }
2409
+ }, [
2410
+ applyDraftSnapshot,
2411
+ clearDocumentFlushSchedule,
2412
+ clearDraftPersistSchedule,
2413
+ roomId,
2414
+ setConflictState,
2415
+ setLocalDraft
2416
+ ]);
2044
2417
  useEffect(() => {
2045
2418
  if (!enabled) {
2046
2419
  manualDisconnectRef.current = true;
2047
2420
  clearReconnectTimer();
2048
2421
  clearHeartbeatTimer();
2049
2422
  clearConnectTimeout();
2423
+ clearDocumentFlushSchedule();
2050
2424
  wsRef.current?.close();
2051
2425
  wsRef.current = null;
2052
- setSessionPeers([]);
2053
- queuedItemsRef.current = null;
2426
+ queuedItemsRef.current = localDraftRef.current?.items ?? null;
2054
2427
  outboundInFlightRef.current = null;
2428
+ setHasPendingDocumentSync(localDraftRef.current != null);
2429
+ collapsePeersToSelf("offline");
2055
2430
  updateConnection((prev) => ({
2056
2431
  ...prev,
2057
2432
  state: "offline",
@@ -2063,6 +2438,17 @@ function useRealtimeSession(options) {
2063
2438
  }
2064
2439
  manualDisconnectRef.current = false;
2065
2440
  const socketUrl = normalizeSocketUrl(url);
2441
+ if (!socketUrl) {
2442
+ collapsePeersToSelf("offline");
2443
+ updateConnection((prev) => ({
2444
+ ...prev,
2445
+ state: "offline",
2446
+ connected: false,
2447
+ roomId,
2448
+ lastError: null
2449
+ }));
2450
+ return;
2451
+ }
2066
2452
  if (!isValidSocketUrl(socketUrl)) {
2067
2453
  updateConnection((prev) => ({
2068
2454
  ...prev,
@@ -2132,8 +2518,6 @@ function useRealtimeSession(options) {
2132
2518
  }));
2133
2519
  if (parsed.type === "session:welcome") {
2134
2520
  retryCountRef.current = 0;
2135
- const queuedBeforeWelcome = queuedItemsRef.current;
2136
- const shouldPromoteQueuedLocalDraft = queuedBeforeWelcome != null && parsed.document.revision === 0 && parsed.document.items.length === 0;
2137
2521
  updateConnection((prev) => ({
2138
2522
  ...prev,
2139
2523
  state: "connected",
@@ -2144,14 +2528,21 @@ function useRealtimeSession(options) {
2144
2528
  lastError: null
2145
2529
  }));
2146
2530
  applyPeers(parsed.peers);
2147
- applyDocument(parsed.document, {
2148
- suppressSubscriberNotify: shouldPromoteQueuedLocalDraft
2149
- });
2150
- if (!shouldPromoteQueuedLocalDraft) {
2531
+ const handledByDraft = resolveAuthoritativeDocument(
2532
+ sanitizeRealtimeSnapshot(parsed.document),
2533
+ {
2534
+ suppressSubscriberNotify: localDraftRef.current != null && sameSerializedItems(
2535
+ latestDocumentRef.current?.items,
2536
+ localDraftRef.current.items
2537
+ )
2538
+ }
2539
+ );
2540
+ if (!handledByDraft) {
2151
2541
  queuedItemsRef.current = null;
2152
2542
  outboundInFlightRef.current = null;
2543
+ setHasPendingDocumentSync(false);
2153
2544
  }
2154
- flushQueuedDocument();
2545
+ scheduleDocumentFlush();
2155
2546
  return;
2156
2547
  }
2157
2548
  if (parsed.type === "presence:sync") {
@@ -2198,30 +2589,39 @@ function useRealtimeSession(options) {
2198
2589
  const isSelfAck = parsed.document.updatedByClientId === selfClientId;
2199
2590
  if (!isSelfAck) {
2200
2591
  outboundInFlightRef.current = null;
2201
- queuedItemsRef.current = null;
2202
- applyDocument(parsed.document);
2592
+ queuedItemsRef.current = localDraftRef.current?.items ?? null;
2593
+ setHasPendingDocumentSync(queuedItemsRef.current != null);
2594
+ resolveAuthoritativeDocument(sanitizeRealtimeSnapshot(parsed.document));
2203
2595
  return;
2204
2596
  }
2205
2597
  const shouldSuppress = inFlight != null && sameSerializedItems(inFlight.items, parsed.document.items);
2206
2598
  outboundInFlightRef.current = null;
2207
- applyDocument(parsed.document, { suppressSubscriberNotify: shouldSuppress });
2599
+ applyDocument(sanitizeRealtimeSnapshot(parsed.document), {
2600
+ suppressSubscriberNotify: shouldSuppress
2601
+ });
2208
2602
  if (queuedItemsRef.current) {
2209
2603
  if (sameSerializedItems(queuedItemsRef.current, parsed.document.items)) {
2210
2604
  queuedItemsRef.current = null;
2211
2605
  } else {
2212
- flushQueuedDocument();
2606
+ scheduleDocumentFlush();
2213
2607
  }
2214
2608
  }
2609
+ if (!queuedItemsRef.current) {
2610
+ clearLocalDraft();
2611
+ }
2612
+ setHasPendingDocumentSync(queuedItemsRef.current != null);
2613
+ setConflictState(null);
2215
2614
  return;
2216
2615
  }
2217
2616
  if (parsed.type === "document:resync-required") {
2218
2617
  outboundInFlightRef.current = null;
2219
- queuedItemsRef.current = null;
2618
+ queuedItemsRef.current = localDraftRef.current?.items ?? null;
2619
+ setHasPendingDocumentSync(queuedItemsRef.current != null);
2220
2620
  updateConnection((prev) => ({
2221
2621
  ...prev,
2222
2622
  lastError: parsed.reason
2223
2623
  }));
2224
- applyDocument(parsed.document);
2624
+ resolveAuthoritativeDocument(sanitizeRealtimeSnapshot(parsed.document));
2225
2625
  }
2226
2626
  });
2227
2627
  socket.addEventListener("error", () => {
@@ -2237,6 +2637,9 @@ function useRealtimeSession(options) {
2237
2637
  clearHeartbeatTimer();
2238
2638
  clearConnectTimeout();
2239
2639
  wsRef.current = null;
2640
+ collapsePeersToSelf(
2641
+ manualDisconnectRef.current || !enabled ? "offline" : "reconnecting"
2642
+ );
2240
2643
  updateConnection((prev) => ({
2241
2644
  ...prev,
2242
2645
  connected: false,
@@ -2252,6 +2655,7 @@ function useRealtimeSession(options) {
2252
2655
  clearReconnectTimer();
2253
2656
  clearHeartbeatTimer();
2254
2657
  clearConnectTimeout();
2658
+ clearDocumentFlushSchedule();
2255
2659
  socket.close();
2256
2660
  };
2257
2661
  }, [
@@ -2259,19 +2663,24 @@ function useRealtimeSession(options) {
2259
2663
  applyPeers,
2260
2664
  clearConnectTimeout,
2261
2665
  clearHeartbeatTimer,
2666
+ clearLocalDraft,
2262
2667
  clearReconnectTimer,
2668
+ collapsePeersToSelf,
2669
+ clearDocumentFlushSchedule,
2263
2670
  connectSequence,
2264
2671
  connectTimeoutMs,
2265
2672
  enabled,
2266
- flushQueuedDocument,
2267
2673
  heartbeatMs,
2268
2674
  peer.color,
2269
2675
  peer.displayName,
2270
- peer.image,
2271
2676
  peer.id,
2677
+ peer.image,
2678
+ resolveAuthoritativeDocument,
2272
2679
  roomId,
2680
+ scheduleDocumentFlush,
2273
2681
  scheduleReconnect,
2274
2682
  sendRaw,
2683
+ setConflictState,
2275
2684
  updateConnection,
2276
2685
  url
2277
2686
  ]);
@@ -2286,6 +2695,18 @@ function useRealtimeSession(options) {
2286
2695
  )
2287
2696
  );
2288
2697
  }, [connection.state]);
2698
+ useEffect(
2699
+ () => () => {
2700
+ clearDocumentFlushSchedule();
2701
+ clearDraftPersistSchedule();
2702
+ },
2703
+ [clearDocumentFlushSchedule, clearDraftPersistSchedule]
2704
+ );
2705
+ const flushDocumentSync = useCallback(async () => {
2706
+ persistLocalDraft();
2707
+ if (!connection.connected) return;
2708
+ flushQueuedDocument();
2709
+ }, [connection.connected, flushQueuedDocument, persistLocalDraft]);
2289
2710
  const remoteAdapter = useMemo(
2290
2711
  () => ({
2291
2712
  subscribe(onItems) {
@@ -2299,9 +2720,12 @@ function useRealtimeSession(options) {
2299
2720
  },
2300
2721
  send(items) {
2301
2722
  queueDocumentSend(items);
2723
+ },
2724
+ flush() {
2725
+ return flushDocumentSync();
2302
2726
  }
2303
2727
  }),
2304
- [queueDocumentSend]
2728
+ [flushDocumentSync, queueDocumentSend]
2305
2729
  );
2306
2730
  const disconnect = useCallback(() => {
2307
2731
  manualDisconnectRef.current = true;
@@ -2315,6 +2739,7 @@ function useRealtimeSession(options) {
2315
2739
  });
2316
2740
  wsRef.current?.close();
2317
2741
  wsRef.current = null;
2742
+ collapsePeersToSelf("offline");
2318
2743
  updateConnection((prev) => ({
2319
2744
  ...prev,
2320
2745
  state: "offline",
@@ -2324,6 +2749,7 @@ function useRealtimeSession(options) {
2324
2749
  clearConnectTimeout,
2325
2750
  clearHeartbeatTimer,
2326
2751
  clearReconnectTimer,
2752
+ collapsePeersToSelf,
2327
2753
  roomId,
2328
2754
  sendRaw,
2329
2755
  updateConnection
@@ -2389,8 +2815,15 @@ function useRealtimeSession(options) {
2389
2815
  remotePresence,
2390
2816
  remoteAdapter,
2391
2817
  document: document2,
2818
+ hasLocalOfflineDraft,
2819
+ hasPendingDocumentSync,
2820
+ syncState,
2821
+ conflict,
2392
2822
  bindViewportPresence,
2393
2823
  syncViewportPresence,
2824
+ resolveConflict,
2825
+ clearLocalDraft,
2826
+ flushDocumentSync,
2394
2827
  disconnect,
2395
2828
  reconnectNow
2396
2829
  };
@@ -2543,7 +2976,76 @@ function realtimeSessionPlugin(options) {
2543
2976
  render: () => /* @__PURE__ */ jsx(RealtimeSessionPanel, { ...options })
2544
2977
  };
2545
2978
  }
2979
+ function useRealtimeCanvasDocument(options) {
2980
+ const {
2981
+ session,
2982
+ items,
2983
+ onItemsChange,
2984
+ normalizeItems,
2985
+ hydrateItems,
2986
+ enabled = true
2987
+ } = options;
2988
+ const [loading, setLoading] = useState(false);
2989
+ const lastAppliedRevisionRef = useRef(null);
2990
+ const realtimeEnabled = enabled && session != null;
2991
+ const applyIncomingItems = useCallback(
2992
+ async (nextItems) => {
2993
+ const normalizedItems = normalizeItems ? normalizeItems(nextItems) : [...nextItems];
2994
+ if (!hydrateItems) return normalizedItems;
2995
+ return await hydrateItems(normalizedItems);
2996
+ },
2997
+ [hydrateItems, normalizeItems]
2998
+ );
2999
+ const handleItemsChange = useCallback(
3000
+ (nextItems) => {
3001
+ if (!enabled) {
3002
+ onItemsChange?.(normalizeItems ? normalizeItems(nextItems) : nextItems);
3003
+ return;
3004
+ }
3005
+ const normalizedItems = normalizeItems ? normalizeItems(nextItems) : nextItems;
3006
+ onItemsChange?.(normalizedItems);
3007
+ session?.remoteAdapter.send?.(normalizedItems);
3008
+ },
3009
+ [enabled, normalizeItems, onItemsChange, session]
3010
+ );
3011
+ useEffect(() => {
3012
+ if (!realtimeEnabled || !onItemsChange || !session?.document) return;
3013
+ if (session.document.updatedByClientId === session.connection.clientId) return;
3014
+ if (lastAppliedRevisionRef.current === session.document.revision) return;
3015
+ let cancelled = false;
3016
+ setLoading(true);
3017
+ void applyIncomingItems(session.document.items).then((resolvedItems) => {
3018
+ if (cancelled) return;
3019
+ lastAppliedRevisionRef.current = session.document?.revision ?? null;
3020
+ onItemsChange(resolvedItems);
3021
+ }).finally(() => {
3022
+ if (cancelled) return;
3023
+ setLoading(false);
3024
+ });
3025
+ return () => {
3026
+ cancelled = true;
3027
+ };
3028
+ }, [applyIncomingItems, realtimeEnabled, onItemsChange, session]);
3029
+ return useMemo(
3030
+ () => ({
3031
+ items,
3032
+ onItemsChange: onItemsChange ? handleItemsChange : void 0,
3033
+ loading,
3034
+ saving: session?.hasPendingDocumentSync ?? false,
3035
+ hasLocalOfflineDraft: session?.hasLocalOfflineDraft ?? false,
3036
+ syncState: session?.syncState ?? "offline",
3037
+ conflict: session?.conflict ?? null,
3038
+ resolveConflict: session?.resolveConflict ?? (() => {
3039
+ }),
3040
+ clearLocalDraft: session?.clearLocalDraft ?? (() => {
3041
+ }),
3042
+ flush: session?.flushDocumentSync ?? (async () => {
3043
+ })
3044
+ }),
3045
+ [handleItemsChange, items, loading, onItemsChange, session]
3046
+ );
3047
+ }
2546
3048
 
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 };
3049
+ 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
3050
  //# sourceMappingURL=realtime.js.map
2549
3051
  //# sourceMappingURL=realtime.js.map