mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
refactor(studio): split oversized files and raise line limit to 600
Split PlayerControls.tsx into focused sub-components (SeekBar, WorkAreaOverlay, MuteButton, LoopButton, FullscreenButton, ShortcutsPanel, SpeedMenu) and extracted seek bar drag/progress tracking into useSeekBarDrag hook. Split manualEditsDom.ts patch-builder functions into manualEditsDomPatches.ts with data-driven helpers to reduce duplication and complexity. Extracted per-type reapply helpers from reapplyPositionEditsAfterSeek and factored out identity-matrix check from stripGsapTranslateFromTransform. Raised file-size limit from 500 to 600 lines, removed .filesize-allowlist.
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
|
||||
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" });
|
||||
|
||||
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,
|
||||
);
|
||||
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 },
|
||||
];
|
||||
}
|
||||
/* ── HTML patch builders (re-exported from manualEditsDomPatches) ── */
|
||||
export {
|
||||
buildPathOffsetPatches,
|
||||
buildClearPathOffsetPatches,
|
||||
buildBoxSizePatches,
|
||||
buildClearBoxSizePatches,
|
||||
buildRotationPatches,
|
||||
buildClearRotationPatches,
|
||||
buildMotionPatches,
|
||||
buildClearMotionPatches,
|
||||
} from "./manualEditsDomPatches";
|
||||
|
||||
/* ── 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,
|
||||
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,
|
||||
);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<HTMLDivElement>(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<HTMLInputElement>) => {
|
||||
if (e.key !== "Enter") return;
|
||||
e.preventDefault();
|
||||
commitJumpFrame();
|
||||
},
|
||||
[commitJumpFrame],
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={shortcutsPanelRef} className="relative flex-shrink-0">
|
||||
<Tooltip label="Shortcuts and tools">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowShortcuts((v) => !v)}
|
||||
className={`w-6 h-6 flex items-center justify-center rounded border transition-colors ${
|
||||
showShortcuts
|
||||
? "border-neutral-600 text-neutral-200 bg-neutral-800"
|
||||
: "border-neutral-800 text-neutral-600 hover:text-neutral-300 hover:border-neutral-600"
|
||||
}`}
|
||||
aria-label="Shortcuts and tools"
|
||||
aria-expanded={showShortcuts}
|
||||
>
|
||||
<svg
|
||||
width="11"
|
||||
height="11"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="2" y="4" width="20" height="16" rx="2" />
|
||||
<path d="M6 8h.01M10 8h.01M14 8h.01M18 8h.01M6 12h.01M10 12h.01M14 12h.01M18 12h.01M8 16h8" />
|
||||
</svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
{showShortcuts && (
|
||||
<div
|
||||
className="absolute bottom-full right-0 mb-2 z-50 rounded-lg shadow-xl min-w-[220px] overflow-y-auto"
|
||||
style={{
|
||||
background: "#161618",
|
||||
border: "1px solid rgba(255,255,255,0.08)",
|
||||
maxHeight: "min(280px, calc(100vh - 80px))",
|
||||
}}
|
||||
>
|
||||
<div className="px-3 pt-3 pb-2.5">
|
||||
<p className="text-[9px] font-medium text-neutral-500 uppercase tracking-wider mb-1.5">
|
||||
Jump to frame
|
||||
</p>
|
||||
<form onSubmit={handleJumpSubmit} className="flex items-center gap-1.5">
|
||||
<input
|
||||
value={jumpFrame}
|
||||
onChange={(e) => 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}
|
||||
/>
|
||||
<Tooltip label="Jump to frame">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={disabled}
|
||||
className="h-6 px-2 rounded border border-neutral-700 text-[10px] text-neutral-300 transition-colors hover:border-neutral-500 hover:bg-neutral-800 disabled:opacity-40"
|
||||
>
|
||||
Go
|
||||
</button>
|
||||
</Tooltip>
|
||||
</form>
|
||||
</div>
|
||||
<div style={{ borderTop: "1px solid rgba(255,255,255,0.06)" }} />
|
||||
<div className="px-3 pt-2.5 pb-2">
|
||||
<p className="text-[9px] font-medium text-neutral-500 uppercase tracking-wider mb-1.5">
|
||||
Work area
|
||||
</p>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="font-mono text-[10px] rounded border border-neutral-700 px-1.5 py-0.5 text-neutral-300 min-w-[20px] text-center"
|
||||
style={{ background: "rgba(255,255,255,0.05)" }}
|
||||
>
|
||||
I
|
||||
</span>
|
||||
<span className="text-[10px] text-neutral-400">In-point</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{inPoint !== null ? (
|
||||
<>
|
||||
<span className="font-mono text-[10px] text-neutral-300">
|
||||
{formatTime(inPoint)}
|
||||
</span>
|
||||
<Tooltip label="Clear in-point">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setInPoint(null)}
|
||||
className="w-4 h-4 flex items-center justify-center rounded text-neutral-500 hover:text-neutral-200 transition-colors"
|
||||
aria-label="Clear in-point"
|
||||
>
|
||||
<svg
|
||||
width="8"
|
||||
height="8"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
>
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-[10px] text-neutral-600">—</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="font-mono text-[10px] rounded border border-neutral-700 px-1.5 py-0.5 text-neutral-300 min-w-[20px] text-center"
|
||||
style={{ background: "rgba(255,255,255,0.05)" }}
|
||||
>
|
||||
O
|
||||
</span>
|
||||
<span className="text-[10px] text-neutral-400">Out-point</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{outPoint !== null ? (
|
||||
<>
|
||||
<span className="font-mono text-[10px] text-neutral-300">
|
||||
{formatTime(outPoint)}
|
||||
</span>
|
||||
<Tooltip label="Clear out-point">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOutPoint(null)}
|
||||
className="w-4 h-4 flex items-center justify-center rounded text-neutral-500 hover:text-neutral-200 transition-colors"
|
||||
aria-label="Clear out-point"
|
||||
>
|
||||
<svg
|
||||
width="8"
|
||||
height="8"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
>
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-[10px] text-neutral-600">—</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ borderTop: "1px solid rgba(255,255,255,0.06)" }} />
|
||||
<div className="px-3 pt-2.5 pb-3 flex flex-col gap-3">
|
||||
{SHORTCUT_SECTIONS.map((section) => (
|
||||
<div key={section.title}>
|
||||
<p className="text-[9px] font-medium text-neutral-500 uppercase tracking-wider mb-1.5">
|
||||
{section.title}
|
||||
</p>
|
||||
<div className="flex flex-col gap-1">
|
||||
{section.hints.map((hint) => (
|
||||
<div key={hint.key} className="flex items-center gap-3">
|
||||
<span
|
||||
className="font-mono text-[10px] rounded border border-neutral-700 px-1.5 py-0.5 text-neutral-300 min-w-[36px] text-center"
|
||||
style={{ background: "rgba(255,255,255,0.05)" }}
|
||||
>
|
||||
{hint.key}
|
||||
</span>
|
||||
<span className="text-[10px] text-neutral-400">{hint.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -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<HTMLDivElement>(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 (
|
||||
<div ref={speedMenuContainerRef} className="relative flex-shrink-0">
|
||||
<Tooltip label="Playback speed">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSpeedMenu((v) => !v)}
|
||||
disabled={disabled}
|
||||
className="w-10 px-2 py-1 rounded-md text-[10px] font-mono tabular-nums transition-colors"
|
||||
style={{ color: "#71717A", background: "rgba(255,255,255,0.04)" }}
|
||||
>
|
||||
{playbackRate === 1 ? "1x" : `${playbackRate}x`}
|
||||
</button>
|
||||
</Tooltip>
|
||||
{showSpeedMenu && (
|
||||
<div
|
||||
className="absolute bottom-full right-0 mb-1.5 rounded-lg shadow-xl z-50 min-w-[56px] overflow-hidden"
|
||||
style={{ background: "#161618", border: "1px solid rgba(255,255,255,0.08)" }}
|
||||
>
|
||||
{SPEED_OPTIONS.map((rate) => (
|
||||
<button
|
||||
key={rate}
|
||||
onClick={() => {
|
||||
trackStudioEvent("playback", { action: "speed_change", rate });
|
||||
setPlaybackRate(rate);
|
||||
setShowSpeedMenu(false);
|
||||
}}
|
||||
className="block w-full px-3 py-1.5 text-[11px] text-left font-mono tabular-nums transition-colors"
|
||||
style={{
|
||||
color: rate === playbackRate ? "#FAFAFA" : "#71717A",
|
||||
background: rate === playbackRate ? "rgba(255,255,255,0.06)" : "transparent",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (rate !== playbackRate)
|
||||
e.currentTarget.style.background = "rgba(255,255,255,0.04)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (rate !== playbackRate) e.currentTarget.style.background = "transparent";
|
||||
}}
|
||||
>
|
||||
{rate}x
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -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<HTMLDivElement | null>;
|
||||
progressFillRef: React.RefObject<HTMLDivElement | null>;
|
||||
progressThumbRef: React.RefObject<HTMLDivElement | null>;
|
||||
sliderRef: React.RefObject<HTMLDivElement | null>;
|
||||
timeDisplayRef: React.RefObject<HTMLSpanElement | null>;
|
||||
isDraggingRef: React.MutableRefObject<boolean>;
|
||||
durationRef: React.MutableRefObject<number>;
|
||||
currentTimeRef: React.MutableRefObject<number>;
|
||||
timeDisplayModeRef: React.MutableRefObject<"time" | "frame">;
|
||||
}
|
||||
|
||||
function updateProgressUI(
|
||||
fillRef: React.RefObject<HTMLDivElement | null>,
|
||||
thumbRef: React.RefObject<HTMLDivElement | null>,
|
||||
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<HTMLDivElement>) => {
|
||||
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 };
|
||||
}
|
||||
@@ -156,6 +156,6 @@ describe("insertTimelineAssetIntoSource", () => {
|
||||
);
|
||||
|
||||
expect(html).toContain('data-composition-id="main">');
|
||||
expect(html).toContain('<img id="photo_asset"');
|
||||
expect(html).toContain('<img id="photo_asset" data-start="0" data-duration="3" />');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user