animot-presenter 0.6.4 → 0.6.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/types.d.ts CHANGED
@@ -432,6 +432,7 @@ export interface DrawElement extends BaseElement {
432
432
  streamline?: number;
433
433
  taperStart?: boolean;
434
434
  taperEnd?: boolean;
435
+ animation?: ArrowAnimationConfig;
435
436
  }
436
437
  export interface StickyElement extends BaseElement {
437
438
  type: 'sticky';
@@ -1,9 +1,12 @@
1
1
  /**
2
2
  * Svelte action for arrow animations. Two strategies depending on mode:
3
3
  *
4
- * • 'draw' / 'undraw' / 'draw-undraw' — clip-path inset on the host <svg>.
5
- * Doesn't touch stroke-dasharray, so dashed/dotted patterns are preserved
6
- * while the path is progressively revealed/hidden from one side.
4
+ * • 'draw' / 'undraw' / 'draw-undraw' — reveals the stroke ALONG THE PATH using
5
+ * stroke-dasharray/offset keyed to the path's total length. This follows the
6
+ * real geometry, so curved, looping and spiral arrows draw the way they're
7
+ * shaped (a linear clip-path wipe could only sweep left↔right / top↔bottom).
8
+ * The arrowhead (a separate sub-path) is revealed in step. Any dashed/dotted
9
+ * pattern is overridden during the reveal and restored when fully drawn.
7
10
  *
8
11
  * • 'flow' — marching ants. Continuously shifts stroke-dashoffset on the
9
12
  * inner .arrow-path, so the dash pattern appears to flow along the path
@@ -1,9 +1,12 @@
1
1
  /**
2
2
  * Svelte action for arrow animations. Two strategies depending on mode:
3
3
  *
4
- * • 'draw' / 'undraw' / 'draw-undraw' — clip-path inset on the host <svg>.
5
- * Doesn't touch stroke-dasharray, so dashed/dotted patterns are preserved
6
- * while the path is progressively revealed/hidden from one side.
4
+ * • 'draw' / 'undraw' / 'draw-undraw' — reveals the stroke ALONG THE PATH using
5
+ * stroke-dasharray/offset keyed to the path's total length. This follows the
6
+ * real geometry, so curved, looping and spiral arrows draw the way they're
7
+ * shaped (a linear clip-path wipe could only sweep left↔right / top↔bottom).
8
+ * The arrowhead (a separate sub-path) is revealed in step. Any dashed/dotted
9
+ * pattern is overridden during the reveal and restored when fully drawn.
7
10
  *
8
11
  * • 'flow' — marching ants. Continuously shifts stroke-dashoffset on the
9
12
  * inner .arrow-path, so the dash pattern appears to flow along the path
@@ -13,55 +16,37 @@
13
16
  export function arrowClipDraw(node, params) {
14
17
  let raf = 0;
15
18
  let pathEl = null;
19
+ let headEl = null;
16
20
  let baseDash = '';
17
21
  let baseOffset = '';
18
- function clearArrowPath() {
19
- if (!pathEl)
20
- return;
21
- pathEl.style.strokeDasharray = baseDash;
22
- pathEl.style.strokeDashoffset = baseOffset;
22
+ function restorePath() {
23
+ // Back to the element's natural (fully-drawn) appearance.
24
+ if (pathEl) {
25
+ pathEl.style.strokeDasharray = baseDash;
26
+ pathEl.style.strokeDashoffset = baseOffset;
27
+ }
28
+ if (headEl)
29
+ headEl.style.opacity = '';
23
30
  }
24
31
  function reset() {
25
32
  if (raf)
26
33
  cancelAnimationFrame(raf);
27
34
  raf = 0;
28
35
  node.style.clipPath = '';
29
- clearArrowPath();
36
+ restorePath();
30
37
  }
31
38
  function run() {
32
39
  reset();
33
40
  if (!params.enabled || params.mode === 'none')
34
41
  return;
35
- // Capture the inner arrow path (used by 'flow' mode).
36
42
  pathEl = node.querySelector('.arrow-path');
37
- if (pathEl) {
38
- baseDash = pathEl.style.strokeDasharray || '';
39
- baseOffset = pathEl.style.strokeDashoffset || '';
40
- }
41
- // Pick the dominant axis so vertical arrows reveal top↔bottom and horizontal
42
- // arrows reveal left↔right. clip-path inset(top right bottom left).
43
- const dx = params.endX - params.startX;
44
- const dy = params.endY - params.startY;
45
- const horizontal = Math.abs(dx) >= Math.abs(dy);
46
- const positive = horizontal ? dx >= 0 : dy >= 0;
47
- const goesPositive = params.reverse ? !positive : positive;
48
- // Build the clip-path string for a given progress (0 = fully hidden,
49
- // 100 = fully visible) on the dominant axis.
50
- function clip(insetPct) {
51
- if (horizontal) {
52
- return goesPositive
53
- ? `inset(0 ${insetPct}% 0 0)` // hide from right edge
54
- : `inset(0 0 0 ${insetPct}%)`; // hide from left edge
55
- }
56
- return goesPositive
57
- ? `inset(0 0 ${insetPct}% 0)` // hide from bottom
58
- : `inset(${insetPct}% 0 0 0)`; // hide from top
59
- }
60
- const clipFull = clip(100); // fully hidden (100% inset on hide side)
61
- const clipNone = clip(0);
43
+ headEl = node.querySelector('.arrow-head');
44
+ if (!pathEl)
45
+ return;
46
+ baseDash = pathEl.style.strokeDasharray || '';
47
+ baseOffset = pathEl.style.strokeDashoffset || '';
62
48
  // Round duration so an integer number of cycles fits in slide_duration.
63
- // Without this, GIF loop boundary shows the arrow mid-draw → snap to
64
- // invisible → re-draw, which the user perceives as a reset.
49
+ // Without this, a GIF loop boundary can show the arrow mid-draw → snap.
65
50
  const requested = Math.max(50, params.duration);
66
51
  const dur = params.slideDuration && params.slideDuration > 0 && (params.loop || params.mode === 'flow')
67
52
  ? params.slideDuration / Math.max(1, Math.round(params.slideDuration / requested))
@@ -70,21 +55,15 @@ export function arrowClipDraw(node, params) {
70
55
  const m = params.mode;
71
56
  // FLOW: marching ants — animate dashoffset continuously, keep base pattern.
72
57
  if (m === 'flow') {
73
- if (!pathEl)
74
- return;
75
- // If the arrow has no inline dasharray (i.e. it's solid), give it one
76
- // so dashes are visible to flow. Otherwise keep the user's pattern.
77
58
  const dashAttr = pathEl.getAttribute('stroke-dasharray');
78
59
  if (!dashAttr || dashAttr === 'none') {
79
60
  pathEl.style.strokeDasharray = '8 5';
80
61
  }
81
- // One pixel of dashoffset shift per ms feels like a steady current; use
82
- // `duration` as the ms per cycle of 24px (one base dash repeat-ish).
83
62
  const cycle = 24;
84
63
  const dir = params.reverse ? 1 : -1;
85
64
  function flowStep(now) {
86
65
  const elapsed = now - start;
87
- const offset = (dir * (elapsed / dur) * cycle) % (cycle * 1000); // huge mod just to keep it bounded
66
+ const offset = (dir * (elapsed / dur) * cycle) % (cycle * 1000);
88
67
  if (pathEl)
89
68
  pathEl.style.strokeDashoffset = String(offset);
90
69
  raf = requestAnimationFrame(flowStep);
@@ -92,59 +71,95 @@ export function arrowClipDraw(node, params) {
92
71
  raf = requestAnimationFrame(flowStep);
93
72
  return;
94
73
  }
95
- // Initial clip-path state for draw modes.
96
- if (m === 'draw' || m === 'draw-undraw') {
97
- node.style.clipPath = clipFull;
74
+ // PATH-LENGTH REVEAL for draw / undraw / draw-undraw.
75
+ let len = 0;
76
+ try {
77
+ len = pathEl.getTotalLength();
78
+ }
79
+ catch {
80
+ len = 0;
81
+ }
82
+ if (!len) {
83
+ // Degenerate path — nothing sensible to animate.
84
+ restorePath();
85
+ return;
98
86
  }
99
- else if (m === 'undraw') {
100
- node.style.clipPath = clipNone;
87
+ // dashoffset reveals from the path start (positive) or the path end
88
+ // (negative) toward the other end, so `reverse` flips the draw direction.
89
+ const hideOffset = params.reverse ? -len : len;
90
+ // p: 0 = nothing drawn, 1 = fully drawn (along the path).
91
+ function setProgress(p) {
92
+ if (!pathEl)
93
+ return;
94
+ pathEl.style.strokeDasharray = `${len} ${len}`;
95
+ pathEl.style.strokeDashoffset = String(hideOffset * (1 - p));
96
+ }
97
+ // Head only shows once the end of the line is reached.
98
+ function setHead(p) {
99
+ if (headEl)
100
+ headEl.style.opacity = p >= 0.999 ? '1' : '0';
101
+ }
102
+ const cubicOut = (t) => 1 - Math.pow(1 - t, 3);
103
+ // Initial frame.
104
+ if (m === 'undraw') {
105
+ setProgress(1);
106
+ setHead(1);
107
+ }
108
+ else {
109
+ setProgress(0);
110
+ setHead(0);
101
111
  }
102
112
  function step(now) {
103
113
  const elapsed = now - start;
104
114
  if (m === 'draw') {
105
115
  const t = Math.min(elapsed / dur, 1);
106
- const eased = 1 - Math.pow(1 - t, 3);
107
- node.style.clipPath = clip(100 * (1 - eased));
116
+ const p = cubicOut(t);
117
+ setProgress(p);
118
+ setHead(p);
108
119
  if (t < 1)
109
120
  raf = requestAnimationFrame(step);
110
121
  else if (params.loop)
111
122
  run();
112
123
  else {
113
- node.style.clipPath = clipNone;
124
+ restorePath();
114
125
  raf = 0;
115
- }
126
+ } // settle fully drawn (restores dashes)
116
127
  }
117
128
  else if (m === 'undraw') {
118
129
  const t = Math.min(elapsed / dur, 1);
119
- const eased = 1 - Math.pow(1 - t, 3);
120
- node.style.clipPath = clip(100 * eased);
130
+ const p = 1 - cubicOut(t);
131
+ setProgress(p);
132
+ setHead(p);
121
133
  if (t < 1)
122
134
  raf = requestAnimationFrame(step);
123
135
  else if (params.loop)
124
136
  run();
125
137
  else {
126
- node.style.clipPath = clipFull;
138
+ setProgress(0);
139
+ setHead(0);
127
140
  raf = 0;
128
- }
141
+ } // settle hidden
129
142
  }
130
143
  else if (m === 'draw-undraw') {
131
144
  const half = dur / 2;
132
145
  if (elapsed < half) {
133
- const t = Math.min(elapsed / half, 1);
134
- const eased = 1 - Math.pow(1 - t, 3);
135
- node.style.clipPath = clip(100 * (1 - eased));
146
+ const p = cubicOut(Math.min(elapsed / half, 1));
147
+ setProgress(p);
148
+ setHead(p);
136
149
  raf = requestAnimationFrame(step);
137
150
  }
138
151
  else {
139
152
  const t = Math.min((elapsed - half) / half, 1);
140
- const eased = 1 - Math.pow(1 - t, 3);
141
- node.style.clipPath = clip(100 * eased);
153
+ const p = 1 - cubicOut(t);
154
+ setProgress(p);
155
+ setHead(p);
142
156
  if (t < 1)
143
157
  raf = requestAnimationFrame(step);
144
158
  else if (params.loop)
145
159
  run();
146
160
  else {
147
- node.style.clipPath = clipFull;
161
+ setProgress(0);
162
+ setHead(0);
148
163
  raf = 0;
149
164
  }
150
165
  }
@@ -0,0 +1,19 @@
1
+ import type { DrawElement } from '../types';
2
+ type DrawStrokeEl = Pick<DrawElement, 'points' | 'brush' | 'strokeWidth' | 'thinning' | 'smoothing' | 'streamline' | 'taperStart' | 'taperEnd'>;
3
+ export interface FreehandDrawRevealParams {
4
+ enabled: boolean;
5
+ mode: 'draw' | 'undraw' | 'draw-undraw' | 'none' | string;
6
+ duration: number;
7
+ el: DrawStrokeEl;
8
+ loop?: boolean;
9
+ reverse?: boolean;
10
+ /** Slide loop duration in ms — when looping, the effective duration is
11
+ * rounded so an integer number of cycles fits (seamless GIF loop). */
12
+ slideDuration?: number;
13
+ key?: unknown;
14
+ }
15
+ export declare function freehandDrawReveal(node: SVGPathElement, params: FreehandDrawRevealParams): {
16
+ update(p: FreehandDrawRevealParams): void;
17
+ destroy(): void;
18
+ };
19
+ export {};
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Svelte action for freehand stroke-reveal (draw / undraw / draw-undraw).
3
+ *
4
+ * Unlike arrows (a thin stroked line revealed via stroke-dashoffset), a freehand
5
+ * element is a FILLED brush outline. Revealing it by masking leaks the future
6
+ * segment at self-crossings (the thick already-drawn mask overlaps wherever a
7
+ * later part of the loop passes through). So instead we rebuild the filled
8
+ * outline from the points up to the current arc-length each frame — the
9
+ * not-yet-drawn part simply doesn't exist yet, so loops draw cleanly.
10
+ *
11
+ * The action owns the host <path>'s `d` attribute (don't bind `d=` on it).
12
+ */
13
+ import { drawOutlineAtProgress } from './freehand';
14
+ export function freehandDrawReveal(node, params) {
15
+ let raf = 0;
16
+ const setShown = (shown) => node.setAttribute('d', drawOutlineAtProgress(params.el, shown, !!params.reverse));
17
+ const full = () => node.setAttribute('d', drawOutlineAtProgress(params.el, 1, !!params.reverse));
18
+ function reset() {
19
+ if (raf)
20
+ cancelAnimationFrame(raf);
21
+ raf = 0;
22
+ }
23
+ function run() {
24
+ reset();
25
+ if (!params.enabled || params.mode === 'none') {
26
+ full();
27
+ return;
28
+ }
29
+ const requested = Math.max(50, params.duration);
30
+ const dur = params.slideDuration && params.slideDuration > 0 && params.loop
31
+ ? params.slideDuration / Math.max(1, Math.round(params.slideDuration / requested))
32
+ : requested;
33
+ const start = performance.now();
34
+ const m = params.mode;
35
+ const cubicOut = (t) => 1 - Math.pow(1 - t, 3);
36
+ // Initial frame.
37
+ setShown(m === 'undraw' ? 1 : 0);
38
+ function step(now) {
39
+ const elapsed = now - start;
40
+ if (m === 'draw') {
41
+ const t = Math.min(elapsed / dur, 1);
42
+ setShown(cubicOut(t));
43
+ if (t < 1)
44
+ raf = requestAnimationFrame(step);
45
+ else if (params.loop)
46
+ run();
47
+ else {
48
+ full();
49
+ raf = 0;
50
+ }
51
+ }
52
+ else if (m === 'undraw') {
53
+ const t = Math.min(elapsed / dur, 1);
54
+ setShown(1 - cubicOut(t));
55
+ if (t < 1)
56
+ raf = requestAnimationFrame(step);
57
+ else if (params.loop)
58
+ run();
59
+ else {
60
+ setShown(0);
61
+ raf = 0;
62
+ }
63
+ }
64
+ else if (m === 'draw-undraw') {
65
+ const half = dur / 2;
66
+ if (elapsed < half) {
67
+ setShown(cubicOut(Math.min(elapsed / half, 1)));
68
+ raf = requestAnimationFrame(step);
69
+ }
70
+ else {
71
+ const t = Math.min((elapsed - half) / half, 1);
72
+ setShown(1 - cubicOut(t));
73
+ if (t < 1)
74
+ raf = requestAnimationFrame(step);
75
+ else if (params.loop)
76
+ run();
77
+ else {
78
+ setShown(0);
79
+ raf = 0;
80
+ }
81
+ }
82
+ }
83
+ }
84
+ raf = requestAnimationFrame(step);
85
+ }
86
+ queueMicrotask(run);
87
+ // Let the video-export pipeline restart this under the virtual clock.
88
+ if (typeof window !== 'undefined') {
89
+ const reg = (window.__svgAnimRestart ||= []);
90
+ reg.push(run);
91
+ node.__svgAnimRestart = run;
92
+ }
93
+ let lastKey = params.key;
94
+ return {
95
+ update(p) {
96
+ const keyChanged = p.key !== lastKey;
97
+ params = p;
98
+ if (keyChanged) {
99
+ lastKey = p.key;
100
+ queueMicrotask(run);
101
+ }
102
+ },
103
+ destroy() {
104
+ reset();
105
+ if (typeof window !== 'undefined') {
106
+ const reg = window.__svgAnimRestart;
107
+ const fn = node.__svgAnimRestart;
108
+ if (reg && fn) {
109
+ const idx = reg.indexOf(fn);
110
+ if (idx >= 0)
111
+ reg.splice(idx, 1);
112
+ }
113
+ }
114
+ }
115
+ };
116
+ }
@@ -8,6 +8,15 @@ export interface StrokeOptions {
8
8
  taperEnd: boolean;
9
9
  }
10
10
  export declare function resolveStrokeOptions(el: Pick<DrawElement, 'brush' | 'strokeWidth' | 'thinning' | 'smoothing' | 'streamline' | 'taperStart' | 'taperEnd'>): StrokeOptions;
11
+ type DrawStrokeEl = Pick<DrawElement, 'points' | 'brush' | 'strokeWidth' | 'thinning' | 'smoothing' | 'streamline' | 'taperStart' | 'taperEnd'>;
12
+ /**
13
+ * Filled-outline `d` for the stroke revealed up to `fraction` (0..1) of its arc
14
+ * length. Rebuilds the geometry from the partial point list each call — so at a
15
+ * self-crossing the not-yet-drawn segment simply isn't there (a mask reveal
16
+ * leaks the future segment wherever the thick already-drawn mask overlaps it).
17
+ * The growing tip is left capped (a pen nib); the real taper applies at full.
18
+ */
19
+ export declare function drawOutlineAtProgress(el: DrawStrokeEl, fraction: number, reverse?: boolean): string;
11
20
  /** Compute the axis-aligned bounding box of raw input points. */
12
21
  export declare function pointsBounds(points: number[][]): {
13
22
  minX: number;
@@ -24,3 +33,4 @@ export declare function drawElementToPath(el: Pick<DrawElement, 'points' | 'brus
24
33
  d: string;
25
34
  viewBox: string;
26
35
  };
36
+ export {};
@@ -30,6 +30,72 @@ function outlineToPath(points) {
30
30
  d.push('Z');
31
31
  return d.join(' ');
32
32
  }
33
+ /** Slice the input points to the first `fraction` (0..1) of total arc length,
34
+ * interpolating the final partial segment. `reverse` slices from the far end
35
+ * so the reveal can run end→start. */
36
+ function pointsUpToFraction(points, fraction, reverse) {
37
+ let pts = reverse ? [...points].reverse() : points;
38
+ const n = pts.length;
39
+ if (n === 0)
40
+ return [];
41
+ if (fraction >= 1)
42
+ return pts;
43
+ let total = 0;
44
+ const seg = [];
45
+ for (let i = 1; i < n; i++) {
46
+ const l = Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]);
47
+ seg.push(l);
48
+ total += l;
49
+ }
50
+ if (total === 0)
51
+ return [pts[0]];
52
+ const target = fraction * total;
53
+ const out = [pts[0]];
54
+ let acc = 0;
55
+ for (let i = 1; i < n; i++) {
56
+ const l = seg[i - 1];
57
+ if (acc + l >= target) {
58
+ const t = l > 0 ? (target - acc) / l : 0;
59
+ const x = pts[i - 1][0] + (pts[i][0] - pts[i - 1][0]) * t;
60
+ const y = pts[i - 1][1] + (pts[i][1] - pts[i - 1][1]) * t;
61
+ const p0 = pts[i - 1][2], p1 = pts[i][2];
62
+ out.push(p0 !== undefined && p1 !== undefined ? [x, y, p0 + (p1 - p0) * t] : [x, y]);
63
+ break;
64
+ }
65
+ acc += l;
66
+ out.push(pts[i]);
67
+ }
68
+ return out;
69
+ }
70
+ /**
71
+ * Filled-outline `d` for the stroke revealed up to `fraction` (0..1) of its arc
72
+ * length. Rebuilds the geometry from the partial point list each call — so at a
73
+ * self-crossing the not-yet-drawn segment simply isn't there (a mask reveal
74
+ * leaks the future segment wherever the thick already-drawn mask overlaps it).
75
+ * The growing tip is left capped (a pen nib); the real taper applies at full.
76
+ */
77
+ export function drawOutlineAtProgress(el, fraction, reverse = false) {
78
+ const f = Math.max(0, Math.min(1, fraction));
79
+ if (f <= 0)
80
+ return '';
81
+ if (f >= 1)
82
+ return drawElementToPath(el).d;
83
+ const opts = resolveStrokeOptions(el);
84
+ const sliced = pointsUpToFraction(el.points, f, reverse);
85
+ if (sliced.length < 2) {
86
+ const [x, y] = sliced[0] ?? [0, 0];
87
+ sliced.push([x + 0.01, y]);
88
+ }
89
+ const stroke = getStroke(sliced, {
90
+ size: opts.size,
91
+ thinning: opts.thinning,
92
+ smoothing: opts.smoothing,
93
+ streamline: opts.streamline,
94
+ start: { taper: opts.taperStart ? opts.size * 4 : 0, cap: !opts.taperStart },
95
+ end: { taper: 0, cap: true }
96
+ });
97
+ return outlineToPath(stroke);
98
+ }
33
99
  /** Compute the axis-aligned bounding box of raw input points. */
34
100
  export function pointsBounds(points) {
35
101
  if (points.length === 0)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "animot-presenter",
3
- "version": "0.6.4",
3
+ "version": "0.6.6",
4
4
  "description": "Embed animated presentations anywhere. Works with vanilla JS, React, Vue, Angular, Svelte, and any frontend framework. Morphing animations, code highlighting, charts, particles, and more.",
5
5
  "type": "module",
6
6
  "svelte": "./dist/index.js",