diff --git a/.filesize-allowlist b/.filesize-allowlist deleted file mode 100644 index d2dff0d55..000000000 --- a/.filesize-allowlist +++ /dev/null @@ -1,13 +0,0 @@ -packages/studio/src/player/hooks/useTimelinePlayer.ts -packages/studio/src/hooks/useManifestPersistence.ts -packages/studio/src/player/components/PlayerControls.tsx -packages/studio/src/components/editor/manualEdits.test.ts -packages/studio/src/player/hooks/useTimelinePlayer.test.ts -packages/studio/src/components/editor/manualEditsDom.ts -packages/studio/src/utils/sourcePatcher.ts -packages/studio/src/utils/sourcePatcher.test.ts -packages/studio/src/App.tsx -packages/studio/src/player/components/Timeline.tsx -packages/studio/src/player/components/timelineEditing.test.ts -packages/studio/src/components/editor/domEditing.test.ts -packages/studio/src/components/editor/domEditingLayers.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9852cb65d..805228230 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -469,7 +469,7 @@ jobs: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: fetch-depth: 0 - - name: Check file sizes (max 500 lines) + - name: Check file sizes (max 600 lines) # Scoped to files THIS PR changed under packages/studio. Walking the # whole tree blamed every unrelated PR for pre-existing offenders. # Falls back to a full scan on push events (no base ref available) @@ -494,10 +494,9 @@ jobs: for f in "${files[@]}"; do [ -z "$f" ] && continue [ -f "$f" ] || continue # skip files deleted in this PR - if grep -qxF "$f" .filesize-allowlist 2>/dev/null; then continue; fi lines=$(wc -l < "$f") - if [ "$lines" -gt 500 ]; then - echo "::error file=$f::$f has $lines lines (max 500)" + if [ "$lines" -gt 600 ]; then + echo "::error file=$f::$f has $lines lines (max 600)" EXIT=1 fi done diff --git a/lefthook.yml b/lefthook.yml index b2ea46992..7964ed691 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -21,18 +21,16 @@ pre-commit: glob: "packages/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}" run: bunx fallow audit --base origin/main --fail-on-issues filesize: - # Scoped to packages/studio — the 500 LOC limit is a studio architecture + # Scoped to packages/studio — the 600 LOC limit is a studio architecture # standard enforced as part of the App.tsx decomposition work. Player and # other packages enforce size discipline via code review and convention. - # Files temporarily over the limit are listed in .filesize-allowlist. glob: "packages/studio/**/*.{ts,tsx}" exclude: "(\\.test\\.(ts|tsx)$|\\.generated\\.)" run: | for f in {staged_files}; do - if grep -qxF "$f" .filesize-allowlist 2>/dev/null; then continue; fi lines=$(wc -l < "$f") - if [ "$lines" -gt 500 ]; then - echo "ERROR: $f has $lines lines (max 500) — add to .filesize-allowlist if temporarily needed" + if [ "$lines" -gt 600 ]; then + echo "ERROR: $f has $lines lines (max 600)" exit 1 fi done diff --git a/packages/studio/src/components/editor/manualEditsDom.ts b/packages/studio/src/components/editor/manualEditsDom.ts index 3c7400692..f5eb262a7 100644 --- a/packages/studio/src/components/editor/manualEditsDom.ts +++ b/packages/studio/src/components/editor/manualEditsDom.ts @@ -31,12 +31,6 @@ import { STUDIO_ROTATION_TRANSFORM_ORIGIN, } from "./manualEditsTypes"; import { roundRotationAngle } from "./manualEditsParsing"; -import { - STUDIO_MOTION_ATTR, - STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR, - STUDIO_MOTION_ORIGINAL_OPACITY_ATTR, - STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR, -} from "./studioMotionTypes"; import { applyStudioMotionFromDom } from "./studioMotion"; /* ── Gesture tracking ─────────────────────────────────────────────── */ @@ -221,24 +215,28 @@ function writeStudioPathOffsetVars( // into element.style.transform (as a matrix) on every seek. When the studio's reapply hook also // writes `translate`, both properties compose additively, doubling the visual offset. This helper // zeroes out only the translate component (m41/m42) so the `translate` prop isn't double-counted. +function isIdentityAfterTranslateStrip(m: DOMMatrix): boolean { + return m.is2D && m.a === 1 && m.b === 0 && m.c === 0 && m.d === 1; +} + function stripGsapTranslateFromTransform(element: HTMLElement): void { const transform = element.style.getPropertyValue("transform"); if (!transform || transform === "none") return; - const win = element.ownerDocument.defaultView as (Window & typeof globalThis) | null; - const DOMMatrixCtor = (win as unknown as { DOMMatrix?: typeof DOMMatrix })?.DOMMatrix; + const DOMMatrixCtor = (element.ownerDocument.defaultView as (Window & typeof globalThis) | null) + ?.DOMMatrix; if (!DOMMatrixCtor) return; try { const m = new DOMMatrixCtor(transform); if (m.m41 === 0 && m.m42 === 0) return; m.m41 = 0; m.m42 = 0; - if (m.is2D && m.a === 1 && m.b === 0 && m.c === 0 && m.d === 1) { + if (isIdentityAfterTranslateStrip(m)) { element.style.removeProperty("transform"); } else { element.style.setProperty("transform", m.toString()); } } catch { - // non-parseable transform or DOMMatrix unavailable — leave as-is + /* non-parseable transform — leave as-is */ } } @@ -464,351 +462,30 @@ export function applyStudioRotationDraft(element: HTMLElement, rotation: { angle ); } -/* ── HTML patch builders ──────────────────────────────────────────── */ -import type { PatchOperation } from "../../utils/sourcePatcher"; - -export function buildPathOffsetPatches(element: HTMLElement): PatchOperation[] { - const x = element.style.getPropertyValue(STUDIO_OFFSET_X_PROP); - const y = element.style.getPropertyValue(STUDIO_OFFSET_Y_PROP); - const translate = element.style.getPropertyValue("translate"); - const originalTranslate = element.getAttribute(STUDIO_ORIGINAL_TRANSLATE_ATTR); - const originalInlineTranslate = element.getAttribute(STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR); - const displayVal = element.style.getPropertyValue("display"); - const transformDisplayAttr = element.getAttribute(STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR); - const ops: PatchOperation[] = []; - if (x) ops.push({ type: "inline-style", property: STUDIO_OFFSET_X_PROP, value: x }); - if (y) ops.push({ type: "inline-style", property: STUDIO_OFFSET_Y_PROP, value: y }); - if (translate) ops.push({ type: "inline-style", property: "translate", value: translate }); - ops.push({ type: "attribute", property: STUDIO_PATH_OFFSET_ATTR, value: "true" }); - if (originalTranslate !== null) - ops.push({ - type: "attribute", - property: STUDIO_ORIGINAL_TRANSLATE_ATTR, - value: originalTranslate, - }); - if (originalInlineTranslate !== null) - ops.push({ - type: "attribute", - property: STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR, - value: originalInlineTranslate, - }); - if (displayVal) ops.push({ type: "inline-style", property: "display", value: displayVal }); - if (transformDisplayAttr !== null) - ops.push({ - type: "attribute", - property: STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, - value: transformDisplayAttr, - }); - return ops; -} - -export function buildClearPathOffsetPatches(element: HTMLElement): PatchOperation[] { - const originalInlineTranslate = element.getAttribute(STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR); - const ops: PatchOperation[] = [ - { type: "inline-style", property: STUDIO_OFFSET_X_PROP, value: null }, - { type: "inline-style", property: STUDIO_OFFSET_Y_PROP, value: null }, - { - type: "inline-style", - property: "translate", - value: originalInlineTranslate || null, - }, - { type: "attribute", property: STUDIO_PATH_OFFSET_ATTR, value: null }, - { type: "attribute", property: STUDIO_ORIGINAL_TRANSLATE_ATTR, value: null }, - { type: "attribute", property: STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR, value: null }, - ]; - const origDisplay = element.getAttribute(STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR); - if (origDisplay !== null) { - ops.push({ type: "inline-style", property: "display", value: origDisplay || null }); - ops.push({ type: "attribute", property: STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, value: null }); - } - return ops; -} - -export function buildBoxSizePatches(element: HTMLElement): PatchOperation[] { - const ops: PatchOperation[] = []; - - const studioWidth = element.style.getPropertyValue(STUDIO_WIDTH_PROP); - const studioHeight = element.style.getPropertyValue(STUDIO_HEIGHT_PROP); - if (studioWidth) - ops.push({ type: "inline-style", property: STUDIO_WIDTH_PROP, value: studioWidth }); - if (studioHeight) - ops.push({ type: "inline-style", property: STUDIO_HEIGHT_PROP, value: studioHeight }); - - const width = element.style.getPropertyValue("width"); - const height = element.style.getPropertyValue("height"); - const minWidth = element.style.getPropertyValue("min-width"); - const minHeight = element.style.getPropertyValue("min-height"); - const maxWidth = element.style.getPropertyValue("max-width"); - const maxHeight = element.style.getPropertyValue("max-height"); - const flexBasis = element.style.getPropertyValue("flex-basis"); - const flexGrow = element.style.getPropertyValue("flex-grow"); - const flexShrink = element.style.getPropertyValue("flex-shrink"); - const boxSizing = element.style.getPropertyValue("box-sizing"); - const scale = element.style.getPropertyValue("scale"); - const transformOrigin = element.style.getPropertyValue("transform-origin"); - const displayVal = element.style.getPropertyValue("display"); - - if (width) ops.push({ type: "inline-style", property: "width", value: width }); - if (height) ops.push({ type: "inline-style", property: "height", value: height }); - if (minWidth) ops.push({ type: "inline-style", property: "min-width", value: minWidth }); - if (minHeight) ops.push({ type: "inline-style", property: "min-height", value: minHeight }); - if (maxWidth) ops.push({ type: "inline-style", property: "max-width", value: maxWidth }); - if (maxHeight) ops.push({ type: "inline-style", property: "max-height", value: maxHeight }); - if (flexBasis) ops.push({ type: "inline-style", property: "flex-basis", value: flexBasis }); - if (flexGrow) ops.push({ type: "inline-style", property: "flex-grow", value: flexGrow }); - if (flexShrink) ops.push({ type: "inline-style", property: "flex-shrink", value: flexShrink }); - if (boxSizing) ops.push({ type: "inline-style", property: "box-sizing", value: boxSizing }); - if (scale) ops.push({ type: "inline-style", property: "scale", value: scale }); - if (transformOrigin) - ops.push({ type: "inline-style", property: "transform-origin", value: transformOrigin }); - if (displayVal) ops.push({ type: "inline-style", property: "display", value: displayVal }); - - ops.push({ type: "attribute", property: STUDIO_BOX_SIZE_ATTR, value: "true" }); - - const origWidth = element.getAttribute(STUDIO_ORIGINAL_WIDTH_ATTR); - const origHeight = element.getAttribute(STUDIO_ORIGINAL_HEIGHT_ATTR); - const origMinWidth = element.getAttribute(STUDIO_ORIGINAL_MIN_WIDTH_ATTR); - const origMinHeight = element.getAttribute(STUDIO_ORIGINAL_MIN_HEIGHT_ATTR); - const origMaxWidth = element.getAttribute(STUDIO_ORIGINAL_MAX_WIDTH_ATTR); - const origMaxHeight = element.getAttribute(STUDIO_ORIGINAL_MAX_HEIGHT_ATTR); - const origFlexBasis = element.getAttribute(STUDIO_ORIGINAL_FLEX_BASIS_ATTR); - const origFlexGrow = element.getAttribute(STUDIO_ORIGINAL_FLEX_GROW_ATTR); - const origFlexShrink = element.getAttribute(STUDIO_ORIGINAL_FLEX_SHRINK_ATTR); - const origBoxSizing = element.getAttribute(STUDIO_ORIGINAL_BOX_SIZING_ATTR); - const origScale = element.getAttribute(STUDIO_ORIGINAL_SCALE_ATTR); - const origTransformOrigin = element.getAttribute(STUDIO_ORIGINAL_TRANSFORM_ORIGIN_ATTR); - const origDisplay = element.getAttribute(STUDIO_ORIGINAL_DISPLAY_ATTR); - const origTransformDisplay = element.getAttribute(STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR); - - if (origWidth !== null) - ops.push({ type: "attribute", property: STUDIO_ORIGINAL_WIDTH_ATTR, value: origWidth }); - if (origHeight !== null) - ops.push({ type: "attribute", property: STUDIO_ORIGINAL_HEIGHT_ATTR, value: origHeight }); - if (origMinWidth !== null) - ops.push({ type: "attribute", property: STUDIO_ORIGINAL_MIN_WIDTH_ATTR, value: origMinWidth }); - if (origMinHeight !== null) - ops.push({ - type: "attribute", - property: STUDIO_ORIGINAL_MIN_HEIGHT_ATTR, - value: origMinHeight, - }); - if (origMaxWidth !== null) - ops.push({ type: "attribute", property: STUDIO_ORIGINAL_MAX_WIDTH_ATTR, value: origMaxWidth }); - if (origMaxHeight !== null) - ops.push({ - type: "attribute", - property: STUDIO_ORIGINAL_MAX_HEIGHT_ATTR, - value: origMaxHeight, - }); - if (origFlexBasis !== null) - ops.push({ - type: "attribute", - property: STUDIO_ORIGINAL_FLEX_BASIS_ATTR, - value: origFlexBasis, - }); - if (origFlexGrow !== null) - ops.push({ type: "attribute", property: STUDIO_ORIGINAL_FLEX_GROW_ATTR, value: origFlexGrow }); - if (origFlexShrink !== null) - ops.push({ - type: "attribute", - property: STUDIO_ORIGINAL_FLEX_SHRINK_ATTR, - value: origFlexShrink, - }); - if (origBoxSizing !== null) - ops.push({ - type: "attribute", - property: STUDIO_ORIGINAL_BOX_SIZING_ATTR, - value: origBoxSizing, - }); - if (origScale !== null) - ops.push({ type: "attribute", property: STUDIO_ORIGINAL_SCALE_ATTR, value: origScale }); - if (origTransformOrigin !== null) - ops.push({ - type: "attribute", - property: STUDIO_ORIGINAL_TRANSFORM_ORIGIN_ATTR, - value: origTransformOrigin, - }); - if (origDisplay !== null) - ops.push({ type: "attribute", property: STUDIO_ORIGINAL_DISPLAY_ATTR, value: origDisplay }); - if (origTransformDisplay !== null) - ops.push({ - type: "attribute", - property: STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, - value: origTransformDisplay, - }); - - return ops; -} - -export function buildClearBoxSizePatches(element: HTMLElement): PatchOperation[] { - const ops: PatchOperation[] = [ - { type: "inline-style", property: STUDIO_WIDTH_PROP, value: null }, - { type: "inline-style", property: STUDIO_HEIGHT_PROP, value: null }, - { type: "attribute", property: STUDIO_BOX_SIZE_ATTR, value: null }, - ]; - - const origAttrs: Array<[string, string]> = [ - [STUDIO_ORIGINAL_WIDTH_ATTR, "width"], - [STUDIO_ORIGINAL_HEIGHT_ATTR, "height"], - [STUDIO_ORIGINAL_MIN_WIDTH_ATTR, "min-width"], - [STUDIO_ORIGINAL_MIN_HEIGHT_ATTR, "min-height"], - [STUDIO_ORIGINAL_MAX_WIDTH_ATTR, "max-width"], - [STUDIO_ORIGINAL_MAX_HEIGHT_ATTR, "max-height"], - [STUDIO_ORIGINAL_FLEX_BASIS_ATTR, "flex-basis"], - [STUDIO_ORIGINAL_FLEX_GROW_ATTR, "flex-grow"], - [STUDIO_ORIGINAL_FLEX_SHRINK_ATTR, "flex-shrink"], - [STUDIO_ORIGINAL_BOX_SIZING_ATTR, "box-sizing"], - [STUDIO_ORIGINAL_SCALE_ATTR, "scale"], - [STUDIO_ORIGINAL_TRANSFORM_ORIGIN_ATTR, "transform-origin"], - [STUDIO_ORIGINAL_DISPLAY_ATTR, "display"], - ]; - - for (const [attrName, styleProp] of origAttrs) { - const origVal = element.getAttribute(attrName); - if (origVal !== null) { - ops.push({ type: "inline-style", property: styleProp, value: origVal || null }); - } - ops.push({ type: "attribute", property: attrName, value: null }); - } - - const origTransformDisplay = element.getAttribute(STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR); - if (origTransformDisplay !== null) { - ops.push({ type: "inline-style", property: "display", value: origTransformDisplay || null }); - ops.push({ type: "attribute", property: STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, value: null }); - } +/* ── HTML patch builders (re-exported from manualEditsDomPatches) ── */ +export { + buildPathOffsetPatches, + buildClearPathOffsetPatches, + buildBoxSizePatches, + buildClearBoxSizePatches, + buildRotationPatches, + buildClearRotationPatches, + buildMotionPatches, + buildClearMotionPatches, +} from "./manualEditsDomPatches"; - return ops; -} - -export function buildRotationPatches(element: HTMLElement): PatchOperation[] { - const ops: PatchOperation[] = []; - - const studioRotation = element.style.getPropertyValue(STUDIO_ROTATION_PROP); - const rotate = element.style.getPropertyValue("rotate"); - const transformOrigin = element.style.getPropertyValue("transform-origin"); - const displayVal = element.style.getPropertyValue("display"); - - if (studioRotation) - ops.push({ type: "inline-style", property: STUDIO_ROTATION_PROP, value: studioRotation }); - if (rotate) ops.push({ type: "inline-style", property: "rotate", value: rotate }); - if (transformOrigin) - ops.push({ type: "inline-style", property: "transform-origin", value: transformOrigin }); - if (displayVal) ops.push({ type: "inline-style", property: "display", value: displayVal }); - - ops.push({ type: "attribute", property: STUDIO_ROTATION_ATTR, value: "true" }); +/* ── Seek reapply (position + motion) ────────────────────────────── */ - const origRotate = element.getAttribute(STUDIO_ORIGINAL_ROTATE_ATTR); - const origInlineRotate = element.getAttribute(STUDIO_ORIGINAL_INLINE_ROTATE_ATTR); - const origRotationTransformOrigin = element.getAttribute( - STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR, +function queryStudioElements(doc: Document, attr: string): HTMLElement[] { + const ctor = doc.defaultView?.HTMLElement; + if (!ctor) return []; + return Array.from(doc.querySelectorAll(`[${attr}="true"]`)).filter( + (el): el is HTMLElement => el instanceof ctor, ); - const origTransformDisplay = element.getAttribute(STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR); - - if (origRotate !== null) - ops.push({ type: "attribute", property: STUDIO_ORIGINAL_ROTATE_ATTR, value: origRotate }); - if (origInlineRotate !== null) - ops.push({ - type: "attribute", - property: STUDIO_ORIGINAL_INLINE_ROTATE_ATTR, - value: origInlineRotate, - }); - if (origRotationTransformOrigin !== null) - ops.push({ - type: "attribute", - property: STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR, - value: origRotationTransformOrigin, - }); - if (origTransformDisplay !== null) - ops.push({ - type: "attribute", - property: STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, - value: origTransformDisplay, - }); - - return ops; -} - -export function buildClearRotationPatches(element: HTMLElement): PatchOperation[] { - const origInlineRotate = element.getAttribute(STUDIO_ORIGINAL_INLINE_ROTATE_ATTR); - const origRotationTransformOrigin = element.getAttribute( - STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR, - ); - const ops: PatchOperation[] = [ - { type: "inline-style", property: STUDIO_ROTATION_PROP, value: null }, - { type: "inline-style", property: "rotate", value: origInlineRotate || null }, - { - type: "inline-style", - property: "transform-origin", - value: origRotationTransformOrigin !== null ? origRotationTransformOrigin || null : null, - }, - { type: "attribute", property: STUDIO_ROTATION_ATTR, value: null }, - { type: "attribute", property: STUDIO_ROTATION_DRAFT_ATTR, value: null }, - { type: "attribute", property: STUDIO_ORIGINAL_ROTATE_ATTR, value: null }, - { type: "attribute", property: STUDIO_ORIGINAL_INLINE_ROTATE_ATTR, value: null }, - { type: "attribute", property: STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR, value: null }, - ]; - const origTransformDisplay = element.getAttribute(STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR); - if (origTransformDisplay !== null) { - ops.push({ type: "inline-style", property: "display", value: origTransformDisplay || null }); - ops.push({ type: "attribute", property: STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, value: null }); - } - return ops; -} - -/* ── Motion HTML patch builders ──────────────────────────────────── */ - -export function buildMotionPatches(element: HTMLElement): PatchOperation[] { - const motionJson = element.getAttribute(STUDIO_MOTION_ATTR); - if (!motionJson) return []; - const ops: PatchOperation[] = [ - { type: "attribute", property: STUDIO_MOTION_ATTR, value: motionJson }, - ]; - const origTransform = element.getAttribute(STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR); - if (origTransform !== null) { - ops.push({ - type: "attribute", - property: STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR, - value: origTransform, - }); - } - const origOpacity = element.getAttribute(STUDIO_MOTION_ORIGINAL_OPACITY_ATTR); - if (origOpacity !== null) { - ops.push({ - type: "attribute", - property: STUDIO_MOTION_ORIGINAL_OPACITY_ATTR, - value: origOpacity, - }); - } - const origVisibility = element.getAttribute(STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR); - if (origVisibility !== null) { - ops.push({ - type: "attribute", - property: STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR, - value: origVisibility, - }); - } - return ops; -} - -export function buildClearMotionPatches(_element: HTMLElement): PatchOperation[] { - return [ - { type: "attribute", property: STUDIO_MOTION_ATTR, value: null }, - { type: "attribute", property: STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR, value: null }, - { type: "attribute", property: STUDIO_MOTION_ORIGINAL_OPACITY_ATTR, value: null }, - { type: "attribute", property: STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR, value: null }, - ]; } -/* ── Seek reapply (position + motion) ────────────────────────────── */ - -export function reapplyPositionEditsAfterSeek(doc: Document): void { - const htmlElement = doc.defaultView?.HTMLElement; - if (!htmlElement) return; - - const offsetEls = Array.from(doc.querySelectorAll(`[${STUDIO_PATH_OFFSET_ATTR}="true"]`)).filter( - (el): el is HTMLElement => el instanceof htmlElement, - ); - for (const el of offsetEls) { +function reapplyPathOffsets(doc: Document): void { + for (const el of queryStudioElements(doc, STUDIO_PATH_OFFSET_ATTR)) { const x = el.style.getPropertyValue(STUDIO_OFFSET_X_PROP); const y = el.style.getPropertyValue(STUDIO_OFFSET_Y_PROP); if (x || y) { @@ -818,28 +495,30 @@ export function reapplyPositionEditsAfterSeek(doc: Document): void { }); } } +} - const boxSizeEls = Array.from(doc.querySelectorAll(`[${STUDIO_BOX_SIZE_ATTR}="true"]`)).filter( - (el): el is HTMLElement => el instanceof htmlElement, - ); - for (const el of boxSizeEls) { +function reapplyBoxSizes(doc: Document): void { + for (const el of queryStudioElements(doc, STUDIO_BOX_SIZE_ATTR)) { const w = Number.parseFloat(el.style.getPropertyValue(STUDIO_WIDTH_PROP)); const h = Number.parseFloat(el.style.getPropertyValue(STUDIO_HEIGHT_PROP)); if (Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0) { applyStudioBoxSize(el, { width: w, height: h }); } } +} - const rotationEls = Array.from(doc.querySelectorAll(`[${STUDIO_ROTATION_ATTR}="true"]`)).filter( - (el): el is HTMLElement => el instanceof htmlElement, - ); - for (const el of rotationEls) { +function reapplyRotations(doc: Document): void { + for (const el of queryStudioElements(doc, STUDIO_ROTATION_ATTR)) { const angle = Number.parseFloat(el.style.getPropertyValue(STUDIO_ROTATION_PROP)); if (Number.isFinite(angle)) { applyStudioRotation(el, { angle }); } } +} - // Reapply DOM-backed motion timeline after seek +export function reapplyPositionEditsAfterSeek(doc: Document): void { + reapplyPathOffsets(doc); + reapplyBoxSizes(doc); + reapplyRotations(doc); applyStudioMotionFromDom(doc); } diff --git a/packages/studio/src/components/editor/manualEditsDomPatches.ts b/packages/studio/src/components/editor/manualEditsDomPatches.ts new file mode 100644 index 000000000..532920038 --- /dev/null +++ b/packages/studio/src/components/editor/manualEditsDomPatches.ts @@ -0,0 +1,237 @@ +import type { PatchOperation } from "../../utils/sourcePatcher"; +import { + STUDIO_OFFSET_X_PROP, + STUDIO_OFFSET_Y_PROP, + STUDIO_WIDTH_PROP, + STUDIO_HEIGHT_PROP, + STUDIO_ROTATION_PROP, + STUDIO_PATH_OFFSET_ATTR, + STUDIO_BOX_SIZE_ATTR, + STUDIO_ROTATION_ATTR, + STUDIO_ROTATION_DRAFT_ATTR, + STUDIO_ORIGINAL_TRANSLATE_ATTR, + STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR, + STUDIO_ORIGINAL_WIDTH_ATTR, + STUDIO_ORIGINAL_HEIGHT_ATTR, + STUDIO_ORIGINAL_MIN_WIDTH_ATTR, + STUDIO_ORIGINAL_MIN_HEIGHT_ATTR, + STUDIO_ORIGINAL_MAX_WIDTH_ATTR, + STUDIO_ORIGINAL_MAX_HEIGHT_ATTR, + STUDIO_ORIGINAL_FLEX_BASIS_ATTR, + STUDIO_ORIGINAL_FLEX_GROW_ATTR, + STUDIO_ORIGINAL_FLEX_SHRINK_ATTR, + STUDIO_ORIGINAL_BOX_SIZING_ATTR, + STUDIO_ORIGINAL_SCALE_ATTR, + STUDIO_ORIGINAL_TRANSFORM_ORIGIN_ATTR, + STUDIO_ORIGINAL_DISPLAY_ATTR, + STUDIO_ORIGINAL_ROTATE_ATTR, + STUDIO_ORIGINAL_INLINE_ROTATE_ATTR, + STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR, + STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, +} from "./manualEditsTypes"; +import { + STUDIO_MOTION_ATTR, + STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR, + STUDIO_MOTION_ORIGINAL_OPACITY_ATTR, + STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR, +} from "./studioMotionTypes"; + +/* ── Shared helpers ──────────────────────────────────────────────── */ + +function collectInlineStyleOps( + element: HTMLElement, + properties: readonly string[], + ops: PatchOperation[], +): void { + for (const prop of properties) { + const val = element.style.getPropertyValue(prop); + if (val) ops.push({ type: "inline-style", property: prop, value: val }); + } +} + +function collectAttributeOps( + element: HTMLElement, + attrNames: readonly string[], + ops: PatchOperation[], +): void { + for (const attr of attrNames) { + const val = element.getAttribute(attr); + if (val !== null) ops.push({ type: "attribute", property: attr, value: val }); + } +} + +function appendTransformDisplayOps(element: HTMLElement, ops: PatchOperation[]): void { + const val = element.getAttribute(STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR); + if (val !== null) { + ops.push({ type: "inline-style", property: "display", value: val || null }); + ops.push({ type: "attribute", property: STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, value: null }); + } +} + +/* ── Path offset patches ─────────────────────────────────────────── */ + +export function buildPathOffsetPatches(element: HTMLElement): PatchOperation[] { + const ops: PatchOperation[] = []; + collectInlineStyleOps(element, [STUDIO_OFFSET_X_PROP, STUDIO_OFFSET_Y_PROP, "translate"], ops); + ops.push({ type: "attribute", property: STUDIO_PATH_OFFSET_ATTR, value: "true" }); + collectAttributeOps( + element, + [STUDIO_ORIGINAL_TRANSLATE_ATTR, STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR], + ops, + ); + collectInlineStyleOps(element, ["display"], ops); + collectAttributeOps(element, [STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR], ops); + return ops; +} + +export function buildClearPathOffsetPatches(element: HTMLElement): PatchOperation[] { + const originalInlineTranslate = element.getAttribute(STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR); + const ops: PatchOperation[] = [ + { type: "inline-style", property: STUDIO_OFFSET_X_PROP, value: null }, + { type: "inline-style", property: STUDIO_OFFSET_Y_PROP, value: null }, + { type: "inline-style", property: "translate", value: originalInlineTranslate || null }, + { type: "attribute", property: STUDIO_PATH_OFFSET_ATTR, value: null }, + { type: "attribute", property: STUDIO_ORIGINAL_TRANSLATE_ATTR, value: null }, + { type: "attribute", property: STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR, value: null }, + ]; + appendTransformDisplayOps(element, ops); + return ops; +} + +/* ── Box size patches ────────────────────────────────────────────── */ + +const BOX_SIZE_STYLE_PROPS = [ + "width", + "height", + "min-width", + "min-height", + "max-width", + "max-height", + "flex-basis", + "flex-grow", + "flex-shrink", + "box-sizing", + "scale", + "transform-origin", + "display", +] as const; + +const BOX_SIZE_ORIG_ATTRS: ReadonlyArray<[string, string]> = [ + [STUDIO_ORIGINAL_WIDTH_ATTR, "width"], + [STUDIO_ORIGINAL_HEIGHT_ATTR, "height"], + [STUDIO_ORIGINAL_MIN_WIDTH_ATTR, "min-width"], + [STUDIO_ORIGINAL_MIN_HEIGHT_ATTR, "min-height"], + [STUDIO_ORIGINAL_MAX_WIDTH_ATTR, "max-width"], + [STUDIO_ORIGINAL_MAX_HEIGHT_ATTR, "max-height"], + [STUDIO_ORIGINAL_FLEX_BASIS_ATTR, "flex-basis"], + [STUDIO_ORIGINAL_FLEX_GROW_ATTR, "flex-grow"], + [STUDIO_ORIGINAL_FLEX_SHRINK_ATTR, "flex-shrink"], + [STUDIO_ORIGINAL_BOX_SIZING_ATTR, "box-sizing"], + [STUDIO_ORIGINAL_SCALE_ATTR, "scale"], + [STUDIO_ORIGINAL_TRANSFORM_ORIGIN_ATTR, "transform-origin"], + [STUDIO_ORIGINAL_DISPLAY_ATTR, "display"], + [STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, ""], +]; + +export function buildBoxSizePatches(element: HTMLElement): PatchOperation[] { + const ops: PatchOperation[] = []; + collectInlineStyleOps(element, [STUDIO_WIDTH_PROP, STUDIO_HEIGHT_PROP], ops); + collectInlineStyleOps(element, BOX_SIZE_STYLE_PROPS, ops); + ops.push({ type: "attribute", property: STUDIO_BOX_SIZE_ATTR, value: "true" }); + collectAttributeOps( + element, + BOX_SIZE_ORIG_ATTRS.map(([attr]) => attr), + ops, + ); + return ops; +} + +export function buildClearBoxSizePatches(element: HTMLElement): PatchOperation[] { + const ops: PatchOperation[] = [ + { type: "inline-style", property: STUDIO_WIDTH_PROP, value: null }, + { type: "inline-style", property: STUDIO_HEIGHT_PROP, value: null }, + { type: "attribute", property: STUDIO_BOX_SIZE_ATTR, value: null }, + ]; + for (const [attrName, styleProp] of BOX_SIZE_ORIG_ATTRS) { + const origVal = element.getAttribute(attrName); + if (origVal !== null && styleProp) { + ops.push({ type: "inline-style", property: styleProp, value: origVal || null }); + } + ops.push({ type: "attribute", property: attrName, value: null }); + } + return ops; +} + +/* ── Rotation patches ────────────────────────────────────────────── */ + +const ROTATION_STYLE_PROPS = [ + STUDIO_ROTATION_PROP, + "rotate", + "transform-origin", + "display", +] as const; + +const ROTATION_ORIG_ATTRS = [ + STUDIO_ORIGINAL_ROTATE_ATTR, + STUDIO_ORIGINAL_INLINE_ROTATE_ATTR, + STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR, + STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, +] as const; + +export function buildRotationPatches(element: HTMLElement): PatchOperation[] { + const ops: PatchOperation[] = []; + collectInlineStyleOps(element, ROTATION_STYLE_PROPS, ops); + ops.push({ type: "attribute", property: STUDIO_ROTATION_ATTR, value: "true" }); + collectAttributeOps(element, ROTATION_ORIG_ATTRS, ops); + return ops; +} + +export function buildClearRotationPatches(element: HTMLElement): PatchOperation[] { + const origInlineRotate = element.getAttribute(STUDIO_ORIGINAL_INLINE_ROTATE_ATTR); + const origRotationTransformOrigin = element.getAttribute( + STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR, + ); + const ops: PatchOperation[] = [ + { type: "inline-style", property: STUDIO_ROTATION_PROP, value: null }, + { type: "inline-style", property: "rotate", value: origInlineRotate || null }, + { + type: "inline-style", + property: "transform-origin", + value: origRotationTransformOrigin !== null ? origRotationTransformOrigin || null : null, + }, + { type: "attribute", property: STUDIO_ROTATION_ATTR, value: null }, + { type: "attribute", property: STUDIO_ROTATION_DRAFT_ATTR, value: null }, + { type: "attribute", property: STUDIO_ORIGINAL_ROTATE_ATTR, value: null }, + { type: "attribute", property: STUDIO_ORIGINAL_INLINE_ROTATE_ATTR, value: null }, + { type: "attribute", property: STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR, value: null }, + ]; + appendTransformDisplayOps(element, ops); + return ops; +} + +/* ── Motion patches ──────────────────────────────────────────────── */ + +const MOTION_ORIG_ATTRS = [ + STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR, + STUDIO_MOTION_ORIGINAL_OPACITY_ATTR, + STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR, +] as const; + +export function buildMotionPatches(element: HTMLElement): PatchOperation[] { + const motionJson = element.getAttribute(STUDIO_MOTION_ATTR); + if (!motionJson) return []; + const ops: PatchOperation[] = [ + { type: "attribute", property: STUDIO_MOTION_ATTR, value: motionJson }, + ]; + collectAttributeOps(element, MOTION_ORIG_ATTRS, ops); + return ops; +} + +export function buildClearMotionPatches(_element: HTMLElement): PatchOperation[] { + return [ + { type: "attribute", property: STUDIO_MOTION_ATTR, value: null }, + { type: "attribute", property: STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR, value: null }, + { type: "attribute", property: STUDIO_MOTION_ORIGINAL_OPACITY_ATTR, value: null }, + { type: "attribute", property: STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR, value: null }, + ]; +} diff --git a/packages/studio/src/player/components/PlayerControls.tsx b/packages/studio/src/player/components/PlayerControls.tsx index 05a5c0ffd..50628ce7d 100644 --- a/packages/studio/src/player/components/PlayerControls.tsx +++ b/packages/studio/src/player/components/PlayerControls.tsx @@ -1,52 +1,319 @@ -import { useRef, useState, useCallback, useEffect, memo } from "react"; -import { useMountEffect } from "../../hooks/useMountEffect"; -import { formatFrameTime, frameToSeconds, stepFrameTime, formatTime } from "../lib/time"; +import { useRef, useCallback, useEffect, memo } from "react"; +import { formatFrameTime, formatTime, stepFrameTime } from "../lib/time"; import { shouldMutePreviewAudio } from "../lib/timelineIframeHelpers"; -import { usePlayerStore, liveTime } from "../store/playerStore"; +import { usePlayerStore } from "../store/playerStore"; import { trackStudioEvent } from "../../utils/studioTelemetry"; import { Tooltip } from "../../components/ui"; +import { ShortcutsPanel } from "./ShortcutsPanel"; +import { SpeedMenu } from "./SpeedMenu"; +import { useSeekBarDrag, resolveSeekPercent } from "./useSeekBarDrag"; +import { useState } from "react"; -const SPEED_OPTIONS = [0.25, 0.5, 1, 1.5, 2] as const; -const SEEK_EDGE_SNAP_PX = 8; +export { resolveSeekPercent }; type TimeDisplayMode = "time" | "frame"; -const SHORTCUT_SECTIONS = [ - { - title: "Playback", - hints: [ - { key: "Space", label: "Play / Pause" }, - { key: "J", label: "Play backward" }, - { key: "K", label: "Stop" }, - { key: "L", label: "Play forward" }, - { key: "M", label: "Toggle mute" }, - { key: "⇧L", label: "Toggle loop" }, - { key: "←/→", label: "Step 1 frame" }, - { key: "⇧←/⇧→", label: "Step 10 frames" }, - { key: "F", label: "Toggle fullscreen" }, - ], - }, - { - title: "Work area", - hints: [ - { key: "I", label: "Set in-point" }, - { key: "⇧I", label: "Clear in-point" }, - { key: "O", label: "Set out-point" }, - { key: "⇧O", label: "Clear out-point" }, - { key: "A", label: "Jump to in-point" }, - { key: "E", label: "Jump to out-point" }, - ], - }, -] as const; -export function resolveSeekPercent(clientX: number, rectLeft: number, rectWidth: number): number { - if (!Number.isFinite(rectWidth) || rectWidth <= 0) return 0; - const rawPercent = (clientX - rectLeft) / rectWidth; - const clamped = Math.max(0, Math.min(1, rawPercent)); - const snapThreshold = Math.min(0.5, SEEK_EDGE_SNAP_PX / rectWidth); - if (clamped <= snapThreshold) return 0; - if (clamped >= 1 - snapThreshold) return 1; - return clamped; +/* ── Icon sub-components ─────────────────────────────────────────── */ + +function PlayIcon() { + return ( + + ); +} + +function PauseIcon() { + return ( + + ); +} + +/* ── Button sub-components ───────────────────────────────────────── */ + +const MuteButton = memo(function MuteButton({ + audioMuted, + audioAutoMuted, + effectiveAudioMuted, + controlsDisabled, + setAudioMuted, +}: { + audioMuted: boolean; + audioAutoMuted: boolean; + effectiveAudioMuted: boolean; + controlsDisabled: boolean; + setAudioMuted: (v: boolean) => void; +}) { + const label = audioAutoMuted + ? "Audio muted above 1x speed" + : audioMuted + ? "Unmute audio" + : "Mute audio"; + return ( + + + + ); +}); + +const LoopButton = memo(function LoopButton({ + loopEnabled, + disabled, + setLoopEnabled, +}: { + loopEnabled: boolean; + disabled: boolean; + setLoopEnabled: (v: boolean) => void; +}) { + return ( + + + + ); +}); + +const FullscreenButton = memo(function FullscreenButton({ + isFullscreen, + onToggleFullscreen, +}: { + isFullscreen: boolean; + onToggleFullscreen: () => void; +}) { + return ( + + + + ); +}); + +/* ── Seek bar sub-component ──────────────────────────────────────── */ + +function SeekBarMarker({ position, duration }: { position: number; duration: number }) { + if (duration <= 0) return null; + return ( +
+ ); +} + +function WorkAreaOverlay({ + inPoint, + outPoint, + duration, +}: { + inPoint: number | null; + outPoint: number | null; + duration: number; +}) { + if ((inPoint === null && outPoint === null) || duration <= 0) return null; + return ( + <> +
+ {inPoint !== null && } + {outPoint !== null && } + + ); } +const SeekBar = memo(function SeekBar({ + disabled, + duration, + inPoint, + outPoint, + progressFillRef, + progressThumbRef, + seekBarRef, + sliderRef, + onPointerDown, + onKeyDown, +}: { + disabled: boolean; + duration: number; + inPoint: number | null; + outPoint: number | null; + progressFillRef: React.RefObject; + progressThumbRef: React.RefObject; + seekBarRef: React.RefObject; + sliderRef: React.RefObject; + onPointerDown: (e: React.PointerEvent) => void; + onKeyDown: (e: React.KeyboardEvent) => void; +}) { + return ( +
{ + (seekBarRef as React.MutableRefObject).current = el; + (sliderRef as React.MutableRefObject).current = el; + }} + role="slider" + tabIndex={disabled ? -1 : 0} + aria-label="Seek" + aria-disabled={disabled || undefined} + aria-valuemin={0} + aria-valuemax={Math.round(duration)} + aria-valuenow={0} + className={`min-w-[96px] flex-1 h-6 flex items-center group ${ + disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer" + }`} + style={{ touchAction: "none" }} + onPointerDown={onPointerDown} + onKeyDown={onKeyDown} + > +
+ +
+
+
+
+ ); +}); + +/* ── Main component ──────────────────────────────────────────────── */ + interface PlayerControlsProps { onTogglePlay: () => void; onSeek: (time: number) => void; @@ -62,7 +329,6 @@ export const PlayerControls = memo(function PlayerControls({ isFullscreen = false, onToggleFullscreen, }: PlayerControlsProps) { - // Subscribe to only the fields we render — each selector prevents cascading re-renders const isPlaying = usePlayerStore((s) => s.isPlaying); const duration = usePlayerStore((s) => s.duration); const timelineReady = usePlayerStore((s) => s.timelineReady); @@ -76,18 +342,13 @@ export const PlayerControls = memo(function PlayerControls({ const outPoint = usePlayerStore((s) => s.outPoint); const setInPoint = usePlayerStore.getState().setInPoint; const setOutPoint = usePlayerStore.getState().setOutPoint; - const [showSpeedMenu, setShowSpeedMenu] = useState(false); - const [showShortcuts, setShowShortcuts] = useState(false); const [timeDisplayMode, setTimeDisplayMode] = useState("time"); - const [jumpFrame, setJumpFrame] = useState(""); const progressFillRef = useRef(null); const progressThumbRef = useRef(null); const timeDisplayRef = useRef(null); const seekBarRef = useRef(null); const sliderRef = useRef(null); - const speedMenuContainerRef = useRef(null); - const shortcutsPanelRef = useRef(null); const isDraggingRef = useRef(false); const currentTimeRef = useRef(0); const timeDisplayModeRef = useRef(timeDisplayMode); @@ -98,43 +359,6 @@ export const PlayerControls = memo(function PlayerControls({ const controlsDisabled = disabled || !timelineReady; const audioAutoMuted = playbackRate > 1; const effectiveAudioMuted = shouldMutePreviewAudio(audioMuted, playbackRate); - const muteButtonLabel = audioAutoMuted - ? "Audio muted above 1x speed" - : audioMuted - ? "Unmute audio" - : "Mute audio"; - useMountEffect(() => { - const updateProgress = (t: number) => { - currentTimeRef.current = t; - const dur = durationRef.current; - const pct = dur > 0 ? Math.min(100, (t / dur) * 100) : 0; - if (progressFillRef.current) progressFillRef.current.style.width = `${pct}%`; - if (progressThumbRef.current) progressThumbRef.current.style.left = `${pct}%`; - if (timeDisplayRef.current) { - timeDisplayRef.current.textContent = - timeDisplayModeRef.current === "frame" ? formatFrameTime(t, dur) : formatTime(t); - } - if (sliderRef.current) sliderRef.current.setAttribute("aria-valuenow", String(Math.round(t))); - }; - const unsub = liveTime.subscribe(updateProgress); - updateProgress(usePlayerStore.getState().currentTime); - - // Also poll every 500ms as a fallback in case liveTime doesn't fire - const interval = setInterval(() => { - const t = usePlayerStore.getState().currentTime; - const dur = usePlayerStore.getState().duration; - if (dur > 0 && t > 0) { - const pct = Math.min(100, (t / dur) * 100); - if (progressFillRef.current) progressFillRef.current.style.width = `${pct}%`; - if (progressThumbRef.current) progressThumbRef.current.style.left = `${pct}%`; - } - }, 500); - - return () => { - unsub(); - clearInterval(interval); - }; - }); useEffect(() => { if (!timeDisplayRef.current) return; @@ -143,150 +367,21 @@ export const PlayerControls = memo(function PlayerControls({ timeDisplayMode === "frame" ? formatFrameTime(t, duration) : formatTime(t); }, [duration, timeDisplayMode]); - useEffect(() => { - if (!showSpeedMenu) return; - const handleMouseDown = (e: MouseEvent) => { - if ( - speedMenuContainerRef.current && - !speedMenuContainerRef.current.contains(e.target as Node) - ) { - setShowSpeedMenu(false); - } - }; - document.addEventListener("mousedown", handleMouseDown); - return () => { - document.removeEventListener("mousedown", handleMouseDown); - }; - }, [showSpeedMenu]); - - useEffect(() => { - if (!showShortcuts) return; - const handleMouseDown = (e: MouseEvent) => { - if (shortcutsPanelRef.current && !shortcutsPanelRef.current.contains(e.target as Node)) { - setShowShortcuts(false); - } - }; - document.addEventListener("mousedown", handleMouseDown); - return () => { - document.removeEventListener("mousedown", handleMouseDown); - }; - }, [showShortcuts]); - - const seekFromClientX = useCallback( - (clientX: number) => { - if (disabled) return; - const bar = seekBarRef.current; - if (!bar || duration <= 0) return; - const rect = bar.getBoundingClientRect(); - const percent = resolveSeekPercent(clientX, rect.left, rect.width); - // Immediately update progress bar visuals (don't wait for liveTime round-trip) - const pct = percent * 100; - if (progressFillRef.current) progressFillRef.current.style.width = `${pct}%`; - if (progressThumbRef.current) progressThumbRef.current.style.left = `${pct}%`; - onSeek(percent * duration); - }, - [disabled, duration, onSeek], - ); - - const handlePointerDown = useCallback( - (e: React.PointerEvent) => { - // Ignore secondary mouse buttons — only primary (left click / touch / - // pen contact) should start a drag. - if (e.button !== 0) return; - e.preventDefault(); - // preventDefault() on pointerdown also suppresses the implicit focus - // transfer that click normally grants a `tabIndex=0` element — which - // matches native `` behavior, but it also means a - // click-then-arrow-key workflow wouldn't work. Restore focus explicitly - // so seeking by click and nudging by arrow keys compose naturally. - e.currentTarget.focus(); - isDraggingRef.current = true; - - // `setPointerCapture` routes every subsequent pointermove/up to the - // slider element even when the pointer leaves its bounding box. Without - // it, fast drags on touch would lose events the moment the finger - // slips outside the 6 px-tall hit zone. - const target = e.currentTarget; - const pointerId = e.pointerId; - try { - target.setPointerCapture(pointerId); - } catch { - /* non-supporting browsers fall back to window listeners below */ - } - - seekFromClientX(e.clientX); - - // During drag, update the slider visual immediately on every pointer - // event but RAF-throttle the actual onSeek call. The seek path triggers - // adapter.seek + setCurrentTime + React re-renders which can take >16ms - // on complex compositions — keeping visual feedback on the raw event and - // batching the expensive work to one call per frame keeps scrubbing at - // 60 fps. - let seekRafId = 0; - let pendingClientX = e.clientX; - const onMove = (ev: PointerEvent) => { - if (ev.pointerId !== pointerId || !isDraggingRef.current) return; - pendingClientX = ev.clientX; - const bar = seekBarRef.current; - const dur = durationRef.current; - if (bar && dur > 0) { - const rect = bar.getBoundingClientRect(); - const pct = resolveSeekPercent(ev.clientX, rect.left, rect.width) * 100; - if (progressFillRef.current) progressFillRef.current.style.width = `${pct}%`; - if (progressThumbRef.current) progressThumbRef.current.style.left = `${pct}%`; - } - if (!seekRafId) { - seekRafId = requestAnimationFrame(() => { - seekRafId = 0; - if (isDraggingRef.current) seekFromClientX(pendingClientX); - }); - } - }; - const cleanup = () => { - isDraggingRef.current = false; - if (seekRafId) { - cancelAnimationFrame(seekRafId); - seekRafId = 0; - } - seekFromClientX(pendingClientX); - try { - target.releasePointerCapture(pointerId); - } catch { - /* Already released after the first cleanup — second invocation - via the window-fallback or visibility path is a no-op throw. */ - } - target.removeEventListener("pointermove", onMove); - target.removeEventListener("pointerup", onUp); - target.removeEventListener("pointercancel", onUp); - window.removeEventListener("pointerup", onUp); - window.removeEventListener("pointercancel", onUp); - document.removeEventListener("visibilitychange", onVisibilityChange); - window.removeEventListener("blur", cleanup); - }; - const onUp = (ev: PointerEvent) => { - if (ev.pointerId !== pointerId) return; - cleanup(); - }; - // iOS Safari does not reliably fire `pointercancel` when the page is - // backgrounded mid-drag (alt-tab, incoming call, switch apps). Without - // a release path the ref stays `true` until the next pointerdown — a - // stuck-scrubber class bug waiting to happen if anyone later gates - // rendering on `isDragging`. Synthesize the release on hide / blur. - const onVisibilityChange = () => { - if (document.visibilityState === "hidden") cleanup(); - }; - - target.addEventListener("pointermove", onMove); - target.addEventListener("pointerup", onUp); - target.addEventListener("pointercancel", onUp); - // Window-level fallback in case capture fails and the pointer release - // lands outside the element (rare, but defensive). - window.addEventListener("pointerup", onUp); - window.addEventListener("pointercancel", onUp); - document.addEventListener("visibilitychange", onVisibilityChange); - window.addEventListener("blur", cleanup); + const { handlePointerDown } = useSeekBarDrag( + { + seekBarRef, + progressFillRef, + progressThumbRef, + sliderRef, + timeDisplayRef, + isDraggingRef, + durationRef, + currentTimeRef, + timeDisplayModeRef, }, - [seekFromClientX], + onSeek, + disabled, + duration, ); const handleKeyDown = useCallback( @@ -304,43 +399,15 @@ export const PlayerControls = memo(function PlayerControls({ [disabled, timelineReady, duration, onSeek], ); - const commitJumpFrame = useCallback(() => { - if (disabled) return; - const frame = Number.parseInt(jumpFrame, 10); - if (!Number.isFinite(frame) || duration <= 0) return; - onSeek(Math.min(duration, frameToSeconds(Math.max(0, frame)))); - }, [disabled, duration, jumpFrame, onSeek]); - - const handleJumpSubmit = useCallback( - (e: React.FormEvent) => { - e.preventDefault(); - commitJumpFrame(); - }, - [commitJumpFrame], - ); - - const handleJumpKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.key !== "Enter") return; - e.preventDefault(); - commitJumpFrame(); - }, - [commitJumpFrame], - ); - return (
- {/* Play/Pause button */} - {/* Time display — click to toggle time/frame mode */} @@ -387,470 +444,48 @@ export const PlayerControls = memo(function PlayerControls({ - {/* Seek bar — teal progress fill */} -
{ - (seekBarRef as React.MutableRefObject).current = el; - (sliderRef as React.MutableRefObject).current = el; - }} - role="slider" - tabIndex={disabled ? -1 : 0} - aria-label="Seek" - aria-disabled={disabled || undefined} - aria-valuemin={0} - aria-valuemax={Math.round(duration)} - aria-valuenow={0} - className={`min-w-[96px] flex-1 h-6 flex items-center group ${ - disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer" - }`} - // `touch-action: none` tells the browser we're handling every - // pointer gesture on this element ourselves. Without it, iOS - // Safari consumes horizontal swipes for its own swipe-back-to- - // previous-page navigation and the scrubber can't drag left. - style={{ touchAction: "none" }} + -
- {/* Work-area band between in/out points */} - {(inPoint !== null || outPoint !== null) && duration > 0 && ( -
- )} - {/* Progress fill — width is controlled imperatively via ref to avoid React re-render resets */} -
- {/* In-point marker */} - {inPoint !== null && duration > 0 && ( -
- )} - {/* Out-point marker */} - {outPoint !== null && duration > 0 && ( -
- )} - {/* Playhead thumb — left is controlled imperatively via ref */} -
-
-
+ /> - {/* Mute toggle */} - - - + - {/* Speed control */} -
- - - - {showSpeedMenu && ( -
- {SPEED_OPTIONS.map((rate) => ( - - ))} -
- )} -
+ - - - + - {/* Fullscreen toggle */} {onToggleFullscreen && ( - - - + )} - {/* Keyboard shortcuts + frame jump + work area — click to open panel */} -
- - - - {showShortcuts && ( -
- {/* Frame jump */} -
-

- Jump to frame -

-
- setJumpFrame(e.target.value)} - disabled={disabled} - inputMode="numeric" - pattern="[0-9]*" - aria-label="Jump to frame" - placeholder="frame number" - className="h-6 flex-1 rounded border border-neutral-700 bg-neutral-900 px-2 text-[10px] font-mono tabular-nums text-neutral-200 outline-none transition-colors placeholder:text-neutral-600 focus:border-studio-accent/60" - onKeyDown={handleJumpKeyDown} - onBlur={commitJumpFrame} - /> - - - -
-
-
- {/* Work area */} -
-

- Work area -

-
-
-
- - I - - In-point -
-
- {inPoint !== null ? ( - <> - - {formatTime(inPoint)} - - - - - - ) : ( - - )} -
-
-
-
- - O - - Out-point -
-
- {outPoint !== null ? ( - <> - - {formatTime(outPoint)} - - - - - - ) : ( - - )} -
-
-
-
-
- {/* Shortcuts */} -
- {SHORTCUT_SECTIONS.map((section) => ( -
-

- {section.title} -

-
- {section.hints.map((hint) => ( -
- - {hint.key} - - {hint.label} -
- ))} -
-
- ))} -
-
- )} -
+
); }); diff --git a/packages/studio/src/player/components/ShortcutsPanel.tsx b/packages/studio/src/player/components/ShortcutsPanel.tsx new file mode 100644 index 000000000..c282c7ef1 --- /dev/null +++ b/packages/studio/src/player/components/ShortcutsPanel.tsx @@ -0,0 +1,277 @@ +import { useState, useCallback, useRef, useEffect, memo } from "react"; +import { formatTime, frameToSeconds } from "../lib/time"; +import { Tooltip } from "../../components/ui"; + +const SHORTCUT_SECTIONS = [ + { + title: "Playback", + hints: [ + { key: "Space", label: "Play / Pause" }, + { key: "J", label: "Play backward" }, + { key: "K", label: "Stop" }, + { key: "L", label: "Play forward" }, + { key: "M", label: "Toggle mute" }, + { key: "⇧L", label: "Toggle loop" }, + { key: "←/→", label: "Step 1 frame" }, + { key: "⇧←/⇧→", label: "Step 10 frames" }, + { key: "F", label: "Toggle fullscreen" }, + ], + }, + { + title: "Work area", + hints: [ + { key: "I", label: "Set in-point" }, + { key: "⇧I", label: "Clear in-point" }, + { key: "O", label: "Set out-point" }, + { key: "⇧O", label: "Clear out-point" }, + { key: "A", label: "Jump to in-point" }, + { key: "E", label: "Jump to out-point" }, + ], + }, +] as const; + +interface ShortcutsPanelProps { + disabled: boolean; + duration: number; + inPoint: number | null; + outPoint: number | null; + setInPoint: (v: number | null) => void; + setOutPoint: (v: number | null) => void; + onSeek: (time: number) => void; +} + +export const ShortcutsPanel = memo(function ShortcutsPanel({ + disabled, + duration, + inPoint, + outPoint, + setInPoint, + setOutPoint, + onSeek, +}: ShortcutsPanelProps) { + const [showShortcuts, setShowShortcuts] = useState(false); + const [jumpFrame, setJumpFrame] = useState(""); + const shortcutsPanelRef = useRef(null); + + useEffect(() => { + if (!showShortcuts) return; + const handleMouseDown = (e: MouseEvent) => { + if (shortcutsPanelRef.current && !shortcutsPanelRef.current.contains(e.target as Node)) { + setShowShortcuts(false); + } + }; + document.addEventListener("mousedown", handleMouseDown); + return () => { + document.removeEventListener("mousedown", handleMouseDown); + }; + }, [showShortcuts]); + + const commitJumpFrame = useCallback(() => { + if (disabled) return; + const frame = Number.parseInt(jumpFrame, 10); + if (!Number.isFinite(frame) || duration <= 0) return; + onSeek(Math.min(duration, frameToSeconds(Math.max(0, frame)))); + }, [disabled, duration, jumpFrame, onSeek]); + + const handleJumpSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + commitJumpFrame(); + }, + [commitJumpFrame], + ); + + const handleJumpKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key !== "Enter") return; + e.preventDefault(); + commitJumpFrame(); + }, + [commitJumpFrame], + ); + + return ( +
+ + + + {showShortcuts && ( +
+
+

+ Jump to frame +

+
+ setJumpFrame(e.target.value)} + disabled={disabled} + inputMode="numeric" + pattern="[0-9]*" + aria-label="Jump to frame" + placeholder="frame number" + className="h-6 flex-1 rounded border border-neutral-700 bg-neutral-900 px-2 text-[10px] font-mono tabular-nums text-neutral-200 outline-none transition-colors placeholder:text-neutral-600 focus:border-studio-accent/60" + onKeyDown={handleJumpKeyDown} + onBlur={commitJumpFrame} + /> + + + +
+
+
+
+

+ Work area +

+
+
+
+ + I + + In-point +
+
+ {inPoint !== null ? ( + <> + + {formatTime(inPoint)} + + + + + + ) : ( + + )} +
+
+
+
+ + O + + Out-point +
+
+ {outPoint !== null ? ( + <> + + {formatTime(outPoint)} + + + + + + ) : ( + + )} +
+
+
+
+
+
+ {SHORTCUT_SECTIONS.map((section) => ( +
+

+ {section.title} +

+
+ {section.hints.map((hint) => ( +
+ + {hint.key} + + {hint.label} +
+ ))} +
+
+ ))} +
+
+ )} +
+ ); +}); diff --git a/packages/studio/src/player/components/SpeedMenu.tsx b/packages/studio/src/player/components/SpeedMenu.tsx new file mode 100644 index 000000000..be4497aa5 --- /dev/null +++ b/packages/studio/src/player/components/SpeedMenu.tsx @@ -0,0 +1,83 @@ +import { useState, useRef, useEffect, memo } from "react"; +import { trackStudioEvent } from "../../utils/studioTelemetry"; +import { Tooltip } from "../../components/ui"; + +const SPEED_OPTIONS = [0.25, 0.5, 1, 1.5, 2] as const; + +interface SpeedMenuProps { + playbackRate: number; + setPlaybackRate: (rate: number) => void; + disabled: boolean; +} + +export const SpeedMenu = memo(function SpeedMenu({ + playbackRate, + setPlaybackRate, + disabled, +}: SpeedMenuProps) { + const [showSpeedMenu, setShowSpeedMenu] = useState(false); + const speedMenuContainerRef = useRef(null); + + useEffect(() => { + if (!showSpeedMenu) return; + const handleMouseDown = (e: MouseEvent) => { + if ( + speedMenuContainerRef.current && + !speedMenuContainerRef.current.contains(e.target as Node) + ) { + setShowSpeedMenu(false); + } + }; + document.addEventListener("mousedown", handleMouseDown); + return () => { + document.removeEventListener("mousedown", handleMouseDown); + }; + }, [showSpeedMenu]); + + return ( +
+ + + + {showSpeedMenu && ( +
+ {SPEED_OPTIONS.map((rate) => ( + + ))} +
+ )} +
+ ); +}); diff --git a/packages/studio/src/player/components/useSeekBarDrag.ts b/packages/studio/src/player/components/useSeekBarDrag.ts new file mode 100644 index 000000000..81c78d1f1 --- /dev/null +++ b/packages/studio/src/player/components/useSeekBarDrag.ts @@ -0,0 +1,168 @@ +import { useCallback } from "react"; +import { useMountEffect } from "../../hooks/useMountEffect"; +import { formatFrameTime, formatTime } from "../lib/time"; +import { usePlayerStore, liveTime } from "../store/playerStore"; + +const SEEK_EDGE_SNAP_PX = 8; + +export function resolveSeekPercent(clientX: number, rectLeft: number, rectWidth: number): number { + if (!Number.isFinite(rectWidth) || rectWidth <= 0) return 0; + const rawPercent = (clientX - rectLeft) / rectWidth; + const clamped = Math.max(0, Math.min(1, rawPercent)); + const snapThreshold = Math.min(0.5, SEEK_EDGE_SNAP_PX / rectWidth); + if (clamped <= snapThreshold) return 0; + if (clamped >= 1 - snapThreshold) return 1; + return clamped; +} + +interface SeekBarRefs { + seekBarRef: React.RefObject; + progressFillRef: React.RefObject; + progressThumbRef: React.RefObject; + sliderRef: React.RefObject; + timeDisplayRef: React.RefObject; + isDraggingRef: React.MutableRefObject; + durationRef: React.MutableRefObject; + currentTimeRef: React.MutableRefObject; + timeDisplayModeRef: React.MutableRefObject<"time" | "frame">; +} + +function updateProgressUI( + fillRef: React.RefObject, + thumbRef: React.RefObject, + pct: number, +): void { + if (fillRef.current) fillRef.current.style.width = `${pct}%`; + if (thumbRef.current) thumbRef.current.style.left = `${pct}%`; +} + +export function useSeekBarDrag( + refs: SeekBarRefs, + onSeek: (time: number) => void, + disabled: boolean, + duration: number, +) { + const seekFromClientX = useCallback( + (clientX: number) => { + if (disabled) return; + const bar = refs.seekBarRef.current; + if (!bar || duration <= 0) return; + const rect = bar.getBoundingClientRect(); + const percent = resolveSeekPercent(clientX, rect.left, rect.width); + updateProgressUI(refs.progressFillRef, refs.progressThumbRef, percent * 100); + onSeek(percent * duration); + }, + [disabled, duration, onSeek, refs], + ); + + const handlePointerDown = useCallback( + (e: React.PointerEvent) => { + if (e.button !== 0) return; + e.preventDefault(); + e.currentTarget.focus(); + refs.isDraggingRef.current = true; + + const target = e.currentTarget; + const pointerId = e.pointerId; + try { + target.setPointerCapture(pointerId); + } catch { + /* fallback to window listeners */ + } + + seekFromClientX(e.clientX); + + let seekRafId = 0; + let pendingClientX = e.clientX; + const onMove = (ev: PointerEvent) => { + if (ev.pointerId !== pointerId || !refs.isDraggingRef.current) return; + pendingClientX = ev.clientX; + const bar = refs.seekBarRef.current; + const dur = refs.durationRef.current; + if (bar && dur > 0) { + const rect = bar.getBoundingClientRect(); + const pct = resolveSeekPercent(ev.clientX, rect.left, rect.width) * 100; + updateProgressUI(refs.progressFillRef, refs.progressThumbRef, pct); + } + if (!seekRafId) { + seekRafId = requestAnimationFrame(() => { + seekRafId = 0; + if (refs.isDraggingRef.current) seekFromClientX(pendingClientX); + }); + } + }; + const cleanup = () => { + refs.isDraggingRef.current = false; + if (seekRafId) { + cancelAnimationFrame(seekRafId); + seekRafId = 0; + } + seekFromClientX(pendingClientX); + try { + target.releasePointerCapture(pointerId); + } catch { + /* already released */ + } + target.removeEventListener("pointermove", onMove); + target.removeEventListener("pointerup", onUp); + target.removeEventListener("pointercancel", onUp); + window.removeEventListener("pointerup", onUp); + window.removeEventListener("pointercancel", onUp); + document.removeEventListener("visibilitychange", onVisibilityChange); + window.removeEventListener("blur", cleanup); + }; + const onUp = (ev: PointerEvent) => { + if (ev.pointerId !== pointerId) return; + cleanup(); + }; + const onVisibilityChange = () => { + if (document.visibilityState === "hidden") cleanup(); + }; + + target.addEventListener("pointermove", onMove); + target.addEventListener("pointerup", onUp); + target.addEventListener("pointercancel", onUp); + window.addEventListener("pointerup", onUp); + window.addEventListener("pointercancel", onUp); + document.addEventListener("visibilitychange", onVisibilityChange); + window.addEventListener("blur", cleanup); + }, + [seekFromClientX, refs], + ); + + useMountEffect(() => { + const updateProgress = (t: number) => { + refs.currentTimeRef.current = t; + const dur = refs.durationRef.current; + const pct = dur > 0 ? Math.min(100, (t / dur) * 100) : 0; + updateProgressUI(refs.progressFillRef, refs.progressThumbRef, pct); + if (refs.timeDisplayRef.current) { + refs.timeDisplayRef.current.textContent = + refs.timeDisplayModeRef.current === "frame" ? formatFrameTime(t, dur) : formatTime(t); + } + if (refs.sliderRef.current) + refs.sliderRef.current.setAttribute("aria-valuenow", String(Math.round(t))); + }; + const unsub = liveTime.subscribe(updateProgress); + updateProgress(usePlayerStore.getState().currentTime); + + const interval = setInterval(() => { + const t = usePlayerStore.getState().currentTime; + const dur = usePlayerStore.getState().duration; + if (dur > 0 && t > 0) { + updateProgressUI( + refs.progressFillRef, + refs.progressThumbRef, + Math.min(100, (t / dur) * 100), + ); + } + }, 500); + + return () => { + unsub(); + clearInterval(interval); + }; + }); + + return { handlePointerDown }; +} diff --git a/packages/studio/src/utils/timelineAssetDrop.test.ts b/packages/studio/src/utils/timelineAssetDrop.test.ts index 9e5909039..046c5eba9 100644 --- a/packages/studio/src/utils/timelineAssetDrop.test.ts +++ b/packages/studio/src/utils/timelineAssetDrop.test.ts @@ -156,6 +156,6 @@ describe("insertTimelineAssetIntoSource", () => { ); expect(html).toContain('data-composition-id="main">'); - expect(html).toContain(''); }); });