From 4ab1b0134988231413e1e9eb03a7ffbb5a68f36f Mon Sep 17 00:00:00 2001 From: PexEric Date: Wed, 26 Aug 2026 03:29:29 +0800 Subject: [PATCH 1/6] feat(timeline): proportional stretch of multi-block selections --- src/stores/project/lines-slice.ts | 8 + src/stores/project/types.ts | 2 + src/views/timeline/stretch-drag.ts | 94 +++ src/views/timeline/stretch-selection.test.ts | 604 ++++++++++++++++++ src/views/timeline/stretch-selection.ts | 79 +++ src/views/timeline/stretch-targets.ts | 213 ++++++ src/views/timeline/use-selection-stretch.ts | 233 +++++++ .../word-track.stretch.browser.test.tsx | 248 +++++++ src/views/timeline/word-track.tsx | 19 +- 9 files changed, 1499 insertions(+), 1 deletion(-) create mode 100644 src/views/timeline/stretch-drag.ts create mode 100644 src/views/timeline/stretch-selection.test.ts create mode 100644 src/views/timeline/stretch-selection.ts create mode 100644 src/views/timeline/stretch-targets.ts create mode 100644 src/views/timeline/use-selection-stretch.ts create mode 100644 src/views/timeline/word-track.stretch.browser.test.tsx diff --git a/src/stores/project/lines-slice.ts b/src/stores/project/lines-slice.ts index 41f22a28..bac789a6 100644 --- a/src/stores/project/lines-slice.ts +++ b/src/stores/project/lines-slice.ts @@ -33,6 +33,14 @@ const createLinesSlice: StateCreator set({ lines, isDirty: true, isDirtySinceHistory: true }), + // Transient in-flight gesture preview: replaces lines WITHOUT touching + // history or dirty flags. The caller must hold the pre-gesture snapshot and + // either restore it or commit through an action with history before the + // gesture ends, and must verify (by array identity) that no other writer + // replaced lines mid-gesture. Reserved for drag previews (e.g. the timeline + // selection stretch) — never for durable edits. + setTransientLines: (lines) => set({ lines }), + setLinesWithHistory: (lines, groups) => set((state) => commitHistory(state, groups ? { lines, groups } : { lines })), updateLine: (id, updates, options = {}) => diff --git a/src/stores/project/types.ts b/src/stores/project/types.ts index c6acfbff..35c5e67b 100644 --- a/src/stores/project/types.ts +++ b/src/stores/project/types.ts @@ -127,6 +127,8 @@ interface HistoryActions { interface LineActions { setLines: (lines: LyricLine[]) => void; + // See the JSDoc on the implementation in lines-slice.ts before using this. + setTransientLines: (lines: LyricLine[]) => void; setLinesWithHistory: (lines: LyricLine[], groups?: LinkGroup[]) => void; updateLine: (id: string, updates: Partial, options?: { deriveText?: boolean }) => void; updateLineWithHistory: ( diff --git a/src/views/timeline/stretch-drag.ts b/src/views/timeline/stretch-drag.ts new file mode 100644 index 00000000..c4a5cdb3 --- /dev/null +++ b/src/views/timeline/stretch-drag.ts @@ -0,0 +1,94 @@ +import type { LyricLine } from "@/domain/line/model"; +import type { WordTiming } from "@/domain/word/timing"; +import { + STRETCH_EPS, + type StretchAnchor, + type StretchClampOptions, + type StretchSelectionRef, + deriveBounds, + isFiniteWord, + resolveStretchTargets, + trackWords, +} from "@/views/timeline/stretch-targets"; + +// -- Types --------------------------------------------------------------------- + +interface StretchDragRef { + lineId: string; + type: "word" | "bg"; + wordIndex: number; + edge: "left" | "right"; +} + +interface StretchDragPlan { + anchor: StretchAnchor; + // Fixed point of the affine map (t0 for "start", t1 for "end"). + anchorTime: number; + // Original time of the dragged edge (the opposite word-block extreme). + edgeTime: number; + minFactor: number; + maxFactor: number; +} + +// -- Public API ---------------------------------------------------------------- + +// A block-edge drag becomes a proportional stretch only when the grip sits on +// the selection's own boundary: the left edge of the earliest selected word +// block (anchor "end") or the right edge of the latest one (anchor "start"). +// Internal edges and single-word selections keep the plain resize behaviour — +// which is what makes arbitrary contiguous sub-ranges (e.g. CJK) stretchable. +function planStretchDrag( + rawLines: LyricLine[], + selections: ReadonlyArray, + drag: StretchDragRef, + options: StretchClampOptions, +): StretchDragPlan | null { + const targets = resolveStretchTargets(rawLines, selections); + if (!targets) return null; + + let count = 0; + let w0 = Number.POSITIVE_INFINITY; + let w1 = Number.NEGATIVE_INFINITY; + for (const track of targets.tracks.values()) { + const words = trackWords(track); + for (const idx of track.indices) { + const word = words[idx]; + if (!isFiniteWord(word)) continue; + count++; + if (word.begin < w0) w0 = word.begin; + if (word.end > w1) w1 = word.end; + } + } + // One block is a plain resize; line-synced rows never add a grip. + if (count < 2) return null; + + const draggedTrack = targets.tracks.get(`${drag.lineId}:${drag.type}`); + const draggedWords = draggedTrack ? trackWords(draggedTrack) : null; + const draggedWord = draggedTrack?.indices.has(drag.wordIndex) + ? (draggedWords?.[drag.wordIndex] as WordTiming | undefined) + : undefined; + if (!isFiniteWord(draggedWord)) return null; + + const anchor: StretchAnchor = drag.edge === "right" ? "start" : "end"; + const edgeTime = anchor === "start" ? draggedWord.end : draggedWord.begin; + // The grip must be the selection's own extreme on that side. + if (Math.abs(edgeTime - (anchor === "start" ? w1 : w0)) > STRETCH_EPS) return null; + + const bounds = deriveBounds(targets, { ...options, anchor }); + if (!bounds) return null; + // Degenerate grip-to-anchor distance (possible only when a line-synced row + // defines the anchor and both words sit on top of it). + if (Math.abs(edgeTime - bounds.anchorTime) <= STRETCH_EPS) return null; + + return { + anchor, + anchorTime: bounds.anchorTime, + edgeTime, + minFactor: bounds.kLo, + maxFactor: bounds.kHi, + }; +} + +// -- Exports ------------------------------------------------------------------- + +export { planStretchDrag }; diff --git a/src/views/timeline/stretch-selection.test.ts b/src/views/timeline/stretch-selection.test.ts new file mode 100644 index 00000000..cc573336 --- /dev/null +++ b/src/views/timeline/stretch-selection.test.ts @@ -0,0 +1,604 @@ +/** + * @vitest-environment node + */ +import type { LyricLine } from "@/domain/line/model"; +import { describe, expect, it } from "vitest"; +import { planStretchDrag } from "./stretch-drag"; +import { stretchSelections } from "./stretch-selection"; + +// -- Helpers -------------------------------------------------------------------- + +function makeLine(id: string, words: { text: string; begin: number; end: number }[]): LyricLine { + return { id, text: words.map((w) => w.text).join(""), agentId: "v1", words }; +} + +const OPTS = { duration: 60, minWordDuration: 0.1 }; + +const word = (lineId: string, wordIndex: number) => ({ lineId, type: "word" as const, wordIndex }); + +// -- planStretchDrag ------------------------------------------------------------ + +describe("planStretchDrag", () => { + const lines = [ + makeLine("L", [ + { text: "a ", begin: 0, end: 1 }, + { text: "b ", begin: 1, end: 2 }, + { text: "c", begin: 2, end: 3 }, + ]), + ]; + + it("plans a left-anchored stretch when dragging the right edge of the last selected word", () => { + const plan = planStretchDrag( + lines, + [word("L", 1), word("L", 2)], + { lineId: "L", type: "word", wordIndex: 2, edge: "right" }, + OPTS, + ); + expect(plan?.anchor).toBe("start"); + expect(plan?.anchorTime).toBeCloseTo(1); + expect(plan?.edgeTime).toBeCloseTo(3); + expect(plan!.minFactor).toBeLessThanOrEqual(1); + expect(plan!.maxFactor).toBeGreaterThanOrEqual(1); + }); + + it("plans a right-anchored stretch when dragging the left edge of the first selected word", () => { + const plan = planStretchDrag( + lines, + [word("L", 1), word("L", 2)], + { lineId: "L", type: "word", wordIndex: 1, edge: "left" }, + OPTS, + ); + expect(plan?.anchor).toBe("end"); + expect(plan?.anchorTime).toBeCloseTo(3); + expect(plan?.edgeTime).toBeCloseTo(1); + }); + + it("rejects single-word selections so plain resize keeps working (CJK fix)", () => { + const plan = planStretchDrag( + lines, + [word("L", 0)], + { lineId: "L", type: "word", wordIndex: 0, edge: "right" }, + OPTS, + ); + expect(plan).toBeNull(); + }); + + it("rejects internal edges — the grip must sit on the selection's own extreme", () => { + const drag = (wordIndex: number, edge: "left" | "right") => ({ + lineId: "L", + type: "word" as const, + wordIndex, + edge, + }); + // Right edge of the first selected word: max end belongs to word 2. + expect(planStretchDrag(lines, [word("L", 1), word("L", 2)], drag(1, "right"), OPTS)).toBeNull(); + // Left edge of the last selected word: min begin belongs to word 1. + expect(planStretchDrag(lines, [word("L", 1), word("L", 2)], drag(2, "left"), OPTS)).toBeNull(); + }); + + it("rejects a word that is not selected", () => { + const plan = planStretchDrag( + lines, + [word("L", 1), word("L", 2)], + { lineId: "L", type: "word", wordIndex: 0, edge: "right" }, + OPTS, + ); + expect(plan).toBeNull(); + }); + + it("counts word blocks only — line-synced rows never add a grip", () => { + const mixed: LyricLine[] = [ + makeLine("A", [{ text: "a", begin: 1, end: 2 }]), + { id: "B", text: "y", agentId: "v1", begin: 2, end: 6 }, + ]; + const plan = planStretchDrag( + mixed, + [word("A", 0), word("B", 0)], + { lineId: "A", type: "word", wordIndex: 0, edge: "right" }, + OPTS, + ); + expect(plan).toBeNull(); + }); + + it("supports bg grips and mixed main+bg selections across extremes", () => { + const mixed: LyricLine[] = [ + { + id: "A", + text: "main", + agentId: "v1", + words: [{ text: "main", begin: 2, end: 4 }], + backgroundWords: [{ text: "ooh", begin: 0, end: 1 }], + }, + ]; + const plan = planStretchDrag( + mixed, + [word("A", 0), { lineId: "A", type: "bg", wordIndex: 0 }], + { lineId: "A", type: "word", wordIndex: 0, edge: "right" }, + OPTS, + ); + expect(plan?.anchor).toBe("start"); + // Word extremes cover both tracks: min begin 0 (bg), max end 4 (main). + expect(plan?.anchorTime).toBeCloseTo(0); + expect(plan?.edgeTime).toBeCloseTo(4); + }); + + it("caps maxFactor at the right neighbour", () => { + const neighbour = [ + makeLine("L", [ + { text: "a ", begin: 1, end: 2 }, + { text: "b", begin: 2, end: 3 }, + { text: "c", begin: 5, end: 6 }, + ]), + ]; + const plan = planStretchDrag( + neighbour, + [word("L", 0), word("L", 1)], + { lineId: "L", type: "word", wordIndex: 1, edge: "right" }, + OPTS, + ); + // kHi = (5 - 1) / (3 - 1) = 2. + expect(plan?.maxFactor).toBeCloseTo(2); + }); + + it("raises minFactor when a word is shorter than minWordDuration", () => { + const short = [ + makeLine("L", [ + { text: "tiny ", begin: 0, end: 0.05 }, + { text: "b", begin: 0.05, end: 2 }, + ]), + ]; + const plan = planStretchDrag( + short, + [word("L", 0), word("L", 1)], + { lineId: "L", type: "word", wordIndex: 1, edge: "right" }, + OPTS, + ); + // Word 0 (dur 0.05) must reach 0.1 → kLo = 2. + expect(plan?.minFactor).toBeCloseTo(2); + }); + + it("returns null for empty selection, ghost lineIds, zero-span and non-finite input", () => { + expect(planStretchDrag(lines, [], { lineId: "L", type: "word", wordIndex: 2, edge: "right" }, OPTS)).toBeNull(); + expect( + planStretchDrag(lines, [word("ghost", 0)], { lineId: "ghost", type: "word", wordIndex: 0, edge: "right" }, OPTS), + ).toBeNull(); + // Zero span: two stacked words at the same instant. + const stacked = [ + makeLine("L", [ + { text: "a", begin: 1, end: 1 }, + { text: "b", begin: 1, end: 1 }, + ]), + ]; + expect( + planStretchDrag( + stacked, + [word("L", 0), word("L", 1)], + { lineId: "L", type: "word", wordIndex: 1, edge: "right" }, + OPTS, + ), + ).toBeNull(); + expect( + planStretchDrag( + lines, + [word("L", 1), word("L", 2)], + { lineId: "L", type: "word", wordIndex: 2, edge: "right" }, + { duration: Number.NaN, minWordDuration: 0.1 }, + ), + ).toBeNull(); + }); + + it("returns null when the feasible interval is empty", () => { + // tiny (0.05s < minWordDuration) sits flush against a right neighbour: + // growing to the minimum would overlap it. + const tight = [ + makeLine("L", [ + { text: "tiny ", begin: 1, end: 1.05 }, + { text: "mid", begin: 1.05, end: 1.2 }, + { text: "next", begin: 1.2, end: 4 }, + ]), + ]; + const plan = planStretchDrag( + tight, + [word("L", 0), word("L", 1)], + { lineId: "L", type: "word", wordIndex: 1, edge: "right" }, + OPTS, + ); + expect(plan).toBeNull(); + }); +}); + +// -- stretchSelections · normal scaling (anchor: start) -------------------------- + +describe("stretchSelections · normal scaling", () => { + it("scales a word run 2x around the anchor, preserving order, text and untouched words", () => { + const lines = [ + makeLine("L", [ + { text: "keep ", begin: 0, end: 0.5 }, + { text: "a ", begin: 1, end: 2 }, + { text: "b", begin: 3, end: 4 }, + ]), + ]; + const result = stretchSelections(lines, [word("L", 1), word("L", 2)], 2, OPTS); + expect(result.appliedFactor).toBeCloseTo(2); + expect(result.updates).toHaveLength(1); + const words = result.updates[0].updates.words!; + // Anchor = 1 (min begin of selection) stays fixed. + expect(words[1].begin).toBeCloseTo(1); + expect(words[1].end).toBeCloseTo(3); + expect(words[2].begin).toBeCloseTo(5); + expect(words[2].end).toBeCloseTo(7); + expect(words[1].text).toBe("a "); + expect(words[2].text).toBe("b"); + // Unselected word untouched by reference. + expect(words[0]).toBe(lines[0].words![0]); + }); + + it("shrinks toward the anchor with k < 1", () => { + const lines = [ + makeLine("L", [ + { text: "a ", begin: 2, end: 4 }, + { text: "b", begin: 4, end: 6 }, + ]), + ]; + const result = stretchSelections(lines, [word("L", 0), word("L", 1)], 0.5, OPTS); + const words = result.updates[0].updates.words!; + expect(words[0].begin).toBeCloseTo(2); + expect(words[0].end).toBeCloseTo(3); + expect(words[1].begin).toBeCloseTo(3); + expect(words[1].end).toBeCloseTo(4); + }); + + it("scales a cross-line selection around the global anchor", () => { + const lines: LyricLine[] = [ + makeLine("A", [{ text: "a", begin: 1, end: 2 }]), + makeLine("B", [{ text: "b", begin: 3, end: 5 }]), + ]; + const result = stretchSelections(lines, [word("A", 0), word("B", 0)], 2, OPTS); + expect(result.updates).toHaveLength(2); + const lineA = result.updates.find((u) => u.id === "A")!.updates.words!; + const lineB = result.updates.find((u) => u.id === "B")!.updates.words!; + expect(lineA[0].begin).toBeCloseTo(1); + expect(lineA[0].end).toBeCloseTo(3); + expect(lineB[0].begin).toBeCloseTo(5); + expect(lineB[0].end).toBeCloseTo(9); + }); + + it("does NOT expand syllable groups — exactly the selected words stretch", () => { + // "hel" + "lo " form a trailing-space syllable group. Only "hel" and "lo" + // are explicitly selected (with "world" beyond them), so a 2x stretch maps + // the pair flush onto "world" without touching anything else. + const lines = [ + makeLine("L", [ + { text: "hel", begin: 1, end: 2 }, + { text: "lo ", begin: 2, end: 3 }, + { text: "world", begin: 5, end: 7 }, + ]), + ]; + const result = stretchSelections(lines, [word("L", 0), word("L", 1)], 2, OPTS); + const words = result.updates[0].updates.words!; + expect(result.appliedFactor).toBeCloseTo(2); + expect(words[0].begin).toBeCloseTo(1); + expect(words[0].end).toBeCloseTo(3); + expect(words[1].begin).toBeCloseTo(3); + expect(words[1].end).toBeCloseTo(5); + expect(words[2].begin).toBeCloseTo(5); + expect(words[2].end).toBeCloseTo(7); + }); + + it("clamps a partial-group grow at its groupmate instead of crossing it", () => { + // Only "hel" selected: its right neighbour is groupmate "lo " at 2, so a + // 2x request is clamped back to 1 (no-op). + const lines = [ + makeLine("L", [ + { text: "hel", begin: 1, end: 2 }, + { text: "lo ", begin: 2, end: 3 }, + ]), + ]; + const result = stretchSelections(lines, [word("L", 0)], 2, OPTS); + expect(result.appliedFactor).toBe(1); + expect(result.updates).toHaveLength(0); + }); +}); + +// -- stretchSelections · anchor: end --------------------------------------------- + +describe("stretchSelections · anchor end", () => { + // Words at 6..10 with a 1s gap to a left neighbour at 0..1: growing leftward + // with anchor 10 has room up to kHi = (10 - 1) / (10 - 6) = 2.25. + const lines = [ + makeLine("L", [ + { text: "block ", begin: 0, end: 1 }, + { text: "a ", begin: 6, end: 8 }, + { text: "b", begin: 8, end: 10 }, + ]), + ]; + const sel = [word("L", 1), word("L", 2)]; + + it("grows leftward 2x with the right edge pinned", () => { + const result = stretchSelections(lines, sel, 2, { ...OPTS, anchor: "end" }); + expect(result.appliedFactor).toBeCloseTo(2); + const words = result.updates[0].updates.words!; + // Anchor t1 = 10 stays fixed; distances from it double. + expect(words[1].begin).toBeCloseTo(2); + expect(words[1].end).toBeCloseTo(6); + expect(words[2].begin).toBeCloseTo(6); + expect(words[2].end).toBeCloseTo(10); + expect(words[0]).toBe(lines[0].words![0]); + }); + + it("shrinks toward the right anchor with k < 1", () => { + const result = stretchSelections(lines, sel, 0.5, { ...OPTS, anchor: "end" }); + const words = result.updates[0].updates.words!; + expect(words[1].begin).toBeCloseTo(8); + expect(words[1].end).toBeCloseTo(9); + expect(words[2].begin).toBeCloseTo(9); + expect(words[2].end).toBeCloseTo(10); + }); + + it("clamps leftward growth flush at the left neighbour", () => { + const tight = [ + makeLine("L", [ + { text: "block ", begin: 0, end: 4 }, + { text: "a ", begin: 6, end: 8 }, + { text: "b", begin: 8, end: 10 }, + ]), + ]; + // kHi = (10 - 4) / (10 - 6) = 1.5. + const result = stretchSelections(tight, sel, 3, { ...OPTS, anchor: "end" }); + expect(result.appliedFactor).toBeCloseTo(1.5); + const words = result.updates[0].updates.words!; + expect(words[1].begin).toBeCloseTo(4); + expect(words[1].end).toBeCloseTo(7); + expect(words[2].end).toBeCloseTo(10); + }); + + it("clamps leftward growth at time 0 when there is no left neighbour", () => { + const open = [ + makeLine("L", [ + { text: "a ", begin: 4, end: 6 }, + { text: "b", begin: 6, end: 10 }, + ]), + ]; + // kHi = 10 / (10 - 4) = 5/3. + const result = stretchSelections(open, [word("L", 0), word("L", 1)], 10, { ...OPTS, anchor: "end" }); + expect(result.appliedFactor).toBeCloseTo(5 / 3); + const words = result.updates[0].updates.words!; + expect(words[0].begin).toBeCloseTo(0); + expect(words[0].end).toBeCloseTo(10 / 3); + expect(words[1].end).toBeCloseTo(10); + }); + + it("clamps shrink at minWordDuration measured on the shortest word", () => { + const result = stretchSelections(lines, sel, 0.01, { ...OPTS, anchor: "end" }); + expect(result.appliedFactor).toBeCloseTo(0.05); + const words = result.updates[0].updates.words!; + expect(words[1].end - words[1].begin).toBeCloseTo(0.1); + }); + + it("maps a mixed line-synced row around the right anchor", () => { + const mixed: LyricLine[] = [ + makeLine("A", [{ text: "a", begin: 2, end: 4 }]), + { id: "B", text: "y", agentId: "v1", begin: 0, end: 1 }, + ]; + const result = stretchSelections(mixed, [word("A", 0), word("B", 0)], 0.5, { ...OPTS, anchor: "end" }); + // Word anchor t1 = 4 (line A); line B maps around it. + const lineA = result.updates.find((u) => u.id === "A")!.updates.words!; + const lineB = result.updates.find((u) => u.id === "B")!.updates; + expect(lineA[0].begin).toBeCloseTo(3); + expect(lineA[0].end).toBeCloseTo(4); + expect(lineB.begin).toBeCloseTo(2); + expect(lineB.end).toBeCloseTo(2.5); + }); +}); + +// -- stretchSelections · clamping ------------------------------------------------ + +describe("stretchSelections · clamping", () => { + it("clamps growth at a right-side unselected neighbour and lands flush", () => { + const lines = [ + makeLine("L", [ + { text: "a ", begin: 1, end: 2 }, + { text: "block", begin: 3, end: 4 }, + ]), + ]; + // Selection = word 0 only; grow beyond the neighbour start is impossible. + // Right bound: k <= (3 - 1) / (2 - 1) = 2. + const result = stretchSelections(lines, [word("L", 0)], 5, OPTS); + expect(result.appliedFactor).toBeCloseTo(2); + const words = result.updates[0].updates.words!; + expect(words[0].end).toBeCloseTo(3); + expect(words[1].begin).toBeCloseTo(3); + }); + + it("clamps shrink at a left-side unselected neighbour on another row", () => { + // Row A anchors T0 at 0; row B's selected word has a non-selected left + // neighbour ending at 4.8 → shrink pulls its begin toward 4.8: k >= 4.8/5. + const lines: LyricLine[] = [ + makeLine("A", [{ text: "anchor", begin: 0, end: 1 }]), + makeLine("B", [ + { text: "block ", begin: 4.2, end: 4.8 }, + { text: "far", begin: 5, end: 6 }, + ]), + ]; + const result = stretchSelections(lines, [word("A", 0), word("B", 1)], 0.5, OPTS); + expect(result.appliedFactor).toBeCloseTo(0.96); + const lineB = result.updates.find((u) => u.id === "B")!.updates.words!; + expect(lineB[1].begin).toBeCloseTo(4.8); + expect(lineB[1].end).toBeCloseTo(5.76); + expect(lineB[0]).toBe((lines[1] as { words: { text: string }[] }).words[0]); + }); + + it("clamps shrink at minWordDuration so the shortest word lands exactly on the minimum", () => { + const lines = [ + makeLine("L", [ + { text: "short ", begin: 1, end: 1.5 }, + { text: "long", begin: 1.5, end: 3.5 }, + ]), + ]; + // minFactor = 0.1 / 0.5 = 0.2. + const result = stretchSelections(lines, [word("L", 0), word("L", 1)], 0.1, OPTS); + expect(result.appliedFactor).toBeCloseTo(0.2); + const words = result.updates[0].updates.words!; + expect(words[0].end - words[0].begin).toBeCloseTo(0.1); + expect(words[1].end - words[1].begin).toBeCloseTo(0.4); + }); + + it("clamps growth at audio duration", () => { + const lines = [makeLine("L", [{ text: "a", begin: 50, end: 55 }])]; + // Right bound from duration: k <= (60 - 50) / 5 = 2. + const result = stretchSelections(lines, [word("L", 0)], 10, { duration: 60, minWordDuration: 0.1 }); + expect(result.appliedFactor).toBeCloseTo(2); + expect(result.updates[0].updates.words![0].end).toBeCloseTo(60); + }); + + it("grows to the minimum when a word already violates minWordDuration", () => { + const lines = [ + makeLine("L", [ + { text: "tiny ", begin: 1, end: 1.05 }, + { text: "b", begin: 1.05, end: 3 }, + ]), + ]; + // minFactor = 0.1 / 0.05 = 2 → even a shrink request grows to exactly 2. + const result = stretchSelections(lines, [word("L", 0), word("L", 1)], 0.5, OPTS); + expect(result.appliedFactor).toBeCloseTo(2); + const words = result.updates[0].updates.words!; + expect(words[0].end - words[0].begin).toBeCloseTo(0.1); + }); +}); + +// -- stretchSelections · tracks and line types ----------------------------------- + +describe("stretchSelections · tracks and line types", () => { + it("scales background selections and stamps manual provenance", () => { + const lines: LyricLine[] = [ + { + id: "A", + text: "main", + agentId: "v1", + words: [{ text: "main", begin: 0, end: 1 }], + backgroundText: "ooh ahh", + backgroundWords: [ + { text: "ooh ", begin: 2, end: 3 }, + { text: "ahh", begin: 3, end: 5 }, + ], + backgroundTextSource: "extraction", + }, + ]; + const result = stretchSelections( + lines, + [ + { lineId: "A", type: "bg", wordIndex: 0 }, + { lineId: "A", type: "bg", wordIndex: 1 }, + ], + 2, + OPTS, + ); + expect(result.updates).toHaveLength(1); + const update = result.updates[0].updates; + const bg = update.backgroundWords!; + expect(bg[0].begin).toBeCloseTo(2); + expect(bg[0].end).toBeCloseTo(4); + expect(bg[1].begin).toBeCloseTo(4); + expect(bg[1].end).toBeCloseTo(8); + expect(update.backgroundTextSource).toBe("manual"); + // Main words untouched. + expect(update.words).toBeUndefined(); + }); + + it("merges main and background selections into a single update entry per line", () => { + const lines: LyricLine[] = [ + { + id: "A", + text: "main", + agentId: "v1", + words: [ + { text: "main ", begin: 1, end: 2 }, + { text: "x", begin: 2, end: 4 }, + ], + backgroundWords: [{ text: "ooh", begin: 1, end: 4 }], + }, + ]; + const result = stretchSelections(lines, [word("A", 1), { lineId: "A", type: "bg", wordIndex: 0 }], 2, OPTS); + expect(result.updates).toHaveLength(1); + const update = result.updates[0].updates; + expect(update.words!.length).toBe(2); + expect(update.backgroundWords).toBeDefined(); + }); + + it("scales line-synced begin/end with the same factor as word-synced rows", () => { + const lines: LyricLine[] = [ + makeLine("A", [{ text: "a", begin: 1, end: 2 }]), + { id: "B", text: "y", agentId: "v1", begin: 2, end: 6 }, + ]; + const result = stretchSelections(lines, [word("A", 0), word("B", 0)], 2, OPTS); + expect(result.updates).toHaveLength(2); + const lineB = result.updates.find((u) => u.id === "B")!.updates; + // Global anchor t0 = 1 (from line A); line B maps around it. + expect(lineB.begin).toBeCloseTo(3); + expect(lineB.end).toBeCloseTo(11); + const lineA = result.updates.find((u) => u.id === "A")!.updates.words!; + expect(lineA[0].end).toBeCloseTo(3); + }); + + it("clamps a line-synced-only selection at duration", () => { + const lines: LyricLine[] = [{ id: "B", text: "y", agentId: "v1", begin: 50, end: 55 }]; + const result = stretchSelections(lines, [word("B", 0)], 10, { duration: 60, minWordDuration: 0.1 }); + // duration bound: k <= (60 - 50) / 5 = 2. + expect(result.appliedFactor).toBeCloseTo(2); + expect(result.updates[0].updates.end).toBeCloseTo(60); + }); +}); + +// -- stretchSelections · degenerate cases ---------------------------------------- + +describe("stretchSelections · degenerate cases", () => { + const lines = [ + makeLine("L", [ + { text: "a ", begin: 1, end: 2 }, + { text: "b", begin: 2, end: 4 }, + ]), + ]; + const selections = [word("L", 0), word("L", 1)]; + + it("is a no-op for factor 1", () => { + const result = stretchSelections(lines, selections, 1, OPTS); + expect(result.appliedFactor).toBe(1); + expect(result.updates).toHaveLength(0); + }); + + it("is a no-op for empty selections and ghost lineIds", () => { + expect(stretchSelections(lines, [], 2, OPTS).updates).toHaveLength(0); + expect(stretchSelections(lines, [word("ghost", 0)], 2, OPTS).updates).toHaveLength(0); + }); + + it.each([0, -1, Number.NaN, Number.POSITIVE_INFINITY])("is a no-op for invalid factor %s", (factor) => { + const result = stretchSelections(lines, selections, factor, OPTS); + expect(result.appliedFactor).toBe(1); + expect(result.updates).toHaveLength(0); + }); + + it("is a no-op when duration or word timings are non-finite (no NaN writes)", () => { + const nanDuration = stretchSelections(lines, selections, 2, { duration: Number.NaN, minWordDuration: 0.1 }); + expect(nanDuration.updates).toHaveLength(0); + + const nanWords: LyricLine[] = [ + { id: "L", text: "a b", agentId: "v1", words: [{ text: "a", begin: Number.NaN, end: 2 }] }, + ]; + const nanTiming = stretchSelections(nanWords, [word("L", 0)], 2, OPTS); + expect(nanTiming.updates).toHaveLength(0); + }); + + it("returns empty updates when the feasible interval is empty", () => { + // A word shorter than minWordDuration sitting flush against a right + // neighbour: growing to the minimum would overlap it. + const tight = [ + makeLine("L", [ + { text: "tiny ", begin: 1, end: 1.05 }, + { text: "next", begin: 1.05, end: 4 }, + ]), + ]; + const result = stretchSelections(tight, [word("L", 0)], 1.5, OPTS); + expect(result.updates).toHaveLength(0); + expect(result.appliedFactor).toBe(1); + }); +}); diff --git a/src/views/timeline/stretch-selection.ts b/src/views/timeline/stretch-selection.ts new file mode 100644 index 00000000..09444d67 --- /dev/null +++ b/src/views/timeline/stretch-selection.ts @@ -0,0 +1,79 @@ +import { manualBackgroundWordEdit } from "@/domain/line/background"; +import type { LyricLine } from "@/domain/line/model"; +import { + STRETCH_EPS, + type StretchClampOptions, + type StretchSelectionRef, + deriveBounds, + resolveStretchTargets, + trackWords, +} from "@/views/timeline/stretch-targets"; + +// -- Types --------------------------------------------------------------------- + +interface StretchResult { + appliedFactor: number; + updates: Array<{ id: string; updates: Partial }>; +} + +// -- Public API ---------------------------------------------------------------- + +// Maps the selected words (and any line-synced rows riding along) affinely +// around the anchor: newX = anchorTime + (x - anchorTime) * k. The requested +// factor is clamped to the feasible interval derived from non-selected +// neighbours, minWordDuration and the audio bounds. +function stretchSelections( + rawLines: LyricLine[], + selections: ReadonlyArray, + requestedFactor: number, + options: StretchClampOptions, +): StretchResult { + const noop: StretchResult = { appliedFactor: 1, updates: [] }; + if (!Number.isFinite(requestedFactor) || requestedFactor <= 0) return noop; + + const targets = resolveStretchTargets(rawLines, selections); + if (!targets) return noop; + const bounds = deriveBounds(targets, options); + if (!bounds) return noop; + + const k = Math.min(Math.max(requestedFactor, bounds.kLo), bounds.kHi); + if (!Number.isFinite(k) || Math.abs(k - 1) < STRETCH_EPS) return noop; + + const mapTime = (x: number) => bounds.anchorTime + (x - bounds.anchorTime) * k; + + // Merge all track updates of one line into a single entry so + // updateLinesWithHistory never receives duplicate ids. + const updatesByLine = new Map }>(); + const entryFor = (lineId: string) => { + let entry = updatesByLine.get(lineId); + if (!entry) { + entry = { id: lineId, updates: {} }; + updatesByLine.set(lineId, entry); + } + return entry; + }; + + for (const track of targets.tracks.values()) { + const words = trackWords(track); + const updatedWords = words.map((word, i) => + track.indices.has(i) ? { ...word, begin: mapTime(word.begin), end: mapTime(word.end) } : word, + ); + if (track.type === "word") { + Object.assign(entryFor(track.line.id).updates, { words: updatedWords }); + } else { + Object.assign(entryFor(track.line.id).updates, manualBackgroundWordEdit(updatedWords)); + } + } + for (const line of targets.lineSynced) { + Object.assign(entryFor(line.id).updates, { + begin: mapTime(line.begin), + end: mapTime(line.end), + }); + } + + return { appliedFactor: k, updates: [...updatesByLine.values()] }; +} + +// -- Exports ------------------------------------------------------------------- + +export { stretchSelections }; diff --git a/src/views/timeline/stretch-targets.ts b/src/views/timeline/stretch-targets.ts new file mode 100644 index 00000000..443a6b3a --- /dev/null +++ b/src/views/timeline/stretch-targets.ts @@ -0,0 +1,213 @@ +import type { LineSyncedLine, LyricLine } from "@/domain/line/model"; +import { isLineSynced, isWordSynced } from "@/domain/line/predicates"; +import type { WordTiming } from "@/domain/word/timing"; + +// -- Types --------------------------------------------------------------------- + +interface StretchSelectionRef { + lineId: string; + type: "word" | "bg"; + wordIndex: number; +} + +// "start" pins the selection's earliest time (drag the right edge), +// "end" pins its latest time (drag the left edge). +type StretchAnchor = "start" | "end"; + +interface StretchClampOptions { + duration: number; + minWordDuration: number; + anchor?: StretchAnchor; +} + +// A track is one word array of one line: (lineId × "word" | "bg"). +interface StretchTrack { + line: LyricLine; + type: "word" | "bg"; + indices: Set; +} + +interface StretchTargets { + tracks: Map; + // Narrowed by the isLineSynced guard during partitioning, so begin/end are + // plain numbers downstream — no `as number` assertions needed. + lineSynced: LineSyncedLine[]; +} + +// -- Constants ----------------------------------------------------------------- + +// Floats at flush boundaries: clamps use inclusive bounds with an epsilon so a +// "grow to exactly touch the neighbour" request applies instead of shying off +// by a rounding error. +const STRETCH_EPS = 1e-6; + +// -- Target resolution --------------------------------------------------------- + +// Unlike the nudge partitioner, syllable groups are NOT expanded: a stretch +// range is exactly what the user selected. Expanding matters for CJK lines, +// where the whole line forms one space-delimited group — expansion would force +// every stretch to cover the entire sentence and make arbitrary contiguous +// sub-ranges unstretchable. +function resolveStretchTargets( + rawLines: LyricLine[], + selections: ReadonlyArray, +): StretchTargets | null { + const linesById = new Map(); + for (const l of rawLines) linesById.set(l.id, l); + + const tracks = new Map(); + const seenWord = new Set(); + const lineSynced: LineSyncedLine[] = []; + const seenLineSynced = new Set(); + + const pushWord = (sel: StretchSelectionRef, line: LyricLine) => { + const words = sel.type === "bg" ? line.backgroundWords : line.words; + if (!words || words[sel.wordIndex] === undefined) return; + const key = `${sel.lineId}:${sel.type}:${sel.wordIndex}`; + if (seenWord.has(key)) return; + seenWord.add(key); + const trackKey = `${sel.lineId}:${sel.type}`; + let track = tracks.get(trackKey); + if (!track) { + track = { line, type: sel.type, indices: new Set() }; + tracks.set(trackKey, track); + } + track.indices.add(sel.wordIndex); + }; + + for (const sel of selections) { + const line = linesById.get(sel.lineId); + if (!line) continue; + // bg words ride along whatever timing shape the line has (same routing as + // the nudge partitioner). + if (sel.type === "bg" || isWordSynced(line)) { + pushWord(sel, line); + } else if (isLineSynced(line)) { + if (seenLineSynced.has(sel.lineId)) continue; + seenLineSynced.add(sel.lineId); + lineSynced.push(line); + } + } + + if (tracks.size === 0 && lineSynced.length === 0) return null; + return { tracks, lineSynced }; +} + +function trackWords(track: StretchTrack): WordTiming[] { + return (track.type === "word" ? track.line.words : track.line.backgroundWords) as WordTiming[]; +} + +function isFiniteWord(word: WordTiming | undefined): word is WordTiming { + return !!word && Number.isFinite(word.begin) && Number.isFinite(word.end); +} + +// -- Constraint derivation ----------------------------------------------------- + +// Every selected item maps affinely around the anchor A: newX = A + (x - A) * k +// with k > 0, which is strictly increasing — selected items can never start +// overlapping each other. Only non-selected neighbours and global bounds +// constrain k (per item, b = begin, e = end, L = left neighbour end or 0, +// R = right neighbour begin or duration): +// min duration k >= minWordDuration / (e - b) +// grow past L (b < A) k <= (A - L) / (A - b) +// grow past R (e > A) k <= (R - A) / (e - A) +// shrink across L k >= (L - A) / (b - A) [b > A, L > A] +// shrink across R k >= (A - R) / (A - e) [e < A, R < A] +// At k = 1 every bound is satisfied for valid input, so 1 is always feasible +// unless a word already sits below minWordDuration. +function deriveBounds( + targets: StretchTargets, + options: StretchClampOptions, +): { t0: number; t1: number; anchorTime: number; kLo: number; kHi: number } | null { + // Non-finite duration (streams without metadata) or corrupt timings must not + // leak NaN into the factor — every bound below is checked before use. + if (!Number.isFinite(options.duration) || !Number.isFinite(options.minWordDuration)) return null; + + // Word-block extremes define the selection's sides (the grips). Line-synced + // rows scale along but never define the anchor; a pure line-synced selection + // falls back to its own extremes for the plain mapping API. + let t0 = Number.POSITIVE_INFINITY; + let t1 = Number.NEGATIVE_INFINITY; + let hasWords = false; + for (const track of targets.tracks.values()) { + const words = trackWords(track); + for (const idx of track.indices) { + const word = words[idx]; + if (!isFiniteWord(word)) continue; + hasWords = true; + if (word.begin < t0) t0 = word.begin; + if (word.end > t1) t1 = word.end; + } + } + if (!hasWords) { + for (const line of targets.lineSynced) { + if (line.begin < t0) t0 = line.begin; + if (line.end > t1) t1 = line.end; + } + } + + const span = t1 - t0; + if (!Number.isFinite(t0) || !Number.isFinite(t1) || span <= STRETCH_EPS) return null; + const anchorTime = options.anchor === "end" ? t1 : t0; + + let kLo = 0; + let kHi = Number.POSITIVE_INFINITY; + + for (const track of targets.tracks.values()) { + const words = trackWords(track); + for (const idx of track.indices) { + const word = words[idx]; + if (!isFiniteWord(word)) continue; + const b = word.begin; + const e = word.end; + if (e - b > STRETCH_EPS) kLo = Math.max(kLo, options.minWordDuration / (e - b)); + // Nearest non-selected neighbours, skipping selected indices. + let leftEnd = 0; + for (let i = idx - 1; i >= 0; i--) { + if (!track.indices.has(i)) { + leftEnd = words[i].end; + break; + } + } + let rightBegin = options.duration; + for (let i = idx + 1; i < words.length; i++) { + if (!track.indices.has(i)) { + rightBegin = words[i].begin; + break; + } + } + if (b > anchorTime + STRETCH_EPS && leftEnd > anchorTime + STRETCH_EPS) { + kLo = Math.max(kLo, (leftEnd - anchorTime) / (b - anchorTime)); + } + if (b < anchorTime - STRETCH_EPS) { + kHi = Math.min(kHi, (anchorTime - leftEnd) / (anchorTime - b)); + } + if (e > anchorTime + STRETCH_EPS) { + kHi = Math.min(kHi, (rightBegin - anchorTime) / (e - anchorTime)); + } + if (e < anchorTime - STRETCH_EPS && rightBegin < anchorTime - STRETCH_EPS) { + kLo = Math.max(kLo, (anchorTime - rightBegin) / (anchorTime - e)); + } + } + } + + // Line-synced rows: same affine map on begin/end. Rows may overlap in time, + // so only min-duration and the global 0/duration bounds apply (mirrors + // shiftLineSyncedRows in utils.ts). + for (const line of targets.lineSynced) { + const b = line.begin; + const e = line.end; + if (!Number.isFinite(b) || !Number.isFinite(e)) continue; + if (e - b > STRETCH_EPS) kLo = Math.max(kLo, options.minWordDuration / (e - b)); + if (b < anchorTime - STRETCH_EPS) kHi = Math.min(kHi, anchorTime / (anchorTime - b)); + if (e > anchorTime + STRETCH_EPS) kHi = Math.min(kHi, (options.duration - anchorTime) / (e - anchorTime)); + } + + if (kLo > kHi + STRETCH_EPS) return null; + return { t0, t1, anchorTime, kLo, kHi }; +} + +// -- Exports ------------------------------------------------------------------- + +export { deriveBounds, isFiniteWord, resolveStretchTargets, STRETCH_EPS, trackWords }; +export type { StretchAnchor, StretchClampOptions, StretchSelectionRef }; diff --git a/src/views/timeline/use-selection-stretch.ts b/src/views/timeline/use-selection-stretch.ts new file mode 100644 index 00000000..23c7b9a0 --- /dev/null +++ b/src/views/timeline/use-selection-stretch.ts @@ -0,0 +1,233 @@ +import { type LyricLine, reconcileLine } from "@/domain/line/model"; +import { useAudioStore } from "@/stores/audio"; +import { useProjectStore } from "@/stores/project"; +import { useSettingsStore } from "@/stores/settings"; +import { DRAG_THRESHOLD_PX } from "@/views/timeline/drag-threshold"; +import { selfKey } from "@/views/timeline/snap"; +import { planStretchDrag } from "@/views/timeline/stretch-drag"; +import { stretchSelections } from "@/views/timeline/stretch-selection"; +import { STRETCH_EPS } from "@/views/timeline/stretch-targets"; +import { useTimelineStore } from "@/views/timeline/timeline-store"; +import { useSnapBypass } from "@/views/timeline/use-snap-bypass"; +import { useTimelineSnap } from "@/views/timeline/use-timeline-snap"; +import { useCallback, useEffect, useRef, useState } from "react"; + +// -- Types --------------------------------------------------------------------- + +interface StretchDragArgs { + lineId: string; + type: "word" | "bg"; + wordIndex: number; + edge: "left" | "right"; + startX: number; +} + +interface UseSelectionStretchOptions { + // Mirrors word-track's justResized guard: invoked with dragged=true when a + // stretch gesture moved past the drag threshold, so the trailing click does + // not rewrite the selection. + onDragEnd?: (dragged: boolean) => void; +} + +interface UseSelectionStretchDrag { + isStretching: boolean; + // Returns true when the drag was claimed as a proportional stretch; the + // caller must then skip its plain resize path. + tryStart: (args: StretchDragArgs) => boolean; +} + +// -- Constants ----------------------------------------------------------------- + +// Commit only when the factor moved meaningfully away from 1; mirrors the +// single-word resize's "did the timing actually change" guard. +const COMMIT_FACTOR_EPS = 1e-3; + +// -- Helpers ------------------------------------------------------------------- + +// Preview writes go through setTransientLines: transient states that must not +// mark the project dirty. The committed result goes through +// updateLinesWithHistory, which owns history and dirty flags. The action keeps +// the array reference, which the drag relies on for its external-writer check. +function writePreviewLines(lines: LyricLine[]): void { + useProjectStore.getState().setTransientLines(lines); +} + +// Applies stretch updates to the pre-drag snapshot, mirroring how +// updateLinesWithHistory reconciles each line (no sibling propagation — the +// commit opts out of it too). +function applyStretchUpdates( + snapshotLines: LyricLine[], + updates: ReadonlyArray<{ id: string; updates: Partial }>, +): LyricLine[] { + const updatesById = new Map(updates.map((u) => [u.id, u.updates])); + return snapshotLines.map((line) => { + const lineUpdates = updatesById.get(line.id); + return lineUpdates ? reconcileLine({ ...line, ...lineUpdates }) : line; + }); +} + +// -- Hook ---------------------------------------------------------------------- + +// Proportional stretch of a multi-block selection, driven by dragging the +// selection's boundary edge on a word block: right edge → anchored at the +// selection's start, left edge → anchored at its end. Everything the drag +// needs is captured from store snapshots at pointerdown, so listeners never +// see stale component state. +function useSelectionStretchDrag({ onDragEnd }: UseSelectionStretchOptions = {}): UseSelectionStretchDrag { + const [isStretching, setIsStretching] = useState(false); + const cleanupRef = useRef<(() => void) | null>(null); + const lastPointerRef = useRef<{ clientX: number; clientY: number } | null>(null); + const onDragEndRef = useRef(onDragEnd); + + useEffect(() => { + onDragEndRef.current = onDragEnd; + }, [onDragEnd]); + + const snap = useTimelineSnap(); + const { beginGesture, computeShiftPx, endGesture } = snap; + const getLastPointer = useCallback(() => lastPointerRef.current, []); + useSnapBypass({ active: isStretching, getLastPointer }); + + // A drag cut short by unmount (undo rewrote lines, project cleared, tab + // switched) must restore the snapshotted lines — otherwise the transient + // preview would be stranded in the store with no history entry to undo it. + // Depend on endGesture (a stable useCallback), NOT the snap object — + // useTimelineSnap returns a fresh object every render, so an object dep + // would re-run this cleanup on every preview frame and tear down the gesture. + useEffect(() => { + return () => { + cleanupRef.current?.(); + endGesture(); + }; + }, [endGesture]); + + const tryStart = useCallback( + ({ lineId, type, wordIndex, edge, startX }: StretchDragArgs): boolean => { + const snapshotLines = useProjectStore.getState().lines; + const selection = useTimelineStore.getState().selectedWords; + const options = { + duration: useAudioStore.getState().duration, + minWordDuration: useSettingsStore.getState().minWordDuration, + }; + const plan = planStretchDrag(snapshotLines, selection, { lineId, type, wordIndex, edge }, options); + if (!plan) return false; + + // A second pointerdown before the first gesture finished (multi-touch, + // stuck pointer) must tear down the first gesture first — same re-entry + // guard as use-timeline-dnd. + cleanupRef.current?.(); + cleanupRef.current = null; + + const zoom = useTimelineStore.getState().zoom; + const { anchor, anchorTime, edgeTime, minFactor, maxFactor } = plan; + const selfIds = new Set(selection.map((s) => selfKey(s.lineId, s.wordIndex, s.type))); + + // Factor of distances from the anchor implied by a dragged-edge time. + const factorForEdgeTime = (t: number) => + anchorTime < edgeTime ? (t - anchorTime) / (edgeTime - anchorTime) : (anchorTime - t) / (anchorTime - edgeTime); + + setIsStretching(true); + lastPointerRef.current = { clientX: startX, clientY: 0 }; + + beginGesture({ + selfIds, + // Synthetic key: matches no word block, so snap highlights never light + // up a block that is not actually being dragged. + leaderKey: `stretch:${anchorTime}:${edgeTime}`, + overlapCheck: (shiftSec) => { + const k = factorForEdgeTime(edgeTime + shiftSec); + return k >= minFactor - STRETCH_EPS && k <= maxFactor + STRETCH_EPS; + }, + }); + + let dragged = false; + // Escape finishes with commit=false, but the pending pointerup listener + // would then run finish(true) again — guard so a discarded drag can never + // be committed by the trailing pointerup. + let finished = false; + let currentFactor = 1; + // Array reference of the last preview write (null before the first one). + // Used to detect external writers (undo, import, project clear) that + // replaced lines mid-drag. + let lastWritten: LyricLine[] | null = null; + + function preview(k: number): void { + const result = stretchSelections(snapshotLines, selection, k, { ...options, anchor }); + lastWritten = applyStretchUpdates(snapshotLines, result.updates); + writePreviewLines(lastWritten); + } + + function handleMove(ev: PointerEvent): void { + lastPointerRef.current = { clientX: ev.clientX, clientY: ev.clientY }; + if (Math.abs(ev.clientX - startX) >= DRAG_THRESHOLD_PX) dragged = true; + if (!dragged) return; + const rawDeltaPx = ev.clientX - startX; + const snapShiftPx = computeShiftPx(rawDeltaPx, [edgeTime]); + const proposedEdge = edgeTime + (rawDeltaPx + snapShiftPx) / zoom; + const k = Math.min(Math.max(factorForEdgeTime(proposedEdge), minFactor), maxFactor); + if (k === currentFactor) return; + currentFactor = k; + preview(k); + } + + function handleUp(): void { + finish(true); + } + + function handleCancel(): void { + finish(false); + } + + function handleKey(ev: KeyboardEvent): void { + if (ev.key === "Escape") finish(false); + } + + function teardown(): void { + document.removeEventListener("pointermove", handleMove); + document.removeEventListener("pointerup", handleUp); + document.removeEventListener("pointercancel", handleCancel); + document.removeEventListener("keydown", handleKey); + cleanupRef.current = null; + } + + function finish(commit: boolean): void { + if (finished) return; + finished = true; + setIsStretching(false); + endGesture(); + // If an external writer (Ctrl+Z mid-drag, import, project clear) + // replaced lines since our last preview, restoring the snapshot would + // swallow their change — abandon the gesture and keep their state. + const stillOurs = useProjectStore.getState().lines === (lastWritten ?? snapshotLines); + if (stillOurs) { + // Restore the snapshot first so the history entry below captures the + // true pre-drag state (and a discarded drag leaves no trace at all). + writePreviewLines(snapshotLines); + } + teardown(); + if (stillOurs && commit && dragged && Math.abs(currentFactor - 1) >= COMMIT_FACTOR_EPS) { + const result = stretchSelections(snapshotLines, selection, currentFactor, { ...options, anchor }); + if (result.updates.length > 0) { + useProjectStore.getState().updateLinesWithHistory(result.updates, { propagateToSiblings: false }); + } + } + onDragEndRef.current?.(dragged); + } + + document.addEventListener("pointermove", handleMove); + document.addEventListener("pointerup", handleUp); + document.addEventListener("pointercancel", handleCancel); + document.addEventListener("keydown", handleKey); + // The unmount path shares the discarded-drag path: restore and release. + cleanupRef.current = () => finish(false); + return true; + }, + [beginGesture, computeShiftPx, endGesture], + ); + + return { isStretching, tryStart }; +} + +// -- Exports ------------------------------------------------------------------- + +export { useSelectionStretchDrag }; diff --git a/src/views/timeline/word-track.stretch.browser.test.tsx b/src/views/timeline/word-track.stretch.browser.test.tsx new file mode 100644 index 00000000..9b5dac90 --- /dev/null +++ b/src/views/timeline/word-track.stretch.browser.test.tsx @@ -0,0 +1,248 @@ +import type { WordTiming } from "@/domain/word/timing"; +import { useAudioStore } from "@/stores/audio"; +import { useProjectStore } from "@/stores/project"; +import { useSettingsStore } from "@/stores/settings"; +import { createLine, createWord } from "@/test/factories"; +import { render } from "@/test/render"; +import { useTimelineStore } from "@/views/timeline/timeline-store"; +import { WordTrack } from "@/views/timeline/word-track"; +import { describe, expect, it } from "vitest"; + +// Proportional stretch of a multi-block selection is driven through the word +// block edge grips: the right edge of the latest selected block anchors at the +// selection start, the left edge of the earliest one anchors at its end. + +const ZOOM = 100; // px per second +const START_X = 200; + +interface Fixture { + lineId: string; + blocks: HTMLElement[]; + original: WordTiming[]; +} + +async function renderStretchTrack( + words: WordTiming[], + selected: number[], + opts: { snap?: boolean; onUpdateWord?: () => void } = {}, +) { + const line = createLine({ words }); + useProjectStore.setState({ lines: [line] }); + useTimelineStore.setState({ + zoom: ZOOM, + selectedWords: selected.map((wordIndex) => ({ + lineId: line.id, + lineIndex: 0, + wordIndex, + type: "word" as const, + })), + }); + useAudioStore.setState({ duration: 60 }); + useSettingsStore.setState({ + minWordDuration: 0.1, + timelineSnap: opts.snap === true, + ...(opts.snap === true ? { timelineSnapThreshold: 8 } : {}), + }); + + const screen = await render( + {})} + />, + { dndContext: true }, + ); + const blocks = [...screen.container.querySelectorAll("[data-word-block]")]; + const fixture: Fixture = { + lineId: line.id, + blocks, + original: useProjectStore.getState().lines[0].words ?? [], + }; + return fixture; +} + +function storeWords(): WordTiming[] { + const line = useProjectStore.getState().lines[0]; + return line.words ?? []; +} + +function pressEdge(block: HTMLElement, edge: "left" | "right"): void { + const el = block.querySelector(`[data-edge="${edge}"]`) as HTMLElement; + el.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0, clientX: START_X })); +} + +function movePointer(dxPx: number): void { + document.dispatchEvent(new PointerEvent("pointermove", { bubbles: true, clientX: START_X + dxPx })); +} + +function releasePointer(dxPx: number): void { + document.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, clientX: START_X + dxPx })); +} + +function dragEdge(block: HTMLElement, edge: "left" | "right", dxPx: number): void { + pressEdge(block, edge); + movePointer(dxPx); + releasePointer(dxPx); +} + +function pressEscape(): void { + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); +} + +describe("WordTrack selection stretch", () => { + it("commits a left-anchored stretch from the right edge of the last selected word", async () => { + const words = [ + createWord({ text: "我 ", begin: 0, end: 1 }), + createWord({ text: "爱 ", begin: 1, end: 2 }), + createWord({ text: "你", begin: 5, end: 6 }), + ]; + const { blocks } = await renderStretchTrack(words, [0, 1]); + + // +100px at zoom 100 → edge 2s → 3s → k = (3 - 0) / (2 - 0) = 1.5. + dragEdge(blocks[1], "right", 100); + + const final = storeWords(); + expect(final[0].begin).toBeCloseTo(0, 5); + expect(final[0].end).toBeCloseTo(1.5, 5); + expect(final[1].begin).toBeCloseTo(1.5, 5); + expect(final[1].end).toBeCloseTo(3, 5); + expect(final[2].begin).toBeCloseTo(5, 5); + expect(final[2].end).toBeCloseTo(6, 5); + }); + + it("previews live in the store during the drag and settles on release", async () => { + const words = [ + createWord({ text: "我 ", begin: 0, end: 1 }), + createWord({ text: "爱 ", begin: 1, end: 2 }), + createWord({ text: "你", begin: 5, end: 6 }), + ]; + const { blocks } = await renderStretchTrack(words, [0, 1]); + + pressEdge(blocks[1], "right"); + movePointer(100); + const preview = storeWords(); + expect(preview[0].end).toBeCloseTo(1.5, 5); + expect(preview[1].end).toBeCloseTo(3, 5); + + releasePointer(100); + const final = storeWords(); + expect(final[0].end).toBeCloseTo(1.5, 5); + expect(final[1].end).toBeCloseTo(3, 5); + }); + + it("commits a right-anchored stretch from the left edge of the first selected word", async () => { + const words = [ + createWord({ text: "前 ", begin: 0, end: 1 }), + createWord({ text: "爱 ", begin: 2, end: 3 }), + createWord({ text: "你", begin: 3, end: 4 }), + ]; + const { blocks } = await renderStretchTrack(words, [1, 2]); + + // -50px at zoom 100 → edge 2s → 1.5s; anchor t1 = 4 → k = (4 - 1.5) / 2 = 1.25. + dragEdge(blocks[1], "left", -50); + + const final = storeWords(); + expect(final[0].begin).toBeCloseTo(0, 5); + expect(final[0].end).toBeCloseTo(1, 5); + expect(final[1].begin).toBeCloseTo(1.5, 5); + expect(final[1].end).toBeCloseTo(2.75, 5); + expect(final[2].begin).toBeCloseTo(2.75, 5); + expect(final[2].end).toBeCloseTo(4, 5); + }); + + it("keeps plain resize for a single selected block (no stretch writes)", async () => { + const words = [createWord({ text: "我 ", begin: 0, end: 1 }), createWord({ text: "爱", begin: 1, end: 2 })]; + let resizeCommitted = false; + const { blocks, original } = await renderStretchTrack(words, [1], { + onUpdateWord: () => { + resizeCommitted = true; + }, + }); + + dragEdge(blocks[1], "right", 100); + + // The resize path committed through onUpdateWord; the stretch path never + // touched the store directly. + expect(resizeCommitted).toBe(true); + expect(storeWords()).toEqual(original); + }); + + it("restores the snapshot when Escape cancels the drag", async () => { + const words = [ + createWord({ text: "我 ", begin: 0, end: 1 }), + createWord({ text: "爱 ", begin: 1, end: 2 }), + createWord({ text: "你", begin: 5, end: 6 }), + ]; + const { blocks, original } = await renderStretchTrack(words, [0, 1]); + + pressEdge(blocks[1], "right"); + movePointer(100); + pressEscape(); + releasePointer(100); + + expect(storeWords()).toEqual(original); + }); + + it("commits nothing when the pointer never crosses the drag threshold", async () => { + const words = [ + createWord({ text: "我 ", begin: 0, end: 1 }), + createWord({ text: "爱 ", begin: 1, end: 2 }), + createWord({ text: "你", begin: 5, end: 6 }), + ]; + const { blocks, original } = await renderStretchTrack(words, [0, 1]); + + dragEdge(blocks[1], "right", 1); + + expect(storeWords()).toEqual(original); + }); + + it("snaps the dragged edge onto an unselected neighbour boundary when snapping is on", async () => { + const words = [ + createWord({ text: "我 ", begin: 0, end: 1 }), + createWord({ text: "爱", begin: 1, end: 2 }), + createWord({ text: "远", begin: 5, end: 6 }), + ]; + const { blocks } = await renderStretchTrack(words, [0, 1], { snap: true }); + + // +295px → proposed edge 4.95s; the unselected neighbour begins at 5s, + // 5px away (within the 8px threshold) → the edge snaps flush onto it and + // k = (5 - 0) / (2 - 0) = 2.5. + dragEdge(blocks[1], "right", 295); + + const final = storeWords(); + expect(final[0].begin).toBeCloseTo(0, 5); + expect(final[0].end).toBeCloseTo(2.5, 5); + expect(final[1].begin).toBeCloseTo(2.5, 5); + expect(final[1].end).toBeCloseTo(5, 5); + expect(final[2].begin).toBeCloseTo(5, 5); + }); + + it("keeps an external mid-drag lines write instead of restoring the snapshot", async () => { + const words = [ + createWord({ text: "我 ", begin: 0, end: 1 }), + createWord({ text: "爱 ", begin: 1, end: 2 }), + createWord({ text: "你", begin: 5, end: 6 }), + ]; + const { blocks } = await renderStretchTrack(words, [0, 1]); + + pressEdge(blocks[1], "right"); + movePointer(100); + // Simulate an external writer (Ctrl+Z, import, project clear) replacing + // lines while the drag is still in flight. + const external = createLine({ words: [createWord({ text: "外部", begin: 0, end: 9 })] }); + useProjectStore.setState({ lines: [external] }); + releasePointer(100); + + // The gesture must abandon restore and commit — the external state wins. + const final = storeWords(); + expect(final).toHaveLength(1); + expect(final[0].text).toBe("外部"); + expect(final[0].begin).toBeCloseTo(0, 5); + expect(final[0].end).toBeCloseTo(9, 5); + }); +}); diff --git a/src/views/timeline/word-track.tsx b/src/views/timeline/word-track.tsx index 836be207..003e821c 100644 --- a/src/views/timeline/word-track.tsx +++ b/src/views/timeline/word-track.tsx @@ -13,6 +13,7 @@ import { DRAG_THRESHOLD_PX } from "@/views/timeline/drag-threshold"; import { resizeGestureSelfIds } from "@/views/timeline/resize-self-ids"; import { selfKey } from "@/views/timeline/snap"; import { useTimelineStore } from "@/views/timeline/timeline-store"; +import { useSelectionStretchDrag } from "@/views/timeline/use-selection-stretch"; import { useSnapBypass } from "@/views/timeline/use-snap-bypass"; import { useTimelineSnap } from "@/views/timeline/use-timeline-snap"; import { WordBlock } from "@/views/timeline/word-block"; @@ -95,6 +96,21 @@ const WordTrack: React.FC = ({ const getLastPointer = useCallback(() => lastPointerRef.current, []); useSnapBypass({ active: resizing, getLastPointer }); + // Multi-block selections: dragging the selection's boundary edge becomes a + // proportional stretch (anchored at the opposite side) instead of a resize. + // Destructure the stable callback: the hook returns a fresh object every + // render, and an object dep would needlessly invalidate this component's + // memoized handlers. + const { tryStart: tryStartStretch } = useSelectionStretchDrag({ + onDragEnd: (dragged) => { + if (!dragged) return; + justResizedRef.current = true; + requestAnimationFrame(() => { + justResizedRef.current = false; + }); + }, + }); + // react-doctor-disable-next-line react-doctor/exhaustive-deps useEffect(() => { return () => { @@ -114,6 +130,7 @@ const WordTrack: React.FC = ({ const handleResizeStart = useCallback( (wordIndex: number, edge: "left" | "right", startX: number) => { + if (tryStartStretch({ lineId, type: trackType, wordIndex, edge, startX })) return; const word = words[wordIndex]; const initialState: DragState = { wordIndex, edge, begin: word.begin, end: word.end }; dragStateRef.current = initialState; @@ -233,7 +250,7 @@ const WordTrack: React.FC = ({ document.addEventListener("pointermove", handleMouseMove); document.addEventListener("pointerup", handleMouseUp); }, - [words, zoom, duration, onUpdateWord, syllablePositions, snap, lineId, trackType], + [words, zoom, duration, onUpdateWord, syllablePositions, snap, lineId, trackType, tryStartStretch], ); const isBoundaryConjoined = (boundaryIndex: number): boolean => From f6621045301e9f30b8da23f10d4eb79f967bd9af Mon Sep 17 00:00:00 2001 From: Boidushya Date: Thu, 27 Aug 2026 00:33:45 +0530 Subject: [PATCH 2/6] feat: stretch-edge grips with undo-safe, frame-loop-safe drags --- src/views/timeline/stretch-drag.ts | 19 +-- src/views/timeline/stretch-grips.ts | 54 ++++++++ src/views/timeline/stretch-selection.ts | 59 +++++---- src/views/timeline/stretch-targets.ts | 139 ++++++++++++-------- src/views/timeline/use-selection-stretch.ts | 26 ++-- src/views/timeline/word-block.tsx | 34 +++-- src/views/timeline/word-track.tsx | 16 ++- 7 files changed, 231 insertions(+), 116 deletions(-) create mode 100644 src/views/timeline/stretch-grips.ts diff --git a/src/views/timeline/stretch-drag.ts b/src/views/timeline/stretch-drag.ts index c4a5cdb3..48b1abea 100644 --- a/src/views/timeline/stretch-drag.ts +++ b/src/views/timeline/stretch-drag.ts @@ -5,9 +5,11 @@ import { type StretchAnchor, type StretchClampOptions, type StretchSelectionRef, + type StretchTargets, deriveBounds, isFiniteWord, resolveStretchTargets, + selectionExtremes, trackWords, } from "@/views/timeline/stretch-targets"; @@ -28,6 +30,8 @@ interface StretchDragPlan { edgeTime: number; minFactor: number; maxFactor: number; + // Resolved once here; the drag remaps these every frame instead of re-resolving. + targets: StretchTargets; } // -- Public API ---------------------------------------------------------------- @@ -46,19 +50,7 @@ function planStretchDrag( const targets = resolveStretchTargets(rawLines, selections); if (!targets) return null; - let count = 0; - let w0 = Number.POSITIVE_INFINITY; - let w1 = Number.NEGATIVE_INFINITY; - for (const track of targets.tracks.values()) { - const words = trackWords(track); - for (const idx of track.indices) { - const word = words[idx]; - if (!isFiniteWord(word)) continue; - count++; - if (word.begin < w0) w0 = word.begin; - if (word.end > w1) w1 = word.end; - } - } + const { t0: w0, t1: w1, count } = selectionExtremes(targets); // One block is a plain resize; line-synced rows never add a grip. if (count < 2) return null; @@ -86,6 +78,7 @@ function planStretchDrag( edgeTime, minFactor: bounds.kLo, maxFactor: bounds.kHi, + targets, }; } diff --git a/src/views/timeline/stretch-grips.ts b/src/views/timeline/stretch-grips.ts new file mode 100644 index 00000000..19a45206 --- /dev/null +++ b/src/views/timeline/stretch-grips.ts @@ -0,0 +1,54 @@ +import type { LyricLine } from "@/domain/line/model"; +import { + STRETCH_EPS, + type StretchSelectionRef, + resolveStretchTargets, + selectedFiniteWords, + selectionExtremes, +} from "@/views/timeline/stretch-targets"; + +// -- Types --------------------------------------------------------------------- + +interface GripRef { + lineId: string; + type: "word" | "bg"; + wordIndex: number; + edge: "left" | "right"; +} + +interface SelectionGrips { + left: GripRef | null; + right: GripRef | null; +} + +// -- Public API ---------------------------------------------------------------- + +// The two outer edges of a multi-block selection are the grips that trigger a +// proportional stretch: the left edge of the earliest selected word block and +// the right edge of the latest one. Only word blocks carry grips (line-synced +// rows ride along but never define an edge), and only when 2+ blocks are +// selected, mirroring planStretchDrag's qualification. +function selectionGripEdges(rawLines: LyricLine[], selections: ReadonlyArray): SelectionGrips { + const none: SelectionGrips = { left: null, right: null }; + const targets = resolveStretchTargets(rawLines, selections); + if (!targets) return none; + + const { t0, t1, count } = selectionExtremes(targets); + if (count < 2) return none; + + let left: GripRef | null = null; + let right: GripRef | null = null; + for (const { track, idx, word } of selectedFiniteWords(targets)) { + if (!left && Math.abs(word.begin - t0) <= STRETCH_EPS) { + left = { lineId: track.line.id, type: track.type, wordIndex: idx, edge: "left" }; + } + if (!right && Math.abs(word.end - t1) <= STRETCH_EPS) { + right = { lineId: track.line.id, type: track.type, wordIndex: idx, edge: "right" }; + } + } + return { left, right }; +} + +// -- Exports ------------------------------------------------------------------- + +export { selectionGripEdges }; diff --git a/src/views/timeline/stretch-selection.ts b/src/views/timeline/stretch-selection.ts index 09444d67..b8a0bb0b 100644 --- a/src/views/timeline/stretch-selection.ts +++ b/src/views/timeline/stretch-selection.ts @@ -4,6 +4,7 @@ import { STRETCH_EPS, type StretchClampOptions, type StretchSelectionRef, + type StretchTargets, deriveBounds, resolveStretchTargets, trackWords, @@ -16,30 +17,13 @@ interface StretchResult { updates: Array<{ id: string; updates: Partial }>; } -// -- Public API ---------------------------------------------------------------- +// -- Mapping ------------------------------------------------------------------- -// Maps the selected words (and any line-synced rows riding along) affinely -// around the anchor: newX = anchorTime + (x - anchorTime) * k. The requested -// factor is clamped to the feasible interval derived from non-selected -// neighbours, minWordDuration and the audio bounds. -function stretchSelections( - rawLines: LyricLine[], - selections: ReadonlyArray, - requestedFactor: number, - options: StretchClampOptions, -): StretchResult { - const noop: StretchResult = { appliedFactor: 1, updates: [] }; - if (!Number.isFinite(requestedFactor) || requestedFactor <= 0) return noop; - - const targets = resolveStretchTargets(rawLines, selections); - if (!targets) return noop; - const bounds = deriveBounds(targets, options); - if (!bounds) return noop; - - const k = Math.min(Math.max(requestedFactor, bounds.kLo), bounds.kHi); - if (!Number.isFinite(k) || Math.abs(k - 1) < STRETCH_EPS) return noop; - - const mapTime = (x: number) => bounds.anchorTime + (x - bounds.anchorTime) * k; +// Applies the affine map newX = anchorTime + (x - anchorTime) * k to already +// resolved targets. Pure geometry, no resolve and no clamp, so a drag can call +// it every frame with only k changing. +function mapStretchTargets(targets: StretchTargets, k: number, anchorTime: number): StretchResult["updates"] { + const mapTime = (x: number) => anchorTime + (x - anchorTime) * k; // Merge all track updates of one line into a single entry so // updateLinesWithHistory never receives duplicate ids. @@ -71,9 +55,34 @@ function stretchSelections( }); } - return { appliedFactor: k, updates: [...updatesByLine.values()] }; + return [...updatesByLine.values()]; +} + +// -- Public API ---------------------------------------------------------------- + +// Maps the selected words (and any line-synced rows riding along) affinely +// around the anchor. The requested factor is clamped to the feasible interval +// derived from non-selected neighbours, minWordDuration and the audio bounds. +function stretchSelections( + rawLines: LyricLine[], + selections: ReadonlyArray, + requestedFactor: number, + options: StretchClampOptions, +): StretchResult { + const noop: StretchResult = { appliedFactor: 1, updates: [] }; + if (!Number.isFinite(requestedFactor) || requestedFactor <= 0) return noop; + + const targets = resolveStretchTargets(rawLines, selections); + if (!targets) return noop; + const bounds = deriveBounds(targets, options); + if (!bounds) return noop; + + const k = Math.min(Math.max(requestedFactor, bounds.kLo), bounds.kHi); + if (!Number.isFinite(k) || Math.abs(k - 1) < STRETCH_EPS) return noop; + + return { appliedFactor: k, updates: mapStretchTargets(targets, k, bounds.anchorTime) }; } // -- Exports ------------------------------------------------------------------- -export { stretchSelections }; +export { mapStretchTargets, stretchSelections }; diff --git a/src/views/timeline/stretch-targets.ts b/src/views/timeline/stretch-targets.ts index 443a6b3a..7c058254 100644 --- a/src/views/timeline/stretch-targets.ts +++ b/src/views/timeline/stretch-targets.ts @@ -101,6 +101,49 @@ function isFiniteWord(word: WordTiming | undefined): word is WordTiming { return !!word && Number.isFinite(word.begin) && Number.isFinite(word.end); } +// One owner for the "walk the finite selected word blocks across every track" +// traversal shared by selectionExtremes, deriveBounds and selectionGripEdges. +function* selectedFiniteWords( + targets: StretchTargets, +): Generator<{ track: StretchTrack; words: WordTiming[]; idx: number; word: WordTiming }> { + for (const track of targets.tracks.values()) { + const words = trackWords(track); + for (const idx of track.indices) { + const word = words[idx]; + if (!isFiniteWord(word)) continue; + yield { track, words, idx, word }; + } + } +} + +// Outer time bounds of the selection: t0 = earliest begin, t1 = latest end over +// the finite selected word blocks (count of them). Word blocks define the grips; +// a purely line-synced selection (no word blocks) falls back to its rows so the +// plain mapping API still has extremes. +function selectionExtremes(targets: StretchTargets): { + t0: number; + t1: number; + count: number; + hasWords: boolean; +} { + let t0 = Number.POSITIVE_INFINITY; + let t1 = Number.NEGATIVE_INFINITY; + let count = 0; + for (const { word } of selectedFiniteWords(targets)) { + count++; + if (word.begin < t0) t0 = word.begin; + if (word.end > t1) t1 = word.end; + } + const hasWords = count > 0; + if (!hasWords) { + for (const line of targets.lineSynced) { + if (line.begin < t0) t0 = line.begin; + if (line.end > t1) t1 = line.end; + } + } + return { t0, t1, count, hasWords }; +} + // -- Constraint derivation ----------------------------------------------------- // Every selected item maps affinely around the anchor A: newX = A + (x - A) * k @@ -123,28 +166,7 @@ function deriveBounds( // leak NaN into the factor — every bound below is checked before use. if (!Number.isFinite(options.duration) || !Number.isFinite(options.minWordDuration)) return null; - // Word-block extremes define the selection's sides (the grips). Line-synced - // rows scale along but never define the anchor; a pure line-synced selection - // falls back to its own extremes for the plain mapping API. - let t0 = Number.POSITIVE_INFINITY; - let t1 = Number.NEGATIVE_INFINITY; - let hasWords = false; - for (const track of targets.tracks.values()) { - const words = trackWords(track); - for (const idx of track.indices) { - const word = words[idx]; - if (!isFiniteWord(word)) continue; - hasWords = true; - if (word.begin < t0) t0 = word.begin; - if (word.end > t1) t1 = word.end; - } - } - if (!hasWords) { - for (const line of targets.lineSynced) { - if (line.begin < t0) t0 = line.begin; - if (line.end > t1) t1 = line.end; - } - } + const { t0, t1 } = selectionExtremes(targets); const span = t1 - t0; if (!Number.isFinite(t0) || !Number.isFinite(t1) || span <= STRETCH_EPS) return null; @@ -153,42 +175,37 @@ function deriveBounds( let kLo = 0; let kHi = Number.POSITIVE_INFINITY; - for (const track of targets.tracks.values()) { - const words = trackWords(track); - for (const idx of track.indices) { - const word = words[idx]; - if (!isFiniteWord(word)) continue; - const b = word.begin; - const e = word.end; - if (e - b > STRETCH_EPS) kLo = Math.max(kLo, options.minWordDuration / (e - b)); - // Nearest non-selected neighbours, skipping selected indices. - let leftEnd = 0; - for (let i = idx - 1; i >= 0; i--) { - if (!track.indices.has(i)) { - leftEnd = words[i].end; - break; - } - } - let rightBegin = options.duration; - for (let i = idx + 1; i < words.length; i++) { - if (!track.indices.has(i)) { - rightBegin = words[i].begin; - break; - } - } - if (b > anchorTime + STRETCH_EPS && leftEnd > anchorTime + STRETCH_EPS) { - kLo = Math.max(kLo, (leftEnd - anchorTime) / (b - anchorTime)); - } - if (b < anchorTime - STRETCH_EPS) { - kHi = Math.min(kHi, (anchorTime - leftEnd) / (anchorTime - b)); - } - if (e > anchorTime + STRETCH_EPS) { - kHi = Math.min(kHi, (rightBegin - anchorTime) / (e - anchorTime)); + for (const { track, words, idx, word } of selectedFiniteWords(targets)) { + const b = word.begin; + const e = word.end; + if (e - b > STRETCH_EPS) kLo = Math.max(kLo, options.minWordDuration / (e - b)); + // Nearest non-selected neighbours, skipping selected indices. + let leftEnd = 0; + for (let i = idx - 1; i >= 0; i--) { + if (!track.indices.has(i)) { + leftEnd = words[i].end; + break; } - if (e < anchorTime - STRETCH_EPS && rightBegin < anchorTime - STRETCH_EPS) { - kLo = Math.max(kLo, (anchorTime - rightBegin) / (anchorTime - e)); + } + let rightBegin = options.duration; + for (let i = idx + 1; i < words.length; i++) { + if (!track.indices.has(i)) { + rightBegin = words[i].begin; + break; } } + if (b > anchorTime + STRETCH_EPS && leftEnd > anchorTime + STRETCH_EPS) { + kLo = Math.max(kLo, (leftEnd - anchorTime) / (b - anchorTime)); + } + if (b < anchorTime - STRETCH_EPS) { + kHi = Math.min(kHi, (anchorTime - leftEnd) / (anchorTime - b)); + } + if (e > anchorTime + STRETCH_EPS) { + kHi = Math.min(kHi, (rightBegin - anchorTime) / (e - anchorTime)); + } + if (e < anchorTime - STRETCH_EPS && rightBegin < anchorTime - STRETCH_EPS) { + kLo = Math.max(kLo, (anchorTime - rightBegin) / (anchorTime - e)); + } } // Line-synced rows: same affine map on begin/end. Rows may overlap in time, @@ -209,5 +226,13 @@ function deriveBounds( // -- Exports ------------------------------------------------------------------- -export { deriveBounds, isFiniteWord, resolveStretchTargets, STRETCH_EPS, trackWords }; -export type { StretchAnchor, StretchClampOptions, StretchSelectionRef }; +export { + deriveBounds, + isFiniteWord, + resolveStretchTargets, + selectedFiniteWords, + selectionExtremes, + STRETCH_EPS, + trackWords, +}; +export type { StretchAnchor, StretchClampOptions, StretchSelectionRef, StretchTargets }; diff --git a/src/views/timeline/use-selection-stretch.ts b/src/views/timeline/use-selection-stretch.ts index 23c7b9a0..af83c95d 100644 --- a/src/views/timeline/use-selection-stretch.ts +++ b/src/views/timeline/use-selection-stretch.ts @@ -5,7 +5,7 @@ import { useSettingsStore } from "@/stores/settings"; import { DRAG_THRESHOLD_PX } from "@/views/timeline/drag-threshold"; import { selfKey } from "@/views/timeline/snap"; import { planStretchDrag } from "@/views/timeline/stretch-drag"; -import { stretchSelections } from "@/views/timeline/stretch-selection"; +import { mapStretchTargets, stretchSelections } from "@/views/timeline/stretch-selection"; import { STRETCH_EPS } from "@/views/timeline/stretch-targets"; import { useTimelineStore } from "@/views/timeline/timeline-store"; import { useSnapBypass } from "@/views/timeline/use-snap-bypass"; @@ -103,6 +103,12 @@ function useSelectionStretchDrag({ onDragEnd }: UseSelectionStretchOptions = {}) const tryStart = useCallback( ({ lineId, type, wordIndex, edge, startX }: StretchDragArgs): boolean => { + // A second pointerdown before the first gesture finished (multi-touch, + // stuck pointer) tears the first gesture down first, so the snapshot below + // captures committed state, not the prior gesture's transient preview. + cleanupRef.current?.(); + cleanupRef.current = null; + const snapshotLines = useProjectStore.getState().lines; const selection = useTimelineStore.getState().selectedWords; const options = { @@ -112,14 +118,8 @@ function useSelectionStretchDrag({ onDragEnd }: UseSelectionStretchOptions = {}) const plan = planStretchDrag(snapshotLines, selection, { lineId, type, wordIndex, edge }, options); if (!plan) return false; - // A second pointerdown before the first gesture finished (multi-touch, - // stuck pointer) must tear down the first gesture first — same re-entry - // guard as use-timeline-dnd. - cleanupRef.current?.(); - cleanupRef.current = null; - const zoom = useTimelineStore.getState().zoom; - const { anchor, anchorTime, edgeTime, minFactor, maxFactor } = plan; + const { anchor, anchorTime, edgeTime, minFactor, maxFactor, targets } = plan; const selfIds = new Set(selection.map((s) => selfKey(s.lineId, s.wordIndex, s.type))); // Factor of distances from the anchor implied by a dragged-edge time. @@ -152,8 +152,14 @@ function useSelectionStretchDrag({ onDragEnd }: UseSelectionStretchOptions = {}) let lastWritten: LyricLine[] | null = null; function preview(k: number): void { - const result = stretchSelections(snapshotLines, selection, k, { ...options, anchor }); - lastWritten = applyStretchUpdates(snapshotLines, result.updates); + // An external writer (undo, import, project clear) replaced lines since + // our last preview: abandon so continued dragging never clobbers it. + if (lastWritten !== null && useProjectStore.getState().lines !== lastWritten) { + finish(false); + return; + } + const updates = mapStretchTargets(targets, k, anchorTime); + lastWritten = applyStretchUpdates(snapshotLines, updates); writePreviewLines(lastWritten); } diff --git a/src/views/timeline/word-block.tsx b/src/views/timeline/word-block.tsx index 3d1a5bde..f17afd61 100644 --- a/src/views/timeline/word-block.tsx +++ b/src/views/timeline/word-block.tsx @@ -26,6 +26,8 @@ interface WordBlockProps { rightHighlighted?: boolean; leftConjoined?: boolean; rightConjoined?: boolean; + showLeftGrip?: boolean; + showRightGrip?: boolean; onClick: (e: React.MouseEvent) => void; onResizeStart: (edge: "left" | "right", startX: number) => void; onEdgeHover?: (edge: "left" | "right", hovering: boolean) => void; @@ -62,6 +64,8 @@ const WordBlock: React.FC = ({ rightHighlighted, leftConjoined, rightConjoined, + showLeftGrip, + showRightGrip, onClick, onResizeStart, onEdgeHover, @@ -160,11 +164,16 @@ const WordBlock: React.FC = ({ aria-orientation="vertical" aria-hidden="true" className={cn( - "absolute left-0 top-0 bottom-0 w-2 z-10 hover:bg-composer-text/10", - syllablePosition === "middle" || syllablePosition === "last" || leftConjoined - ? "cursor-col-resize" - : "cursor-ew-resize", - leftHighlighted && "bg-composer-text/10", + "absolute left-0 top-0 bottom-0 w-2 z-20", + showLeftGrip + ? "cursor-ew-resize rounded-l-md bg-composer-accent" + : [ + "z-10 hover:bg-composer-text/10", + syllablePosition === "middle" || syllablePosition === "last" || leftConjoined + ? "cursor-col-resize" + : "cursor-ew-resize", + leftHighlighted && "bg-composer-text/10", + ], )} onMouseDown={handleResizeStart} onPointerDown={(e) => e.stopPropagation()} @@ -180,11 +189,16 @@ const WordBlock: React.FC = ({ aria-orientation="vertical" aria-hidden="true" className={cn( - "absolute right-0 top-0 bottom-0 w-2 z-10 hover:bg-composer-text/10", - syllablePosition === "first" || syllablePosition === "middle" || rightConjoined - ? "cursor-col-resize" - : "cursor-ew-resize", - rightHighlighted && "bg-composer-text/10", + "absolute right-0 top-0 bottom-0 w-2 z-20", + showRightGrip + ? "cursor-ew-resize rounded-r-md bg-composer-accent" + : [ + "z-10 hover:bg-composer-text/10", + syllablePosition === "first" || syllablePosition === "middle" || rightConjoined + ? "cursor-col-resize" + : "cursor-ew-resize", + rightHighlighted && "bg-composer-text/10", + ], )} onMouseDown={handleResizeStart} onPointerDown={(e) => e.stopPropagation()} diff --git a/src/views/timeline/word-track.tsx b/src/views/timeline/word-track.tsx index 0fcb66d1..2fe89f3e 100644 --- a/src/views/timeline/word-track.tsx +++ b/src/views/timeline/word-track.tsx @@ -13,6 +13,7 @@ import { findInsertionSlot } from "@/utils/word-spaces"; import { DRAG_THRESHOLD_PX } from "@/views/timeline/drag-threshold"; import { resizeGestureSelfIds } from "@/views/timeline/resize-self-ids"; import { selfKey } from "@/views/timeline/snap"; +import { selectionGripEdges } from "@/views/timeline/stretch-grips"; import { useTimelineStore } from "@/views/timeline/timeline-store"; import { useSelectionStretchDrag } from "@/views/timeline/use-selection-stretch"; import { useSnapBypass } from "@/views/timeline/use-snap-bypass"; @@ -106,7 +107,7 @@ const WordTrack: React.FC = ({ onDragEnd: (dragged) => { if (!dragged) return; justResizedRef.current = true; - requestAnimationFrame(() => { + nextFrame(() => { justResizedRef.current = false; }); }, @@ -266,6 +267,17 @@ const WordTrack: React.FC = ({ const hasSelection = selectedWords.length > 0; + // The two outer edges of a multi-block selection advertise the proportional + // stretch. Read lines non-reactively: the grip block identity is stable while + // a stretch drag rescales timings, so it only needs to follow selection changes. + const gripIndices = useMemo(() => { + const { left, right } = selectionGripEdges(useProjectStore.getState().lines, selectedWords); + return { + leftGrip: left && left.lineId === lineId && left.type === trackType ? left.wordIndex : -1, + rightGrip: right && right.lineId === lineId && right.type === trackType ? right.wordIndex : -1, + }; + }, [selectedWords, lineId, trackType]); + const getDisplay = (wordIndex: number) => { if (dragState) { if (dragState.wordIndex === wordIndex) { @@ -419,6 +431,8 @@ const WordTrack: React.FC = ({ rightHighlighted={hoveredBoundary === wordIndex && isBoundaryConjoined(wordIndex)} leftConjoined={isBoundaryConjoined(wordIndex - 1)} rightConjoined={isBoundaryConjoined(wordIndex)} + showLeftGrip={wordIndex === gripIndices.leftGrip} + showRightGrip={wordIndex === gripIndices.rightGrip} onClick={(e) => handleSelect(wordIndex, e)} onResizeStart={(edge, startX) => handleResizeStart(wordIndex, edge, startX)} onEdgeHover={(edge, hovering) => handleEdgeHover(wordIndex, edge, hovering)} From cc080dc021d9705d356f647ee5a0b76549fe11ae Mon Sep 17 00:00:00 2001 From: Boidushya Date: Thu, 27 Aug 2026 00:33:45 +0530 Subject: [PATCH 3/6] test: cover stretch grips and drag regressions --- .../timeline/word-track.stretch-harness.tsx | 101 ++++++++++++++ .../word-track.stretch.browser.test.tsx | 128 +++++------------- ...-track.stretch.regression.browser.test.tsx | 73 ++++++++++ 3 files changed, 210 insertions(+), 92 deletions(-) create mode 100644 src/views/timeline/word-track.stretch-harness.tsx create mode 100644 src/views/timeline/word-track.stretch.regression.browser.test.tsx diff --git a/src/views/timeline/word-track.stretch-harness.tsx b/src/views/timeline/word-track.stretch-harness.tsx new file mode 100644 index 00000000..02f584bb --- /dev/null +++ b/src/views/timeline/word-track.stretch-harness.tsx @@ -0,0 +1,101 @@ +import type { WordTiming } from "@/domain/word/timing"; +import { useAudioStore } from "@/stores/audio"; +import { useProjectStore } from "@/stores/project"; +import { useSettingsStore } from "@/stores/settings"; +import { createLine } from "@/test/factories"; +import { render } from "@/test/render"; +import { useTimelineStore } from "@/views/timeline/timeline-store"; +import { WordTrack } from "@/views/timeline/word-track"; + +// Shared harness for the selection-stretch browser tests. Proportional stretch +// of a multi-block selection is driven through the word block edge grips: the +// right edge of the latest selected block anchors at the selection start, the +// left edge of the earliest one anchors at its end. + +const ZOOM = 100; // px per second +const START_X = 200; + +interface Fixture { + lineId: string; + blocks: HTMLElement[]; + original: WordTiming[]; +} + +async function renderStretchTrack( + words: WordTiming[], + selected: number[], + opts: { snap?: boolean; onUpdateWord?: () => void } = {}, +) { + const line = createLine({ words }); + useProjectStore.setState({ lines: [line] }); + useTimelineStore.setState({ + zoom: ZOOM, + selectedWords: selected.map((wordIndex) => ({ + lineId: line.id, + lineIndex: 0, + wordIndex, + type: "word" as const, + })), + }); + useAudioStore.setState({ duration: 60 }); + useSettingsStore.setState({ + minWordDuration: 0.1, + timelineSnap: opts.snap === true, + ...(opts.snap === true ? { timelineSnapThreshold: 8 } : {}), + }); + + const screen = await render( + {})} + />, + { dndContext: true }, + ); + const blocks = [...screen.container.querySelectorAll("[data-word-block]")]; + const fixture: Fixture = { + lineId: line.id, + blocks, + original: useProjectStore.getState().lines[0].words ?? [], + }; + return fixture; +} + +function storeWords(): WordTiming[] { + const line = useProjectStore.getState().lines[0]; + return line.words ?? []; +} + +function gripEdge(block: HTMLElement, edge: "left" | "right"): HTMLElement | null { + return block.querySelector(`[data-edge="${edge}"].bg-composer-accent`); +} + +function pressEdge(block: HTMLElement, edge: "left" | "right"): void { + const el = block.querySelector(`[data-edge="${edge}"]`) as HTMLElement; + el.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0, clientX: START_X })); +} + +function movePointer(dxPx: number): void { + document.dispatchEvent(new PointerEvent("pointermove", { bubbles: true, clientX: START_X + dxPx })); +} + +function releasePointer(dxPx: number): void { + document.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, clientX: START_X + dxPx })); +} + +function dragEdge(block: HTMLElement, edge: "left" | "right", dxPx: number): void { + pressEdge(block, edge); + movePointer(dxPx); + releasePointer(dxPx); +} + +function pressEscape(): void { + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); +} + +export { dragEdge, gripEdge, movePointer, pressEdge, pressEscape, releasePointer, renderStretchTrack, storeWords }; diff --git a/src/views/timeline/word-track.stretch.browser.test.tsx b/src/views/timeline/word-track.stretch.browser.test.tsx index 9b5dac90..60732138 100644 --- a/src/views/timeline/word-track.stretch.browser.test.tsx +++ b/src/views/timeline/word-track.stretch.browser.test.tsx @@ -1,99 +1,17 @@ -import type { WordTiming } from "@/domain/word/timing"; -import { useAudioStore } from "@/stores/audio"; import { useProjectStore } from "@/stores/project"; -import { useSettingsStore } from "@/stores/settings"; import { createLine, createWord } from "@/test/factories"; -import { render } from "@/test/render"; -import { useTimelineStore } from "@/views/timeline/timeline-store"; -import { WordTrack } from "@/views/timeline/word-track"; +import { + dragEdge, + gripEdge, + movePointer, + pressEdge, + pressEscape, + releasePointer, + renderStretchTrack, + storeWords, +} from "@/views/timeline/word-track.stretch-harness"; import { describe, expect, it } from "vitest"; -// Proportional stretch of a multi-block selection is driven through the word -// block edge grips: the right edge of the latest selected block anchors at the -// selection start, the left edge of the earliest one anchors at its end. - -const ZOOM = 100; // px per second -const START_X = 200; - -interface Fixture { - lineId: string; - blocks: HTMLElement[]; - original: WordTiming[]; -} - -async function renderStretchTrack( - words: WordTiming[], - selected: number[], - opts: { snap?: boolean; onUpdateWord?: () => void } = {}, -) { - const line = createLine({ words }); - useProjectStore.setState({ lines: [line] }); - useTimelineStore.setState({ - zoom: ZOOM, - selectedWords: selected.map((wordIndex) => ({ - lineId: line.id, - lineIndex: 0, - wordIndex, - type: "word" as const, - })), - }); - useAudioStore.setState({ duration: 60 }); - useSettingsStore.setState({ - minWordDuration: 0.1, - timelineSnap: opts.snap === true, - ...(opts.snap === true ? { timelineSnapThreshold: 8 } : {}), - }); - - const screen = await render( - {})} - />, - { dndContext: true }, - ); - const blocks = [...screen.container.querySelectorAll("[data-word-block]")]; - const fixture: Fixture = { - lineId: line.id, - blocks, - original: useProjectStore.getState().lines[0].words ?? [], - }; - return fixture; -} - -function storeWords(): WordTiming[] { - const line = useProjectStore.getState().lines[0]; - return line.words ?? []; -} - -function pressEdge(block: HTMLElement, edge: "left" | "right"): void { - const el = block.querySelector(`[data-edge="${edge}"]`) as HTMLElement; - el.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0, clientX: START_X })); -} - -function movePointer(dxPx: number): void { - document.dispatchEvent(new PointerEvent("pointermove", { bubbles: true, clientX: START_X + dxPx })); -} - -function releasePointer(dxPx: number): void { - document.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, clientX: START_X + dxPx })); -} - -function dragEdge(block: HTMLElement, edge: "left" | "right", dxPx: number): void { - pressEdge(block, edge); - movePointer(dxPx); - releasePointer(dxPx); -} - -function pressEscape(): void { - document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); -} - describe("WordTrack selection stretch", () => { it("commits a left-anchored stretch from the right edge of the last selected word", async () => { const words = [ @@ -245,4 +163,30 @@ describe("WordTrack selection stretch", () => { expect(final[0].begin).toBeCloseTo(0, 5); expect(final[0].end).toBeCloseTo(9, 5); }); + + it("renders grips on the outer edges of a multi-block selection", async () => { + const words = [ + createWord({ text: "我 ", begin: 0, end: 1 }), + createWord({ text: "爱 ", begin: 1, end: 2 }), + createWord({ text: "你", begin: 5, end: 6 }), + ]; + const { blocks } = await renderStretchTrack(words, [0, 1]); + + // Grips mark exactly where the stretch triggers: the left edge of the first + // selected block and the right edge of the last, nowhere else. + expect(gripEdge(blocks[0], "left")).not.toBeNull(); + expect(gripEdge(blocks[1], "right")).not.toBeNull(); + expect(gripEdge(blocks[0], "right")).toBeNull(); + expect(gripEdge(blocks[1], "left")).toBeNull(); + expect(gripEdge(blocks[2], "left")).toBeNull(); + expect(gripEdge(blocks[2], "right")).toBeNull(); + }); + + it("renders no grips for a single-block selection", async () => { + const words = [createWord({ text: "我 ", begin: 0, end: 1 }), createWord({ text: "爱", begin: 1, end: 2 })]; + const { blocks } = await renderStretchTrack(words, [1]); + + expect(gripEdge(blocks[1], "left")).toBeNull(); + expect(gripEdge(blocks[1], "right")).toBeNull(); + }); }); diff --git a/src/views/timeline/word-track.stretch.regression.browser.test.tsx b/src/views/timeline/word-track.stretch.regression.browser.test.tsx new file mode 100644 index 00000000..ad9d3731 --- /dev/null +++ b/src/views/timeline/word-track.stretch.regression.browser.test.tsx @@ -0,0 +1,73 @@ +import { useProjectStore } from "@/stores/project"; +import { createWord } from "@/test/factories"; +import { + movePointer, + pressEdge, + releasePointer, + renderStretchTrack, + storeWords, +} from "@/views/timeline/word-track.stretch-harness"; +import { describe, expect, it } from "vitest"; + +// Regressions for two logic bugs found in the original stretch gesture, both +// reproduced deterministically through the real WordTrack gesture. + +describe("WordTrack selection stretch regressions", () => { + it("regression: keeps a mid-drag undo when the drag continues", async () => { + const sA = [ + createWord({ text: "我 ", begin: 0, end: 1 }), + createWord({ text: "爱 ", begin: 1, end: 2 }), + createWord({ text: "你", begin: 5, end: 6 }), + ]; + const { blocks, lineId } = await renderStretchTrack(sA, [0, 1]); + + // Commit an edit (only the unselected word[2] moves) so there is a real + // undo target that differs from the pre-drag state. + const sB = [sA[0], sA[1], { ...sA[2], end: 7 }]; + useProjectStore.getState().updateLinesWithHistory([{ id: lineId, updates: { words: sB } }], { + propagateToSiblings: false, + }); + + pressEdge(blocks[1], "right"); + movePointer(100); + // Undo mid-drag, then keep dragging: the gesture must yield to the undo, not + // clobber it and commit a stretch over the undone state. + useProjectStore.getState().undo(); + movePointer(150); + releasePointer(150); + + const final = storeWords(); + expect(final[1].end).toBeCloseTo(2, 5); + expect(final[2].end).toBeCloseTo(6, 5); + }); + + it("regression: a re-entrant drag baselines off committed state, not a transient", async () => { + const l0 = [ + createWord({ text: "我 ", begin: 0, end: 1 }), + createWord({ text: "爱 ", begin: 1, end: 2 }), + createWord({ text: "你", begin: 5, end: 6 }), + ]; + const { blocks } = await renderStretchTrack(l0, [0, 1]); + + // Gesture A leaves a transient preview in the store, then a second pointerdown + // (multi-touch / stuck pointer) starts gesture B before A finishes. + pressEdge(blocks[1], "right"); + movePointer(100); + pressEdge(blocks[1], "right"); + movePointer(100); + releasePointer(100); + + // The commit is a single stretch of the committed baseline (k = 3 / 2 = 1.5), + // not a double stretch of gesture A's phantom preview. + const final = storeWords(); + expect(final[0].end).toBeCloseTo(1.5, 5); + expect(final[1].end).toBeCloseTo(3, 5); + expect(final[2].begin).toBeCloseTo(5, 5); + expect(final[2].end).toBeCloseTo(6, 5); + + // Undo lands on the real baseline, never a state that was only ever transient. + useProjectStore.getState().undo(); + const undone = storeWords(); + expect(undone[1].end).toBeCloseTo(2, 5); + }); +}); From c7488af8a417fe7070106d446ceedb07a3405a8b Mon Sep 17 00:00:00 2001 From: Boidushya Date: Thu, 27 Aug 2026 00:33:45 +0530 Subject: [PATCH 4/6] chore: 1.39.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f0bf83dc..0fcc9416 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "composer", "private": true, - "version": "1.38.2", + "version": "1.39.0", "type": "module", "scripts": { "dev": "vite-react-ssg dev", From 236f57cc5a42e2299ab089c5d0f6a592ba2a8836 Mon Sep 17 00:00:00 2001 From: Boidushya Date: Thu, 27 Aug 2026 00:45:00 +0530 Subject: [PATCH 5/6] refactor: give stretch grip identity a single owner --- src/views/timeline/stretch-drag.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/views/timeline/stretch-drag.ts b/src/views/timeline/stretch-drag.ts index 48b1abea..3b6d9f7b 100644 --- a/src/views/timeline/stretch-drag.ts +++ b/src/views/timeline/stretch-drag.ts @@ -9,9 +9,9 @@ import { deriveBounds, isFiniteWord, resolveStretchTargets, - selectionExtremes, trackWords, } from "@/views/timeline/stretch-targets"; +import { selectionGripEdges } from "@/views/timeline/stretch-grips"; // -- Types --------------------------------------------------------------------- @@ -50,9 +50,13 @@ function planStretchDrag( const targets = resolveStretchTargets(rawLines, selections); if (!targets) return null; - const { t0: w0, t1: w1, count } = selectionExtremes(targets); - // One block is a plain resize; line-synced rows never add a grip. - if (count < 2) return null; + // Single owner of "what is a grip": the dragged edge must be the selection's + // grip on that side. This also enforces the 2+ block rule. + const grips = selectionGripEdges(rawLines, selections); + const grip = drag.edge === "right" ? grips.right : grips.left; + if (!grip || grip.lineId !== drag.lineId || grip.type !== drag.type || grip.wordIndex !== drag.wordIndex) { + return null; + } const draggedTrack = targets.tracks.get(`${drag.lineId}:${drag.type}`); const draggedWords = draggedTrack ? trackWords(draggedTrack) : null; @@ -63,8 +67,6 @@ function planStretchDrag( const anchor: StretchAnchor = drag.edge === "right" ? "start" : "end"; const edgeTime = anchor === "start" ? draggedWord.end : draggedWord.begin; - // The grip must be the selection's own extreme on that side. - if (Math.abs(edgeTime - (anchor === "start" ? w1 : w0)) > STRETCH_EPS) return null; const bounds = deriveBounds(targets, { ...options, anchor }); if (!bounds) return null; From d7584e704b989cadd016e4b9213b2a5c474f22d1 Mon Sep 17 00:00:00 2001 From: Boidushya Date: Thu, 27 Aug 2026 00:45:00 +0530 Subject: [PATCH 6/6] style: highlight stretch grips, brightening on hover --- src/views/timeline/word-block.tsx | 14 ++++++++------ src/views/timeline/word-track.stretch-harness.tsx | 2 +- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/views/timeline/word-block.tsx b/src/views/timeline/word-block.tsx index f17afd61..eab38acd 100644 --- a/src/views/timeline/word-block.tsx +++ b/src/views/timeline/word-block.tsx @@ -164,17 +164,18 @@ const WordBlock: React.FC = ({ aria-orientation="vertical" aria-hidden="true" className={cn( - "absolute left-0 top-0 bottom-0 w-2 z-20", + "absolute left-0 top-0 bottom-0 w-2 z-10", showLeftGrip - ? "cursor-ew-resize rounded-l-md bg-composer-accent" + ? "cursor-ew-resize bg-composer-text/10 hover:bg-composer-text/20" : [ - "z-10 hover:bg-composer-text/10", + "hover:bg-composer-text/10", syllablePosition === "middle" || syllablePosition === "last" || leftConjoined ? "cursor-col-resize" : "cursor-ew-resize", leftHighlighted && "bg-composer-text/10", ], )} + data-grip={showLeftGrip || undefined} onMouseDown={handleResizeStart} onPointerDown={(e) => e.stopPropagation()} onMouseEnter={() => onEdgeHover?.("left", true)} @@ -189,17 +190,18 @@ const WordBlock: React.FC = ({ aria-orientation="vertical" aria-hidden="true" className={cn( - "absolute right-0 top-0 bottom-0 w-2 z-20", + "absolute right-0 top-0 bottom-0 w-2 z-10", showRightGrip - ? "cursor-ew-resize rounded-r-md bg-composer-accent" + ? "cursor-ew-resize bg-composer-text/10 hover:bg-composer-text/20" : [ - "z-10 hover:bg-composer-text/10", + "hover:bg-composer-text/10", syllablePosition === "first" || syllablePosition === "middle" || rightConjoined ? "cursor-col-resize" : "cursor-ew-resize", rightHighlighted && "bg-composer-text/10", ], )} + data-grip={showRightGrip || undefined} onMouseDown={handleResizeStart} onPointerDown={(e) => e.stopPropagation()} onMouseEnter={() => onEdgeHover?.("right", true)} diff --git a/src/views/timeline/word-track.stretch-harness.tsx b/src/views/timeline/word-track.stretch-harness.tsx index 02f584bb..6bb39144 100644 --- a/src/views/timeline/word-track.stretch-harness.tsx +++ b/src/views/timeline/word-track.stretch-harness.tsx @@ -72,7 +72,7 @@ function storeWords(): WordTiming[] { } function gripEdge(block: HTMLElement, edge: "left" | "right"): HTMLElement | null { - return block.querySelector(`[data-edge="${edge}"].bg-composer-accent`); + return block.querySelector(`[data-edge="${edge}"][data-grip]`); } function pressEdge(block: HTMLElement, edge: "left" | "right"): void {