diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-04-20 11:31:17 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-04-20 11:31:17 +0200 |
| commit | 3e89d2998588f8e35c524e523ba2ee853f0f6475 (patch) | |
| tree | 57221f96c1259b3b07e3ddf8c1adefc502b9d8eb /apps/web/src/rendering/components/blending-tab.tsx | |
| parent | 729d10592f4e802f91c555c309ff3ba4b119f843 (diff) | |
| download | opencut-3e89d2998588f8e35c524e523ba2ee853f0f6475.tar.gz | |
refactor: restructure files to their domains, new preview overlay system, and dep graph
Diffstat (limited to 'apps/web/src/rendering/components/blending-tab.tsx')
| -rw-r--r-- | apps/web/src/rendering/components/blending-tab.tsx | 230 |
1 files changed, 230 insertions, 0 deletions
diff --git a/apps/web/src/rendering/components/blending-tab.tsx b/apps/web/src/rendering/components/blending-tab.tsx new file mode 100644 index 00000000..3e0db46c --- /dev/null +++ b/apps/web/src/rendering/components/blending-tab.tsx @@ -0,0 +1,230 @@ +import { useEditor } from "@/editor/use-editor"; +import { clamp } from "@/utils/math"; +import { NumberField } from "@/components/ui/number-field"; +import { OcCheckerboardIcon } from "@/components/icons"; +import { Fragment, useRef } from "react"; +import { useMenuPreview } from "@/editor/use-menu-preview"; +import { + Section, + SectionContent, + SectionField, + SectionHeader, + SectionTitle, +} from "@/components/section"; +import { + Select, + SelectContent, + SelectItem, + SelectSeparator, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import type { BlendMode } from "@/rendering"; +import type { ElementType } from "@/timeline"; +import type { ElementAnimations } from "@/animation/types"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { RainDropIcon } from "@hugeicons/core-free-icons"; +import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle"; +import { useKeyframedNumberProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-number-property"; +import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead"; +import { resolveOpacityAtTime } from "@/animation"; +import { DEFAULTS } from "@/timeline/defaults"; +import { isPropertyAtDefault } from "./transform-tab"; + +type BlendingElement = { + id: string; + opacity: number; + type: ElementType; + blendMode?: BlendMode; + startTime: number; + duration: number; + animations?: ElementAnimations; +}; + +const BLEND_MODE_GROUPS: { value: BlendMode; label: string }[][] = [ + [{ 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 BlendingTab({ + element, + trackId, +}: { + element: BlendingElement; + trackId: string; +}) { + const editor = useEditor(); + const isPreviewActive = useEditor((e) => e.timeline.isPreviewActive()); + const blendMode = element.blendMode ?? DEFAULTS.element.blendMode; + const committedBlendModeRef = useRef(blendMode); + if (!isPreviewActive) { + committedBlendModeRef.current = blendMode; + } + + const { + onPointerLeave, + onOpenChange: handleBlendModeOpenChange, + markCommitted, + } = useMenuPreview(); + + const previewBlendMode = ({ value }: { value: BlendMode }) => + editor.timeline.previewElements({ + updates: [ + { trackId, elementId: element.id, updates: { blendMode: value } }, + ], + }); + + const commitBlendMode = (value: BlendMode) => { + if (editor.timeline.isPreviewActive()) { + editor.timeline.commitPreview(); + } else { + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + patch: { blendMode: value }, + }, + ], + }); + } + markCommitted(); + }; + + 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, + step: 0.01, + buildBaseUpdates: ({ value }) => ({ opacity: value }), + }); + + return ( + <Section collapsible sectionKey={`${element.id}: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: DEFAULTS.element.opacity }) + } + isDefault={isPropertyAtDefault({ + hasAnimatedKeyframes: opacity.hasAnimatedKeyframes, + isPlayheadWithinElementRange, + resolvedValue: resolvedOpacity, + staticValue: element.opacity, + defaultValue: DEFAULTS.element.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" onPointerLeave={onPointerLeave}> + {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 }) + } + > + {option.label} + </SelectItem> + ))} + {groupIndex < BLEND_MODE_GROUPS.length - 1 ? ( + <SelectSeparator /> + ) : null} + </Fragment> + ))} + </SelectContent> + </Select> + </SectionField> + </div> + </SectionContent> + </Section> + ); +} |
