Skip to content

element animations - #721

Open
barnabasmolnar wants to merge 6 commits into
dwelle:masterfrom
barnabasmolnar:barnabasmolnar/animations-v3
Open

barnabasmolnar wants to merge 6 commits into
dwelle:masterfrom
barnabasmolnar:barnabasmolnar/animations-v3

Conversation

@barnabasmolnar

@barnabasmolnar barnabasmolnar commented Jul 10, 2026 •

Copy link
Copy Markdown

Presentation effects need to change how elements are drawn without changing the document. This PR adds transient element render overrides to the editor and demonstrates host-owned animation playback in excalidraw-app, using E+'s beat/reveal-count and preview flows.

The intended upstream scope is the generic editor API, rendering support, documentation and regression tests. The host animator and presentation demo live in the fork to exercise the design before integrating it into E+. An alpha/beta/RC build of the editor package from this branch will be needed for that integration.

How it is split

Layer Responsibility
Editor package (packages/excalidraw) Exposes setElementRenderOverrides(), owns a copied visual snapshot, and applies it through the existing React/canvas rendering path.
Renderer (packages/element, packages/excalidraw/renderer and scene/Renderer.ts) Applies opacity and translation, including visual viewport culling, frame clipping, bound-label rendering, embeds, frame names and link badges.
Host helper (excalidraw-app/animations/ElementAnimator.ts) Owns effect presets, timing, rAF, steady visibility, overlapping requests, interruption, cancellation and completion.
Host demo (excalidraw-app/animations/AnimationPrototype.tsx and fixtures.ts) Simulates presentation navigation and authoring previews across three slides. The animator is its sole snapshot writer.

ElementAnimator lives outside the editor package. Presentation effects do not require a new editor clock or play/pause/seek API. There are no changes under examples/ relative to master.

How it works

The editor accepts one complete snapshot per mounted editor:

api.setElementRenderOverrides(
  new Map([
    ["unrevealed-element", { opacity: 0 }],
    ["moving-element", { opacity: 65, offset: { x: -120, y: 0 } }],
  ]),
);
api.setElementRenderOverrides(null); // restore ordinary rendering

Each submission replaces all previous entries and fields atomically. Missing values use the document's values. The editor defensively copies the map and nested offsets; invalid numeric values reject the whole submission. Opacity is absolute on the existing 0–100 scale, and offsets are in scene units. Other element properties cannot be overridden.

Snapshots affect rendering without changing elements, AppState, history, collaboration data or exports, and do not emit onChange. Submission requests a React update and may be coalesced before canvas drawing. An explicit visual-update guard preserves the normal lifecycle for real updates batched with it. Memoized visual culling avoids extra static repaints from unrelated App updates while a snapshot remains installed.

The host combines steady visibility with active transitions before submitting:

const animator = new ElementAnimator(api, mountedNode.ownerDocument.defaultView!);

// Compute nextVisibility from reveal counts across ALL slides, including labels.
const handle = animator.animateElements(beatRequests, {
  baseOverrides: nextVisibility,
});

await handle.finished; // useful for authoring previews; navigation need not wait
// handle.cancel();

animator.setBaseOverrides(nextVisibility, { cancelAnimations: true });
// animator.dispose() when the host unmounts

Reveal counts advance immediately. A later request can interrupt overlapping targets, starting from their last submitted visual values. Completion or cancellation removes transient values and returns control to the base snapshot: an outgoing effect needs a hidden base to remain hidden. Passing the new base with the requests makes the handoff atomic, including for zero-duration effects. Host rAF runs only while transitions remain and uses the mounted editor's window.

The helper supports fade/fly, direction, in/out phase, duration, delay, stagger and easing, with finished and cancel() handles. It expands containers and bound labels together; group members and frame children are explicit targets. Reversal keeps the requested duration, and fly retains E+'s existing easeOut behavior in both directions.

Geometry limits: hit testing, selection, editing and bindings use document positions. Hidden elements remain interactive, and connected arrows do not follow translated shapes. Arrow and label offsets should match so the label hole moves with the label. Frame opacity multiplies child opacity; a frame-only fade therefore fades its children. Canvas clipping requires frame clipping to be enabled; live DOM embeds retain their existing clipping limitation.

Full contracts and integration details: editor API and host helper, E+ migration and measurements.

Why keep AnimationController internal

Clock delivery and playback policy are separate decisions: keeping effects in E+ does not require the clock to live there forever. For this POC, host rAF keeps the public contract to one snapshot setter, while an editor-provided frame source has no demonstrated benefit yet.

  • The existing controller is an internal scheduler. It uses a static registry with string keys and global scheduling APIs, and switches between rAF and setTimeout(0) according to render throttling. Exposing it directly would make those implementation choices part of the host contract. A supported API would need editor-instance isolation, owner-window handling and automatic teardown.
  • Sharing a tick does not guarantee sharing a paint. Submitting a snapshot still schedules React work, and the static canvas has its own optional rAF throttle. Calling the setter from AnimationController would not by itself coordinate React commits with canvas drawing or guarantee removal of the observed extra-frame delay. That requires work on the rendering schedule, not just exposing a clock.
  • The measurements do not justify that extra API yet. They include React and static-render work, and show that dense-scene cost is dominated by drawing. A shared frame source would not remove snapshot-copying, React or canvas costs. The POC establishes a workable baseline; it does not establish that an editor-provided driver would improve it.
  • Host rAF already has a small, explicit lifecycle. Each animator uses its mounted editor's window, requests frames only while effects are active, and cancels them on disposal. The snapshot API also accepts updates from previews or application state without requiring a clock subscription.

If real E+ workloads reveal a scheduling problem, first measure where coordination is missing. If an editor-provided driver then earns its complexity, expose a small per-editor frame subscription with defined lifecycle and ordering relative to rendering, while retaining host-owned beats, effects and interruption semantics. That can be added without changing the snapshot API or exposing the existing AnimationController class.

How to test

From the repository root, start the app if it is not already running:

yarn start --port 3001

Open http://localhost:3001/animation-prototype. The route uses fixture data with no persistence or collaboration.

  1. Use Next/Previous on Opening to check simultaneous fade/fly, bound labels, link badges and the staggered embed placeholder. Set the duration to 1.8 seconds and reverse during a flight: the transition should start at the current visual position.
  2. Visit Connected & grouped. The moving shape leaves its bound arrow in place, both explicit group targets animate, and the image/shape retain their rotation. This slide has four beats; backward navigation should enter the previous slide fully revealed using that slide's beat count.
  3. Visit Frames & boundaries. The frame child clips at the slide edge, while the unframed shape remains outside it. Switch to Edit & preview, then use Preview frame fade to check inherited child and frame-title opacity.
  4. In edit mode, try Preview selection, Preview all beats, and Cancel preview. Cancellation, slide changes and restart should release motion and restore the appropriate base visibility.
  5. Open Diagnostics. Animation frames should not increment document-change notifications; navigation, selection and edits may. Verify edits and exports continue to use document geometry and opacity.

Automated checks:

# Focused editor + host regression tests
yarn vitest run packages/excalidraw/tests/renderOverrides.test.tsx excalidraw-app/animations/ElementAnimator.test.ts

# Existing renderer/export regression tests
yarn vitest run packages/excalidraw/renderer/helpers.test.ts packages/excalidraw/tests/export.test.tsx packages/excalidraw/tests/scene/export.test.ts --testTimeout=15000

yarn tsc --noEmit

Validation at f0e64bb87: 31/31 focused tests pass. Including the renderer/export suites gives 64 passing tests and one known baseline failure: the arrow-label SVG test calls the obsolete three-argument export API. An export-test timeout during concurrent checks passed on an isolated rerun. TypeScript reports the two existing errors (COLOR_PALETTE.black and that obsolete export call). Changed TypeScript files pass lint; formatting and browser checks pass.

The performance check measured the actual host animator, React rendering and the complete static renderer. With 2,015 hidden entries and three animated IDs, p95 submission-to-draw completion was 2.8 ms with direct rendering and 9.1 ms with throttling. The dense slide was dominated by canvas drawing. These development-build measurements used a roughly 120 Hz cadence and exclude GPU completion; they support keeping complete snapshots and host rAF for the POC. The host README contains the workload, full results and limitations.

@dwelle
dwelle force-pushed the master branch 3 times, most recently from f655a39 to 9dd9383 Compare July 16, 2026 11:31
@dwelle
dwelle force-pushed the barnabasmolnar/animations-v3 branch from 3c6f672 to 80f06e5 Compare July 16, 2026 11:33
@dwelle
dwelle force-pushed the master branch 4 times, most recently from 8776355 to a4a4306 Compare July 23, 2026 16:43
@dwelle
dwelle force-pushed the master branch 2 times, most recently from b315901 to e626358 Compare August 4, 2026 14:26
@dwelle
dwelle force-pushed the master branch 8 times, most recently from 075417c to 5735dd2 Compare August 9, 2026 16:15
@barnabasmolnar
barnabasmolnar force-pushed the barnabasmolnar/animations-v3 branch from 80f06e5 to afa1e11 Compare August 10, 2026 16:14
@barnabasmolnar
barnabasmolnar force-pushed the barnabasmolnar/animations-v3 branch from afa1e11 to f1fadfa Compare August 14, 2026 18:18
dwelle and others added 3 commits September 3, 2026 19:05
Port of barnabasmolnar/animations-v2 onto latest master, squashed into a
single commit. Adds an imperative element animation API to the editor:

- animateElements() on the imperative API: fade/fly animations with
  per-element duration, delay, stagger, and easing (linear, easeOut,
  easeInOut); returns a batch handle with finished promise plus
  finish()/cancel()/getStatus() for completion signaling
- cancelElementAnimation() and clearElementAnimationOverrides() APIs
- resolveRenderOpacity prop for host-controlled render opacity
- render caches are preserved during animations: position/opacity
  overrides are applied at draw time (positionOffset / context
  translation) instead of mutating elements, keyed by a render
  animation version
- bound text elements animate together with their containers
- example app (with-script-in-browser) animation playground and
  initialData demo

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@dwelle
dwelle force-pushed the barnabasmolnar/animations-v3 branch from f1fadfa to 9b52de8 Compare September 5, 2026 15:54
Expose atomic render override snapshots in the editor and keep effect timing, visibility, and interruption in the host animator. Add a presentation prototype, regression coverage, and API documentation, and remove the animation example changes.
@mtolmacs

mtolmacs commented Sep 7, 2026

Copy link
Copy Markdown

Here're the nits and thoughts from my earlier exploration:

Two mechanisms apply the same offset in the renderer. renderElement.ts:150-170 wraps the export branches in a context.translate, while the cached-canvas path threads a positionOffset parameter into drawElementFromCanvas. Exports never receive overrides, so the wrappers in the export branches are dead in practice, and they cause the large re-indent that dominates this file's diff. A single context.translate at the top of renderElement covers every branch, reduces the diff to a few lines, and also fixes the uncropped image ghost which currently gets a hard-coded zero offset. It would also give you one place to generalise to a full transform later if scale or rotation effects are wanted.

Copied elements defeat the bounds cache. getRenderElementWithPositionOverride allocates a fresh element per frame for culling in App, frame clipping, link badges and embeds. The bounds cache in bounds.ts:85-92 is a WeakMap keyed by element identity, so every copy is a miss. The Renderer deliberately avoids this by shifting the viewport instead, and its comment notes the copy approach breaks for arrow-bound labels whose coordinates derive from the container. That limitation now silently applies to the link badge and frame-clip paths. Pick the viewport-shift approach everywhere.

Culling is duplicated. Renderer.ts:71-124 reimplements getVisibleCanvasElements plus the frame-highlight sort. Factor the visibility predicate to accept an optional offset and reuse it. The memoization keys are sound, and the memoized selectedElements identity from Scene makes the cache hit as intended.

Frame clipping expression is duplicated and hard to read. staticScene.ts:341-362 and the second block near line 480 inline a twelve-line boolean. Extract a helper such as shouldClipTranslatedFrameChild. The second block also omits the bound-text check the first one has, which looks accidental.

Interaction gap for presentations. Opacity-zero elements stay hittable, hoverable and link-clickable in view mode, and the hover cursor cannot be fixed by the host through onLinkOpen. The snapshot map is the natural place for an interactive: false flag in a follow-up.

@dwelle
dwelle force-pushed the master branch 3 times, most recently from a84db4b to e9944c4 Compare September 14, 2026 14:19
@dwelle
dwelle force-pushed the master branch 3 times, most recently from 150a4a3 to 10a08e5 Compare September 21, 2026 08:45
@dwelle
dwelle force-pushed the master branch 3 times, most recently from cf91ce7 to aef0851 Compare October 1, 2026 14:58
@dwelle
dwelle force-pushed the master branch 4 times, most recently from 63d8bbe to ddee6e5 Compare October 12, 2026 12:37
@dwelle
dwelle force-pushed the master branch 2 times, most recently from 5e7e32b to f499d71 Compare October 20, 2026 10:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants