animot-presenter 0.5.9 → 0.5.11

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
@@ -231,6 +231,7 @@ export interface DecorationsConfig {
231
231
  speedMs?: number;
232
232
  angle?: number;
233
233
  direction?: 'forward' | 'reverse' | 'snake' | 'chase';
234
+ borderWidth?: number;
234
235
  };
235
236
  rgbSplit?: {
236
237
  enabled: boolean;
@@ -59,8 +59,17 @@ export function parallaxOffset(camera, depth, worldWidth, worldHeight) {
59
59
  const cy = camera.y + camera.height / 2;
60
60
  const wx = worldWidth / 2;
61
61
  const wy = worldHeight / 2;
62
- // Tame the multiplier — full ±1 doubling is too much in practice; use 0.4
63
- // so depth = +1 means 40% extra parallax, depth = -1 means 40% damped.
64
- const factor = depth * 0.4;
62
+ // Parallax magnitude is `depth × camera-offset-from-world-center × factor`.
63
+ // The earlier 0.4 factor blew up on large worlds (6000×4000) because the
64
+ // camera-offset term scales with world size — depth=0.7 gave ±840px shifts,
65
+ // destroying composition.
66
+ //
67
+ // Fix: scale the factor by the CAMERA WIDTH ratio to a 1920-baseline so the
68
+ // effect feels the same regardless of world size. A 1920-wide camera gets
69
+ // the original feel; a 3200-wide camera (zoomed out) gets a smaller offset
70
+ // per unit-of-world-distance so the layout doesn't fly apart.
71
+ const baseFactor = depth * 0.15;
72
+ const widthScale = Math.min(1, 1920 / Math.max(1, camera.width));
73
+ const factor = baseFactor * widthScale;
65
74
  return { x: -(cx - wx) * factor, y: -(cy - wy) * factor };
66
75
  }
@@ -16,6 +16,14 @@ export interface DecorationsParams {
16
16
  /** Slide loop duration; when present, each effect's cycle is rounded so an
17
17
  * integer number fits in slide_duration — guarantees seamless GIF loop. */
18
18
  slideDuration?: number;
19
+ /** Optional shape info — if present, shimmer and gradientShift overlays
20
+ * get clipped to this silhouette. Without it both effects render against
21
+ * the rectangular wrapper bounds (which looks wrong on circles/hexagons/
22
+ * stars). Pass `{ type: 'circle' }` etc. from the host for shape elements. */
23
+ shape?: {
24
+ type: 'rectangle' | 'circle' | 'ellipse' | 'triangle' | 'hexagon' | 'star';
25
+ borderRadius?: number;
26
+ };
19
27
  /** Bumped by the host when ANY decoration prop changes. Without it the
20
28
  * action would silently keep running with stale params. */
21
29
  key?: unknown;
@@ -10,6 +10,55 @@
10
10
  * • gradientShift — animates background-position on a multi-color gradient
11
11
  * • rgbSplit — chromatic-aberration-style R/B channel offset (drop-shadow)
12
12
  */
13
+ /** Build a CSS clip-path that matches a shape silhouette. Returns null for
14
+ * rectangles with no border-radius (no clipping needed). */
15
+ function shapeClipPath(shape) {
16
+ switch (shape.type) {
17
+ case 'circle': return 'circle(50% at 50% 50%)';
18
+ case 'ellipse': return 'ellipse(50% 50% at 50% 50%)';
19
+ case 'triangle': return 'polygon(50% 0%, 0% 100%, 100% 100%)';
20
+ case 'hexagon': return 'polygon(50% 0%, 93.3% 25%, 93.3% 75%, 50% 100%, 6.7% 75%, 6.7% 25%)';
21
+ case 'star': return 'polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%)';
22
+ case 'rectangle':
23
+ return shape.borderRadius && shape.borderRadius > 0
24
+ ? `inset(0 round ${shape.borderRadius}px)`
25
+ : null;
26
+ }
27
+ }
28
+ // SVG mask for the gradient border ring. We stroke the shape silhouette in a
29
+ // 100×100 viewBox with `preserveAspectRatio=none` so it stretches to the
30
+ // element's box, and `vector-effect=non-scaling-stroke` so the stroke stays a
31
+ // constant pixel width regardless of element size or aspect ratio. The stroke
32
+ // is centered on the path, so half of it falls outside the silhouette — the
33
+ // caller's clip-path trims that outer half away, leaving an inner ring of
34
+ // `borderWidth` px against the silhouette edge. We therefore set stroke-width
35
+ // to `borderWidth * 2`.
36
+ function silhouetteMaskUrl(shapeType, borderWidth) {
37
+ const sw = borderWidth * 2;
38
+ const common = `vector-effect="non-scaling-stroke" fill="none" stroke="#fff" stroke-width="${sw}"`;
39
+ let inner = '';
40
+ switch (shapeType) {
41
+ case 'circle':
42
+ inner = `<circle cx="50" cy="50" r="50" ${common}/>`;
43
+ break;
44
+ case 'ellipse':
45
+ inner = `<ellipse cx="50" cy="50" rx="50" ry="50" ${common}/>`;
46
+ break;
47
+ case 'triangle':
48
+ inner = `<polygon points="50,0 0,100 100,100" ${common}/>`;
49
+ break;
50
+ case 'hexagon':
51
+ inner = `<polygon points="50,0 93.3,25 93.3,75 50,100 6.7,75 6.7,25" ${common}/>`;
52
+ break;
53
+ case 'star':
54
+ inner = `<polygon points="50,0 61,35 98,35 68,57 79,91 50,70 21,91 32,57 2,35 39,35" ${common}/>`;
55
+ break;
56
+ default:
57
+ inner = `<rect x="0" y="0" width="100" height="100" ${common}/>`;
58
+ }
59
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" preserveAspectRatio="none">${inner}</svg>`;
60
+ return `url("data:image/svg+xml;utf8,${encodeURIComponent(svg)}")`;
61
+ }
13
62
  function effectiveCycle(requested, slideDuration) {
14
63
  if (slideDuration && slideDuration > 0) {
15
64
  return slideDuration / Math.max(1, Math.round(slideDuration / requested));
@@ -19,32 +68,34 @@ function effectiveCycle(requested, slideDuration) {
19
68
  export function decorations(node, params) {
20
69
  let raf = 0;
21
70
  let shimmerEl = null;
71
+ // Dedicated child overlay for the animated gradient background. We CANNOT
72
+ // put the gradient on the wrapper itself if a shape clip is needed —
73
+ // `clip-path` on the wrapper would also clip the wrapper's `filter:
74
+ // drop-shadow` (the glow halo), so the glow can never extend past the
75
+ // silhouette. By putting the gradient on a clipped child, the wrapper
76
+ // stays unclipped and the glow halo renders correctly outside the shape.
77
+ let gradientEl = null;
22
78
  let originalBoxShadow = '';
23
79
  let originalFilter = '';
24
- let originalBackgroundImage = '';
25
- let originalBackgroundSize = '';
26
- let originalBackgroundPosition = '';
27
80
  let savedOriginal = false;
28
81
  function saveOriginalStyles() {
29
82
  if (savedOriginal)
30
83
  return;
31
84
  originalBoxShadow = node.style.boxShadow;
32
85
  originalFilter = node.style.filter;
33
- originalBackgroundImage = node.style.backgroundImage;
34
- originalBackgroundSize = node.style.backgroundSize;
35
- originalBackgroundPosition = node.style.backgroundPosition;
36
86
  savedOriginal = true;
37
87
  }
38
88
  function restoreOriginalStyles() {
39
89
  node.style.boxShadow = originalBoxShadow;
40
90
  node.style.filter = originalFilter;
41
- node.style.backgroundImage = originalBackgroundImage;
42
- node.style.backgroundSize = originalBackgroundSize;
43
- node.style.backgroundPosition = originalBackgroundPosition;
44
91
  if (shimmerEl) {
45
92
  shimmerEl.remove();
46
93
  shimmerEl = null;
47
94
  }
95
+ if (gradientEl) {
96
+ gradientEl.remove();
97
+ gradientEl = null;
98
+ }
48
99
  }
49
100
  function reset() {
50
101
  if (raf)
@@ -61,12 +112,13 @@ export function decorations(node, params) {
61
112
  if (!anyEnabled)
62
113
  return;
63
114
  saveOriginalStyles();
64
- // Shimmer: original implementationa gradient overlay sized 300% of
65
- // the host with the stripe at 50%. Animating backgroundPosition slides
66
- // the visible window across the gradient. User confirmed this looked
67
- // right on the first cycle; the only issue was a perceived gap at the
68
- // loop boundary. Fix below is in the tick loop (we time-shift `phase`
69
- // so the empty/offscreen portion lands at the wrap, hiding the jump).
115
+ // Compute the shape clip-path once applied to shimmer + gradient
116
+ // children individually (NOT the wrapper) so the wrapper's glow filter
117
+ // halo can render outside the silhouette without being clipped.
118
+ const shapeClip = params.shape ? shapeClipPath(params.shape) : null;
119
+ // Shimmer: a child overlay with the diagonal stripe gradient. Clipped
120
+ // to the shape silhouette individually so circle/hexagon/etc. shapes
121
+ // don't get rectangular shimmer.
70
122
  if (cfg.shimmer?.enabled) {
71
123
  if (getComputedStyle(node).position === 'static') {
72
124
  node.style.position = 'relative';
@@ -84,49 +136,100 @@ export function decorations(node, params) {
84
136
  borderRadius: 'inherit',
85
137
  background: `linear-gradient(${angle}deg, transparent 0%, transparent ${50 - widthPct / 2}%, ${color} 50%, transparent ${50 + widthPct / 2}%, transparent 100%)`,
86
138
  backgroundSize: '300% 300%',
87
- // Default `repeat` would tile the gradient — when the bg slides past
88
- // an edge, the next tile's stripe enters from the opposite side
89
- // (the "two squares touching corners" artifact). One stripe per cycle.
90
139
  backgroundRepeat: 'no-repeat',
91
140
  backgroundPosition: '-100% -100%',
92
- willChange: 'background-position'
141
+ willChange: 'background-position',
142
+ ...(shapeClip ? { clipPath: shapeClip } : {})
93
143
  });
94
144
  node.appendChild(shimmerEl);
95
145
  }
96
- // Initial styles for the static parts of each effect.
146
+ // Animated gradient: border-only ring along the element's silhouette.
147
+ // The user's fillColor stays visible inside; only the perimeter receives
148
+ // the animated gradient. Two strategies:
149
+ // • Polygon / curved shapes (circle, ellipse, triangle, hexagon, star):
150
+ // SVG mask of a stroked silhouette (vector-effect=non-scaling-stroke)
151
+ // combined with the shape clip-path that trims the outer half of the
152
+ // stroke — the inner half (= borderWidth px) forms the visible ring.
153
+ // • Rectangles / non-shape elements (text, code, image, icon):
154
+ // classic CSS gradient-border trick — transparent border + two-layer
155
+ // mask with `mask-composite: exclude` so only the border ring shows.
156
+ // Respects border-radius via `borderRadius: inherit`.
97
157
  if (cfg.gradientShift?.enabled) {
158
+ if (getComputedStyle(node).position === 'static') {
159
+ node.style.position = 'relative';
160
+ }
98
161
  const colors = cfg.gradientShift.colors ?? ['#7c3aed', '#06b6d4', '#ec4899', '#7c3aed'];
99
162
  const angle = cfg.gradientShift.angle ?? 135;
100
163
  const direction = cfg.gradientShift.direction ?? 'forward';
164
+ const borderWidth = cfg.gradientShift.borderWidth ?? 3;
165
+ gradientEl = document.createElement('div');
166
+ gradientEl.className = 'animot-gradient-shift';
167
+ Object.assign(gradientEl.style, {
168
+ position: 'absolute',
169
+ inset: '0',
170
+ pointerEvents: 'none',
171
+ zIndex: '0'
172
+ });
173
+ const useSilhouetteMask = params.shape && params.shape.type !== 'rectangle';
174
+ if (useSilhouetteMask) {
175
+ const svgMask = silhouetteMaskUrl(params.shape.type, borderWidth);
176
+ gradientEl.style.webkitMaskImage = svgMask;
177
+ gradientEl.style.maskImage = svgMask;
178
+ gradientEl.style.webkitMaskRepeat = 'no-repeat';
179
+ gradientEl.style.maskRepeat = 'no-repeat';
180
+ gradientEl.style.webkitMaskSize = '100% 100%';
181
+ gradientEl.style.maskSize = '100% 100%';
182
+ // Trim the outer half of the stroke so the visible ring sits
183
+ // flush with the silhouette edge instead of bleeding outside.
184
+ if (shapeClip)
185
+ gradientEl.style.clipPath = shapeClip;
186
+ }
187
+ else {
188
+ gradientEl.style.boxSizing = 'border-box';
189
+ gradientEl.style.border = `${borderWidth}px solid transparent`;
190
+ gradientEl.style.borderRadius = 'inherit';
191
+ const m = 'linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)';
192
+ gradientEl.style.webkitMask = m;
193
+ gradientEl.style.mask = m;
194
+ gradientEl.style.webkitMaskComposite = 'xor';
195
+ gradientEl.style.maskComposite = 'exclude';
196
+ }
101
197
  if (direction === 'chase') {
102
- // Conic gradient: each color is a slice of the pie. Repeat the first
103
- // color at the end so the seam where it wraps doesn't show. The tick
104
- // loop animates the `from` angle every frame.
105
198
  const stops = [...colors, colors[0]].join(', ');
106
- node.style.backgroundImage = `conic-gradient(from 0deg at 50% 50%, ${stops})`;
107
- node.style.backgroundSize = '100% 100%';
108
- node.style.backgroundPosition = '0 0';
199
+ gradientEl.style.backgroundImage = `conic-gradient(from 0deg at 50% 50%, ${stops})`;
200
+ gradientEl.style.backgroundSize = '100% 100%';
201
+ gradientEl.style.backgroundPosition = '0 0';
109
202
  }
110
203
  else {
111
- node.style.backgroundImage = `linear-gradient(${angle}deg, ${colors.join(', ')})`;
112
- // Bigger backgroundSize means more "headroom" for the position to
113
- // sweep before the gradient repeats. 400% lets snake mode swing back
114
- // and forth without ever showing a tile seam, even at 45° angles.
115
- node.style.backgroundSize = '400% 400%';
204
+ gradientEl.style.backgroundImage = `linear-gradient(${angle}deg, ${colors.join(', ')})`;
205
+ gradientEl.style.backgroundSize = '400% 400%';
116
206
  }
207
+ // Insert as the FIRST child so it sits behind the actual element
208
+ // content (text, code block, etc.) instead of covering it.
209
+ node.insertBefore(gradientEl, node.firstChild);
117
210
  }
118
211
  const start = performance.now();
119
212
  function tick(now) {
213
+ // Re-alias cfg as non-null inside the tick closure — TS loses the
214
+ // `if (!cfg) return` narrowing across the function boundary,
215
+ // otherwise every cfg.* access flags as "possibly undefined".
216
+ const c = cfg;
120
217
  const elapsed = now - start;
121
- // GLOW — pulsing box-shadow.
122
- if (cfg.glow?.enabled) {
123
- const cycle = effectiveCycle(cfg.glow.speedMs ?? 2400, params.slideDuration);
124
- const phase = (elapsed % cycle) / cycle; // 0–1
125
- const wave = (Math.sin(phase * Math.PI * 2) + 1) / 2; // 0–1
126
- const intensity = cfg.glow.intensity ?? 0.6;
127
- const blur = 12 + wave * 36 * intensity;
128
- const spread = 2 + wave * 8 * intensity;
129
- node.style.boxShadow = `0 0 ${blur}px ${spread}px ${cfg.glow.color}`;
218
+ // GLOW — pulsing halo. Uses `filter: drop-shadow` (not box-shadow)
219
+ // so the halo follows the element's actual ALPHA MASK — SVG shapes,
220
+ // icons, and PNGs with transparency get a halo around the visible
221
+ // silhouette, not around their rectangular bounding box. Composed
222
+ // with rgbSplit below into a single filter string.
223
+ let glowFilter = '';
224
+ if (c.glow?.enabled) {
225
+ const cycle = effectiveCycle(c.glow.speedMs ?? 2400, params.slideDuration);
226
+ const phase = (elapsed % cycle) / cycle;
227
+ const wave = (Math.sin(phase * Math.PI * 2) + 1) / 2;
228
+ const intensity = c.glow.intensity ?? 0.6;
229
+ const blur = 6 + wave * 24 * intensity;
230
+ // Two stacked drop-shadows give a denser halo than a single one
231
+ // (drop-shadow has no `spread` parameter, so we layer them).
232
+ glowFilter = `drop-shadow(0 0 ${blur}px ${c.glow.color}) drop-shadow(0 0 ${blur * 0.4}px ${c.glow.color})`;
130
233
  }
131
234
  // SHIMMER — sweep the stretched gradient ALONG its own gradient
132
235
  // direction (derived from `angle`) so the stripe glides perpendicular
@@ -137,10 +240,10 @@ export function decorations(node, params) {
137
240
  // instead of metronomic. Per-cycle state (start time, duration,
138
241
  // pause length) is stashed on the wrapper so we don't burn a closure
139
242
  // rebuild every frame.
140
- if (cfg.shimmer?.enabled && shimmerEl) {
141
- const baseSpeed = cfg.shimmer.speedMs ?? 3000;
243
+ if (c.shimmer?.enabled && shimmerEl) {
244
+ const baseSpeed = c.shimmer.speedMs ?? 3000;
142
245
  const baseCycle = effectiveCycle(baseSpeed, params.slideDuration);
143
- const randomness = Math.max(0, Math.min(1, cfg.shimmer.randomness ?? 0));
246
+ const randomness = Math.max(0, Math.min(1, c.shimmer.randomness ?? 0));
144
247
  const w = shimmerEl;
145
248
  if (typeof w.__shimCycleStart !== 'number') {
146
249
  w.__shimCycleStart = elapsed;
@@ -167,7 +270,7 @@ export function decorations(node, params) {
167
270
  w.__shimCycleStart = elapsed;
168
271
  local = 0;
169
272
  }
170
- const angle = cfg.shimmer.angle ?? 110;
273
+ const angle = c.shimmer.angle ?? 110;
171
274
  const rad = angle * Math.PI / 180;
172
275
  const dirX = Math.sin(rad);
173
276
  const dirY = -Math.cos(rad);
@@ -193,22 +296,21 @@ export function decorations(node, params) {
193
296
  // next side" pinwheel)
194
297
  // Sweep direction (for non-chase modes) is derived from `angle` so
195
298
  // the gradient flows perpendicular to its own bands.
196
- if (cfg.gradientShift?.enabled) {
197
- const cycle = effectiveCycle(cfg.gradientShift.speedMs ?? 6000, params.slideDuration);
198
- const direction = cfg.gradientShift.direction ?? 'forward';
299
+ if (c.gradientShift?.enabled) {
300
+ const cycle = effectiveCycle(c.gradientShift.speedMs ?? 6000, params.slideDuration);
301
+ const direction = c.gradientShift.direction ?? 'forward';
302
+ // Write to the gradient child element instead of the wrapper —
303
+ // the wrapper carries the glow filter, which would be clipped
304
+ // if the wrapper itself had clip-path.
305
+ const target = gradientEl ?? node;
199
306
  if (direction === 'chase') {
200
- // Rebuild the conic gradient each frame with a rotating `from`
201
- // angle. Cheap to render — modern browsers compose conic
202
- // gradients on the GPU. Color list is closed (first color
203
- // repeated at end) so the seam where 360° wraps to 0° is
204
- // invisible.
205
- const colors = cfg.gradientShift.colors ?? ['#7c3aed', '#06b6d4', '#ec4899', '#7c3aed'];
307
+ const colors = c.gradientShift.colors ?? ['#7c3aed', '#06b6d4', '#ec4899', '#7c3aed'];
206
308
  const stops = [...colors, colors[0]].join(', ');
207
309
  const rotateDeg = ((elapsed % cycle) / cycle) * 360;
208
- node.style.backgroundImage = `conic-gradient(from ${rotateDeg}deg at 50% 50%, ${stops})`;
310
+ target.style.backgroundImage = `conic-gradient(from ${rotateDeg}deg at 50% 50%, ${stops})`;
209
311
  }
210
312
  else {
211
- const angle = cfg.gradientShift.angle ?? 135;
313
+ const angle = c.gradientShift.angle ?? 135;
212
314
  const rad = angle * Math.PI / 180;
213
315
  const dirX = Math.sin(rad);
214
316
  const dirY = -Math.cos(rad);
@@ -222,27 +324,31 @@ export function decorations(node, params) {
222
324
  else {
223
325
  phase = (elapsed % cycle) / cycle;
224
326
  }
225
- // Sweep range = 200% of host (centered at 50%). With
226
- // backgroundSize 400%, position values from -50% to 250% all
227
- // keep the gradient fully covering the element across all angles.
228
327
  const range = 200;
229
328
  const offset = (phase - 0.5) * range;
230
329
  const posX = 50 + offset * dirX;
231
330
  const posY = 50 + offset * dirY;
232
- node.style.backgroundPosition = `${posX}% ${posY}%`;
331
+ target.style.backgroundPosition = `${posX}% ${posY}%`;
233
332
  }
234
333
  }
235
334
  // RGB SPLIT — chromatic aberration via stacked drop-shadows.
236
- if (cfg.rgbSplit?.enabled) {
237
- const offset = cfg.rgbSplit.offset ?? 3;
335
+ let rgbSplitFilter = '';
336
+ if (c.rgbSplit?.enabled) {
337
+ const offset = c.rgbSplit.offset ?? 3;
238
338
  let dx = offset;
239
- if ((cfg.rgbSplit.speedMs ?? 0) > 0) {
240
- const cycle = effectiveCycle(cfg.rgbSplit.speedMs, params.slideDuration);
339
+ if ((c.rgbSplit.speedMs ?? 0) > 0) {
340
+ const cycle = effectiveCycle(c.rgbSplit.speedMs, params.slideDuration);
241
341
  const phase = (elapsed % cycle) / cycle;
242
342
  dx = offset * Math.sin(phase * Math.PI * 2);
243
343
  }
244
- node.style.filter = `${originalFilter} drop-shadow(${dx}px 0 0 rgba(255, 0, 64, 0.7)) drop-shadow(${-dx}px 0 0 rgba(0, 200, 255, 0.7))`;
344
+ rgbSplitFilter = `drop-shadow(${dx}px 0 0 rgba(255, 0, 64, 0.7)) drop-shadow(${-dx}px 0 0 rgba(0, 200, 255, 0.7))`;
245
345
  }
346
+ // Compose the user's original filter + glow + rgbSplit into a single
347
+ // `filter` value. Writing each effect to node.style.filter
348
+ // independently would clobber the others, since CSS filter is one
349
+ // string property — not a multi-value list.
350
+ const composed = [originalFilter, glowFilter, rgbSplitFilter].filter(Boolean).join(' ');
351
+ node.style.filter = composed;
246
352
  raf = requestAnimationFrame(tick);
247
353
  }
248
354
  raf = requestAnimationFrame(tick);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "animot-presenter",
3
- "version": "0.5.9",
3
+ "version": "0.5.11",
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",