diff --git a/examples/with-script-in-browser/components/ExampleApp.tsx b/examples/with-script-in-browser/components/ExampleApp.tsx index c242df000f..ccc87dd8e4 100644 --- a/examples/with-script-in-browser/components/ExampleApp.tsx +++ b/examples/with-script-in-browser/components/ExampleApp.tsx @@ -121,6 +121,16 @@ export default function ExampleApp({ const [hideAllForFadeDemo, setHideAllForFadeDemo] = useState(false); const [fadeDemoNextIndex, setFadeDemoNextIndex] = useState(0); const [fadeDemoElementIds, setFadeDemoElementIds] = useState([]); + const [demoAnimationType, setDemoAnimationType] = useState<"fade" | "fly">( + "fade", + ); + const [demoAnimationDuration, setDemoAnimationDuration] = useState(500); + const [demoFlyFrom, setDemoFlyFrom] = useState< + "left" | "right" | "top" | "bottom" + >("left"); + const [demoAnimationEasing, setDemoAnimationEasing] = useState< + "linear" | "easeOut" | "easeInOut" + >("easeOut"); const initialStatePromiseRef = useRef<{ promise: ResolvablePromise; @@ -676,6 +686,67 @@ export default function ExampleApp({ {showFadeDemo && ( <> + + + + {demoAnimationType === "fly" && ( + + )} )} diff --git a/packages/element/src/renderElement.ts b/packages/element/src/renderElement.ts index 6ce7538c33..d8503a3a01 100644 --- a/packages/element/src/renderElement.ts +++ b/packages/element/src/renderElement.ts @@ -130,6 +130,32 @@ export const resolveRenderOpacity = ( return element.opacity; }; +export const resolveRenderPositionOffset = ( + element: ExcalidrawElement, + renderConfig: Pick, +) => { + return renderConfig.elementPositionOverrides?.get(element.id) ?? { x: 0, y: 0 }; +}; + +export const getRenderElementWithPositionOverride = < + TElement extends NonDeletedExcalidrawElement, +>( + element: TElement, + renderConfig: Pick, +): TElement => { + const positionOffset = resolveRenderPositionOffset(element, renderConfig); + + if (positionOffset.x === 0 && positionOffset.y === 0) { + return element; + } + + return { + ...element, + x: element.x + positionOffset.x, + y: element.y + positionOffset.y, + } as TElement; +}; + export const getRenderOpacity = ( element: ExcalidrawElement, renderConfig: Pick< @@ -821,6 +847,8 @@ export const renderElement = ( !appState.selectedElementIds[element.id] && !appState.hoveredElementIds[element.id]; + element = getRenderElementWithPositionOverride(element, renderConfig); + context.globalAlpha = getRenderOpacity( element, renderConfig, diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 2008bcbfd3..9bcdcfcae5 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -210,6 +210,7 @@ import { getMinTextElementWidth, ShapeCache, getRenderOpacity, + resolveRenderPositionOffset, resolveRenderOpacity, editGroupForSelectedElement, getElementsInGroup, @@ -742,28 +743,33 @@ class App extends React.Component { onRemoveEventListenersEmitter = new Emitter<[]>(); api: ExcalidrawImperativeAPI; - private renderOpacityVersion = 0; + private renderAnimationVersion = 0; private elementOpacityOverrides = new Map(); - private elementFadeStates = new Map< + private elementPositionOverrides = new Map(); + private elementAnimationStates = new Map< string, { - from: number; - to: number; + opacityFrom: number; + opacityTo: number; + positionFrom: { x: number; y: number }; + positionTo: { x: number; y: number }; + easing: "linear" | "easeOut" | "easeInOut"; duration: number; delay: number; elapsed: number; } >(); - private getElementFadeAnimationKey = () => `${this.id}:fade-element`; + private getElementAnimationKey = () => `${this.id}:animate-element`; - private bumpRenderOpacityVersion = () => { - this.renderOpacityVersion++; + private bumpRenderAnimationVersion = () => { + this.renderAnimationVersion++; this.setState({}); }; private getRenderOpacityConfig = () => ({ elementOpacityOverrides: this.elementOpacityOverrides, + elementPositionOverrides: this.elementPositionOverrides, resolveRenderOpacity: this.props.resolveRenderOpacity, }); @@ -771,10 +777,116 @@ class App extends React.Component { return resolveRenderOpacity(element, this.getRenderOpacityConfig()); }; - private syncFadeElementAnimations = () => { - const animationKey = this.getElementFadeAnimationKey(); + private getElementVisibleOpacity = (element: NonDeletedExcalidrawElement) => { + return clamp(element.opacity, 0, 100); + }; - if (this.elementFadeStates.size === 0) { + private applyAnimationEasing = ( + progress: number, + easing: "linear" | "easeOut" | "easeInOut", + ) => { + switch (easing) { + case "linear": + return progress; + case "easeOut": + return easeOut(progress); + case "easeInOut": + return progress < 0.5 + ? 4 * progress * progress * progress + : 1 - Math.pow(-2 * progress + 2, 3) / 2; + } + }; + + private getFlyPositionOffset = ( + element: NonDeletedExcalidrawElement, + from: "left" | "right" | "top" | "bottom", + ) => { + const [x1, y1, x2, y2] = getElementAbsoluteCoords( + element, + this.scene.getNonDeletedElementsMap(), + ); + const viewportWidth = this.state.width / this.state.zoom.value; + const viewportHeight = this.state.height / this.state.zoom.value; + const elementWidth = x2 - x1; + const elementHeight = y2 - y1; + + switch (from) { + case "left": + return { x: -(Math.max(viewportWidth, elementWidth) + 64), y: 0 }; + case "right": + return { x: Math.max(viewportWidth, elementWidth) + 64, y: 0 }; + case "top": + return { x: 0, y: -(Math.max(viewportHeight, elementHeight) + 64) }; + case "bottom": + return { x: 0, y: Math.max(viewportHeight, elementHeight) + 64 }; + } + }; + + private animateElement = ({ + id, + opacityFrom, + opacityTo, + positionFrom = { x: 0, y: 0 }, + positionTo = { x: 0, y: 0 }, + easing, + duration, + delay, + }: { + id: string; + opacityFrom: number; + opacityTo: number; + positionFrom?: { x: number; y: number }; + positionTo?: { x: number; y: number }; + easing: "linear" | "easeOut" | "easeInOut"; + duration: number; + delay: number; + }) => { + const normalizedOpacityFrom = clamp(opacityFrom, 0, 100); + const normalizedOpacityTo = clamp(opacityTo, 0, 100); + const normalizedDuration = Math.max(duration, 0); + const normalizedDelay = Math.max(delay, 0); + + this.elementOpacityOverrides.set(id, normalizedOpacityFrom); + + if (positionFrom.x !== 0 || positionFrom.y !== 0) { + this.elementPositionOverrides.set(id, positionFrom); + } else { + this.elementPositionOverrides.delete(id); + } + + if (normalizedDuration === 0 && normalizedDelay === 0) { + this.elementAnimationStates.delete(id); + this.elementOpacityOverrides.set(id, normalizedOpacityTo); + + if (positionTo.x !== 0 || positionTo.y !== 0) { + this.elementPositionOverrides.set(id, positionTo); + } else { + this.elementPositionOverrides.delete(id); + } + + this.bumpRenderAnimationVersion(); + return; + } + + this.elementAnimationStates.set(id, { + opacityFrom: normalizedOpacityFrom, + opacityTo: normalizedOpacityTo, + positionFrom, + positionTo, + easing, + duration: normalizedDuration, + delay: normalizedDelay, + elapsed: 0, + }); + + this.bumpRenderAnimationVersion(); + this.syncElementAnimations(); + }; + + private syncElementAnimations = () => { + const animationKey = this.getElementAnimationKey(); + + if (this.elementAnimationStates.size === 0) { AnimationController.cancel(animationKey); return; } @@ -786,26 +898,45 @@ class App extends React.Component { AnimationController.start(animationKey, ({ deltaTime }) => { let shouldRerender = false; - for (const [id, fade] of this.elementFadeStates) { + for (const [id, animation] of this.elementAnimationStates) { const element = this.scene.getNonDeletedElement(id); if (!element) { - this.elementFadeStates.delete(id); + this.elementAnimationStates.delete(id); shouldRerender = this.elementOpacityOverrides.delete(id) || shouldRerender; + shouldRerender = + this.elementPositionOverrides.delete(id) || shouldRerender; continue; } - fade.elapsed += deltaTime; + animation.elapsed += deltaTime; + + const progress = + animation.elapsed <= animation.delay + ? 0 + : animation.duration === 0 + ? 1 + : Math.min( + (animation.elapsed - animation.delay) / animation.duration, + 1, + ); + const easedProgress = this.applyAnimationEasing( + progress, + animation.easing, + ); const nextOpacity = - fade.elapsed <= fade.delay - ? fade.from - : fade.duration === 0 - ? fade.to - : fade.from + - (fade.to - fade.from) * - Math.min((fade.elapsed - fade.delay) / fade.duration, 1); + animation.opacityFrom + + (animation.opacityTo - animation.opacityFrom) * easedProgress; + const nextPosition = { + x: + animation.positionFrom.x + + (animation.positionTo.x - animation.positionFrom.x) * easedProgress, + y: + animation.positionFrom.y + + (animation.positionTo.y - animation.positionFrom.y) * easedProgress, + }; const clampedOpacity = clamp(nextOpacity, 0, 100); @@ -814,16 +945,31 @@ class App extends React.Component { shouldRerender = true; } - if (fade.elapsed >= fade.delay + fade.duration) { - this.elementFadeStates.delete(id); + const currentPositionOverride = + this.elementPositionOverrides.get(id) ?? ({ x: 0, y: 0 } as const); + + if ( + currentPositionOverride.x !== nextPosition.x || + currentPositionOverride.y !== nextPosition.y + ) { + if (nextPosition.x === 0 && nextPosition.y === 0) { + this.elementPositionOverrides.delete(id); + } else { + this.elementPositionOverrides.set(id, nextPosition); + } + shouldRerender = true; + } + + if (animation.elapsed >= animation.delay + animation.duration) { + this.elementAnimationStates.delete(id); } } if (shouldRerender) { - this.bumpRenderOpacityVersion(); + this.bumpRenderAnimationVersion(); } - return this.elementFadeStates.size > 0 ? {} : undefined; + return this.elementAnimationStates.size > 0 ? {} : undefined; }); }; @@ -843,9 +989,9 @@ class App extends React.Component { clear: this.resetHistory, }, scrollToContent: this.scrollToContent, - fadeElements: this.fadeElements, - cancelFadeElement: this.cancelFadeElement, - clearElementOpacityOverrides: this.clearElementOpacityOverrides, + animateElements: this.animateElements, + cancelElementAnimation: this.cancelElementAnimation, + clearElementAnimationOverrides: this.clearElementAnimationOverrides, getSceneElements: this.getSceneElements, getAppState: () => this.state, getFiles: () => this.files, @@ -1823,6 +1969,10 @@ class App extends React.Component { const isHovered = this.state.activeEmbeddable?.element === el && this.state.activeEmbeddable?.state === "hover"; + const renderPositionOffset = resolveRenderPositionOffset( + el, + this.getRenderOpacityConfig(), + ); // scale video embeds based on zoom (capped) so that smaller embeds // on canvas when zoomed are still of legible quality @@ -1844,8 +1994,8 @@ class App extends React.Component { })} style={{ transform: isVisible - ? `translate(${x - this.state.offsetLeft}px, ${ - y - this.state.offsetTop + ? `translate(${x + renderPositionOffset.x * this.state.zoom.value - this.state.offsetLeft}px, ${ + y + renderPositionOffset.y * this.state.zoom.value - this.state.offsetTop }px) scale(${scale})` : "none", display: isVisible ? "block" : "none", @@ -2442,7 +2592,8 @@ class App extends React.Component { this.flowChartCreator.pendingNodes, theme: this.state.theme, ...this.getRenderOpacityConfig(), - renderOpacityVersion: this.renderOpacityVersion, + renderAnimationVersion: + this.renderAnimationVersion, }} /> {newElementCanvasElement && ( @@ -2466,8 +2617,8 @@ class App extends React.Component { pendingFlowchartNodes: null, theme: this.state.theme, ...this.getRenderOpacityConfig(), - renderOpacityVersion: - this.renderOpacityVersion, + renderAnimationVersion: + this.renderAnimationVersion, }} /> )} @@ -3301,7 +3452,7 @@ class App extends React.Component { this.editorLifecycleEvents.emit("editor:unmount"); this.props.onUnmount?.(); this.props.onExcalidrawAPI?.(null); - AnimationController.cancel(this.getElementFadeAnimationKey()); + AnimationController.cancel(this.getElementAnimationKey()); (window as any).launchQueue?.setConsumer(() => {}); @@ -4714,104 +4865,96 @@ class App extends React.Component { }, ); - private fadeElement = ({ - id, - from, - to = 100, - duration = 250, - delay = 0, - }: { - id: string; - from?: number; - to?: number; - duration?: number; - delay?: number; - }) => { - const element = this.scene.getNonDeletedElement(id); - - if (!element) { - return; - } - - const startOpacity = clamp( - from ?? this.getResolvedElementOpacity(element), - 0, - 100, - ); - const targetOpacity = clamp(to, 0, 100); - const normalizedDuration = Math.max(duration, 0); - const normalizedDelay = Math.max(delay, 0); - - this.elementOpacityOverrides.set(id, startOpacity); - - if (normalizedDuration === 0 && normalizedDelay === 0) { - this.elementFadeStates.delete(id); - this.elementOpacityOverrides.set(id, targetOpacity); - this.bumpRenderOpacityVersion(); - return; - } - - this.elementFadeStates.set(id, { - from: startOpacity, - to: targetOpacity, - duration: normalizedDuration, - delay: normalizedDelay, - elapsed: 0, - }); - - this.bumpRenderOpacityVersion(); - this.syncFadeElementAnimations(); - }; - - public fadeElements = ({ + public animateElements = ({ elements, - from, - to = 100, duration = 250, delay = 0, stagger = 0, - }: { - elements: readonly (ExcalidrawElement | ExcalidrawElement["id"])[]; - from?: number; - to?: number; - duration?: number; - delay?: number; - stagger?: number; - }) => { + phase = "in", + easing, + ...animation + }: + | { + elements: readonly (ExcalidrawElement | ExcalidrawElement["id"])[]; + type: "fade"; + duration?: number; + delay?: number; + stagger?: number; + phase?: "in" | "out"; + easing?: "linear" | "easeOut" | "easeInOut"; + } + | { + elements: readonly (ExcalidrawElement | ExcalidrawElement["id"])[]; + type: "fly"; + from: "left" | "right" | "top" | "bottom"; + duration?: number; + delay?: number; + stagger?: number; + phase?: "in" | "out"; + easing?: "linear" | "easeOut" | "easeInOut"; + }) => { const normalizedDelay = Math.max(delay, 0); const normalizedStagger = Math.max(stagger, 0); - elements.forEach((element, index) => { - this.fadeElement({ - id: typeof element === "string" ? element : element.id, - from, - to, + elements.forEach((elementOrId, index) => { + const id = typeof elementOrId === "string" ? elementOrId : elementOrId.id; + const element = this.scene.getNonDeletedElement(id); + + if (!element) { + return; + } + + if (animation.type === "fade") { + this.animateElement({ + id, + opacityFrom: + phase === "in" ? 0 : this.getElementVisibleOpacity(element), + opacityTo: + phase === "in" ? this.getElementVisibleOpacity(element) : 0, + easing: easing ?? "easeInOut", + duration, + delay: normalizedDelay + index * normalizedStagger, + }); + return; + } + + const flyOffset = this.getFlyPositionOffset(element, animation.from); + + this.animateElement({ + id, + opacityFrom: phase === "in" ? 0 : this.getElementVisibleOpacity(element), + opacityTo: phase === "in" ? this.getElementVisibleOpacity(element) : 0, + positionFrom: phase === "in" ? flyOffset : { x: 0, y: 0 }, + positionTo: phase === "in" ? { x: 0, y: 0 } : flyOffset, + easing: easing ?? "easeOut", duration, delay: normalizedDelay + index * normalizedStagger, }); }); }; - public cancelFadeElement = (id: string) => { - if (!this.elementFadeStates.delete(id)) { + public cancelElementAnimation = (id: string) => { + if (!this.elementAnimationStates.delete(id)) { return; } - this.syncFadeElementAnimations(); + this.syncElementAnimations(); }; - public clearElementOpacityOverrides = () => { + public clearElementAnimationOverrides = () => { if ( this.elementOpacityOverrides.size === 0 && - this.elementFadeStates.size === 0 + this.elementPositionOverrides.size === 0 && + this.elementAnimationStates.size === 0 ) { return; } - this.elementFadeStates.clear(); + this.elementAnimationStates.clear(); this.elementOpacityOverrides.clear(); - this.syncFadeElementAnimations(); - this.bumpRenderOpacityVersion(); + this.elementPositionOverrides.clear(); + this.syncElementAnimations(); + this.bumpRenderAnimationVersion(); }; public applyDeltas = ( diff --git a/packages/excalidraw/renderer/staticScene.ts b/packages/excalidraw/renderer/staticScene.ts index 4ad624bf13..2db713f4a7 100644 --- a/packages/excalidraw/renderer/staticScene.ts +++ b/packages/excalidraw/renderer/staticScene.ts @@ -19,7 +19,11 @@ import { shouldApplyFrameClip, } from "@excalidraw/element"; -import { getRenderOpacity, renderElement } from "@excalidraw/element"; +import { + getRenderElementWithPositionOverride, + getRenderOpacity, + renderElement, +} from "@excalidraw/element"; import { getElementAbsoluteCoords } from "@excalidraw/element"; @@ -173,6 +177,8 @@ const renderLinkIcon = ( elementsMap: ElementsMap, renderConfig: StaticCanvasRenderConfig, ) => { + element = getRenderElementWithPositionOverride(element, renderConfig); + if (element.link && !appState.selectedElementIds[element.id]) { const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const [x, y, width, height] = getLinkHandleFromCoords( diff --git a/packages/excalidraw/scene/types.ts b/packages/excalidraw/scene/types.ts index 4dbe05b6ab..7833445911 100644 --- a/packages/excalidraw/scene/types.ts +++ b/packages/excalidraw/scene/types.ts @@ -40,7 +40,11 @@ export type StaticCanvasRenderConfig = { theme: AppState["theme"]; resolveRenderOpacity?: RenderOpacityResolver; elementOpacityOverrides?: ReadonlyMap; - renderOpacityVersion?: number; + elementPositionOverrides?: ReadonlyMap< + ExcalidrawElement["id"], + { x: number; y: number } + >; + renderAnimationVersion?: number; }; export type SVGRenderConfig = { diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index e9b82d18a6..bf2e7a71b4 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -967,11 +967,11 @@ export interface ExcalidrawImperativeAPI { getFiles: () => InstanceType["files"]; getName: InstanceType["getName"]; scrollToContent: InstanceType["scrollToContent"]; - fadeElements: InstanceType["fadeElements"]; - cancelFadeElement: InstanceType["cancelFadeElement"]; - clearElementOpacityOverrides: InstanceType< + animateElements: InstanceType["animateElements"]; + cancelElementAnimation: InstanceType["cancelElementAnimation"]; + clearElementAnimationOverrides: InstanceType< typeof App - >["clearElementOpacityOverrides"]; + >["clearElementAnimationOverrides"]; registerAction: (action: Action) => void; refresh: InstanceType["refresh"]; setToast: InstanceType["setToast"];