diff --git a/apps/obsidian/src/components/AdminPanelSettings.tsx b/apps/obsidian/src/components/AdminPanelSettings.tsx index e8e6b8af3..2ee1b91dd 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) Node card context menu
+
+ 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..0c452a3fd --- /dev/null +++ b/apps/obsidian/src/components/canvas/NodeCardContextMenu.tsx @@ -0,0 +1,97 @@ +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 { RelationsPanel } 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 selectedShape = useValue( + "selected shape for node card context menu", + () => editor.getOnlySelectedShape(), + [editor], + ); + const selectedNode = + isEnabled && selectedShape?.type === "discourse-node" + ? (selectedShape as DiscourseNodeShape) + : null; + const [activeTab, setActiveTab] = useState("context"); + + useEffect(() => { + setActiveTab("context"); + }, [selectedNode?.id]); + + 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..49ff5638e 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 { useEffect, useState } from "react"; import type { TFile } from "obsidian"; import type DiscourseGraphPlugin from "~/index"; import { @@ -57,7 +57,9 @@ export type RelationsPanelProps = { plugin: DiscourseGraphPlugin; canvasFile: TFile; nodeShape: DiscourseNodeShape; - onClose: () => void; + onClose?: () => void; + embedded?: boolean; + includeAllDirections?: boolean; }; const RelationFileItem = ({ @@ -165,6 +167,8 @@ export const RelationsPanel = ({ canvasFile, nodeShape, onClose, + embedded = false, + includeAllDirections = false, }: RelationsPanelProps) => { const editor = useEditor(); const [groups, setGroups] = useState([]); @@ -193,7 +197,7 @@ export const RelationsPanel = ({ setError("Linked file not found."); return; } - const g = await computeRelations(plugin, file); + const g = await computeRelations(plugin, file, includeAllDirections); setGroups(g); } catch (e) { showToast({ @@ -208,11 +212,14 @@ 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, + includeAllDirections, + ]); const ensureNodeShapeForFile = async ( file: TFile, @@ -460,21 +467,33 @@ export const RelationsPanel = ({ }; return ( -
-
-

Relations

- -
- -
-
{headerTitle}
-
+
+ {!embedded && ( + <> +
+

Relations

+ +
+ +
+
+ {nodeShape.props.title || "Selected node"} +
+
+ + )} {loading ? (
Loading relations...
@@ -521,6 +540,7 @@ export const RelationsPanel = ({ const computeRelations = async ( plugin: DiscourseGraphPlugin, file: TFile, + includeAllDirections: boolean, ): Promise => { const fileCache = plugin.app.metadataCache.getFileCache(file); if (!fileCache?.frontmatter) return []; @@ -540,38 +560,76 @@ 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: [], - }); - } + // Preserve the legacy panel's previous first-match behavior. + const typeLevelRelations = includeAllDirections + ? matchingRelations + : matchingRelations.slice(0, 1); + + for (const typeLevelRelation of typeLevelRelations) { + 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, + includeAllDirections, + }); + 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()); }; + +const getRelationCounterpartId = ({ + relation, + nodeInstanceId, + isSource, + includeAllDirections, +}: { + relation: { source: string; destination: string }; + nodeInstanceId: string; + isSource: boolean; + includeAllDirections: boolean; +}): string | null => { + if (!includeAllDirections) { + return relation.source === nodeInstanceId + ? relation.destination + : relation.source; + } + + if (isSource) { + return relation.source === nodeInstanceId ? relation.destination : null; + } + + return relation.destination === nodeInstanceId ? relation.source : null; +}; 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..4b1f5691f 100644 --- a/apps/obsidian/styles.css +++ b/apps/obsidian/styles.css @@ -1,6 +1,12 @@ @tailwind components; @tailwind utilities; +/* tldraw otherwise caps the style panel at 148px. */ +.tlui-style-panel:has(.dg-node-card-menu) { + width: min(20rem, calc(100vw - 1rem)); + max-width: min(20rem, calc(100vw - 1rem)); +} + .accent-border-bottom { border-bottom: 2px solid var(--interactive-accent) !important; }