diff --git a/apps/obsidian/src/components/AdminPanelSettings.tsx b/apps/obsidian/src/components/AdminPanelSettings.tsx index e8e6b8af3..0b796f322 100644 --- a/apps/obsidian/src/components/AdminPanelSettings.tsx +++ b/apps/obsidian/src/components/AdminPanelSettings.tsx @@ -14,6 +14,8 @@ export const AdminPanelSettings = () => { const [username, setUsername] = useState( plugin.settings.username || "", ); + const [nodeCardContextMenuEnabled, setNodeCardContextMenuEnabled] = + useState(plugin.settings.nodeCardContextMenuEnabled ?? false); const handleSyncModeToggle = useCallback( async (newValue: boolean) => { @@ -43,6 +45,15 @@ export const AdminPanelSettings = () => { await updateUsername(plugin, newValue); }; + const handleNodeCardContextMenuToggle = useCallback( + async (newValue: boolean) => { + setNodeCardContextMenuEnabled(newValue); + plugin.settings.nodeCardContextMenuEnabled = newValue; + await plugin.saveSettings(); + }, + [plugin], + ); + const handleLoginHandoff = async () => { const client = await getLoggedInClient(plugin); if (!client) { @@ -72,6 +83,30 @@ export const AdminPanelSettings = () => { return (
+
+
+
(BETA) Discourse context
+
+ Show discourse context and styling tabs when a node card is selected + on a canvas +
+
+
+
+ void handleNodeCardContextMenuToggle(!nodeCardContextMenuEnabled) + } + > + +
+
+
(BETA) Sync mode enable
diff --git a/apps/obsidian/src/components/canvas/NodeCardContextMenu.tsx b/apps/obsidian/src/components/canvas/NodeCardContextMenu.tsx new file mode 100644 index 000000000..b8519de28 --- /dev/null +++ b/apps/obsidian/src/components/canvas/NodeCardContextMenu.tsx @@ -0,0 +1,116 @@ +import { createElement, useEffect, useState, type ComponentType } from "react"; +import type { TFile } from "obsidian"; +import { + DefaultStylePanel, + DefaultStylePanelContent, + useEditor, + useRelevantStyles, + useValue, + type TLUiStylePanelContentProps, + type TLUiStylePanelProps, +} from "tldraw"; +import type DiscourseGraphPlugin from "~/index"; +import type { DiscourseNodeShape } from "./shapes/DiscourseNodeShape"; +import { RelationsPanelContent } from "./overlays/RelationPanel"; + +type NodeCardContextMenuProps = TLUiStylePanelProps & { + plugin: DiscourseGraphPlugin; + canvasFile: TFile; +}; + +const NODE_CARD_CONTEXT_MENU_TABS = [ + { id: "context", label: "Context" }, + { id: "styling", label: "Styling" }, +] as const; + +type NodeCardContextMenuTab = + (typeof NODE_CARD_CONTEXT_MENU_TABS)[number]["id"]; + +const DefaultStylePanelComponent = + DefaultStylePanel as unknown as ComponentType; +const DefaultStylePanelContentComponent = + DefaultStylePanelContent as unknown as ComponentType; + +export const NodeCardContextMenu = ({ + plugin, + canvasFile, + isMobile, +}: NodeCardContextMenuProps) => { + const editor = useEditor(); + const styles = useRelevantStyles(); + const isEnabled = plugin.settings.nodeCardContextMenuEnabled ?? false; + const currentToolId = useValue( + "current tool for node card context menu", + () => editor.getCurrentToolId(), + [editor], + ); + const selectedShape = useValue( + "selected shape for node card context menu", + () => + editor.getCurrentToolId() === "select" + ? editor.getOnlySelectedShape() + : null, + [editor], + ); + const selectedNode = + isEnabled && selectedShape?.type === "discourse-node" + ? (selectedShape as DiscourseNodeShape) + : null; + const [activeTab, setActiveTab] = useState("context"); + + useEffect(() => { + setActiveTab("context"); + }, [selectedNode?.id]); + + // The DiscourseToolPanel occupies the top-right corner while these tools + // are active; don't render a second panel next to it. + if ( + isEnabled && + (currentToolId === "discourse-node" || + currentToolId === "discourse-relation") + ) { + return null; + } + + if (!selectedNode) { + return createElement(DefaultStylePanelComponent, { isMobile }); + } + + return createElement( + DefaultStylePanelComponent, + { isMobile }, +
+
+ {NODE_CARD_CONTEXT_MENU_TABS.map(({ id, label }) => ( + + ))} +
+ + {activeTab === "context" ? ( +
+ +
+ ) : ( + createElement(DefaultStylePanelContentComponent, { styles }) + )} +
, + ); +}; diff --git a/apps/obsidian/src/components/canvas/TldrawViewComponent.tsx b/apps/obsidian/src/components/canvas/TldrawViewComponent.tsx index d553a3487..fa336c316 100644 --- a/apps/obsidian/src/components/canvas/TldrawViewComponent.tsx +++ b/apps/obsidian/src/components/canvas/TldrawViewComponent.tsx @@ -47,6 +47,7 @@ import { import ToastListener from "./ToastListener"; import { RelationsOverlay } from "./overlays/RelationOverlay"; import { DragHandleOverlay } from "./overlays/DragHandleOverlay"; +import { NodeCardContextMenu } from "./NodeCardContextMenu"; import { WHITE_LOGO_SVG } from "~/icons"; import { CustomContextMenu } from "./CustomContextMenu"; import { @@ -431,6 +432,13 @@ export const TldrawPreviewComponent = ({ ContextMenu: (props) => ( ), + StylePanel: (props) => ( + + ), SharePanel: () => { const tools = useTools(); const isDiscourseNodeToolSelected = useIsToolSelected( diff --git a/apps/obsidian/src/components/canvas/overlays/RelationPanel.tsx b/apps/obsidian/src/components/canvas/overlays/RelationPanel.tsx index d1b53007f..c3317f9e8 100644 --- a/apps/obsidian/src/components/canvas/overlays/RelationPanel.tsx +++ b/apps/obsidian/src/components/canvas/overlays/RelationPanel.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import type { TFile } from "obsidian"; import type DiscourseGraphPlugin from "~/index"; import { @@ -7,6 +7,7 @@ import { } from "~/components/canvas/shapes/DiscourseNodeShape"; import { ensureBlockRefForFile, + findBlockRefForFile, resolveLinkedFileFromSrc, extractBlockRefId, } from "~/components/canvas/stores/assetStore"; @@ -60,6 +61,8 @@ export type RelationsPanelProps = { onClose: () => void; }; +type RelationsPanelContentProps = Omit; + const RelationFileItem = ({ file, group, @@ -160,16 +163,18 @@ const RelationFileItem = ({ ); }; -export const RelationsPanel = ({ +const LINKED_FILE_NOT_FOUND_ERROR = "Linked file not found."; + +export const RelationsPanelContent = ({ plugin, canvasFile, nodeShape, - onClose, -}: RelationsPanelProps) => { +}: RelationsPanelContentProps) => { const editor = useEditor(); const [groups, setGroups] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [reloadToken, setReloadToken] = useState(0); // Resolve the file from the shape's src useEffect(() => { @@ -190,7 +195,7 @@ export const RelationsPanel = ({ }); if (!file) { setGroups([]); - setError("Linked file not found."); + setError(LINKED_FILE_NOT_FOUND_ERROR); return; } const g = await computeRelations(plugin, file); @@ -208,11 +213,26 @@ export const RelationsPanel = ({ } }; void load(); - }, [plugin, canvasFile, nodeShape.id, nodeShape.props.src, editor]); - - const headerTitle = useMemo(() => { - return nodeShape.props.title || "Selected node"; - }, [nodeShape.props.title]); + }, [ + plugin, + canvasFile, + nodeShape.id, + nodeShape.props.src, + editor, + reloadToken, + ]); + + // Right after node creation the canvas file's block refs may not be indexed + // yet; retry once Obsidian re-indexes the canvas file. + useEffect(() => { + if (error !== LINKED_FILE_NOT_FOUND_ERROR) return; + const eventRef = plugin.app.metadataCache.on("changed", (changedFile) => { + if (changedFile.path === canvasFile.path) { + setReloadToken((token) => token + 1); + } + }); + return () => plugin.app.metadataCache.offref(eventRef); + }, [error, plugin, canvasFile]); const ensureNodeShapeForFile = async ( file: TFile, @@ -257,62 +277,67 @@ export const RelationsPanel = ({ }; // Check if a relation already exists between the selected node and a target file - const checkExistingRelation = async ( - targetFile: TFile, - relationTypeId: string, - ): Promise => { - try { - // Get all shapes on the canvas - const allShapes = editor.getCurrentPageShapes(); + const checkExistingRelation = useCallback( + async ( + targetFile: TFile, + relationTypeId: string, + ): Promise => { + try { + // Get all shapes on the canvas + const allShapes = editor.getCurrentPageShapes(); - // Find the target node shape that corresponds to the file - const targetBlockRef = await ensureBlockRefForFile({ - app: plugin.app, - canvasFile, - targetFile, - }); - const targetNodeShape = allShapes.find((shape) => { - if (shape.type !== "discourse-node") return false; - const src = (shape as DiscourseNodeShape).props.src ?? ""; - return extractBlockRefId(src) === targetBlockRef; - }) as DiscourseNodeShape | undefined; - - if (!targetNodeShape) return null; - - // Find relation shapes that connect the selected node and target node - const relationShapes = allShapes.filter( - (shape) => - shape.type === "discourse-relation" && - (shape as DiscourseRelationShape).props.relationTypeId === - relationTypeId, - ) as DiscourseRelationShape[]; - - for (const relationShape of relationShapes) { - const bindings = getArrowBindings(editor, relationShape); - - // Check if this relation connects our two nodes in ANY direction - // The relation could exist as either: - // 1. selectedNode -> targetNode (forward direction) - // 2. targetNode -> selectedNode (reverse direction) - const isConnectedForward = - bindings.start?.toId === nodeShape.id && - bindings.end?.toId === targetNodeShape.id; - - const isConnectedReverse = - bindings.start?.toId === targetNodeShape.id && - bindings.end?.toId === nodeShape.id; - - if (isConnectedForward || isConnectedReverse) { - return relationShape; + // Find the target node shape that corresponds to the file. + // Read-only lookup: rendering the panel must not write to the canvas file. + const targetBlockRef = await findBlockRefForFile({ + app: plugin.app, + canvasFile, + targetFile, + }); + if (!targetBlockRef) return null; + const targetNodeShape = allShapes.find((shape) => { + if (shape.type !== "discourse-node") return false; + const src = (shape as DiscourseNodeShape).props.src ?? ""; + return extractBlockRefId(src) === targetBlockRef; + }) as DiscourseNodeShape | undefined; + + if (!targetNodeShape) return null; + + // Find relation shapes that connect the selected node and target node + const relationShapes = allShapes.filter( + (shape) => + shape.type === "discourse-relation" && + (shape as DiscourseRelationShape).props.relationTypeId === + relationTypeId, + ) as DiscourseRelationShape[]; + + for (const relationShape of relationShapes) { + const bindings = getArrowBindings(editor, relationShape); + + // Check if this relation connects our two nodes in ANY direction + // The relation could exist as either: + // 1. selectedNode -> targetNode (forward direction) + // 2. targetNode -> selectedNode (reverse direction) + const isConnectedForward = + bindings.start?.toId === nodeShape.id && + bindings.end?.toId === targetNodeShape.id; + + const isConnectedReverse = + bindings.start?.toId === targetNodeShape.id && + bindings.end?.toId === nodeShape.id; + + if (isConnectedForward || isConnectedReverse) { + return relationShape; + } } - } - return null; - } catch (e) { - console.error("Failed to check existing relation", e); - return null; - } - }; + return null; + } catch (e) { + console.error("Failed to check existing relation", e); + return null; + } + }, + [editor, plugin, canvasFile, nodeShape.id], + ); const handleDeleteRelationShape = async ( targetFile: TFile, @@ -459,6 +484,48 @@ export const RelationsPanel = ({ } }; + return loading ? ( +
Loading relations...
+ ) : error ? ( +
{error}
+ ) : groups.length === 0 ? ( +
No relations found.
+ ) : ( +
    + {groups.map((group) => ( +
  • +
    + + {group.isSource ? "→" : "←"} + + {group.label} +
    +
      + {group.linkedFiles.map((f) => { + return ( + + ); + })} +
    +
  • + ))} +
+ ); +}; + +export const RelationsPanel = ({ + plugin, + canvasFile, + nodeShape, + onClose, +}: RelationsPanelProps) => { return (
@@ -473,47 +540,16 @@ export const RelationsPanel = ({
-
{headerTitle}
+
+ {nodeShape.props.title || "Selected node"} +
- {loading ? ( -
Loading relations...
- ) : error ? ( -
{error}
- ) : groups.length === 0 ? ( -
No relations found.
- ) : ( -
    - {groups.map((group) => ( -
  • -
    - - {group.isSource ? "→" : "←"} - - {group.label} -
    - {group.linkedFiles.length === 0 ? ( -
    None
    - ) : ( -
      - {group.linkedFiles.map((f) => { - return ( - - ); - })} -
    - )} -
  • - ))} -
- )} +
); }; @@ -540,38 +576,65 @@ const computeRelations = async ( plugin.settings.discourseRelations.filter(isAcceptedSchema); for (const relationType of acceptedRelationTypes) { - const typeLevelRelation = acceptedDiscourseRelations.find( - (rel) => - (rel.sourceId === activeNodeTypeId || - rel.destinationId === activeNodeTypeId) && - rel.relationshipTypeId === relationType.id, + const matchingRelations = acceptedDiscourseRelations.filter( + (relation) => + (relation.sourceId === activeNodeTypeId || + relation.destinationId === activeNodeTypeId) && + relation.relationshipTypeId === relationType.id, ); - if (!typeLevelRelation) continue; - - const instanceRels = relations.filter((r) => r.type === relationType.id); - const isSource = typeLevelRelation.sourceId === activeNodeTypeId; - const label = isSource ? relationType.label : relationType.complement; - const key = `${relationType.id}-${isSource}`; - - if (!result.has(key)) { - result.set(key, { - key, - label, - isSource, - relationTypeId: relationType.id, - linkedFiles: [], - }); - } + for (const typeLevelRelation of matchingRelations) { + const isSource = typeLevelRelation.sourceId === activeNodeTypeId; + const key = `${relationType.id}-${isSource}`; + + if (!result.has(key)) { + result.set(key, { + key, + label: isSource ? relationType.label : relationType.complement, + isSource, + relationTypeId: relationType.id, + linkedFiles: [], + }); + } - const group = result.get(key)!; - for (const r of instanceRels) { - const otherId = r.source === nodeInstanceId ? r.destination : r.source; - const linked = getFileForNodeInstanceId(plugin, otherId); - if (linked && !group.linkedFiles.some((f) => f.path === linked.path)) { - group.linkedFiles.push(linked); + const group = result.get(key)!; + for (const relation of relations) { + if (relation.type !== relationType.id) continue; + const otherId = getRelationCounterpartId({ + relation, + nodeInstanceId, + isSource, + }); + if (!otherId) continue; + + const linkedFile = getFileForNodeInstanceId(plugin, otherId); + if ( + linkedFile && + !group.linkedFiles.some(({ path }) => path === linkedFile.path) + ) { + group.linkedFiles.push(linkedFile); + } } } } - return Array.from(result.values()); + // Only show relation types that have relation instances + return Array.from(result.values()).filter( + (group) => group.linkedFiles.length > 0, + ); +}; + +const getRelationCounterpartId = ({ + relation, + nodeInstanceId, + isSource, +}: { + relation: { source: string; destination: string }; + nodeInstanceId: string; + isSource: boolean; +}): string | null => { + if (isSource) { + return relation.source === nodeInstanceId ? relation.destination : null; + } + + return relation.destination === nodeInstanceId ? relation.source : null; }; diff --git a/apps/obsidian/src/components/canvas/stores/assetStore.ts b/apps/obsidian/src/components/canvas/stores/assetStore.ts index d882edad1..c2bda9c7a 100644 --- a/apps/obsidian/src/components/canvas/stores/assetStore.ts +++ b/apps/obsidian/src/components/canvas/stores/assetStore.ts @@ -112,10 +112,10 @@ export const resolveLinkedTFileByBlockRef = async ({ }; /** - * Ensure there is a block reference in the canvas file that links to the given file. - * Return the blockRef id; create it if it doesn't exist yet. + * Find an existing block reference in the canvas file that links to the given file. + * Read-only: returns the blockRef id, or null if none exists. */ -export const ensureBlockRefForFile = async ({ +export const findBlockRefForFile = async ({ app, canvasFile, targetFile, @@ -123,10 +123,9 @@ export const ensureBlockRefForFile = async ({ app: App; canvasFile: TFile; targetFile: TFile; -}): Promise => { - // First, scan existing blocks to see if any link to the target file +}): Promise => { const fileCache = app.metadataCache.getFileCache(canvasFile); - if (!fileCache) return ""; + if (!fileCache) return null; const blocks = fileCache.blocks ?? {}; for (const [blockId] of Object.entries(blocks)) { const linked = await resolveLinkedTFileByBlockRef({ @@ -139,6 +138,30 @@ export const ensureBlockRefForFile = async ({ return blockId; } } + return null; +}; + +/** + * Ensure there is a block reference in the canvas file that links to the given file. + * Return the blockRef id; create it if it doesn't exist yet. + */ +export const ensureBlockRefForFile = async ({ + app, + canvasFile, + targetFile, +}: { + app: App; + canvasFile: TFile; + targetFile: TFile; +}): Promise => { + const fileCache = app.metadataCache.getFileCache(canvasFile); + if (!fileCache) return ""; + const existingBlockRef = await findBlockRefForFile({ + app, + canvasFile, + targetFile, + }); + if (existingBlockRef) return existingBlockRef; // Create a new block ref at the top that links to the target file const blockRefId = crypto.randomUUID(); diff --git a/apps/obsidian/src/constants.ts b/apps/obsidian/src/constants.ts index a47526a23..ce7ea3c3c 100644 --- a/apps/obsidian/src/constants.ts +++ b/apps/obsidian/src/constants.ts @@ -121,6 +121,7 @@ export const DEFAULT_SETTINGS: Settings = { spacePassword: undefined, accountLocalId: undefined, syncModeEnabled: false, + nodeCardContextMenuEnabled: false, spaceNames: {}, }; diff --git a/apps/obsidian/src/types.ts b/apps/obsidian/src/types.ts index f7bc3fc41..45eeca898 100644 --- a/apps/obsidian/src/types.ts +++ b/apps/obsidian/src/types.ts @@ -70,6 +70,7 @@ export type Settings = { spacePassword?: string; accountLocalId?: string; syncModeEnabled?: boolean; + nodeCardContextMenuEnabled?: boolean; /** Maps spaceUri (e.g. "obsidian:abc123") to human-readable name (e.g. "My Vault") */ spaceNames?: Record; username?: string; diff --git a/apps/obsidian/styles.css b/apps/obsidian/styles.css index 567e1539b..a958722ce 100644 --- a/apps/obsidian/styles.css +++ b/apps/obsidian/styles.css @@ -1,6 +1,13 @@ @tailwind components; @tailwind utilities; +/* tldraw otherwise caps the style panel at 148px; only the context tab needs + the extra width, the styling tab keeps tldraw's default proportions. */ +.tlui-style-panel:has(.dg-node-card-menu--context) { + width: min(20rem, calc(100vw - 1rem)); + max-width: min(20rem, calc(100vw - 1rem)); +} + .accent-border-bottom { border-bottom: 2px solid var(--interactive-accent) !important; } @@ -100,3 +107,11 @@ body.dg-hide-frontmatter-ids .metadata-property[data-property-key^="rel_" i] { border: none !important; box-shadow: none !important; } + +/* The reset above outranks utility classes; re-assert the node card menu's active tab styling. + border-radius 0 keeps the underline flat instead of bending around the button's rounded corners. */ +.tldraw__editor .dg-node-card-menu button.accent-border-bottom { + border-bottom: 2px solid var(--interactive-accent) !important; + border-radius: 0; + color: var(--interactive-accent); +}