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
17 changes: 14 additions & 3 deletions apps/roam/src/components/DiscourseNodeMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ type Props = {
isShift?: boolean;
menuMaxHeight?: number;
settingsSnapshot?: SettingsSnapshot;
onTagAdded?: (newText: string) => void;
};

const NodeMenu = ({
Expand All @@ -53,6 +54,7 @@ const NodeMenu = ({
isShift,
menuMaxHeight,
settingsSnapshot,
onTagAdded,
}: { onClose: () => void } & Props) => {
const isInitialTextSelected =
!!textarea && textarea.selectionStart !== textarea.selectionEnd;
Expand Down Expand Up @@ -161,7 +163,7 @@ const NodeMenu = ({
const tag = menuItem.getAttribute("data-tag") || "";
if (!tag) return;

const addTagToBlock = () => {
const addTagToBlock = async () => {
const textToInsert = `${
selectionStart === 0 ? "" : " "
}#${tag.replace(/^#/, "")}`;
Expand All @@ -171,10 +173,11 @@ const NodeMenu = ({
selectionStart,
)}${textToInsert}${currentText.substring(selectionStart)}`;

void updateBlock({ text: newText, uid: targetBlockUid });
await updateBlock({ text: newText, uid: targetBlockUid });
posthog.capture("Discourse Tag: Created via Node Menu", {
tag,
});
onTagAdded?.(newText);
};
// timeout required to ensure the block is updated
setTimeout(() => void addTagToBlock(), 100);
Expand All @@ -184,7 +187,15 @@ const NodeMenu = ({
}
onClose();
},
[menuRef, targetBlockUid, onClose, textarea, extensionAPI, showNodeTypes],
[
menuRef,
targetBlockUid,
onClose,
textarea,
extensionAPI,
showNodeTypes,
onTagAdded,
],
);

const keydownListener = useCallback(
Expand Down
256 changes: 158 additions & 98 deletions apps/roam/src/components/canvas/DiscourseNodeUtil.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import { getRelationColor } from "./DiscourseRelationShape/DiscourseRelationUtil
import { getPersonalSetting } from "~/components/settings/utils/accessors";
import { PERSONAL_KEYS } from "~/components/settings/utils/settingKeys";
import DiscourseContextOverlay from "~/components/DiscourseContextOverlay";
import NodeMenu from "~/components/DiscourseNodeMenu";
import { getDiscourseNodeColors } from "~/utils/getDiscourseNodeColors";
import { render as renderToast } from "roamjs-components/components/Toast";
import { RenderRoamBlockString } from "~/utils/roamReactComponents";
Expand Down Expand Up @@ -459,6 +460,8 @@ export class DiscourseNodeUtil extends BaseBoxShapeUtil<DiscourseNodeShape> {
// eslint-disable-next-line react-hooks/rules-of-hooks
const [overlayMounted, setOverlayMounted] = useState(false);
// eslint-disable-next-line react-hooks/rules-of-hooks
const [addTagMenuKey, setAddTagMenuKey] = useState(0);
// eslint-disable-next-line react-hooks/rules-of-hooks
const dialogRenderedRef = useRef(false);

// Detect discourse node tags in block text for blck-node shapes
Expand Down Expand Up @@ -487,6 +490,32 @@ export class DiscourseNodeUtil extends BaseBoxShapeUtil<DiscourseNodeShape> {
return null;
}, [shape]);

const showAddTagButton =
getDiscourseNodeTypeId({ shape }) === "blck-node" &&
isLiveBlock(shape.props.uid) &&
Object.values(discourseContext.nodes).some(
(n) => n.backedBy === "user" && n.tag,
);

const handleTagAdded = (newText: string) => {
const updateShape = async () => {
if (!extensionAPI) return;
const { h, w, imageUrl } = await calcCanvasNodeSizeAndImg({
nodeText: newText,
uid: shape.props.uid,
nodeType: getDiscourseNodeTypeId({ shape }),
extensionAPI,
});
this.updateProps(shape.id, shape.type, {
title: newText,
h,
w,
imageUrl,
});
};
void updateShape();
};

const { backgroundColor, textColor } = this.getColors(shape);
const showEmbeddedRoamBlock =
!isPageUid(shape.props.uid) && isLiveBlock(shape.props.uid);
Expand Down Expand Up @@ -619,114 +648,145 @@ export class DiscourseNodeUtil extends BaseBoxShapeUtil<DiscourseNodeShape> {
className="relative flex h-full min-h-0 w-full min-w-0 flex-col"
style={{ pointerEvents: "all" }}
>
{/* Open in Sidebar Button */}
<Button
className="absolute left-1 top-1 z-10"
minimal
small
icon={
<Icon
icon="panel-stats"
color={textColor}
className="opacity-50"
/>
}
onClick={(e) => {
e.stopPropagation();
void openBlockInSidebar(shape.props.uid);
}}
onPointerDown={(e) => e.stopPropagation()}
title="Open in sidebar (Shift+Click)"
/>

{/* Convert to Node Type Button */}
{matchedNodeForConversion && (
<div className="absolute left-1 top-1 z-10 flex items-center">
{/* Open in Sidebar Button */}
<Button
className="absolute left-7 top-1 z-10"
minimal
small
icon={
<Icon icon="plus" color={textColor} className="opacity-50" />
<Icon
icon="panel-stats"
color={textColor}
className="opacity-50"
/>
}
onClick={(e) => {
e.stopPropagation();
const { node, blockText } = matchedNodeForConversion;
const tag = node.tag;
if (!tag) return;
const cleanTag = getCleanTagText(tag);
const escapedCleanTag = escapeRegExp(cleanTag);
// Strip the tag from block text (same pattern as detection above)
const cleanedText = blockText
.replace(
new RegExp(`#\\[\\[${escapedCleanTag}\\]\\]`, "i"),
"",
)
.replace(new RegExp(`#${escapedCleanTag}`, "i"), "")
.trim();
const { x, y } = shape;
renderModifyNodeDialog({
mode: "create",
nodeType: node.type,
initialValue: { text: cleanedText, uid: "" },
extensionAPI,
includeDefaultNodes: true,
disableNodeTypeChange: true,
onSuccess: async ({ text, uid }) => {
if (!extensionAPI) return;
try {
const {
h,
w,
imageUrl: nodeImageUrl,
} = await calcCanvasNodeSizeAndImg({
nodeText: text,
extensionAPI,
nodeType: node.type,
uid,
});
editor.createShapes([
{
type: DISCOURSE_NODE_SHAPE_TYPE,
id: createShapeId(),
props: {
uid,
title: text,
h,
w,
imageUrl: nodeImageUrl,
fontFamily: "sans",
size: "s",
nodeTypeId: node.type,
},
x,
y,
},
]);
editor.deleteShapes([shape.id]);
} catch (error) {
renderToast({
id: `discourse-node-convert-error-${Date.now()}`,
intent: "danger",
content: (
<span>Error converting block: {String(error)}</span>
),
});
}
},
onClose: () => {},
});
void openBlockInSidebar(shape.props.uid);
}}
onPointerDown={(e) => e.stopPropagation()}
title={`Convert to ${matchedNodeForConversion.node.text}`}
>
<span
className="opacity-70"
style={{ color: textColor, fontSize: "11px" }}
title="Open in sidebar (Shift+Click)"
/>

{/* Add Tag to Block Button */}
{extensionAPI && showAddTagButton && (
<NodeMenu

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Mount the tag menu only when a card opens it

On canvases with many live block cards, this mounts a separate NodeMenu for every card even while all menus are closed. Each instance reads and parses all discourse nodes and, because it has a trigger, registers its own keydown and keyup listeners on document; consequently initialization and every keyboard event scale with the number of mounted cards. Lazily mounting the menu after the trigger is activated, or sharing one menu among cards, avoids that canvas-wide overhead.

Useful? React with 👍 / 👎.

key={addTagMenuKey}
blockUid={shape.props.uid}
extensionAPI={extensionAPI}
onClose={() => setAddTagMenuKey((k) => k + 1)}
onTagAdded={handleTagAdded}
Comment on lines +673 to +678

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Add unit coverage for the canvas tag flow

Add meaningful unit tests covering tag selection, the asynchronous block update, and the subsequent shape title/size refresh; this commit introduces that behavior without any tests, so regressions such as a rejected update or stale shape state will not be detected.

AGENTS.md reference: AGENTS.md:L82-L85

Useful? React with 👍 / 👎.

trigger={
<Button
minimal
small
icon={
<span className="opacity-50" style={{ color: textColor }}>
#
</span>
}
onPointerDown={(e) => e.stopPropagation()}
title="Add tag"
>
<span
className="opacity-70"
style={{ color: textColor, fontSize: "11px" }}
>
Add tag
</span>
</Button>
}
/>
)}

{/* Convert to Node Type Button */}
{matchedNodeForConversion && (
<Button
minimal
small
icon={
<Icon icon="plus" color={textColor} className="opacity-50" />
}
onClick={(e) => {
e.stopPropagation();
const { node, blockText } = matchedNodeForConversion;
const tag = node.tag;
if (!tag) return;
const cleanTag = getCleanTagText(tag);
const escapedCleanTag = escapeRegExp(cleanTag);
// Strip the tag from block text (same pattern as detection above)
const cleanedText = blockText
.replace(
new RegExp(`#\\[\\[${escapedCleanTag}\\]\\]`, "i"),
"",
)
.replace(new RegExp(`#${escapedCleanTag}`, "i"), "")
.trim();
const { x, y } = shape;
renderModifyNodeDialog({
mode: "create",
nodeType: node.type,
initialValue: { text: cleanedText, uid: "" },
extensionAPI,
includeDefaultNodes: true,
disableNodeTypeChange: true,
onSuccess: async ({ text, uid }) => {
if (!extensionAPI) return;
try {
const {
h,
w,
imageUrl: nodeImageUrl,
} = await calcCanvasNodeSizeAndImg({
nodeText: text,
extensionAPI,
nodeType: node.type,
uid,
});
editor.createShapes([
{
type: DISCOURSE_NODE_SHAPE_TYPE,
id: createShapeId(),
props: {
uid,
title: text,
h,
w,
imageUrl: nodeImageUrl,
fontFamily: "sans",
size: "s",
nodeTypeId: node.type,
},
x,
y,
},
]);
editor.deleteShapes([shape.id]);
} catch (error) {
renderToast({
id: `discourse-node-convert-error-${Date.now()}`,
intent: "danger",
content: (
<span>Error converting block: {String(error)}</span>
),
});
}
},
onClose: () => {},
});
}}
onPointerDown={(e) => e.stopPropagation()}
title={`Convert to ${matchedNodeForConversion.node.text}`}
>
Convert to {matchedNodeForConversion.node.text}
</span>
</Button>
)}
<span
className="opacity-70"
style={{ color: textColor, fontSize: "11px" }}
>
Convert to {matchedNodeForConversion.node.text}
</span>
</Button>
)}
</div>

{shape.props.imageUrl && isKeyImage === "true" ? (
<div className="mt-2 flex min-h-0 w-full flex-1 items-center justify-center overflow-hidden">
Expand Down