Skip to content

Commit 900eba0

Browse files
[ENG-1271] Implement filter, search, sort designs in clipboard (#900)
* add filter search * updated filter * Apply suggestion from @graphite-app[bot] Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com> * format * update filter * address PR comment * add comment --------- Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>
1 parent 6bd3dbc commit 900eba0

2 files changed

Lines changed: 181 additions & 14 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,4 @@ local/*
4141
apps/tldraw-sync-worker/tsconfig.tsbuildinfo
4242
apps/tldraw-sync-worker/.wrangler/*
4343
.claude/*
44+
CLAUDE.md

apps/roam/src/components/canvas/Clipboard.tsx

Lines changed: 180 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@ import {
1414
Collapse,
1515
Dialog,
1616
Icon,
17+
InputGroup,
1718
Intent,
19+
Menu,
20+
MenuItem,
1821
NonIdealState,
1922
Popover,
2023
Position,
@@ -43,7 +46,6 @@ import AutocompleteInput from "roamjs-components/components/AutocompleteInput";
4346
import { Result } from "roamjs-components/types/query-builder";
4447
import fuzzy from "fuzzy";
4548
import getAllReferencesOnPage from "~/utils/getAllReferencesOnPage";
46-
import isDiscourseNode from "~/utils/isDiscourseNode";
4749
import {
4850
DiscourseNodeShape,
4951
DEFAULT_STYLE_PROPS,
@@ -53,9 +55,11 @@ import { openBlockInSidebar, createBlock } from "roamjs-components/writes";
5355
import getPageTitleByPageUid from "roamjs-components/queries/getPageTitleByPageUid";
5456
import getPageUidByPageTitle from "roamjs-components/queries/getPageUidByPageTitle";
5557
import findDiscourseNode from "~/utils/findDiscourseNode";
58+
import getDiscourseNodes from "~/utils/getDiscourseNodes";
5659
import calcCanvasNodeSizeAndImg from "~/utils/calcCanvasNodeSizeAndImg";
5760
import { useExtensionAPI } from "roamjs-components/components/ExtensionApiContext";
5861
import { getDiscourseNodeColors } from "~/utils/getDiscourseNodeColors";
62+
import { formatHexColor } from "~/components/settings/DiscourseNodeCanvasSettings";
5963
import { MAX_WIDTH } from "./Tldraw";
6064
import getBlockProps from "~/utils/getBlockProps";
6165
import setBlockProps from "~/utils/setBlockProps";
@@ -389,6 +393,7 @@ const AddPageModal = ({ isOpen, onClose, onConfirm }: AddPageModalProps) => {
389393
type NodeGroup = {
390394
uid: string;
391395
text: string;
396+
type: string;
392397
shapes: DiscourseNodeShape[];
393398
isDuplicate: boolean;
394399
};
@@ -422,14 +427,20 @@ const ClipboardPageSection = ({
422427
page,
423428
onRemove,
424429
showNodesOnCanvas,
430+
searchQuery,
431+
selectedNodeType,
432+
onNodeTypesChange,
425433
}: {
426434
page: ClipboardPage;
427435
onRemove: (uid: string) => void;
428436
showNodesOnCanvas: boolean;
437+
searchQuery: string;
438+
selectedNodeType: string;
439+
onNodeTypesChange: (pageUid: string, types: string[]) => void;
429440
}) => {
430441
const [isOpen, setIsOpen] = useState(true);
431442
const [discourseNodes, setDiscourseNodes] = useState<
432-
Array<{ uid: string; text: string }>
443+
Array<{ uid: string; text: string; type: string }>
433444
>([]);
434445
const [isLoading, setIsLoading] = useState(false);
435446
const [openSections, setOpenSections] = useState<Record<string, boolean>>({});
@@ -453,9 +464,17 @@ const ClipboardPageSection = ({
453464
setIsLoading(true);
454465
try {
455466
const referencedPages = await getAllReferencesOnPage(page.text);
456-
const nodes = referencedPages.filter((refPage) =>
457-
isDiscourseNode(refPage.uid),
458-
);
467+
const nodes = referencedPages.flatMap((refPage) => {
468+
const discourseNode = findDiscourseNode({ uid: refPage.uid });
469+
if (!discourseNode || discourseNode.backedBy === "default") return [];
470+
return [
471+
{
472+
uid: refPage.uid,
473+
text: refPage.text,
474+
type: discourseNode.text,
475+
},
476+
];
477+
});
459478
setDiscourseNodes(nodes);
460479
} catch (error) {
461480
internalError({
@@ -537,23 +556,48 @@ const ClipboardPageSection = ({
537556
return {
538557
uid: node.uid,
539558
text: node.text,
559+
type: node.type,
540560
shapes,
541561
isDuplicate: shapes.length > 1,
542562
};
543563
});
544564

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

549569
const visibleGroupedNodes = useMemo(
550570
() =>
551-
groupedNodes.filter((group) =>
552-
showNodesOnCanvas ? true : group.shapes.length === 0,
553-
),
554-
[groupedNodes, showNodesOnCanvas],
571+
groupedNodes
572+
.filter((group) =>
573+
showNodesOnCanvas ? true : group.shapes.length === 0,
574+
)
575+
.filter((group) =>
576+
searchQuery
577+
? group.text.toLowerCase().includes(searchQuery.toLowerCase())
578+
: true,
579+
)
580+
.filter((group) =>
581+
selectedNodeType && selectedNodeType !== "All"
582+
? group.type === selectedNodeType
583+
: true,
584+
)
585+
.sort((a, b) => a.text.localeCompare(b.text)),
586+
[groupedNodes, showNodesOnCanvas, searchQuery, selectedNodeType],
555587
);
556588

589+
// Publish this page's distinct node types up to ClipboardPanel so it can
590+
// build a unified filter dropdown across all open pages. When
591+
// `showNodesOnCanvas` is off, only consider nodes not yet on the canvas so
592+
// the filter options match what the user can actually act on.
593+
useEffect(() => {
594+
const candidateNodes = showNodesOnCanvas
595+
? groupedNodes
596+
: groupedNodes.filter((n) => n.shapes.length === 0);
597+
const types = [...new Set(candidateNodes.map((n) => n.type))];
598+
onNodeTypesChange(page.uid, types);
599+
}, [groupedNodes, page.uid, onNodeTypesChange, showNodesOnCanvas]);
600+
557601
useEffect(() => {
558602
setOpenSections((prev) => {
559603
const next: Record<string, boolean> = {};
@@ -948,8 +992,13 @@ const ClipboardPageSection = ({
948992
</div>
949993
) : visibleGroupedNodes.length === 0 ? (
950994
<div className="rounded border border-dashed border-gray-200 p-2">
951-
All nodes from this page are already on canvas. Turn on &quot;Show
952-
nodes on canvas&quot; to view them.
995+
{searchQuery || selectedNodeType !== "All"
996+
? showNodesOnCanvas
997+
? "No nodes match the current filters."
998+
: 'No nodes match the current filters, or matching nodes are already on canvas. Turn on "Show nodes on canvas" to view them.'
999+
: showNodesOnCanvas
1000+
? "All nodes from this page are already on canvas."
1001+
: 'All nodes from this page are already on canvas. Turn on "Show nodes on canvas" to view them.'}
9531002
</div>
9541003
) : (
9551004
<div className="space-y-1">
@@ -1090,6 +1139,51 @@ export const ClipboardPanel = () => {
10901139
} = useClipboard();
10911140
const [isModalOpen, setIsModalOpen] = useState(false);
10921141
const [isCollapsed, setIsCollapsed] = useState(false);
1142+
const [searchQuery, setSearchQuery] = useState("");
1143+
const [selectedNodeType, setSelectedNodeType] = useState("All");
1144+
const [nodeTypesByPage, setNodeTypesByPage] = useState<
1145+
Record<string, string[]>
1146+
>({});
1147+
1148+
const handleNodeTypesChange = useCallback(
1149+
(pageUid: string, types: string[]) => {
1150+
setNodeTypesByPage((prev) => ({ ...prev, [pageUid]: types }));
1151+
},
1152+
[],
1153+
);
1154+
1155+
const availableNodeTypes = useMemo(() => {
1156+
const pageUids = new Set(pages.map((p) => p.uid));
1157+
const allTypes = new Set(
1158+
Object.entries(nodeTypesByPage)
1159+
.filter(([uid]) => pageUids.has(uid))
1160+
.flatMap(([, types]) => types),
1161+
);
1162+
return ["All", ...Array.from(allTypes).sort()];
1163+
}, [nodeTypesByPage, pages]);
1164+
1165+
const nodeTypeColorMap = useMemo(() => {
1166+
return Object.fromEntries(
1167+
getDiscourseNodes().map((n) => [
1168+
n.text,
1169+
formatHexColor(n.canvasSettings?.color) || "#000000",
1170+
]),
1171+
);
1172+
}, []);
1173+
1174+
// Reset the filter to "All" when the currently selected type disappears
1175+
// from `availableNodeTypes` (e.g. the page containing it was removed),
1176+
// otherwise the dropdown would keep a stale value that filters everything out.
1177+
useEffect(() => {
1178+
if (
1179+
selectedNodeType !== "All" &&
1180+
!availableNodeTypes.includes(selectedNodeType)
1181+
) {
1182+
setSelectedNodeType("All");
1183+
}
1184+
}, [availableNodeTypes, selectedNodeType]);
1185+
1186+
const hasActiveFilters = !!searchQuery || selectedNodeType !== "All";
10931187

10941188
if (!isOpen) return null;
10951189

@@ -1138,9 +1232,78 @@ export const ClipboardPanel = () => {
11381232
{!isCollapsed && (
11391233
<>
11401234
<div
1141-
className="flex items-center justify-end px-2 py-1"
1235+
className="flex items-center gap-1 px-2 py-1"
11421236
style={{ borderTop: "1px solid hsl(0, 0%, 91%)" }}
11431237
>
1238+
<InputGroup
1239+
small
1240+
leftIcon="search"
1241+
placeholder="Find page"
1242+
value={searchQuery}
1243+
onChange={(e) => setSearchQuery(e.target.value)}
1244+
className="flex-1"
1245+
rightElement={
1246+
searchQuery ? (
1247+
<Button
1248+
minimal
1249+
small
1250+
icon="cross"
1251+
onClick={() => setSearchQuery("")}
1252+
/>
1253+
) : undefined
1254+
}
1255+
/>
1256+
<Popover
1257+
position={Position.BOTTOM}
1258+
content={
1259+
<div
1260+
onPointerDown={(e) => e.stopPropagation()}
1261+
style={{ pointerEvents: "all" }}
1262+
>
1263+
<Menu>
1264+
{availableNodeTypes.map((type) => (
1265+
<MenuItem
1266+
key={type}
1267+
active={selectedNodeType === type}
1268+
onClick={() => setSelectedNodeType(type)}
1269+
text={
1270+
<span className="flex items-center gap-2">
1271+
{type !== "All" && (
1272+
<span
1273+
className="inline-block h-3 w-3 shrink-0 rounded-full"
1274+
style={{
1275+
backgroundColor:
1276+
nodeTypeColorMap[type] || "#000000",
1277+
}}
1278+
/>
1279+
)}
1280+
{type}
1281+
</span>
1282+
}
1283+
/>
1284+
))}
1285+
</Menu>
1286+
</div>
1287+
}
1288+
>
1289+
<Button
1290+
minimal
1291+
small
1292+
rightIcon="caret-down"
1293+
text={selectedNodeType}
1294+
/>
1295+
</Popover>
1296+
<Button
1297+
minimal
1298+
small
1299+
icon="filter-remove"
1300+
disabled={!hasActiveFilters}
1301+
onClick={() => {
1302+
setSearchQuery("");
1303+
setSelectedNodeType("All");
1304+
}}
1305+
title="Clear filters"
1306+
/>
11441307
<Popover
11451308
position={Position.BOTTOM_RIGHT}
11461309
content={
@@ -1163,7 +1326,7 @@ export const ClipboardPanel = () => {
11631326
</div>
11641327
}
11651328
>
1166-
<Button minimal small icon="menu" title="Clipboard options" />
1329+
<Button minimal small icon="settings" title="Clipboard options" />
11671330
</Popover>
11681331
</div>
11691332
<div className="max-h-96 overflow-y-auto px-4 pb-4">
@@ -1187,6 +1350,9 @@ export const ClipboardPanel = () => {
11871350
page={page}
11881351
onRemove={removePage}
11891352
showNodesOnCanvas={showNodesOnCanvas}
1353+
searchQuery={searchQuery}
1354+
selectedNodeType={selectedNodeType}
1355+
onNodeTypesChange={handleNodeTypesChange}
11901356
/>
11911357
))}
11921358
</div>

0 commit comments

Comments
 (0)