From f9637481f2d2c7bc7899a081628640ec8f1cdaec Mon Sep 17 00:00:00 2001 From: Trang Doan <44855874+trangdoan982@users.noreply.github.com> Date: Tue, 22 Jul 2025 11:33:50 -0400 Subject: [PATCH 01/26] [ENG-495] Tldraw obsidian setup (#285) * cleaned * sm * address PR comments --- apps/obsidian/package.json | 4 +- apps/obsidian/src/components/TldrawView.tsx | 218 ++++++++++++++++++ .../src/components/TldrawViewComponent.tsx | 53 +++++ apps/obsidian/src/constants.ts | 11 + apps/obsidian/src/index.ts | 85 ++++++- apps/obsidian/src/utils/file.ts | 34 +++ apps/obsidian/src/utils/registerCommands.ts | 43 ++++ apps/obsidian/src/utils/tldraw.ts | 124 ++++++++++ apps/obsidian/tsconfig.json | 7 +- 9 files changed, 572 insertions(+), 7 deletions(-) create mode 100644 apps/obsidian/src/components/TldrawView.tsx create mode 100644 apps/obsidian/src/components/TldrawViewComponent.tsx create mode 100644 apps/obsidian/src/utils/file.ts create mode 100644 apps/obsidian/src/utils/tldraw.ts diff --git a/apps/obsidian/package.json b/apps/obsidian/package.json index f64520315..4cb48a41d 100644 --- a/apps/obsidian/package.json +++ b/apps/obsidian/package.json @@ -39,6 +39,8 @@ "nanoid": "^4.0.2", "react": "catalog:obsidian", "react-dom": "catalog:obsidian", - "tailwindcss-animate": "^1.0.7" + "date-fns": "^4.1.0", + "tailwindcss-animate": "^1.0.7", + "tldraw": "3.14.2" } } diff --git a/apps/obsidian/src/components/TldrawView.tsx b/apps/obsidian/src/components/TldrawView.tsx new file mode 100644 index 000000000..564a745e2 --- /dev/null +++ b/apps/obsidian/src/components/TldrawView.tsx @@ -0,0 +1,218 @@ +import { TextFileView, TFile, WorkspaceLeaf } from "obsidian"; +import { VIEW_TYPE_TLDRAW_DG_PREVIEW } from "~/constants"; +import { Root, createRoot } from "react-dom/client"; +import { TldrawPreviewComponent } from "./TldrawViewComponent"; +import { + TLRecord, + TLStore, + createTLStore, + defaultShapeUtils, + loadSnapshot, +} from "tldraw"; +import React from "react"; +import DiscourseGraphPlugin from "~/index"; + +export class TldrawView extends TextFileView { + plugin: DiscourseGraphPlugin; + private reactRoot?: Root; + private store?: TLStore; + private onUnloadCallbacks: (() => void)[] = []; + + constructor(leaf: WorkspaceLeaf, plugin: DiscourseGraphPlugin) { + super(leaf); + this.plugin = plugin; + this.navigation = true; + } + + getViewType(): string { + return VIEW_TYPE_TLDRAW_DG_PREVIEW; + } + + getDisplayText(): string { + return this.file?.basename ?? "Discourse Graph Canvas Preview"; + } + + getViewData(): string { + return this.data; + } + + setViewData(data: string, clear: boolean): void { + this.data = data; + } + + clear(): void { + this.data = ""; + } + + protected get tldrawContainer() { + return this.containerEl.children[1]; + } + + override onload(): void { + super.onload(); + this.contentEl.addClass("tldraw-view-content"); + this.addAction("file-text", "View as markdown", () => + this.leaf.setViewState({ + type: "markdown", + state: this.leaf.view.getState(), + }), + ); + } + + async onOpen() { + const container = this.tldrawContainer; + if (!container) return; + + container.empty(); + } + + async onLoadFile(file: TFile): Promise { + await super.onLoadFile(file); + + const fileData = await this.app.vault.read(file); + + const store = await this.createStore(fileData); + + if (!store) { + console.warn("No tldraw data found in file"); + return; + } + + await this.setStore(store); + } + + private async createStore(fileData: string): Promise { + 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```/, + ); + + if (!match?.[1]) { + console.warn("No tldraw data found in file"); + return; + } + + const data = JSON.parse(match[1]); + if (!data.raw) { + console.warn("Invalid tldraw data format - missing raw field"); + return; + } + + const recordsData = Array.isArray(data.raw.records) + ? data.raw.records.reduce( + ( + acc: Record, + record: { id: string } & TLRecord, + ) => { + acc[record.id] = { + ...record, + }; + return acc; + }, + {}, + ) + : data.raw.records; + + let store: TLStore; + if (recordsData) { + store = createTLStore({ + shapeUtils: defaultShapeUtils, + initialData: recordsData, + }); + } else { + store = createTLStore({ + shapeUtils: defaultShapeUtils, + }); + loadSnapshot(store, data.raw); + } + + return store; + } catch (e) { + console.error("Failed to create store from file data", e); + return; + } + } + + private createReactRoot(entryPoint: Element, store: TLStore) { + const root = createRoot(entryPoint); + root.render( + + + , + ); + return root; + } + + protected async setStore(store: TLStore) { + if (this.store) { + try { + this.store.dispose(); + } catch (e) { + console.error("Failed to dispose old store", e); + } + } + + this.store = store; + if (this.tldrawContainer) { + await this.refreshView(); + } + } + + private async refreshView() { + if (!this.store) return; + + if (this.reactRoot) { + try { + const container = this.tldrawContainer; + if (container?.hasChildNodes()) { + this.reactRoot.unmount(); + } + } catch (e) { + console.error("Failed to unmount React root", e); + } + this.reactRoot = undefined; + } + + const container = this.tldrawContainer; + if (container) { + this.reactRoot = this.createReactRoot(container, this.store); + } + } + + registerOnUnloadFile(callback: () => void) { + this.onUnloadCallbacks.push(callback); + } + + async onUnloadFile(file: TFile): Promise { + const callbacks = [...this.onUnloadCallbacks]; + this.onUnloadCallbacks = []; + callbacks.forEach((cb) => cb()); + + return super.onUnloadFile(file); + } + + async onClose() { + await super.onClose(); + + if (this.reactRoot) { + try { + const container = this.tldrawContainer; + if (container?.hasChildNodes()) { + this.reactRoot.unmount(); + } + } catch (e) { + console.error("Failed to unmount React root", e); + } + this.reactRoot = undefined; + } + + if (this.store) { + try { + this.store.dispose(); + } catch (e) { + console.error("Failed to dispose store", e); + } + this.store = undefined; + } + } +} diff --git a/apps/obsidian/src/components/TldrawViewComponent.tsx b/apps/obsidian/src/components/TldrawViewComponent.tsx new file mode 100644 index 000000000..9577e9a05 --- /dev/null +++ b/apps/obsidian/src/components/TldrawViewComponent.tsx @@ -0,0 +1,53 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { Editor, ErrorBoundary, Tldraw, TLStore } from "tldraw"; +import "tldraw/tldraw.css"; + +interface TldrawPreviewProps { + store: TLStore; + isReadonly?: boolean; +} + +export const TldrawPreviewComponent = ({ + store, + isReadonly = false, +}: TldrawPreviewProps) => { + const containerRef = useRef(null); + const [isReady, setIsReady] = useState(false); + + useEffect(() => { + const timer = setTimeout(() => { + setIsReady(true); + }, 250); + return () => clearTimeout(timer); + }, []); + + const handleMount = useCallback((editor: Editor) => { + editor.setCurrentTool("hand"); + editor.updateInstanceState({}); + + const shapes = editor.getCurrentPageShapes(); + if (shapes.length > 0) { + editor.zoomToFit(); + } + }, []); + + return ( +
e.stopPropagation()} + > + {isReady ? ( + ( +
Error in Tldraw component: {JSON.stringify(error)}
+ )} + > + +
+ ) : ( +
Loading Tldraw...
+ )} +
+ ); +}; \ No newline at end of file diff --git a/apps/obsidian/src/constants.ts b/apps/obsidian/src/constants.ts index 73258322e..565003e64 100644 --- a/apps/obsidian/src/constants.ts +++ b/apps/obsidian/src/constants.ts @@ -62,3 +62,14 @@ export const DEFAULT_SETTINGS: Settings = { showIdsInFrontmatter: false, nodesFolderPath: "", }; +export const FRONTMATTER_KEY = "tldr-dg"; +export const TLDATA_DELIMITER_START = + "!!!_START_OF_TLDRAW_DG_DATA__DO_NOT_CHANGE_THIS_PHRASE_!!!"; +export const TLDATA_DELIMITER_END = + "!!!_END_OF_TLDRAW_DG_DATA__DO_NOT_CHANGE_THIS_PHRASE_!!!"; + +export const VIEW_TYPE_MARKDOWN = "markdown"; +export const VIEW_TYPE_TLDRAW_DG_PREVIEW = "tldraw-dg-preview"; + +export const TLDRAW_VERSION = "3.14.1"; +export const DEFAULT_SAVE_DELAY = 500; // in ms \ No newline at end of file diff --git a/apps/obsidian/src/index.ts b/apps/obsidian/src/index.ts index 2f30ede5a..426f4b3ee 100644 --- a/apps/obsidian/src/index.ts +++ b/apps/obsidian/src/index.ts @@ -1,9 +1,16 @@ -import { Plugin, Editor, Menu, TFile } from "obsidian"; +import { + Plugin, + Editor, + Menu, + TFile, + MarkdownView, + WorkspaceLeaf, +} from "obsidian"; import { SettingsTab } from "~/components/Settings"; -import { Settings } from "~/types"; +import { Settings, VIEW_TYPE_DISCOURSE_CONTEXT } from "~/types"; import { registerCommands } from "~/utils/registerCommands"; import { DiscourseContextView } from "~/components/DiscourseContextView"; -import { VIEW_TYPE_DISCOURSE_CONTEXT } from "~/types"; +import { VIEW_TYPE_TLDRAW_DG_PREVIEW, FRONTMATTER_KEY } from "~/constants"; import { convertPageToDiscourseNode, createDiscourseNode, @@ -11,17 +18,67 @@ import { import { DEFAULT_SETTINGS } from "~/constants"; import { CreateNodeModal } from "~/components/CreateNodeModal"; import { TagNodeHandler } from "~/utils/tagNodeHandler"; +import { TldrawView } from "~/components/TldrawView"; export default class DiscourseGraphPlugin extends Plugin { settings: Settings = { ...DEFAULT_SETTINGS }; private styleElement: HTMLStyleElement | null = null; private tagNodeHandler: TagNodeHandler | null = null; + private currentViewActions: { leaf: WorkspaceLeaf; action: any }[] = []; async onload() { await this.loadSettings(); registerCommands(this); this.addSettingTab(new SettingsTab(this.app, this)); + this.registerEvent( + this.app.workspace.on( + "active-leaf-change", + (leaf: WorkspaceLeaf | null) => { + this.cleanupViewActions(); + + if (!leaf) return; + + const view = leaf.view; + if (!(view instanceof MarkdownView)) return; + + const file = view.file; + if (!file) return; + + const cache = this.app.metadataCache.getFileCache(file); + if (cache?.frontmatter?.[FRONTMATTER_KEY]) { + // Add new action and track it + const action = view.addAction( + "layout", + "View as canvas", + async () => { + await leaf.setViewState({ + type: VIEW_TYPE_TLDRAW_DG_PREVIEW, + state: view.getState(), + }); + }, + ); + + this.currentViewActions.push({ leaf, action }); + } + }, + ), + // @ts-ignore - file-open event exists but is not in the type definitions + this.app.workspace.on("file-open", (file: TFile) => { + const cache = this.app.metadataCache.getFileCache(file); + if (cache?.frontmatter?.[FRONTMATTER_KEY]) { + const leaf = + this.app.workspace.getActiveViewOfType(MarkdownView)?.leaf; + if (leaf) { + leaf.setViewState({ + type: VIEW_TYPE_TLDRAW_DG_PREVIEW, + state: leaf.view.getState(), + }); + } + } + }), + ); + this.registerView( VIEW_TYPE_DISCOURSE_CONTEXT, (leaf) => new DiscourseContextView(leaf, this), @@ -42,6 +99,10 @@ export default class DiscourseGraphPlugin extends Plugin { console.error("Failed to initialize TagNodeHandler:", error); this.tagNodeHandler = null; } + this.registerView( + VIEW_TYPE_TLDRAW_DG_PREVIEW, + (leaf) => new TldrawView(leaf, this), + ); this.registerEvent( // @ts-ignore - file-menu event exists but is not in the type definitions @@ -206,7 +267,25 @@ export default class DiscourseGraphPlugin extends Plugin { this.updateFrontmatterStyles(); } + private cleanupViewActions() { + this.currentViewActions.forEach(({ leaf, action }) => { + try { + if (leaf?.view) { + if (action?.remove) { + action.remove(); + } else if (action?.detach) { + action.detach(); + } + } + } catch (e) { + console.error("Failed to cleanup view action:", e); + } + }); + this.currentViewActions = []; + } + async onunload() { + this.cleanupViewActions(); if (this.styleElement) { this.styleElement.remove(); } diff --git a/apps/obsidian/src/utils/file.ts b/apps/obsidian/src/utils/file.ts new file mode 100644 index 000000000..952f77d9c --- /dev/null +++ b/apps/obsidian/src/utils/file.ts @@ -0,0 +1,34 @@ +import { TAbstractFile, TFolder, Vault, normalizePath } from "obsidian"; + +export const checkAndCreateFolder = async (folderpath: string, vault: Vault) => { + if (!folderpath) return; + + const abstractItem = vault.getAbstractFileByPath(folderpath); + if (abstractItem instanceof TFolder) return; + if (abstractItem instanceof TAbstractFile) { + throw new Error(`${folderpath} exists as a file`); + } + await vault.createFolder(folderpath); +}; + +export const getNewUniqueFilepath = ({ + vault, + filename, + folderpath, +}: { + vault: Vault; + filename: string; + folderpath: string; +}): string => { + let fname = normalizePath(`${folderpath}/${filename}`); + let num = 1; + + while (vault.getAbstractFileByPath(fname) != null) { + const ext = filename.split(".").pop(); + const base = filename.replace(/\.[^/.]+$/, ""); + fname = normalizePath(`${folderpath}/${base} ${num}.${ext}`); + num++; + } + + return fname; +}; diff --git a/apps/obsidian/src/utils/registerCommands.ts b/apps/obsidian/src/utils/registerCommands.ts index 6f62e6edf..8a52ec075 100644 --- a/apps/obsidian/src/utils/registerCommands.ts +++ b/apps/obsidian/src/utils/registerCommands.ts @@ -4,6 +4,8 @@ import { NodeTypeModal } from "~/components/NodeTypeModal"; import { CreateNodeModal } from "~/components/CreateNodeModal"; import { BulkIdentifyDiscourseNodesModal } from "~/components/BulkIdentifyDiscourseNodesModal"; import { createDiscourseNode } from "./createNode"; +import { VIEW_TYPE_MARKDOWN, VIEW_TYPE_TLDRAW_DG_PREVIEW } from "~/constants"; +import { createCanvas } from "./tldraw"; export const registerCommands = (plugin: DiscourseGraphPlugin) => { plugin.addCommand({ @@ -77,4 +79,45 @@ export const registerCommands = (plugin: DiscourseGraphPlugin) => { setting.openTabById(plugin.manifest.id); }, }); + + plugin.addCommand({ + id: "switch-to-tldraw-edit", + name: "Switch to Discourse Markdown Edit", + checkCallback: (checking: boolean) => { + const leaf = plugin.app.workspace.activeLeaf; + if (!leaf) return false; + + if (!checking) { + leaf.setViewState({ + type: VIEW_TYPE_MARKDOWN, + state: leaf.view.getState(), + }); + } + return true; + }, + }); + + plugin.addCommand({ + id: "switch-to-tldraw-preview", + name: "Switch to Discourse Graph Canvas View", + checkCallback: (checking: boolean) => { + const leaf = plugin.app.workspace.activeLeaf; + if (!leaf) return false; + + if (!checking) { + leaf.setViewState({ + type: VIEW_TYPE_TLDRAW_DG_PREVIEW, + state: leaf.view.getState(), + }); + } + return true; + }, + }); + + plugin.addCommand({ + id: "create-discourse-graph-canvas", + name: "Create new Discourse Graph canvas", + icon: "layout-dashboard", // Using Lucide icon as per style guide + callback: () => createCanvas(plugin), + }); }; diff --git a/apps/obsidian/src/utils/tldraw.ts b/apps/obsidian/src/utils/tldraw.ts new file mode 100644 index 000000000..eba105314 --- /dev/null +++ b/apps/obsidian/src/utils/tldraw.ts @@ -0,0 +1,124 @@ +import { createTLStore, TldrawFile, TLStore } from "tldraw"; +import { + FRONTMATTER_KEY, + TLDATA_DELIMITER_END, + TLDATA_DELIMITER_START, + TLDRAW_VERSION, +} from "../constants"; +import DiscourseGraphPlugin from ".."; +import { checkAndCreateFolder, getNewUniqueFilepath } from "./file"; +import { Notice } from "obsidian"; +import { format } from "date-fns"; + +export type TldrawPluginMetaData = { + "plugin-version": string; + "tldraw-version": string; + uuid: string; +}; + +export type TLData = { + meta: TldrawPluginMetaData; + raw: { + tldrawFileFormatVersion: number; + schema: any; + records: any; + }; +}; + +export const createRawTldrawFile = (store?: TLStore): TldrawFile => { + store ??= createTLStore(); + return { + tldrawFileFormatVersion: 1, + schema: store.schema.serialize(), + records: store.allRecords(), + }; +}; + +export const getTLMetaTemplate = ( + pluginVersion: string, + uuid: string = window.crypto.randomUUID(), +): TldrawPluginMetaData => { + return { + uuid, + "plugin-version": pluginVersion, + "tldraw-version": TLDRAW_VERSION, + }; +}; + +export const getTLDataTemplate = ({ + pluginVersion, + tldrawFile, + uuid, +}: { + pluginVersion: string; + tldrawFile: TldrawFile; + uuid: string; +}): TLData => { + return { + meta: getTLMetaTemplate(pluginVersion, uuid), + raw: tldrawFile, + }; +}; + +export const frontmatterTemplate = (data: string, tags: string[] = []) => { + let str = "---\n"; + str += `${data}\n`; + if (tags.length) { + str += `tags:\n[${tags.join(", ")}]\n`; + } + str += "---\n"; + return str; +}; + +export const codeBlockTemplate = (data: TLData) => { + let str = "```json" + ` ${TLDATA_DELIMITER_START}`; + str += "\n"; + str += `${JSON.stringify(data, null, "\t")}\n`; + str += `${TLDATA_DELIMITER_END}\n`; + str += "```"; + return str; +}; + +export const tlFileTemplate = (frontmatter: string, codeblock: string) => { + return `${frontmatter}\n\n${codeblock}`; +}; + +export const createEmptyTldrawContent = ( + pluginVersion: string, + tags: string[] = [], +): string => { + const tldrawFile = createRawTldrawFile(); + const tlData = getTLDataTemplate({ + pluginVersion, + tldrawFile, + uuid: window.crypto.randomUUID(), + }); + const frontmatter = frontmatterTemplate(`${FRONTMATTER_KEY}: true`, tags); + const codeblock = codeBlockTemplate(tlData); + return tlFileTemplate(frontmatter, codeblock); +}; + +export const createCanvas = async (plugin: DiscourseGraphPlugin) => { + try { + const filename = `Canvas-${format(new Date(), "yyyy-MM-dd-HHmm")}`; + // TODO: For now we'll create files in this default location, later we can add settings for this + const folderpath = "tldraw-dg"; + + await checkAndCreateFolder(folderpath, plugin.app.vault); + const fname = getNewUniqueFilepath({ + vault: plugin.app.vault, + filename: filename + ".md", + folderpath, + }); + + const content = createEmptyTldrawContent(plugin.manifest.version); + const file = await plugin.app.vault.create(fname, content); + const leaf = plugin.app.workspace.getLeaf(false); + await leaf.openFile(file); + + return file; + } catch (e) { + new Notice(e instanceof Error ? e.message : "Failed to create canvas file"); + console.error(e); + } +}; \ No newline at end of file diff --git a/apps/obsidian/tsconfig.json b/apps/obsidian/tsconfig.json index 56146f6ec..c3b1a22de 100644 --- a/apps/obsidian/tsconfig.json +++ b/apps/obsidian/tsconfig.json @@ -1,6 +1,5 @@ { "extends": "@repo/typescript-config/react-library.json", - "include": ["**/*.ts"], "compilerOptions": { "baseUrl": ".", "rootDir": ".", @@ -14,6 +13,8 @@ "strictNullChecks": true, "paths": { "~/*": ["./src/*"] - } - } + }, + "lib": ["DOM", "ES5", "ES6", "ES7"] + }, + "include": ["**/*.ts", "**/*.tsx"] } From 2675effb705de6bd0ee44c2e94339a6a7f0cb10f Mon Sep 17 00:00:00 2001 From: Trang Doan <44855874+trangdoan982@users.noreply.github.com> Date: Mon, 4 Aug 2025 11:25:36 -0400 Subject: [PATCH 02/26] [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 --- apps/obsidian/src/components/TldrawView.tsx | 59 +++------- .../src/components/TldrawViewComponent.tsx | 110 ++++++++++++++++-- apps/obsidian/src/utils/tldraw.ts | 68 +++++++++-- 3 files changed, 179 insertions(+), 58 deletions(-) diff --git a/apps/obsidian/src/components/TldrawView.tsx b/apps/obsidian/src/components/TldrawView.tsx index 564a745e2..775daef2a 100644 --- a/apps/obsidian/src/components/TldrawView.tsx +++ b/apps/obsidian/src/components/TldrawView.tsx @@ -2,15 +2,10 @@ import { TextFileView, TFile, WorkspaceLeaf } from "obsidian"; import { VIEW_TYPE_TLDRAW_DG_PREVIEW } from "~/constants"; import { Root, createRoot } from "react-dom/client"; import { TldrawPreviewComponent } from "./TldrawViewComponent"; -import { - TLRecord, - TLStore, - createTLStore, - defaultShapeUtils, - loadSnapshot, -} from "tldraw"; +import { TLStore } from "tldraw"; import React from "react"; import DiscourseGraphPlugin from "~/index"; +import { processInitialData, TLData } from "~/utils/tldraw"; export class TldrawView extends TextFileView { plugin: DiscourseGraphPlugin; @@ -71,17 +66,17 @@ export class TldrawView extends TextFileView { const fileData = await this.app.vault.read(file); - const store = await this.createStore(fileData); + const store = this.createStore(fileData); if (!store) { console.warn("No tldraw data found in file"); return; } - await this.setStore(store); + this.setStore(store); } - private async createStore(fileData: string): Promise { + private createStore(fileData: string): 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```/, @@ -92,39 +87,13 @@ export class TldrawView extends TextFileView { return; } - const data = JSON.parse(match[1]); + const data = JSON.parse(match[1]) as TLData; if (!data.raw) { console.warn("Invalid tldraw data format - missing raw field"); return; } - const recordsData = Array.isArray(data.raw.records) - ? data.raw.records.reduce( - ( - acc: Record, - record: { id: string } & TLRecord, - ) => { - acc[record.id] = { - ...record, - }; - return acc; - }, - {}, - ) - : data.raw.records; - - let store: TLStore; - if (recordsData) { - store = createTLStore({ - shapeUtils: defaultShapeUtils, - initialData: recordsData, - }); - } else { - store = createTLStore({ - shapeUtils: defaultShapeUtils, - }); - loadSnapshot(store, data.raw); - } + const { store } = processInitialData(data); return store; } catch (e) { @@ -135,15 +104,21 @@ export class TldrawView extends TextFileView { private createReactRoot(entryPoint: Element, store: TLStore) { const root = createRoot(entryPoint); + if (!this.file) return; + root.render( - + , ); return root; } - protected async setStore(store: TLStore) { + protected setStore(store: TLStore) { if (this.store) { try { this.store.dispose(); @@ -154,11 +129,11 @@ export class TldrawView extends TextFileView { this.store = store; if (this.tldrawContainer) { - await this.refreshView(); + this.refreshView(); } } - private async refreshView() { + private refreshView() { if (!this.store) return; if (this.reactRoot) { diff --git a/apps/obsidian/src/components/TldrawViewComponent.tsx b/apps/obsidian/src/components/TldrawViewComponent.tsx index 9577e9a05..e37111c55 100644 --- a/apps/obsidian/src/components/TldrawViewComponent.tsx +++ b/apps/obsidian/src/components/TldrawViewComponent.tsx @@ -1,18 +1,38 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { Editor, ErrorBoundary, Tldraw, TLStore } from "tldraw"; import "tldraw/tldraw.css"; +import { + getTLDataTemplate, + createRawTldrawFile, + getUpdatedMdContent, + TLData, + processInitialData, +} from "~/utils/tldraw"; +import DiscourseGraphPlugin from "~/index"; +import { + DEFAULT_SAVE_DELAY, + TLDATA_DELIMITER_END, + TLDATA_DELIMITER_START, +} from "~/constants"; +import { TFile } from "obsidian"; interface TldrawPreviewProps { store: TLStore; - isReadonly?: boolean; + plugin: DiscourseGraphPlugin; + file: TFile; } export const TldrawPreviewComponent = ({ store, - isReadonly = false, + plugin, + file, }: 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(""); useEffect(() => { const timer = setTimeout(() => { @@ -21,14 +41,86 @@ export const TldrawPreviewComponent = ({ return () => clearTimeout(timer); }, []); - const handleMount = useCallback((editor: Editor) => { - editor.setCurrentTool("hand"); - editor.updateInstanceState({}); + const saveChanges = useCallback(async () => { + const newData = getTLDataTemplate({ + pluginVersion: plugin.manifest.version, + tldrawFile: createRawTldrawFile(currentStore), + uuid: window.crypto.randomUUID(), + }); + const stringifiedData = JSON.stringify(newData, null, "\t"); + + if (stringifiedData === lastSavedDataRef.current) { + return; + } + + const currentContent = await plugin.app.vault.read(file); + if (!currentContent) { + console.error("Could not read file content"); + return; + } - const shapes = editor.getCurrentPageShapes(); - if (shapes.length > 0) { - editor.zoomToFit(); + const updatedString = getUpdatedMdContent(currentContent, stringifiedData); + if (updatedString === currentContent) { + return; } + + try { + await plugin.app.vault.modify(file, updatedString); + + const verifyContent = await plugin.app.vault.read(file); + const verifyMatch = verifyContent.match( + new RegExp( + `${TLDATA_DELIMITER_START}\\s*([\\s\\S]*?)\\s*${TLDATA_DELIMITER_END}`, + ), + ); + + if (!verifyMatch || verifyMatch[1]?.trim() !== stringifiedData.trim()) { + throw new Error("Failed to verify saved TLDraw data"); + } + + lastSavedDataRef.current = stringifiedData; + } catch (error) { + console.error("Error saving/verifying TLDraw data:", error); + // Reload the editor state from file since save failed + const fileContent = await plugin.app.vault.read(file); + const match = fileContent.match( + new RegExp( + `${TLDATA_DELIMITER_START}([\\s\\S]*?)${TLDATA_DELIMITER_END}`, + ), + ); + if (match?.[1]) { + const data = JSON.parse(match[1]) as TLData; + const { store: newStore } = processInitialData(data); + setCurrentStore(newStore); + } + } + }, [file, plugin, currentStore]); + + useEffect(() => { + const unsubscribe = currentStore.listen( + () => { + if (saveTimeoutRef.current) { + clearTimeout(saveTimeoutRef.current); + } + saveTimeoutRef.current = setTimeout( + () => void saveChanges(), + DEFAULT_SAVE_DELAY, + ); + }, + { source: "user", scope: "document" }, + ); + + return () => { + unsubscribe(); + if (saveTimeoutRef.current) { + clearTimeout(saveTimeoutRef.current); + } + }; + }, [currentStore, saveChanges]); + + const handleMount = useCallback((editor: Editor) => { + editorRef.current = editor; + editor.setCurrentTool("select"); }, []); return ( @@ -43,7 +135,7 @@ export const TldrawPreviewComponent = ({
Error in Tldraw component: {JSON.stringify(error)}
)} > - + ) : (
Loading Tldraw...
diff --git a/apps/obsidian/src/utils/tldraw.ts b/apps/obsidian/src/utils/tldraw.ts index eba105314..6b868d811 100644 --- a/apps/obsidian/src/utils/tldraw.ts +++ b/apps/obsidian/src/utils/tldraw.ts @@ -1,11 +1,11 @@ -import { createTLStore, TldrawFile, TLStore } from "tldraw"; +import { createTLStore, defaultShapeUtils, TldrawFile, TLRecord, TLStore } from "tldraw"; import { FRONTMATTER_KEY, TLDATA_DELIMITER_END, TLDATA_DELIMITER_START, TLDRAW_VERSION, } from "../constants"; -import DiscourseGraphPlugin from ".."; +import DiscourseGraphPlugin from "~/index"; import { checkAndCreateFolder, getNewUniqueFilepath } from "./file"; import { Notice } from "obsidian"; import { format } from "date-fns"; @@ -16,12 +16,47 @@ export type TldrawPluginMetaData = { uuid: string; }; +export type TldrawRawData = { + tldrawFileFormatVersion: number; + schema: any; + records: any; +}; + export type TLData = { meta: TldrawPluginMetaData; - raw: { - tldrawFileFormatVersion: number; - schema: any; - records: any; + raw: TldrawRawData; +}; + +export const processInitialData = ( + data: TLData, +): { meta: TldrawPluginMetaData; store: TLStore } => { + const recordsData = Array.isArray(data.raw.records) + ? data.raw.records.reduce( + (acc: Record, record: { id: string } & TLRecord) => { + acc[record.id] = { + ...record, + }; + return acc; + }, + {}, + ) + : data.raw.records; + + let store: TLStore; + if (recordsData) { + store = createTLStore({ + shapeUtils: defaultShapeUtils, + initialData: recordsData, + }); + } else { + store = createTLStore({ + shapeUtils: defaultShapeUtils, + }); + } + + return { + meta: data.meta, + store, }; }; @@ -121,4 +156,23 @@ export const createCanvas = async (plugin: DiscourseGraphPlugin) => { new Notice(e instanceof Error ? e.message : "Failed to create canvas file"); console.error(e); } -}; \ No newline at end of file +}; + +/** + * Get the updated markdown content with the new TLData + * @param currentContent - The current markdown content + * @param stringifiedData - The new TLData stringified + * @returns The updated markdown content + */ +export const getUpdatedMdContent = ( + currentContent: string, + stringifiedData: string, +) => { + const regex = new RegExp( + `${TLDATA_DELIMITER_START}([\\s\\S]*?)${TLDATA_DELIMITER_END}`, + ); + return currentContent.replace( + regex, + `${TLDATA_DELIMITER_START}\n${stringifiedData}\n${TLDATA_DELIMITER_END}`, + ); +}; From c7f90a86ca3c744d297caafc29cb4f903fb9c85d Mon Sep 17 00:00:00 2001 From: Trang Doan <44855874+trangdoan982@users.noreply.github.com> Date: Fri, 15 Aug 2025 14:13:42 -0400 Subject: [PATCH 03/26] [ENG-624] TLDraw Obsidian asset store (#326) * current state * works now * clean up * address PR comments * address PR reviews * cleanup * fix styling issues * address PR comments --- apps/obsidian/src/components/TldrawView.tsx | 61 ++++- .../src/components/TldrawViewComponent.tsx | 21 +- apps/obsidian/src/utils/assetStore.ts | 224 ++++++++++++++++++ apps/obsidian/src/utils/tldraw.ts | 6 +- apps/obsidian/styles.css | 6 + 5 files changed, 291 insertions(+), 27 deletions(-) create mode 100644 apps/obsidian/src/utils/assetStore.ts 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 7dcdc33d2..06d0b4f05 100644 --- a/apps/obsidian/styles.css +++ b/apps/obsidian/styles.css @@ -34,3 +34,9 @@ .dg-create-node-button:hover { background: var(--interactive-accent-hover); } + +/* Neutralize host button styling inside our editor */ +.tldraw__editor button { + background: transparent !important; + color: inherit; +} From 22da2898a3c18c437dcf4f8176d8a7e78ae9faf7 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Wed, 20 Aug 2025 16:06:34 -0400 Subject: [PATCH 04/26] correct styles --- apps/obsidian/src/styles/style.css | 3793 ++++++++++++++++++++++++++++ apps/obsidian/styles.css | 2 + 2 files changed, 3795 insertions(+) diff --git a/apps/obsidian/src/styles/style.css b/apps/obsidian/src/styles/style.css index e69de29bb..93c597c51 100644 --- a/apps/obsidian/src/styles/style.css +++ b/apps/obsidian/src/styles/style.css @@ -0,0 +1,3793 @@ + + +/* We copy the styling from tldraw/tldraw.css here due to compilation failure */ +/* This file is created by the copy-css-files.mjs script in packages/tldraw. */ +/* It combines @tldraw/editor's editor.css and tldraw's ui.css */ + +/* @tldraw/editor */ + +.tl-container { + width: 100%; + height: 100%; + font-size: 12px; + /* Spacing */ + --space-1: 2px; + --space-2: 4px; + --space-3: 8px; + --space-4: 12px; + --space-5: 16px; + --space-6: 20px; + --space-7: 28px; + --space-8: 32px; + --space-9: 64px; + --space-10: 72px; + /* Radius */ + --radius-0: 2px; + --radius-1: 4px; + --radius-2: 6px; + --radius-3: 9px; + --radius-4: 11px; + + /* Canvas z-index */ + --layer-canvas-hidden: -999999; + --layer-canvas-background: 100; + --layer-canvas-grid: 150; + --layer-watermark: 200; + --layer-canvas-shapes: 300; + --layer-canvas-overlays: 500; + --layer-canvas-blocker: 10000; + + /* Canvas overlays z-index */ + --layer-overlays-collaborator-scribble: 10; + --layer-overlays-collaborator-brush: 20; + --layer-overlays-collaborator-shape-indicator: 30; + --layer-overlays-user-scribble: 40; + --layer-overlays-user-brush: 50; + --layer-overlays-user-snapline: 90; + --layer-overlays-selection-fg: 100; + /* User handles need to be above selection edges / corners, matters for sticky note clone handles */ + --layer-overlays-user-handles: 105; + --layer-overlays-user-indicator-hint: 110; + --layer-overlays-custom: 115; + --layer-overlays-collaborator-cursor-hint: 120; + --layer-overlays-collaborator-cursor: 130; + + /* Text editor z-index */ + --layer-text-container: 1; + --layer-text-content: 3; + --layer-text-editor: 4; + + /* Error fallback z-index */ + --layer-error-overlay: 1; + --layer-error-canvas: 2; + --layer-error-canvas-after: 3; + --layer-error-content: 4; + + /* Misc */ + --tl-zoom: 1; + + /* Cursor SVGs */ + --tl-cursor-none: none; + --tl-cursor-default: + url("data:image/svg+xml,") + 12 8, + default; + --tl-cursor-pointer: + url("data:image/svg+xml,") + 14 10, + pointer; + --tl-cursor-cross: + url("data:image/svg+xml,") + 16 16, + crosshair; + --tl-cursor-move: + url("data:image/svg+xml,") + 16 16, + move; + --tl-cursor-grab: + url("data:image/svg+xml,") + 16 16, + grab; + --tl-cursor-grabbing: + url("data:image/svg+xml,") + 16 16, + grabbing; + --tl-cursor-text: + url("data:image/svg+xml,") + 4 10, + text; + --tl-cursor-zoom-in: + url("data:image/svg+xml,") + 16 16, + zoom-in; + --tl-cursor-zoom-out: + url("data:image/svg+xml,") + 16 16, + zoom-out; + + /* These cursor values get programmatically overridden */ + /* They're just here to help your editor autocomplete */ + --tl-cursor: var(--tl-cursor-default); + --tl-cursor-resize-edge: ew-resize; + --tl-cursor-resize-corner: nesw-resize; + --tl-cursor-ew-resize: ew-resize; + --tl-cursor-ns-resize: ns-resize; + --tl-cursor-nesw-resize: nesw-resize; + --tl-cursor-nwse-resize: nwse-resize; + --tl-cursor-rotate: pointer; + --tl-cursor-nwse-rotate: pointer; + --tl-cursor-nesw-rotate: pointer; + --tl-cursor-senw-rotate: pointer; + --tl-cursor-swne-rotate: pointer; + --tl-scale: calc(1 / var(--tl-zoom)); + /* fonts */ + --tl-font-draw: 'tldraw_draw', sans-serif; + --tl-font-sans: 'tldraw_sans', sans-serif; + --tl-font-serif: 'tldraw_serif', serif; + --tl-font-mono: 'tldraw_mono', monospace; + /* text outline */ + --a: calc(min(0.5, 1 / var(--tl-zoom)) * 2px); + --b: calc(min(0.5, 1 / var(--tl-zoom)) * -2px); + --tl-text-outline-reference: + 0 var(--b) 0 var(--color-background), 0 var(--a) 0 var(--color-background), + var(--b) var(--b) 0 var(--color-background), var(--a) var(--b) 0 var(--color-background), + var(--a) var(--a) 0 var(--color-background), var(--b) var(--a) 0 var(--color-background); + --tl-text-outline: var(--tl-text-outline-reference); + /* own properties */ + position: relative; + inset: 0px; + height: 100%; + width: 100%; + overflow: clip; + color: var(--color-text); +} + +.tl-theme__light { + /* Canvas */ + --color-snap: hsl(0, 76%, 60%); + --color-selection-fill: hsl(210, 100%, 56%, 24%); + --color-selection-stroke: hsl(214, 84%, 56%); + --color-background: hsl(210, 20%, 98%); + --color-brush-fill: hsl(0, 0%, 56%, 10.2%); + --color-brush-stroke: hsl(0, 0%, 56%, 25.1%); + --color-grid: hsl(0, 0%, 43%); + /* UI */ + --color-low: hsl(204, 16%, 94%); + --color-low-border: hsl(204, 16%, 92%); + --color-culled: hsl(204, 14%, 93%); + --color-muted-none: hsl(0, 0%, 0%, 0%); + --color-muted-0: hsl(0, 0%, 0%, 2%); + --color-muted-1: hsl(0, 0%, 0%, 10%); + --color-muted-2: hsl(0, 0%, 0%, 4.3%); + --color-hint: hsl(0, 0%, 0%, 5.5%); + --color-overlay: hsl(0, 0%, 0%, 20%); + --color-divider: hsl(0, 0%, 91%); + --color-panel: hsl(0, 0%, 99%); + --color-panel-contrast: hsl(0, 0%, 100%); + --color-panel-overlay: hsl(0, 0%, 100%, 82%); + --color-panel-transparent: hsla(0, 0%, 99%, 0%); + --color-selected: hsl(214, 84%, 56%); + --color-selected-contrast: hsl(0, 0%, 100%); + --color-focus: hsl(219, 65%, 50%); + /* Text */ + --color-text: hsl(0, 0%, 0%); + --color-text-0: hsl(0, 0%, 11%); + --color-text-1: hsl(0, 0%, 18%); + --color-text-3: hsl(220, 2%, 65%); + --color-text-shadow: hsl(0, 0%, 100%); + --color-text-highlight: hsl(52, 100%, 50%); + --color-text-highlight-p3: color(display-p3 0.972 0.8205 0.05); + /* Named */ + --color-primary: hsl(214, 84%, 56%); + --color-success: hsl(123, 46%, 34%); + --color-info: hsl(201, 98%, 41%); + --color-warning: hsl(27, 98%, 47%); + --color-danger: hsl(0, 90%, 43%); + --color-laser: hsl(0, 100%, 50%); + /* Shadows */ + --shadow-1: 0px 1px 2px hsl(0, 0%, 0%, 25%), 0px 1px 3px hsl(0, 0%, 0%, 9%); + --shadow-2: + 0px 0px 2px hsl(0, 0%, 0%, 16%), 0px 2px 3px hsl(0, 0%, 0%, 24%), + 0px 2px 6px hsl(0, 0%, 0%, 0.1), inset 0px 0px 0px 1px var(--color-panel-contrast); + --shadow-3: + 0px 1px 2px hsl(0, 0%, 0%, 28%), 0px 2px 6px hsl(0, 0%, 0%, 14%), + inset 0px 0px 0px 1px var(--color-panel-contrast); + --shadow-4: + 0px 0px 3px hsl(0, 0%, 0%, 19%), 0px 5px 4px hsl(0, 0%, 0%, 16%), + 0px 2px 16px hsl(0, 0%, 0%, 6%), inset 0px 0px 0px 1px var(--color-panel-contrast); +} + +.tl-theme__dark { + /* Canvas */ + --color-snap: hsl(0, 76%, 60%); + --color-selection-fill: hsl(209, 100%, 57%, 20%); + --color-selection-stroke: hsl(214, 84%, 56%); + --color-background: hsl(240, 5%, 6.5%); + --color-brush-fill: hsl(0, 0%, 71%, 5.1%); + --color-brush-stroke: hsl(0, 0%, 71%, 25.1%); + --color-grid: hsl(0, 0%, 40%); + /* UI */ + --color-low: hsl(260, 4.5%, 10.5%); + --color-low-border: hsl(207, 10%, 10%); + --color-culled: hsl(210, 11%, 19%); + --color-muted-none: hsl(0, 0%, 100%, 0%); + --color-muted-0: hsl(0, 0%, 100%, 2%); + --color-muted-1: hsl(0, 0%, 100%, 10%); + --color-muted-2: hsl(0, 0%, 100%, 5%); + --color-hint: hsl(0, 0%, 100%, 7%); + --color-overlay: hsl(0, 0%, 0%, 50%); + --color-divider: hsl(240, 9%, 22%); + --color-panel: hsl(235, 6.8%, 13.5%); + --color-panel-contrast: hsl(245, 12%, 23%); + --color-panel-overlay: hsl(210, 10%, 24%, 82%); + --color-panel-transparent: hsla(235, 6.8%, 13.5%, 0%); + --color-selected: hsl(217, 89%, 61%); + --color-selected-contrast: hsl(0, 0%, 100%); + --color-focus: hsl(217, 76%, 80%); + /* Text */ + --color-text: hsl(210, 17%, 98%); + --color-text-0: hsl(0, 9%, 94%); + --color-text-1: hsl(0, 0%, 85%); + --color-text-3: hsl(210, 6%, 45%); + --color-text-shadow: hsl(210, 13%, 18%); + --color-text-highlight: hsl(52, 100%, 41%); + --color-text-highlight-p3: color(display-p3 0.8078 0.6225 0.0312); + /* Named */ + --color-primary: hsl(214, 84%, 56%); + --color-success: hsl(123, 38%, 57%); + --color-info: hsl(199, 92%, 56%); + --color-warning: hsl(36, 100%, 57%); + --color-danger: hsl(0, 82%, 66%); + --color-laser: hsl(0, 100%, 50%); + /* Shadows */ + --shadow-1: + 0px 1px 2px hsl(0, 0%, 0%, 16.1%), 0px 1px 3px hsl(0, 0%, 0%, 22%), + inset 0px 0px 0px 1px var(--color-panel-contrast); + --shadow-2: + 0px 1px 3px hsl(0, 0%, 0%, 66.6%), 0px 2px 6px hsl(0, 0%, 0%, 33%), + inset 0px 0px 0px 1px var(--color-panel-contrast); + --shadow-3: + 0px 1px 3px hsl(0, 0%, 0%, 50%), 0px 2px 12px hsl(0, 0%, 0%, 50%), + inset 0px 0px 0px 1px var(--color-panel-contrast); +} + +.tl-counter-scaled { + transform: scale(var(--tl-scale)); + transform-origin: top left; + width: calc(100% * var(--tl-zoom)); + height: calc(100% * var(--tl-zoom)); +} + +.tl-container, +.tl-container * { + -webkit-touch-callout: none; + -webkit-tap-highlight-color: transparent; + scrollbar-highlight-color: transparent; + -webkit-user-select: none; + user-select: none; + box-sizing: border-box; + outline: none; +} + +.tl-container a { + -webkit-touch-callout: initial; +} + +.tl-container__focused { + outline: 1px solid var(--color-low); +} + +input, +*[contenteditable], +*[contenteditable] * { + user-select: text; +} + +/* --------------------- Canvas --------------------- */ + +.tl-canvas { + position: absolute; + inset: 0px; + height: 100%; + width: 100%; + color: var(--color-text); + cursor: var(--tl-cursor); + overflow: clip; + content-visibility: auto; + touch-action: none; + contain: strict; +} + +.tl-shapes { + position: relative; + z-index: var(--layer-canvas-shapes); +} + +.tl-overlays { + position: absolute; + top: 0px; + left: 0px; + height: 100%; + width: 100%; + contain: strict; + pointer-events: none; + z-index: var(--layer-canvas-overlays); +} + +.tl-overlays__item { + position: absolute; + top: 0px; + left: 0px; + overflow: visible; + pointer-events: none; + transform-origin: top left; +} + +.tl-svg-context { + position: absolute; + top: 0px; + left: 0px; + width: 100%; + height: 100%; + pointer-events: none; +} + +/* ------------------- Background ------------------- */ + +.tl-background__wrapper { + z-index: var(--layer-canvas-background); + position: absolute; + inset: 0px; + height: 100%; + width: 100%; +} + +.tl-background { + background-color: var(--color-background); + width: 100%; + height: 100%; +} + +/* --------------------- Grid Layer --------------------- */ + +.tl-grid { + position: absolute; + inset: 0px; + width: 100%; + height: 100%; + touch-action: none; + pointer-events: none; + z-index: var(--layer-canvas-grid); + contain: strict; +} + +.tl-grid-dot { + fill: var(--color-grid); +} + +/* --------------------- Layers --------------------- */ + +.tl-html-layer { + position: absolute; + top: 0px; + left: 0px; + width: 1px; + height: 1px; + contain: layout style size; +} + +/* --------------- Overlay Stack --------------- */ + +/* back of the stack, behind user's stuff */ +.tl-collaborator__scribble { + z-index: var(--layer-overlays-collaborator-scribble); +} + +.tl-collaborator__brush { + z-index: var(--layer-overlays-collaborator-brush); +} + +.tl-collaborator__shape-indicator { + z-index: var(--layer-overlays-collaborator-shape-indicator); +} + +.tl-user-scribble { + z-index: var(--layer-overlays-user-scribble); +} + +.tl-user-brush { + z-index: var(--layer-overlays-user-brush); +} + +.tl-user-handles { + z-index: var(--layer-overlays-user-handles); +} + +.tl-user-snapline { + z-index: var(--layer-overlays-user-snapline); +} + +.tl-selection__fg { + pointer-events: none; + z-index: var(--layer-overlays-selection-fg); +} + +.tl-user-indicator__hint { + z-index: var(--layer-overlays-user-indicator-hint); + stroke-width: calc(2.5px * var(--tl-scale)); +} + +.tl-custom-overlays { + z-index: var(--layer-overlays-custom); +} + +/* behind collaborator cursor */ +.tl-collaborator__cursor-hint { + z-index: var(--layer-overlays-collaborator-cursor-hint); +} + +.tl-collaborator__cursor { + z-index: var(--layer-overlays-collaborator-cursor); +} + +.tl-cursor { + overflow: visible; +} + +/* -------------- Selection foreground -------------- */ + +.tl-selection__bg { + position: absolute; + top: 0px; + left: 0px; + transform-origin: top left; + background-color: transparent; + pointer-events: all; +} + +.tl-selection__fg__outline { + fill: none; + pointer-events: none; + stroke: var(--color-selection-stroke); + stroke-width: calc(1.5px * var(--tl-scale)); +} + +.tl-corner-handle { + pointer-events: none; + stroke: var(--color-selection-stroke); + fill: var(--color-background); + stroke-width: calc(1.5px * var(--tl-scale)); +} + +.tl-text-handle { + pointer-events: none; + fill: var(--color-selection-stroke); +} + +.tl-corner-crop-handle { + pointer-events: none; + fill: none; + stroke: var(--color-selection-stroke); +} + +.tl-corner-crop-edge-handle { + pointer-events: none; + fill: none; + stroke: var(--color-selection-stroke); +} + +.tl-mobile-rotate__bg { + pointer-events: all; + cursor: var(--tl-cursor-grab); +} + +.tl-mobile-rotate__fg { + pointer-events: none; + stroke: var(--color-selection-stroke); + fill: var(--color-background); + stroke-width: calc(1.5px * var(--tl-scale)); +} + +.tl-transparent { + fill: transparent; + stroke: transparent; +} + +.tl-hidden { + opacity: 0; + pointer-events: none; +} + +/* -------------- Nametag / cursor chat ------------- */ + +.tl-nametag { + position: absolute; + top: 16px; + left: 13px; + width: fit-content; + height: fit-content; + max-width: 120px; + padding: 3px 6px; + white-space: nowrap; + position: absolute; + overflow: hidden; + text-overflow: ellipsis; + font-size: 12px; + font-family: var(--font-body); + border-radius: var(--radius-2); + color: var(--color-selected-contrast); +} + +.tl-nametag-title { + position: absolute; + top: -2px; + left: 13px; + width: fit-content; + height: fit-content; + padding: 0px 6px; + max-width: 120px; + white-space: nowrap; + position: absolute; + overflow: hidden; + text-overflow: ellipsis; + font-size: 12px; + font-family: var(--font-body); + text-shadow: var(--tl-text-outline); + color: var(--color-selected-contrast); +} + +.tl-nametag-chat { + position: absolute; + top: 16px; + left: 13px; + width: fit-content; + height: fit-content; + color: var(--color-selected-contrast); + white-space: nowrap; + position: absolute; + padding: 3px 6px; + font-size: 12px; + font-family: var(--font-body); + opacity: 1; + border-radius: var(--radius-2); +} + +.tl-cursor-chat { + position: absolute; + color: var(--color-selected-contrast); + white-space: nowrap; + padding: 3px 6px; + font-size: 12px; + font-family: var(--font-body); + pointer-events: none; + z-index: var(--layer-cursor); + margin-top: 16px; + margin-left: 13px; + opacity: 1; + border: none; + user-select: text; + border-radius: var(--radius-2); +} + +.tl-cursor-chat .tl-cursor-chat__bubble { + padding-right: 12px; +} + +.tl-cursor-chat::selection { + background: var(--color-selected); + color: var(--color-selected-contrast); + text-shadow: none; +} + +.tl-cursor-chat::placeholder { + color: var(--color-selected-contrast); + opacity: 0.7; +} + +/* ---------------------- Text ---------------------- */ + +.tl-text-shape-label { + position: relative; + font-weight: normal; + min-width: 1px; + padding: 0px; + margin: 0px; + border: none; + width: fit-content; + height: fit-content; + font-variant: normal; + font-style: normal; + pointer-events: all; + white-space: pre-wrap; + overflow-wrap: break-word; + text-shadow: var(--tl-text-outline); +} + +.tl-text-wrapper[data-font='draw'] { + font-family: var(--tl-font-draw); +} + +.tl-text-wrapper[data-font='sans'] { + font-family: var(--tl-font-sans); +} + +.tl-text-wrapper[data-font='serif'] { + font-family: var(--tl-font-serif); +} + +.tl-text-wrapper[data-font='mono'] { + font-family: var(--tl-font-mono); +} + +.tl-text-wrapper[data-align='start'], +.tl-text-wrapper[data-align='start-legacy'] { + text-align: left; +} + +.tl-text-wrapper[data-align='middle'], +.tl-text-wrapper[data-align='middle-legacy'] { + text-align: center; +} + +.tl-text-wrapper[data-align='end'], +.tl-text-wrapper[data-align='end-legacy'] { + text-align: right; +} + +.tl-plain-text-wrapper[data-isediting='true'] .tl-text-content { + opacity: 0; +} + +.tl-rich-text-wrapper[data-isediting='true'] .tl-text-content { + display: none; +} + +.tl-text { + /* remove overflow from textarea on windows */ + margin: 0px; + padding: 0px; + + appearance: auto; + background: none; + border-image: none; + border: 0px; + caret-color: var(--color-text); + color: inherit; + column-count: initial !important; + display: inline-block; + font-family: inherit; + font-feature-settings: normal; + font-kerning: auto; + font-optical-sizing: auto; + font-size: inherit; + font-stretch: 100%; + font-style: inherit; + font-variant: inherit; + font-variation-settings: normal; + font-weight: inherit; + letter-spacing: inherit; + line-height: inherit; + outline: none; + overflow-wrap: break-word; + text-align: inherit; + text-indent: 0px; + text-rendering: auto; + text-shadow: inherit; + text-transform: none; + white-space: pre-wrap; + line-break: normal; + word-spacing: 0px; + word-wrap: break-word; + writing-mode: horizontal-tb !important; +} + +.tl-text-measure { + position: absolute; + z-index: var(--layer-canvas-hidden); + top: 0px; + left: 0px; + opacity: 0; + width: max-content; + box-sizing: border-box; + pointer-events: none; + white-space: pre-wrap; + word-wrap: break-word; + overflow-wrap: break-word; + resize: none; + border: none; + user-select: none; + contain: style paint; + visibility: hidden; + /* N.B. This property, while discouraged ("intended for Document Type Definition (DTD) designers") is necessary for ensuring correct mixed RTL/LTR behavior when exporting SVGs. */ + unicode-bidi: plaintext; + -webkit-user-select: none; +} + +.tl-text-input, +.tl-text-content { + position: absolute; + inset: 0px; + height: 100%; + width: 100%; + min-width: 1px; + min-height: 1px; + outline: none; +} + +.tl-text-content__wrapper { + position: relative; + width: fit-content; + height: fit-content; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; + min-height: auto; +} + +.tl-text-content { + overflow: visible; + pointer-events: none; +} + +.tl-text-input { + resize: none; + user-select: all; + -webkit-user-select: text; + cursor: var(--tl-cursor-text); +} + +.tl-text-input:not(.tl-rich-text) { + /* + * Note: this `overflow: hidden` is key for scrollbars to not show up + * plaintext/