diff --git a/webviews/codex-webviews/src/CodexCellEditor/BatchSelectionOverlay.tsx b/webviews/codex-webviews/src/CodexCellEditor/BatchSelectionOverlay.tsx new file mode 100644 index 000000000..04978d572 --- /dev/null +++ b/webviews/codex-webviews/src/CodexCellEditor/BatchSelectionOverlay.tsx @@ -0,0 +1,121 @@ +import React, { useEffect, useState, useRef, useCallback } from "react"; + +interface BatchSelectionOverlayProps { + cellIds: string[]; + containerRef: React.RefObject; + isDragging: boolean; +} + +/** + * Draws a straight vertical line from the midpoint of the first selected cell + * to the midpoint of the last selected cell. The real sparkle buttons on the + * endpoint cells serve as the visual anchors — this component is just the line. + */ +const BatchSelectionOverlay: React.FC = ({ + cellIds, + containerRef, + isDragging, +}) => { + const [position, setPosition] = useState<{ + top: number; + bottom: number; + left: number; + } | null>(null); + const rafRef = useRef(0); + + const measure = useCallback(() => { + if (!containerRef.current || cellIds.length === 0) { + setPosition(null); + return; + } + + const container = containerRef.current; + const containerRect = container.getBoundingClientRect(); + + const firstEl = container.querySelector( + `[data-cell-id="${cellIds[0]}"]` + ) as HTMLElement | null; + const lastEl = container.querySelector( + `[data-cell-id="${cellIds[cellIds.length - 1]}"]` + ) as HTMLElement | null; + + if (!firstEl || !lastEl) { + setPosition(null); + return; + } + + const firstRect = firstEl.getBoundingClientRect(); + const lastRect = lastEl.getBoundingClientRect(); + + // Snap to vertical midpoint of each cell + const firstMid = + firstRect.top + firstRect.height / 2 - containerRect.top + container.scrollTop; + const lastMid = + lastRect.top + lastRect.height / 2 - containerRect.top + container.scrollTop; + + // Find the sparkle button's horizontal center and half-height for inset + const sparkleBtn = firstEl.querySelector(".action-button-container button"); + let sparkleCenter = 24; // fallback + let sparkleHalfHeight = 8; // fallback (16px button / 2) + if (sparkleBtn) { + const btnRect = sparkleBtn.getBoundingClientRect(); + sparkleCenter = btnRect.left + btnRect.width / 2 - containerRect.left + container.scrollLeft; + sparkleHalfHeight = btnRect.height / 2; + } + + const topMid = Math.min(firstMid, lastMid); + const bottomMid = Math.max(firstMid, lastMid); + + setPosition({ + top: topMid + sparkleHalfHeight, + bottom: bottomMid - sparkleHalfHeight, + left: sparkleCenter, + }); + }, [cellIds, containerRef]); + + useEffect(() => { + measure(); + }, [measure]); + + // Re-measure on scroll and resize + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const onScroll = () => { + if (rafRef.current) cancelAnimationFrame(rafRef.current); + rafRef.current = requestAnimationFrame(measure); + }; + + container.addEventListener("scroll", onScroll, { passive: true }); + window.addEventListener("resize", onScroll, { passive: true }); + + return () => { + container.removeEventListener("scroll", onScroll); + window.removeEventListener("resize", onScroll); + if (rafRef.current) cancelAnimationFrame(rafRef.current); + }; + }, [containerRef, measure]); + + if (!position || cellIds.length < 2) return null; + + const lineHeight = position.bottom - position.top; + + return ( +
+ ); +}; + +export default React.memo(BatchSelectionOverlay); diff --git a/webviews/codex-webviews/src/CodexCellEditor/CellContentDisplay.tsx b/webviews/codex-webviews/src/CodexCellEditor/CellContentDisplay.tsx index d4bee15c3..dedb67d94 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/CellContentDisplay.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/CellContentDisplay.tsx @@ -74,6 +74,9 @@ interface CellContentDisplayProps { isAudioOnly?: boolean; showInlineBacktranslations?: boolean; backtranslation?: any; + onDragStart?: (cellId: string) => void; + hideSparkleButton?: boolean; + forceShowSparkleButton?: boolean; } const DEBUG_ENABLED = false; @@ -136,6 +139,9 @@ const CellContentDisplay: React.FC = React.memo( isAudioOnly = false, showInlineBacktranslations = false, backtranslation, + onDragStart, + hideSparkleButton = false, + forceShowSparkleButton = false, }) => { // const { cellContent, timestamps, editHistory } = cell; // I don't think we use this const cellIds = cell.cellMarkers; @@ -677,21 +683,22 @@ const CellContentDisplay: React.FC = React.memo( height: "16px", width: "16px", padding: 0, - display: "flex", + display: hideSparkleButton ? "none" : "flex", alignItems: "center", justifyContent: "center", position: "relative", - opacity: showSparkleButton + opacity: (showSparkleButton || forceShowSparkleButton) ? isCellLocked ? 0.5 : 1 : 0, transform: `translateX(${ - showSparkleButton ? "0" : "20px" - }) scale(${showSparkleButton ? 1 : 0})`, - transition: - "all 0.2s ease-in-out, transform 0.2s cubic-bezier(.68,-0.75,.27,1.75)", - visibility: showSparkleButton + (showSparkleButton || forceShowSparkleButton) ? "0" : "20px" + }) scale(${(showSparkleButton || forceShowSparkleButton) ? 1 : 0})`, + transition: forceShowSparkleButton + ? "none" + : "all 0.2s ease-in-out, transform 0.2s cubic-bezier(.68,-0.75,.27,1.75)", + visibility: (showSparkleButton || forceShowSparkleButton) ? "visible" : "hidden", cursor: isCellLocked @@ -699,6 +706,13 @@ const CellContentDisplay: React.FC = React.memo( : "pointer", }} disabled={false} + onMouseDown={(e) => { + if (onDragStart && !isCellLocked) { + e.preventDefault(); + e.stopPropagation(); + onDragStart(cellIds[0]); + } + }} onClick={(e) => { e.stopPropagation(); if (isCellLocked) { diff --git a/webviews/codex-webviews/src/CodexCellEditor/CellList.tsx b/webviews/codex-webviews/src/CodexCellEditor/CellList.tsx index a71271df6..838887d9d 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/CellList.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/CellList.tsx @@ -8,6 +8,7 @@ import { import React, { useMemo, useCallback, useState, useEffect, useRef, useContext } from "react"; import CellEditor from "./TextCellEditor"; import CellContentDisplay from "./CellContentDisplay"; +import BatchSelectionOverlay from "./BatchSelectionOverlay"; import EmptyCellDisplay from "./EmptyCellDisplay"; import { CELL_DISPLAY_MODES } from "../lib/types"; @@ -134,6 +135,16 @@ const CellList: React.FC = ({ // State to track unresolved comments count for each cell const [cellCommentsCount, setCellCommentsCount] = useState>(new Map()); + // Batch selection state for drag-to-select translation (supports multiple selections) + const [dragAnchorCellId, setDragAnchorCellId] = useState(null); + const [dragCurrentCellId, setDragCurrentCellId] = useState(null); + const [isDragging, setIsDragging] = useState(false); + const [batchSelections, setBatchSelections] = useState([]); + const cellListContainerRef = useRef(null); + const dragStartPos = useRef<{ x: number; y: number } | null>(null); + const dragConfirmed = useRef(false); + const wasDraggingRef = useRef(false); + // Filter out merged cells if we're in correction editor mode for source text const filteredTranslationUnits = useMemo(() => { let filtered = translationUnits; @@ -348,9 +359,40 @@ const CellList: React.FC = ({ ] ); + // Batch translation handler — does not clear the selection so it persists + const handleBatchTranslationFromSelection = useCallback( + (cellIds: string[]) => { + const cellIdSet = new Set(cellIds); + const selectedCells = workingTranslationUnits.filter((unit) => + cellIdSet.has(unit.cellMarkers[0]) + ); + if (selectedCells.length === 0) return; + + vscode.postMessage({ + command: "requestAutocompleteChapter", + content: selectedCells, + }); + }, + [workingTranslationUnits, vscode] + ); + // Handle sparkle button click with throttling const handleCellTranslation = useCallback( (cellId: string) => { + // Skip if we just finished a drag (click fires after mouseup) + if (wasDraggingRef.current) { + wasDraggingRef.current = false; + return; + } + + // If this cell is an endpoint of any batch selection, trigger batch translation + for (const sel of batchSelections) { + if (sel.length > 1 && (cellId === sel[0] || cellId === sel[sel.length - 1])) { + handleBatchTranslationFromSelection(sel); + return; + } + } + // Skip if this cell is already being translated if (isCellInTranslationProcess(cellId)) { return; @@ -381,7 +423,7 @@ const CellList: React.FC = ({ }); } }, - [isCellInTranslationProcess, vscode, lastRequestTime] + [isCellInTranslationProcess, vscode, lastRequestTime, batchSelections, handleBatchTranslationFromSelection] ); // When cells are added/removed from translation queue or completed @@ -779,6 +821,121 @@ const CellList: React.FC = ({ (window as any).openCellByIdForce = openCellByIdForce; + // Compute the set of cell IDs in the drag range + const dragRangeCellIds = useMemo(() => { + if (!dragConfirmed.current || !dragAnchorCellId || !dragCurrentCellId) return []; + + const ids = workingTranslationUnits.map((u) => u.cellMarkers[0]); + const anchorIdx = ids.indexOf(dragAnchorCellId); + const currentIdx = ids.indexOf(dragCurrentCellId); + if (anchorIdx === -1 || currentIdx === -1) return []; + + const start = Math.min(anchorIdx, currentIdx); + const end = Math.max(anchorIdx, currentIdx); + return ids.slice(start, end + 1); + }, [dragAnchorCellId, dragCurrentCellId, workingTranslationUnits]); + + // All active selections: persisted ones + current drag (if any) + const allActiveSelections = useMemo(() => { + const selections = [...batchSelections]; + if (isDragging && dragRangeCellIds.length > 1) { + selections.push(dragRangeCellIds); + } + return selections; + }, [batchSelections, isDragging, dragRangeCellIds]); + + // Union of all cell IDs across all selections (for hiding middle sparkles) + const activeBatchSet = useMemo(() => { + const set = new Set(); + for (const sel of allActiveSelections) { + for (const id of sel) set.add(id); + } + return set; + }, [allActiveSelections]); + + // Union of all endpoints across all selections (for keeping endpoint sparkles visible) + const batchEndpoints = useMemo(() => { + const set = new Set(); + for (const sel of allActiveSelections) { + if (sel.length >= 2) { + set.add(sel[0]); + set.add(sel[sel.length - 1]); + } + } + return set; + }, [allActiveSelections]); + + // Drag start handler — called from sparkle button onMouseDown + const handleDragStart = useCallback((cellId: string) => { + setDragAnchorCellId(cellId); + setDragCurrentCellId(cellId); + setIsDragging(true); + dragConfirmed.current = false; + dragStartPos.current = null; + }, []); + + // Global mousemove / mouseup during drag + useEffect(() => { + if (!isDragging) return; + + const DRAG_THRESHOLD = 5; // px before we confirm it's a drag + + const handleMouseMove = (e: MouseEvent) => { + // Record initial position on first move + if (!dragStartPos.current) { + dragStartPos.current = { x: e.clientX, y: e.clientY }; + return; + } + + // Check threshold before confirming drag + if (!dragConfirmed.current) { + const dx = e.clientX - dragStartPos.current.x; + const dy = e.clientY - dragStartPos.current.y; + if (Math.sqrt(dx * dx + dy * dy) < DRAG_THRESHOLD) return; + dragConfirmed.current = true; + } + + // Find the closest cell element under the cursor + const els = document.elementsFromPoint(e.clientX, e.clientY); + for (const el of els) { + const cellEl = (el as HTMLElement).closest?.("[data-cell-id]"); + if (cellEl) { + const id = cellEl.getAttribute("data-cell-id"); + if (id) { + setDragCurrentCellId(id); + return; + } + } + } + }; + + const handleMouseUp = () => { + const wasDrag = dragConfirmed.current; + setIsDragging(false); + + if (wasDrag && dragRangeCellIds.length > 1) { + // Append new selection + setBatchSelections((prev) => [...prev, dragRangeCellIds]); + wasDraggingRef.current = true; // prevent sparkle click + } else { + wasDraggingRef.current = false; + } + + setDragAnchorCellId(null); + setDragCurrentCellId(null); + dragConfirmed.current = false; + dragStartPos.current = null; + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + }, [isDragging, dragRangeCellIds]); + + const renderCellGroup = useCallback( (group: typeof workingTranslationUnits, startIndex: number) => ( = ({ isAudioOnly={isAudioOnly} showInlineBacktranslations={showInlineBacktranslations} backtranslation={backtranslationsMap.get(cellMarkers[0])} + onDragStart={!isSourceText ? handleDragStart : undefined} + hideSparkleButton={activeBatchSet.has(cellMarkers[0]) && !batchEndpoints.has(cellMarkers[0])} + forceShowSparkleButton={batchEndpoints.has(cellMarkers[0])} /> ); @@ -873,6 +1033,9 @@ const CellList: React.FC = ({ userAccessLevel, isAudioOnly, lineNumbersEnabled, + handleDragStart, + activeBatchSet, + batchEndpoints, ] ); @@ -1008,6 +1171,9 @@ const CellList: React.FC = ({ isAudioOnly={isAudioOnly} showInlineBacktranslations={showInlineBacktranslations} backtranslation={backtranslationsMap.get(cellMarkers[0])} + onDragStart={!isSourceText ? handleDragStart : undefined} + hideSparkleButton={activeBatchSet.has(cellMarkers[0]) && !batchEndpoints.has(cellMarkers[0])} + forceShowSparkleButton={batchEndpoints.has(cellMarkers[0])} /> ); @@ -1056,6 +1222,9 @@ const CellList: React.FC = ({ requiredAudioValidations, isAudioOnly, isAuthenticated, + handleDragStart, + activeBatchSet, + batchEndpoints, ]); // Fetch comments count for all visible cells (batched) @@ -1140,6 +1309,7 @@ const CellList: React.FC = ({ return (
= ({ // Keep minimal breathing room above the first cell to match prior layout paddingTop: "0.25rem", paddingBottom: "4rem", + position: "relative", }} > + {batchSelections.map((sel, i) => + sel.length > 1 ? ( + + ) : null + )} + {isDragging && dragRangeCellIds.length > 1 && ( + + )} {renderCells()}
);