From 2a2feb14adeb8c2a8a47a82a258d4280bb8382a5 Mon Sep 17 00:00:00 2001 From: Christopher Serr Date: Tue, 14 Apr 2026 21:13:55 +0200 Subject: [PATCH] Enable Run Editor Modifier Selection This makes Run Editor rows and text boxes treat `Shift`, `Ctrl`, and `Meta` mouse gestures as selection modifiers instead of ignoring them. Before this, modifier clicks did not produce additive or range selection reliably because rows and inputs used different event paths and browser focus changes interfered with the editor state. --- livesplit-core | 2 +- src/ui/views/RunEditor.tsx | 242 ++++++++++++++++++++++++++++++++++--- 2 files changed, 227 insertions(+), 17 deletions(-) diff --git a/livesplit-core b/livesplit-core index c2682f49..cb5f1368 160000 --- a/livesplit-core +++ b/livesplit-core @@ -1 +1 @@ -Subproject commit c2682f49537a03bc57facf376053f1cf63ab89dd +Subproject commit cb5f1368eb6cabe1e5c85c27a152112329fc5879 diff --git a/src/ui/views/RunEditor.tsx b/src/ui/views/RunEditor.tsx index 9a241e50..c6801070 100644 --- a/src/ui/views/RunEditor.tsx +++ b/src/ui/views/RunEditor.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; import * as LiveSplit from "../../livesplit-core"; import { FILE_EXT_IMAGES, @@ -84,6 +84,9 @@ interface RowState { index: number; } +type SegmentSelectionState = + LiveSplit.RunEditorStateJson["segments"][number]["selected"]; + enum Tab { RealTime, GameTime, @@ -845,6 +848,8 @@ function SegmentsTable({ lang: LiveSplit.Language | undefined; }) { const [dragIndex, setDragIndex] = useState(0); + const skipNextFocusedSegmentSelection = useRef(false); + const segmentNameInputRefs = useRef<(HTMLInputElement | null)[]>([]); const [rowState, setRowState] = useState(() => ({ bestSegmentTime: "", bestSegmentTimeChanged: false, @@ -857,6 +862,82 @@ function SegmentsTable({ splitTimeChanged: false, })); + const handleSegmentInputMouseDown = ( + event: React.MouseEvent, + index: number, + selectionState: SegmentSelectionState, + ) => { + event.stopPropagation(); + + const preserveCurrentFocus = shouldPreserveCurrentFocus( + event, + selectionState, + ); + const focusClickedRow = shouldFocusClickedRow(event, selectionState); + + if (event.shiftKey || preserveCurrentFocus) { + event.preventDefault(); + } + + skipNextFocusedSegmentSelection.current = focusClickedRow; + + changeSegmentSelection( + event, + index, + selectionState, + editor, + rowState, + setRowState, + update, + ); + + if (event.shiftKey && focusClickedRow) { + event.currentTarget.focus(); + } + }; + + const handleSegmentInputClick = ( + event: React.MouseEvent, + ) => { + // Input selection is already handled on mousedown so the row's click + // handler must not run again on mouseup. + event.stopPropagation(); + }; + + const handleSegmentRowMouseDown = ( + event: React.MouseEvent, + index: number, + selectionState: SegmentSelectionState, + ) => { + const preserveCurrentFocus = shouldPreserveCurrentFocus( + event, + selectionState, + ); + const focusClickedRow = shouldFocusClickedRow(event, selectionState); + + if (event.shiftKey || preserveCurrentFocus) { + // Modifier-based selection should not trigger native text selection + // behavior or move focus away from the currently edited field. + event.preventDefault(); + } + + skipNextFocusedSegmentSelection.current = focusClickedRow; + + changeSegmentSelection( + event, + index, + selectionState, + editor, + rowState, + setRowState, + update, + ); + + if (focusClickedRow) { + segmentNameInputRefs.current[index]?.focus(); + } + }; + return ( @@ -934,12 +1015,11 @@ function SegmentsTable({ ? tableClasses.selected : "" } - onClick={(e) => - changeSegmentSelection( + onMouseDown={(e) => + handleSegmentRowMouseDown( e, segmentIndex, - editor, - update, + s.selected, ) } > @@ -966,11 +1046,25 @@ function SegmentsTable({ { + segmentNameInputRefs.current[ + segmentIndex + ] = element; + }} value={s.name} + onClick={handleSegmentInputClick} + onMouseDown={(e) => + handleSegmentInputMouseDown( + e, + segmentIndex, + s.selected, + ) + } onFocus={(_) => focusSegment( segmentIndex, editor, + skipNextFocusedSegmentSelection, rowState, setRowState, update, @@ -992,10 +1086,19 @@ function SegmentsTable({ ? rowState.splitTime : s.split_time } + onClick={handleSegmentInputClick} + onMouseDown={(e) => + handleSegmentInputMouseDown( + e, + segmentIndex, + s.selected, + ) + } onFocus={(_) => focusSegment( segmentIndex, editor, + skipNextFocusedSegmentSelection, rowState, setRowState, update, @@ -1035,10 +1138,19 @@ function SegmentsTable({ ? rowState.segmentTime : s.segment_time } + onClick={handleSegmentInputClick} + onMouseDown={(e) => + handleSegmentInputMouseDown( + e, + segmentIndex, + s.selected, + ) + } onFocus={(_) => focusSegment( segmentIndex, editor, + skipNextFocusedSegmentSelection, rowState, setRowState, update, @@ -1072,10 +1184,19 @@ function SegmentsTable({ ? rowState.bestSegmentTime : s.best_segment_time } + onClick={handleSegmentInputClick} + onMouseDown={(e) => + handleSegmentInputMouseDown( + e, + segmentIndex, + s.selected, + ) + } onFocus={(_) => focusSegment( segmentIndex, editor, + skipNextFocusedSegmentSelection, rowState, setRowState, update, @@ -1118,10 +1239,19 @@ function SegmentsTable({ ] : comparisonTime } + onClick={handleSegmentInputClick} + onMouseDown={(e) => + handleSegmentInputMouseDown( + e, + segmentIndex, + s.selected, + ) + } onFocus={(_) => focusSegment( segmentIndex, editor, + skipNextFocusedSegmentSelection, rowState, setRowState, update, @@ -2562,17 +2692,68 @@ function getSegmentIconUrl( } function changeSegmentSelection( - event: React.MouseEvent, - i: number, + event: + | React.MouseEvent + | React.MouseEvent, + index: number, + selectionState: SegmentSelectionState, editor: LiveSplit.RunEditorRefMut, - update: () => void, + rowState: RowState, + setRowState: (rowState: RowState) => void, + update: () => LiveSplit.RunEditorStateJson, ) { - if (!event.currentTarget.classList.contains(tableClasses.selected)) { - editor.selectAdditionally(i); + if (event.shiftKey) { + editor.selectRange(index); + } else if (event.ctrlKey || event.metaKey) { + if (selectionState === "Selected") { + editor.unselect(index); + } else { + editor.selectAdditionally(index); + } } else { - editor.unselect(i); + editor.selectOnly(index); } - update(); + + const editorState = update(); + setFocusedSegmentRowState( + editorState, + getActiveSegmentIndex(editorState, index), + rowState, + setRowState, + ); +} + +function shouldPreserveCurrentFocus( + event: React.MouseEvent, + selectionState: SegmentSelectionState, +) { + return (event.ctrlKey || event.metaKey) && selectionState === "Selected"; +} + +function shouldFocusClickedRow( + event: React.MouseEvent, + selectionState: SegmentSelectionState, +) { + if (event.shiftKey) { + return true; + } + + if (event.ctrlKey || event.metaKey) { + return selectionState !== "Selected"; + } + + return true; +} + +function getActiveSegmentIndex( + editorState: LiveSplit.RunEditorStateJson, + fallbackIndex: number, +) { + const activeIndex = editorState.segments.findIndex( + (segment) => segment.selected === "Active", + ); + + return activeIndex === -1 ? fallbackIndex : activeIndex; } async function changeSegmentIcon( @@ -2610,16 +2791,45 @@ function removeSegmentIcon( } function focusSegment( - i: number, + index: number, editor: LiveSplit.RunEditorRefMut, + skipNextFocusedSegmentSelection: React.MutableRefObject, rowState: RowState, setRowState: (rowState: RowState) => void, update: () => LiveSplit.RunEditorStateJson, ) { - editor.selectOnly(i); + // Mouse-based selection is handled on mousedown so modifier keys can change + // selection without the subsequent focus event collapsing it back to a + // single row. Keyboard focus still falls back to exclusive selection. + if (skipNextFocusedSegmentSelection.current) { + skipNextFocusedSegmentSelection.current = false; + const editorState = update(); + setFocusedSegmentRowState( + editorState, + getActiveSegmentIndex(editorState, index), + rowState, + setRowState, + ); + return; + } + + editor.selectOnly(index); const editorState = update(); + setFocusedSegmentRowState( + editorState, + getActiveSegmentIndex(editorState, index), + rowState, + setRowState, + ); +} - const comparisonTimes = editorState.segments[i].comparison_times; +function setFocusedSegmentRowState( + editorState: LiveSplit.RunEditorStateJson, + index: number, + rowState: RowState, + setRowState: (rowState: RowState) => void, +) { + const comparisonTimes = editorState.segments[index].comparison_times; setRowState({ ...rowState, splitTimeChanged: false, @@ -2627,7 +2837,7 @@ function focusSegment( bestSegmentTimeChanged: false, comparisonTimes, comparisonTimesChanged: comparisonTimes.map(() => false), - index: i, + index, }); }