Skip to content

Commit be63ce7

Browse files
committed
[ENG-598] Data persistence for tldraw (#303)
* data persistence to the file * error handling * address PR comments * address some PR comments * address other PR comments * address PR comments
1 parent cf9c8fb commit be63ce7

File tree

3 files changed

+179
-58
lines changed

3 files changed

+179
-58
lines changed

apps/obsidian/src/components/TldrawView.tsx

Lines changed: 17 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,10 @@ import { TextFileView, TFile, WorkspaceLeaf } from "obsidian";
22
import { VIEW_TYPE_TLDRAW_DG_PREVIEW } from "~/constants";
33
import { Root, createRoot } from "react-dom/client";
44
import { TldrawPreviewComponent } from "./TldrawViewComponent";
5-
import {
6-
TLRecord,
7-
TLStore,
8-
createTLStore,
9-
defaultShapeUtils,
10-
loadSnapshot,
11-
} from "tldraw";
5+
import { TLStore } from "tldraw";
126
import React from "react";
137
import DiscourseGraphPlugin from "~/index";
8+
import { processInitialData, TLData } from "~/utils/tldraw";
149

1510
export class TldrawView extends TextFileView {
1611
plugin: DiscourseGraphPlugin;
@@ -71,17 +66,17 @@ export class TldrawView extends TextFileView {
7166

7267
const fileData = await this.app.vault.read(file);
7368

74-
const store = await this.createStore(fileData);
69+
const store = this.createStore(fileData);
7570

7671
if (!store) {
7772
console.warn("No tldraw data found in file");
7873
return;
7974
}
8075

81-
await this.setStore(store);
76+
this.setStore(store);
8277
}
8378

84-
private async createStore(fileData: string): Promise<TLStore | undefined> {
79+
private createStore(fileData: string): TLStore | undefined {
8580
try {
8681
const match = fileData.match(
8782
/```json !!!_START_OF_TLDRAW_DG_DATA__DO_NOT_CHANGE_THIS_PHRASE_!!!([\s\S]*?)!!!_END_OF_TLDRAW_DG_DATA__DO_NOT_CHANGE_THIS_PHRASE_!!!\n```/,
@@ -92,39 +87,13 @@ export class TldrawView extends TextFileView {
9287
return;
9388
}
9489

95-
const data = JSON.parse(match[1]);
90+
const data = JSON.parse(match[1]) as TLData;
9691
if (!data.raw) {
9792
console.warn("Invalid tldraw data format - missing raw field");
9893
return;
9994
}
10095

101-
const recordsData = Array.isArray(data.raw.records)
102-
? data.raw.records.reduce(
103-
(
104-
acc: Record<string, TLRecord>,
105-
record: { id: string } & TLRecord,
106-
) => {
107-
acc[record.id] = {
108-
...record,
109-
};
110-
return acc;
111-
},
112-
{},
113-
)
114-
: data.raw.records;
115-
116-
let store: TLStore;
117-
if (recordsData) {
118-
store = createTLStore({
119-
shapeUtils: defaultShapeUtils,
120-
initialData: recordsData,
121-
});
122-
} else {
123-
store = createTLStore({
124-
shapeUtils: defaultShapeUtils,
125-
});
126-
loadSnapshot(store, data.raw);
127-
}
96+
const { store } = processInitialData(data);
12897

12998
return store;
13099
} catch (e) {
@@ -135,15 +104,21 @@ export class TldrawView extends TextFileView {
135104

136105
private createReactRoot(entryPoint: Element, store: TLStore) {
137106
const root = createRoot(entryPoint);
107+
if (!this.file) return;
108+
138109
root.render(
139110
<React.StrictMode>
140-
<TldrawPreviewComponent store={store} isReadonly={false} />
111+
<TldrawPreviewComponent
112+
store={store}
113+
plugin={this.plugin}
114+
file={this.file}
115+
/>
141116
</React.StrictMode>,
142117
);
143118
return root;
144119
}
145120

146-
protected async setStore(store: TLStore) {
121+
protected setStore(store: TLStore) {
147122
if (this.store) {
148123
try {
149124
this.store.dispose();
@@ -154,11 +129,11 @@ export class TldrawView extends TextFileView {
154129

155130
this.store = store;
156131
if (this.tldrawContainer) {
157-
await this.refreshView();
132+
this.refreshView();
158133
}
159134
}
160135

161-
private async refreshView() {
136+
private refreshView() {
162137
if (!this.store) return;
163138

164139
if (this.reactRoot) {

apps/obsidian/src/components/TldrawViewComponent.tsx

Lines changed: 101 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,38 @@
11
import { useCallback, useEffect, useRef, useState } from "react";
22
import { Editor, ErrorBoundary, Tldraw, TLStore } from "tldraw";
33
import "tldraw/tldraw.css";
4+
import {
5+
getTLDataTemplate,
6+
createRawTldrawFile,
7+
getUpdatedMdContent,
8+
TLData,
9+
processInitialData,
10+
} from "~/utils/tldraw";
11+
import DiscourseGraphPlugin from "~/index";
12+
import {
13+
DEFAULT_SAVE_DELAY,
14+
TLDATA_DELIMITER_END,
15+
TLDATA_DELIMITER_START,
16+
} from "~/constants";
17+
import { TFile } from "obsidian";
418

519
interface TldrawPreviewProps {
620
store: TLStore;
7-
isReadonly?: boolean;
21+
plugin: DiscourseGraphPlugin;
22+
file: TFile;
823
}
924

1025
export const TldrawPreviewComponent = ({
1126
store,
12-
isReadonly = false,
27+
plugin,
28+
file,
1329
}: TldrawPreviewProps) => {
1430
const containerRef = useRef<HTMLDivElement>(null);
31+
const [currentStore, setCurrentStore] = useState<TLStore>(store);
1532
const [isReady, setIsReady] = useState(false);
33+
const editorRef = useRef<Editor | null>(null);
34+
const saveTimeoutRef = useRef<NodeJS.Timeout>();
35+
const lastSavedDataRef = useRef<string>("");
1636

1737
useEffect(() => {
1838
const timer = setTimeout(() => {
@@ -21,14 +41,86 @@ export const TldrawPreviewComponent = ({
2141
return () => clearTimeout(timer);
2242
}, []);
2343

24-
const handleMount = useCallback((editor: Editor) => {
25-
editor.setCurrentTool("hand");
26-
editor.updateInstanceState({});
44+
const saveChanges = useCallback(async () => {
45+
const newData = getTLDataTemplate({
46+
pluginVersion: plugin.manifest.version,
47+
tldrawFile: createRawTldrawFile(currentStore),
48+
uuid: window.crypto.randomUUID(),
49+
});
50+
const stringifiedData = JSON.stringify(newData, null, "\t");
51+
52+
if (stringifiedData === lastSavedDataRef.current) {
53+
return;
54+
}
55+
56+
const currentContent = await plugin.app.vault.read(file);
57+
if (!currentContent) {
58+
console.error("Could not read file content");
59+
return;
60+
}
2761

28-
const shapes = editor.getCurrentPageShapes();
29-
if (shapes.length > 0) {
30-
editor.zoomToFit();
62+
const updatedString = getUpdatedMdContent(currentContent, stringifiedData);
63+
if (updatedString === currentContent) {
64+
return;
3165
}
66+
67+
try {
68+
await plugin.app.vault.modify(file, updatedString);
69+
70+
const verifyContent = await plugin.app.vault.read(file);
71+
const verifyMatch = verifyContent.match(
72+
new RegExp(
73+
`${TLDATA_DELIMITER_START}\\s*([\\s\\S]*?)\\s*${TLDATA_DELIMITER_END}`,
74+
),
75+
);
76+
77+
if (!verifyMatch || verifyMatch[1]?.trim() !== stringifiedData.trim()) {
78+
throw new Error("Failed to verify saved TLDraw data");
79+
}
80+
81+
lastSavedDataRef.current = stringifiedData;
82+
} catch (error) {
83+
console.error("Error saving/verifying TLDraw data:", error);
84+
// Reload the editor state from file since save failed
85+
const fileContent = await plugin.app.vault.read(file);
86+
const match = fileContent.match(
87+
new RegExp(
88+
`${TLDATA_DELIMITER_START}([\\s\\S]*?)${TLDATA_DELIMITER_END}`,
89+
),
90+
);
91+
if (match?.[1]) {
92+
const data = JSON.parse(match[1]) as TLData;
93+
const { store: newStore } = processInitialData(data);
94+
setCurrentStore(newStore);
95+
}
96+
}
97+
}, [file, plugin, currentStore]);
98+
99+
useEffect(() => {
100+
const unsubscribe = currentStore.listen(
101+
() => {
102+
if (saveTimeoutRef.current) {
103+
clearTimeout(saveTimeoutRef.current);
104+
}
105+
saveTimeoutRef.current = setTimeout(
106+
() => void saveChanges(),
107+
DEFAULT_SAVE_DELAY,
108+
);
109+
},
110+
{ source: "user", scope: "document" },
111+
);
112+
113+
return () => {
114+
unsubscribe();
115+
if (saveTimeoutRef.current) {
116+
clearTimeout(saveTimeoutRef.current);
117+
}
118+
};
119+
}, [currentStore, saveChanges]);
120+
121+
const handleMount = useCallback((editor: Editor) => {
122+
editorRef.current = editor;
123+
editor.setCurrentTool("select");
32124
}, []);
33125

34126
return (
@@ -43,7 +135,7 @@ export const TldrawPreviewComponent = ({
43135
<div>Error in Tldraw component: {JSON.stringify(error)}</div>
44136
)}
45137
>
46-
<Tldraw store={store} onMount={handleMount} autoFocus={false} />
138+
<Tldraw store={currentStore} onMount={handleMount} autoFocus={true} />
47139
</ErrorBoundary>
48140
) : (
49141
<div>Loading Tldraw...</div>

apps/obsidian/src/utils/tldraw.ts

Lines changed: 61 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
import { createTLStore, TldrawFile, TLStore } from "tldraw";
1+
import { createTLStore, defaultShapeUtils, TldrawFile, TLRecord, TLStore } from "tldraw";
22
import {
33
FRONTMATTER_KEY,
44
TLDATA_DELIMITER_END,
55
TLDATA_DELIMITER_START,
66
TLDRAW_VERSION,
77
} from "../constants";
8-
import DiscourseGraphPlugin from "..";
8+
import DiscourseGraphPlugin from "~/index";
99
import { checkAndCreateFolder, getNewUniqueFilepath } from "./file";
1010
import { Notice } from "obsidian";
1111
import { format } from "date-fns";
@@ -16,12 +16,47 @@ export type TldrawPluginMetaData = {
1616
uuid: string;
1717
};
1818

19+
export type TldrawRawData = {
20+
tldrawFileFormatVersion: number;
21+
schema: any;
22+
records: any;
23+
};
24+
1925
export type TLData = {
2026
meta: TldrawPluginMetaData;
21-
raw: {
22-
tldrawFileFormatVersion: number;
23-
schema: any;
24-
records: any;
27+
raw: TldrawRawData;
28+
};
29+
30+
export const processInitialData = (
31+
data: TLData,
32+
): { meta: TldrawPluginMetaData; store: TLStore } => {
33+
const recordsData = Array.isArray(data.raw.records)
34+
? data.raw.records.reduce(
35+
(acc: Record<string, TLRecord>, record: { id: string } & TLRecord) => {
36+
acc[record.id] = {
37+
...record,
38+
};
39+
return acc;
40+
},
41+
{},
42+
)
43+
: data.raw.records;
44+
45+
let store: TLStore;
46+
if (recordsData) {
47+
store = createTLStore({
48+
shapeUtils: defaultShapeUtils,
49+
initialData: recordsData,
50+
});
51+
} else {
52+
store = createTLStore({
53+
shapeUtils: defaultShapeUtils,
54+
});
55+
}
56+
57+
return {
58+
meta: data.meta,
59+
store,
2560
};
2661
};
2762

@@ -121,4 +156,23 @@ export const createCanvas = async (plugin: DiscourseGraphPlugin) => {
121156
new Notice(e instanceof Error ? e.message : "Failed to create canvas file");
122157
console.error(e);
123158
}
124-
};
159+
};
160+
161+
/**
162+
* Get the updated markdown content with the new TLData
163+
* @param currentContent - The current markdown content
164+
* @param stringifiedData - The new TLData stringified
165+
* @returns The updated markdown content
166+
*/
167+
export const getUpdatedMdContent = (
168+
currentContent: string,
169+
stringifiedData: string,
170+
) => {
171+
const regex = new RegExp(
172+
`${TLDATA_DELIMITER_START}([\\s\\S]*?)${TLDATA_DELIMITER_END}`,
173+
);
174+
return currentContent.replace(
175+
regex,
176+
`${TLDATA_DELIMITER_START}\n${stringifiedData}\n${TLDATA_DELIMITER_END}`,
177+
);
178+
};

0 commit comments

Comments
 (0)