summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor/panels/properties/sections
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-03-25 16:47:22 +0100
committerMaze Winther <mazewinther@gmail.com>2026-03-29 15:48:22 +0200
commit8db3bead13441a5882d8689dfc6a9f09c82375d9 (patch)
treee9d0cafa93904ffcbec3188c37961bcc16c6422d /apps/web/src/components/editor/panels/properties/sections
parent39ea298a9ccba3104c549c0ca079c231981811f5 (diff)
downloadopencut-8db3bead13441a5882d8689dfc6a9f09c82375d9.tar.gz
feat: masks, properties refactor, shaders, storage migrations, and more
Diffstat (limited to 'apps/web/src/components/editor/panels/properties/sections')
-rw-r--r--apps/web/src/components/editor/panels/properties/sections/blending.tsx222
-rw-r--r--apps/web/src/components/editor/panels/properties/sections/index.tsx2
-rw-r--r--apps/web/src/components/editor/panels/properties/sections/transform.tsx382
3 files changed, 0 insertions, 606 deletions
diff --git a/apps/web/src/components/editor/panels/properties/sections/blending.tsx b/apps/web/src/components/editor/panels/properties/sections/blending.tsx
deleted file mode 100644
index a50e84e7..00000000
--- a/apps/web/src/components/editor/panels/properties/sections/blending.tsx
+++ /dev/null
@@ -1,222 +0,0 @@
-import { useEditor } from "@/hooks/use-editor";
-import { clamp } from "@/utils/math";
-import { NumberField } from "@/components/ui/number-field";
-import {
- DEFAULT_BLEND_MODE,
- DEFAULT_OPACITY,
-} from "@/constants/timeline-constants";
-import { OcCheckerboardIcon } from "@opencut/ui/icons";
-import { Fragment, useRef } from "react";
-import { Section, SectionContent, SectionField, SectionHeader, SectionTitle } from "../section";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectSeparator,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
-import type { BlendMode } from "@/types/rendering";
-import type { ElementType } from "@/types/timeline";
-import type { ElementAnimations } from "@/types/animation";
-import { HugeiconsIcon } from "@hugeicons/react";
-import { RainDropIcon } from "@hugeicons/core-free-icons";
-import { KeyframeToggle } from "../keyframe-toggle";
-import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
-import { useElementPlayhead } from "../hooks/use-element-playhead";
-import { resolveOpacityAtTime } from "@/lib/animation";
-import { isPropertyAtDefault } from "./transform";
-
-type BlendingElement = {
- id: string;
- opacity: number;
- type: ElementType;
- blendMode?: BlendMode;
- startTime: number;
- duration: number;
- animations?: ElementAnimations;
-};
-
-const BLEND_MODE_GROUPS = [
- [{ value: "normal", label: "Normal" }],
- [
- { value: "darken", label: "Darken" },
- { value: "multiply", label: "Multiply" },
- { value: "color-burn", label: "Color Burn" },
- ],
- [
- { value: "lighten", label: "Lighten" },
- { value: "screen", label: "Screen" },
- { value: "plus-lighter", label: "Plus Lighter" },
- { value: "color-dodge", label: "Color Dodge" },
- ],
- [
- { value: "overlay", label: "Overlay" },
- { value: "soft-light", label: "Soft Light" },
- { value: "hard-light", label: "Hard Light" },
- ],
- [
- { value: "difference", label: "Difference" },
- { value: "exclusion", label: "Exclusion" },
- ],
- [
- { value: "hue", label: "Hue" },
- { value: "saturation", label: "Saturation" },
- { value: "color", label: "Color" },
- { value: "luminosity", label: "Luminosity" },
- ],
-];
-
-export function BlendingSection({
- element,
- trackId,
-}: {
- element: BlendingElement;
- trackId: string;
-}) {
- const editor = useEditor();
- const blendMode = element.blendMode ?? DEFAULT_BLEND_MODE;
- const didSelectRef = useRef(false);
- const committedBlendModeRef = useRef(blendMode);
- if (!editor.timeline.isPreviewActive()) {
- committedBlendModeRef.current = blendMode;
- }
-
- const previewBlendMode = ({ value }: { value: BlendMode }) =>
- editor.timeline.previewElements({
- updates: [
- { trackId, elementId: element.id, updates: { blendMode: value } },
- ],
- });
-
- const commitBlendMode = (value: string) => {
- if (editor.timeline.isPreviewActive()) {
- editor.timeline.commitPreview();
- } else {
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { blendMode: value as BlendMode },
- },
- ],
- });
- }
- didSelectRef.current = true;
- };
-
- const handleBlendModeOpenChange = (isOpen: boolean) => {
- if (!isOpen) {
- if (!didSelectRef.current) editor.timeline.discardPreview();
- didSelectRef.current = false;
- }
- };
-
- const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
- startTime: element.startTime,
- duration: element.duration,
- });
- const resolvedOpacity = resolveOpacityAtTime({
- baseOpacity: element.opacity,
- animations: element.animations,
- localTime,
- });
-
- const opacity = useKeyframedNumberProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "opacity",
- localTime,
- isPlayheadWithinElementRange,
- displayValue: Math.round(resolvedOpacity * 100).toString(),
- parse: (input) => {
- const parsed = parseFloat(input);
- if (Number.isNaN(parsed)) return null;
- return clamp({ value: parsed, min: 0, max: 100 }) / 100;
- },
- valueAtPlayhead: resolvedOpacity,
- buildBaseUpdates: ({ value }) => ({ opacity: value }),
- });
-
- return (
- <Section collapsible sectionKey={`${element.type}:blending`}>
- <SectionHeader><SectionTitle>Blending</SectionTitle></SectionHeader>
- <SectionContent>
- <div className="flex items-start gap-2">
- <SectionField
- label="Opacity"
- className="w-1/2"
- beforeLabel={
- <KeyframeToggle
- isActive={opacity.isKeyframedAtTime}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle opacity keyframe"
- onToggle={opacity.toggleKeyframe}
- />
- }
- >
- <NumberField
- className="w-full"
- icon={
- <OcCheckerboardIcon className="size-3.5 text-muted-foreground" />
- }
- value={opacity.displayValue}
- min={0}
- max={100}
- onFocus={opacity.onFocus}
- onChange={opacity.onChange}
- onBlur={opacity.onBlur}
- onScrub={opacity.scrubTo}
- onScrubEnd={opacity.commitScrub}
- onReset={() => opacity.commitValue({ value: DEFAULT_OPACITY })}
- isDefault={isPropertyAtDefault({
- hasAnimatedKeyframes: opacity.hasAnimatedKeyframes,
- isPlayheadWithinElementRange,
- resolvedValue: resolvedOpacity,
- staticValue: element.opacity,
- defaultValue: DEFAULT_OPACITY,
- })}
- dragSensitivity="slow"
- />
- </SectionField>
- <SectionField label="Blend mode" className="w-1/2">
- <Select
- value={committedBlendModeRef.current}
- onOpenChange={handleBlendModeOpenChange}
- onValueChange={commitBlendMode}
- >
- <SelectTrigger
- icon={<HugeiconsIcon icon={RainDropIcon} />}
- className="w-full"
- >
- <SelectValue placeholder="Select blend mode" />
- </SelectTrigger>
- <SelectContent className="w-36">
- {BLEND_MODE_GROUPS.map((group, groupIndex) => (
- <Fragment key={group[0]?.value ?? `group-${groupIndex}`}>
- {group.map((option) => (
- <SelectItem
- key={option.value}
- value={option.value}
- onPointerEnter={() =>
- previewBlendMode({ value: option.value as BlendMode })
- }
- >
- {option.label}
- </SelectItem>
- ))}
- {groupIndex < BLEND_MODE_GROUPS.length - 1 ? (
- <SelectSeparator />
- ) : null}
- </Fragment>
- ))}
- </SelectContent>
- </Select>
- </SectionField>
- </div>
- </SectionContent>
- </Section>
- );
-}
diff --git a/apps/web/src/components/editor/panels/properties/sections/index.tsx b/apps/web/src/components/editor/panels/properties/sections/index.tsx
deleted file mode 100644
index 8be58f39..00000000
--- a/apps/web/src/components/editor/panels/properties/sections/index.tsx
+++ /dev/null
@@ -1,2 +0,0 @@
-export * from "./transform";
-export * from "./blending";
diff --git a/apps/web/src/components/editor/panels/properties/sections/transform.tsx b/apps/web/src/components/editor/panels/properties/sections/transform.tsx
deleted file mode 100644
index 6aaaa1fe..00000000
--- a/apps/web/src/components/editor/panels/properties/sections/transform.tsx
+++ /dev/null
@@ -1,382 +0,0 @@
-import { NumberField } from "@/components/ui/number-field";
-import { useEditor } from "@/hooks/use-editor";
-import { clamp, isNearlyEqual } from "@/utils/math";
-import type { AnimationPropertyPath } from "@/types/animation";
-import type { VisualElement } from "@/types/timeline";
-import {
- Section,
- SectionContent,
- SectionField,
- SectionFields,
- SectionHeader,
- SectionTitle,
-} from "../section";
-import { Button } from "@/components/ui/button";
-import { HugeiconsIcon } from "@hugeicons/react";
-import {
- ArrowExpandIcon,
- Link05Icon,
- RotateClockwiseIcon,
-} from "@hugeicons/core-free-icons";
-import { useState } from "react";
-import { DEFAULT_TRANSFORM } from "@/constants/timeline-constants";
-import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
-import { getElementLocalTime, resolveTransformAtTime } from "@/lib/animation";
-import { KeyframeToggle } from "../keyframe-toggle";
-import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
-
-export function parseNumericInput({ input }: { input: string }): number | null {
- const parsed = parseFloat(input);
- return Number.isNaN(parsed) ? null : parsed;
-}
-
-export function isPropertyAtDefault({
- hasAnimatedKeyframes,
- isPlayheadWithinElementRange,
- resolvedValue,
- staticValue,
- defaultValue,
-}: {
- hasAnimatedKeyframes: boolean;
- isPlayheadWithinElementRange: boolean;
- resolvedValue: number;
- staticValue: number;
- defaultValue: number;
-}): boolean {
- if (hasAnimatedKeyframes && isPlayheadWithinElementRange) {
- return isNearlyEqual({
- leftValue: resolvedValue,
- rightValue: defaultValue,
- });
- }
-
- return staticValue === defaultValue;
-}
-
-export function TransformSection({
- element,
- trackId,
- showTopBorder = true,
-}: {
- element: VisualElement;
- trackId: string;
- showTopBorder?: boolean;
-}) {
- const editor = useEditor();
- const [isScaleLocked, setIsScaleLocked] = useState(false);
- const playheadTime = editor.playback.getCurrentTime();
- const localTime = getElementLocalTime({
- timelineTime: playheadTime,
- elementStartTime: element.startTime,
- elementDuration: element.duration,
- });
- const resolvedTransform = resolveTransformAtTime({
- baseTransform: element.transform,
- animations: element.animations,
- localTime,
- });
- const isPlayheadWithinElementRange =
- playheadTime >= element.startTime - TIME_EPSILON_SECONDS &&
- playheadTime <= element.startTime + element.duration + TIME_EPSILON_SECONDS;
-
- const positionX = useKeyframedNumberProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "transform.position.x",
- localTime,
- isPlayheadWithinElementRange,
- displayValue: Math.round(resolvedTransform.position.x).toString(),
- parse: (input) => parseNumericInput({ input }),
- valueAtPlayhead: resolvedTransform.position.x,
- buildBaseUpdates: ({ value }) => ({
- transform: {
- ...element.transform,
- position: {
- ...element.transform.position,
- x: value,
- },
- },
- }),
- });
-
- const positionY = useKeyframedNumberProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "transform.position.y",
- localTime,
- isPlayheadWithinElementRange,
- displayValue: Math.round(resolvedTransform.position.y).toString(),
- parse: (input) => parseNumericInput({ input }),
- valueAtPlayhead: resolvedTransform.position.y,
- buildBaseUpdates: ({ value }) => ({
- transform: {
- ...element.transform,
- position: {
- ...element.transform.position,
- y: value,
- },
- },
- }),
- });
-
- const scale = useKeyframedNumberProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "transform.scale",
- localTime,
- isPlayheadWithinElementRange,
- displayValue: Math.round(resolvedTransform.scale * 100).toString(),
- parse: (input) => {
- const parsed = parseNumericInput({ input });
- if (parsed === null) return null;
- return Math.max(parsed, 1) / 100;
- },
- valueAtPlayhead: resolvedTransform.scale,
- buildBaseUpdates: ({ value }) => ({
- transform: {
- ...element.transform,
- scale: value,
- },
- }),
- });
- const scaleFieldProps = {
- className: "flex-1",
- value: scale.displayValue,
- onFocus: scale.onFocus,
- onChange: scale.onChange,
- onBlur: scale.onBlur,
- dragSensitivity: "slow" as const,
- onScrub: scale.scrubTo,
- onScrubEnd: scale.commitScrub,
- onReset: () => scale.commitValue({ value: DEFAULT_TRANSFORM.scale }),
- isDefault: isPropertyAtDefault({
- hasAnimatedKeyframes: scale.hasAnimatedKeyframes,
- isPlayheadWithinElementRange,
- resolvedValue: resolvedTransform.scale,
- staticValue: element.transform.scale,
- defaultValue: DEFAULT_TRANSFORM.scale,
- }),
- };
-
- const rotation = useKeyframedNumberProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "transform.rotate",
- localTime,
- isPlayheadWithinElementRange,
- displayValue: Math.round(resolvedTransform.rotate).toString(),
- parse: (input) => {
- const parsed = parseNumericInput({ input });
- if (parsed === null) return null;
- return clamp({ value: parsed, min: -360, max: 360 });
- },
- valueAtPlayhead: resolvedTransform.rotate,
- buildBaseUpdates: ({ value }) => ({
- transform: {
- ...element.transform,
- rotate: value,
- },
- }),
- });
-
- const hasPositionKeyframe =
- positionX.isKeyframedAtTime || positionY.isKeyframedAtTime;
-
- const togglePositionKeyframe = () => {
- if (!isPlayheadWithinElementRange) {
- return;
- }
-
- if (positionX.keyframeIdAtTime || positionY.keyframeIdAtTime) {
- const keyframesToRemove: Array<{
- trackId: string;
- elementId: string;
- propertyPath: AnimationPropertyPath;
- keyframeId: string;
- }> = [];
- if (positionX.keyframeIdAtTime) {
- keyframesToRemove.push({
- trackId,
- elementId: element.id,
- propertyPath: "transform.position.x" as const,
- keyframeId: positionX.keyframeIdAtTime,
- });
- }
- if (positionY.keyframeIdAtTime) {
- keyframesToRemove.push({
- trackId,
- elementId: element.id,
- propertyPath: "transform.position.y" as const,
- keyframeId: positionY.keyframeIdAtTime,
- });
- }
-
- editor.timeline.removeKeyframes({
- keyframes: keyframesToRemove,
- });
- return;
- }
-
- editor.timeline.upsertKeyframes({
- keyframes: [
- {
- trackId,
- elementId: element.id,
- propertyPath: "transform.position.x",
- time: localTime,
- value: resolvedTransform.position.x,
- },
- {
- trackId,
- elementId: element.id,
- propertyPath: "transform.position.y",
- time: localTime,
- value: resolvedTransform.position.y,
- },
- ],
- });
- };
-
- return (
- <Section
- collapsible
- sectionKey={`${element.type}:transform`}
- showTopBorder={showTopBorder}
- >
- <SectionHeader><SectionTitle>Transform</SectionTitle></SectionHeader>
- <SectionContent>
- <SectionFields>
- <SectionField
- label="Scale"
- beforeLabel={
- <KeyframeToggle
- isActive={scale.isKeyframedAtTime}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle scale keyframe"
- onToggle={scale.toggleKeyframe}
- />
- }
- >
- <div className="flex items-center gap-2">
- {isScaleLocked ? (
- <>
- <NumberField icon="W" {...scaleFieldProps} />
- <NumberField icon="H" {...scaleFieldProps} />
- </>
- ) : (
- <NumberField
- icon={<HugeiconsIcon icon={ArrowExpandIcon} />}
- {...scaleFieldProps}
- className="flex-1"
- />
- )}
- <Button
- type="button"
- variant={isScaleLocked ? "secondary" : "ghost"}
- size="icon"
- aria-pressed={isScaleLocked}
- onClick={() => setIsScaleLocked((isLocked) => !isLocked)}
- >
- <HugeiconsIcon icon={Link05Icon} />
- </Button>
- </div>
- </SectionField>
- <SectionField
- label="Position"
- beforeLabel={
- <KeyframeToggle
- isActive={hasPositionKeyframe}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle position keyframe"
- onToggle={togglePositionKeyframe}
- />
- }
- >
- <div className="flex items-center gap-2">
- <NumberField
- icon="X"
- className="flex-1"
- value={positionX.displayValue}
- onFocus={positionX.onFocus}
- onChange={positionX.onChange}
- onBlur={positionX.onBlur}
- onScrub={positionX.scrubTo}
- onScrubEnd={positionX.commitScrub}
- onReset={() =>
- positionX.commitValue({ value: DEFAULT_TRANSFORM.position.x })
- }
- isDefault={isPropertyAtDefault({
- hasAnimatedKeyframes: positionX.hasAnimatedKeyframes,
- isPlayheadWithinElementRange,
- resolvedValue: resolvedTransform.position.x,
- staticValue: element.transform.position.x,
- defaultValue: DEFAULT_TRANSFORM.position.x,
- })}
- />
- <NumberField
- icon="Y"
- className="flex-1"
- value={positionY.displayValue}
- onFocus={positionY.onFocus}
- onChange={positionY.onChange}
- onBlur={positionY.onBlur}
- onScrub={positionY.scrubTo}
- onScrubEnd={positionY.commitScrub}
- onReset={() =>
- positionY.commitValue({ value: DEFAULT_TRANSFORM.position.y })
- }
- isDefault={isPropertyAtDefault({
- hasAnimatedKeyframes: positionY.hasAnimatedKeyframes,
- isPlayheadWithinElementRange,
- resolvedValue: resolvedTransform.position.y,
- staticValue: element.transform.position.y,
- defaultValue: DEFAULT_TRANSFORM.position.y,
- })}
- />
- </div>
- </SectionField>
-
- <SectionField
- label="Rotation"
- beforeLabel={
- <KeyframeToggle
- isActive={rotation.isKeyframedAtTime}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle rotation keyframe"
- onToggle={rotation.toggleKeyframe}
- />
- }
- >
- <div className="flex items-center gap-2">
- <NumberField
- icon={<HugeiconsIcon icon={RotateClockwiseIcon} />}
- className="flex-none"
- value={rotation.displayValue}
- onFocus={rotation.onFocus}
- onChange={rotation.onChange}
- onBlur={rotation.onBlur}
- dragSensitivity="slow"
- onScrub={rotation.scrubTo}
- onScrubEnd={rotation.commitScrub}
- onReset={() =>
- rotation.commitValue({ value: DEFAULT_TRANSFORM.rotate })
- }
- isDefault={isPropertyAtDefault({
- hasAnimatedKeyframes: rotation.hasAnimatedKeyframes,
- isPlayheadWithinElementRange,
- resolvedValue: resolvedTransform.rotate,
- staticValue: element.transform.rotate,
- defaultValue: DEFAULT_TRANSFORM.rotate,
- })}
- />
- </div>
- </SectionField>
- </SectionFields>
- </SectionContent>
- </Section>
- );
-}