Skip to content

Commit 391a3db

Browse files
committed
Remove deprecated per-canvas sync mode plan and enhance Tldraw components
- Deleted the `eng-1324-plan.md` file outlining the previous sync mode implementation. - Updated Tldraw components to integrate a new `shouldWarnSyncModeStartsBlank` utility, which checks for existing sync rooms before enabling sync mode. - Refactored the main menu to include a toggle for sync mode, providing user feedback on the current state and warnings when necessary. - Improved the Cloudflare sync status handling in the Tldraw components, ensuring a seamless user experience when switching between sync and local modes.
1 parent 7dd2c8d commit 391a3db

6 files changed

Lines changed: 138 additions & 143 deletions

File tree

apps/roam/src/components/canvas/Tldraw.tsx

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import React, {
66
useEffect,
77
useCallback,
88
} from "react";
9-
import { Icon, Tooltip } from "@blueprintjs/core";
9+
import { Icon } from "@blueprintjs/core";
1010
import ExtensionApiContextProvider, {
1111
useExtensionAPI,
1212
} from "roamjs-components/components/ExtensionApiContext";
@@ -77,6 +77,7 @@ import { useRoamStore } from "./useRoamStore";
7777
import {
7878
TLDRAW_CLOUDFLARE_SYNC_ENABLED,
7979
TLDRAW_CLOUDFLARE_SYNC_WS_BASE_URL,
80+
getCloudflareSyncRoomExists,
8081
useCloudflareSyncStore,
8182
} from "./TldrawCanvasCloudflareSync";
8283
import getPageUidByPageTitle from "roamjs-components/queries/getPageUidByPageTitle";
@@ -163,8 +164,7 @@ const TldrawCanvas = ({ title }: { title: string }) => {
163164
() => getEffectiveCanvasSyncMode({ pageUid }),
164165
);
165166
const isCloudflareSyncAvailable =
166-
TLDRAW_CLOUDFLARE_SYNC_ENABLED &&
167-
!!TLDRAW_CLOUDFLARE_SYNC_WS_BASE_URL;
167+
TLDRAW_CLOUDFLARE_SYNC_ENABLED && !!TLDRAW_CLOUDFLARE_SYNC_WS_BASE_URL;
168168
const useCloudflareSync =
169169
canvasSyncMode === "sync" && isCloudflareSyncAvailable;
170170

@@ -179,6 +179,13 @@ const TldrawCanvas = ({ title }: { title: string }) => {
179179
},
180180
[pageUid],
181181
);
182+
const shouldWarnSyncModeStartsBlank =
183+
useCallback(async (): Promise<boolean> => {
184+
const hasExistingSyncRoom = await getCloudflareSyncRoomExists({
185+
pageUid,
186+
});
187+
return hasExistingSyncRoom === false;
188+
}, [pageUid]);
182189

183190
if (useCloudflareSync) {
184191
return (
@@ -188,6 +195,7 @@ const TldrawCanvas = ({ title }: { title: string }) => {
188195
canvasSyncMode={canvasSyncMode}
189196
isCloudflareSyncAvailable={isCloudflareSyncAvailable}
190197
onCanvasSyncModeChange={onCanvasSyncModeChange}
198+
shouldWarnSyncModeStartsBlank={shouldWarnSyncModeStartsBlank}
191199
/>
192200
);
193201
}
@@ -199,6 +207,7 @@ const TldrawCanvas = ({ title }: { title: string }) => {
199207
canvasSyncMode={canvasSyncMode}
200208
isCloudflareSyncAvailable={isCloudflareSyncAvailable}
201209
onCanvasSyncModeChange={onCanvasSyncModeChange}
210+
shouldWarnSyncModeStartsBlank={shouldWarnSyncModeStartsBlank}
202211
/>
203212
);
204213
};
@@ -273,12 +282,14 @@ const TldrawCanvasRoam = ({
273282
canvasSyncMode,
274283
isCloudflareSyncAvailable,
275284
onCanvasSyncModeChange,
285+
shouldWarnSyncModeStartsBlank,
276286
}: {
277287
title: string;
278288
pageUid: string;
279289
canvasSyncMode: CanvasSyncMode;
280290
isCloudflareSyncAvailable: boolean;
281291
onCanvasSyncModeChange: (mode: CanvasSyncMode) => void;
292+
shouldWarnSyncModeStartsBlank: () => Promise<boolean>;
282293
}) => {
283294
return (
284295
<TldrawCanvasShared
@@ -289,6 +300,7 @@ const TldrawCanvasRoam = ({
289300
canvasSyncMode={canvasSyncMode}
290301
isCloudflareSyncAvailable={isCloudflareSyncAvailable}
291302
onCanvasSyncModeChange={onCanvasSyncModeChange}
303+
shouldWarnSyncModeStartsBlank={shouldWarnSyncModeStartsBlank}
292304
/>
293305
);
294306
};
@@ -299,12 +311,14 @@ const TldrawCanvasCloudflare = ({
299311
canvasSyncMode,
300312
isCloudflareSyncAvailable,
301313
onCanvasSyncModeChange,
314+
shouldWarnSyncModeStartsBlank,
302315
}: {
303316
title: string;
304317
pageUid: string;
305318
canvasSyncMode: CanvasSyncMode;
306319
isCloudflareSyncAvailable: boolean;
307320
onCanvasSyncModeChange: (mode: CanvasSyncMode) => void;
321+
shouldWarnSyncModeStartsBlank: () => Promise<boolean>;
308322
}) => {
309323
return (
310324
<TldrawCanvasShared
@@ -315,6 +329,7 @@ const TldrawCanvasCloudflare = ({
315329
canvasSyncMode={canvasSyncMode}
316330
isCloudflareSyncAvailable={isCloudflareSyncAvailable}
317331
onCanvasSyncModeChange={onCanvasSyncModeChange}
332+
shouldWarnSyncModeStartsBlank={shouldWarnSyncModeStartsBlank}
318333
/>
319334
);
320335
};
@@ -327,6 +342,7 @@ const TldrawCanvasShared = ({
327342
canvasSyncMode,
328343
isCloudflareSyncAvailable,
329344
onCanvasSyncModeChange,
345+
shouldWarnSyncModeStartsBlank,
330346
}: {
331347
title: string;
332348
pageUid: string;
@@ -335,6 +351,7 @@ const TldrawCanvasShared = ({
335351
canvasSyncMode: CanvasSyncMode;
336352
isCloudflareSyncAvailable: boolean;
337353
onCanvasSyncModeChange: (mode: CanvasSyncMode) => void;
354+
shouldWarnSyncModeStartsBlank: () => Promise<boolean>;
338355
}) => {
339356
const appRef = useRef<Editor | null>(null);
340357
const lastInsertRef = useRef<VecModel>();
@@ -656,6 +673,7 @@ const TldrawCanvasShared = ({
656673
canvasSyncMode,
657674
isCloudflareSyncAvailable,
658675
onCanvasSyncModeChange,
676+
shouldWarnSyncModeStartsBlank,
659677
}),
660678
[
661679
allNodes,
@@ -664,6 +682,7 @@ const TldrawCanvasShared = ({
664682
canvasSyncMode,
665683
isCloudflareSyncAvailable,
666684
onCanvasSyncModeChange,
685+
shouldWarnSyncModeStartsBlank,
667686
],
668687
);
669688

@@ -931,14 +950,12 @@ const TldrawCanvasShared = ({
931950
onDrop={handleDrop}
932951
>
933952
{isCloudflareSync && (
934-
<Tooltip
935-
content="Sync mode is enabled. Collaborators on older builds may still open this canvas in local mode."
936-
hoverOpenDelay={100}
953+
<div
954+
className="absolute right-3 top-3 z-20 opacity-90"
955+
title="Cloudflare Sync enabled"
937956
>
938-
<div className="absolute right-3 top-3 z-20 opacity-90">
939-
<Icon icon="cloud" size={12} className="text-green-500" />
940-
</div>
941-
</Tooltip>
957+
<Icon icon="cloud" size={12} className="text-green-500" />
958+
</div>
942959
)}
943960
<style>{tldrawStyles}</style>
944961

apps/roam/src/components/canvas/TldrawCanvasCloudflareSync.tsx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export type CloudflareCanvasStoreAdapterResult = {
2222
isLoading: boolean;
2323
};
2424

25-
const getSyncRoomId = ({ pageUid }: { pageUid: string }): string => {
25+
export const getSyncRoomId = ({ pageUid }: { pageUid: string }): string => {
2626
const graphName = window.roamAlphaAPI.graph.name;
2727
const payload = JSON.stringify({ graphName, pageUid });
2828
const bytes = new TextEncoder().encode(payload);
@@ -37,6 +37,25 @@ const getSyncRoomId = ({ pageUid }: { pageUid: string }): string => {
3737
.replace(/=+$/g, "");
3838
};
3939

40+
export const getCloudflareSyncRoomExists = async ({
41+
pageUid,
42+
}: {
43+
pageUid: string;
44+
}): Promise<boolean | null> => {
45+
if (!TLDRAW_CLOUDFLARE_SYNC_WS_BASE_URL) return null;
46+
try {
47+
const roomId = getSyncRoomId({ pageUid });
48+
const response = await fetch(
49+
`${TLDRAW_CLOUDFLARE_SYNC_WS_BASE_URL}/room-status/${roomId}`,
50+
);
51+
if (!response.ok) return null;
52+
const data = (await response.json()) as { exists?: unknown };
53+
return typeof data.exists === "boolean" ? data.exists : null;
54+
} catch {
55+
return null;
56+
}
57+
};
58+
4059
const parseRoamUploadResponse = (value: string): string => {
4160
return value.replace(/^!\[\]\(/, "").replace(/\)$/, "");
4261
};

apps/roam/src/components/canvas/uiOverrides.tsx

Lines changed: 50 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -14,21 +14,18 @@ import {
1414
DefaultKeyboardShortcutsDialogContent,
1515
DefaultToolbarContent,
1616
TldrawUiMenuItem,
17-
TldrawUiMenuCheckboxItem,
1817
DefaultMainMenu,
18+
DefaultMainMenuContent,
1919
TldrawUiMenuGroup,
20+
TldrawUiDropdownMenuItem,
21+
TldrawUiButton,
22+
TldrawUiButtonLabel,
23+
TldrawUiIcon,
2024
useActions,
2125
DefaultContextMenu,
2226
DefaultContextMenuContent,
2327
TLUiComponents,
24-
EditSubmenu,
25-
ExportFileContentSubMenu,
26-
ExtrasGroup,
27-
PreferencesGroup,
2828
TldrawUiMenuSubmenu,
29-
ZoomTo100MenuItem,
30-
ZoomToFitMenuItem,
31-
ZoomToSelectionMenuItem,
3229
useEditor,
3330
useValue,
3431
useToasts,
@@ -50,6 +47,40 @@ import { renderModifyNodeDialog } from "~/components/ModifyNodeDialog";
5047
import { CanvasSyncMode } from "./canvasSyncMode";
5148
import renderToast from "roamjs-components/components/Toast";
5249

50+
const SyncModeMenuSwitchItem = ({
51+
checked,
52+
disabled,
53+
label,
54+
onToggle,
55+
}: {
56+
checked: boolean;
57+
disabled: boolean;
58+
label: string;
59+
onToggle: () => void;
60+
}): React.ReactElement => {
61+
return (
62+
<TldrawUiDropdownMenuItem>
63+
<TldrawUiButton
64+
type="menu"
65+
title={label}
66+
disabled={disabled}
67+
onClick={onToggle}
68+
>
69+
<TldrawUiButtonLabel>{label}</TldrawUiButtonLabel>
70+
<span
71+
style={{
72+
marginLeft: "auto",
73+
display: "inline-flex",
74+
alignItems: "center",
75+
}}
76+
>
77+
<TldrawUiIcon icon={checked ? "toggle-on" : "toggle-off"} small />
78+
</span>
79+
</TldrawUiButton>
80+
</TldrawUiDropdownMenuItem>
81+
);
82+
};
83+
5384
export const getOnSelectForShape = ({
5485
shape,
5586
nodeType,
@@ -191,13 +222,15 @@ export const createUiComponents = ({
191222
canvasSyncMode,
192223
isCloudflareSyncAvailable,
193224
onCanvasSyncModeChange,
225+
shouldWarnSyncModeStartsBlank,
194226
}: {
195227
allNodes: DiscourseNode[];
196228
allRelationNames: string[];
197229
allAddReferencedNodeActions: string[];
198230
canvasSyncMode: CanvasSyncMode;
199231
isCloudflareSyncAvailable: boolean;
200232
onCanvasSyncModeChange: (mode: CanvasSyncMode) => void;
233+
shouldWarnSyncModeStartsBlank: () => Promise<boolean>;
201234
}): TLUiComponents => {
202235
return {
203236
Toolbar: (props) => {
@@ -228,54 +261,37 @@ export const createUiComponents = ({
228261
);
229262
},
230263
MainMenu: () => {
231-
const CustomViewMenu = () => {
232-
const actions = useActions();
233-
return (
234-
<TldrawUiMenuSubmenu id="view" label="menu.view">
235-
<TldrawUiMenuGroup id="view-actions">
236-
<TldrawUiMenuItem {...actions["zoom-in"]} />
237-
<TldrawUiMenuItem {...actions["zoom-out"]} />
238-
<ZoomTo100MenuItem />
239-
<ZoomToFitMenuItem />
240-
<ZoomToSelectionMenuItem />
241-
<TldrawUiMenuItem {...actions["toggle-full-screen"]} />
242-
</TldrawUiMenuGroup>
243-
</TldrawUiMenuSubmenu>
244-
);
245-
};
246264
const onToggleSyncMode = (): void => {
247265
const nextMode: CanvasSyncMode =
248266
canvasSyncMode === "sync" ? "local" : "sync";
249267
onCanvasSyncModeChange(nextMode);
250-
if (nextMode === "sync") {
268+
if (nextMode !== "sync") return;
269+
270+
void (async () => {
271+
const shouldWarn = await shouldWarnSyncModeStartsBlank();
272+
if (!shouldWarn) return;
251273
renderToast({
252274
id: "tldraw-sync-mode-enabled-v0",
253275
intent: "warning",
254276
content: "Sync mode starts from a new blank sync canvas.",
255277
});
256-
}
278+
})();
257279
};
258280
const syncModeLabel = isCloudflareSyncAvailable
259281
? "Sync mode"
260282
: "Sync mode (backend unavailable)";
261283

262284
return (
263285
<DefaultMainMenu>
264-
<EditSubmenu />
265-
<CustomViewMenu /> {/* Replaced <ViewSubmenu /> */}
266286
<TldrawUiMenuGroup id="sync-mode">
267-
<TldrawUiMenuCheckboxItem
268-
id="toggle-sync-mode"
287+
<SyncModeMenuSwitchItem
269288
label={syncModeLabel}
270289
checked={canvasSyncMode === "sync"}
271-
readonlyOk
272290
disabled={!isCloudflareSyncAvailable}
273-
onSelect={onToggleSyncMode}
291+
onToggle={onToggleSyncMode}
274292
/>
275293
</TldrawUiMenuGroup>
276-
<ExportFileContentSubMenu />
277-
<ExtrasGroup />
278-
<PreferencesGroup />
294+
<DefaultMainMenuContent />
279295
</DefaultMainMenu>
280296
);
281297
},

apps/tldraw-sync-worker/worker/TldrawDurableObject.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,31 @@ export class TldrawDurableObject {
9595
return error(e)
9696
},
9797
})
98+
// Read-only room probe used by the Roam UI toggle flow.
99+
// The client calls this to decide whether enabling sync should warn that
100+
// the room will start blank (only true when no sync room exists yet).
101+
.get('/status/:roomId', async (request) => {
102+
const requestedRoomId = request.params.roomId as string
103+
// `roomId` can be absent in memory on a cold start, so also check DO storage.
104+
const persistedRoomId =
105+
this.roomId ?? ((await this.ctx.storage.get('roomId')) as string | null)
106+
// A room also "exists" if there's already a persisted snapshot in R2.
107+
const hasPersistedSnapshot = !!(await this.r2.get(`rooms/${requestedRoomId}`))
108+
// Existence means either this DO has claimed the room id before,
109+
// or the room has persisted data from prior sessions.
110+
const exists =
111+
persistedRoomId === requestedRoomId || hasPersistedSnapshot
112+
113+
return Response.json(
114+
{ exists },
115+
{
116+
headers: {
117+
// Status must be fresh; stale caches would cause incorrect UI warnings.
118+
'Cache-Control': 'no-store',
119+
},
120+
}
121+
)
122+
})
98123
// when we get a connection request, we stash the room id if needed and handle the connection
99124
.get('/connect/:roomId', async (request) => {
100125
if (!this.roomId) {

apps/tldraw-sync-worker/worker/worker.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,22 @@ const router = AutoRouter<IRequest, [env: Environment, ctx: ExecutionContext]>({
7272
},
7373
})
7474
.options("*", handlePreflight)
75+
// Lightweight read-only probe for the canvas sync-mode toggle UI.
76+
// The client uses this to decide whether to show the "starts blank" warning.
77+
.get("/room-status/:roomId", async (request, env) => {
78+
const id = env.TLDRAW_DURABLE_OBJECT.idFromName(request.params.roomId);
79+
const room = env.TLDRAW_DURABLE_OBJECT.get(id);
80+
const statusUrl = new URL(request.url);
81+
// Forward to the Durable Object's internal status route for this room.
82+
statusUrl.pathname = `/status/${request.params.roomId}`;
83+
// Drop client query params; status checks do not require session metadata.
84+
statusUrl.search = "";
85+
86+
const response = await room.fetch(statusUrl.toString(), {
87+
headers: request.headers,
88+
});
89+
return setCorsHeaders({ request, response });
90+
})
7591
// requests to /connect are routed to the Durable Object, and handle realtime websocket syncing
7692
.get("/connect/:roomId", async (request, env) => {
7793
const id = env.TLDRAW_DURABLE_OBJECT.idFromName(request.params.roomId);

0 commit comments

Comments
 (0)