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..2fb887cfa --- /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 { 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 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]); + + 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..5fef2446e 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 { @@ -60,6 +60,8 @@ export type RelationsPanelProps = { onClose: () => void; }; +type RelationsPanelContentProps = Omit; + const RelationFileItem = ({ file, group, @@ -160,12 +162,11 @@ const RelationFileItem = ({ ); }; -export const RelationsPanel = ({ +export const RelationsPanelContent = ({ plugin, canvasFile, nodeShape, - onClose, -}: RelationsPanelProps) => { +}: RelationsPanelContentProps) => { const editor = useEditor(); const [groups, setGroups] = useState([]); const [loading, setLoading] = useState(true); @@ -210,10 +211,6 @@ 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]); - const ensureNodeShapeForFile = async ( file: TFile, ): Promise => { @@ -459,6 +456,52 @@ export const RelationsPanel = ({ } }; + return 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 ( + + ); + })} +
    + )} +
  • + ))} +
+ ); +}; + +export const RelationsPanel = ({ + plugin, + canvasFile, + nodeShape, + onClose, +}: RelationsPanelProps) => { return (
@@ -473,47 +516,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 +552,62 @@ 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()); }; + +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/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..071d852f8 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; } @@ -100,3 +106,9 @@ 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. */ +.tldraw__editor .dg-node-card-menu button.accent-border-bottom { + border-bottom: 2px solid var(--interactive-accent) !important; + background: var(--background-modifier-hover) !important; +}