Skip to content
Open
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
247 changes: 247 additions & 0 deletions apps/roam/src/components/canvas/CustomStylePanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
import React, { useEffect, useState } from "react";
import {
DefaultStylePanel,
DefaultStylePanelContent,
TLUiStylePanelProps,
createShapeId,
useEditor,
useRelevantStyles,
useValue,
} from "tldraw";
import { Button, Tab, Tabs } from "@blueprintjs/core";
import { useExtensionAPI } from "roamjs-components/components/ExtensionApiContext";
import getDiscourseContextResults from "~/utils/getDiscourseContextResults";
import type { DiscourseContextResults } from "~/components/DiscourseContext";
import findDiscourseNode from "~/utils/findDiscourseNode";
import calcCanvasNodeSizeAndImg from "~/utils/calcCanvasNodeSizeAndImg";
import { withAutoCanvasRelationsSuppressed } from "./autoCanvasRelationsSuppression";
import { isDiscourseNodeShape } from "./canvasUtils";
import {
DISCOURSE_NODE_SHAPE_TYPE,
DiscourseNodeShape,
DiscourseNodeUtil,
} from "./DiscourseNodeUtil";
import { dispatchToastEvent } from "./ToastListener";

const NEW_NODE_OFFSET_PX = 80;

const ContextTabContent = ({ shape }: { shape: DiscourseNodeShape }) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 New panel code omits the explicit function return types the repo style guide requires

The newly added panel components and helpers are declared without explicit return types (for example const ContextTabContent = ({ shape }: { shape: DiscourseNodeShape }) => { at apps/roam/src/components/canvas/CustomStylePanel.tsx:28), which conflicts with the repository TypeScript rules.
Impact: The code diverges from the project's documented style, making future refactors harder to review.

Rule source and affected declarations

AGENTS.md and STYLE_GUIDE.md both state "Use explicit return types for functions". Affected declarations: ContextTabContent (apps/roam/src/components/canvas/CustomStylePanel.tsx:28), removeFromCanvas (:66), addToCanvas (:74), toggleCanvasPresence (:126), NodeCardPanelContent (:198), and CustomStylePanel (:226). Existing code in the same area follows the rule, e.g. SyncModeMenuSwitchItem returns ReactElement in apps/roam/src/components/canvas/uiOverrides.tsx:85-96.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

const editor = useEditor();
const extensionAPI = useExtensionAPI();
const [results, setResults] = useState<DiscourseContextResults | null>(null);
const [failed, setFailed] = useState(false);
const [pendingUids, setPendingUids] = useState<string[]>([]);
const uid = shape.props.uid;

useEffect(() => {
let cancelled = false;
setResults(null);
setFailed(false);
getDiscourseContextResults({ uid })
.then((r) => {
if (!cancelled) setResults(r);
})
.catch(() => {
if (!cancelled) setFailed(true);
});
return () => {
cancelled = true;
};
}, [uid]);

const nodeShapesByUid = useValue(
"discourse-node-shapes-by-uid",
() =>
new Map(
editor
.getCurrentPageShapes()
.filter((s): s is DiscourseNodeShape =>
isDiscourseNodeShape(editor, s),
)
.map((s) => [s.props.uid, s]),
),
[editor],
);

const removeFromCanvas = (nodeShape: DiscourseNodeShape) => {
const util = editor.getShapeUtil(nodeShape);
if (util instanceof DiscourseNodeUtil) {
util.deleteRelationsInCanvas({ shape: nodeShape });
}
editor.deleteShapes([nodeShape.id]);
};

const addToCanvas = async ({
relatedUid,
text,
}: {
relatedUid: string;
text: string;
}) => {
if (!extensionAPI) return;
const node = findDiscourseNode({ uid: relatedUid });
if (!node) {
dispatchToastEvent({
id: "dg-context-tab-missing-node",
title: "Could not find a discourse node for this result.",
severity: "error",
});
return;
}
const { w, h, imageUrl } = await calcCanvasNodeSizeAndImg({
nodeText: text,
uid: relatedUid,
nodeType: node.type,
extensionAPI,
});
const id = createShapeId();
withAutoCanvasRelationsSuppressed(() =>
editor.createShapes([
{
id,
type: DISCOURSE_NODE_SHAPE_TYPE,
x: shape.x + shape.props.w + NEW_NODE_OFFSET_PX,
y: shape.y,
Comment on lines +103 to +104

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Related nodes added from the context list all land in the same spot, hiding each other

Every node added from the relation list is placed at one fixed position next to the selected card (x: shape.x + shape.props.w + NEW_NODE_OFFSET_PX at apps/roam/src/components/canvas/CustomStylePanel.tsx:103-104), so adding a second related node drops it exactly on top of the first one.
Impact: Users who add several related nodes see a single stack of overlapping cards and must drag them apart manually.

Fixed placement offset with no collision or fan-out logic

addToCanvas computes the new shape's position purely from the anchor shape (apps/roam/src/components/canvas/CustomStylePanel.tsx:98-117). It does not consider how many nodes were already added from the panel, nor whether other shapes already occupy that point, so N adds produce N shapes with identical x/y. A simple fix is to offset by the number of already placed siblings (e.g. stagger y by index or search for a free spot before creating).

Prompt for agents
In apps/roam/src/components/canvas/CustomStylePanel.tsx, addToCanvas always positions the newly created discourse node shape at shape.x + shape.props.w + NEW_NODE_OFFSET_PX, shape.y. When a user adds multiple related nodes from the Context tab, they are all created at that same point and visually stack on top of one another. Consider computing a placement that avoids collisions, e.g. stagger vertically based on how many shapes already occupy the target area (editor.getCurrentPageShapes / editor.getShapesAtPoint) or track the number of nodes added from this panel and offset accordingly.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

props: {
uid: relatedUid,
title: text,
w,
h,
...(imageUrl && { imageUrl }),
size: "s",
fontFamily: "sans",
nodeTypeId: node.type,
},
},
]),
);
const created = editor.getShape<DiscourseNodeShape>(id);
if (!created) return;
const util = editor.getShapeUtil(created);
if (util instanceof DiscourseNodeUtil) {
await util.createExistingRelations({ shape: created });
}
};

const toggleCanvasPresence = async ({
relatedUid,
text,
}: {
relatedUid: string;
text: string;
}) => {
setPendingUids((prev) => [...prev, relatedUid]);
try {
const existing = nodeShapesByUid.get(relatedUid);
if (existing) {
removeFromCanvas(existing);
} else {
await addToCanvas({ relatedUid, text });
}
} finally {
setPendingUids((prev) => prev.filter((u) => u !== relatedUid));
}
};
Comment on lines +126 to +144

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Missing error handling in toggleCanvasPresence async function. If addToCanvas throws an error (e.g., network failure, missing data), it will be silently swallowed because:

  1. The try-finally block has no catch clause
  2. The onClick handler on line 185 uses void operator, discarding the rejected promise

This will leave users confused when add/remove operations fail without feedback.

Fix: Add a catch block to handle errors:

const toggleCanvasPresence = async ({
  relatedUid,
  text,
}: {
  relatedUid: string;
  text: string;
}) => {
  setPendingUids((prev) => [...prev, relatedUid]);
  try {
    const existing = nodeShapesByUid.get(relatedUid);
    if (existing) {
      removeFromCanvas(existing);
    } else {
      await addToCanvas({ relatedUid, text });
    }
  } catch (error) {
    dispatchToastEvent({
      id: "dg-context-tab-toggle-error",
      title: "Failed to update canvas",
      severity: "error",
    });
    console.error("Error toggling canvas presence:", error);
  } finally {
    setPendingUids((prev) => prev.filter((u) => u !== relatedUid));
  }
};
Suggested change
const toggleCanvasPresence = async ({
relatedUid,
text,
}: {
relatedUid: string;
text: string;
}) => {
setPendingUids((prev) => [...prev, relatedUid]);
try {
const existing = nodeShapesByUid.get(relatedUid);
if (existing) {
removeFromCanvas(existing);
} else {
await addToCanvas({ relatedUid, text });
}
} finally {
setPendingUids((prev) => prev.filter((u) => u !== relatedUid));
}
};
const toggleCanvasPresence = async ({
relatedUid,
text,
}: {
relatedUid: string;
text: string;
}) => {
setPendingUids((prev) => [...prev, relatedUid]);
try {
const existing = nodeShapesByUid.get(relatedUid);
if (existing) {
removeFromCanvas(existing);
} else {
await addToCanvas({ relatedUid, text });
}
} catch (error) {
dispatchToastEvent({
id: "dg-context-tab-toggle-error",
title: "Failed to update canvas",
severity: "error",
});
console.error("Error toggling canvas presence:", error);
} finally {
setPendingUids((prev) => prev.filter((u) => u !== relatedUid));
}
};

Spotted by Graphite

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.


if (failed) {
return <div className="p-3 text-sm">Failed to load relations.</div>;
}
if (results === null) {
return <div className="p-3 text-sm">Loading relations...</div>;
}
if (results.length === 0) {
return <div className="p-3 text-sm">No relations found.</div>;
}

return (
<div className="max-h-96 overflow-y-auto p-3">
{results.map((relation) => (
<div key={relation.label} className="mb-3 last:mb-0">
<div className="mb-1 text-xs font-semibold text-gray-500">
{relation.label}
</div>
<ul className="m-0 list-none p-0">
{Object.entries(relation.results).map(([relatedUid, result]) => {
const text = result.text ?? relatedUid;
const onCanvas = nodeShapesByUid.has(relatedUid);
return (
<li
key={relatedUid}
className="flex items-center justify-between gap-2 py-1"
>
<span
className="min-w-0 flex-1 truncate text-sm"
title={text}
>
{text}
</span>
<Button
minimal
small
icon={onCanvas ? "minus" : "plus"}
title={onCanvas ? "Remove from canvas" : "Add to canvas"}
loading={pendingUids.includes(relatedUid)}
onClick={() =>
void toggleCanvasPresence({ relatedUid, text })
}
/>
</li>
);
})}
</ul>
</div>
))}
</div>
);
};

const NodeCardPanelContent = ({ shape }: { shape: DiscourseNodeShape }) => {
const styles = useRelevantStyles();
const [activeTab, setActiveTab] = useState<"context" | "styling">("context");
return (
<div className="dg-node-style-panel px-2 pt-1">
<Tabs
id="dg-node-card-tabs"
selectedTabId={activeTab}
onChange={(tabId) =>
setActiveTab(tabId === "styling" ? "styling" : "context")
}
renderActiveTabPanelOnly
>
<Tab
id="context"
title="Context"
panel={<ContextTabContent shape={shape} />}
/>
<Tab
id="styling"
title="Styling"
panel={<DefaultStylePanelContent styles={styles} />}
/>
</Tabs>
</div>
);
};

export const CustomStylePanel = (props: TLUiStylePanelProps) => {
const editor = useEditor();
const selectedNodeShape = useValue(
"selected-discourse-node-shape",
() => {
const selected = editor.getOnlySelectedShape();
return selected && isDiscourseNodeShape(editor, selected)
? selected
: null;
},
[editor],
);
if (!selectedNodeShape) return <DefaultStylePanel {...props} />;
return (
<DefaultStylePanel {...props}>
<NodeCardPanelContent
key={selectedNodeShape.id}
shape={selectedNodeShape}
/>
</DefaultStylePanel>
);
};
6 changes: 6 additions & 0 deletions apps/roam/src/components/canvas/tldrawStyles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,4 +79,10 @@ export default /* css */ `
background-color: var(--color-muted-2);
opacity: 1;
}

/* Widen the style panel when it shows the node card Context/Styling tabs */
.tlui-style-panel:has(.dg-node-style-panel) {
width: 280px;
max-width: 280px;
}
`;
2 changes: 2 additions & 0 deletions apps/roam/src/components/canvas/uiOverrides.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ import { createOrUpdateArrowBinding } from "./DiscourseRelationShape/helpers";
import DiscourseGraphPanel from "./DiscourseToolPanel";
import type { CanvasNodeShortcuts } from "~/components/settings/utils/zodSchema";
import { CustomDefaultToolbar } from "./CustomDefaultToolbar";
import { CustomStylePanel } from "./CustomStylePanel";
import { renderModifyNodeDialog } from "~/components/ModifyNodeDialog";
import { CanvasSyncMode } from "./canvasSyncMode";
import { getPersonalSetting } from "~/components/settings/utils/accessors";
Expand Down Expand Up @@ -545,6 +546,7 @@ export const createUiComponents = ({
canvasSyncMode: CanvasSyncMode;
}): TLUiComponents => {
return {
StylePanel: CustomStylePanel,
Toolbar: (props) => {
const tools = useTools();
return (
Expand Down