diff --git a/src/components/CanvasDrawer.tsx b/src/components/CanvasDrawer.tsx new file mode 100644 index 00000000..ffd634cc --- /dev/null +++ b/src/components/CanvasDrawer.tsx @@ -0,0 +1,162 @@ +import React, { useEffect, useMemo, useState } from "react"; +import ResizableDrawer from "./ResizableDrawer"; +import renderOverlay from "roamjs-components/util/renderOverlay"; +import { DiscourseNodeShape } from "./TldrawCanvas"; +import { Button, Collapse, Checkbox } from "@blueprintjs/core"; +import getPageTitleByPageUid from "roamjs-components/queries/getPageTitleByPageUid"; +import MenuItemSelect from "roamjs-components/components/MenuItemSelect"; +import getDiscourseNodes from "../utils/getDiscourseNodes"; + +export type GroupedShapes = Record; + +type Props = { + groupedShapes: GroupedShapes; + pageUid: string; +}; + +const CanvasDrawerContent = ({ groupedShapes, pageUid }: Props) => { + const [openSections, setOpenSections] = useState>({}); + const [showDuplicates, setShowDuplicates] = useState(false); + const [filterType, setFilterType] = useState("All"); + const [filteredShapes, setFilteredShapes] = useState({}); + + const pageTitle = useMemo(() => getPageTitleByPageUid(pageUid), []); + const noResults = Object.keys(groupedShapes).length === 0; + const typeToTitleMap = useMemo(() => { + const nodes = getDiscourseNodes(); + const map: { [key: string]: string } = {}; + nodes.forEach((node) => { + map[node.type] = node.text; + }); + return map; + }, []); + const shapeTypes = useMemo(() => { + const allTypes = new Set(["All"]); + Object.values(groupedShapes).forEach((shapes) => + shapes.forEach((shape) => + allTypes.add(typeToTitleMap[shape.type] || shape.type) + ) + ); + return Array.from(allTypes); + }, [groupedShapes, typeToTitleMap]); + const hasDuplicates = useMemo(() => { + return Object.values(groupedShapes).some((shapes) => shapes.length > 1); + }, [groupedShapes]); + + useEffect(() => { + const filtered = Object.entries(groupedShapes).reduce( + (acc, [uid, shapes]) => { + const filteredShapes = shapes.filter( + (shape) => + filterType === "All" || typeToTitleMap[shape.type] === filterType + ); + if ( + filteredShapes.length > 0 && + (!showDuplicates || filteredShapes.length > 1) + ) { + acc[uid] = filteredShapes; + } + return acc; + }, + {} + ); + setFilteredShapes(filtered); + }, [groupedShapes, showDuplicates, filterType, typeToTitleMap]); + + const toggleCollapse = (uid: string) => { + setOpenSections((prevState) => ({ + ...prevState, + [uid]: !prevState[uid], + })); + }; + const moveCameraToShape = (shapeId: string) => { + document.dispatchEvent( + new CustomEvent("roamjs:query-builder:action", { + detail: { + action: "move-camera-to-shape", + shapeId, + }, + }) + ); + }; + + return ( +
+
+ setFilterType(type)} + activeItem={filterType} + items={shapeTypes} + /> + {hasDuplicates && ( + setShowDuplicates(!showDuplicates)} + /> + )} +
+ {noResults ? ( +
No nodes found for {pageTitle}
+ ) : ( + Object.entries(filteredShapes).map(([uid, shapes]) => { + const title = shapes[0].props.title; + const isExpandable = shapes.length > 1; + return ( +
+ + +
+ {shapes.map((shape) => ( + + ))} +
+
+
+ ); + }) + )} +
+ ); +}; + +const CanvasDrawer = ({ + onClose, + ...props +}: { onClose: () => void } & Props) => ( + + + +); + +export const render = (props: Props) => + renderOverlay({ Overlay: CanvasDrawer, props }); + +export default CanvasDrawer; diff --git a/src/components/TldrawCanvas.tsx b/src/components/TldrawCanvas.tsx index b8c52e30..22e34f64 100644 --- a/src/components/TldrawCanvas.tsx +++ b/src/components/TldrawCanvas.tsx @@ -1572,16 +1572,18 @@ const TldrawCanvas = ({ title }: Props) => { window.roamAlphaAPI.data.removePullWatch(...pullWatchProps); }; }, [initialState, store]); + + // Handle actions (roamjs:query-builder:action) useEffect(() => { - const actionListener = (( - e: CustomEvent<{ - action: string; - uid: string; - val: string; - onRefresh: () => void; - }> - ) => { - if (!/canvas/i.test(e.detail.action)) return; + const handleCreateShapeAction = ({ + uid, + val, + onRefresh, + }: { + uid: string; + val: string; + onRefresh: () => void; + }) => { const app = appRef.current; if (!app) return; const { x, y } = app.pageCenter; @@ -1593,21 +1595,63 @@ const TldrawCanvas = ({ title }: Props) => { y: lastTime.y + h * 0.05, } : { x: x - DEFAULT_WIDTH / 2, y: y - DEFAULT_HEIGHT / 2 }; - const nodeType = findDiscourseNode(e.detail.uid, allNodes); + const nodeType = findDiscourseNode(uid, allNodes); if (nodeType) { app.createShapes([ { type: nodeType.type, id: createShapeId(), props: { - uid: e.detail.uid, - title: e.detail.val, + uid, + title: val, }, ...position, }, ]); lastInsertRef.current = position; - e.detail.onRefresh(); + onRefresh(); + } + }; + const handleMoveCameraToShapeAction = ({ + shapeId, + }: { + shapeId: TLShapeId; + }) => { + const app = appRef.current; + if (!app) return; + const shape = app.getShapeById(shapeId); + if (!shape) { + return renderToast({ + id: "tldraw-warning", + intent: "warning", + content: `Shape not found.`, + }); + } + const x = shape?.x || 0; + const y = shape?.y || 0; + app.centerOnPoint(x, y, { duration: 500, easing: (t) => t * t }); + app.select(shapeId); + }; + const actionListener = (( + e: CustomEvent<{ + action: string; + uid?: string; + val?: string; + shapeId?: TLShapeId; + onRefresh?: () => void; + }> + ) => { + if (e.detail.action === "move-camera-to-shape") { + if (!e.detail.shapeId) return; + handleMoveCameraToShapeAction({ shapeId: e.detail.shapeId }); + } + if (/canvas/i.test(e.detail.action)) { + if (!e.detail.uid || !e.detail.val || !e.detail.onRefresh) return; + handleCreateShapeAction({ + uid: e.detail.uid, + val: e.detail.val, + onRefresh: e.detail.onRefresh, + }); } }) as EventListener; document.addEventListener("roamjs:query-builder:action", actionListener); diff --git a/src/index.ts b/src/index.ts index ba2c08fe..2a4bcadb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,10 +29,14 @@ import initializeDiscourseGraphsMode, { } from "./discourseGraphsMode"; import getPageMetadata from "./utils/getPageMetadata"; import { render as queryRender } from "./components/QueryDrawer"; +import { render as canvasDrawerRender } from "./components/CanvasDrawer"; import createPage from "roamjs-components/writes/createPage"; import getBasicTreeByParentUid from "roamjs-components/queries/getBasicTreeByParentUid"; import isLiveBlock from "roamjs-components/queries/isLiveBlock"; -import { renderTldrawCanvas } from "./components/TldrawCanvas"; +import { + DiscourseNodeShape, + renderTldrawCanvas, +} from "./components/TldrawCanvas"; import { QBGlobalRefs } from "./utils/types"; import localStorageSet from "roamjs-components/util/localStorageSet"; import localStorageGet from "roamjs-components/util/localStorageGet"; @@ -45,6 +49,9 @@ import getBlockProps, { json } from "./utils/getBlockProps"; import resolveQueryBuilderRef from "./utils/resolveQueryBuilderRef"; import getBlockUidFromTarget from "roamjs-components/dom/getBlockUidFromTarget"; import { render as renderToast } from "roamjs-components/components/Toast"; +import getCurrentPageUid from "roamjs-components/dom/getCurrentPageUid"; +import { TLBaseShape, TLStore } from "@tldraw/tldraw"; +import { GroupedShapes } from "./components/CanvasDrawer"; const loadedElsewhere = document.currentScript ? document.currentScript.getAttribute("data-source") === "discourse-graph" @@ -595,6 +602,35 @@ svg.rs-svg-container { ).map((b) => ({ uid: b[0][":block/uid"] || "" })), }; + extensionAPI.ui.commandPalette.addCommand({ + label: "Open Canvas Drawer", + callback: () => { + const pageUid = getCurrentPageUid(); + const props = getBlockProps(pageUid) as Record; + const rjsqb = props["roamjs-query-builder"] as Record; + const tldraw = (rjsqb?.tldraw as Record) || {}; + const shapes = Object.values(tldraw).filter((s) => { + const shape = s as TLBaseShape; + const uid = shape.props?.uid; + return !!uid; + }) as DiscourseNodeShape[]; + + const groupShapesByUid = (shapes: DiscourseNodeShape[]) => { + const groupedShapes = shapes.reduce((acc: GroupedShapes, shape) => { + const uid = shape.props.uid; + if (!acc[uid]) acc[uid] = []; + acc[uid].push(shape); + return acc; + }, {}); + + return groupedShapes; + }; + + const groupedShapes = groupShapesByUid(shapes); + canvasDrawerRender({ groupedShapes, pageUid }); + }, + }); + extensionAPI.ui.commandPalette.addCommand({ label: "Open Query Drawer", callback: () =>