Skip to content

Commit 84db740

Browse files
authored
ENG-1234 Show and hide nodes in the clipboard that are already on canvas (#787)
1 parent 21d2edb commit 84db740

1 file changed

Lines changed: 106 additions & 20 deletions

File tree

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

Lines changed: 106 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,11 @@ import {
1616
Icon,
1717
Intent,
1818
NonIdealState,
19+
Popover,
20+
Position,
1921
Spinner,
2022
SpinnerSize,
23+
Switch,
2124
Tag,
2225
} from "@blueprintjs/core";
2326
import getCurrentUserUid from "roamjs-components/queries/getCurrentUserUid";
@@ -67,17 +70,20 @@ export type ClipboardPage = {
6770
type ClipboardContextValue = {
6871
isOpen: boolean;
6972
pages: ClipboardPage[];
73+
showNodesOnCanvas: boolean;
7074
openClipboard: () => void;
7175
closeClipboard: () => void;
7276
toggleClipboard: () => void;
7377
addPage: (page: ClipboardPage) => void;
7478
removePage: (uid: string) => void;
79+
setShowNodesOnCanvas: (show: boolean) => void;
7580
};
7681

7782
// eslint-disable-next-line @typescript-eslint/naming-convention
7883
const ClipboardContext = createContext<ClipboardContextValue | null>(null);
7984

8085
const CLIPBOARD_PROP_KEY = "pages";
86+
const CLIPBOARD_SHOW_NODES_ON_CANVAS_PROP_KEY = "showNodesOnCanvas";
8187

8288
const getOrCreateClipboardBlock = async (
8389
canvasPageTitle: string,
@@ -130,6 +136,7 @@ export const ClipboardProvider = ({
130136
}) => {
131137
const [isOpen, setIsOpen] = useState(false);
132138
const [pages, setPages] = useState<ClipboardPage[]>([]);
139+
const [showNodesOnCanvas, setShowNodesOnCanvas] = useState(true);
133140
const [clipboardBlockUid, setClipboardBlockUid] = useState<string | null>(
134141
null,
135142
);
@@ -166,6 +173,12 @@ export const ClipboardProvider = ({
166173
) {
167174
setPages(storedPages as ClipboardPage[]);
168175
}
176+
177+
const storedShowNodesOnCanvas =
178+
props[CLIPBOARD_SHOW_NODES_ON_CANVAS_PROP_KEY];
179+
if (typeof storedShowNodesOnCanvas === "boolean") {
180+
setShowNodesOnCanvas(storedShowNodesOnCanvas);
181+
}
169182
} catch (error) {
170183
internalError({
171184
error,
@@ -186,15 +199,20 @@ export const ClipboardProvider = ({
186199
try {
187200
setBlockProps(clipboardBlockUid, {
188201
[CLIPBOARD_PROP_KEY]: pages,
202+
[CLIPBOARD_SHOW_NODES_ON_CANVAS_PROP_KEY]: showNodesOnCanvas,
189203
});
190204
} catch (error) {
191205
internalError({
192206
error,
193207
type: "Canvas Clipboard: Failed to persist state",
194-
context: { clipboardBlockUid, pageCount: pages.length },
208+
context: {
209+
clipboardBlockUid,
210+
pageCount: pages.length,
211+
showNodesOnCanvas,
212+
},
195213
});
196214
}
197-
}, [pages, clipboardBlockUid, isInitialized]);
215+
}, [pages, clipboardBlockUid, isInitialized, showNodesOnCanvas]);
198216

199217
const openClipboard = useCallback(() => setIsOpen(true), []);
200218
const closeClipboard = useCallback(() => setIsOpen(false), []);
@@ -216,19 +234,23 @@ export const ClipboardProvider = ({
216234
() => ({
217235
isOpen,
218236
pages,
237+
showNodesOnCanvas,
219238
openClipboard,
220239
closeClipboard,
221240
toggleClipboard,
222241
addPage,
223242
removePage,
243+
setShowNodesOnCanvas,
224244
}),
225245
[
226246
isOpen,
227247
pages,
248+
showNodesOnCanvas,
228249
addPage,
229250
closeClipboard,
230251
openClipboard,
231252
removePage,
253+
setShowNodesOnCanvas,
232254
toggleClipboard,
233255
],
234256
);
@@ -395,9 +417,11 @@ type DragState =
395417
const ClipboardPageSection = ({
396418
page,
397419
onRemove,
420+
showNodesOnCanvas,
398421
}: {
399422
page: ClipboardPage;
400423
onRemove: (uid: string) => void;
424+
showNodesOnCanvas: boolean;
401425
}) => {
402426
const [isOpen, setIsOpen] = useState(true);
403427
const [discourseNodes, setDiscourseNodes] = useState<
@@ -484,22 +508,28 @@ const ClipboardPageSection = ({
484508
};
485509
}, [editor.store]);
486510

487-
const findShapesByUid = useCallback(
488-
(uid: string): DiscourseNodeShape[] => {
489-
const allRecords = editor.store.allRecords();
490-
const shapes = allRecords.filter((record) => {
491-
if (record.typeName !== "shape") return false;
492-
const shape = record as DiscourseNodeShape;
493-
return shape.props?.uid === uid;
494-
}) as DiscourseNodeShape[];
495-
return shapes;
496-
},
497-
[editor.store],
498-
);
511+
const shapesByUid = useMemo(() => {
512+
void storeVersion;
513+
const groupedShapes = new Map<string, DiscourseNodeShape[]>();
514+
const allRecords = editor.store.allRecords();
515+
allRecords.forEach((record) => {
516+
if (record.typeName !== "shape") return;
517+
const shape = record as DiscourseNodeShape;
518+
const uid = shape.props?.uid;
519+
if (!uid) return;
520+
const currentShapes = groupedShapes.get(uid);
521+
if (currentShapes) {
522+
currentShapes.push(shape);
523+
} else {
524+
groupedShapes.set(uid, [shape]);
525+
}
526+
});
527+
return groupedShapes;
528+
}, [editor.store, storeVersion]);
499529

500530
const groupedNodes = useMemo(() => {
501531
const groups: NodeGroup[] = discourseNodes.map((node) => {
502-
const shapes = findShapesByUid(node.uid);
532+
const shapes = shapesByUid.get(node.uid) ?? [];
503533
return {
504534
uid: node.uid,
505535
text: node.text,
@@ -510,7 +540,15 @@ const ClipboardPageSection = ({
510540

511541
return groups.sort((a, b) => a.text.localeCompare(b.text));
512542
// eslint-disable-next-line react-hooks/exhaustive-deps
513-
}, [discourseNodes, findShapesByUid, storeVersion]);
543+
}, [discourseNodes, shapesByUid]);
544+
545+
const visibleGroupedNodes = useMemo(
546+
() =>
547+
groupedNodes.filter((group) =>
548+
showNodesOnCanvas ? true : group.shapes.length === 0,
549+
),
550+
[groupedNodes, showNodesOnCanvas],
551+
);
514552

515553
useEffect(() => {
516554
setOpenSections((prev) => {
@@ -586,6 +624,14 @@ const ClipboardPageSection = ({
586624
const handleDropNode = useCallback(
587625
async (node: { uid: string; text: string }, pagePoint: Vec) => {
588626
if (!extensionAPI) return;
627+
if (!showNodesOnCanvas) {
628+
const nodeExistsOnCanvas = editor.store.allRecords().some((record) => {
629+
if (record.typeName !== "shape") return false;
630+
const shape = record as DiscourseNodeShape;
631+
return shape.props?.uid === node.uid;
632+
});
633+
if (nodeExistsOnCanvas) return;
634+
}
589635

590636
const nodeType = findDiscourseNode({ uid: node.uid });
591637
if (!nodeType) {
@@ -624,7 +670,7 @@ const ClipboardPageSection = ({
624670
editor.createShape<DiscourseNodeShape>(shape);
625671
editor.setCurrentTool("select");
626672
},
627-
[editor, extensionAPI],
673+
[editor, extensionAPI, showNodesOnCanvas],
628674
);
629675

630676
// Drag and drop handlers
@@ -897,9 +943,14 @@ const ClipboardPageSection = ({
897943
<div className="rounded border border-dashed border-gray-200 p-2">
898944
No discourse nodes found on this page.
899945
</div>
946+
) : visibleGroupedNodes.length === 0 ? (
947+
<div className="rounded border border-dashed border-gray-200 p-2">
948+
All nodes from this page are already on canvas. Turn on &quot;Show
949+
nodes on canvas&quot; to view them.
950+
</div>
900951
) : (
901952
<div className="space-y-1">
902-
{groupedNodes.map((group) => {
953+
{visibleGroupedNodes.map((group) => {
903954
const nodeExistsInCanvas = group.shapes.length > 0;
904955
const isGroupOpen = openSections[group.uid] ?? false;
905956

@@ -1025,7 +1076,15 @@ const ClipboardPageSection = ({
10251076
};
10261077

10271078
export const ClipboardPanel = () => {
1028-
const { isOpen, pages, closeClipboard, addPage, removePage } = useClipboard();
1079+
const {
1080+
isOpen,
1081+
pages,
1082+
closeClipboard,
1083+
addPage,
1084+
removePage,
1085+
showNodesOnCanvas,
1086+
setShowNodesOnCanvas,
1087+
} = useClipboard();
10291088
const [isModalOpen, setIsModalOpen] = useState(false);
10301089
const [isCollapsed, setIsCollapsed] = useState(false);
10311090

@@ -1076,9 +1135,35 @@ export const ClipboardPanel = () => {
10761135
{!isCollapsed && (
10771136
<>
10781137
<div
1079-
className="max-h-96 overflow-y-auto p-4"
1138+
className="flex items-center justify-end px-2 py-1"
10801139
style={{ borderTop: "1px solid hsl(0, 0%, 91%)" }}
10811140
>
1141+
<Popover
1142+
position={Position.BOTTOM_RIGHT}
1143+
content={
1144+
<div
1145+
className="p-3"
1146+
onPointerDown={(e) => e.stopPropagation()}
1147+
style={{ pointerEvents: "all" }}
1148+
>
1149+
<Switch
1150+
checked={showNodesOnCanvas}
1151+
alignIndicator="right"
1152+
className="m-0 w-full"
1153+
label="Show nodes on canvas"
1154+
onChange={(e) =>
1155+
setShowNodesOnCanvas(
1156+
(e.target as HTMLInputElement).checked,
1157+
)
1158+
}
1159+
/>
1160+
</div>
1161+
}
1162+
>
1163+
<Button minimal small icon="menu" title="Clipboard options" />
1164+
</Popover>
1165+
</div>
1166+
<div className="max-h-96 overflow-y-auto px-4 pb-4">
10821167
{pages.length === 0 ? (
10831168
<NonIdealState
10841169
action={
@@ -1098,6 +1183,7 @@ export const ClipboardPanel = () => {
10981183
key={page.uid}
10991184
page={page}
11001185
onRemove={removePage}
1186+
showNodesOnCanvas={showNodesOnCanvas}
11011187
/>
11021188
))}
11031189
</div>

0 commit comments

Comments
 (0)