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
249 changes: 214 additions & 35 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 @@ -389,6 +392,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 +426,18 @@ const ClipboardPageSection = ({
page,
onRemove,
showNodesOnCanvas,
searchQuery,
sortDirection,
selectedNodeType,
onNodeTypesChange,
}: {
page: ClipboardPage;
onRemove: (uid: string) => void;
showNodesOnCanvas: boolean;
searchQuery: string;
sortDirection: "asc" | "desc";
selectedNodeType: string;
onNodeTypesChange: (pageUid: string, types: string[]) => void;
}) => {
const [isOpen, setIsOpen] = useState(true);
const [discourseNodes, setDiscourseNodes] = useState<
Expand Down Expand Up @@ -534,26 +546,58 @@ 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) =>
sortDirection === "asc"
? a.text.localeCompare(b.text)
: b.text.localeCompare(a.text),
),
[
groupedNodes,
showNodesOnCanvas,
searchQuery,
selectedNodeType,
sortDirection,
],
);

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 +993,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 +1140,41 @@ export const ClipboardPanel = () => {
} = useClipboard();
const [isModalOpen, setIsModalOpen] = useState(false);
const [isCollapsed, setIsCollapsed] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [isSearchExpanded, setIsSearchExpanded] = useState(false);
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
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]);

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

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

if (!isOpen) return null;

Expand Down Expand Up @@ -1119,7 +1203,7 @@ export const ClipboardPanel = () => {
</h2>
<div className="flex-shrink-0">
<Button
icon={<Icon icon="minus" />}
icon={<Icon icon={isCollapsed ? "chevron-down" : "minus"} />}
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

unrelated to this ticket but a quick UI fix

onClick={() => setIsCollapsed(!isCollapsed)}
minimal
small
Expand All @@ -1138,35 +1222,126 @@ export const ClipboardPanel = () => {
</div>
{!isCollapsed && (
<>
<div
className="flex items-center justify-end px-2 py-1"
style={{ borderTop: "1px solid hsl(0, 0%, 91%)" }}
>
<Popover
position={Position.BOTTOM_RIGHT}
content={
<div
className="p-3"
onPointerDown={(e) => e.stopPropagation()}
style={{ pointerEvents: "all" }}
>
<Switch
checked={showNodesOnCanvas}
alignIndicator="right"
className="m-0 w-full"
label="Show nodes on canvas"
onChange={(e) =>
setShowNodesOnCanvas(
(e.target as HTMLInputElement).checked,
)
}
{isSearchExpanded ? (
<div
className="px-2 py-1"
style={{ borderTop: "1px solid hsl(0, 0%, 91%)" }}
>
<InputGroup
autoFocus
leftIcon="search"
placeholder="Find page"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onBlur={() => {
if (!searchQuery) setIsSearchExpanded(false);
}}
rightElement={
<Button
minimal
small
icon="cross"
onClick={() => {
setSearchQuery("");
setIsSearchExpanded(false);
}}
/>
</div>
}
}
/>
</div>
) : (
<div
className="flex items-center gap-1 px-2 py-1"
style={{ borderTop: "1px solid hsl(0, 0%, 91%)" }}
>
<Button minimal small icon="menu" title="Clipboard options" />
</Popover>
</div>
<Button
minimal
small
icon="search"
onClick={() => setIsSearchExpanded(true)}
/>
<Button
minimal
small
icon={
sortDirection === "asc"
? "sort-alphabetical"
: "sort-alphabetical-desc"
}
title={
sortDirection === "asc"
? "Sorted A→Z (click for Z→A)"
: "Sorted Z→A (click for A→Z)"
}
onClick={() =>
setSortDirection((d) => (d === "asc" ? "desc" : "asc"))
}
/>
<Popover
position={Position.BOTTOM}
content={
<Menu>
{availableNodeTypes.map((type) => (
<MenuItem
key={type}
text={type}
active={selectedNodeType === type}
onClick={() => setSelectedNodeType(type)}
/>
))}
</Menu>
}
>
<Button
minimal
small
rightIcon="caret-down"
text={selectedNodeType}
/>
</Popover>
{hasActiveFilters && (
<Button
minimal
small
icon="filter-remove"
onClick={() => {
setSearchQuery("");
setSelectedNodeType("All");
}}
title="Clear filters"
/>
)}
<Popover
position={Position.BOTTOM_RIGHT}
content={
<div
className="p-3"
onPointerDown={(e) => e.stopPropagation()}
style={{ pointerEvents: "all" }}
>
<Switch
checked={showNodesOnCanvas}
alignIndicator="right"
className="m-0 w-full"
label="Show nodes on canvas"
onChange={(e) =>
setShowNodesOnCanvas(
(e.target as HTMLInputElement).checked,
)
}
/>
</div>
}
>
<Button
minimal
small
icon="settings"
title="Clipboard options"
/>
</Popover>
</div>
)}
<div className="max-h-96 overflow-y-auto px-4 pb-4">
{pages.length === 0 ? (
<NonIdealState
Expand All @@ -1188,6 +1363,10 @@ export const ClipboardPanel = () => {
page={page}
onRemove={removePage}
showNodesOnCanvas={showNodesOnCanvas}
searchQuery={searchQuery}
sortDirection={sortDirection}
selectedNodeType={selectedNodeType}
onNodeTypesChange={handleNodeTypesChange}
/>
))}
</div>
Expand Down
Loading