Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,7 @@ export const DragHandleOverlay = ({ plugin, file }: DragHandleOverlayProps) => {
<RelationTypeDropdown
arrowId={pendingArrowId}
plugin={plugin}
canvasPath={file.path}
onSelect={handleDropdownSelect}
onDismiss={handleDropdownDismiss}
/>
Expand Down
186 changes: 146 additions & 40 deletions apps/obsidian/src/components/canvas/overlays/RelationTypeDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,27 +15,36 @@ import {
getArrowInfo,
} from "~/components/canvas/utils/relationUtils";
import {
associateRelationTypeWithNodePair,
getAssociableRelationTypesForNodePair,
getDiscourseNodeTypeId,
getValidRelationTypesForNodePair,
} from "~/components/canvas/utils/relationTypeUtils";
import { showToast } from "~/components/canvas/utils/toastUtils";
import { getNodeTypeById } from "~/utils/typeUtils";
import type { DiscourseRelationType } from "~/types";
import { clampMenuCentre } from "~/components/canvas/utils/menuPlacement";

type RelationTypeDropdownProps = {
arrowId: TLShapeId;
plugin: DiscourseGraphPlugin;
canvasPath: string;
onSelect: (relationTypeId: string) => void;
onDismiss: () => void;
};

export const RelationTypeDropdown = ({
arrowId,
plugin,
canvasPath,
onSelect,
onDismiss,
}: RelationTypeDropdownProps) => {
const editor = useEditor();
const dropdownRef = useRef<HTMLDivElement>(null);
const [isAddMenuOpen, setIsAddMenuOpen] = useState(false);
const [isPickingExisting, setIsPickingExisting] = useState(false);
const [isSaving, setIsSaving] = useState(false);

const arrow = useValue<DiscourseRelationShape | null>(
"dropdownArrow",
Expand All @@ -50,29 +59,45 @@ export const RelationTypeDropdown = ({
}
}, [arrow, onDismiss]);

// Get valid relation types based on source/target node types
const validRelationTypes = useMemo(() => {
if (!arrow) return [];
const nodePair = useMemo(() => {
if (!arrow) return null;

const bindings = getArrowBindings(editor, arrow);
if (!bindings.start || !bindings.end) return [];
if (!bindings.start || !bindings.end) return null;

const startNode = editor.getShape(bindings.start.toId);
const endNode = editor.getShape(bindings.end.toId);
const sourceNodeTypeId = getDiscourseNodeTypeId(
editor.getShape(bindings.start.toId),
);
const targetNodeTypeId = getDiscourseNodeTypeId(
editor.getShape(bindings.end.toId),
);
if (!sourceNodeTypeId || !targetNodeTypeId) return null;

if (!startNode || !endNode) return [];
return { sourceNodeTypeId, targetNodeTypeId };
}, [arrow, editor]);

const startNodeTypeId = getDiscourseNodeTypeId(startNode);
const endNodeTypeId = getDiscourseNodeTypeId(endNode);

if (!startNodeTypeId || !endNodeTypeId) return [];

return getValidRelationTypesForNodePair({
settings: plugin.settings,
sourceNodeTypeId: startNodeTypeId,
targetNodeTypeId: endNodeTypeId,
});
}, [arrow, editor, plugin]);
// Associating replaces the discourseRelations array, so its identity refreshes both lists
const { relationTypes, discourseRelations } = plugin.settings;
const validRelationTypes = useMemo(
() =>
nodePair
? getValidRelationTypesForNodePair({
settings: { relationTypes, discourseRelations },
...nodePair,
})
: [],
[nodePair, relationTypes, discourseRelations],
);
const associableRelationTypes = useMemo(
() =>
nodePair
? getAssociableRelationTypesForNodePair({
settings: { relationTypes, discourseRelations },
...nodePair,
})
: [],
[nodePair, relationTypes, discourseRelations],
);

// Position dropdown at arrow midpoint
const dropdownPosition = useValue<{ left: number; top: number } | null>(
Expand Down Expand Up @@ -109,6 +134,7 @@ export const RelationTypeDropdown = ({
} | null>(null);
const hasPosition = !!dropdownPosition;
const relationTypeCount = validRelationTypes.length;
const associableCount = associableRelationTypes.length;
useLayoutEffect(() => {
const [menu, flyout] = Array.from(
dropdownRef.current?.children ?? [],
Expand All @@ -120,7 +146,13 @@ export const RelationTypeDropdown = ({
? flyout.offsetLeft + flyout.offsetWidth - menu.offsetWidth
: 0,
});
}, [hasPosition, isAddMenuOpen, relationTypeCount]);
}, [
hasPosition,
isAddMenuOpen,
isPickingExisting,
relationTypeCount,
associableCount,
]);

// Handle click outside
useEffect(() => {
Expand All @@ -144,10 +176,15 @@ export const RelationTypeDropdown = ({
};
}, [onDismiss]);

// Handle Escape key: close the add menu first, then the dropdown
// Handle Escape key: leave the picker or add menu first, then the dropdown
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
if (isPickingExisting) {
e.stopPropagation();
setIsPickingExisting(false);
return;
}
if (isAddMenuOpen) {
e.stopPropagation();
setIsAddMenuOpen(false);
Expand All @@ -157,7 +194,7 @@ export const RelationTypeDropdown = ({
};
window.addEventListener("keydown", handleKeyDown, true);
return () => window.removeEventListener("keydown", handleKeyDown, true);
}, [isAddMenuOpen, onDismiss]);
}, [isAddMenuOpen, isPickingExisting, onDismiss]);

const handleSelect = useCallback(
(relationTypeId: string) => {
Expand All @@ -166,6 +203,41 @@ export const RelationTypeDropdown = ({
[onSelect],
);

const handleAssociate = useCallback(
async (relationType: DiscourseRelationType) => {
if (!nodePair || isSaving) return;
setIsSaving(true);
try {
await associateRelationTypeWithNodePair({
plugin,
relationTypeId: relationType.id,
...nodePair,
});
} catch {
showToast({
severity: "error",
title: "Couldn't add relation",
targetCanvasId: canvasPath,
});
return;
} finally {
setIsSaving(false);
}
setIsPickingExisting(false);
const sourceName =
getNodeTypeById(plugin, nodePair.sourceNodeTypeId)?.name ?? "source";
const targetName =
getNodeTypeById(plugin, nodePair.targetNodeTypeId)?.name ?? "target";
showToast({
severity: "success",
title: "Discourse relation added",
description: `${relationType.label} relation added for ${sourceName} and ${targetName}`,
targetCanvasId: canvasPath,
});
},
[nodePair, isSaving, plugin, canvasPath],
);

if (!dropdownPosition || !arrow) return null;

const centre = menuSize
Expand All @@ -181,11 +253,51 @@ export const RelationTypeDropdown = ({
"flex w-full cursor-pointer items-center justify-start rounded border-none bg-transparent px-2 py-1.5 text-left text-sm font-medium text-gray-700 hover:bg-gray-100";
const addActions = (
<>
<button className={actionClassName}>Add existing…</button>
<button
className={actionClassName}
onClick={() => {
setIsAddMenuOpen(false);
setIsPickingExisting(true);
}}
>
Add existing…
</button>
<button className={actionClassName}>Create new</button>
</>
);
const hasRelationTypes = validRelationTypes.length > 0;
const listContent = hasRelationTypes
? validRelationTypes.map((rt) => (
<button
key={rt.id}
onClick={() => handleSelect(rt.id)}
className="flex w-full cursor-pointer items-center gap-2 rounded border-none bg-transparent px-2 py-1.5 text-left text-sm text-gray-700 hover:bg-gray-100"
>
<span
className="h-2 w-2 shrink-0 rounded-full"
style={{ backgroundColor: rt.color }}
/>
{rt.label}
</button>
))
: addActions;
const pickerContent =
associableRelationTypes.length > 0 ? (
associableRelationTypes.map((rt) => (
<button
key={rt.id}
disabled={isSaving}
onClick={() => void handleAssociate(rt)}
className={actionClassName}
Comment thread
trangdoan982 marked this conversation as resolved.
>
{rt.label} / {rt.complement}
</button>
))
) : (
<p className="m-0 px-2 py-1.5 text-sm text-gray-500">
All relation types are already available for these nodes
</p>
);

return (
<div
Expand All @@ -201,11 +313,19 @@ export const RelationTypeDropdown = ({
onClick={(e) => e.stopPropagation()}
>
<div className="max-h-60 min-w-40 overflow-y-auto rounded-lg border bg-white p-1 shadow-lg">
<div className="flex items-center justify-between px-2 py-1">
<span className="text-xs font-medium uppercase tracking-wide text-gray-500">
<div className="flex items-center justify-between gap-1 px-2 py-1">
{isPickingExisting && (
<button
aria-label="Back to relation types"
onClick={() => setIsPickingExisting(false)}
className="flex h-auto cursor-pointer items-center rounded border-none bg-transparent p-0.5 text-gray-500 hover:bg-gray-100"
ref={(el) => (el && setIcon(el, "chevron-left")) || undefined}
/>
)}
<span className="mr-auto text-xs font-medium uppercase tracking-wide text-gray-500">
Relation type
</span>
{hasRelationTypes && (
{hasRelationTypes && !isPickingExisting && (
<button
aria-label="Add relation type"
aria-expanded={isAddMenuOpen}
Expand All @@ -215,21 +335,7 @@ export const RelationTypeDropdown = ({
/>
)}
</div>
{hasRelationTypes
? validRelationTypes.map((rt) => (
<button
key={rt.id}
onClick={() => handleSelect(rt.id)}
className="flex w-full cursor-pointer items-center gap-2 rounded border-none bg-transparent px-2 py-1.5 text-left text-sm text-gray-700 hover:bg-gray-100"
>
<span
className="h-2 w-2 shrink-0 rounded-full"
style={{ backgroundColor: rt.color }}
/>
{rt.label}
</button>
))
: addActions}
{isPickingExisting ? pickerContent : listContent}
</div>
{/* Outside the scroll container so it isn't clipped, inside dropdownRef so clicks don't dismiss */}
{hasRelationTypes && isAddMenuOpen && (
Expand Down
Loading
Loading