Skip to content

Commit cf9c8fb

Browse files
committed
[ENG-495] Tldraw obsidian setup (#285)
* cleaned * sm * address PR comments
1 parent a69b94e commit cf9c8fb

File tree

9 files changed

+576
-8
lines changed

9 files changed

+576
-8
lines changed

apps/obsidian/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@
3939
"nanoid": "^4.0.2",
4040
"react": "catalog:obsidian",
4141
"react-dom": "catalog:obsidian",
42-
"tailwindcss-animate": "^1.0.7"
42+
"date-fns": "^4.1.0",
43+
"tailwindcss-animate": "^1.0.7",
44+
"tldraw": "3.14.2"
4345
}
4446
}
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
import { TextFileView, TFile, WorkspaceLeaf } from "obsidian";
2+
import { VIEW_TYPE_TLDRAW_DG_PREVIEW } from "~/constants";
3+
import { Root, createRoot } from "react-dom/client";
4+
import { TldrawPreviewComponent } from "./TldrawViewComponent";
5+
import {
6+
TLRecord,
7+
TLStore,
8+
createTLStore,
9+
defaultShapeUtils,
10+
loadSnapshot,
11+
} from "tldraw";
12+
import React from "react";
13+
import DiscourseGraphPlugin from "~/index";
14+
15+
export class TldrawView extends TextFileView {
16+
plugin: DiscourseGraphPlugin;
17+
private reactRoot?: Root;
18+
private store?: TLStore;
19+
private onUnloadCallbacks: (() => void)[] = [];
20+
21+
constructor(leaf: WorkspaceLeaf, plugin: DiscourseGraphPlugin) {
22+
super(leaf);
23+
this.plugin = plugin;
24+
this.navigation = true;
25+
}
26+
27+
getViewType(): string {
28+
return VIEW_TYPE_TLDRAW_DG_PREVIEW;
29+
}
30+
31+
getDisplayText(): string {
32+
return this.file?.basename ?? "Discourse Graph Canvas Preview";
33+
}
34+
35+
getViewData(): string {
36+
return this.data;
37+
}
38+
39+
setViewData(data: string, clear: boolean): void {
40+
this.data = data;
41+
}
42+
43+
clear(): void {
44+
this.data = "";
45+
}
46+
47+
protected get tldrawContainer() {
48+
return this.containerEl.children[1];
49+
}
50+
51+
override onload(): void {
52+
super.onload();
53+
this.contentEl.addClass("tldraw-view-content");
54+
this.addAction("file-text", "View as markdown", () =>
55+
this.leaf.setViewState({
56+
type: "markdown",
57+
state: this.leaf.view.getState(),
58+
}),
59+
);
60+
}
61+
62+
async onOpen() {
63+
const container = this.tldrawContainer;
64+
if (!container) return;
65+
66+
container.empty();
67+
}
68+
69+
async onLoadFile(file: TFile): Promise<void> {
70+
await super.onLoadFile(file);
71+
72+
const fileData = await this.app.vault.read(file);
73+
74+
const store = await this.createStore(fileData);
75+
76+
if (!store) {
77+
console.warn("No tldraw data found in file");
78+
return;
79+
}
80+
81+
await this.setStore(store);
82+
}
83+
84+
private async createStore(fileData: string): Promise<TLStore | undefined> {
85+
try {
86+
const match = fileData.match(
87+
/```json !!!_START_OF_TLDRAW_DG_DATA__DO_NOT_CHANGE_THIS_PHRASE_!!!([\s\S]*?)!!!_END_OF_TLDRAW_DG_DATA__DO_NOT_CHANGE_THIS_PHRASE_!!!\n```/,
88+
);
89+
90+
if (!match?.[1]) {
91+
console.warn("No tldraw data found in file");
92+
return;
93+
}
94+
95+
const data = JSON.parse(match[1]);
96+
if (!data.raw) {
97+
console.warn("Invalid tldraw data format - missing raw field");
98+
return;
99+
}
100+
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+
}
128+
129+
return store;
130+
} catch (e) {
131+
console.error("Failed to create store from file data", e);
132+
return;
133+
}
134+
}
135+
136+
private createReactRoot(entryPoint: Element, store: TLStore) {
137+
const root = createRoot(entryPoint);
138+
root.render(
139+
<React.StrictMode>
140+
<TldrawPreviewComponent store={store} isReadonly={false} />
141+
</React.StrictMode>,
142+
);
143+
return root;
144+
}
145+
146+
protected async setStore(store: TLStore) {
147+
if (this.store) {
148+
try {
149+
this.store.dispose();
150+
} catch (e) {
151+
console.error("Failed to dispose old store", e);
152+
}
153+
}
154+
155+
this.store = store;
156+
if (this.tldrawContainer) {
157+
await this.refreshView();
158+
}
159+
}
160+
161+
private async refreshView() {
162+
if (!this.store) return;
163+
164+
if (this.reactRoot) {
165+
try {
166+
const container = this.tldrawContainer;
167+
if (container?.hasChildNodes()) {
168+
this.reactRoot.unmount();
169+
}
170+
} catch (e) {
171+
console.error("Failed to unmount React root", e);
172+
}
173+
this.reactRoot = undefined;
174+
}
175+
176+
const container = this.tldrawContainer;
177+
if (container) {
178+
this.reactRoot = this.createReactRoot(container, this.store);
179+
}
180+
}
181+
182+
registerOnUnloadFile(callback: () => void) {
183+
this.onUnloadCallbacks.push(callback);
184+
}
185+
186+
async onUnloadFile(file: TFile): Promise<void> {
187+
const callbacks = [...this.onUnloadCallbacks];
188+
this.onUnloadCallbacks = [];
189+
callbacks.forEach((cb) => cb());
190+
191+
return super.onUnloadFile(file);
192+
}
193+
194+
async onClose() {
195+
await super.onClose();
196+
197+
if (this.reactRoot) {
198+
try {
199+
const container = this.tldrawContainer;
200+
if (container?.hasChildNodes()) {
201+
this.reactRoot.unmount();
202+
}
203+
} catch (e) {
204+
console.error("Failed to unmount React root", e);
205+
}
206+
this.reactRoot = undefined;
207+
}
208+
209+
if (this.store) {
210+
try {
211+
this.store.dispose();
212+
} catch (e) {
213+
console.error("Failed to dispose store", e);
214+
}
215+
this.store = undefined;
216+
}
217+
}
218+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { useCallback, useEffect, useRef, useState } from "react";
2+
import { Editor, ErrorBoundary, Tldraw, TLStore } from "tldraw";
3+
import "tldraw/tldraw.css";
4+
5+
interface TldrawPreviewProps {
6+
store: TLStore;
7+
isReadonly?: boolean;
8+
}
9+
10+
export const TldrawPreviewComponent = ({
11+
store,
12+
isReadonly = false,
13+
}: TldrawPreviewProps) => {
14+
const containerRef = useRef<HTMLDivElement>(null);
15+
const [isReady, setIsReady] = useState(false);
16+
17+
useEffect(() => {
18+
const timer = setTimeout(() => {
19+
setIsReady(true);
20+
}, 250);
21+
return () => clearTimeout(timer);
22+
}, []);
23+
24+
const handleMount = useCallback((editor: Editor) => {
25+
editor.setCurrentTool("hand");
26+
editor.updateInstanceState({});
27+
28+
const shapes = editor.getCurrentPageShapes();
29+
if (shapes.length > 0) {
30+
editor.zoomToFit();
31+
}
32+
}, []);
33+
34+
return (
35+
<div
36+
ref={containerRef}
37+
className="tldraw__editor relative flex h-full w-full flex-1 overflow-hidden"
38+
onTouchStart={(e) => e.stopPropagation()}
39+
>
40+
{isReady ? (
41+
<ErrorBoundary
42+
fallback={({ error }) => (
43+
<div>Error in Tldraw component: {JSON.stringify(error)}</div>
44+
)}
45+
>
46+
<Tldraw store={store} onMount={handleMount} autoFocus={false} />
47+
</ErrorBoundary>
48+
) : (
49+
<div>Loading Tldraw...</div>
50+
)}
51+
</div>
52+
);
53+
};

apps/obsidian/src/constants.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,3 +62,14 @@ export const DEFAULT_SETTINGS: Settings = {
6262
showIdsInFrontmatter: false,
6363
nodesFolderPath: "",
6464
};
65+
export const FRONTMATTER_KEY = "tldr-dg";
66+
export const TLDATA_DELIMITER_START =
67+
"!!!_START_OF_TLDRAW_DG_DATA__DO_NOT_CHANGE_THIS_PHRASE_!!!";
68+
export const TLDATA_DELIMITER_END =
69+
"!!!_END_OF_TLDRAW_DG_DATA__DO_NOT_CHANGE_THIS_PHRASE_!!!";
70+
71+
export const VIEW_TYPE_MARKDOWN = "markdown";
72+
export const VIEW_TYPE_TLDRAW_DG_PREVIEW = "tldraw-dg-preview";
73+
74+
export const TLDRAW_VERSION = "3.14.1";
75+
export const DEFAULT_SAVE_DELAY = 500; // in ms

0 commit comments

Comments
 (0)