Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions apps/obsidian/src/components/AdminPanelSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ export const AdminPanelSettings = () => {
const [username, setUsername] = useState<string>(
plugin.settings.username || "",
);
const [nodeCardContextMenuEnabled, setNodeCardContextMenuEnabled] =
useState<boolean>(plugin.settings.nodeCardContextMenuEnabled ?? false);

const handleSyncModeToggle = useCallback(
async (newValue: boolean) => {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -72,6 +83,30 @@ export const AdminPanelSettings = () => {

return (
<div className="general-settings">
<div className="setting-item">
<div className="setting-item-info">
<div className="setting-item-name">(BETA) Node card context menu</div>
<div className="setting-item-description">
Show discourse context and styling tabs when a node card is selected
on a canvas
</div>
</div>
<div className="setting-item-control">
<div
className={`checkbox-container ${nodeCardContextMenuEnabled ? "is-enabled" : ""}`}
onClick={() =>
void handleNodeCardContextMenuToggle(!nodeCardContextMenuEnabled)
}
>
<input
type="checkbox"
checked={nodeCardContextMenuEnabled}
aria-label="Enable node card context menu"
readOnly
/>
</div>
</div>
</div>
<div className="setting-item">
<div className="setting-item-info">
<div className="setting-item-name">(BETA) Sync mode enable</div>
Expand Down
97 changes: 97 additions & 0 deletions apps/obsidian/src/components/canvas/NodeCardContextMenu.tsx
Original file line number Diff line number Diff line change
@@ -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<TLUiStylePanelProps>;
const DefaultStylePanelContentComponent =
DefaultStylePanelContent as unknown as ComponentType<TLUiStylePanelContentProps>;

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<NodeCardContextMenuTab>("context");

useEffect(() => {
setActiveTab("context");
}, [selectedNode?.id]);

if (!selectedNode) {
return createElement(DefaultStylePanelComponent, { isMobile });
}

return createElement(
DefaultStylePanelComponent,
{ isMobile },
<div className="dg-node-card-menu">
<div className="grid grid-cols-2 border-b border-[var(--color-divider)] bg-[var(--color-panel)]">
{NODE_CARD_CONTEXT_MENU_TABS.map(({ id, label }) => (
<button
key={id}
type="button"
aria-pressed={activeTab === id}
className={[
"border-b-2 px-3 py-2 text-xs font-semibold",
activeTab === id
? "border-[var(--color-selected)] text-[var(--color-selected)]"
: "border-transparent text-[var(--color-text-3)]",
].join(" ")}
onClick={() => setActiveTab(id)}
>
{label}
</button>
))}
</div>

{activeTab === "context" ? (
<RelationsPanel
plugin={plugin}
canvasFile={canvasFile}
nodeShape={selectedNode}
embedded
includeAllDirections
/>
) : (
createElement(DefaultStylePanelContentComponent, { styles })
)}
</div>,
);
};
8 changes: 8 additions & 0 deletions apps/obsidian/src/components/canvas/TldrawViewComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -431,6 +432,13 @@ export const TldrawPreviewComponent = ({
ContextMenu: (props) => (
<CustomContextMenu canvasFile={file} props={props} />
),
StylePanel: (props) => (
<NodeCardContextMenu
plugin={plugin}
canvasFile={file}
{...props}
/>
),
SharePanel: () => {
const tools = useTools();
const isDiscourseNodeToolSelected = useIsToolSelected(
Expand Down
158 changes: 108 additions & 50 deletions apps/obsidian/src/components/canvas/overlays/RelationPanel.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -57,7 +57,9 @@ export type RelationsPanelProps = {
plugin: DiscourseGraphPlugin;
canvasFile: TFile;
nodeShape: DiscourseNodeShape;
onClose: () => void;
onClose?: () => void;
embedded?: boolean;
includeAllDirections?: boolean;
};

const RelationFileItem = ({
Expand Down Expand Up @@ -165,6 +167,8 @@ export const RelationsPanel = ({
canvasFile,
nodeShape,
onClose,
embedded = false,
includeAllDirections = false,
}: RelationsPanelProps) => {
const editor = useEditor();
const [groups, setGroups] = useState<GroupedRelation[]>([]);
Expand Down Expand Up @@ -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({
Expand All @@ -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,
Expand Down Expand Up @@ -460,21 +467,33 @@ export const RelationsPanel = ({
};

return (
<div className="min-w-80 max-w-md rounded-lg border bg-white p-4 shadow-lg">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-lg font-semibold">Relations</h3>
<button
onClick={onClose}
className="text-gray-500 hover:text-gray-700"
aria-label="Close"
>
</button>
</div>

<div className="mb-3">
<div className="text-sm font-medium text-gray-700">{headerTitle}</div>
</div>
<div
className={
embedded
? "p-3"
: "min-w-80 max-w-md rounded-lg border bg-white p-4 shadow-lg"
}
>
{!embedded && (
<>
<div className="mb-3 flex items-center justify-between">
<h3 className="text-lg font-semibold">Relations</h3>
<button
onClick={onClose}
className="text-gray-500 hover:text-gray-700"
aria-label="Close"
>
</button>
</div>

<div className="mb-3">
<div className="text-sm font-medium text-gray-700">
{nodeShape.props.title || "Selected node"}
</div>
</div>
</>
)}

{loading ? (
<div className="text-center text-gray-500">Loading relations...</div>
Expand Down Expand Up @@ -521,6 +540,7 @@ export const RelationsPanel = ({
const computeRelations = async (
plugin: DiscourseGraphPlugin,
file: TFile,
includeAllDirections: boolean,
): Promise<GroupedRelation[]> => {
const fileCache = plugin.app.metadataCache.getFileCache(file);
if (!fileCache?.frontmatter) return [];
Expand All @@ -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;
};
1 change: 1 addition & 0 deletions apps/obsidian/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ export const DEFAULT_SETTINGS: Settings = {
spacePassword: undefined,
accountLocalId: undefined,
syncModeEnabled: false,
nodeCardContextMenuEnabled: false,
spaceNames: {},
};

Expand Down
Loading