Skip to content
Open
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
166 changes: 157 additions & 9 deletions apps/roam/src/components/canvas/Clipboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ import {
Collapse,
Dialog,
Icon,
InputGroup,
Intent,
Menu,
MenuItem,
NonIdealState,
Popover,
Position,
Expand Down Expand Up @@ -53,9 +56,11 @@ import { openBlockInSidebar, createBlock } from "roamjs-components/writes";
import getPageTitleByPageUid from "roamjs-components/queries/getPageTitleByPageUid";
import getPageUidByPageTitle from "roamjs-components/queries/getPageUidByPageTitle";
import findDiscourseNode from "~/utils/findDiscourseNode";
import getDiscourseNodes from "~/utils/getDiscourseNodes";
import calcCanvasNodeSizeAndImg from "~/utils/calcCanvasNodeSizeAndImg";
import { useExtensionAPI } from "roamjs-components/components/ExtensionApiContext";
import { getDiscourseNodeColors } from "~/utils/getDiscourseNodeColors";
import { formatHexColor } from "~/components/settings/DiscourseNodeCanvasSettings";
import { MAX_WIDTH } from "./Tldraw";
import getBlockProps from "~/utils/getBlockProps";
import setBlockProps from "~/utils/setBlockProps";
Expand Down Expand Up @@ -389,6 +394,7 @@ const AddPageModal = ({ isOpen, onClose, onConfirm }: AddPageModalProps) => {
type NodeGroup = {
uid: string;
text: string;
type: string;
shapes: DiscourseNodeShape[];
isDuplicate: boolean;
};
Expand Down Expand Up @@ -422,10 +428,16 @@ const ClipboardPageSection = ({
page,
onRemove,
showNodesOnCanvas,
searchQuery,
selectedNodeType,
onNodeTypesChange,
}: {
page: ClipboardPage;
onRemove: (uid: string) => void;
showNodesOnCanvas: boolean;
searchQuery: string;
selectedNodeType: string;
onNodeTypesChange: (pageUid: string, types: string[]) => void;
}) => {
const [isOpen, setIsOpen] = useState(true);
const [discourseNodes, setDiscourseNodes] = useState<
Expand Down Expand Up @@ -534,26 +546,48 @@ const ClipboardPageSection = ({
const groupedNodes = useMemo(() => {
const groups: NodeGroup[] = discourseNodes.map((node) => {
const shapes = shapesByUid.get(node.uid) ?? [];
const discourseNode = findDiscourseNode({ uid: node.uid });
return {
uid: node.uid,
text: node.text,
type: discourseNode ? discourseNode.text : "Unknown",
shapes,
isDuplicate: shapes.length > 1,
};
});

return groups.sort((a, b) => a.text.localeCompare(b.text));
return groups;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [discourseNodes, shapesByUid]);

const visibleGroupedNodes = useMemo(
() =>
groupedNodes.filter((group) =>
showNodesOnCanvas ? true : group.shapes.length === 0,
),
[groupedNodes, showNodesOnCanvas],
groupedNodes
.filter((group) =>
showNodesOnCanvas ? true : group.shapes.length === 0,
)
.filter((group) =>
searchQuery
? group.text.toLowerCase().includes(searchQuery.toLowerCase())
: true,
)
.filter((group) =>
selectedNodeType && selectedNodeType !== "All"
? group.type === selectedNodeType
: true,
)
.sort((a, b) => a.text.localeCompare(b.text)),
[groupedNodes, showNodesOnCanvas, searchQuery, selectedNodeType],
);

useEffect(() => {
const candidateNodes = showNodesOnCanvas
? groupedNodes
: groupedNodes.filter((n) => n.shapes.length === 0);
const types = [...new Set(candidateNodes.map((n) => n.type))];
onNodeTypesChange(page.uid, types);
}, [groupedNodes, page.uid, onNodeTypesChange, showNodesOnCanvas]);

useEffect(() => {
setOpenSections((prev) => {
const next: Record<string, boolean> = {};
Expand Down Expand Up @@ -949,8 +983,13 @@ const ClipboardPageSection = ({
</div>
) : visibleGroupedNodes.length === 0 ? (
<div className="rounded border border-dashed border-gray-200 p-2">
All nodes from this page are already on canvas. Turn on &quot;Show
nodes on canvas&quot; to view them.
{searchQuery || selectedNodeType !== "All"
? showNodesOnCanvas
? "No nodes match the current filters."
: 'No nodes match the current filters, or matching nodes are already on canvas. Turn on "Show nodes on canvas" to view them.'
: showNodesOnCanvas
? "All nodes from this page are already on canvas."
: 'All nodes from this page are already on canvas. Turn on "Show nodes on canvas" to view them.'}
</div>
) : (
<div className="space-y-1">
Expand Down Expand Up @@ -1091,6 +1130,48 @@ export const ClipboardPanel = () => {
} = useClipboard();
const [isModalOpen, setIsModalOpen] = useState(false);
const [isCollapsed, setIsCollapsed] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [selectedNodeType, setSelectedNodeType] = useState("All");
const [nodeTypesByPage, setNodeTypesByPage] = useState<
Record<string, string[]>
>({});

const handleNodeTypesChange = useCallback(
(pageUid: string, types: string[]) => {
setNodeTypesByPage((prev) => ({ ...prev, [pageUid]: types }));
},
[],
);

const availableNodeTypes = useMemo(() => {
const pageUids = new Set(pages.map((p) => p.uid));
const allTypes = new Set(
Object.entries(nodeTypesByPage)
.filter(([uid]) => pageUids.has(uid))
.flatMap(([, types]) => types),
);
return ["All", ...Array.from(allTypes).sort()];
}, [nodeTypesByPage, pages]);

const nodeTypeColorMap = useMemo(() => {
return Object.fromEntries(
getDiscourseNodes().map((n) => [
n.text,
formatHexColor(n.canvasSettings?.color) || "#000000",
]),
);
}, []);

useEffect(() => {
if (
selectedNodeType !== "All" &&
!availableNodeTypes.includes(selectedNodeType)
) {
setSelectedNodeType("All");
}
}, [availableNodeTypes, selectedNodeType]);

const hasActiveFilters = !!searchQuery || selectedNodeType !== "All";

if (!isOpen) return null;

Expand Down Expand Up @@ -1139,9 +1220,73 @@ export const ClipboardPanel = () => {
{!isCollapsed && (
<>
<div
className="flex items-center justify-end px-2 py-1"
className="flex items-center gap-1 px-2 py-1"
style={{ borderTop: "1px solid hsl(0, 0%, 91%)" }}
>
<InputGroup
small
leftIcon="search"
placeholder="Find page"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="flex-1"
rightElement={
searchQuery ? (
<Button
minimal
small
icon="cross"
onClick={() => setSearchQuery("")}
/>
) : undefined
}
/>
<Popover
position={Position.BOTTOM}
content={
<Menu>
{availableNodeTypes.map((type) => (
<MenuItem
key={type}
active={selectedNodeType === type}
onClick={() => setSelectedNodeType(type)}
text={
<span className="flex items-center gap-2">
{type !== "All" && (
<span
className="inline-block h-3 w-3 shrink-0 rounded-full"
style={{
backgroundColor:
nodeTypeColorMap[type] || "#000000",
}}
/>
)}
{type}
</span>
}
/>
))}
</Menu>
Comment on lines +1247 to +1269
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.

🟡 Filter Popover content missing onPointerDown stopPropagation, inconsistent with sibling Popover

The new node-type filter Popover at line 1244 renders its <Menu> content directly without wrapping it in a <div> with onPointerDown={(e) => e.stopPropagation()} and style={{ pointerEvents: "all" }}. The adjacent settings Popover at apps/roam/src/components/canvas/Clipboard.tsx:1290-1310 explicitly wraps its content with these event protections, with a comment at apps/roam/src/components/canvas/Clipboard.tsx:342-344 explaining this prevents TLDraw from hijacking click and pointer events. Since Blueprint Popovers render via Portal (at the document body), TLDraw's document-level event listeners can still intercept pointer events from the menu, potentially causing the dropdown menu items to not register clicks or triggering unintended TLDraw canvas interactions when the user tries to select a node type filter.

Suggested change
<Menu>
{availableNodeTypes.map((type) => (
<MenuItem
key={type}
active={selectedNodeType === type}
onClick={() => setSelectedNodeType(type)}
text={
<span className="flex items-center gap-2">
{type !== "All" && (
<span
className="inline-block h-3 w-3 shrink-0 rounded-full"
style={{
backgroundColor:
nodeTypeColorMap[type] || "#000000",
}}
/>
)}
{type}
</span>
}
/>
))}
</Menu>
<Menu
onPointerDown={(e: React.MouseEvent) => e.stopPropagation()}
style={{ pointerEvents: "all" }}
>
{availableNodeTypes.map((type) => (
<MenuItem
key={type}
active={selectedNodeType === type}
onClick={() => setSelectedNodeType(type)}
text={
<span className="flex items-center gap-2">
{type !== "All" && (
<span
className="inline-block h-3 w-3 shrink-0 rounded-full"
style={{
backgroundColor:
nodeTypeColorMap[type] || "#000000",
}}
/>
)}
{type}
</span>
}
/>
))}
</Menu>
Open in Devin Review

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

}
>
<Button
minimal
small
rightIcon="caret-down"
text={selectedNodeType}
/>
</Popover>
<Button
minimal
small
icon="filter-remove"
disabled={!hasActiveFilters}
onClick={() => {
setSearchQuery("");
setSelectedNodeType("All");
}}
title="Clear filters"
/>
<Popover
position={Position.BOTTOM_RIGHT}
content={
Expand All @@ -1164,7 +1309,7 @@ export const ClipboardPanel = () => {
</div>
}
>
<Button minimal small icon="menu" title="Clipboard options" />
<Button minimal small icon="settings" title="Clipboard options" />
</Popover>
</div>
<div className="max-h-96 overflow-y-auto px-4 pb-4">
Expand All @@ -1188,6 +1333,9 @@ export const ClipboardPanel = () => {
page={page}
onRemove={removePage}
showNodesOnCanvas={showNodesOnCanvas}
searchQuery={searchQuery}
selectedNodeType={selectedNodeType}
onNodeTypesChange={handleNodeTypesChange}
/>
))}
</div>
Expand Down
Loading