diff --git a/desktop/src/features/messages/ui/MessageComposerToolbar.tsx b/desktop/src/features/messages/ui/MessageComposerToolbar.tsx index b47d2fa68..468bf5920 100644 --- a/desktop/src/features/messages/ui/MessageComposerToolbar.tsx +++ b/desktop/src/features/messages/ui/MessageComposerToolbar.tsx @@ -20,6 +20,7 @@ import { type SpoilerToggleState, toggleSpoilerFormatting, } from "./FormattingToolbar"; +import { SelectionFormattingTray } from "./SelectionFormattingTray"; /** Spring for enter/exit of button groups — all fire simultaneously. */ const presenceSpring = { @@ -98,6 +99,11 @@ export const MessageComposerToolbar = React.memo( return (
+
{/* * AnimatePresence with mode="popLayout" — exiting elements diff --git a/desktop/src/features/messages/ui/SelectionFormattingTray.tsx b/desktop/src/features/messages/ui/SelectionFormattingTray.tsx new file mode 100644 index 000000000..e7e29cab3 --- /dev/null +++ b/desktop/src/features/messages/ui/SelectionFormattingTray.tsx @@ -0,0 +1,214 @@ +import * as React from "react"; +import { createPortal } from "react-dom"; +import type { Editor } from "@tiptap/react"; + +import { cn } from "@/shared/lib/cn"; +import { FormattingToolbar } from "./FormattingToolbar"; + +type SelectionFormattingTrayProps = { + editor: Editor | null; + disabled?: boolean; + onLinkButton?: () => void; +}; + +type TrayPosition = { + left: number; + placement: "top" | "bottom"; + top: number; +}; + +const EDGE_GUTTER = 12; +const SELECTION_OFFSET = 8; +const MIN_SPACE_ABOVE = 44; + +function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} + +function getSelectionRect(editor: Editor): DOMRect | null { + const { from, to } = editor.state.selection; + + try { + const range = document.createRange(); + const start = editor.view.domAtPos(from); + const end = editor.view.domAtPos(to); + range.setStart(start.node, start.offset); + range.setEnd(end.node, end.offset); + + const clientRects = Array.from(range.getClientRects()).filter( + (rect) => rect.width > 0 || rect.height > 0, + ); + const rect = clientRects[0] ?? range.getBoundingClientRect(); + range.detach(); + + if (rect.width > 0 || rect.height > 0) return rect; + } catch { + // Fall back to the caret coordinates below. + } + + const startCoords = editor.view.coordsAtPos(from); + const endCoords = editor.view.coordsAtPos(to); + const left = Math.min(startCoords.left, endCoords.left); + const right = Math.max(startCoords.right, endCoords.right); + const top = Math.min(startCoords.top, endCoords.top); + const bottom = Math.max(startCoords.bottom, endCoords.bottom); + + if (right <= left && bottom <= top) return null; + return new DOMRect(left, top, Math.max(1, right - left), bottom - top); +} + +function getTrayPosition( + editor: Editor, + trayWidth: number, +): TrayPosition | null { + const { selection } = editor.state; + if (selection.empty || selection.from === selection.to) return null; + + const selectedText = editor.state.doc.textBetween( + selection.from, + selection.to, + "\n", + "\n", + ); + if (selectedText.trim().length === 0) return null; + + const rect = getSelectionRect(editor); + if (!rect) return null; + + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const selectionCenter = rect.left + rect.width / 2; + const halfTrayWidth = trayWidth / 2; + const minLeft = Math.min( + viewportWidth - EDGE_GUTTER, + EDGE_GUTTER + halfTrayWidth, + ); + const maxLeft = Math.max( + EDGE_GUTTER, + viewportWidth - EDGE_GUTTER - halfTrayWidth, + ); + const left = + minLeft <= maxLeft + ? clamp(selectionCenter, minLeft, maxLeft) + : viewportWidth / 2; + const hasRoomAbove = rect.top >= MIN_SPACE_ABOVE; + + if (hasRoomAbove) { + return { + left, + placement: "top", + top: Math.max(EDGE_GUTTER, rect.top - SELECTION_OFFSET), + }; + } + + return { + left, + placement: "bottom", + top: Math.min(viewportHeight - EDGE_GUTTER, rect.bottom + SELECTION_OFFSET), + }; +} + +export function SelectionFormattingTray({ + editor, + disabled = false, + onLinkButton, +}: SelectionFormattingTrayProps) { + const [position, setPosition] = React.useState(null); + const rafRef = React.useRef(null); + const trayRef = React.useRef(null); + const [trayWidth, setTrayWidth] = React.useState(0); + + const updatePosition = React.useCallback(() => { + if (!editor || disabled || !editor.isEditable || !editor.isFocused) { + setPosition(null); + return; + } + setPosition(getTrayPosition(editor, trayWidth)); + }, [disabled, editor, trayWidth]); + + const scheduleUpdate = React.useCallback(() => { + if (rafRef.current !== null) { + window.cancelAnimationFrame(rafRef.current); + } + rafRef.current = window.requestAnimationFrame(() => { + rafRef.current = null; + updatePosition(); + }); + }, [updatePosition]); + + React.useEffect(() => { + if (!editor) { + setPosition(null); + return; + } + + const hide = () => setPosition(null); + + scheduleUpdate(); + editor.on("selectionUpdate", scheduleUpdate); + editor.on("transaction", scheduleUpdate); + editor.on("focus", scheduleUpdate); + editor.on("blur", hide); + window.addEventListener("resize", scheduleUpdate); + window.addEventListener("scroll", scheduleUpdate, true); + + return () => { + if (rafRef.current !== null) { + window.cancelAnimationFrame(rafRef.current); + rafRef.current = null; + } + editor.off("selectionUpdate", scheduleUpdate); + editor.off("transaction", scheduleUpdate); + editor.off("focus", scheduleUpdate); + editor.off("blur", hide); + window.removeEventListener("resize", scheduleUpdate); + window.removeEventListener("scroll", scheduleUpdate, true); + }; + }, [editor, scheduleUpdate]); + + React.useLayoutEffect(() => { + if (!position || !trayRef.current) return; + + const updateTrayWidth = () => { + const nextWidth = trayRef.current?.getBoundingClientRect().width ?? 0; + setTrayWidth((currentWidth) => + Math.abs(currentWidth - nextWidth) > 1 ? nextWidth : currentWidth, + ); + }; + + updateTrayWidth(); + + if (typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(updateTrayWidth); + observer.observe(trayRef.current); + return () => observer.disconnect(); + }, [position]); + + if (!position || typeof document === "undefined") return null; + + return createPortal( +
event.preventDefault()} + role="toolbar" + aria-label="Selection formatting" + style={{ left: position.left, top: position.top }} + > +
+ +
+
, + document.body, + ); +} diff --git a/desktop/src/main.tsx b/desktop/src/main.tsx index 85b14ca09..bbcfa834c 100644 --- a/desktop/src/main.tsx +++ b/desktop/src/main.tsx @@ -18,8 +18,7 @@ type E2eWindow = Window & { const E2E_DEFAULT_PUBKEY = "deadbeef".repeat(8); const E2E_WORKSPACE_ID = "e2e-default-workspace"; -const ONBOARDING_COMPLETION_STORAGE_KEY_PREFIX = - "sprout-onboarding-complete.v1:"; +const ONBOARDING_COMPLETION_STORAGE_KEY_PREFIX = "buzz-onboarding-complete.v1:"; function configureDevE2eBridgeFromUrl() { if (!import.meta.env.DEV) { @@ -40,8 +39,8 @@ function configureDevE2eBridgeFromUrl() { name: "E2E Test", relayUrl: "ws://localhost:3000", }; - window.localStorage.setItem("sprout-workspaces", JSON.stringify([workspace])); - window.localStorage.setItem("sprout-active-workspace-id", E2E_WORKSPACE_ID); + window.localStorage.setItem("buzz-workspaces", JSON.stringify([workspace])); + window.localStorage.setItem("buzz-active-workspace-id", E2E_WORKSPACE_ID); window.localStorage.setItem( `${ONBOARDING_COMPLETION_STORAGE_KEY_PREFIX}${E2E_DEFAULT_PUBKEY}`, "true",