summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--components/contextmenu.tsx216
-rw-r--r--org-roam-ui.el18
-rw-r--r--pages/index.tsx165
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>
)
})