Skip to content

Commit 27017b7

Browse files
committed
feat: Improve scrolling behavior
1 parent 7e81b79 commit 27017b7

24 files changed

+488
-200
lines changed

src/bindings/Web.gen.tsx

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/bindings/Web.res

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,14 @@ let isFocusable = el =>
3434
}
3535
}
3636

37+
// Returns true only for elements where typing text would conflict with shortcuts
38+
@genType
39+
let isTextInput = el =>
40+
switch el->Webapi.Dom.Element.tagName {
41+
| "TEXTAREA" | "INPUT" => true
42+
| _ => el->Webapi.Dom.Element.getAttribute("contenteditable")->Option.isSome
43+
}
44+
3745
let isFocusingInteractiveElement = () =>
3846
Webapi.Dom.document
3947
->Webapi.Dom.Document.activeElement

src/bindings/Web.res.mjs

Lines changed: 12 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/codecs/subtitle-renderer.ts

Lines changed: 45 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -357,7 +357,32 @@ function updateWordAnimationLayer(
357357
popScale = calculatePopScale(animationProgress, wordAnim.pop.scale);
358358
}
359359

360-
// for background calculate interpolated position
360+
// Render non-active words first (below)
361+
for (const pos of positions) {
362+
if (pos.index === activeWordIndex) continue; // Skip active word, render it last
363+
364+
let wordTextContent = pos.word.text.trim();
365+
if (ctx.style.hidePunctuation) {
366+
wordTextContent = stripPunctuation(wordTextContent);
367+
}
368+
369+
const wordText = new Text({
370+
x: pos.x,
371+
y: pos.y,
372+
text: wordTextContent,
373+
fill: ctx.style.color,
374+
fontSize: ctx.style.fontSizePx,
375+
fontStyle: ctx.style.fontWeight >= 700 ? "bold" : "normal",
376+
fontFamily: ctx.style.fontFamily,
377+
stroke: ctx.style.strokeColor,
378+
strokeWidth: ctx.style.strokeWidth,
379+
strokeEnabled: ctx.style.strokeColor !== ctx.style.color,
380+
fillAfterStrokeEnabled: true,
381+
});
382+
ctx.wordGroup.add(wordText);
383+
}
384+
385+
// for background calculate interpolated position (render after non-active words)
361386
if (activePos && wordAnim.showBackground) {
362387
const isNewWord = ctx.lastActiveWordIndex !== activeWordIndex;
363388
const bgPos = interpolateBackgroundPosition(
@@ -395,38 +420,38 @@ function updateWordAnimationLayer(
395420
};
396421
}
397422

398-
for (const pos of positions) {
399-
const isActive = pos.index === activeWordIndex;
400-
423+
// Render active word last (on top)
424+
if (activePos) {
401425
let fillColor = ctx.style.color;
402426
let fontWeight = ctx.style.fontWeight;
427+
let scale = 1;
403428
let offsetX = 0;
404429
let offsetY = 0;
405430

406-
if (isActive) {
407-
// Font effect: change text color and/or font weight (fallback to main style)
408-
if (wordAnim.showFont) {
409-
fillColor = wordAnim.font.color ?? ctx.style.color;
410-
fontWeight = wordAnim.font.fontWeight ?? ctx.style.fontWeight;
411-
}
431+
// Font effect: change text color and/or font weight (fallback to main style)
432+
if (wordAnim.showFont) {
433+
fillColor = wordAnim.font.color ?? ctx.style.color;
434+
fontWeight = wordAnim.font.fontWeight ?? ctx.style.fontWeight;
435+
}
412436

413-
if (wordAnim.showPop) {
414-
// Offset to scale from center of the word
415-
offsetX = (pos.width * (popScale - 1)) / 2;
416-
offsetY = (ctx.style.fontSizePx * (popScale - 1)) / 2;
417-
}
437+
// Pop effect: scale animation (only for active word)
438+
if (wordAnim.showPop) {
439+
scale = popScale;
440+
// Offset to scale from center of the word
441+
offsetX = (activePos.width * (scale - 1)) / 2;
442+
offsetY = (ctx.style.fontSizePx * (scale - 1)) / 2;
418443
}
419444

420-
let wordTextContent = pos.word.text.trim();
445+
let wordTextContent = activePos.word.text.trim();
421446
if (ctx.style.hidePunctuation) {
422447
wordTextContent = stripPunctuation(wordTextContent);
423448
}
424449

425450
const wordText = new Text({
426-
x: pos.x - offsetX,
427-
y: pos.y - offsetY,
428-
scaleX: popScale,
429-
scaleY: popScale,
451+
x: activePos.x - offsetX,
452+
y: activePos.y - offsetY,
453+
scaleX: scale,
454+
scaleY: scale,
430455
text: wordTextContent,
431456
fill: fillColor,
432457
fontSize: ctx.style.fontSizePx,

src/hooks/Hooks.res

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,8 @@ let useToggle = default => {
3434
}),
3535
)
3636
}
37+
38+
@module("./useMediaQuery")
39+
external useContainerBreakpoint: (
40+
~threshold: int=?,
41+
) => (React.ref<Js.nullable<Dom.element>>, bool) = "useContainerBreakpoint"

src/hooks/Hooks.res.mjs

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/hooks/useMediaQuery.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { useState, useEffect, useRef } from "react";
2+
3+
export function useContainerBreakpoint(
4+
threshold: number = 672
5+
): [React.RefObject<HTMLDivElement | null>, boolean] {
6+
const containerRef = useRef<HTMLDivElement | null>(null);
7+
const [isAboveThreshold, setIsAboveThreshold] = useState(false);
8+
9+
useEffect(() => {
10+
const container = containerRef.current;
11+
if (!container) return;
12+
13+
const observer = new ResizeObserver((entries) => {
14+
for (const entry of entries) {
15+
setIsAboveThreshold(entry.contentRect.width >= threshold);
16+
}
17+
});
18+
19+
observer.observe(container);
20+
return () => observer.disconnect();
21+
}, [threshold]);
22+
23+
return [containerRef, isAboveThreshold];
24+
}

src/screens/editor/ChunksList/ChunkEditor.res

Lines changed: 35 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -144,32 +144,47 @@ let make = React.memo((
144144
let textAreaRef = React.useRef(null)
145145
let previousWasCurrentRef = React.useRef(current)
146146

147-
// Call hook unconditionally at the top level
148-
let editorInputHandler = useEditorInputHandler()
147+
// Handler for textarea keydown - handles Escape to exit, seek to start, and play
148+
let textareaKeyDownHandler = Hooks.useEvent(event => {
149+
let key = ReactEvent.Keyboard.key(event)
150+
151+
switch key {
152+
| "Escape" =>
153+
let _ = ReactEvent.Keyboard.target(event)["blur"]()
154+
ctx.playerImmediateDispatch(Seek(start))
155+
ctx.playerImmediateDispatch(Play)
156+
| " "
157+
if event->ReactEvent.Keyboard.shiftKey ||
158+
event->ReactEvent.Keyboard.ctrlKey ||
159+
event->ReactEvent.Keyboard.metaKey =>
160+
ctx.playerImmediateDispatch(Play)
161+
| _ => ()
162+
}
163+
})
149164

150165
// Don't auto-scroll when split preview is active or user is interacting with menus
151166
React.useEffect2(() => {
167+
// Always update the ref when this cue is current
152168
if current {
153-
// Always update the ref when this cue is current
154169
globalCurrentCueTextAreaRef := Some(textAreaRef)
170+
}
155171

156-
// Only auto-scroll on transition to current
157-
if !previousWasCurrentRef.current {
158-
// Skip scroll if ANY split preview is active (user is in split menu)
159-
// or if user is focusing any interactive element
160-
let shouldScroll = !isAnySplitPreviewActive && !Web.isFocusingInteractiveElement()
172+
// Only scroll on transition to current (not already current)
173+
if current && !previousWasCurrentRef.current {
174+
// Skip scroll if ANY split preview is active (user is in split menu)
175+
// or if user is focusing any interactive element
176+
let shouldScroll = !isAnySplitPreviewActive && !Web.isFocusingInteractiveElement()
161177

162-
if shouldScroll {
163-
ref.current
164-
->Js.Nullable.toOption
165-
->Option.forEach(
166-
el =>
167-
el->Webapi.Dom.Element.scrollIntoViewWithOptions({
168-
"behavior": "smooth",
169-
"block": "nearest",
170-
}),
171-
)
172-
}
178+
if shouldScroll {
179+
ref.current
180+
->Js.Nullable.toOption
181+
->Option.forEach(
182+
el =>
183+
el->Webapi.Dom.Element.scrollIntoViewWithOptions({
184+
"behavior": "instant",
185+
"block": "nearest",
186+
}),
187+
)
173188
}
174189
}
175190

@@ -286,7 +301,7 @@ let make = React.memo((
286301
key={chunk.text}
287302
rows={chunk.text === "" ? 2 : 3}
288303
onBlur={handleBlur}
289-
onKeyDown={editorInputHandler}
304+
onKeyDown={textareaKeyDownHandler}
290305
id={current ? "current-cue-textarea" : ""}
291306
className={Cx.cx([
292307
"col-span-2 block w-full resize-none rounded-lg border-none bg-white/10 py-1.5 px-3 text-sm/6 text-white",

src/screens/editor/ChunksList/ChunkEditor.res.mjs

Lines changed: 31 additions & 13 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/screens/editor/ChunksList/ChunksList.res

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -715,7 +715,7 @@ let make = React.memo((~subtitlesManager, ~title: React.element) => {
715715
->Array.mapWithIndex((chunk, index) =>
716716
<ChunkEditor
717717
index
718-
current={currentPlayingCueIndex->Option.map(i => i === index)->Option.getOr(false)}
718+
current={currentPlayingCueIndex->Option.map(i => i === index)->Option.getOr(index === 0)}
719719
readonly={subtitlesManager.transcriptionState == TranscriptionInProgress}
720720
chunk
721721
key={switch chunk.id {

0 commit comments

Comments
 (0)