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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3,082 changes: 476 additions & 2,606 deletions bun.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@
"packageManager": "bun@1.3.3",
"overrides": {
"caniuse-lite": "1.0.30001766",
"@rspack/core": "1.7.6",
"@rspack/core": "1.7.11",
"@remix-run/dev": "2.17.4",
"@remix-run/node": "2.17.4",
"@remix-run/react": "2.17.4",
Expand Down
2 changes: 1 addition & 1 deletion packages/bundler/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
"author": "Jonny Burger <jonny@remotion.dev>",
"license": "SEE LICENSE IN LICENSE.md",
"dependencies": {
"@rspack/core": "1.7.6",
"@rspack/core": "1.7.11",
"@rspack/plugin-react-refresh": "1.6.1",
"css-loader": "7.1.4",
"esbuild": "0.28.0",
Expand Down
212 changes: 212 additions & 0 deletions packages/core/src/Interactive.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import React, {forwardRef, useCallback, useRef} from 'react';
import type {SequenceControls} from './CompositionManager.js';
import {addSequenceStackTraces} from './enable-sequence-stack-traces.js';
import {
durationInFramesField,
fromField,
hiddenField,
sequenceVisualStyleSchema,
type SequenceSchema,
} from './sequence-field-schema.js';
import type {SequenceProps} from './Sequence.js';
import {Sequence} from './Sequence.js';
import {wrapInSchema} from './wrap-in-schema.js';

type InteractiveHtmlTag =
| 'a'
| 'article'
| 'aside'
| 'button'
| 'code'
| 'div'
| 'em'
| 'footer'
| 'h1'
| 'h2'
| 'h3'
| 'h4'
| 'h5'
| 'h6'
| 'header'
| 'label'
| 'li'
| 'main'
| 'nav'
| 'ol'
| 'p'
| 'pre'
| 'section'
| 'small'
| 'span'
| 'strong'
| 'ul';

type InteractiveSvgTag =
| 'circle'
| 'ellipse'
| 'g'
| 'line'
| 'path'
| 'rect'
| 'svg'
| 'text';

type InteractiveTag = InteractiveHtmlTag | InteractiveSvgTag;

type ElementForTag<Tag extends InteractiveTag> =
Tag extends keyof HTMLElementTagNameMap
? HTMLElementTagNameMap[Tag]
: Tag extends keyof SVGElementTagNameMap
? SVGElementTagNameMap[Tag]
: Element;

type InteractiveSequenceProps = Pick<
SequenceProps,
'durationInFrames' | 'from' | 'hidden' | 'name' | 'showInTimeline'
> & {
/**
* @deprecated For internal use only
*/
readonly stack?: string;
};

type InteractiveElementProps<Tag extends InteractiveTag> = Omit<
React.ComponentPropsWithoutRef<Tag>,
keyof InteractiveSequenceProps
> &
InteractiveSequenceProps;

type InteractiveElementComponent<Tag extends InteractiveTag> =
React.ComponentType<
InteractiveElementProps<Tag> & React.RefAttributes<ElementForTag<Tag>>
>;

const interactiveElementSchema = {
durationInFrames: durationInFramesField,
from: fromField,
...sequenceVisualStyleSchema,
hidden: hiddenField,
} as const satisfies SequenceSchema;

const setRef = <ElementType,>(
ref: React.ForwardedRef<ElementType>,
value: ElementType | null,
) => {
if (typeof ref === 'function') {
ref(value);
} else if (ref) {
ref.current = value;
}
};

const makeInteractiveElement = <Tag extends InteractiveTag>(
tag: Tag,
displayName: string,
): InteractiveElementComponent<Tag> => {
type ElementType = ElementForTag<Tag>;
type Props = InteractiveElementProps<Tag>;

const Inner = forwardRef<
ElementType,
Props & {
readonly _experimentalControls: SequenceControls | undefined;
}
>((propsWithControls, ref) => {
const {
durationInFrames,
from,
hidden,
name,
showInTimeline,
stack,
_experimentalControls,
...props
} = propsWithControls as Props & {
readonly _experimentalControls: SequenceControls | undefined;
};
const refForOutline = useRef<ElementType | null>(null);
const callbackRef = useCallback(
(element: ElementType | null) => {
refForOutline.current = element;
setRef(ref, element);
},
[ref],
);

return (
<Sequence
layout="none"
from={from ?? 0}
durationInFrames={durationInFrames ?? Infinity}
hidden={hidden}
name={name ?? displayName}
showInTimeline={showInTimeline ?? true}
_experimentalControls={_experimentalControls}
_remotionInternalStack={stack}
_remotionInternalRefForOutline={refForOutline}
>
{React.createElement(tag, {
...props,
ref: callbackRef,
})}
</Sequence>
);
});

Inner.displayName = displayName;

const Wrapped = wrapInSchema({
Component: Inner,
schema: interactiveElementSchema,
supportsEffects: false,
}) as InteractiveElementComponent<Tag>;

Wrapped.displayName = displayName;
addSequenceStackTraces(Wrapped);

return Wrapped;
};

/**
* @description HTML and SVG elements that are registered in the Remotion Studio timeline and can be visually edited.
*/
export const Interactive = {
A: makeInteractiveElement('a', '<Interactive.A>'),
Article: makeInteractiveElement('article', '<Interactive.Article>'),
Aside: makeInteractiveElement('aside', '<Interactive.Aside>'),
Button: makeInteractiveElement('button', '<Interactive.Button>'),
Circle: makeInteractiveElement('circle', '<Interactive.Circle>'),
Code: makeInteractiveElement('code', '<Interactive.Code>'),
Div: makeInteractiveElement('div', '<Interactive.Div>'),
Ellipse: makeInteractiveElement('ellipse', '<Interactive.Ellipse>'),
Em: makeInteractiveElement('em', '<Interactive.Em>'),
Footer: makeInteractiveElement('footer', '<Interactive.Footer>'),
G: makeInteractiveElement('g', '<Interactive.G>'),
H1: makeInteractiveElement('h1', '<Interactive.H1>'),
H2: makeInteractiveElement('h2', '<Interactive.H2>'),
H3: makeInteractiveElement('h3', '<Interactive.H3>'),
H4: makeInteractiveElement('h4', '<Interactive.H4>'),
H5: makeInteractiveElement('h5', '<Interactive.H5>'),
H6: makeInteractiveElement('h6', '<Interactive.H6>'),
Header: makeInteractiveElement('header', '<Interactive.Header>'),
Label: makeInteractiveElement('label', '<Interactive.Label>'),
Li: makeInteractiveElement('li', '<Interactive.Li>'),
Line: makeInteractiveElement('line', '<Interactive.Line>'),
Main: makeInteractiveElement('main', '<Interactive.Main>'),
Nav: makeInteractiveElement('nav', '<Interactive.Nav>'),
Ol: makeInteractiveElement('ol', '<Interactive.Ol>'),
P: makeInteractiveElement('p', '<Interactive.P>'),
Path: makeInteractiveElement('path', '<Interactive.Path>'),
Pre: makeInteractiveElement('pre', '<Interactive.Pre>'),
Rect: makeInteractiveElement('rect', '<Interactive.Rect>'),
Section: makeInteractiveElement('section', '<Interactive.Section>'),
Small: makeInteractiveElement('small', '<Interactive.Small>'),
Span: makeInteractiveElement('span', '<Interactive.Span>'),
Strong: makeInteractiveElement('strong', '<Interactive.Strong>'),
Svg: makeInteractiveElement('svg', '<Interactive.Svg>'),
Text: makeInteractiveElement('text', '<Interactive.Text>'),
Ul: makeInteractiveElement('ul', '<Interactive.Ul>'),
};

export type InteractiveProps<Tag extends InteractiveTag> =
InteractiveElementProps<Tag>;
2 changes: 1 addition & 1 deletion packages/core/src/animated-image/AnimatedImage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ const animatedImageSchema = {
max: 10,
step: 0.1,
default: 1,
description: 'Playback Rate',
description: 'Playback rate',
hiddenFromList: false,
keyframable: false,
},
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ export {getRemotionEnvironment} from './get-remotion-environment.js';
export {getStaticFiles, StaticFile} from './get-static-files.js';
export * from './IFrame.js';
export {Img, ImgProps} from './Img.js';
export {Interactive, type InteractiveProps} from './Interactive.js';
export * from './internals.js';
export {
interpolateColors,
Expand Down
15 changes: 15 additions & 0 deletions packages/core/src/sequence-field-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,14 @@ export type TranslateFieldSchema = {
keyframable?: boolean;
};

export type TransformOriginFieldSchema = {
type: 'transform-origin';
step?: number;
default: string | undefined;
description?: string;
keyframable?: boolean;
};

export type ScaleFieldSchema = {
type: 'scale';
min?: number;
Expand Down Expand Up @@ -150,6 +158,7 @@ export type VisibleFieldSchema =
| RotationCssFieldSchema
| RotationDegreesFieldSchema
| TranslateFieldSchema
| TransformOriginFieldSchema
| ScaleFieldSchema
| UvCoordinateFieldSchema
| ColorFieldSchema
Expand All @@ -166,6 +175,12 @@ export type SchemaKeysRecord<S extends SequenceSchema> = Record<
>;

export const sequenceVisualStyleSchema = {
'style.transformOrigin': {
type: 'transform-origin',
step: 1,
default: '50% 50%',
description: 'Transform origin',
},
'style.translate': {
type: 'translate',
step: 1,
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/test/find-props-to-delete.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ test('find right values to delete when upgrading a discriminated union', () => {
value: 'none',
}),
).toEqual([
'style.transformOrigin',
'style.translate',
'style.scale',
'style.rotate',
Expand Down
51 changes: 51 additions & 0 deletions packages/core/src/test/sequence-register-once.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import React, {useCallback, useMemo, useState} from 'react';
import {AnimatedImage} from '../animated-image/index.js';
import type {TSequence} from '../CompositionManager.js';
import {Img} from '../Img.js';
import {Interactive} from '../Interactive.js';
import {Internals} from '../internals.js';
import {Sequence} from '../Sequence.js';
import type {SequenceManagerContext} from '../SequenceManager.js';
Expand Down Expand Up @@ -238,6 +239,56 @@ test('Img registers a refForOutline pointing to the rendered image element', ()
expect(registeredSequences[0]?.refForOutline?.current?.tagName).toBe('IMG');
});

test('Interactive elements register their rendered element for Studio outlines', () => {
const registeredSequences: TSequence[] = [];
const divRef = React.createRef<HTMLDivElement>();

render(
<SequenceTestWrapper
onRegisterSequence={(sequence) => {
registeredSequences.push(sequence);
}}
>
<Interactive.Div ref={divRef}>Hello</Interactive.Div>
<Interactive.Span>World</Interactive.Span>
<Interactive.Svg viewBox="0 0 100 100">
<Interactive.Rect width={100} height={100} />
</Interactive.Svg>
</SequenceTestWrapper>,
);

expect(
registeredSequences.map((sequence) => sequence.displayName).sort(),
).toEqual([
'<Interactive.Div>',
'<Interactive.Rect>',
'<Interactive.Span>',
'<Interactive.Svg>',
]);

const getByName = (displayName: string) =>
registeredSequences.find(
(sequence) => sequence.displayName === displayName,
);

expect(getByName('<Interactive.Div>')?.refForOutline?.current?.tagName).toBe(
'DIV',
);
expect(getByName('<Interactive.Span>')?.refForOutline?.current?.tagName).toBe(
'SPAN',
);
expect(getByName('<Interactive.Svg>')?.refForOutline?.current?.tagName).toBe(
'svg',
);
expect(getByName('<Interactive.Rect>')?.refForOutline?.current?.tagName).toBe(
'rect',
);
expect(getByName('<Interactive.Div>')?.refForOutline?.current).toBe(
divRef.current,
);
expect(getByName('<Interactive.Div>')?.controls).not.toBe(null);
});

test('Imperative sequence refs update without rerendering ref-only consumers', async () => {
const nodePath = {
absolutePath: 'test.tsx',
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/test/wrap-in-schema-helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ test('getFlatSchema(sequenceSchema) exposes every variant key', () => {
'style.translate',
'style.scale',
'style.rotate',
'style.transformOrigin',
'style.opacity',
'premountFor',
'postmountFor',
Expand Down Expand Up @@ -91,6 +92,7 @@ test('selectActiveKeys exposes style.* keys when layout=absolute-fill', () => {
'style.translate',
'style.scale',
'style.rotate',
'style.transformOrigin',
'style.opacity',
'premountFor',
'postmountFor',
Expand Down
Loading
Loading