-
Notifications
You must be signed in to change notification settings - Fork 4
[ENG-412] Node creation popover menu #218
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
cc019ea
10d0f84
6357754
4cb0d87
f819693
6497f76
cc9b498
4e70cdd
7d52e24
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,6 +7,7 @@ import { | |
| InputGroup, | ||
| getKeyCombo, | ||
| IKeyCombo, | ||
| Icon, | ||
| } from "@blueprintjs/core"; | ||
| import React, { | ||
| useCallback, | ||
|
|
@@ -30,12 +31,14 @@ import posthog from "posthog-js"; | |
| type Props = { | ||
| textarea: HTMLTextAreaElement; | ||
| extensionAPI: OnloadArgs["extensionAPI"]; | ||
| trigger?: JSX.Element; | ||
| }; | ||
|
|
||
| const NodeMenu = ({ | ||
| onClose, | ||
| textarea, | ||
| extensionAPI, | ||
| trigger, | ||
| }: { onClose: () => void } & Props) => { | ||
| const discourseNodes = useMemo( | ||
| () => getDiscourseNodes().filter((n) => n.backedBy === "user"), | ||
|
|
@@ -49,6 +52,8 @@ const NodeMenu = ({ | |
| const blockUid = useMemo(() => getUids(textarea).blockUid, [textarea]); | ||
| const menuRef = useRef<HTMLUListElement>(null); | ||
| const [activeIndex, setActiveIndex] = useState(0); | ||
| const [isOpen, setIsOpen] = useState(!trigger); | ||
|
|
||
| const onSelect = useCallback( | ||
| (index) => { | ||
| const menuItem = | ||
|
|
@@ -90,17 +95,18 @@ const NodeMenu = ({ | |
| }); | ||
| onClose(); | ||
| }, | ||
| [menuRef, blockUid, onClose], | ||
| [menuRef, blockUid, onClose, textarea, extensionAPI], | ||
| ); | ||
|
|
||
| const keydownListener = useCallback( | ||
| (e: KeyboardEvent) => { | ||
| if (e.key === "ArrowRight" || e.key === "ArrowDown") { | ||
| if (e.key === "ArrowDown") { | ||
| const index = Number( | ||
| menuRef.current?.getAttribute("data-active-index"), | ||
| ); | ||
| const count = menuRef.current?.childElementCount || 0; | ||
| setActiveIndex((index + 1) % count); | ||
| } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") { | ||
| } else if (e.key === "ArrowUp") { | ||
| const index = Number( | ||
| menuRef.current?.getAttribute("data-active-index"), | ||
| ); | ||
|
|
@@ -113,6 +119,9 @@ const NodeMenu = ({ | |
| onSelect(index); | ||
| // Remove focus from the block to ensure updateBlock works properly | ||
trangdoan982 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| document.body.click(); | ||
| } else if (e.key === "Escape") { | ||
| onClose(); | ||
| document.body.click(); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @trangdoan982 do you remember why
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I removed it here: #477 Let me know if that is in error.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. i don't think it was specifically to address any bug but just as general behavior to de-focus. I think it's okay that you remove it |
||
| } else if (shortcuts.has(e.key.toUpperCase())) { | ||
| onSelect(indexBySC[e.key.toUpperCase()]); | ||
| // Remove focus from the block to ensure updateBlock works properly | ||
trangdoan982 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
@@ -123,30 +132,52 @@ const NodeMenu = ({ | |
| e.stopPropagation(); | ||
| e.preventDefault(); | ||
| }, | ||
| [menuRef, setActiveIndex], | ||
| [onSelect, onClose, indexBySC], | ||
| ); | ||
| useEffect(() => { | ||
| textarea.addEventListener("keydown", keydownListener); | ||
| textarea.addEventListener("input", onClose); | ||
| const eventTarget = trigger ? document : textarea; | ||
| const keydownHandler = (e: Event) => { | ||
| keydownListener(e as KeyboardEvent); | ||
| }; | ||
| eventTarget.addEventListener("keydown", keydownHandler); | ||
|
|
||
| if (!trigger) { | ||
| textarea.addEventListener("input", onClose); | ||
| } | ||
|
|
||
| return () => { | ||
| textarea.removeEventListener("keydown", keydownListener); | ||
| textarea.removeEventListener("input", onClose); | ||
| eventTarget.removeEventListener("keydown", keydownHandler); | ||
| if (!trigger) { | ||
| textarea.removeEventListener("input", onClose); | ||
| } | ||
| }; | ||
| }, [keydownListener, onClose]); | ||
| }, [keydownListener, onClose, textarea, trigger]); | ||
|
|
||
| const handlePopoverInteraction = useCallback( | ||
| (nextOpenState: boolean) => { | ||
| setIsOpen(nextOpenState); | ||
| if (!nextOpenState) { | ||
| onClose(); | ||
| } | ||
| }, | ||
| [onClose], | ||
| ); | ||
|
|
||
| return ( | ||
| <Popover | ||
| onClose={onClose} | ||
| isOpen={true} | ||
| isOpen={isOpen} | ||
| canEscapeKeyClose | ||
| minimal | ||
| target={<span />} | ||
| target={trigger || <span />} | ||
| position={Position.BOTTOM_LEFT} | ||
| modifiers={{ | ||
| flip: { enabled: false }, | ||
| preventOverflow: { enabled: false }, | ||
| }} | ||
| autoFocus={false} | ||
| enforceFocus={false} | ||
| onInteraction={trigger ? handlePopoverInteraction : undefined} | ||
| content={ | ||
| <Menu ulRef={menuRef} data-active-index={activeIndex}> | ||
| {discourseNodes.map((item, i) => { | ||
|
|
@@ -200,6 +231,52 @@ export const render = (props: Props) => { | |
| ); | ||
| }; | ||
|
|
||
| export const TextSelectionNodeMenu = ({ | ||
| textarea, | ||
| extensionAPI, | ||
| onClose, | ||
| }: { | ||
| textarea: HTMLTextAreaElement; | ||
| extensionAPI: OnloadArgs["extensionAPI"]; | ||
| onClose: () => void; | ||
| }) => { | ||
| const trigger = ( | ||
| <Button | ||
| minimal | ||
| small | ||
| className="rounded border border-[#d3d8de] bg-white px-2 py-1 shadow-md hover:border-[#bfccd6] hover:bg-[#f7f9fc]" | ||
| icon={ | ||
| <div className="flex items-center gap-1"> | ||
| <svg | ||
| width="18" | ||
| height="19" | ||
| viewBox="0 0 256 264" | ||
| fill="none" | ||
| xmlns="http://www.w3.org/2000/svg" | ||
| > | ||
| <path | ||
| fillRule="evenodd" | ||
| clipRule="evenodd" | ||
| d="M156.705 252.012C140.72 267.995 114.803 267.995 98.8183 252.012L11.9887 165.182C-3.99622 149.197 -3.99622 123.28 11.9886 107.296L55.4035 63.8807C63.3959 55.8881 76.3541 55.8881 84.3467 63.8807C92.3391 71.8731 92.3391 84.8313 84.3467 92.8239L69.8751 107.296C53.8901 123.28 53.8901 149.197 69.8751 165.182L113.29 208.596C121.282 216.589 134.241 216.589 142.233 208.596C150.225 200.604 150.225 187.646 142.233 179.653L127.761 165.182C111.777 149.197 111.777 123.28 127.761 107.296C143.746 91.3105 143.746 65.3939 127.761 49.4091L113.29 34.9375C105.297 26.9452 105.297 13.9868 113.29 5.99432C121.282 -1.99811 134.241 -1.99811 142.233 5.99434L243.533 107.296C259.519 123.28 259.519 149.197 243.533 165.182L156.705 252.012ZM200.119 121.767C192.127 113.775 179.168 113.775 171.176 121.767C163.184 129.76 163.184 142.718 171.176 150.71C179.168 158.703 192.127 158.703 200.119 150.71C208.112 142.718 208.112 129.76 200.119 121.767Z" | ||
| fill="#555555" | ||
| /> | ||
| </svg> | ||
| <Icon icon="chevron-down" size={16} color="#555555" /> | ||
| </div> | ||
| } | ||
| /> | ||
| ); | ||
|
|
||
| return ( | ||
| <NodeMenu | ||
| textarea={textarea} | ||
| extensionAPI={extensionAPI} | ||
| trigger={trigger} | ||
| onClose={onClose} | ||
| /> | ||
| ); | ||
| }; | ||
|
|
||
| // node_modules\@blueprintjs\core\lib\esm\components\hotkeys\hotkeyParser.js | ||
| const isMac = () => { | ||
| const platform = | ||
|
|
@@ -298,4 +375,5 @@ export const NodeMenuTriggerComponent = ({ | |
| /> | ||
| ); | ||
| }; | ||
|
|
||
| export default NodeMenu; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,73 @@ | ||
| import React from "react"; | ||
| import ReactDOM from "react-dom"; | ||
| import { TextSelectionNodeMenu } from "~/components/DiscourseNodeMenu"; | ||
| import { getCoordsFromTextarea } from "roamjs-components/components/CursorMenu"; | ||
| import { OnloadArgs } from "roamjs-components/types"; | ||
|
|
||
| let currentPopupContainer: HTMLDivElement | null = null; | ||
|
|
||
| export const findBlockElementFromSelection = (): Element | null => { | ||
| const selection = window.getSelection(); | ||
| if (!selection || selection.rangeCount === 0) return null; | ||
|
|
||
| const range = selection.getRangeAt(0); | ||
| const commonAncestor = range.commonAncestorContainer; | ||
|
|
||
| let blockElement: Element | null = null; | ||
| let currentElement = | ||
| commonAncestor.nodeType === Node.TEXT_NODE | ||
| ? commonAncestor.parentElement | ||
| : (commonAncestor as Element); | ||
|
|
||
| while (currentElement && currentElement !== document.body) { | ||
| if ( | ||
| currentElement.classList?.contains("rm-block-text") || | ||
| currentElement.classList?.contains("rm-block-input") || | ||
| currentElement.closest(".rm-autocomplete__wrapper") | ||
| ) { | ||
| blockElement = currentElement; | ||
| break; | ||
| } | ||
| currentElement = currentElement.parentElement; | ||
| } | ||
|
|
||
| return blockElement; | ||
| }; | ||
|
|
||
| export const renderTextSelectionPopup = ({ | ||
| extensionAPI, | ||
| blockElement, | ||
| textarea, | ||
| }: { | ||
| extensionAPI: OnloadArgs["extensionAPI"]; | ||
| blockElement: Element; | ||
| textarea: HTMLTextAreaElement; | ||
| }) => { | ||
| removeTextSelectionPopup(); | ||
| const coords = getCoordsFromTextarea(textarea); | ||
| currentPopupContainer = document.createElement("div"); | ||
| currentPopupContainer.id = "discourse-text-selection-popup"; | ||
| currentPopupContainer.className = | ||
| "absolute z-[9999] max-w-none font-inherit bg-white"; | ||
| currentPopupContainer.style.left = `${coords.left + 50}px`; | ||
| currentPopupContainer.style.top = `${coords.top - 40}px`; | ||
|
|
||
| blockElement.parentElement?.insertBefore(currentPopupContainer, blockElement); | ||
|
|
||
| ReactDOM.render( | ||
| <TextSelectionNodeMenu | ||
| textarea={textarea} | ||
| extensionAPI={extensionAPI} | ||
| onClose={removeTextSelectionPopup} | ||
| />, | ||
| currentPopupContainer, | ||
| ); | ||
| }; | ||
|
|
||
| export const removeTextSelectionPopup = () => { | ||
| if (currentPopupContainer) { | ||
| ReactDOM.unmountComponentAtNode(currentPopupContainer); | ||
| currentPopupContainer.remove(); | ||
| currentPopupContainer = null; | ||
| } | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Any specific reason ArrowRight/ArrowLeft was removed?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yeah i often select with Shift+ arrow left/right, to select up to specific characters using keyboard-only. adding key listener for ArrowLeft and ArrowRight prevents this flow.