Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "composer",
"private": true,
"version": "1.38.2",
"version": "1.39.0",
"type": "module",
"scripts": {
"dev": "vite-react-ssg dev",
Expand Down
8 changes: 8 additions & 0 deletions src/stores/project/lines-slice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@ const createLinesSlice: StateCreator<ProjectStore, [], [], LinesState & LineActi

setLines: (lines) => 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 = {}) =>
Expand Down
2 changes: 2 additions & 0 deletions src/stores/project/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<LyricLine>, options?: { deriveText?: boolean }) => void;
updateLineWithHistory: (
Expand Down
89 changes: 89 additions & 0 deletions src/views/timeline/stretch-drag.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import type { LyricLine } from "@/domain/line/model";
import type { WordTiming } from "@/domain/word/timing";
import {
STRETCH_EPS,
type StretchAnchor,
type StretchClampOptions,
type StretchSelectionRef,
type StretchTargets,
deriveBounds,
isFiniteWord,
resolveStretchTargets,
trackWords,
} from "@/views/timeline/stretch-targets";
import { selectionGripEdges } from "@/views/timeline/stretch-grips";

// -- 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;
// Resolved once here; the drag remaps these every frame instead of re-resolving.
targets: StretchTargets;
}

// -- 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<StretchSelectionRef>,
drag: StretchDragRef,
options: StretchClampOptions,
): StretchDragPlan | null {
const targets = resolveStretchTargets(rawLines, selections);
if (!targets) 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;
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;

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,
targets,
};
}

// -- Exports -------------------------------------------------------------------

export { planStretchDrag };
54 changes: 54 additions & 0 deletions src/views/timeline/stretch-grips.ts
Original file line number Diff line number Diff line change
@@ -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<StretchSelectionRef>): 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 };
Loading