| 1 | import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; |
| 2 | import { createPortal } from "react-dom"; |
| 3 | import { MessageSquare } from "lucide-react"; |
| 4 | import { ContextMenu, type ContextMenuPoint } from "./ContextMenu"; |
| 5 | import { messageSelectionContextText } from "../lib/messageSelectionCopy"; |
| 6 | import { writeClipboardText } from "../lib/clipboard"; |
| 7 | import { |
| 8 | detectShortcutPlatform, |
| 9 | formatShortcutCombo, |
| 10 | onShortcutsChanged, |
| 11 | resolvedShortcutCombo, |
| 12 | useGlobalShortcut, |
| 13 | } from "../lib/keyboardShortcuts"; |
| 14 | import { useT } from "../lib/i18n"; |
| 15 | |
| 16 | // Inside the Wails shell main.tsx suppresses the webview's default context |
| 17 | // menu (its Reload/Back/Inspect entries can navigate away from the app), which |
| 18 | // also removes the native Copy menu for selected transcript text — ⌘C still |
| 19 | // works, but the right-click path is dead. This mounts one document-level |
| 20 | // listener that offers an app-drawn Copy menu whenever a suppressed selection |
| 21 | // menu would have applied, gated exactly like the ⌘C interceptor. It stays |
| 22 | // inert in a plain browser (no window.runtime), where the native menu opens. |
| 23 | type SelectionAction = { |
| 24 | text: string; |
| 25 | point: ContextMenuPoint; |
| 26 | }; |
| 27 | |
| 28 | const ACTION_EDGE_GAP = 8; |
| 29 | |
| 30 | export function TranscriptSelectionMenu({ |
| 31 | enabled = true, |
| 32 | resetKey, |
| 33 | onAddToChat, |
| 34 | }: { |
| 35 | enabled?: boolean; |
| 36 | // Identifies the transcript the selection was made in (the active tab). |
| 37 | // The overlay captures only text, while onAddToChat routes to whatever is |
| 38 | // active at click time — so any surviving overlay must be discarded when |
| 39 | // the source changes or a selection from session A could land in session B. |
| 40 | resetKey?: string | number; |
| 41 | onAddToChat?: (text: string) => void; |
| 42 | }) { |
| 43 | const t = useT(); |
| 44 | const [point, setPoint] = useState<ContextMenuPoint | null>(null); |
| 45 | const [text, setText] = useState(""); |
| 46 | const [action, setAction] = useState<SelectionAction | null>(null); |
| 47 | const [actionPoint, setActionPoint] = useState<ContextMenuPoint | null>(null); |
| 48 | const actionRef = useRef<HTMLDivElement>(null); |
| 49 | // Escape dismisses the floating action but browsers keep the text selection, |
| 50 | // so the trailing keyup would immediately re-show it. Remember the dismissed |
| 51 | // selection and stay hidden until it changes or a new pointer gesture lands. |
| 52 | const dismissedRef = useRef<string | null>(null); |
| 53 | const shortcutPlatform = useMemo(() => detectShortcutPlatform(), []); |
| 54 | const [shortcutRevision, setShortcutRevision] = useState(0); |
| 55 | useEffect(() => onShortcutsChanged(() => setShortcutRevision((value) => value + 1)), []); |
| 56 | const addShortcut = useMemo( |
| 57 | () => formatShortcutCombo(resolvedShortcutCombo("selection.addToChat", shortcutPlatform), shortcutPlatform), |
| 58 | // shortcutRevision re-resolves the combo after the user rebinds it in settings. |
| 59 | // eslint-disable-next-line react-hooks/exhaustive-deps |
| 60 | [shortcutPlatform, shortcutRevision], |
| 61 | ); |
| 62 | |
| 63 | const closeAction = useCallback(() => { |
| 64 | setAction(null); |
| 65 | setActionPoint(null); |
| 66 | }, []); |
| 67 | |
| 68 | // Drop every piece of captured selection state when the source transcript |
| 69 | // changes: the floating action, the copy menu, and the Escape-dismissal |
| 70 | // memory all describe the previous tab's content. |
| 71 | const lastResetKeyRef = useRef(resetKey); |
| 72 | useEffect(() => { |
| 73 | if (lastResetKeyRef.current === resetKey) return; |
| 74 | lastResetKeyRef.current = resetKey; |
| 75 | dismissedRef.current = null; |
| 76 | setPoint(null); |
| 77 | setText(""); |
| 78 | closeAction(); |
| 79 | }, [closeAction, resetKey]); |
| 80 | |
| 81 | const addSelectionToChat = useCallback(() => { |
| 82 | if (!action || !onAddToChat) return; |
| 83 | const selectedText = action.text; |
| 84 | document.getSelection()?.removeAllRanges(); |
| 85 | closeAction(); |
| 86 | onAddToChat(selectedText); |
| 87 | }, [action, closeAction, onAddToChat]); |
| 88 | |
| 89 | // The shortcut is registered in SHORTCUT_DEFINITIONS so settings can rebind |
| 90 | // it and conflict-check it against other actions; it only arms while the |
| 91 | // floating action is visible. |
| 92 | useGlobalShortcut( |
| 93 | "selection.addToChat", |
| 94 | addSelectionToChat, |
| 95 | [], |
| 96 | Boolean(action) && enabled && Boolean(onAddToChat), |
| 97 | ); |
| 98 | |
| 99 | useLayoutEffect(() => { |
| 100 | if (!action) { |
| 101 | setActionPoint(null); |
| 102 | return; |
| 103 | } |
| 104 | const rect = actionRef.current?.getBoundingClientRect(); |
| 105 | if (!rect) { |
| 106 | setActionPoint(action.point); |
| 107 | return; |
| 108 | } |
| 109 | setActionPoint({ |
| 110 | left: Math.min( |
| 111 | Math.max(ACTION_EDGE_GAP, action.point.left), |
| 112 | Math.max(ACTION_EDGE_GAP, window.innerWidth - rect.width - ACTION_EDGE_GAP), |
| 113 | ), |
| 114 | top: Math.min( |
| 115 | Math.max(ACTION_EDGE_GAP, action.point.top), |
| 116 | Math.max(ACTION_EDGE_GAP, window.innerHeight - rect.height - ACTION_EDGE_GAP), |
| 117 | ), |
| 118 | }); |
| 119 | }, [action]); |
| 120 | |
| 121 | useEffect(() => { |
| 122 | const onContextMenu = (event: MouseEvent) => { |
| 123 | if (typeof window === "undefined" || !window.runtime) return; |
| 124 | const selected = messageSelectionContextText(document, event.target); |
| 125 | if (selected == null) return; |
| 126 | event.preventDefault(); |
| 127 | setText(selected); |
| 128 | setPoint(menuPointFromEvent(event)); |
| 129 | }; |
| 130 | document.addEventListener("contextmenu", onContextMenu); |
| 131 | return () => document.removeEventListener("contextmenu", onContextMenu); |
| 132 | }, []); |
| 133 | |
| 134 | useEffect(() => { |
| 135 | if (!enabled || !onAddToChat) { |
| 136 | closeAction(); |
| 137 | return; |
| 138 | } |
| 139 | |
| 140 | let frame: number | null = null; |
| 141 | const showForTarget = (target: EventTarget | null) => { |
| 142 | const selected = messageSelectionContextText(document, target); |
| 143 | const selection = document.getSelection(); |
| 144 | const range = selection?.rangeCount ? selection.getRangeAt(selection.rangeCount - 1) : null; |
| 145 | if (selected == null || !range) { |
| 146 | dismissedRef.current = null; |
| 147 | closeAction(); |
| 148 | return; |
| 149 | } |
| 150 | if (dismissedRef.current === selected) return; |
| 151 | dismissedRef.current = null; |
| 152 | const rect = typeof range.getBoundingClientRect === "function" ? range.getBoundingClientRect() : null; |
| 153 | setAction({ |
| 154 | text: selected, |
| 155 | point: rect && (rect.width > 0 || rect.height > 0) |
| 156 | ? { left: rect.right, top: rect.bottom + 8 } |
| 157 | : { left: 12, top: 12 }, |
| 158 | }); |
| 159 | }; |
| 160 | const scheduleShow = (target: EventTarget | null) => { |
| 161 | if (frame !== null) cancelAnimationFrame(frame); |
| 162 | frame = requestAnimationFrame(() => { |
| 163 | frame = null; |
| 164 | showForTarget(target); |
| 165 | }); |
| 166 | }; |
| 167 | const onPointerUp = (event: PointerEvent) => { |
| 168 | if (event.button !== 0) return; |
| 169 | dismissedRef.current = null; |
| 170 | scheduleShow(event.target); |
| 171 | }; |
| 172 | const onKeyUp = (event: KeyboardEvent) => { |
| 173 | const selection = document.getSelection(); |
| 174 | const target = selection?.focusNode instanceof Element |
| 175 | ? selection.focusNode |
| 176 | : selection?.focusNode?.parentElement ?? event.target; |
| 177 | scheduleShow(target); |
| 178 | }; |
| 179 | const onSelectionChange = () => { |
| 180 | const selection = document.getSelection(); |
| 181 | if (!selection || selection.isCollapsed || selection.toString().trim() === "") { |
| 182 | dismissedRef.current = null; |
| 183 | closeAction(); |
| 184 | } |
| 185 | }; |
| 186 | const onKeyDown = (event: KeyboardEvent) => { |
| 187 | if (event.key !== "Escape" || !action) return; |
| 188 | dismissedRef.current = action.text; |
| 189 | closeAction(); |
| 190 | }; |
| 191 | const close = () => closeAction(); |
| 192 | |
| 193 | document.addEventListener("pointerup", onPointerUp); |
| 194 | document.addEventListener("keyup", onKeyUp); |
| 195 | document.addEventListener("keydown", onKeyDown); |
| 196 | document.addEventListener("selectionchange", onSelectionChange); |
| 197 | window.addEventListener("resize", close); |
| 198 | window.addEventListener("scroll", close, true); |
| 199 | return () => { |
| 200 | if (frame !== null) cancelAnimationFrame(frame); |
| 201 | document.removeEventListener("pointerup", onPointerUp); |
| 202 | document.removeEventListener("keyup", onKeyUp); |
| 203 | document.removeEventListener("keydown", onKeyDown); |
| 204 | document.removeEventListener("selectionchange", onSelectionChange); |
| 205 | window.removeEventListener("resize", close); |
| 206 | window.removeEventListener("scroll", close, true); |
| 207 | }; |
| 208 | }, [action, closeAction, enabled, onAddToChat]); |
| 209 | |
| 210 | return <> |
| 211 | <ContextMenu |
| 212 | open={point != null} |
| 213 | point={point} |
| 214 | minWidth={140} |
| 215 | ariaLabel={t("common.copy")} |
| 216 | items={[ |
| 217 | { |
| 218 | key: "copy", |
| 219 | label: t("common.copy"), |
| 220 | shortcut: formatShortcutCombo( |
| 221 | shortcutPlatform === "darwin" ? { key: "c", meta: true } : { key: "c", ctrl: true }, |
| 222 | shortcutPlatform, |
| 223 | ), |
| 224 | onSelect: () => { |
| 225 | void writeClipboardText(text); |
| 226 | setPoint(null); |
| 227 | }, |
| 228 | }, |
| 229 | ]} |
| 230 | onClose={() => setPoint(null)} |
| 231 | /> |
| 232 | {action && typeof document !== "undefined" && createPortal( |
| 233 | <div |
| 234 | ref={actionRef} |
| 235 | className="transcript-selection-action" |
| 236 | role="toolbar" |
| 237 | aria-label={t("selection.actions")} |
| 238 | style={{ |
| 239 | left: actionPoint?.left ?? action.point.left, |
| 240 | top: actionPoint?.top ?? action.point.top, |
| 241 | visibility: actionPoint ? "visible" : "hidden", |
| 242 | }} |
| 243 | onMouseDown={(event) => event.preventDefault()} |
| 244 | > |
| 245 | <button type="button" onClick={addSelectionToChat}> |
| 246 | <MessageSquare size={14} aria-hidden="true" /> |
| 247 | <span>{t("selection.addToChat")}</span> |
| 248 | <kbd>{addShortcut}</kbd> |
| 249 | </button> |
| 250 | </div>, |
| 251 | document.body, |
| 252 | )} |
| 253 | </>; |
| 254 | } |
| 255 | |
| 256 | // The keyboard context-menu key fires contextmenu at (0, 0); anchor the menu |
| 257 | // to the selection instead so it opens next to the highlighted text. |
| 258 | function menuPointFromEvent(event: MouseEvent): ContextMenuPoint { |
| 259 | if (event.clientX > 0 || event.clientY > 0) { |
| 260 | return { left: event.clientX, top: event.clientY }; |
| 261 | } |
| 262 | const range = document.getSelection()?.rangeCount ? document.getSelection()?.getRangeAt(0) : null; |
| 263 | const rect = typeof range?.getBoundingClientRect === "function" ? range.getBoundingClientRect() : null; |
| 264 | if (rect && (rect.width > 0 || rect.height > 0)) { |
| 265 | return { left: rect.left, top: rect.bottom + 4 }; |
| 266 | } |
| 267 | return { left: 12, top: 12 }; |
| 268 | } |
| 269 |