diff options
-rw-r--r-- | components/contextmenu.tsx | 216 | ||||
-rw-r--r-- | org-roam-ui.el | 18 | ||||
-rw-r--r-- | pages/index.tsx | 165 |
3 files changed, 371 insertions, 28 deletions
diff --git a/components/contextmenu.tsx b/components/contextmenu.tsx new file mode 100644 index 0000000..c20b3e9 --- /dev/null +++ b/components/contextmenu.tsx @@ -0,0 +1,216 @@ +import React, { useRef } from 'react' +import { + Box, + Menu, + MenuItem, + MenuList, + MenuGroup, + MenuItemOption, + MenuOptionGroup, + Heading, + MenuDivider, + Modal, + ModalOverlay, + ModalContent, + ModalHeader, + ModalFooter, + ModalBody, + ModalCloseButton, + useDisclosure, + Button, + PopoverTrigger, + PopoverContent, + Popover, + Flex, + PopoverBody, + PopoverCloseButton, + PopoverArrow, + PopoverHeader, + PopoverFooter, + Portal, + Text, + VStack, +} from '@chakra-ui/react' +import { + DeleteIcon, + EditIcon, + CopyIcon, + AddIcon, + ViewIcon, + ExternalLinkIcon, + ChevronRightIcon, + PlusSquareIcon, +} from '@chakra-ui/icons' + +import { OrgRoamGraphReponse, OrgRoamLink, OrgRoamNode } from '../api' + +export default interface ContextMenuProps { + background: Boolean + node?: OrgRoamNode + nodeType?: string + coordinates: number[] + handleLocal: (node: OrgRoamNode, add: string) => void + openNodeInEmacs: (node: OrgRoamNode) => void + menuClose: () => void + scope: { nodeIds: string[] } + deleteNodeInEmacs: (node: OrgRoamNode) => void +} + +export const ContextMenu = (props: ContextMenuProps) => { + const { background, node, nodeType, coordinates, handleLocal, menuClose, scope, openNodeInEmacs, deleteNodeInEmacs } = props + const { isOpen, onOpen, onClose } = useDisclosure() + const copyRef = useRef<any>() + return ( + <> + <Box + position="absolute" + zIndex="overlay" + left={coordinates[0] + 10} + top={coordinates[1] - 10} + padding={5} + > + <Menu closeOnBlur={false} defaultIsOpen onClose={() => menuClose()}> + <MenuList zIndex="overlay" bgColor="alt.100" borderColor="gray.500" maxWidth="xs"> + {node && ( + <> + <Heading size="sm" isTruncated px={3} py={1}> + {node.title} + </Heading> + <MenuDivider borderColor="gray.500" /> + </> + )} + {scope.nodeIds.length !== 0 && + <> + <MenuItem onClick={() => handleLocal(node!, "add")} icon={<PlusSquareIcon />}> + Expand local graph at node + </MenuItem> + <MenuItem onClick={() => handleLocal(node!, "replace")} icon={<ViewIcon />}> + Open local graph for this node + </MenuItem> + </> + } + {!node?.properties.FILELESS ? ( + <MenuItem icon={<EditIcon />} onClick={() => openNodeInEmacs(node)}>Open in Emacs</MenuItem> + ) : ( + <MenuItem icon={<AddIcon />}>Create node</MenuItem> + )} + {node?.properties.ROAM_REFS && ( + <MenuItem icon={<ExternalLinkIcon />}>Open in Zotero</MenuItem> + )} + {scope.nodeIds.length === 0 && + <MenuItem icon={<ViewIcon />} onClick={() => handleLocal(node!, "replace")}>Open local graph</MenuItem> + } + {/* Doesn't work at the moment + <MenuItem closeOnSelect={false} closeOnBlur={false}> + <Box _hover={{ bg: 'gray.200' }} width="100%"> + <Popover + initialFocusRef={copyRef} + trigger="hover" + placement="right-start" + gutter={0} + > + <PopoverTrigger> + <MenuItem closeOnSelect={false} icon={<CopyIcon />}> + <Flex justifyContent="space-between" alignItems="center"> + Copy... + <ChevronRightIcon /> + </Flex> + </MenuItem> + </PopoverTrigger> + <PopoverContent width={100}> + <Menu defaultIsOpen closeOnBlur={false} closeOnSelect={false}> + <MenuList bg="alt.100" zIndex="popover"> + <MenuItem ref={copyRef}>ID</MenuItem> + <MenuItem>Title</MenuItem> + <MenuItem>File path</MenuItem> + </MenuList> + </Menu> + </PopoverContent> + </Popover> + </Box> + </MenuItem> */} + {node?.level === 0 && + <MenuItem + closeOnSelect={false} + icon={<DeleteIcon color="red.500" />} + color="red.500" + onClick={onOpen} + > + Permenantly delete note + </MenuItem> + } + </MenuList> + </Menu> + </Box> + <Modal isCentered isOpen={isOpen} onClose={onClose}> + <ModalOverlay /> + <ModalContent zIndex="popover" > + <ModalHeader>Delete node?</ModalHeader> + <ModalCloseButton /> + <ModalBody> + <VStack + spacing={4} + display="flex" + alignItems="flex-start"> + <Text> + This will permanently delete your note: + </Text> + <Text fontWeight="bold">{node?.title} + </Text> + {node?.level !== 0 && + <Text>This will only delete the from this heading until but not including the next node. + Your parent file and all other nodes will not be deleted.</Text>} + <Text> + Are you sure you want to do continue? + </Text> + </VStack> + </ModalBody> + <ModalFooter> + <Button mr={3} onClick={() => { + console.log('closing') + onClose() + }}> + Cancel + </Button> + <Button variant="link" colorScheme="red" ml={3} + onClick={() => { + console.log('aaaaa') + deleteNodeInEmacs(node!) + onClose() + }} + > + Delete node + </Button> + </ModalFooter> + </ModalContent> + </Modal> + </> + ) +} + +/* <Box> + * <Popover> + * <PopoverTrigger> + * Permenantly delete node + * </MenuItem> + * </PopoverTrigger> + * <PopoverContent borderColor="red.500" _focus={{}}> + * <PopoverHeader fontWeight="semibold">Delete Node?</PopoverHeader> + * <PopoverArrow /> + * <PopoverCloseButton onClick={onClose} /> + * <PopoverBody> + * This will permanently delete your node! Are you sure you want to do this? + * </PopoverBody> + * <PopoverFooter> + * <Flex justifyContent="space-between" py={1}> + * <Button colorScheme="gray" bg="gray.800" color="alt.100" width={30} onClick={onClose}> + * Nah + * </Button> + * <Button colorScheme="red" variant="link" onClick={onClose}> + * Delete node + * </Button> + * </Flex> + * </PopoverFooter> + * </PopoverContent> + * </Popover> + * </Box> */ diff --git a/org-roam-ui.el b/org-roam-ui.el index 4cff4fa..b3c3ace 100644 --- a/org-roam-ui.el +++ b/org-roam-ui.el @@ -141,9 +141,22 @@ This serves the web-build and API over HTTP." (when org-roam-ui-follow (org-roam-ui-follow-mode 1)))) :on-message (lambda (_websocket frame) + (let* ((msg (json-parse-string (websocket-frame-text frame) :object-type 'alist)) + (command (alist-get 'command msg)) + (data (alist-get 'data msg))) + (message "%s" (websocket-frame-text frame)) + (cond ((string= command "open") (org-roam-node-visit (org-roam-populate (org-roam-node-create - :id (websocket-frame-text frame))))) + :id (alist-get 'id data))))) + ((string= command "delete") + (progn + (delete-file (alist-get 'file data) + (message "Deleted %s" (alist-get 'file data)))) + (org-roam-db-sync) + (org-roam-ui--update-graphdata "node" "deleted" (alist-get 'id data)) + ) + (t (message "Something went wrong when receiving a message from Org-Roam-UI"))))) :on-close (lambda (_websocket) (remove-hook 'after-save-hook #'org-roam-ui--on-save) (org-roam-ui-follow-mode -1) @@ -217,9 +230,10 @@ loaded. Returns `ref' if an entry could not be found." (tags . ,(seq-mapcat #'seq-reverse (org-roam-db-query [:select :distinct tag :from tags])))))) (websocket-send-text oru-ws (json-encode `((type . "graphdata") (data . ,response)))))) + (defun org-roam-ui--update-current-node () "Send the current node data to the web-socket." - (when (and (websocket-openp oru-ws) (org-roam-buffer-p)) + (when (and (websocket-openp oru-ws) (org-roam-buffer-p) (file-exists-p (buffer-file-name))) (let* ((node (org-roam-id-at-point))) (unless (string= org-roam-ui--ws-current-node node) (setq org-roam-ui--ws-current-node node) diff --git a/pages/index.tsx b/pages/index.tsx index e298695..fb12906 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -21,7 +21,7 @@ import { GraphData, NodeObject, LinkObject } from 'force-graph' import { useWindowSize } from '@react-hook/window-size' import { useAnimation } from '@lilib/hooks' -import { Box, useTheme } from '@chakra-ui/react' +import { Box, useDisclosure, useTheme } from '@chakra-ui/react' import { initialPhysics, @@ -33,11 +33,13 @@ import { TagColors, } from '../components/config' import { Tweaks } from '../components/tweaks' +import { ContextMenu } from '../components/contextmenu' import { ThemeContext, ThemeContextProps } from '../util/themecontext' import SpriteText from 'three-spritetext' import ReconnectingWebSocket from 'reconnecting-websocket' +import { sendJson } from 'next/dist/next-server/server/api-utils' // react-force-graph fails on import when server-rendered // https://github.com/vasturiano/react-force-graph/issues/155 @@ -92,8 +94,14 @@ export function GraphPage() { const nodeByIdRef = useRef<NodeById>({}) const linksByNodeIdRef = useRef<LinksByNodeId>({}) const tagsRef = useRef<Tags>([]) + const graphRef = useRef<any>(null) + + const currentGraphDataRef = useRef<GraphData>({ nodes: [], links: [] }) const updateGraphData = (orgRoamGraphData: OrgRoamGraphReponse) => { + const currentGraphData = currentGraphDataRef.current + const oldNodeById = nodeByIdRef.current + const oldLinksByNodeId = linksByNodeIdRef.current tagsRef.current = orgRoamGraphData.tags ?? [] const nodesByFile = orgRoamGraphData.nodes.reduce<NodesByFile>((acc, node) => { return { @@ -141,11 +149,63 @@ export function GraphPage() { links, } - // react-force-graph modifies the graph data implicitly, - // so we make sure there's no overlap between the objects we pass it and - // nodeByIdRef, linksByNodeIdRef - const orgRoamGraphDataClone = JSON.parse(JSON.stringify(orgRoamGraphDataWithFileLinks)) - setGraphData(orgRoamGraphDataClone) + if (!currentGraphData.nodes.length) { + // react-force-graph modifies the graph data implicitly, + // so we make sure there's no overlap between the objects we pass it and + // nodeByIdRef, linksByNodeIdRef + const orgRoamGraphDataClone = JSON.parse(JSON.stringify(orgRoamGraphDataWithFileLinks)) + currentGraphDataRef.current = orgRoamGraphDataClone + setGraphData(orgRoamGraphDataClone) + return + } + + const newNodes = [ + ...currentGraphData.nodes.map((node: NodeObject) => { + const newNode = nodeByIdRef.current[node.id!] ?? false + if (!newNode) { + return + } + return { ...node, ...newNode } + }), + ...Object.keys(nodeByIdRef.current) + .filter((id) => !oldNodeById[id]) + .map((id) => nodeByIdRef.current[id] as NodeObject), + ] + + /* const currentGraphIndexByLink = currentGraphData.links.reduce<{[key: string]: number}>((acc, link, index) => { +* const [source, target] = normalizeLinkEnds(link) +* const sourceTarget=source+target +* return { +* ...acc, +* [sourceTarget]: index +* } +},{}) */ + + /* const newLinks = graphData!.links.filter(link => { + * const [source, target] = normalizeLinkEnds(link) + * if (!nodeByIdRef.current[source] || !nodeByIdRef.current[target]) { + * return false + * } + * if (!linksByNodeIdRef.current[source]!.some(link => link.target === target || link.source === target) + * && !linksByNodeIdRef.current[target]!.some(link => link.target === source || link.source === source)) { + * return false + * } + * return true + * }) + * console.log(newLinks) + * console.log(currentGraphData.links) */ + /* ...Object.keys(linksByNodeIdRef.current).flatMap((id) => { +if (!oldLinksByNodeId[id]!) { +return linksByNodeIdRef.current![id!]! +} +return linksByNodeIdRef.current![id]!.filter(link => { +const [source, target] = normalizeLinkEnds(link) +return !oldLinksByNodeId[id]!.some(oldLink => oldLink.source === source && oldLink.target === target)! +}) ?? [] +})] */ + const fg = graphRef.current + fg.cooldownTicks = 0 + setGraphData({ nodes: newNodes as NodeObject[], links: links }) } const { setEmacsTheme } = useContext(ThemeContext) @@ -156,7 +216,6 @@ export function GraphPage() { const scopeRef = useRef<Scope>({ nodeIds: [] }) const behaviorRef = useRef(initialBehavior) behaviorRef.current = behavior - const graphRef = useRef<any>(null) const WebSocketRef = useRef<any>(null) scopeRef.current = scope @@ -269,7 +328,7 @@ export function GraphPage() { } return ( - <Box display="flex" alignItems="flex-start" flexDirection="row" height="100%"> + <Box display="flex" alignItems="flex-start" flexDirection="row" height="100%" overflow="hidden"> <Tweaks {...{ physics, @@ -289,7 +348,7 @@ export function GraphPage() { }} tags={tagsRef.current} /> - <Box position="absolute" alignItems="top"> + <Box position="absolute" alignItems="top" overflow="hidden"> <Graph ref={graphRef} nodeById={nodeByIdRef.current!} @@ -360,21 +419,46 @@ export const Graph = forwardRef(function (props: GraphProps, graphRef: any) { const { emacsTheme } = useContext<ThemeContextProps>(ThemeContext) - const handleClick = (click: string, node: NodeObject) => { + const handleLocal = (node: OrgRoamNode, add: string) => { + if (scope.nodeIds.includes(node.id as string)) { + return + } + if (add === 'replace') { + setScope({ nodeIds: [node.id] }) + return + } + setScope((currentScope: Scope) => ({ + ...currentScope, + nodeIds: [...currentScope.nodeIds, node.id as string], + })) + return + } + + const sendMessageToEmacs = (command: string, data: {}) => { + webSocket.send(JSON.stringify({ command: command, data: data })) + } + const openNodeInEmacs = (node: OrgRoamNode) => { + sendMessageToEmacs('open', { id: node.id }) + } + + const deleteNodeInEmacs = (node: OrgRoamNode) => { + console.log('deletin') + if (node.level !== 0) { + return + } + console.log('deletin') + sendMessageToEmacs('delete', { id: node.id, file: node.file }) + } + + const handleClick = (click: string, node: OrgRoamNode) => { switch (click) { //mouse.highlight: case mouse.local: { - if (scope.nodeIds.includes(node.id as string)) { - break - } - setScope((currentScope: Scope) => ({ - ...currentScope, - nodeIds: [...currentScope.nodeIds, node.id as string], - })) + handleLocal(node, behavior.localSame) break } case mouse.follow: { - webSocket.send(node.id) + openNodeInEmacs(node) break } default: @@ -423,8 +507,8 @@ export const Graph = forwardRef(function (props: GraphProps, graphRef: any) { const filteredLinksByNodeId = useRef<LinksByNodeId>({}) const filteredGraphData = useMemo(() => { hiddenNodeIdsRef.current = {} - const filteredNodes = graphData.nodes - .filter((nodeArg) => { + const filteredNodes = graphData?.nodes + ?.filter((nodeArg) => { const node = nodeArg as OrgRoamNode if ( filter.tagsBlacklist.length && @@ -538,7 +622,7 @@ export const Graph = forwardRef(function (props: GraphProps, graphRef: any) { physics.collision ? d3.forceCollide().radius(physics.collisionStrength) : null, ) })() - }) + }, [physics]) // Normally the graph doesn't update when you just change the physics parameters // This forces the graph to make a small update when you do @@ -731,6 +815,16 @@ export const Graph = forwardRef(function (props: GraphProps, graphRef: any) { [visuals.labelBackgroundColor, emacsTheme], ) + const { isOpen, onOpen, onClose } = useDisclosure() + const [rightClickedNode, setRightClickedNode] = useState<OrgRoamNode | null>(null) + const [contextPos, setContextPos] = useState([0, 0]) + const openContextMenu = (node: OrgRoamNode, event: any) => { + setContextPos([event.pageX, event.pageY]) + setRightClickedNode(node) + onOpen() + console.log(event) + } + const graphCommonProps: ComponentPropsWithoutRef<typeof TForceGraph2D> = { graphData: scopedGraphData, width: windowWidth, @@ -863,7 +957,9 @@ export const Graph = forwardRef(function (props: GraphProps, graphRef: any) { d3AlphaMin: physics.alphaMin, d3VelocityDecay: physics.velocityDecay, - onNodeClick: (node: NodeObject, event: any) => { + onNodeClick: (nodeArg: NodeObject, event: any) => { + const node = nodeArg as OrgRoamNode + onClose() const isDoubleClick = event.timeStamp - lastNodeClickRef.current < 400 lastNodeClickRef.current = event.timeStamp if (isDoubleClick) { @@ -872,6 +968,7 @@ export const Graph = forwardRef(function (props: GraphProps, graphRef: any) { return handleClick('click', node) }, onBackgroundClick: () => { + onClose() setHoverNode(null) if (scope.nodeIds.length === 0) { return @@ -892,13 +989,29 @@ export const Graph = forwardRef(function (props: GraphProps, graphRef: any) { } setHoverNode(node) }, - onNodeRightClick: (node) => { - handleClick('right', node) + onNodeRightClick: (nodeArg, event) => { + const node = nodeArg as OrgRoamNode + openContextMenu(node, event) + + //handleClick('right', node) }, } return ( - <div> + <Box overflow="hidden"> + {isOpen && ( + <ContextMenu + scope={scope} + node={rightClickedNode!} + nodeType={rightClickedNode?.id} + background={false} + coordinates={contextPos} + handleLocal={handleLocal} + menuClose={onClose} + openNodeInEmacs={openNodeInEmacs} + deleteNodeInEmacs={deleteNodeInEmacs} + /> + )} {threeDim ? ( <ForceGraph3D ref={graphRef} @@ -940,7 +1053,7 @@ export const Graph = forwardRef(function (props: GraphProps, graphRef: any) { }} /> )} - </div> + </Box> ) }) |