Skip to content

Commit cf4b58c

Browse files
authored
[ENG-1165] Rename node titles on page load if title has changed (#835)
* first pass * address pr comment + fix lint * address PR comments
1 parent 087afdd commit cf4b58c

2 files changed

Lines changed: 119 additions & 0 deletions

File tree

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ import ToastListener, { dispatchToastEvent } from "./ToastListener";
106106
import { CanvasDrawerPanel } from "./CanvasDrawer";
107107
import { ClipboardPanel, ClipboardProvider } from "./Clipboard";
108108
import internalError from "~/utils/internalError";
109+
import { syncCanvasNodeTitlesOnLoad } from "~/utils/syncCanvasNodeTitlesOnLoad";
109110
import { AUTO_CANVAS_RELATIONS_KEY } from "~/data/userSettings";
110111
import { getSetting } from "~/utils/extensionSettings";
111112
import { isPluginTimerReady, waitForPluginTimer } from "~/utils/pluginTimer";
@@ -932,6 +933,7 @@ const TldrawCanvasShared = ({
932933
return (
933934
<div
934935
className="roamjs-tldraw-canvas-container relative z-10 h-full w-full overflow-hidden rounded-md border border-gray-300 bg-white"
936+
data-page-uid={pageUid ?? undefined}
935937
ref={containerRef}
936938
tabIndex={-1}
937939
onDragOver={handleDragOver}
@@ -1026,6 +1028,16 @@ const TldrawCanvasShared = ({
10261028
// hack for "cannot change atoms during reaction cycle" bug
10271029
installSafeHintingSetter({ app, title, pageUid });
10281030
setHasMountedEditor(true);
1031+
void syncCanvasNodeTitlesOnLoad(
1032+
app,
1033+
allNodes.map((n) => n.type),
1034+
allRelationIds,
1035+
).catch((error) => {
1036+
internalError({
1037+
error,
1038+
type: "Canvas: Sync node titles on load",
1039+
});
1040+
});
10291041

10301042
app.on("change", (entry) => {
10311043
lastActionsRef.current.push(entry);
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import type { Editor } from "tldraw";
2+
import type { DiscourseNodeShape } from "~/components/canvas/DiscourseNodeUtil";
3+
4+
/**
5+
* Query Roam for current :node/title or :block/string for each uid.
6+
* Returns a map of uid -> title for uids that exist; uids not in the map no longer exist.
7+
*/
8+
const queryTitlesByUids = async (
9+
uids: string[],
10+
): Promise<Map<string, string>> => {
11+
if (uids.length === 0) return new Map();
12+
13+
const results = (await window.roamAlphaAPI.data.async.fast.q(
14+
`[:find ?uid (pull ?e [:node/title :block/string])
15+
:in $ [?uid ...]
16+
:where [?e :block/uid ?uid]]`,
17+
uids,
18+
/* eslint-disable-next-line @typescript-eslint/naming-convention */
19+
)) as [string, { ":node/title"?: string; ":block/string"?: string }][];
20+
21+
const map = new Map<string, string>();
22+
for (const [uid, pull] of results) {
23+
const title = pull?.[":node/title"] ?? pull?.[":block/string"] ?? "";
24+
map.set(uid, title);
25+
}
26+
return map;
27+
};
28+
29+
/** Delete relation arrows and their bindings that reference this node shape, then the node shape. */
30+
const deleteNodeShapeAndRelations = (
31+
editor: Editor,
32+
shape: DiscourseNodeShape,
33+
relationIds: Set<string>,
34+
): void => {
35+
const bindingsToThisShape = Array.from(relationIds).flatMap((typeId) =>
36+
editor.getBindingsToShape(shape.id, typeId),
37+
);
38+
const relationShapeIdsAndType = bindingsToThisShape.map((b) => ({
39+
id: b.fromId,
40+
type: b.type,
41+
}));
42+
const bindingsToDelete = relationShapeIdsAndType.flatMap(({ id, type }) =>
43+
editor.getBindingsFromShape(id, type),
44+
);
45+
const relationShapeIdsToDelete = relationShapeIdsAndType.map((r) => r.id);
46+
const bindingIdsToDelete = bindingsToDelete.map((b) => b.id);
47+
editor
48+
.deleteShapes(relationShapeIdsToDelete)
49+
.deleteBindings(bindingIdsToDelete);
50+
editor.deleteShapes([shape.id]);
51+
};
52+
53+
/**
54+
* On canvas load: sync discourse node shape titles with Roam and remove shapes whose nodes no longer exist.
55+
* - Queries Roam for current title per uid via async.fast.q
56+
* - Updates shapes whose title changed
57+
* - Removes shapes whose uid no longer exists in the graph
58+
*/
59+
export const syncCanvasNodeTitlesOnLoad = async (
60+
editor: Editor,
61+
nodeTypeIds: string[],
62+
relationShapeTypeIds: string[],
63+
): Promise<void> => {
64+
const nodeTypeSet = new Set(nodeTypeIds);
65+
const relationIds = new Set(relationShapeTypeIds);
66+
const allRecords = editor.store.allRecords();
67+
const discourseNodeShapes = allRecords.filter(
68+
(r) =>
69+
r.typeName === "shape" &&
70+
nodeTypeSet.has((r as DiscourseNodeShape).type) &&
71+
typeof (r as DiscourseNodeShape).props?.uid === "string",
72+
) as DiscourseNodeShape[];
73+
74+
const uids = [...new Set(discourseNodeShapes.map((s) => s.props.uid))];
75+
if (uids.length === 0) return;
76+
77+
const uidToTitle = await queryTitlesByUids(uids);
78+
79+
const shapesToUpdate: { shape: DiscourseNodeShape; newTitle: string }[] = [];
80+
const shapesToRemove: DiscourseNodeShape[] = [];
81+
82+
for (const shape of discourseNodeShapes) {
83+
const uid = shape.props.uid;
84+
const currentInRoam = uidToTitle.get(uid);
85+
if (currentInRoam === undefined) {
86+
shapesToRemove.push(shape);
87+
} else if ((shape.props.title ?? "") !== (currentInRoam ?? "")) {
88+
shapesToUpdate.push({ shape, newTitle: currentInRoam });
89+
}
90+
}
91+
92+
if (shapesToRemove.length > 0) {
93+
for (const shape of shapesToRemove) {
94+
deleteNodeShapeAndRelations(editor, shape, relationIds);
95+
}
96+
}
97+
98+
if (shapesToUpdate.length > 0) {
99+
editor.updateShapes(
100+
shapesToUpdate.map(({ shape, newTitle }) => ({
101+
id: shape.id,
102+
type: shape.type,
103+
props: { title: newTitle },
104+
})),
105+
);
106+
}
107+
};

0 commit comments

Comments
 (0)