From 6e25a2bbdeeec9fd6bf00d2028dcf59ee7d852e3 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Thu, 28 Aug 2025 15:11:44 -0400 Subject: [PATCH 1/2] eng-604: create node flow --- .../components/canvas/DiscourseNodePanel.tsx | 65 ++++++ .../components/canvas/DiscourseNodeTool.ts | 13 ++ .../components/canvas/TldrawViewComponent.tsx | 142 ++++++++++++- .../canvas/shapes/DiscourseNodeShape.tsx | 10 - .../canvas/shapes/discourseNodeShapeUtils.ts | 1 - .../components/canvas/stores/assetStore.ts | 42 ++++ .../canvas/utils/nodeCreationFlow.ts | 65 ++++++ .../src/components/canvas/utils/tldraw.ts | 198 ++++++++++++++++++ apps/obsidian/src/constants.ts | 4 +- 9 files changed, 524 insertions(+), 16 deletions(-) create mode 100644 apps/obsidian/src/components/canvas/DiscourseNodePanel.tsx create mode 100644 apps/obsidian/src/components/canvas/DiscourseNodeTool.ts create mode 100644 apps/obsidian/src/components/canvas/utils/nodeCreationFlow.ts create mode 100644 apps/obsidian/src/components/canvas/utils/tldraw.ts diff --git a/apps/obsidian/src/components/canvas/DiscourseNodePanel.tsx b/apps/obsidian/src/components/canvas/DiscourseNodePanel.tsx new file mode 100644 index 000000000..e64fd0191 --- /dev/null +++ b/apps/obsidian/src/components/canvas/DiscourseNodePanel.tsx @@ -0,0 +1,65 @@ +import { useEditor } from "tldraw"; +import * as React from "react"; +import { TFile } from "obsidian"; +import { DiscourseNode } from "~/types"; +import DiscourseGraphPlugin from "~/index"; +import { openCreateDiscourseNodeAt } from "./utils/nodeCreationFlow"; + +export const DiscourseNodePanel = ({ + plugin, + canvasFile, +}: { + plugin: DiscourseGraphPlugin; + canvasFile: TFile; +}) => { + const editor = useEditor(); + + const handleNodeTypeSelect = React.useCallback( + (selectedNodeType: DiscourseNode, position?: { x: number; y: number }) => { + const finalPosition = position || editor.getViewportScreenCenter(); + openCreateDiscourseNodeAt({ + plugin, + canvasFile, + tldrawEditor: editor, + position: finalPosition, + initialNodeType: selectedNodeType, + }); + }, + [editor, plugin, canvasFile], + ); + + const nodeTypes = plugin.settings.nodeTypes; + + return ( +
+

Discourse Node Types

+
+ {nodeTypes.map((nodeType) => ( + + ))} +
+
+ ); +}; diff --git a/apps/obsidian/src/components/canvas/DiscourseNodeTool.ts b/apps/obsidian/src/components/canvas/DiscourseNodeTool.ts new file mode 100644 index 000000000..35459056b --- /dev/null +++ b/apps/obsidian/src/components/canvas/DiscourseNodeTool.ts @@ -0,0 +1,13 @@ +import { StateNode } from "@tldraw/editor"; + +export class DiscourseNodeTool extends StateNode { + static override id = "discourse-node"; + + override onEnter = () => { + this.editor.setCursor({ type: "cross" }); + }; + + override onPointerDown = () => { + this.editor.setCurrentTool("select"); + }; +} diff --git a/apps/obsidian/src/components/canvas/TldrawViewComponent.tsx b/apps/obsidian/src/components/canvas/TldrawViewComponent.tsx index 3d34dec47..396a28260 100644 --- a/apps/obsidian/src/components/canvas/TldrawViewComponent.tsx +++ b/apps/obsidian/src/components/canvas/TldrawViewComponent.tsx @@ -1,5 +1,17 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { defaultShapeUtils, ErrorBoundary, Tldraw, TLStore } from "tldraw"; +import { + defaultShapeUtils, + DefaultStylePanel, + DefaultToolbar, + DefaultToolbarContent, + ErrorBoundary, + Tldraw, + TldrawUiMenuItem, + TLStore, + Editor, + useIsToolSelected, + useTools, +} from "tldraw"; import "tldraw/tldraw.css"; import { getTLDataTemplate, @@ -7,17 +19,20 @@ import { getUpdatedMdContent, TLData, processInitialData, -} from "~/components/canvas/tldraw"; +} from "~/components/canvas/utils/tldraw"; import DiscourseGraphPlugin from "~/index"; import { DEFAULT_SAVE_DELAY, TLDATA_DELIMITER_END, TLDATA_DELIMITER_START, + WHITE_LOGO_SVG, } from "~/constants"; import { TFile } from "obsidian"; import { ObsidianTLAssetStore } from "~/components/canvas/stores/assetStore"; import { DiscourseNodeUtil } from "~/components/canvas/shapes/DiscourseNodeShape"; - +import { DiscourseNodeTool } from "./DiscourseNodeTool"; +import { openCreateDiscourseNodeAt } from "./utils/nodeCreationFlow"; +import { DiscourseNodePanel } from "./DiscourseNodePanel"; interface TldrawPreviewProps { store: TLStore; @@ -37,6 +52,7 @@ export const TldrawPreviewComponent = ({ const [isReady, setIsReady] = useState(false); const saveTimeoutRef = useRef(); const lastSavedDataRef = useRef(""); + const editorRef = useRef(); const customShapeUtils = [ ...defaultShapeUtils, @@ -47,6 +63,33 @@ export const TldrawPreviewComponent = ({ }), ]; + const customTools = [DiscourseNodeTool]; + + const svgToDataUrl = (svg: string) => + `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`; + + const [iconUrl, setIconUrl] = useState(() => { + const isDark = document.body.classList.contains("theme-dark"); + const svg = isDark + ? WHITE_LOGO_SVG + : WHITE_LOGO_SVG.replace('fill="white"', 'fill="black"'); + return svgToDataUrl(svg); + }); + + useEffect(() => { + const updateIcon = () => { + const isDark = document.body.classList.contains("theme-dark"); + const svg = isDark + ? WHITE_LOGO_SVG + : WHITE_LOGO_SVG.replace('fill="white"', 'fill="black"'); + setIconUrl(svgToDataUrl(svg)); + }; + const ref = plugin.app.workspace.on("css-change", updateIcon); + return () => { + if (ref) plugin.app.workspace.offref(ref); + }; + }, [plugin]); + useEffect(() => { const timer = setTimeout(() => { setIsReady(true); @@ -135,8 +178,42 @@ export const TldrawPreviewComponent = ({ }; }, [currentStore, saveChanges]); + const handleMount = (editor: Editor) => { + editorRef.current = editor; + }; + return ( -
+
{ + const editor = editorRef.current; + if (!editor) return; + + const nodeTypeId = e.dataTransfer?.getData( + "application/x-dg-node-type", + ); + if (!nodeTypeId) return; + + e.preventDefault(); + e.stopPropagation(); + + const pagePoint = editor.screenToPage({ x: e.clientX, y: e.clientY }); + + const nodeType = plugin.settings.nodeTypes.find( + (nt) => nt.id === nodeTypeId, + ); + if (!nodeType) return; + + openCreateDiscourseNodeAt({ + plugin, + canvasFile: file, + tldrawEditor: editor, + position: pagePoint, + initialNodeType: nodeType, + }); + }} + > {isReady ? ( ( @@ -146,8 +223,65 @@ export const TldrawPreviewComponent = ({ { + tools["discourse-node"] = { + id: "discourse-node", + label: "Discourse Node", + readonlyOk: false, + icon: "discourseNodeIcon", + onSelect: () => { + editor.setCurrentTool("discourse-node"); + }, + }; + return tools; + }, + }} + components={{ + StylePanel: () => { + const tools = useTools(); + const isDiscourseNodeSelected = useIsToolSelected( + tools["discourse-node"], + ); + + if (!isDiscourseNodeSelected) { + return ; + } + + return ; + }, + Toolbar: (props) => { + const tools = useTools(); + const isDiscourseNodeSelected = useIsToolSelected( + tools["discourse-node"], + ); + return ( + + { + if (editorRef.current) { + editorRef.current.setCurrentTool("discourse-node"); + } + }} + isSelected={isDiscourseNodeSelected} + /> + + + ); + }, + }} /> ) : ( diff --git a/apps/obsidian/src/components/canvas/shapes/DiscourseNodeShape.tsx b/apps/obsidian/src/components/canvas/shapes/DiscourseNodeShape.tsx index f95839d72..21775d770 100644 --- a/apps/obsidian/src/components/canvas/shapes/DiscourseNodeShape.tsx +++ b/apps/obsidian/src/components/canvas/shapes/DiscourseNodeShape.tsx @@ -10,11 +10,9 @@ import { memo, createElement, useEffect } from "react"; import DiscourseGraphPlugin from "~/index"; import { getFrontmatterForFile, - getNodeTypeIdFromFrontmatter, getNodeTypeById, FrontmatterRecord, } from "./discourseNodeShapeUtils"; -import { DiscourseNode } from "~/types"; import { resolveLinkedFileFromSrc } from "~/components/canvas/stores/assetStore"; export type DiscourseNodeShape = TLBaseShape< @@ -148,14 +146,6 @@ const discourseNodeContent = memo( }); if (!linkedFile) { - editor.updateShape({ - id: shape.id, - type: "discourse-node", - props: { - ...shape.props, - title: "(unlinked)", - }, - }); return; } diff --git a/apps/obsidian/src/components/canvas/shapes/discourseNodeShapeUtils.ts b/apps/obsidian/src/components/canvas/shapes/discourseNodeShapeUtils.ts index b5bcf9b40..57a83b499 100644 --- a/apps/obsidian/src/components/canvas/shapes/discourseNodeShapeUtils.ts +++ b/apps/obsidian/src/components/canvas/shapes/discourseNodeShapeUtils.ts @@ -1,7 +1,6 @@ import type { App, TFile } from "obsidian"; import type DiscourseGraphPlugin from "~/index"; import type { DiscourseNode } from "~/types"; -import { resolveLinkedFileFromSrc } from "~/components/canvas/stores/assetStore"; export type FrontmatterRecord = Record; diff --git a/apps/obsidian/src/components/canvas/stores/assetStore.ts b/apps/obsidian/src/components/canvas/stores/assetStore.ts index 732150f82..6d5c2fd89 100644 --- a/apps/obsidian/src/components/canvas/stores/assetStore.ts +++ b/apps/obsidian/src/components/canvas/stores/assetStore.ts @@ -11,6 +11,48 @@ type AssetStoreOptions = { file: TFile; }; +/** + * Create a wikilink + block reference at the top of the provided canvas markdown file + * that points to the provided linked file, and return an asset-style src that encodes + * the generated block ref id (e.g., `asset:obsidian.blockref.`). + * + * This mirrors how media assets are added/resolved in the ObsidianTLAssetStore, but + * for arbitrarily linked markdown files. Shapes can store the returned `src` and use + * `resolveLinkedFileFromSrc` to obtain the `TFile` later. + */ +export const addWikilinkBlockrefForFile = async ({ + app, + canvasFile, + linkedFile, +}: { + app: App; + canvasFile: TFile; + linkedFile: TFile; +}): Promise => { + const blockRefId = crypto.randomUUID(); + const linkText = app.metadataCache.fileToLinktext( + linkedFile, + canvasFile.path, + ); + const content = `[[${linkText}]]\n^${blockRefId}`; + + await app.vault.process(canvasFile, (data: string) => { + const fileCache = app.metadataCache.getFileCache(canvasFile); + const { start, end } = + fileCache?.frontmatterPosition ?? + ({ + start: { offset: 0 }, + end: { offset: 0 }, + } as { start: { offset: number }; end: { offset: number } }); + + const frontmatter = data.slice(start.offset, end.offset); + const rest = data.slice(end.offset); + return `${frontmatter}\n${content}\n${rest}`; + }); + + return `asset:${ASSET_PREFIX}${blockRefId}`; +}; + /** * Extract the block reference id from either an asset src string (e.g., * `asset:obsidian.blockref.`) or from the internal asset id with the diff --git a/apps/obsidian/src/components/canvas/utils/nodeCreationFlow.ts b/apps/obsidian/src/components/canvas/utils/nodeCreationFlow.ts new file mode 100644 index 000000000..ee705dc05 --- /dev/null +++ b/apps/obsidian/src/components/canvas/utils/nodeCreationFlow.ts @@ -0,0 +1,65 @@ +import { Notice, TFile } from "obsidian"; +import { Editor, createShapeId } from "tldraw"; +import DiscourseGraphPlugin from "~/index"; +import { DiscourseNode } from "~/types"; +import { CreateNodeModal } from "~/components/CreateNodeModal"; +import { createDiscourseNode } from "~/utils/createNode"; +import { addWikilinkBlockrefForFile } from "~/components/canvas/stores/assetStore"; + +export type CreateNodeAtArgs = { + plugin: DiscourseGraphPlugin; + canvasFile: TFile; + tldrawEditor: Editor; + position: { x: number; y: number }; + initialNodeType?: DiscourseNode; +}; + +export const openCreateDiscourseNodeAt = (args: CreateNodeAtArgs): void => { + const { plugin, canvasFile, tldrawEditor, position, initialNodeType } = args; + + const modal = new CreateNodeModal(plugin.app, { + nodeTypes: plugin.settings.nodeTypes, + plugin, + initialNodeType, + onNodeCreate: async (selectedNodeType: DiscourseNode, title: string) => { + try { + const createdFile = await createDiscourseNode({ + plugin, + nodeType: selectedNodeType, + text: title, + }); + + const src = createdFile + ? await addWikilinkBlockrefForFile({ + app: plugin.app, + canvasFile, + linkedFile: createdFile, + }) + : null; + + const shapeId = createShapeId(); + tldrawEditor.createShape({ + id: shapeId, + type: "discourse-node", + x: position.x, + y: position.y, + props: { + w: 200, + h: 100, + src: src ?? "", + title: title, + nodeTypeId: selectedNodeType.id, + }, + }); + + tldrawEditor.markHistoryStoppingPoint("create discourse node"); + tldrawEditor.setSelectedShapes([shapeId]); + } catch (error) { + console.error("Error creating discourse node:", error); + new Notice("Failed to create discourse node"); + } + }, + }); + + modal.open(); +}; diff --git a/apps/obsidian/src/components/canvas/utils/tldraw.ts b/apps/obsidian/src/components/canvas/utils/tldraw.ts new file mode 100644 index 000000000..122e66f46 --- /dev/null +++ b/apps/obsidian/src/components/canvas/utils/tldraw.ts @@ -0,0 +1,198 @@ +import { + createTLStore, + defaultShapeUtils, + TldrawFile, + TLRecord, + TLStore, +} from "tldraw"; +import { + FRONTMATTER_KEY, + TLDATA_DELIMITER_END, + TLDATA_DELIMITER_START, + TLDRAW_VERSION, +} from "~/constants"; +import DiscourseGraphPlugin from "~/index"; +import { checkAndCreateFolder, getNewUniqueFilepath } from "../../../utils/file"; +import { Notice } from "obsidian"; +import { format } from "date-fns"; +import { ObsidianTLAssetStore } from "../stores/assetStore"; +import { + DiscourseNodeUtil, + DiscourseNodeUtilOptions, +} from "~/components/canvas/shapes/DiscourseNodeShape"; + +export type TldrawPluginMetaData = { + "plugin-version": string; + "tldraw-version": string; + uuid: string; +}; + +export type TldrawRawData = { + tldrawFileFormatVersion: number; + schema: any; + records: any; +}; + +export type TLData = { + meta: TldrawPluginMetaData; + raw: TldrawRawData; +}; + +export const processInitialData = ( + data: TLData, + assetStore: ObsidianTLAssetStore, + ctx: DiscourseNodeUtilOptions, +): { meta: TldrawPluginMetaData; store: TLStore } => { + const customShapeUtils = [ + ...defaultShapeUtils, + DiscourseNodeUtil.configure(ctx), + ]; + + 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: customShapeUtils, + initialData: recordsData, + assets: assetStore, + }); + } else { + store = createTLStore({ + shapeUtils: customShapeUtils, + assets: assetStore, + }); + } + + return { + meta: data.meta, + store, + }; +}; + +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); + } +}; + +/** + * 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}`, + ); +}; diff --git a/apps/obsidian/src/constants.ts b/apps/obsidian/src/constants.ts index 565003e64..aaf2053cc 100644 --- a/apps/obsidian/src/constants.ts +++ b/apps/obsidian/src/constants.ts @@ -72,4 +72,6 @@ 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 +export const DEFAULT_SAVE_DELAY = 500; // in ms +export const WHITE_LOGO_SVG = + ''; \ No newline at end of file From 0d69cd785ce5790607f73570c2044d6fb34ae0d4 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Thu, 28 Aug 2025 16:09:46 -0400 Subject: [PATCH 2/2] pwd --- .../BulkIdentifyDiscourseNodesModal.tsx | 5 +- .../src/components/DiscourseContextView.tsx | 5 +- .../src/components/RelationshipSection.tsx | 8 +- .../src/components/RelationshipSettings.tsx | 21 +- .../components/canvas/DiscourseNodePanel.tsx | 307 +++++++++++++++--- .../components/canvas/DiscourseNodeTool.ts | 47 ++- .../src/components/canvas/TldrawView.tsx | 2 +- .../components/canvas/TldrawViewComponent.tsx | 60 +--- .../canvas/shapes/DiscourseNodeShape.tsx | 2 +- .../canvas/shapes/DiscourseRelationShape.tsx | 0 .../canvas/shapes/discourseNodeShapeUtils.ts | 13 - apps/obsidian/src/components/canvas/tldraw.ts | 198 ----------- .../canvas/utils/nodeCreationFlow.ts | 24 +- .../src/components/canvas/utils/tldraw.ts | 6 +- apps/obsidian/src/constants.ts | 2 +- apps/obsidian/src/utils/createNode.ts | 4 +- apps/obsidian/src/utils/file.ts | 2 +- apps/obsidian/src/utils/registerCommands.ts | 2 +- apps/obsidian/src/utils/utils.ts | 9 + 19 files changed, 358 insertions(+), 359 deletions(-) create mode 100644 apps/obsidian/src/components/canvas/shapes/DiscourseRelationShape.tsx delete mode 100644 apps/obsidian/src/components/canvas/tldraw.ts create mode 100644 apps/obsidian/src/utils/utils.ts diff --git a/apps/obsidian/src/components/BulkIdentifyDiscourseNodesModal.tsx b/apps/obsidian/src/components/BulkIdentifyDiscourseNodesModal.tsx index 73cb4c63e..1959b2670 100644 --- a/apps/obsidian/src/components/BulkIdentifyDiscourseNodesModal.tsx +++ b/apps/obsidian/src/components/BulkIdentifyDiscourseNodesModal.tsx @@ -5,6 +5,7 @@ import type DiscourseGraphPlugin from "../index"; import { BulkImportCandidate, BulkImportPattern } from "~/types"; import { QueryEngine } from "~/services/QueryEngine"; import { TFile } from "obsidian"; +import { getNodeTypeById } from "~/utils/utils"; type BulkImportModalProps = { plugin: DiscourseGraphPlugin; @@ -216,9 +217,7 @@ const BulkImportContent = ({ plugin, onClose }: BulkImportModalProps) => {
{patterns.map((pattern, index) => { - const nodeType = plugin.settings.nodeTypes.find( - (n) => n.id === pattern.nodeTypeId, - ); + const nodeType = getNodeTypeById(plugin, pattern.nodeTypeId); return (
{ return
Not a discourse node (no nodeTypeId)
; } - const nodeType = plugin.settings.nodeTypes.find( - (type) => type.id === frontmatter.nodeTypeId, - ); + const nodeType = getNodeTypeById(plugin, frontmatter.nodeTypeId); if (!nodeType) { return
Unknown node type: {frontmatter.nodeTypeId}
; diff --git a/apps/obsidian/src/components/RelationshipSection.tsx b/apps/obsidian/src/components/RelationshipSection.tsx index bb71bc8a3..9414902b5 100644 --- a/apps/obsidian/src/components/RelationshipSection.tsx +++ b/apps/obsidian/src/components/RelationshipSection.tsx @@ -5,6 +5,7 @@ import SearchBar from "./SearchBar"; import { DiscourseNode } from "~/types"; import DropdownSelect from "./DropdownSelect"; import { usePlugin } from "./PluginContext"; +import { getNodeTypeById } from "~/utils/utils"; type RelationTypeOption = { id: string; @@ -60,12 +61,7 @@ const AddRelationship = ({ activeFile }: RelationshipSectionProps) => { ); const compatibleNodeTypes = compatibleNodeTypeIds - .map((id) => { - const nodeType = plugin.settings.nodeTypes.find( - (type) => type.id === id, - ); - return nodeType; - }) + .map((id) => getNodeTypeById(plugin, id)) .filter(Boolean) as DiscourseNode[]; setCompatibleNodeTypes(compatibleNodeTypes); diff --git a/apps/obsidian/src/components/RelationshipSettings.tsx b/apps/obsidian/src/components/RelationshipSettings.tsx index 7dc1bca7b..86f2a0a2b 100644 --- a/apps/obsidian/src/components/RelationshipSettings.tsx +++ b/apps/obsidian/src/components/RelationshipSettings.tsx @@ -1,12 +1,9 @@ import { useState } from "react"; -import { - DiscourseRelation, - DiscourseNode, - DiscourseRelationType, -} from "~/types"; +import { DiscourseRelation, DiscourseRelationType } from "~/types"; import { Notice } from "obsidian"; import { usePlugin } from "./PluginContext"; import { ConfirmationModal } from "./ConfirmationModal"; +import { getNodeTypeById } from "~/utils/utils"; const RelationshipSettings = () => { const plugin = usePlugin(); @@ -15,10 +12,6 @@ const RelationshipSettings = () => { >(() => plugin.settings.discourseRelations ?? []); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); - const findNodeById = (id: string): DiscourseNode | undefined => { - return plugin.settings.nodeTypes.find((node) => node.id === id); - }; - const findRelationTypeById = ( id: string, ): DiscourseRelationType | undefined => { @@ -72,8 +65,8 @@ const RelationshipSettings = () => { relation.destinationId && relation.relationshipTypeId ) { - const sourceNode = findNodeById(relation.sourceId); - const targetNode = findNodeById(relation.destinationId); + const sourceNode = getNodeTypeById(plugin, relation.sourceId); + const targetNode = getNodeTypeById(plugin, relation.destinationId); const relationType = findRelationTypeById(relation.relationshipTypeId); if (sourceNode && targetNode && relationType) { @@ -202,7 +195,7 @@ const RelationshipSettings = () => {
- {findNodeById(relation.sourceId)?.name || + {getNodeTypeById(plugin, relation.sourceId)?.name || "Unknown Node"}
@@ -224,8 +217,8 @@ const RelationshipSettings = () => {
- {findNodeById(relation.destinationId)?.name || - "Unknown Node"} + {getNodeTypeById(plugin, relation.destinationId) + ?.name || "Unknown Node"}
diff --git a/apps/obsidian/src/components/canvas/DiscourseNodePanel.tsx b/apps/obsidian/src/components/canvas/DiscourseNodePanel.tsx index e64fd0191..147426aa4 100644 --- a/apps/obsidian/src/components/canvas/DiscourseNodePanel.tsx +++ b/apps/obsidian/src/components/canvas/DiscourseNodePanel.tsx @@ -1,9 +1,18 @@ -import { useEditor } from "tldraw"; +import { + Box, + Vec, + useAtom, + useEditor, + useQuickReactor, + useValue, +} from "tldraw"; import * as React from "react"; import { TFile } from "obsidian"; -import { DiscourseNode } from "~/types"; import DiscourseGraphPlugin from "~/index"; import { openCreateDiscourseNodeAt } from "./utils/nodeCreationFlow"; +import { getNodeTypeById } from "~/utils/utils"; +import { useEffect } from "react"; +import { setDiscourseNodeToolContext } from "./DiscourseNodeTool"; export const DiscourseNodePanel = ({ plugin, @@ -13,53 +22,269 @@ export const DiscourseNodePanel = ({ canvasFile: TFile; }) => { const editor = useEditor(); + const rPanelContainer = React.useRef(null); + const rDraggingImage = React.useRef(null); + const didDragRef = React.useRef(false); + const [focusedNodeTypeId, setFocusedNodeTypeId] = React.useState< + string | null + >(null); - const handleNodeTypeSelect = React.useCallback( - (selectedNodeType: DiscourseNode, position?: { x: number; y: number }) => { - const finalPosition = position || editor.getViewportScreenCenter(); - openCreateDiscourseNodeAt({ - plugin, - canvasFile, - tldrawEditor: editor, - position: finalPosition, - initialNodeType: selectedNodeType, - }); + type DragState = + | { name: "idle" } + | { name: "pointing_item"; nodeTypeId: string; startPosition: Vec } + | { name: "dragging"; nodeTypeId: string; currentPosition: Vec }; + + const dragState = useAtom("dgPanelDragState", () => ({ + name: "idle", + })); + + const handlers = React.useMemo(() => { + let target: HTMLButtonElement | null = null; + + const handlePointerMove = (e: PointerEvent) => { + const current = dragState.get(); + const screenPoint = new Vec(e.clientX, e.clientY); + switch (current.name) { + case "idle": + break; + case "pointing_item": { + const dist = Vec.Dist(screenPoint, current.startPosition); + if (dist > 10) { + didDragRef.current = true; + dragState.set({ + name: "dragging", + nodeTypeId: current.nodeTypeId, + currentPosition: screenPoint, + }); + } + break; + } + case "dragging": { + dragState.set({ ...current, currentPosition: screenPoint }); + break; + } + } + }; + + const handleKeyDown = (e: KeyboardEvent) => { + const current = dragState.get(); + if (e.key === "Escape" && current.name === "dragging") { + removeEventListeners(); + } + }; + + const removeEventListeners = () => { + if (target) { + target.removeEventListener("pointermove", handlePointerMove); + document.removeEventListener("keydown", handleKeyDown); + } + dragState.set({ name: "idle" }); + }; + + const onPointerUp = (e: React.PointerEvent) => { + const current = dragState.get(); + target = e.currentTarget as HTMLButtonElement; + target.releasePointerCapture(e.pointerId); + + switch (current.name) { + case "idle": + break; + case "pointing_item": { + dragState.set({ name: "idle" }); + break; + } + case "dragging": { + e.preventDefault(); + e.stopPropagation(); + const screenPoint = new Vec(e.clientX, e.clientY); + const pagePoint = editor.screenToPage(screenPoint); + const nodeType = getNodeTypeById(plugin, current.nodeTypeId); + if (nodeType) { + openCreateDiscourseNodeAt({ + plugin, + canvasFile, + tldrawEditor: editor, + position: pagePoint, + initialNodeType: nodeType, + }); + } + dragState.set({ name: "idle" }); + // keep didDrag true through click phase; reset on next tick + setTimeout(() => { + didDragRef.current = false; + }, 0); + break; + } + } + + removeEventListeners(); + }; + + const onPointerDown = (e: React.PointerEvent) => { + e.preventDefault(); + target = e.currentTarget as HTMLButtonElement; + target.setPointerCapture(e.pointerId); + const nodeTypeId = target.dataset.dg_node_type_id!; + if (!nodeTypeId) return; + const startPosition = new Vec(e.clientX, e.clientY); + dragState.set({ name: "pointing_item", nodeTypeId, startPosition }); + target.addEventListener("pointermove", handlePointerMove); + document.addEventListener("keydown", handleKeyDown); + }; + + return { handlePointerDown: onPointerDown, handlePointerUp: onPointerUp }; + }, [dragState, editor, plugin, canvasFile]); + + const state = useValue("dgPanelDragState", () => dragState.get(), [ + dragState, + ]); + + useQuickReactor( + "dg-panel-drag-image-style", + () => { + const current = dragState.get(); + const imageRef = rDraggingImage.current; + const panelRef = rPanelContainer.current; + if (!imageRef || !panelRef) return; + + switch (current.name) { + case "idle": + case "pointing_item": { + imageRef.style.display = "none"; + break; + } + case "dragging": { + const panelRect = panelRef.getBoundingClientRect(); + const box = new Box( + panelRect.x, + panelRect.y, + panelRect.width, + panelRect.height, + ); + const viewportScreenBounds = editor.getViewportScreenBounds(); + const isInside = Box.ContainsPoint(box, current.currentPosition); + if (isInside) { + imageRef.style.display = "none"; + } else { + imageRef.style.display = "block"; + imageRef.style.position = "fixed"; + imageRef.style.pointerEvents = "none"; + imageRef.style.left = "0px"; + imageRef.style.top = "0px"; + imageRef.style.transform = `translate(${current.currentPosition.x - viewportScreenBounds.x - 25}px, ${current.currentPosition.y - viewportScreenBounds.y - 25}px)`; + imageRef.style.width = "50px"; + imageRef.style.height = "50px"; + imageRef.style.display = "flex"; + imageRef.style.alignItems = "center"; + } + } + } }, - [editor, plugin, canvasFile], + [dragState, editor], ); const nodeTypes = plugin.settings.nodeTypes; + useEffect(() => { + if (!focusedNodeTypeId) return; + const exists = !!getNodeTypeById(plugin, focusedNodeTypeId); + if (!exists) setFocusedNodeTypeId(null); + }, [focusedNodeTypeId, plugin]); + + const focusedNodeType = focusedNodeTypeId + ? getNodeTypeById(plugin, focusedNodeTypeId) + : null; + + const displayNodeTypes = focusedNodeType ? [focusedNodeType] : nodeTypes; + + useEffect(() => { + const cursor = focusedNodeTypeId ? "cross" : "default"; + editor.setCursor({ type: cursor }); + return () => { + editor.setCursor({ type: "default" }); + }; + }, [focusedNodeTypeId, editor]); + + const handleItemClick = (id: string) => { + if (didDragRef.current) return; + if (focusedNodeTypeId) { + setFocusedNodeTypeId(null); + return; + } + setFocusedNodeTypeId(id); + setDiscourseNodeToolContext({ plugin, canvasFile, nodeTypeId: id }); + editor.setCurrentTool("discourse-node"); + }; + return ( -
-

Discourse Node Types

-
- {nodeTypes.map((nodeType) => ( - - ))} +
+
+
+ {displayNodeTypes.map((nodeType) => ( + handleItemClick(nodeType.id)} + /> + ))} +
+
+ {state.name === "dragging" + ? (getNodeTypeById(plugin, state.nodeTypeId)?.name ?? "") + : null} +
); }; + +const NodeTypeButton = ({ + nodeType, + handlers, + didDragRef, + onClickNoDrag, +}: NodeTypeButtonProps) => { + return ( + + ); +}; + +type NodeTypeSummary = { + id: string; + name: string; + color?: string; +}; + +type NodeTypeButtonProps = { + nodeType: NodeTypeSummary; + handlers: { + handlePointerDown: (e: React.PointerEvent) => void; + handlePointerUp: (e: React.PointerEvent) => void; + }; + didDragRef: React.MutableRefObject; + onClickNoDrag: () => void; +}; diff --git a/apps/obsidian/src/components/canvas/DiscourseNodeTool.ts b/apps/obsidian/src/components/canvas/DiscourseNodeTool.ts index 35459056b..b897aeb21 100644 --- a/apps/obsidian/src/components/canvas/DiscourseNodeTool.ts +++ b/apps/obsidian/src/components/canvas/DiscourseNodeTool.ts @@ -1,13 +1,52 @@ -import { StateNode } from "@tldraw/editor"; +import { StateNode, TLPointerEventInfo } from "@tldraw/editor"; +import type { TFile } from "obsidian"; +import DiscourseGraphPlugin from "~/index"; +import { getNodeTypeById } from "~/utils/utils"; +import { openCreateDiscourseNodeAt } from "./utils/nodeCreationFlow"; + +type ToolContext = { + plugin: DiscourseGraphPlugin; + canvasFile: TFile; + nodeTypeId?: string; +} | null; + +let toolContext: ToolContext = null; + +export const setDiscourseNodeToolContext = (args: ToolContext): void => { + toolContext = args; +}; export class DiscourseNodeTool extends StateNode { static override id = "discourse-node"; - override onEnter = () => { - this.editor.setCursor({ type: "cross" }); + this.editor.setCursor({ + type: "cross", + rotation: 45, + }); }; - override onPointerDown = () => { + override onPointerDown = (_info?: TLPointerEventInfo) => { + const { currentPagePoint } = this.editor.inputs; + + if (!toolContext) { + this.editor.setCurrentTool("select"); + return; + } + + const { plugin, canvasFile, nodeTypeId } = toolContext; + const initialNodeType = nodeTypeId + ? (getNodeTypeById(plugin, nodeTypeId) ?? undefined) + : undefined; + + openCreateDiscourseNodeAt({ + plugin, + canvasFile, + tldrawEditor: this.editor, + position: currentPagePoint, + initialNodeType, + }); + + toolContext = null; this.editor.setCurrentTool("select"); }; } diff --git a/apps/obsidian/src/components/canvas/TldrawView.tsx b/apps/obsidian/src/components/canvas/TldrawView.tsx index af1c3fb43..02d1e9ef1 100644 --- a/apps/obsidian/src/components/canvas/TldrawView.tsx +++ b/apps/obsidian/src/components/canvas/TldrawView.tsx @@ -5,7 +5,7 @@ import { TldrawPreviewComponent } from "./TldrawViewComponent"; import { TLStore } from "tldraw"; import React from "react"; import DiscourseGraphPlugin from "~/index"; -import { processInitialData, TLData } from "~/components/canvas/tldraw"; +import { processInitialData, TLData } from "~/components/canvas/utils/tldraw"; import { ObsidianTLAssetStore } from "~/components/canvas/stores/assetStore"; export class TldrawView extends TextFileView { diff --git a/apps/obsidian/src/components/canvas/TldrawViewComponent.tsx b/apps/obsidian/src/components/canvas/TldrawViewComponent.tsx index 396a28260..b69f77ab6 100644 --- a/apps/obsidian/src/components/canvas/TldrawViewComponent.tsx +++ b/apps/obsidian/src/components/canvas/TldrawViewComponent.tsx @@ -31,7 +31,6 @@ import { TFile } from "obsidian"; import { ObsidianTLAssetStore } from "~/components/canvas/stores/assetStore"; import { DiscourseNodeUtil } from "~/components/canvas/shapes/DiscourseNodeShape"; import { DiscourseNodeTool } from "./DiscourseNodeTool"; -import { openCreateDiscourseNodeAt } from "./utils/nodeCreationFlow"; import { DiscourseNodePanel } from "./DiscourseNodePanel"; interface TldrawPreviewProps { @@ -65,30 +64,7 @@ export const TldrawPreviewComponent = ({ const customTools = [DiscourseNodeTool]; - const svgToDataUrl = (svg: string) => - `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`; - - const [iconUrl, setIconUrl] = useState(() => { - const isDark = document.body.classList.contains("theme-dark"); - const svg = isDark - ? WHITE_LOGO_SVG - : WHITE_LOGO_SVG.replace('fill="white"', 'fill="black"'); - return svgToDataUrl(svg); - }); - - useEffect(() => { - const updateIcon = () => { - const isDark = document.body.classList.contains("theme-dark"); - const svg = isDark - ? WHITE_LOGO_SVG - : WHITE_LOGO_SVG.replace('fill="white"', 'fill="black"'); - setIconUrl(svgToDataUrl(svg)); - }; - const ref = plugin.app.workspace.on("css-change", updateIcon); - return () => { - if (ref) plugin.app.workspace.offref(ref); - }; - }, [plugin]); + const iconUrl = `data:image/svg+xml;utf8,${encodeURIComponent(WHITE_LOGO_SVG)}`; useEffect(() => { const timer = setTimeout(() => { @@ -183,37 +159,7 @@ export const TldrawPreviewComponent = ({ }; return ( -
{ - const editor = editorRef.current; - if (!editor) return; - - const nodeTypeId = e.dataTransfer?.getData( - "application/x-dg-node-type", - ); - if (!nodeTypeId) return; - - e.preventDefault(); - e.stopPropagation(); - - const pagePoint = editor.screenToPage({ x: e.clientX, y: e.clientY }); - - const nodeType = plugin.settings.nodeTypes.find( - (nt) => nt.id === nodeTypeId, - ); - if (!nodeType) return; - - openCreateDiscourseNodeAt({ - plugin, - canvasFile: file, - tldrawEditor: editor, - position: pagePoint, - initialNodeType: nodeType, - }); - }} - > +
{isReady ? ( ( @@ -289,4 +235,4 @@ export const TldrawPreviewComponent = ({ )}
); -}; \ No newline at end of file +}; diff --git a/apps/obsidian/src/components/canvas/shapes/DiscourseNodeShape.tsx b/apps/obsidian/src/components/canvas/shapes/DiscourseNodeShape.tsx index 21775d770..6b47d514f 100644 --- a/apps/obsidian/src/components/canvas/shapes/DiscourseNodeShape.tsx +++ b/apps/obsidian/src/components/canvas/shapes/DiscourseNodeShape.tsx @@ -10,10 +10,10 @@ import { memo, createElement, useEffect } from "react"; import DiscourseGraphPlugin from "~/index"; import { getFrontmatterForFile, - getNodeTypeById, FrontmatterRecord, } from "./discourseNodeShapeUtils"; import { resolveLinkedFileFromSrc } from "~/components/canvas/stores/assetStore"; +import { getNodeTypeById } from "~/utils/utils"; export type DiscourseNodeShape = TLBaseShape< "discourse-node", diff --git a/apps/obsidian/src/components/canvas/shapes/DiscourseRelationShape.tsx b/apps/obsidian/src/components/canvas/shapes/DiscourseRelationShape.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/apps/obsidian/src/components/canvas/shapes/discourseNodeShapeUtils.ts b/apps/obsidian/src/components/canvas/shapes/discourseNodeShapeUtils.ts index 57a83b499..b6c0523c3 100644 --- a/apps/obsidian/src/components/canvas/shapes/discourseNodeShapeUtils.ts +++ b/apps/obsidian/src/components/canvas/shapes/discourseNodeShapeUtils.ts @@ -1,6 +1,4 @@ import type { App, TFile } from "obsidian"; -import type DiscourseGraphPlugin from "~/index"; -import type { DiscourseNode } from "~/types"; export type FrontmatterRecord = Record; @@ -19,17 +17,6 @@ export const getNodeTypeIdFromFrontmatter = ( return (frontmatter as { nodeTypeId?: string })?.nodeTypeId ?? null; }; -export const getNodeTypeById = ( - plugin: DiscourseGraphPlugin, - nodeTypeId: string | null, -): DiscourseNode | null => { - if (!nodeTypeId) return null; - return ( - plugin.settings.nodeTypes.find((nodeType) => nodeType.id === nodeTypeId) ?? - null - ); -}; - export const getRelationsFromFrontmatter = ( _frontmatter: FrontmatterRecord | null, ): unknown[] => { diff --git a/apps/obsidian/src/components/canvas/tldraw.ts b/apps/obsidian/src/components/canvas/tldraw.ts deleted file mode 100644 index 8b551108e..000000000 --- a/apps/obsidian/src/components/canvas/tldraw.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { - createTLStore, - defaultShapeUtils, - TldrawFile, - TLRecord, - TLStore, -} from "tldraw"; -import { - FRONTMATTER_KEY, - TLDATA_DELIMITER_END, - TLDATA_DELIMITER_START, - TLDRAW_VERSION, -} from "~/constants"; -import DiscourseGraphPlugin from "~/index"; -import { checkAndCreateFolder, getNewUniqueFilepath } from "../../utils/file"; -import { Notice } from "obsidian"; -import { format } from "date-fns"; -import { ObsidianTLAssetStore } from "./stores/assetStore"; -import { - DiscourseNodeUtil, - DiscourseNodeUtilOptions, -} from "~/components/canvas/shapes/DiscourseNodeShape"; - -export type TldrawPluginMetaData = { - "plugin-version": string; - "tldraw-version": string; - uuid: string; -}; - -export type TldrawRawData = { - tldrawFileFormatVersion: number; - schema: any; - records: any; -}; - -export type TLData = { - meta: TldrawPluginMetaData; - raw: TldrawRawData; -}; - -export const processInitialData = ( - data: TLData, - assetStore: ObsidianTLAssetStore, - ctx: DiscourseNodeUtilOptions, -): { meta: TldrawPluginMetaData; store: TLStore } => { - const customShapeUtils = [ - ...defaultShapeUtils, - DiscourseNodeUtil.configure(ctx), - ]; - - 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: customShapeUtils, - initialData: recordsData, - assets: assetStore, - }); - } else { - store = createTLStore({ - shapeUtils: customShapeUtils, - assets: assetStore, - }); - } - - return { - meta: data.meta, - store, - }; -}; - -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); - } -}; - -/** - * 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}`, - ); -}; diff --git a/apps/obsidian/src/components/canvas/utils/nodeCreationFlow.ts b/apps/obsidian/src/components/canvas/utils/nodeCreationFlow.ts index ee705dc05..fdadc6355 100644 --- a/apps/obsidian/src/components/canvas/utils/nodeCreationFlow.ts +++ b/apps/obsidian/src/components/canvas/utils/nodeCreationFlow.ts @@ -29,13 +29,15 @@ export const openCreateDiscourseNodeAt = (args: CreateNodeAtArgs): void => { text: title, }); - const src = createdFile - ? await addWikilinkBlockrefForFile({ - app: plugin.app, - canvasFile, - linkedFile: createdFile, - }) - : null; + if (!createdFile) { + throw new Error("Failed to create discourse node file"); + } + + const src = await addWikilinkBlockrefForFile({ + app: plugin.app, + canvasFile, + linkedFile: createdFile, + }); const shapeId = createShapeId(); tldrawEditor.createShape({ @@ -47,16 +49,18 @@ export const openCreateDiscourseNodeAt = (args: CreateNodeAtArgs): void => { w: 200, h: 100, src: src ?? "", - title: title, + title: createdFile.basename, nodeTypeId: selectedNodeType.id, }, }); - tldrawEditor.markHistoryStoppingPoint("create discourse node"); + tldrawEditor.markHistoryStoppingPoint( + `create discourse node ${selectedNodeType.id}`, + ); tldrawEditor.setSelectedShapes([shapeId]); } catch (error) { console.error("Error creating discourse node:", error); - new Notice("Failed to create discourse node"); + new Notice(`Failed to create discourse node: ${JSON.stringify(error)}`); } }, }); diff --git a/apps/obsidian/src/components/canvas/utils/tldraw.ts b/apps/obsidian/src/components/canvas/utils/tldraw.ts index 122e66f46..d2b218fce 100644 --- a/apps/obsidian/src/components/canvas/utils/tldraw.ts +++ b/apps/obsidian/src/components/canvas/utils/tldraw.ts @@ -12,10 +12,10 @@ import { TLDRAW_VERSION, } from "~/constants"; import DiscourseGraphPlugin from "~/index"; -import { checkAndCreateFolder, getNewUniqueFilepath } from "../../../utils/file"; +import { checkAndCreateFolder, getNewUniqueFilepath } from "~/utils/file"; import { Notice } from "obsidian"; import { format } from "date-fns"; -import { ObsidianTLAssetStore } from "../stores/assetStore"; +import { ObsidianTLAssetStore } from "~/components/canvas/stores/assetStore"; import { DiscourseNodeUtil, DiscourseNodeUtilOptions, @@ -119,7 +119,7 @@ export const frontmatterTemplate = (data: string, tags: string[] = []) => { let str = "---\n"; str += `${data}\n`; if (tags.length) { - str += `tags:\n[${tags.join(", ")}]\n`; + str += `tags: [${tags.map((t) => JSON.stringify(t)).join(", ")}]\n`; } str += "---\n"; return str; diff --git a/apps/obsidian/src/constants.ts b/apps/obsidian/src/constants.ts index aaf2053cc..34b3bcbed 100644 --- a/apps/obsidian/src/constants.ts +++ b/apps/obsidian/src/constants.ts @@ -74,4 +74,4 @@ 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 export const WHITE_LOGO_SVG = - ''; \ No newline at end of file + ''; \ No newline at end of file diff --git a/apps/obsidian/src/utils/createNode.ts b/apps/obsidian/src/utils/createNode.ts index c27c29491..227558ae1 100644 --- a/apps/obsidian/src/utils/createNode.ts +++ b/apps/obsidian/src/utils/createNode.ts @@ -1,4 +1,4 @@ -import { App, Editor, Notice, TFile } from "obsidian"; +import { Editor, Notice, TFile } from "obsidian"; import { DiscourseNode } from "~/types"; import { getDiscourseNodeFormatExpression } from "./getDiscourseNodeFormatExpression"; import { checkInvalidChars } from "./validateNodeType"; @@ -192,4 +192,4 @@ export const convertPageToDiscourseNode = async ({ 5000, ); } -}; +}; \ No newline at end of file diff --git a/apps/obsidian/src/utils/file.ts b/apps/obsidian/src/utils/file.ts index 952f77d9c..5d1bc92c9 100644 --- a/apps/obsidian/src/utils/file.ts +++ b/apps/obsidian/src/utils/file.ts @@ -31,4 +31,4 @@ export const getNewUniqueFilepath = ({ } return fname; -}; +}; \ No newline at end of file diff --git a/apps/obsidian/src/utils/registerCommands.ts b/apps/obsidian/src/utils/registerCommands.ts index 136c5bbe9..d69e40bc1 100644 --- a/apps/obsidian/src/utils/registerCommands.ts +++ b/apps/obsidian/src/utils/registerCommands.ts @@ -5,7 +5,7 @@ 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 "../components/canvas/tldraw"; +import { createCanvas } from "~/components/canvas/utils/tldraw"; export const registerCommands = (plugin: DiscourseGraphPlugin) => { plugin.addCommand({ diff --git a/apps/obsidian/src/utils/utils.ts b/apps/obsidian/src/utils/utils.ts new file mode 100644 index 000000000..1b71782b2 --- /dev/null +++ b/apps/obsidian/src/utils/utils.ts @@ -0,0 +1,9 @@ +import type DiscourseGraphPlugin from "~/index"; +import { DiscourseNode } from "~/types"; + +export const getNodeTypeById = ( + plugin: DiscourseGraphPlugin, + nodeTypeId: string, +): DiscourseNode | undefined => { + return plugin.settings.nodeTypes.find((node) => node.id === nodeTypeId); +}; \ No newline at end of file