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 new file mode 100644 index 000000000..147426aa4 --- /dev/null +++ b/apps/obsidian/src/components/canvas/DiscourseNodePanel.tsx @@ -0,0 +1,290 @@ +import { + Box, + Vec, + useAtom, + useEditor, + useQuickReactor, + useValue, +} from "tldraw"; +import * as React from "react"; +import { TFile } from "obsidian"; +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, + canvasFile, +}: { + plugin: DiscourseGraphPlugin; + 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); + + 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"; + } + } + } + }, + [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 ( +
+
+
+ {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 new file mode 100644 index 000000000..b897aeb21 --- /dev/null +++ b/apps/obsidian/src/components/canvas/DiscourseNodeTool.ts @@ -0,0 +1,52 @@ +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", + rotation: 45, + }); + }; + + 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 3d34dec47..b69f77ab6 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,19 @@ 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 { DiscourseNodePanel } from "./DiscourseNodePanel"; interface TldrawPreviewProps { store: TLStore; @@ -37,6 +51,7 @@ export const TldrawPreviewComponent = ({ const [isReady, setIsReady] = useState(false); const saveTimeoutRef = useRef(); const lastSavedDataRef = useRef(""); + const editorRef = useRef(); const customShapeUtils = [ ...defaultShapeUtils, @@ -47,6 +62,10 @@ export const TldrawPreviewComponent = ({ }), ]; + const customTools = [DiscourseNodeTool]; + + const iconUrl = `data:image/svg+xml;utf8,${encodeURIComponent(WHITE_LOGO_SVG)}`; + useEffect(() => { const timer = setTimeout(() => { setIsReady(true); @@ -135,6 +154,10 @@ export const TldrawPreviewComponent = ({ }; }, [currentStore, saveChanges]); + const handleMount = (editor: Editor) => { + editorRef.current = editor; + }; + return (
{isReady ? ( @@ -146,8 +169,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} + /> + + + ); + }, + }} /> ) : ( @@ -155,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 f95839d72..6b47d514f 100644 --- a/apps/obsidian/src/components/canvas/shapes/DiscourseNodeShape.tsx +++ b/apps/obsidian/src/components/canvas/shapes/DiscourseNodeShape.tsx @@ -10,12 +10,10 @@ 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"; +import { getNodeTypeById } from "~/utils/utils"; export type DiscourseNodeShape = TLBaseShape< "discourse-node", @@ -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/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 b5bcf9b40..b6c0523c3 100644 --- a/apps/obsidian/src/components/canvas/shapes/discourseNodeShapeUtils.ts +++ b/apps/obsidian/src/components/canvas/shapes/discourseNodeShapeUtils.ts @@ -1,7 +1,4 @@ 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; @@ -20,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/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..fdadc6355 --- /dev/null +++ b/apps/obsidian/src/components/canvas/utils/nodeCreationFlow.ts @@ -0,0 +1,69 @@ +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, + }); + + 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({ + id: shapeId, + type: "discourse-node", + x: position.x, + y: position.y, + props: { + w: 200, + h: 100, + src: src ?? "", + title: createdFile.basename, + nodeTypeId: selectedNodeType.id, + }, + }); + + 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: ${JSON.stringify(error)}`); + } + }, + }); + + modal.open(); +}; diff --git a/apps/obsidian/src/components/canvas/tldraw.ts b/apps/obsidian/src/components/canvas/utils/tldraw.ts similarity index 95% rename from apps/obsidian/src/components/canvas/tldraw.ts rename to apps/obsidian/src/components/canvas/utils/tldraw.ts index 8b551108e..d2b218fce 100644 --- a/apps/obsidian/src/components/canvas/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 565003e64..34b3bcbed 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 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