diff --git a/apps/obsidian/src/components/TldrawView.tsx b/apps/obsidian/src/components/TldrawView.tsx index 775daef2a..44fd45528 100644 --- a/apps/obsidian/src/components/TldrawView.tsx +++ b/apps/obsidian/src/components/TldrawView.tsx @@ -6,11 +6,13 @@ import { TLStore } from "tldraw"; import React from "react"; import DiscourseGraphPlugin from "~/index"; import { processInitialData, TLData } from "~/utils/tldraw"; +import { ObsidianTLAssetStore } from "~/utils/assetStore"; export class TldrawView extends TextFileView { plugin: DiscourseGraphPlugin; private reactRoot?: Root; - private store?: TLStore; + private store: TLStore | null = null; + private assetStore: ObsidianTLAssetStore | null = null; private onUnloadCallbacks: (() => void)[] = []; constructor(leaf: WorkspaceLeaf, plugin: DiscourseGraphPlugin) { @@ -31,7 +33,7 @@ export class TldrawView extends TextFileView { return this.data; } - setViewData(data: string, clear: boolean): void { + setViewData(data: string, _clear: boolean): void { this.data = data; } @@ -66,17 +68,28 @@ export class TldrawView extends TextFileView { const fileData = await this.app.vault.read(file); - const store = this.createStore(fileData); + const assetStore = new ObsidianTLAssetStore( + `tldraw-${encodeURIComponent(file.path)}`, + { + app: this.app, + file, + }, + ); + const store = this.createStore(fileData, assetStore); if (!store) { console.warn("No tldraw data found in file"); return; } - this.setStore(store); + this.assetStore = assetStore; + await this.setStore(store); } - private createStore(fileData: string): TLStore | undefined { + private createStore( + fileData: string, + assetStore: ObsidianTLAssetStore, + ): TLStore | undefined { try { const match = fileData.match( /```json !!!_START_OF_TLDRAW_DG_DATA__DO_NOT_CHANGE_THIS_PHRASE_!!!([\s\S]*?)!!!_END_OF_TLDRAW_DG_DATA__DO_NOT_CHANGE_THIS_PHRASE_!!!\n```/, @@ -93,7 +106,7 @@ export class TldrawView extends TextFileView { return; } - const { store } = processInitialData(data); + const { store } = processInitialData(data, assetStore); return store; } catch (e) { @@ -102,9 +115,21 @@ export class TldrawView extends TextFileView { } } + private assertInitialized(): void { + if (!this.file) throw new Error("TldrawView not initialized: missing file"); + if (!this.assetStore) + throw new Error("TldrawView not initialized: missing assetStore"); + if (!this.store) + throw new Error("TldrawView not initialized: missing store"); + } + private createReactRoot(entryPoint: Element, store: TLStore) { const root = createRoot(entryPoint); - if (!this.file) return; + if (!this.file) throw new Error("TldrawView not initialized: missing file"); + if (!this.assetStore) + throw new Error("TldrawView not initialized: missing assetStore"); + if (!this.store) + throw new Error("TldrawView not initialized: missing store"); root.render( @@ -112,13 +137,14 @@ export class TldrawView extends TextFileView { store={store} plugin={this.plugin} file={this.file} + assetStore={this.assetStore} /> , ); return root; } - protected setStore(store: TLStore) { + protected async setStore(store: TLStore) { if (this.store) { try { this.store.dispose(); @@ -129,11 +155,11 @@ export class TldrawView extends TextFileView { this.store = store; if (this.tldrawContainer) { - this.refreshView(); + await this.refreshView(); } } - private refreshView() { + private async refreshView() { if (!this.store) return; if (this.reactRoot) { @@ -151,6 +177,7 @@ export class TldrawView extends TextFileView { const container = this.tldrawContainer; if (container) { this.reactRoot = this.createReactRoot(container, this.store); + await new Promise((resolve) => setTimeout(resolve, 0)); // Wait for React to render } } @@ -163,6 +190,11 @@ export class TldrawView extends TextFileView { this.onUnloadCallbacks = []; callbacks.forEach((cb) => cb()); + if (this.assetStore) { + this.assetStore.dispose(); + this.assetStore = null; + } + return super.onUnloadFile(file); } @@ -187,7 +219,12 @@ export class TldrawView extends TextFileView { } catch (e) { console.error("Failed to dispose store", e); } - this.store = undefined; + this.store = null; + } + + if (this.assetStore) { + this.assetStore.dispose(); + this.assetStore = null; } } -} +} \ No newline at end of file diff --git a/apps/obsidian/src/components/TldrawViewComponent.tsx b/apps/obsidian/src/components/TldrawViewComponent.tsx index e37111c55..1b4414e17 100644 --- a/apps/obsidian/src/components/TldrawViewComponent.tsx +++ b/apps/obsidian/src/components/TldrawViewComponent.tsx @@ -15,22 +15,24 @@ import { TLDATA_DELIMITER_START, } from "~/constants"; import { TFile } from "obsidian"; +import { ObsidianTLAssetStore } from "~/utils/assetStore"; interface TldrawPreviewProps { store: TLStore; plugin: DiscourseGraphPlugin; file: TFile; + assetStore: ObsidianTLAssetStore; } export const TldrawPreviewComponent = ({ store, plugin, file, + assetStore, }: TldrawPreviewProps) => { const containerRef = useRef(null); const [currentStore, setCurrentStore] = useState(store); const [isReady, setIsReady] = useState(false); - const editorRef = useRef(null); const saveTimeoutRef = useRef(); const lastSavedDataRef = useRef(""); @@ -90,11 +92,11 @@ export const TldrawPreviewComponent = ({ ); if (match?.[1]) { const data = JSON.parse(match[1]) as TLData; - const { store: newStore } = processInitialData(data); + const { store: newStore } = processInitialData(data, assetStore); setCurrentStore(newStore); } } - }, [file, plugin, currentStore]); + }, [file, plugin, currentStore, assetStore]); useEffect(() => { const unsubscribe = currentStore.listen( @@ -118,24 +120,15 @@ export const TldrawPreviewComponent = ({ }; }, [currentStore, saveChanges]); - const handleMount = useCallback((editor: Editor) => { - editorRef.current = editor; - editor.setCurrentTool("select"); - }, []); - return ( -
e.stopPropagation()} - > +
{isReady ? ( (
Error in Tldraw component: {JSON.stringify(error)}
)} > - +
) : (
Loading Tldraw...
diff --git a/apps/obsidian/src/utils/assetStore.ts b/apps/obsidian/src/utils/assetStore.ts new file mode 100644 index 000000000..30f25b0f1 --- /dev/null +++ b/apps/obsidian/src/utils/assetStore.ts @@ -0,0 +1,224 @@ +import { App, TFile } from "obsidian"; +import { TLAsset, TLAssetStore, TLAssetId, TLAssetContext } from "tldraw"; +import { JsonObject } from "@tldraw/utils"; + +const ASSET_PREFIX = "obsidian.blockref."; +type BlockRefAssetId = `${typeof ASSET_PREFIX}${string}`; +type AssetDataUrl = string; + +type AssetStoreOptions = { + app: App; + file: TFile; +}; + +/** + * Proxy class that handles Obsidian-specific file operations for the TLAssetStore + */ +class ObsidianMarkdownFileTLAssetStoreProxy { + private resolvedAssetDataCache = new Map(); + private app: App; + private file: TFile; + + /** + * Safely set a cached Blob URL for an asset id, revoking any previous URL to avoid leaks + */ + private setCachedUrl(blockRefAssetId: BlockRefAssetId, url: AssetDataUrl) { + const previousUrl = this.resolvedAssetDataCache.get(blockRefAssetId); + if (previousUrl && previousUrl !== url) { + try { + URL.revokeObjectURL(previousUrl); + } catch (err) { + console.warn("Failed to revoke previous object URL", err); + } + } + this.resolvedAssetDataCache.set(blockRefAssetId, url); + } + + constructor(options: AssetStoreOptions) { + this.app = options.app; + this.file = options.file; + } + + storeAsset = async ( + _asset: TLAsset, + file: File, + ): Promise => { + const blockRefId = crypto.randomUUID(); + + const objectName = `${blockRefId}-${file.name}`.replace(/\W/g, "-"); + const ext = file.type.split("/").at(1); + const fileName = !ext ? objectName : `${objectName}.${ext}`; + + // TODO: in the future, get this from the user's settings + let attachmentFolder = this.app.vault.getFolderByPath("attachments"); + if (!attachmentFolder) { + attachmentFolder = await this.app.vault.createFolder("attachments"); + } + const filePath = `${attachmentFolder.path}/${fileName}`; + + const arrayBuffer = await file.arrayBuffer(); + const assetFile = await this.app.vault.createBinary(filePath, arrayBuffer); + + const linkText = this.app.metadataCache.fileToLinktext( + assetFile, + this.file.path, + ); + const internalLink = `[[${linkText}]]`; + const linkBlock = `${internalLink}\n^${blockRefId}`; + + await this.addToTopOfFile(linkBlock); + + const assetDataUri = URL.createObjectURL(file); + const assetId = `${ASSET_PREFIX}${blockRefId}` as BlockRefAssetId; + this.setCachedUrl(assetId, assetDataUri); + + return assetId; + } + + getCached = async ( + blockRefAssetId: BlockRefAssetId, + ): Promise => { + try { + // Check cache first + const cached = this.resolvedAssetDataCache.get(blockRefAssetId); + if (cached) return cached; + + // Load and cache if needed + const assetData = await this.getAssetData(blockRefAssetId); + if (!assetData) return null; + + const uri = URL.createObjectURL(new Blob([assetData])); + this.setCachedUrl(blockRefAssetId, uri); + return uri; + } catch (error) { + console.error("Error getting cached asset:", error); + return null; + } + } + + dispose = () => { + for (const url of this.resolvedAssetDataCache.values()) { + URL.revokeObjectURL(url); + } + this.resolvedAssetDataCache.clear(); + } + + private addToTopOfFile = async (content: string) => { + await this.app.vault.process(this.file, (data: string) => { + const fileCache = this.app.metadataCache.getFileCache(this.file); + const { start, end } = fileCache?.frontmatterPosition ?? { + start: { offset: 0 }, + end: { offset: 0 }, + }; + + const frontmatter = data.slice(start.offset, end.offset); + const rest = data.slice(end.offset); + return `${frontmatter}\n${content}\n${rest}`; + }); + } + + private getAssetData = async ( + blockRefAssetId: BlockRefAssetId, + ): Promise => { + try { + const blockRef = blockRefAssetId.slice(ASSET_PREFIX.length); + if (!blockRef) return null; + + const fileCache = this.app.metadataCache.getFileCache(this.file); + if (!fileCache?.blocks?.[blockRef]) return null; + + const block = fileCache.blocks[blockRef]; + const fileContent = await this.app.vault.read(this.file); + const blockContent = fileContent.substring( + block.position.start.offset, + block.position.end.offset, + ); + + const match = blockContent.match(/\[\[(.*?)\]\]/); + if (!match?.[1]) return null; + + const linkPath = match[1]; + const linkedFile = this.app.metadataCache.getFirstLinkpathDest( + linkPath, + this.file.path, + ); + + if (!linkedFile) return null; + // TODO: handle other file types too + return await this.app.vault.readBinary(linkedFile); + } catch (error) { + console.error("Error getting asset data:", error); + return null; + } + } +} + +/** + * TLAssetStore implementation for Obsidian + */ +export class ObsidianTLAssetStore implements Required { + private proxy: ObsidianMarkdownFileTLAssetStoreProxy; + + constructor( + public readonly persistenceKey: string, + options: AssetStoreOptions, + ) { + this.proxy = new ObsidianMarkdownFileTLAssetStoreProxy(options); + } + + upload = async ( + asset: TLAsset, + file: File, + ): Promise<{ src: string; meta?: JsonObject }> => { + try { + const blockRefAssetId = await this.proxy.storeAsset(asset, file); + return { + src: `asset:${blockRefAssetId}`, + meta: { + mimeType: file.type, + }, + }; + } catch (error) { + console.error("Error uploading asset:", error); + throw error; + } + }; + + resolve = async ( + asset: TLAsset, + _ctx: TLAssetContext, + ): Promise => { + try { + const assetSrc = asset.props.src; + if (!assetSrc?.startsWith("asset:")) return assetSrc ?? null; + + const assetId = assetSrc.split(":")[1] as BlockRefAssetId; + if (!assetId) return null; + + const mimeType = + (asset.props as unknown as { mimeType?: string })?.mimeType ?? + (asset.meta as unknown as { mimeType?: string })?.mimeType ?? + ""; + + if (mimeType.startsWith("image/") || mimeType.startsWith("video/")) { + return await this.proxy.getCached(assetId); + } + + // Non-media (e.g., text/markdown, application/*): let custom shapes decide. + // Return null so default media shapes won't attempt to render it. + return null; + } catch (error) { + console.error("Error resolving asset:", error); + return null; + } + }; + + remove = async (_assetIds: TLAssetId[]): Promise => { + // No-op for now as we don't want to delete files from the vault + // The files will remain in the vault and can be managed by the user + }; + + dispose = () => { + this.proxy.dispose(); + }; +} diff --git a/apps/obsidian/src/utils/tldraw.ts b/apps/obsidian/src/utils/tldraw.ts index 6b868d811..a34d140c6 100644 --- a/apps/obsidian/src/utils/tldraw.ts +++ b/apps/obsidian/src/utils/tldraw.ts @@ -4,11 +4,12 @@ import { TLDATA_DELIMITER_END, TLDATA_DELIMITER_START, TLDRAW_VERSION, -} from "../constants"; +} from "~/constants"; import DiscourseGraphPlugin from "~/index"; import { checkAndCreateFolder, getNewUniqueFilepath } from "./file"; import { Notice } from "obsidian"; import { format } from "date-fns"; +import { ObsidianTLAssetStore } from "./assetStore"; export type TldrawPluginMetaData = { "plugin-version": string; @@ -29,6 +30,7 @@ export type TLData = { export const processInitialData = ( data: TLData, + assetStore: ObsidianTLAssetStore, ): { meta: TldrawPluginMetaData; store: TLStore } => { const recordsData = Array.isArray(data.raw.records) ? data.raw.records.reduce( @@ -47,10 +49,12 @@ export const processInitialData = ( store = createTLStore({ shapeUtils: defaultShapeUtils, initialData: recordsData, + assets: assetStore, }); } else { store = createTLStore({ shapeUtils: defaultShapeUtils, + assets: assetStore, }); } diff --git a/apps/obsidian/styles.css b/apps/obsidian/styles.css index 295645138..565152a06 100644 --- a/apps/obsidian/styles.css +++ b/apps/obsidian/styles.css @@ -17,3 +17,9 @@ .dg-h4 { @apply text-lg font-bold mb-2; } + +/* Neutralize host button styling inside our editor */ +.tldraw__editor button { + background: transparent !important; + color: inherit; +}