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
35 changes: 20 additions & 15 deletions apps/obsidian/src/components/canvas/overlays/DragHandleOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ import { showToast } from "~/components/canvas/utils/toastUtils";
import {
getDiscourseNodeAtPoint,
getDiscourseNodeTypeId,
hasValidRelationTypeForNodePair,
} from "~/components/canvas/utils/relationTypeUtils";
import { RelationTypeDropdown } from "./RelationTypeDropdown";

Expand All @@ -41,6 +40,8 @@ type HandlePosition = {
const HANDLE_RADIUS = 5;
const HANDLE_HIT_AREA = 12;
const HANDLE_PADDING = 8; // px offset in viewport space, outward from the node edge
// Marks an arrow awaiting a relation type; the canvas save loop persists it
const PENDING_RELATION_META_KEY = "pendingRelationMenu";

/** Page-space edge midpoints and their outward direction vectors. */
const getEdgeMidpoints = (bounds: {
Expand Down Expand Up @@ -95,6 +96,19 @@ export const DragHandleOverlay = ({ plugin, file }: DragHandleOverlayProps) => {
};
}, []);

// A pending arrow found on mount was abandoned when the canvas last closed
useEffect(() => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why can't we remove the arrow based on the action, whether you click away from the canvas or unfocus or something? Why do we have to catch it on the next canvas load?

const abandonedArrowIds = editor
.getCurrentPageShapes()
.filter(
(shape) =>
shape.type === "discourse-relation" &&
shape.meta[PENDING_RELATION_META_KEY],
)
.map((shape) => shape.id);
if (abandonedArrowIds.length > 0) editor.deleteShapes(abandonedArrowIds);
}, [editor]);

const selectedNode = useValue<DiscourseNodeShape | null>(
"dragHandleSelectedNode",
() => {
Expand Down Expand Up @@ -164,6 +178,7 @@ export const DragHandleOverlay = ({ plugin, file }: DragHandleOverlayProps) => {
type: "discourse-relation",
x: startX,
y: startY,
meta: { [PENDING_RELATION_META_KEY]: true },
props: {
color: DEFAULT_TLDRAW_COLOR,
relationTypeId: "",
Expand Down Expand Up @@ -276,28 +291,17 @@ export const DragHandleOverlay = ({ plugin, file }: DragHandleOverlayProps) => {
) {
const endTarget = editor.getShape(bindings.end.toId);
if (endTarget && endTarget.type === "discourse-node") {
// Check if any relation types are valid for this node pair
const startNodeTypeId = getDiscourseNodeTypeId(
editor.getShape(bindings.start.toId),
);
const endNodeTypeId = getDiscourseNodeTypeId(endTarget);

const hasValidRelationType =
startNodeTypeId &&
endNodeTypeId &&
hasValidRelationTypeForNodePair({
settings: plugin.settings,
sourceNodeTypeId: startNodeTypeId,
targetNodeTypeId: endNodeTypeId,
});

if (!hasValidRelationType) {
if (!startNodeTypeId || !endNodeTypeId) {
Comment thread
trangdoan982 marked this conversation as resolved.
cleanupArrow(arrowId);
showToast({
severity: "warning",
title: "Relation",
description:
"No relation types are defined between these node types",
description: "Both nodes need a node type to create a relation",
targetCanvasId: file.path,
});
if (sourceNodeRef.current) {
Expand Down Expand Up @@ -340,7 +344,7 @@ export const DragHandleOverlay = ({ plugin, file }: DragHandleOverlayProps) => {
dragCleanupRef.current = null;
};
},
[selectedNode, editor, cleanupArrow, file.path, plugin.settings],
[selectedNode, editor, cleanupArrow, file.path],
);

const handleDropdownSelect = useCallback(
Expand All @@ -365,6 +369,7 @@ export const DragHandleOverlay = ({ plugin, file }: DragHandleOverlayProps) => {
{
id: pendingArrowId,
type: "discourse-relation",
meta: { ...shape.meta, [PENDING_RELATION_META_KEY]: false },
props: {
relationTypeId,
color: relationType.color,
Expand Down
128 changes: 105 additions & 23 deletions apps/obsidian/src/components/canvas/overlays/RelationTypeDropdown.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
import { useCallback, useEffect, useMemo, useRef } from "react";
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { setIcon } from "obsidian";
import { TLShapeId, useEditor, useValue } from "tldraw";
import DiscourseGraphPlugin from "~/index";
import { DiscourseRelationShape } from "~/components/canvas/shapes/DiscourseRelationShape";
Expand All @@ -10,6 +18,7 @@ import {
getDiscourseNodeTypeId,
getValidRelationTypesForNodePair,
} from "~/components/canvas/utils/relationTypeUtils";
import { clampMenuCentre } from "~/components/canvas/utils/menuPlacement";

type RelationTypeDropdownProps = {
arrowId: TLShapeId;
Expand All @@ -26,6 +35,7 @@ export const RelationTypeDropdown = ({
}: RelationTypeDropdownProps) => {
const editor = useEditor();
const dropdownRef = useRef<HTMLDivElement>(null);
const [isAddMenuOpen, setIsAddMenuOpen] = useState(false);

const arrow = useValue<DiscourseRelationShape | null>(
"dropdownArrow",
Expand Down Expand Up @@ -83,6 +93,35 @@ export const RelationTypeDropdown = ({
[editor, arrow?.id],
);

const viewport = useValue(
"dropdownViewport",
() => {
const bounds = editor.getViewportScreenBounds();
return { width: bounds.w, height: bounds.h };
},
[editor],
);

// Measured before paint so the first frame is already inside the canvas.
const [menuSize, setMenuSize] = useState<{
menu: { width: number; height: number };
flyoutWidth: number;
} | null>(null);
const hasPosition = !!dropdownPosition;
const relationTypeCount = validRelationTypes.length;
useLayoutEffect(() => {
const [menu, flyout] = Array.from(
dropdownRef.current?.children ?? [],
) as HTMLElement[];
if (!menu) return;
setMenuSize({
menu: { width: menu.offsetWidth, height: menu.offsetHeight },
flyoutWidth: flyout
? flyout.offsetLeft + flyout.offsetWidth - menu.offsetWidth
: 0,
});
}, [hasPosition, isAddMenuOpen, relationTypeCount]);

// Handle click outside
useEffect(() => {
const handlePointerDown = (e: PointerEvent) => {
Expand All @@ -105,16 +144,20 @@ export const RelationTypeDropdown = ({
};
}, [onDismiss]);

// Handle Escape key
// Handle Escape key: close the add menu first, then the dropdown
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onDismiss();
if (e.key !== "Escape") return;
if (isAddMenuOpen) {
e.stopPropagation();
setIsAddMenuOpen(false);
return;
}
onDismiss();
};
window.addEventListener("keydown", handleKeyDown, true);
return () => window.removeEventListener("keydown", handleKeyDown, true);
}, [onDismiss]);
}, [isAddMenuOpen, onDismiss]);

const handleSelect = useCallback(
(relationTypeId: string) => {
Expand All @@ -125,36 +168,75 @@ export const RelationTypeDropdown = ({

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

const centre = menuSize
? clampMenuCentre({
anchor: { x: dropdownPosition.left, y: dropdownPosition.top },
...menuSize,
viewport,
margin: 8,
})
: { x: dropdownPosition.left, y: dropdownPosition.top };

const actionClassName =
"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}>Create new</button>
</>
);
const hasRelationTypes = validRelationTypes.length > 0;

return (
<div
ref={dropdownRef}
className="pointer-events-auto absolute z-30 -translate-x-1/2 -translate-y-1/2"
// Above tldraw's panels (z 300), below its menus (z 400). Arbitrary transform because preflight is off.
className="pointer-events-auto absolute z-[301] [transform:translate(-50%,-50%)]"
style={{
left: `${dropdownPosition.left}px`,
top: `${dropdownPosition.top}px`,
left: `${centre.x}px`,
top: `${centre.y}px`,
}}
onPointerDown={(e) => e.stopPropagation()}
onPointerUp={(e) => e.stopPropagation()}
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="px-2 py-1 text-xs font-medium uppercase tracking-wide text-gray-500">
Relation Type
</div>
{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 }}
<div className="flex items-center justify-between px-2 py-1">
<span className="text-xs font-medium uppercase tracking-wide text-gray-500">
Relation type
</span>
{hasRelationTypes && (
<button
aria-label="Add relation type"
aria-expanded={isAddMenuOpen}
onClick={() => setIsAddMenuOpen((open) => !open)}
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, "plus")) || undefined}
/>
{rt.label}
</button>
))}
)}
</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}
</div>
{/* Outside the scroll container so it isn't clipped, inside dropdownRef so clicks don't dismiss */}
{hasRelationTypes && isAddMenuOpen && (
<div className="absolute left-full top-0 ml-1 min-w-32 rounded-lg border bg-white p-1 shadow-lg">
{addActions}
</div>
)}
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import { clampMenuCentre } from "~/components/canvas/utils/menuPlacement";

const menu = { width: 160, height: 128 };
const viewport = { width: 1000, height: 800 };
const place = (x: number, y: number, flyoutWidth = 0) =>
clampMenuCentre({
anchor: { x, y },
menu,
flyoutWidth,
viewport,
margin: 8,
});

describe("clampMenuCentre", () => {
it("keeps the anchor when the menu fits", () => {
expect(place(500, 400)).toEqual({ x: 500, y: 400 });
});

it("moves the menu inside the top and left edges", () => {
expect(place(10, 10)).toEqual({ x: 88, y: 72 });
});

it("moves the menu inside the bottom and right edges", () => {
expect(place(995, 795)).toEqual({ x: 912, y: 728 });
});

it("leaves room for an open flyout on the right", () => {
expect(place(900, 400, 132)).toEqual({ x: 780, y: 400 });
});

it("keeps the left edge visible when the menu is wider than the viewport", () => {
const narrow = clampMenuCentre({
anchor: { x: 50, y: 400 },
menu,
flyoutWidth: 132,
viewport: { width: 300, height: 800 },
margin: 8,
});
expect(narrow.x).toBe(88);
});
});
52 changes: 52 additions & 0 deletions apps/obsidian/src/components/canvas/utils/menuPlacement.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
type Point = { x: number; y: number };
type Size = { width: number; height: number };

const clampAxis = ({
centre,
before,
after,
length,
margin,
}: {
centre: number;
before: number;
after: number;
length: number;
margin: number;
}): number => {
const min = margin + before;
const max = length - margin - after;
// Too big to fit: keep the start edge visible, where the header and + are.
if (max < min) return min;
return Math.min(Math.max(centre, min), max);
};

/** Centre of a centred menu, moved just enough to keep it and a right-side flyout inside the viewport. */
export const clampMenuCentre = ({
anchor,
menu,
flyoutWidth,
viewport,
margin,
}: {
anchor: Point;
menu: Size;
flyoutWidth: number;
viewport: Size;
margin: number;
}): Point => ({
x: clampAxis({
centre: anchor.x,
before: menu.width / 2,
after: menu.width / 2 + flyoutWidth,
length: viewport.width,
margin,
}),
y: clampAxis({
centre: anchor.y,
before: menu.height / 2,
after: menu.height / 2,
length: viewport.height,
margin,
}),
});
22 changes: 0 additions & 22 deletions apps/obsidian/src/components/canvas/utils/relationTypeUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,25 +187,3 @@ export const getCompatibleTargetNodeTypeIds = ({
}
return [...targets];
};

/**
* Checks whether any valid relation type exists between two node types.
*/
export const hasValidRelationTypeForNodePair = ({
settings,
sourceNodeTypeId,
targetNodeTypeId,
}: {
settings: RelationTypeSettings;
sourceNodeTypeId: string;
targetNodeTypeId: string;
}): boolean => {
return settings.discourseRelations.some(
(r) =>
settings.relationTypes.some((rt) => rt.id === r.relationshipTypeId) &&
((r.sourceId === sourceNodeTypeId &&
r.destinationId === targetNodeTypeId) ||
(r.sourceId === targetNodeTypeId &&
r.destinationId === sourceNodeTypeId)),
);
};
Loading