diff --git a/apps/roam/src/components/canvas/Clipboard.tsx b/apps/roam/src/components/canvas/Clipboard.tsx index d5254313f..2d72280fd 100644 --- a/apps/roam/src/components/canvas/Clipboard.tsx +++ b/apps/roam/src/components/canvas/Clipboard.tsx @@ -14,7 +14,10 @@ import { Collapse, Dialog, Icon, + InputGroup, Intent, + Menu, + MenuItem, NonIdealState, Popover, Position, @@ -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"; @@ -389,6 +394,7 @@ const AddPageModal = ({ isOpen, onClose, onConfirm }: AddPageModalProps) => { type NodeGroup = { uid: string; text: string; + type: string; shapes: DiscourseNodeShape[]; isDuplicate: boolean; }; @@ -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< @@ -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 = {}; @@ -949,8 +983,13 @@ const ClipboardPageSection = ({ ) : visibleGroupedNodes.length === 0 ? (
- All nodes from this page are already on canvas. Turn on "Show - nodes on canvas" 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.'}
) : (
@@ -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 + >({}); + + 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; @@ -1139,9 +1220,73 @@ export const ClipboardPanel = () => { {!isCollapsed && ( <>
+ setSearchQuery(e.target.value)} + className="flex-1" + rightElement={ + searchQuery ? ( +
} > -
@@ -1188,6 +1333,9 @@ export const ClipboardPanel = () => { page={page} onRemove={removePage} showNodesOnCanvas={showNodesOnCanvas} + searchQuery={searchQuery} + selectedNodeType={selectedNodeType} + onNodeTypesChange={handleNodeTypesChange} /> ))}