summaryrefslogtreecommitdiff
path: root/apps/web/src
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src')
-rw-r--r--apps/web/src/effects/__tests__/motion.test.ts182
-rw-r--r--apps/web/src/effects/components/assets-view.tsx106
-rw-r--r--apps/web/src/effects/definitions/index.ts3
-rw-r--r--apps/web/src/effects/definitions/motion.ts132
-rw-r--r--apps/web/src/effects/motion.ts131
-rw-r--r--apps/web/src/effects/types.ts15
-rw-r--r--apps/web/src/services/renderer/resolve.ts16
-rw-r--r--apps/web/src/timeline/controllers/drag-drop-controller.ts5
8 files changed, 584 insertions, 6 deletions
diff --git a/apps/web/src/effects/__tests__/motion.test.ts b/apps/web/src/effects/__tests__/motion.test.ts
new file mode 100644
index 00000000..907dd6a9
--- /dev/null
+++ b/apps/web/src/effects/__tests__/motion.test.ts
@@ -0,0 +1,182 @@
+import { describe, expect, test } from "bun:test";
+import { registerDefaultEffects } from "@/effects";
+import { buildDefaultEffectInstance } from "@/effects";
+import { applyMotionEffects, sampleEasingBezier } from "@/effects/motion";
+import type { Transform } from "@/rendering";
+import { GraphicNode } from "@/services/renderer/nodes/graphic-node";
+import { resolveRenderTree } from "@/services/renderer/resolve";
+import type { CanvasRenderer } from "@/services/renderer/canvas-renderer";
+
+registerDefaultEffects();
+
+const baseTransform: Transform = {
+ scaleX: 1,
+ scaleY: 1,
+ position: { x: 0, y: 0 },
+ rotate: 0,
+};
+
+function applyZoomIn({
+ localTime,
+ duration = 10,
+ intensity = 100,
+ easing = "linear",
+}: {
+ localTime: number;
+ duration?: number;
+ intensity?: number;
+ easing?: string;
+}) {
+ const effect = buildDefaultEffectInstance({ effectType: "zoom-in" });
+ effect.params.intensity = intensity;
+ effect.params.easing = easing;
+ return applyMotionEffects({
+ baseTransform,
+ effects: [effect],
+ animations: undefined,
+ localTime,
+ duration,
+ canvasWidth: 1920,
+ canvasHeight: 1080,
+ });
+}
+
+describe("sampleEasingBezier", () => {
+ test("linear easing is identity", () => {
+ expect(sampleEasingBezier({ bezier: [0, 0, 1, 1], x: 0.37 })).toBeCloseTo(
+ 0.37,
+ 5,
+ );
+ });
+
+ test("preserves endpoints", () => {
+ expect(sampleEasingBezier({ bezier: [0.4, 0, 0.2, 1], x: 0 })).toBeCloseTo(
+ 0,
+ 5,
+ );
+ expect(sampleEasingBezier({ bezier: [0.4, 0, 0.2, 1], x: 1 })).toBeCloseTo(
+ 1,
+ 5,
+ );
+ });
+
+ test("ease-in lags behind linear progress mid-way", () => {
+ const eased = sampleEasingBezier({ bezier: [0.8, 0, 1, 1], x: 0.5 });
+ expect(eased).toBeLessThan(0.5);
+ });
+});
+
+describe("applyMotionEffects", () => {
+ test("zoom-in ramps scale from 1 to full range over the clip", () => {
+ expect(applyZoomIn({ localTime: 0 }).scaleX).toBeCloseTo(1, 5);
+ expect(applyZoomIn({ localTime: 10 }).scaleX).toBeCloseTo(1.35, 5);
+ });
+
+ test("intensity scales the effect proportionally", () => {
+ const full = applyZoomIn({ localTime: 10, intensity: 100 });
+ const fifth = applyZoomIn({ localTime: 10, intensity: 20 });
+ expect(fifth.scaleX).toBeCloseTo(1.07, 5);
+ expect((fifth.scaleX - 1) / (full.scaleX - 1)).toBeCloseTo(0.2, 5);
+ });
+
+ test("eased zoom is non-linear at the midpoint", () => {
+ const eased = applyZoomIn({ localTime: 5, easing: "ease-in" });
+ expect(eased.scaleX).toBeLessThan(1.175);
+ expect(eased.scaleX).toBeGreaterThan(1);
+ });
+
+ test("zoom-out starts zoomed and settles at 1", () => {
+ const effect = buildDefaultEffectInstance({ effectType: "zoom-out" });
+ effect.params.easing = "linear";
+ const apply = (localTime: number) =>
+ applyMotionEffects({
+ baseTransform,
+ effects: [effect],
+ animations: undefined,
+ localTime,
+ duration: 10,
+ canvasWidth: 1920,
+ canvasHeight: 1080,
+ });
+ expect(apply(0).scaleX).toBeCloseTo(1.35, 5);
+ expect(apply(10).scaleX).toBeCloseTo(1, 5);
+ });
+
+ test("pan-right drifts position across the canvas with zoom headroom", () => {
+ const effect = buildDefaultEffectInstance({ effectType: "pan-right" });
+ effect.params.easing = "linear";
+ const apply = (localTime: number) =>
+ applyMotionEffects({
+ baseTransform,
+ effects: [effect],
+ animations: undefined,
+ localTime,
+ duration: 10,
+ canvasWidth: 1920,
+ canvasHeight: 1080,
+ });
+ const start = apply(0);
+ const end = apply(10);
+ expect(start.position.x).toBeCloseTo(-0.06 * 1920, 3);
+ expect(end.position.x).toBeCloseTo(0.06 * 1920, 3);
+ expect(end.scaleX).toBeCloseTo(1.15, 5);
+ expect(end.scaleX).toBe(end.scaleY);
+ });
+
+ test("multiplies onto an existing base transform", () => {
+ const scaledBase: Transform = { ...baseTransform, scaleX: 2, scaleY: 2 };
+ const effect = buildDefaultEffectInstance({ effectType: "zoom-in" });
+ effect.params.easing = "linear";
+ const resolved = applyMotionEffects({
+ baseTransform: scaledBase,
+ effects: [effect],
+ animations: undefined,
+ localTime: 10,
+ duration: 10,
+ canvasWidth: 1920,
+ canvasHeight: 1080,
+ });
+ expect(resolved.scaleX).toBeCloseTo(2.7, 5);
+ });
+
+ test("ignores disabled effects and effects without motion", () => {
+ const motion = buildDefaultEffectInstance({ effectType: "zoom-in" });
+ motion.enabled = false;
+ const blur = buildDefaultEffectInstance({ effectType: "blur" });
+ const resolved = applyMotionEffects({
+ baseTransform,
+ effects: [motion, blur],
+ animations: undefined,
+ localTime: 5,
+ duration: 10,
+ canvasWidth: 1920,
+ canvasHeight: 1080,
+ });
+ expect(resolved).toEqual(baseTransform);
+ });
+});
+
+describe("renderer wiring", () => {
+ test("resolveRenderTree applies motion presets to the resolved transform", async () => {
+ const effect = buildDefaultEffectInstance({ effectType: "zoom-in" });
+ effect.params.easing = "linear";
+ const node = new GraphicNode({
+ definitionId: "rectangle",
+ params: {},
+ duration: 10,
+ timeOffset: 0,
+ trimStart: 0,
+ trimEnd: 0,
+ transform: baseTransform,
+ opacity: 1,
+ effects: [effect],
+ });
+ const renderer = { width: 1920, height: 1080 } as CanvasRenderer;
+
+ await resolveRenderTree({ node, renderer, time: 0 });
+ expect(node.resolved?.transform.scaleX).toBeCloseTo(1, 5);
+
+ await resolveRenderTree({ node, renderer, time: 5 });
+ expect(node.resolved?.transform.scaleX).toBeCloseTo(1.175, 5);
+ });
+});
diff --git a/apps/web/src/effects/components/assets-view.tsx b/apps/web/src/effects/components/assets-view.tsx
index 000af7e6..8c2ec85b 100644
--- a/apps/web/src/effects/components/assets-view.tsx
+++ b/apps/web/src/effects/components/assets-view.tsx
@@ -53,6 +53,102 @@ function EffectPreviewCanvas({ effectType }: { effectType: string }) {
return <canvas ref={canvasRef} className="size-full" />;
}
+const MOTION_ARROW_DIRECTIONS: Record<string, [number, number]> = {
+ "pan-left": [-1, 0],
+ "pan-right": [1, 0],
+ "pan-up": [0, -1],
+ "pan-down": [0, 1],
+};
+
+function drawArrow({
+ context,
+ fromX,
+ fromY,
+ toX,
+ toY,
+}: {
+ context: CanvasRenderingContext2D;
+ fromX: number;
+ fromY: number;
+ toX: number;
+ toY: number;
+}) {
+ const headLength = 10;
+ const angle = Math.atan2(toY - fromY, toX - fromX);
+ context.beginPath();
+ context.moveTo(fromX, fromY);
+ context.lineTo(toX, toY);
+ context.moveTo(toX, toY);
+ context.lineTo(
+ toX - headLength * Math.cos(angle - Math.PI / 6),
+ toY - headLength * Math.sin(angle - Math.PI / 6),
+ );
+ context.moveTo(toX, toY);
+ context.lineTo(
+ toX - headLength * Math.cos(angle + Math.PI / 6),
+ toY - headLength * Math.sin(angle + Math.PI / 6),
+ );
+ context.stroke();
+}
+
+function MotionPreviewCanvas({ effectType }: { effectType: string }) {
+ const canvasRef = useRef<HTMLCanvasElement>(null);
+
+ useEffect(() => {
+ const canvas = canvasRef.current;
+ const context = canvas?.getContext("2d");
+ if (!canvas || !context) return;
+
+ const size = canvas.width;
+ const center = size / 2;
+ context.clearRect(0, 0, size, size);
+ context.fillStyle = "#17171c";
+ context.fillRect(0, 0, size, size);
+ context.strokeStyle = "rgba(255, 255, 255, 0.35)";
+ context.lineWidth = 2;
+ context.strokeRect(size * 0.2, size * 0.28, size * 0.6, size * 0.44);
+ context.strokeStyle = "#ffffff";
+ context.lineWidth = 4;
+ context.lineCap = "round";
+
+ const panDirection = MOTION_ARROW_DIRECTIONS[effectType];
+ if (panDirection) {
+ const [dx, dy] = panDirection;
+ drawArrow({
+ context,
+ fromX: center - dx * size * 0.18,
+ fromY: center - dy * size * 0.18,
+ toX: center + dx * size * 0.18,
+ toY: center + dy * size * 0.18,
+ });
+ return;
+ }
+
+ const inward = effectType === "zoom-out";
+ const spread = size * 0.16;
+ const reach = size * 0.3;
+ for (const [dx, dy] of [
+ [1, 1],
+ [-1, -1],
+ ] as const) {
+ const innerX = center + dx * (inward ? reach : spread);
+ const innerY = center + dy * (inward ? reach : spread) * 0.7;
+ const outerX = center + dx * (inward ? spread : reach);
+ const outerY = center + dy * (inward ? spread : reach) * 0.7;
+ drawArrow({ context, fromX: innerX, fromY: innerY, toX: outerX, toY: outerY });
+ }
+ }, [effectType]);
+
+ return (
+ <canvas
+ ref={canvasRef}
+ width={effectPreviewService.PREVIEW_SIZE}
+ height={effectPreviewService.PREVIEW_SIZE}
+ className="size-full"
+ />
+ );
+}
+
function EffectItem({ effect }: { effect: EffectDefinition }) {
const editor = useEditor();
@@ -69,7 +165,11 @@ function EffectItem({ effect }: { effect: EffectDefinition }) {
});
}, [editor, effect.type]);
- const preview = <EffectPreviewCanvas effectType={effect.type} />;
+ const preview = effect.motion ? (
+ <MotionPreviewCanvas effectType={effect.type} />
+ ) : (
+ <EffectPreviewCanvas effectType={effect.type} />
+ );
return (
<DraggableItem
@@ -82,7 +182,9 @@ function EffectItem({ effect }: { effect: EffectDefinition }) {
effectType: effect.type,
targetElementTypes: EFFECT_TARGET_ELEMENT_TYPES,
}}
- onAddToTimeline={handleAddToTimeline}
+ onAddToTimeline={
+ effect.supportsStandalone === false ? undefined : handleAddToTimeline
+ }
aspectRatio={1}
isRounded
variant="card"
diff --git a/apps/web/src/effects/definitions/index.ts b/apps/web/src/effects/definitions/index.ts
index 511df042..e38e5710 100644
--- a/apps/web/src/effects/definitions/index.ts
+++ b/apps/web/src/effects/definitions/index.ts
@@ -1,7 +1,8 @@
import { effectsRegistry } from "../registry";
import { blurEffectDefinition } from "./blur";
+import { motionEffectDefinitions } from "./motion";
-const defaultEffects = [blurEffectDefinition];
+const defaultEffects = [blurEffectDefinition, ...motionEffectDefinitions];
export function registerDefaultEffects(): void {
for (const definition of defaultEffects) {
diff --git a/apps/web/src/effects/definitions/motion.ts b/apps/web/src/effects/definitions/motion.ts
new file mode 100644
index 00000000..8c81d801
--- /dev/null
+++ b/apps/web/src/effects/definitions/motion.ts
@@ -0,0 +1,132 @@
+import type { ParamDefinition, ParamValues } from "@/params";
+import type { EffectDefinition, MotionOffset } from "@/effects/types";
+import { MOTION_EASING_OPTIONS, getMotionIntensity } from "@/effects/motion";
+
+const ZOOM_RANGE = 0.35;
+const PAN_ZOOM_HEADROOM = 0.15;
+const PAN_TRAVEL = 0.06;
+
+function motionParams(): ParamDefinition[] {
+ return [
+ {
+ key: "intensity",
+ label: "Intensity",
+ type: "number",
+ default: 100,
+ min: 0,
+ max: 100,
+ step: 1,
+ keyframable: true,
+ },
+ {
+ key: "easing",
+ label: "Easing",
+ type: "select",
+ default: "smooth",
+ options: MOTION_EASING_OPTIONS,
+ },
+ ];
+}
+
+function zoomOffset({
+ effectParams,
+ progress,
+ direction,
+}: {
+ effectParams: ParamValues;
+ progress: number;
+ direction: 1 | -1;
+}): MotionOffset {
+ const intensity = getMotionIntensity({ effectParams });
+ const amount =
+ direction === 1 ? progress * intensity : (1 - progress) * intensity;
+ return {
+ scale: 1 + ZOOM_RANGE * amount,
+ translateX: 0,
+ translateY: 0,
+ };
+}
+
+function panOffset({
+ effectParams,
+ progress,
+ axis,
+ direction,
+}: {
+ effectParams: ParamValues;
+ progress: number;
+ axis: "x" | "y";
+ direction: 1 | -1;
+}): MotionOffset {
+ const intensity = getMotionIntensity({ effectParams });
+ const travel = (progress - 0.5) * 2 * PAN_TRAVEL * intensity * direction;
+ return {
+ scale: 1 + PAN_ZOOM_HEADROOM * intensity,
+ translateX: axis === "x" ? travel : 0,
+ translateY: axis === "y" ? travel : 0,
+ };
+}
+
+function buildMotionDefinition({
+ type,
+ name,
+ keywords,
+ evaluate,
+}: Pick<EffectDefinition, "type" | "name" | "keywords"> & {
+ evaluate: NonNullable<EffectDefinition["motion"]>["evaluate"];
+}): EffectDefinition {
+ return {
+ type,
+ name,
+ keywords,
+ params: motionParams(),
+ renderer: { passes: [] },
+ motion: { evaluate },
+ supportsStandalone: false,
+ };
+}
+
+export const motionEffectDefinitions: EffectDefinition[] = [
+ buildMotionDefinition({
+ type: "zoom-in",
+ name: "Zoom In",
+ keywords: ["zoom", "ken burns", "scale", "push in"],
+ evaluate: ({ effectParams, progress }) =>
+ zoomOffset({ effectParams, progress, direction: 1 }),
+ }),
+ buildMotionDefinition({
+ type: "zoom-out",
+ name: "Zoom Out",
+ keywords: ["zoom", "ken burns", "scale", "pull out"],
+ evaluate: ({ effectParams, progress }) =>
+ zoomOffset({ effectParams, progress, direction: -1 }),
+ }),
+ buildMotionDefinition({
+ type: "pan-left",
+ name: "Pan Left",
+ keywords: ["pan", "ken burns", "slide", "left"],
+ evaluate: ({ effectParams, progress }) =>
+ panOffset({ effectParams, progress, axis: "x", direction: -1 }),
+ }),
+ buildMotionDefinition({
+ type: "pan-right",
+ name: "Pan Right",
+ keywords: ["pan", "ken burns", "slide", "right"],
+ evaluate: ({ effectParams, progress }) =>
+ panOffset({ effectParams, progress, axis: "x", direction: 1 }),
+ }),
+ buildMotionDefinition({
+ type: "pan-up",
+ name: "Pan Up",
+ keywords: ["pan", "ken burns", "slide", "up", "tilt"],
+ evaluate: ({ effectParams, progress }) =>
+ panOffset({ effectParams, progress, axis: "y", direction: -1 }),
+ }),
+ buildMotionDefinition({
+ type: "pan-down",
+ name: "Pan Down",
+ keywords: ["pan", "ken burns", "slide", "down", "tilt"],
+ evaluate: ({ effectParams, progress }) =>
+ panOffset({ effectParams, progress, axis: "y", direction: 1 }),
+ }),
+];
diff --git a/apps/web/src/effects/motion.ts b/apps/web/src/effects/motion.ts
new file mode 100644
index 00000000..ab51002a
--- /dev/null
+++ b/apps/web/src/effects/motion.ts
@@ -0,0 +1,131 @@
+import type { ElementAnimations } from "@/animation/types";
+import type { NormalizedCubicBezier } from "@/animation/types";
+import { getBezierPoint } from "@/animation/bezier";
+import { resolveEffectParamsAtTime } from "@/animation/effect-param-channel";
+import type { ParamValues } from "@/params";
+import { clamp } from "@/utils/math";
+import type { Transform } from "@/rendering";
+import type { Effect } from "@/effects/types";
+import { effectsRegistry } from "@/effects/registry";
+import { BUILTIN_PRESETS } from "@/timeline/components/graph-editor/easing-presets";
+
+const BEZIER_SOLVE_ITERATIONS = 20;
+
+export const MOTION_EASING_OPTIONS = BUILTIN_PRESETS.map((preset) => ({
+ value: preset.id,
+ label: preset.label,
+}));
+
+export function getMotionIntensity({
+ effectParams,
+}: {
+ effectParams: ParamValues;
+}): number {
+ const raw = effectParams.intensity;
+ const intensity = typeof raw === "number" ? raw : Number.parseFloat(String(raw));
+ if (!Number.isFinite(intensity)) return 1;
+ return clamp({ value: intensity / 100, min: 0, max: 1 });
+}
+
+export function sampleEasingBezier({
+ bezier,
+ x,
+}: {
+ bezier: NormalizedCubicBezier;
+ x: number;
+}): number {
+ const [x1, y1, x2, y2] = bezier;
+ let lower = 0;
+ let upper = 1;
+ for (let iteration = 0; iteration < BEZIER_SOLVE_ITERATIONS; iteration++) {
+ const mid = (lower + upper) / 2;
+ const estimate = getBezierPoint({
+ progress: mid,
+ p0: 0,
+ p1: x1,
+ p2: x2,
+ p3: 1,
+ });
+ if (estimate < x) {
+ lower = mid;
+ } else {
+ upper = mid;
+ }
+ }
+ return getBezierPoint({
+ progress: (lower + upper) / 2,
+ p0: 0,
+ p1: y1,
+ p2: y2,
+ p3: 1,
+ });
+}
+
+export function applyMotionEffects({
+ baseTransform,
+ effects,
+ animations,
+ localTime,
+ duration,
+ canvasWidth,
+ canvasHeight,
+}: {
+ baseTransform: Transform;
+ effects: Effect[] | undefined;
+ animations: ElementAnimations | undefined;
+ localTime: number;
+ duration: number;
+ canvasWidth: number;
+ canvasHeight: number;
+}): Transform {
+ const motionEffects = (effects ?? []).filter(
+ (effect) => effect.enabled && effectsRegistry.get(effect.type).motion,
+ );
+ if (motionEffects.length === 0 || duration <= 0) {
+ return baseTransform;
+ }
+
+ const linearProgress = clamp({
+ value: localTime / duration,
+ min: 0,
+ max: 1,
+ });
+
+ let scale = 1;
+ let translateX = 0;
+ let translateY = 0;
+
+ for (const effect of motionEffects) {
+ const definition = effectsRegistry.get(effect.type);
+ if (!definition.motion) continue;
+ const resolvedParams = resolveEffectParamsAtTime({
+ effectId: effect.id,
+ params: effect.params,
+ animations,
+ localTime,
+ });
+ const progress = sampleEasingBezier({
+ bezier:
+ BUILTIN_PRESETS.find((entry) => entry.id === resolvedParams.easing)
+ ?.value ?? [0.25, 0.1, 0.25, 1],
+ x: linearProgress,
+ });
+ const offset = definition.motion.evaluate({
+ effectParams: resolvedParams,
+ progress,
+ });
+ scale *= offset.scale;
+ translateX += offset.translateX;
+ translateY += offset.translateY;
+ }
+
+ return {
+ scaleX: baseTransform.scaleX * scale,
+ scaleY: baseTransform.scaleY * scale,
+ position: {
+ x: baseTransform.position.x + translateX * canvasWidth,
+ y: baseTransform.position.y + translateY * canvasHeight,
+ },
+ rotate: baseTransform.rotate,
+ };
+}
diff --git a/apps/web/src/effects/types.ts b/apps/web/src/effects/types.ts
index 6b85e743..88c32207 100644
--- a/apps/web/src/effects/types.ts
+++ b/apps/web/src/effects/types.ts
@@ -23,6 +23,19 @@ export interface EffectPassTemplate {
}): Record<string, EffectUniformValue>;
}
+export interface MotionOffset {
+ scale: number;
+ translateX: number;
+ translateY: number;
+}
+
+export interface EffectMotionConfig {
+ evaluate(params: {
+ effectParams: ParamValues;
+ progress: number;
+ }): MotionOffset;
+}
+
export interface EffectRendererConfig {
passes: EffectPassTemplate[];
buildPasses?: (params: {
@@ -38,4 +51,6 @@ export interface EffectDefinition {
keywords: string[];
params: ParamDefinition[];
renderer: EffectRendererConfig;
+ motion?: EffectMotionConfig;
+ supportsStandalone?: boolean;
}
diff --git a/apps/web/src/services/renderer/resolve.ts b/apps/web/src/services/renderer/resolve.ts
index 5335de2d..a4c8d303 100644
--- a/apps/web/src/services/renderer/resolve.ts
+++ b/apps/web/src/services/renderer/resolve.ts
@@ -6,6 +6,7 @@ import {
intensityToSigma,
} from "@/effects/definitions/blur";
import { effectsRegistry, resolveEffectPasses } from "@/effects";
+import { applyMotionEffects } from "@/effects/motion";
import type { Effect, EffectPass } from "@/effects/types";
import { getSourceTimeAtClipTime } from "@/retime";
import {
@@ -125,7 +126,8 @@ function resolveEffectPassGroups({
width,
height,
});
- });
+ })
+ .filter((passes) => passes.length > 0);
}
function resolveVisualState({
@@ -149,10 +151,18 @@ function resolveVisualState({
elementStartTime: params.timeOffset,
elementDuration: params.duration,
});
- const transform = resolveTransformAtTime({
- baseTransform: params.transform,
+ const transform = applyMotionEffects({
+ baseTransform: resolveTransformAtTime({
+ baseTransform: params.transform,
+ animations: params.animations,
+ localTime,
+ }),
+ effects: params.effects,
animations: params.animations,
localTime,
+ duration: params.duration,
+ canvasWidth: context.renderer.width,
+ canvasHeight: context.renderer.height,
});
const opacity = resolveOpacityAtTime({
baseOpacity: params.opacity,
diff --git a/apps/web/src/timeline/controllers/drag-drop-controller.ts b/apps/web/src/timeline/controllers/drag-drop-controller.ts
index a419101f..6c48428a 100644
--- a/apps/web/src/timeline/controllers/drag-drop-controller.ts
+++ b/apps/web/src/timeline/controllers/drag-drop-controller.ts
@@ -15,6 +15,7 @@ import {
buildEffectElement,
} from "@/timeline/element-utils";
import { AddTrackCommand, InsertElementCommand } from "@/commands/timeline";
+import { effectsRegistry } from "@/effects";
import { BatchCommand } from "@/commands";
import type { Command } from "@/commands/base-command";
import { computeDropTarget } from "@/timeline/components/drop-target";
@@ -470,6 +471,10 @@ export class DragDropController {
return;
}
+ if (effectsRegistry.get(dragData.effectType).supportsStandalone === false) {
+ return;
+ }
+
const element = buildEffectElement({
effectType: dragData.effectType,
startTime: target.xPosition,