diff --git a/components/SpeakerLabel.tsx b/components/SpeakerLabel.tsx index 106e5ff..1b0d52e 100644 --- a/components/SpeakerLabel.tsx +++ b/components/SpeakerLabel.tsx @@ -8,6 +8,7 @@ import { useRef, useState, type PointerEvent as ReactPointerEvent, + type RefObject, } from "react"; import { Check, @@ -19,6 +20,7 @@ import { Trash2, UserRoundPen, } from "lucide-react"; +import { FloatingPortal } from "@floating-ui/react"; import { useEditorStore } from "@/lib/store"; import { findSpeakerByName, @@ -26,6 +28,7 @@ import { speakerLabel, } from "@/lib/speakers"; import type { SpeakerInfo } from "@/lib/types"; +import { useWordAnchorFloating } from "@/hooks/useWordAnchorFloating"; import Popover, { PopoverContent, PopoverTrigger } from "./Popover"; type PickerMode = "change" | "rename" | "replace"; @@ -387,13 +390,11 @@ export function SelectionSpeakerButton({ onClick }: { onClick: () => void }) { /** Anchored picker for reassigning the current word selection. */ export function SelectionSpeakerPopover({ wordIds, - top, - left, + containerRef, onClose, }: { wordIds: number[]; - top: number; - left: number; + containerRef: RefObject; onClose: () => void; }) { const speakers = useEditorStore((s) => s.speakers); @@ -402,6 +403,13 @@ export function SelectionSpeakerPopover({ const [query, setQuery] = useState(""); const inputRef = useRef(null); const panelRef = useRef(null); + const { setFloating, floatingStyles } = useWordAnchorFloating({ + open: true, + wordIds, + containerRef, + placement: "top", + offsetMain: 8, + }); const currentSpeaker = useMemo(() => { const idSet = new Set(wordIds); @@ -449,52 +457,57 @@ export function SelectionSpeakerPopover({ ); return ( -
-
- setQuery(e.target.value)} - onKeyDown={(e) => { - if (e.key !== "Enter") return; - e.preventDefault(); - if (exact) apply(exact.id); - else if (canCreate) apply("new", trimmedQuery); - }} - placeholder="Search or create…" - className="w-full rounded-lg border border-zinc-200 bg-zinc-50 px-2 py-1.5 text-[13px] text-zinc-800 outline-none focus:border-zinc-400 focus:bg-white dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-100 dark:focus:border-zinc-400" - /> -
-
- {canCreate && ( - - )} - {filtered.map((s) => ( - apply(s.id)} + +
{ + panelRef.current = node; + setFloating(node); + }} + data-speaker-toolbar + className="z-40 w-60 max-w-[calc(100vw-16px)] overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-xl shadow-zinc-900/10 dark:border-zinc-700 dark:bg-zinc-800 dark:shadow-black/40" + style={floatingStyles} + role="dialog" + aria-label="Change speaker" + > +
+ setQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key !== "Enter") return; + e.preventDefault(); + if (exact) apply(exact.id); + else if (canCreate) apply("new", trimmedQuery); + }} + placeholder="Search or create…" + className="w-full rounded-lg border border-zinc-200 bg-zinc-50 px-2 py-1.5 text-[13px] text-zinc-800 outline-none focus:border-zinc-400 focus:bg-white dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-100 dark:focus:border-zinc-400" /> - ))} +
+
+ {canCreate && ( + + )} + {filtered.map((s) => ( + apply(s.id)} + /> + ))} +
-
+ ); } diff --git a/components/TranscriptPanel.tsx b/components/TranscriptPanel.tsx index e9bcbdb..be087cd 100644 --- a/components/TranscriptPanel.tsx +++ b/components/TranscriptPanel.tsx @@ -14,6 +14,7 @@ import { WandSparkles, X, } from "lucide-react"; +import { FloatingPortal } from "@floating-ui/react"; import { useEditorStore } from "@/lib/store"; import { findFillerWordIds } from "@/lib/fillers"; import { findSilenceRanges } from "@/lib/silences"; @@ -36,6 +37,7 @@ import { mapSplitsToWords, } from "@/lib/edits"; import { useTranscriptSelection } from "@/hooks/useTranscriptSelection"; +import { useWordAnchorFloating } from "@/hooks/useWordAnchorFloating"; import { useCutRanges } from "@/hooks/useCutRanges"; import { findActiveWordId, groupWordsBySpeaker } from "@/lib/transcript"; @@ -143,17 +145,10 @@ export default function TranscriptPanel() { const containerRef = useRef(null); const scrollRef = useRef(null); const importInputRef = useRef(null); - const [correcting, setCorrecting] = useState<{ - ids: number[]; - top: number; - left: number; - containerWidth: number; - } | null>(null); + const [correcting, setCorrecting] = useState<{ ids: number[] } | null>(null); const [correctText, setCorrectText] = useState(""); const [assigningSpeaker, setAssigningSpeaker] = useState<{ ids: number[]; - top: number; - left: number; } | null>(null); // Mirrors Correct / Speaker pickers so selection handlers freeze highlights. const freezeSelectionRef = useRef(false); @@ -171,6 +166,25 @@ export default function TranscriptPanel() { freezeSelectionRef, }); + const toolbarOpen = !!(selection && !correcting && !assigningSpeaker); + const { setFloating: setToolbarFloating, floatingStyles: toolbarStyles } = + useWordAnchorFloating({ + open: toolbarOpen, + wordIds: selection?.ids, + containerRef, + placement: "top", + offsetMain: 8, + }); + + const { setFloating: setCorrectFloating, floatingStyles: correctStyles } = + useWordAnchorFloating({ + open: !!correcting, + wordIds: correcting?.ids, + containerRef, + placement: "top", + offsetMain: 12, + }); + const turns = useMemo(() => groupWordsBySpeaker(words), [words]); const deletedCount = useMemo(() => cutOutIds.size, [cutOutIds]); @@ -234,12 +248,7 @@ export default function TranscriptPanel() { .join(" "); freezeSelectionRef.current = true; setCorrectText(text); - setCorrecting({ - ids: selection.ids, - top: selection.top, - left: selection.left, - containerWidth: containerRef.current?.clientWidth ?? 640, - }); + setCorrecting({ ids: selection.ids }); releaseToolbar(); }, [selection, words, releaseToolbar]); @@ -252,11 +261,7 @@ export default function TranscriptPanel() { const openSpeakerAssign = useCallback(() => { if (!selection) return; freezeSelectionRef.current = true; - setAssigningSpeaker({ - ids: selection.ids, - top: selection.top, - left: selection.left, - }); + setAssigningSpeaker({ ids: selection.ids }); releaseToolbar(); }, [selection, releaseToolbar]); @@ -482,93 +487,94 @@ export default function TranscriptPanel() {
)} - {selection && !correcting && !assigningSpeaker && ( -
e.preventDefault()} - > - {selection.anyKept && ( - - )} - {selection.anyDeleted && ( + {toolbarOpen && selection && ( + +
e.preventDefault()} + > + {selection.anyKept && ( + + )} + {selection.anyDeleted && ( + + )} - )} - - -
+ +
+ )} {assigningSpeaker && ( )} {correcting && ( -
-
- Correct - -
- setCorrectText(e.target.value)} - onFocus={(e) => e.currentTarget.select()} - onKeyDown={(e) => { - if (e.key === "Enter") applyCorrection(); - else if (e.key === "Escape") closeCorrect(); + +
{ + popoverRef.current = node; + setCorrectFloating(node); }} - className="w-full rounded-lg border border-zinc-300 bg-zinc-50 px-2.5 py-1.5 text-sm text-zinc-800 outline-none focus:border-zinc-500 focus:bg-white dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-100 dark:focus:border-zinc-400 dark:focus:bg-zinc-950" - /> -
- + className="z-40 w-80 max-w-[calc(100vw-16px)] rounded-2xl border border-zinc-200 bg-white p-3 shadow-xl shadow-zinc-900/10 dark:border-zinc-700 dark:bg-zinc-800 dark:shadow-black/40" + style={correctStyles} + > +
+ Correct + +
+ setCorrectText(e.target.value)} + onFocus={(e) => e.currentTarget.select()} + onKeyDown={(e) => { + if (e.key === "Enter") applyCorrection(); + else if (e.key === "Escape") closeCorrect(); + }} + className="w-full rounded-lg border border-zinc-300 bg-zinc-50 px-2.5 py-1.5 text-sm text-zinc-800 outline-none focus:border-zinc-500 focus:bg-white dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-100 dark:focus:border-zinc-400 dark:focus:bg-zinc-950" + /> +
+ +
-
+
)}
diff --git a/hooks/useTranscriptSelection.ts b/hooks/useTranscriptSelection.ts index ade5a42..e11debe 100644 --- a/hooks/useTranscriptSelection.ts +++ b/hooks/useTranscriptSelection.ts @@ -15,8 +15,6 @@ export interface TranscriptSelectionInfo { ids: number[]; anyDeleted: boolean; anyKept: boolean; - top: number; - left: number; } /** While dragging: paint marks only. On mouseup / keyboard: sync to React. */ @@ -26,16 +24,6 @@ function sameIds(a: number[], b: number[]): boolean { return a.length === b.length && a.every((id, i) => id === b[i]); } -function toolbarPosition( - rangeRect: DOMRect, - containerRect: DOMRect -): Pick { - return { - top: rangeRect.top - containerRect.top - 44, - left: Math.max(8, rangeRect.left - containerRect.left + rangeRect.width / 2), - }; -} - /** Walk up from a Range boundary to the nearest word span inside `container`. */ function wordElFromNode( node: Node | null, @@ -84,8 +72,6 @@ function wordElsInRange( function selectionInfoFromWordEls( els: HTMLElement[], - range: Range, - container: HTMLElement, cutOutIds: Set ): TranscriptSelectionInfo | null { if (els.length === 0) return null; @@ -104,7 +90,6 @@ function selectionInfoFromWordEls( ids, anyDeleted, anyKept, - ...toolbarPosition(range.getBoundingClientRect(), container.getBoundingClientRect()), }; } @@ -209,10 +194,6 @@ export function useTranscriptSelection({ ids: [word.id], anyDeleted: cutOut, anyKept: !cutOut, - ...toolbarPosition( - el.getBoundingClientRect(), - container.getBoundingClientRect() - ), }); setSelectedWords([word.id]); }, @@ -247,12 +228,7 @@ export function useTranscriptSelection({ clickSelectionRef.current = false; const els = wordElsInRange(container, range); applyMarks(els); - const info = selectionInfoFromWordEls( - els, - range, - container, - cutOutIdsRef.current - ); + const info = selectionInfoFromWordEls(els, cutOutIdsRef.current); if (mode === "paint") { // Hide a stale toolbar while dragging; marks stay imperative. @@ -336,10 +312,6 @@ export function useTranscriptSelection({ ids: selectedWordIds, anyDeleted: selectedWordIds.some((id) => cutOutIds.has(id)), anyKept: selectedWordIds.some((id) => !cutOutIds.has(id)), - ...toolbarPosition( - els[0].getBoundingClientRect(), - container.getBoundingClientRect() - ), }); }, [ selectedWordIds, diff --git a/hooks/useWordAnchorFloating.ts b/hooks/useWordAnchorFloating.ts new file mode 100644 index 0000000..0ea70ef --- /dev/null +++ b/hooks/useWordAnchorFloating.ts @@ -0,0 +1,92 @@ +"use client"; + +import { + autoUpdate, + flip, + offset, + shift, + useFloating, + type Placement, +} from "@floating-ui/react"; +import { useLayoutEffect, type RefObject } from "react"; + +/** Union the viewport boxes of the given word spans inside `container`. */ +export function unionWordRects( + container: HTMLElement, + wordIds: number[] +): DOMRect { + let top = Infinity; + let left = Infinity; + let bottom = -Infinity; + let right = -Infinity; + let found = false; + + for (const id of wordIds) { + const el = container.querySelector(`[data-wid="${id}"]`); + if (!(el instanceof HTMLElement)) continue; + const r = el.getBoundingClientRect(); + top = Math.min(top, r.top); + left = Math.min(left, r.left); + bottom = Math.max(bottom, r.bottom); + right = Math.max(right, r.right); + found = true; + } + + if (!found) return new DOMRect(); + return new DOMRect(left, top, right - left, bottom - top); +} + +/** + * Fixed, portaled anchoring against a live union of transcript word spans. + * Escapes overflow clipping on the transcript pane (and other workspace + * sections) the same way app menus do via Floating UI. + */ +export function useWordAnchorFloating({ + open, + wordIds, + containerRef, + placement = "top", + offsetMain = 8, + padding = 8, +}: { + open: boolean; + wordIds: number[] | null | undefined; + containerRef: RefObject; + placement?: Placement; + offsetMain?: number; + padding?: number; +}) { + const { refs, floatingStyles } = useFloating({ + open, + placement, + strategy: "fixed", + // Match usePopover: top/left positioning so nested fixed UI isn't trapped + // by a transformed ancestor. + transform: false, + whileElementsMounted: autoUpdate, + middleware: [ + offset(offsetMain), + flip({ padding }), + shift({ padding }), + ], + }); + + const { setFloating, setPositionReference } = refs; + // Primitive dep so a new number[] with the same ids doesn't reset the anchor. + const idsKey = wordIds?.join(",") ?? ""; + + useLayoutEffect(() => { + if (!open || !wordIds?.length) return; + const container = containerRef.current; + if (!container) return; + const ids = wordIds; + + setPositionReference({ + contextElement: container, + getBoundingClientRect: () => unionWordRects(container, ids), + }); + // eslint-disable-next-line react-hooks/exhaustive-deps -- idsKey stands in for wordIds + }, [open, idsKey, containerRef, setPositionReference]); + + return { setFloating, floatingStyles }; +}