Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 89 additions & 11 deletions apps/roam/src/components/DiscourseNodeMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
InputGroup,
getKeyCombo,
IKeyCombo,
Icon,
} from "@blueprintjs/core";
import React, {
useCallback,
Expand All @@ -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"),
Expand All @@ -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 =
Expand Down Expand Up @@ -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") {
Copy link
Contributor

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?

Copy link
Collaborator Author

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.

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"),
);
Expand All @@ -113,6 +119,9 @@ const NodeMenu = ({
onSelect(index);
// Remove focus from the block to ensure updateBlock works properly
document.body.click();
} else if (e.key === "Escape") {
onClose();
document.body.click();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@trangdoan982 do you remember why document.body.click() was added here? I imagine a user would like to keep editing the block so I want to remove it, but I don't want to re-introduce a bug that this possibly fixing.

Copy link
Contributor

Choose a reason for hiding this comment

The 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.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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
Expand All @@ -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) => {
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -298,4 +375,5 @@ export const NodeMenuTriggerComponent = ({
/>
);
};

export default NodeMenu;
19 changes: 19 additions & 0 deletions apps/roam/src/components/settings/HomePersonalSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,25 @@ const HomePersonalSettings = ({ onloadArgs }: { onloadArgs: OnloadArgs }) => {
</>
}
/>
<Checkbox
defaultChecked={
extensionAPI.settings.get("text-selection-popup") !== false
}
onChange={(e) => {
const target = e.target as HTMLInputElement;
extensionAPI.settings.set("text-selection-popup", target.checked);
}}
labelElement={
<>
Text Selection Popup
<Description
description={
"Whether or not to show the Discourse Node Menu when selecting text."
}
/>
</>
}
/>
<Checkbox
defaultChecked={
extensionAPI.settings.get("disable-sidebar-open") as boolean
Expand Down
6 changes: 6 additions & 0 deletions apps/roam/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,11 +103,13 @@ export default runExtension(async (onloadArgs) => {
hashChangeListener,
nodeMenuTriggerListener,
discourseNodeSearchTriggerListener,
nodeCreationPopoverListener,
} = listeners;
document.addEventListener("roamjs:query-builder:action", pageActionListener);
window.addEventListener("hashchange", hashChangeListener);
document.addEventListener("keydown", nodeMenuTriggerListener);
document.addEventListener("input", discourseNodeSearchTriggerListener);
document.addEventListener("selectionchange", nodeCreationPopoverListener);

const { extensionAPI } = onloadArgs;
window.roamjs.extension.queryBuilder = {
Expand Down Expand Up @@ -139,6 +141,10 @@ export default runExtension(async (onloadArgs) => {
window.removeEventListener("hashchange", hashChangeListener);
document.removeEventListener("keydown", nodeMenuTriggerListener);
document.removeEventListener("input", discourseNodeSearchTriggerListener);
document.removeEventListener(
"selectionchange",
nodeCreationPopoverListener,
);
window.roamAlphaAPI.ui.graphView.wholeGraph.removeCallback({
label: "discourse-node-styling",
});
Expand Down
42 changes: 42 additions & 0 deletions apps/roam/src/utils/initializeObserversAndListeners.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ import {
import { IKeyCombo } from "@blueprintjs/core";
import { configPageTabs } from "~/utils/configPageTabs";
import { renderDiscourseNodeSearchMenu } from "~/components/DiscourseNodeSearchMenu";
import {
renderTextSelectionPopup,
removeTextSelectionPopup,
findBlockElementFromSelection,
} from "~/utils/renderTextSelectionPopup";

export const initObservers = async ({
onloadArgs,
Expand All @@ -47,6 +52,7 @@ export const initObservers = async ({
hashChangeListener: EventListener;
nodeMenuTriggerListener: EventListener;
discourseNodeSearchTriggerListener: EventListener;
nodeCreationPopoverListener: EventListener;
};
}> => {
const pageTitleObserver = createHTMLObserver({
Expand Down Expand Up @@ -240,6 +246,41 @@ export const initObservers = async ({
}
};

const nodeCreationPopoverListener = () => {
const isTextSelectionPopupEnabled =
onloadArgs.extensionAPI.settings.get("text-selection-popup") !== false;

if (!isTextSelectionPopupEnabled) {
removeTextSelectionPopup();
return;
}

const selection = window.getSelection();

if (!selection || selection.rangeCount === 0) return;

const selectedText = selection.toString().trim();

if (!selectedText) {
removeTextSelectionPopup();
return;
}

const blockElement = findBlockElementFromSelection();

if (blockElement) {
const textarea = blockElement.querySelector("textarea");
if (!textarea) return;
renderTextSelectionPopup({
extensionAPI: onloadArgs.extensionAPI,
blockElement,
textarea,
});
} else {
removeTextSelectionPopup();
}
};

return {
observers: [
pageTitleObserver,
Expand All @@ -253,6 +294,7 @@ export const initObservers = async ({
hashChangeListener,
nodeMenuTriggerListener,
discourseNodeSearchTriggerListener,
nodeCreationPopoverListener,
},
};
};
73 changes: 73 additions & 0 deletions apps/roam/src/utils/renderTextSelectionPopup.tsx
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;
}
};