fancy-ui-svelte 0.13.0 → 0.14.0
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/fancy-ui/button/Button.svelte +24 -2
- package/dist/fancy-ui/button/README.md +2 -2
- package/dist/fancy-ui/compare/Compare.svelte +404 -47
- package/dist/fancy-ui/compare/Compare.svelte.d.ts +26 -0
- package/dist/fancy-ui/compare/Compare.test.js +82 -2
- package/dist/fancy-ui/compare/README.md +9 -2
- package/dist/fancy-ui/datamosh-transition/DatamoshTransition.ssr.test.d.ts +1 -0
- package/dist/fancy-ui/datamosh-transition/DatamoshTransition.ssr.test.js +24 -0
- package/dist/fancy-ui/datamosh-transition/DatamoshTransition.svelte +479 -0
- package/dist/fancy-ui/datamosh-transition/DatamoshTransition.svelte.d.ts +69 -0
- package/dist/fancy-ui/datamosh-transition/DatamoshTransition.test.d.ts +1 -0
- package/dist/fancy-ui/datamosh-transition/DatamoshTransition.test.js +250 -0
- package/dist/fancy-ui/datamosh-transition/README.md +87 -0
- package/dist/fancy-ui/datamosh-transition/datamosh-transition-core.d.ts +180 -0
- package/dist/fancy-ui/datamosh-transition/datamosh-transition-core.js +462 -0
- package/dist/fancy-ui/datamosh-transition/datamosh-transition-core.test.d.ts +1 -0
- package/dist/fancy-ui/datamosh-transition/datamosh-transition-core.test.js +309 -0
- package/dist/fancy-ui/datamosh-transition/index.d.ts +3 -0
- package/dist/fancy-ui/datamosh-transition/index.js +3 -0
- package/dist/fancy-ui/flip-card/FlipCard.svelte +373 -32
- package/dist/fancy-ui/flip-card/FlipCard.svelte.d.ts +39 -2
- package/dist/fancy-ui/flip-card/FlipCard.test.js +125 -25
- package/dist/fancy-ui/flip-card/README.md +41 -14
- package/dist/fancy-ui/flip-card/index.d.ts +1 -1
- package/dist/fancy-ui/glow-border/GlowBorder.svelte +256 -33
- package/dist/fancy-ui/glow-border/GlowBorder.svelte.d.ts +41 -2
- package/dist/fancy-ui/glow-border/GlowBorder.test.js +45 -1
- package/dist/fancy-ui/glow-border/README.md +25 -33
- package/dist/fancy-ui/glow-border/index.d.ts +1 -1
- package/dist/fancy-ui/glow-border/index.js +1 -1
- package/dist/fancy-ui/gradient-button/GradientButton.svelte +123 -42
- package/dist/fancy-ui/gradient-button/GradientButton.svelte.d.ts +10 -5
- package/dist/fancy-ui/gradient-button/GradientButton.test.js +22 -5
- package/dist/fancy-ui/gradient-button/README.md +14 -6
- package/dist/fancy-ui/index.d.ts +1 -0
- package/dist/fancy-ui/index.js +1 -0
- package/dist/fancy-ui/interactive-hover-button/InteractiveHoverButton.svelte +166 -23
- package/dist/fancy-ui/interactive-hover-button/InteractiveHoverButton.test.js +18 -68
- package/dist/fancy-ui/interactive-hover-button/README.md +21 -15
- package/dist/fancy-ui/meteors/Meteors.svelte +179 -18
- package/dist/fancy-ui/meteors/Meteors.svelte.d.ts +32 -2
- package/dist/fancy-ui/meteors/Meteors.test.js +70 -22
- package/dist/fancy-ui/meteors/README.md +21 -6
- package/dist/fancy-ui/neon-border/NeonBorder.svelte +187 -58
- package/dist/fancy-ui/neon-border/NeonBorder.svelte.d.ts +24 -5
- package/dist/fancy-ui/neon-border/NeonBorder.test.js +51 -19
- package/dist/fancy-ui/neon-border/README.md +21 -11
- package/dist/fancy-ui/pagination/Pagination.svelte +144 -31
- package/dist/fancy-ui/pagination/Pagination.test.js +64 -8
- package/dist/fancy-ui/pagination/README.md +32 -22
- package/dist/fancy-ui/rainbow-button/README.md +3 -2
- package/dist/fancy-ui/rainbow-button/RainbowButton.svelte +108 -0
- package/dist/fancy-ui/rainbow-button/RainbowButton.test.js +12 -0
- package/dist/fancy-ui/registry.js +256 -41
- package/dist/fancy-ui/ripple-button/README.md +10 -7
- package/dist/fancy-ui/ripple-button/RippleButton.svelte +172 -19
- package/dist/fancy-ui/ripple-button/RippleButton.svelte.d.ts +20 -1
- package/dist/fancy-ui/ripple-button/RippleButton.test.js +47 -1
- package/dist/fancy-ui/shimmer-button/README.md +14 -6
- package/dist/fancy-ui/shimmer-button/ShimmerButton.svelte +137 -54
- package/dist/fancy-ui/shimmer-button/ShimmerButton.svelte.d.ts +3 -3
- package/dist/fancy-ui/shimmer-button/ShimmerButton.test.js +21 -7
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { render, cleanup } from "@testing-library/svelte";
|
|
1
|
+
import { render, cleanup, fireEvent } from "@testing-library/svelte";
|
|
2
2
|
import { afterEach, describe, it, expect } from "vitest";
|
|
3
|
-
import Compare from "./Compare.svelte";
|
|
3
|
+
import Compare, { beamPalette, beamStep, keyStep, COMPARE_BEAM_COLORS } from "./Compare.svelte";
|
|
4
4
|
describe("Compare", () => {
|
|
5
5
|
afterEach(cleanup);
|
|
6
6
|
it('renders a container with role="slider"', () => {
|
|
@@ -55,3 +55,83 @@ describe("Compare", () => {
|
|
|
55
55
|
expect(slider).toHaveAttribute("aria-valuemax", "100");
|
|
56
56
|
});
|
|
57
57
|
});
|
|
58
|
+
describe("Compare beam", () => {
|
|
59
|
+
afterEach(cleanup);
|
|
60
|
+
it("renders the beam layers and a glass handle", () => {
|
|
61
|
+
const { container } = render(Compare);
|
|
62
|
+
for (const part of ["glow", "trail", "fringe", "core", "pulse"]) {
|
|
63
|
+
expect(container.querySelector(`.compare-beam__${part}`)).toBeInTheDocument();
|
|
64
|
+
}
|
|
65
|
+
expect(container.querySelector(".compare-handle")).toBeInTheDocument();
|
|
66
|
+
});
|
|
67
|
+
it("hides the handle when showHandlebar is false", () => {
|
|
68
|
+
const { container } = render(Compare, { props: { showHandlebar: false } });
|
|
69
|
+
expect(container.querySelector(".compare-handle")).toBeNull();
|
|
70
|
+
});
|
|
71
|
+
it("sets the beam colours as CSS variables", () => {
|
|
72
|
+
const { container } = render(Compare, { props: { beamColors: ["red", "lime", "blue"] } });
|
|
73
|
+
const root = container.querySelector('[role="slider"]');
|
|
74
|
+
expect(root.style.getPropertyValue("--cmp-c1")).toBe("red");
|
|
75
|
+
expect(root.style.getPropertyValue("--cmp-c2")).toBe("lime");
|
|
76
|
+
expect(root.style.getPropertyValue("--cmp-c3")).toBe("blue");
|
|
77
|
+
});
|
|
78
|
+
it("has an accessible name", () => {
|
|
79
|
+
const { container } = render(Compare, { props: { label: "Before and after" } });
|
|
80
|
+
expect(container.querySelector('[role="slider"]')).toHaveAttribute("aria-label", "Before and after");
|
|
81
|
+
});
|
|
82
|
+
it("moves with the arrow keys, Shift for big steps, Home/End for the ends", async () => {
|
|
83
|
+
const seen = [];
|
|
84
|
+
const { container } = render(Compare, {
|
|
85
|
+
props: { onpercentagechange: (p) => seen.push(p) },
|
|
86
|
+
});
|
|
87
|
+
const slider = container.querySelector('[role="slider"]');
|
|
88
|
+
await fireEvent.keyDown(slider, { key: "ArrowRight" });
|
|
89
|
+
expect(slider).toHaveAttribute("aria-valuenow", "52");
|
|
90
|
+
await fireEvent.keyDown(slider, { key: "ArrowLeft", shiftKey: true });
|
|
91
|
+
expect(slider).toHaveAttribute("aria-valuenow", "42");
|
|
92
|
+
await fireEvent.keyDown(slider, { key: "End" });
|
|
93
|
+
expect(slider).toHaveAttribute("aria-valuenow", "100");
|
|
94
|
+
await fireEvent.keyDown(slider, { key: "Home" });
|
|
95
|
+
expect(slider).toHaveAttribute("aria-valuenow", "0");
|
|
96
|
+
expect(seen).toEqual([52, 42, 100, 0]);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
describe("beamPalette", () => {
|
|
100
|
+
it("falls back to the default colours", () => {
|
|
101
|
+
expect(beamPalette(undefined)).toEqual(COMPARE_BEAM_COLORS);
|
|
102
|
+
expect(beamPalette([])).toEqual(COMPARE_BEAM_COLORS);
|
|
103
|
+
});
|
|
104
|
+
it("spreads one, two or many colours over three slots", () => {
|
|
105
|
+
expect(beamPalette(["red"])).toEqual(["red", "red", "red"]);
|
|
106
|
+
expect(beamPalette(["red", "blue"])).toEqual(["red", "color-mix(in srgb, red, blue)", "blue"]);
|
|
107
|
+
expect(beamPalette(["a", "b", "c", "d", "e"])).toEqual(["a", "c", "e"]);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
describe("beamStep", () => {
|
|
111
|
+
it("stretches the trail with speed, on the side of the motion", () => {
|
|
112
|
+
expect(beamStep(0, 0, 10, 0).trail).toBeGreaterThan(0);
|
|
113
|
+
expect(beamStep(0, 0, -10, 0).trail).toBeLessThan(0);
|
|
114
|
+
});
|
|
115
|
+
it("caps the trail and lets it settle to zero", () => {
|
|
116
|
+
expect(beamStep(0, 0, 1000, 0).trail).toBe(140);
|
|
117
|
+
let s = { trail: 60, energy: 0 };
|
|
118
|
+
for (let i = 0; i < 200; i++)
|
|
119
|
+
s = beamStep(s.trail, s.energy, 0, 0);
|
|
120
|
+
expect(s.trail).toBe(0);
|
|
121
|
+
});
|
|
122
|
+
it("eases the energy toward its target, kicked up by speed", () => {
|
|
123
|
+
let s = { trail: 0, energy: 0 };
|
|
124
|
+
for (let i = 0; i < 200; i++)
|
|
125
|
+
s = beamStep(s.trail, s.energy, 0, 1);
|
|
126
|
+
expect(s.energy).toBeCloseTo(1, 2);
|
|
127
|
+
expect(beamStep(0, 0, 20, 0).energy).toBeGreaterThan(0);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
describe("keyStep", () => {
|
|
131
|
+
it("maps keys to positions and clamps", () => {
|
|
132
|
+
expect(keyStep("ArrowRight", false, 99)).toBe(100);
|
|
133
|
+
expect(keyStep("ArrowLeft", true, 5)).toBe(0);
|
|
134
|
+
expect(keyStep("PageUp", false, 50)).toBe(60);
|
|
135
|
+
expect(keyStep("Enter", false, 50)).toBeNull();
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
# Compare
|
|
2
2
|
|
|
3
|
-
A before/after image comparison slider
|
|
3
|
+
A before/after image comparison slider split by a blade of light.
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
7
|
- **Two slide modes**: `hover` (follows cursor) or `drag` (click and drag)
|
|
8
8
|
- **Autoplay**: Automatic back-and-forth animation
|
|
9
9
|
- **Custom content**: Supports images or custom snippets for both sides
|
|
10
|
-
- **
|
|
10
|
+
- **A seam of light**: a white-hot core with a chromatic fringe and a glow that spills onto both sides; pulses of light run along it, faster while you interact
|
|
11
|
+
- **Motion trail**: moving the seam leaves a trail behind it that stretches with the speed of the gesture and settles when it stops
|
|
12
|
+
- **Glass handle**: a frosted bead with a ring of light round it
|
|
13
|
+
- **Keyboard**: arrow keys move by 2%, Shift+arrows and Page Up/Down by 10%, Home/End jump to the ends
|
|
14
|
+
- **Soft return**: in hover mode the seam glides back to its resting place when the pointer leaves
|
|
15
|
+
- **Reduced motion**: no pulses, no trail, no glide
|
|
11
16
|
- **Touch support**: Works on mobile devices
|
|
12
17
|
|
|
13
18
|
## Props
|
|
@@ -26,6 +31,8 @@ A before/after image comparison slider with smooth interactions.
|
|
|
26
31
|
| `showHandlebar` | `boolean` | `true` | Show the drag handle |
|
|
27
32
|
| `autoplay` | `boolean` | `false` | Enable auto-animation |
|
|
28
33
|
| `autoplayDuration` | `number` | `5000` | Duration of one autoplay cycle (ms) |
|
|
34
|
+
| `beamColors` | `string[]` | `['#22d3ee', '#818cf8', '#f472b6']` | Beam colours: left fringe, centre, right fringe (one or more colours) |
|
|
35
|
+
| `label` | `string` | `'Comparison slider'` | Accessible name of the slider |
|
|
29
36
|
|
|
30
37
|
## Events (Callbacks)
|
|
31
38
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
import { describe, it, expect } from "vitest";
|
|
3
|
+
import { render } from "svelte/server";
|
|
4
|
+
import DatamoshTransition from "./DatamoshTransition.svelte";
|
|
5
|
+
describe("DatamoshTransition (SSR)", () => {
|
|
6
|
+
it("renders an idle, hidden overlay without touching window", () => {
|
|
7
|
+
const { body } = render(DatamoshTransition, { props: { class: "custom" } });
|
|
8
|
+
expect(body).toContain("datamosh-transition");
|
|
9
|
+
expect(body).toContain("custom");
|
|
10
|
+
expect(body).toContain("<canvas");
|
|
11
|
+
expect(body).toContain('data-state="idle"');
|
|
12
|
+
expect(body).toContain("invisible");
|
|
13
|
+
expect(body).toContain('aria-hidden="true"');
|
|
14
|
+
});
|
|
15
|
+
it("is byte-identical across renders", () => {
|
|
16
|
+
const a = render(DatamoshTransition, { props: { seed: 3 } }).body;
|
|
17
|
+
const b = render(DatamoshTransition, { props: { seed: 3 } }).body;
|
|
18
|
+
expect(a).toBe(b);
|
|
19
|
+
});
|
|
20
|
+
it("renders deterministically with a picture source", () => {
|
|
21
|
+
const props = { source: "/sunset.svg", variant: "split" };
|
|
22
|
+
expect(render(DatamoshTransition, { props }).body).toBe(render(DatamoshTransition, { props }).body);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
import type { HTMLAttributes } from "svelte/elements";
|
|
3
|
+
import type {
|
|
4
|
+
DatamoshEffect,
|
|
5
|
+
DatamoshPaletteName,
|
|
6
|
+
DatamoshSweep,
|
|
7
|
+
} from "./datamosh-transition-core.js";
|
|
8
|
+
|
|
9
|
+
export type DatamoshTransitionPhase = "idle" | "covering" | "covered" | "revealing";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* DatamoshTransition — a page-transition overlay that looks like a corrupted
|
|
13
|
+
* video decode.
|
|
14
|
+
*
|
|
15
|
+
* A fixed grid of columns (narrow on the left, wide on the right) fills with
|
|
16
|
+
* flat blocks of saturated colour. Inside each column a stack of tiles falls
|
|
17
|
+
* along one sigmoid: slivers at the edges, one tile snapping open through
|
|
18
|
+
* the middle. `cover()` drops the columns over the page like a curtain,
|
|
19
|
+
* right to left; `reveal()` lets them fall away. Nothing moves sideways.
|
|
20
|
+
*
|
|
21
|
+
* Mount once (root layout) and drive it from your router's navigation hook.
|
|
22
|
+
*/
|
|
23
|
+
interface BaseProps {
|
|
24
|
+
/** Additional classes on the overlay */
|
|
25
|
+
class?: string;
|
|
26
|
+
/** Bindable overlay element */
|
|
27
|
+
ref?: HTMLDivElement | null;
|
|
28
|
+
/**
|
|
29
|
+
* Palette name or colour list (hex or rgb()). The lightest and darkest
|
|
30
|
+
* colours recur most often.
|
|
31
|
+
*/
|
|
32
|
+
colors?: string[] | DatamoshPaletteName;
|
|
33
|
+
/** Shape of the covering band: curtain, rise, split or interlace */
|
|
34
|
+
variant?: DatamoshEffect;
|
|
35
|
+
/** Order in which the columns move */
|
|
36
|
+
sweep?: DatamoshSweep;
|
|
37
|
+
/**
|
|
38
|
+
* Picture to decode: tiles take their colours from it instead of the
|
|
39
|
+
* palette. A URL (same-origin or CORS-enabled), an image or a canvas.
|
|
40
|
+
*/
|
|
41
|
+
source?: string | HTMLImageElement | HTMLCanvasElement;
|
|
42
|
+
/** Seed for the colour order — same seed, same strip */
|
|
43
|
+
seed?: number;
|
|
44
|
+
/** Number of columns */
|
|
45
|
+
columns?: number;
|
|
46
|
+
/** Column edge exponent: 1 = uniform grid, higher = wider columns to the right */
|
|
47
|
+
power?: number;
|
|
48
|
+
/** Tiles per column stack (forced odd) */
|
|
49
|
+
tiles?: number;
|
|
50
|
+
/** Time for one column to cover, ms */
|
|
51
|
+
coverDuration?: number;
|
|
52
|
+
/** Time for one column to clear, ms */
|
|
53
|
+
revealDuration?: number;
|
|
54
|
+
/** Delay between neighbouring columns, ms (rightmost first) */
|
|
55
|
+
stagger?: number;
|
|
56
|
+
/** Speed of the falling tiles, 1 = default */
|
|
57
|
+
speed?: number;
|
|
58
|
+
/** Fill the positioned parent instead of the viewport */
|
|
59
|
+
contained?: boolean;
|
|
60
|
+
/** Stacking order of the overlay */
|
|
61
|
+
zIndex?: number;
|
|
62
|
+
/** Current phase (read-only, bindable) */
|
|
63
|
+
phase?: DatamoshTransitionPhase;
|
|
64
|
+
/** Called once the page is fully covered */
|
|
65
|
+
oncovered?: () => void;
|
|
66
|
+
/** Called once the page is fully revealed */
|
|
67
|
+
onrevealed?: () => void;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type DatamoshTransitionProps = BaseProps &
|
|
71
|
+
Omit<HTMLAttributes<HTMLDivElement>, keyof BaseProps>;
|
|
72
|
+
</script>
|
|
73
|
+
|
|
74
|
+
<script lang="ts">
|
|
75
|
+
import { onMount, untrack } from "svelte";
|
|
76
|
+
import { cn } from "../../utils.js";
|
|
77
|
+
import { createReducedMotion } from "../_internals/motion/media-query.svelte.js";
|
|
78
|
+
import {
|
|
79
|
+
BLEED,
|
|
80
|
+
CYCLE,
|
|
81
|
+
DEFAULT_COLUMNS,
|
|
82
|
+
DEFAULT_POWER,
|
|
83
|
+
DEFAULT_TILES,
|
|
84
|
+
FALL_STAGGER,
|
|
85
|
+
buildStrip,
|
|
86
|
+
columnBand,
|
|
87
|
+
columnDelays,
|
|
88
|
+
columnEdges,
|
|
89
|
+
columnFlow,
|
|
90
|
+
columnTiles,
|
|
91
|
+
coverFit,
|
|
92
|
+
sampleSource,
|
|
93
|
+
sourceSlot,
|
|
94
|
+
createCoverage,
|
|
95
|
+
easeEdge,
|
|
96
|
+
oddTiles,
|
|
97
|
+
paletteRoles,
|
|
98
|
+
resolvePalette,
|
|
99
|
+
settleCoverage,
|
|
100
|
+
stepCoverage,
|
|
101
|
+
stripIndex,
|
|
102
|
+
type Coverage,
|
|
103
|
+
type Direction,
|
|
104
|
+
} from "./datamosh-transition-core.js";
|
|
105
|
+
|
|
106
|
+
let {
|
|
107
|
+
class: className,
|
|
108
|
+
ref = $bindable(null),
|
|
109
|
+
colors = "broadcast",
|
|
110
|
+
variant = "curtain",
|
|
111
|
+
sweep = "right",
|
|
112
|
+
source,
|
|
113
|
+
seed = 1,
|
|
114
|
+
columns = DEFAULT_COLUMNS,
|
|
115
|
+
power = DEFAULT_POWER,
|
|
116
|
+
tiles = DEFAULT_TILES,
|
|
117
|
+
coverDuration = 380,
|
|
118
|
+
revealDuration = 480,
|
|
119
|
+
stagger = 28,
|
|
120
|
+
speed = 1,
|
|
121
|
+
contained = false,
|
|
122
|
+
zIndex = 9999,
|
|
123
|
+
phase = $bindable("idle"),
|
|
124
|
+
oncovered,
|
|
125
|
+
onrevealed,
|
|
126
|
+
...restProps
|
|
127
|
+
}: DatamoshTransitionProps = $props();
|
|
128
|
+
|
|
129
|
+
const reduced = createReducedMotion();
|
|
130
|
+
$effect(() => reduced.start());
|
|
131
|
+
|
|
132
|
+
const palette = $derived(resolvePalette(colors));
|
|
133
|
+
const columnsC = $derived(
|
|
134
|
+
Math.max(1, Math.round(Number.isFinite(columns) ? columns : DEFAULT_COLUMNS))
|
|
135
|
+
);
|
|
136
|
+
const powerC = $derived(Number.isFinite(power) && power > 0 ? power : DEFAULT_POWER);
|
|
137
|
+
const tilesC = $derived(oddTiles(tiles));
|
|
138
|
+
const speedC = $derived(Number.isFinite(speed) && speed > 0 ? speed : 1);
|
|
139
|
+
const strip = $derived(buildStrip(seed, palette));
|
|
140
|
+
const delays = $derived(
|
|
141
|
+
columnDelays(columnsC, sweep, Math.max(0, Number.isFinite(stagger) ? stagger : 0) / 1000, seed)
|
|
142
|
+
);
|
|
143
|
+
/** Darkest colour as a backing fill, so rounding seams never show the page through. */
|
|
144
|
+
const backing = $derived(palette[paletteRoles(palette).darks[0] ?? 0] ?? "#14101f");
|
|
145
|
+
|
|
146
|
+
let canvasEl: HTMLCanvasElement | null = $state(null);
|
|
147
|
+
|
|
148
|
+
// --- plain closure state (touched every frame, never rendered) --------------
|
|
149
|
+
|
|
150
|
+
let ctx: CanvasRenderingContext2D | null = null;
|
|
151
|
+
let w = 0;
|
|
152
|
+
let h = 0;
|
|
153
|
+
let dpr = 1;
|
|
154
|
+
let edges: number[] = [];
|
|
155
|
+
let cov: Coverage = createCoverage(DEFAULT_COLUMNS);
|
|
156
|
+
let direction: Direction = "reveal";
|
|
157
|
+
let phaseTime = 0;
|
|
158
|
+
let elapsed = 0;
|
|
159
|
+
let rafId: number | null = null;
|
|
160
|
+
let lastT = 0;
|
|
161
|
+
let pending: Array<() => void> = [];
|
|
162
|
+
/** Loaded picture, and its colours per `[column][slot]` for the current box. */
|
|
163
|
+
let sourceImage: CanvasImageSource | null = null;
|
|
164
|
+
let sourceSize = { w: 0, h: 0 };
|
|
165
|
+
let sourceTable: string[][] | null = null;
|
|
166
|
+
|
|
167
|
+
// --- promises ------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
function settlePending() {
|
|
170
|
+
const list = pending;
|
|
171
|
+
pending = [];
|
|
172
|
+
for (const resolve of list) resolve();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function finish(dir: Direction) {
|
|
176
|
+
settleCoverage(cov, dir);
|
|
177
|
+
phase = dir === "cover" ? "covered" : "idle";
|
|
178
|
+
if (dir === "reveal") {
|
|
179
|
+
stopLoop();
|
|
180
|
+
clear();
|
|
181
|
+
} else if (!reduced.current) {
|
|
182
|
+
requestFrame();
|
|
183
|
+
} else {
|
|
184
|
+
draw();
|
|
185
|
+
}
|
|
186
|
+
settlePending();
|
|
187
|
+
if (dir === "cover") oncovered?.();
|
|
188
|
+
else onrevealed?.();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function run(dir: Direction): Promise<void> {
|
|
192
|
+
// A superseded call resolves now: its caller moves on, the new phase takes over.
|
|
193
|
+
settlePending();
|
|
194
|
+
const promise = new Promise<void>((resolve) => pending.push(resolve));
|
|
195
|
+
|
|
196
|
+
// Already there: nothing to animate, no callback.
|
|
197
|
+
if ((dir === "reveal" && phase === "idle") || (dir === "cover" && phase === "covered")) {
|
|
198
|
+
settlePending();
|
|
199
|
+
return promise;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
direction = dir;
|
|
203
|
+
phaseTime = 0;
|
|
204
|
+
phase = dir === "cover" ? "covering" : "revealing";
|
|
205
|
+
|
|
206
|
+
const instant =
|
|
207
|
+
!ctx ||
|
|
208
|
+
reduced.current ||
|
|
209
|
+
typeof requestAnimationFrame !== "function" ||
|
|
210
|
+
(typeof document !== "undefined" && document.hidden);
|
|
211
|
+
if (instant) finish(dir);
|
|
212
|
+
else requestFrame();
|
|
213
|
+
return promise;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Cover the page. Resolves once every column is down. */
|
|
217
|
+
export function cover(): Promise<void> {
|
|
218
|
+
return run("cover");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Clear the page. Resolves once every column has fallen away. */
|
|
222
|
+
export function reveal(): Promise<void> {
|
|
223
|
+
return run("reveal");
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Cover, then reveal — a one-shot flash. */
|
|
227
|
+
export async function play(): Promise<void> {
|
|
228
|
+
await cover();
|
|
229
|
+
await reveal();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// --- setup ---------------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
function currentDpr() {
|
|
235
|
+
return Math.min(typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1, 2);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Sample the loaded picture onto the current grid; any failure falls back to the palette. */
|
|
239
|
+
function buildSourceTable() {
|
|
240
|
+
sourceTable = null;
|
|
241
|
+
if (!sourceImage || !edges.length || typeof document === "undefined") return;
|
|
242
|
+
const pw = 96;
|
|
243
|
+
const ph = Math.max(8, Math.round((pw * h) / Math.max(1, w)));
|
|
244
|
+
try {
|
|
245
|
+
const scratch = document.createElement("canvas");
|
|
246
|
+
scratch.width = pw;
|
|
247
|
+
scratch.height = ph;
|
|
248
|
+
const sctx = scratch.getContext("2d", { willReadFrequently: true });
|
|
249
|
+
if (!sctx) return;
|
|
250
|
+
const fit = coverFit(sourceSize.w, sourceSize.h, pw, ph);
|
|
251
|
+
sctx.drawImage(sourceImage, fit.sx, fit.sy, fit.sw, fit.sh, 0, 0, pw, ph);
|
|
252
|
+
const { data } = sctx.getImageData(0, 0, pw, ph);
|
|
253
|
+
const norm = edges.map((e) => e / Math.max(1, w));
|
|
254
|
+
sourceTable = sampleSource(data, pw, ph, norm, tilesC);
|
|
255
|
+
} catch {
|
|
256
|
+
// Cross-origin picture without CORS taints the canvas: keep the palette.
|
|
257
|
+
sourceTable = null;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function measure() {
|
|
262
|
+
const host = ref;
|
|
263
|
+
const canvas = canvasEl;
|
|
264
|
+
if (!host || !canvas || !ctx) return;
|
|
265
|
+
dpr = currentDpr();
|
|
266
|
+
w = Math.max(1, Math.round(host.clientWidth * dpr));
|
|
267
|
+
h = Math.max(1, Math.round(host.clientHeight * dpr));
|
|
268
|
+
canvas.width = w;
|
|
269
|
+
canvas.height = h;
|
|
270
|
+
ctx.imageSmoothingEnabled = false;
|
|
271
|
+
edges = columnEdges(w, columnsC, powerC);
|
|
272
|
+
buildSourceTable();
|
|
273
|
+
if (cov.top.length !== columnsC) {
|
|
274
|
+
const covered = phase === "covered" || phase === "covering";
|
|
275
|
+
cov = createCoverage(columnsC, covered);
|
|
276
|
+
}
|
|
277
|
+
if (phase !== "idle") draw();
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// --- loop ----------------------------------------------------------------------
|
|
281
|
+
|
|
282
|
+
function requestFrame() {
|
|
283
|
+
if (rafId !== null || !ctx || typeof requestAnimationFrame !== "function") return;
|
|
284
|
+
lastT = 0;
|
|
285
|
+
rafId = requestAnimationFrame(tick);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function stopLoop() {
|
|
289
|
+
if (rafId !== null && typeof cancelAnimationFrame === "function") cancelAnimationFrame(rafId);
|
|
290
|
+
rafId = null;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function tick(now: number) {
|
|
294
|
+
rafId = null;
|
|
295
|
+
if (!ctx) return;
|
|
296
|
+
// Time-based: identical pacing at 60 Hz and 120 Hz; long stalls clamp.
|
|
297
|
+
const dt = lastT ? Math.min(0.05, Math.max(0, (now - lastT) / 1000)) : 0;
|
|
298
|
+
lastT = now;
|
|
299
|
+
elapsed += dt;
|
|
300
|
+
|
|
301
|
+
if (phase === "covering" || phase === "revealing") {
|
|
302
|
+
phaseTime += dt;
|
|
303
|
+
const duration = (direction === "cover" ? coverDuration : revealDuration) / 1000;
|
|
304
|
+
const done = stepCoverage(cov, direction, phaseTime, dt, duration, delays);
|
|
305
|
+
if (done) {
|
|
306
|
+
finish(direction);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
draw();
|
|
312
|
+
if (phase !== "idle") rafId = requestAnimationFrame(tick);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function clear() {
|
|
316
|
+
ctx?.clearRect(0, 0, w, h);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function draw() {
|
|
320
|
+
const c = ctx;
|
|
321
|
+
if (!c || !edges.length) return;
|
|
322
|
+
c.clearRect(0, 0, w, h);
|
|
323
|
+
const bleed = Math.round(BLEED * h);
|
|
324
|
+
const len = palette.length;
|
|
325
|
+
// Reduced motion paints one still frame part-way into a step.
|
|
326
|
+
const t = reduced.current ? CYCLE * 0.45 + columnsC * FALL_STAGGER : elapsed;
|
|
327
|
+
|
|
328
|
+
for (let i = 0; i < columnsC; i++) {
|
|
329
|
+
const x0 = edges[i]!;
|
|
330
|
+
const cw = edges[i + 1]! - x0;
|
|
331
|
+
if (cw <= 0) continue;
|
|
332
|
+
const band = columnBand(variant, i, easeEdge(cov.top[i] ?? 0), easeEdge(cov.bot[i] ?? 0));
|
|
333
|
+
const bandTop = Math.round(band.y0 * h);
|
|
334
|
+
const bandBot = Math.round(band.y1 * h);
|
|
335
|
+
if (bandBot <= bandTop) continue;
|
|
336
|
+
|
|
337
|
+
c.fillStyle = backing;
|
|
338
|
+
c.fillRect(x0, bandTop, cw, bandBot - bandTop);
|
|
339
|
+
|
|
340
|
+
const flow = columnFlow(t, i, columnsC, speedC);
|
|
341
|
+
for (const tile of columnTiles(flow, tilesC, h, bleed)) {
|
|
342
|
+
// Mirrored stacks rise instead of fall; the overhang still laps the next tile.
|
|
343
|
+
const top = band.mirror ? h - tile.bot : tile.top;
|
|
344
|
+
const bot = band.mirror ? h - tile.top : tile.bot;
|
|
345
|
+
const y0 = Math.max(bandTop, top);
|
|
346
|
+
const y1 = Math.min(bandBot, bot);
|
|
347
|
+
if (y1 <= y0) continue;
|
|
348
|
+
c.fillStyle =
|
|
349
|
+
sourceTable?.[i]?.[sourceSlot(tile.id, i, tilesC)] ??
|
|
350
|
+
palette[stripIndex(strip, tile.id, i) % len]!;
|
|
351
|
+
c.fillRect(x0, y0, cw, y1 - y0);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// --- mount ---------------------------------------------------------------------
|
|
357
|
+
|
|
358
|
+
onMount(() => {
|
|
359
|
+
const host = ref;
|
|
360
|
+
const canvas = canvasEl;
|
|
361
|
+
if (!host || !canvas) return;
|
|
362
|
+
ctx = canvas.getContext("2d");
|
|
363
|
+
if (!ctx) return;
|
|
364
|
+
measure();
|
|
365
|
+
|
|
366
|
+
let ro: ResizeObserver | undefined;
|
|
367
|
+
if (typeof ResizeObserver !== "undefined") {
|
|
368
|
+
ro = new ResizeObserver(() => measure());
|
|
369
|
+
ro.observe(host);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// A hidden tab stops rAF: finish the phase so an awaiting navigation never hangs.
|
|
373
|
+
const onVisibility = () => {
|
|
374
|
+
if (!document.hidden) {
|
|
375
|
+
if (phase !== "idle") requestFrame();
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
stopLoop();
|
|
379
|
+
if (phase === "covering") finish("cover");
|
|
380
|
+
else if (phase === "revealing") finish("reveal");
|
|
381
|
+
};
|
|
382
|
+
document.addEventListener("visibilitychange", onVisibility);
|
|
383
|
+
|
|
384
|
+
return () => {
|
|
385
|
+
stopLoop();
|
|
386
|
+
ro?.disconnect();
|
|
387
|
+
document.removeEventListener("visibilitychange", onVisibility);
|
|
388
|
+
settlePending();
|
|
389
|
+
ctx = null;
|
|
390
|
+
};
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
// --- reactivity ------------------------------------------------------------------
|
|
394
|
+
|
|
395
|
+
// Load the picture source; a stale load never overwrites a newer one.
|
|
396
|
+
$effect(() => {
|
|
397
|
+
const src = source;
|
|
398
|
+
void tilesC;
|
|
399
|
+
untrack(() => {
|
|
400
|
+
sourceImage = null;
|
|
401
|
+
sourceTable = null;
|
|
402
|
+
});
|
|
403
|
+
if (!src || typeof window === "undefined") return;
|
|
404
|
+
|
|
405
|
+
let cancelled = false;
|
|
406
|
+
const ready = (img: CanvasImageSource, iw: number, ih: number) => {
|
|
407
|
+
if (cancelled || iw <= 0 || ih <= 0) return;
|
|
408
|
+
sourceImage = img;
|
|
409
|
+
sourceSize = { w: iw, h: ih };
|
|
410
|
+
untrack(() => {
|
|
411
|
+
buildSourceTable();
|
|
412
|
+
if (phase !== "idle") draw();
|
|
413
|
+
});
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
if (typeof src !== "string") {
|
|
417
|
+
if (src instanceof HTMLCanvasElement) ready(src, src.width, src.height);
|
|
418
|
+
else if (src.complete && src.naturalWidth) ready(src, src.naturalWidth, src.naturalHeight);
|
|
419
|
+
else
|
|
420
|
+
src.addEventListener("load", () => ready(src, src.naturalWidth, src.naturalHeight), {
|
|
421
|
+
once: true,
|
|
422
|
+
});
|
|
423
|
+
return () => {
|
|
424
|
+
cancelled = true;
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const img = new Image();
|
|
429
|
+
img.decoding = "async";
|
|
430
|
+
if (!src.startsWith("data:")) img.crossOrigin = "anonymous";
|
|
431
|
+
img.onload = () => ready(img, img.naturalWidth || 300, img.naturalHeight || 150);
|
|
432
|
+
img.src = src;
|
|
433
|
+
return () => {
|
|
434
|
+
cancelled = true;
|
|
435
|
+
img.onload = null;
|
|
436
|
+
};
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
// Grid-shaping props: re-measure (column count may change the coverage arrays).
|
|
440
|
+
let gridInit = false;
|
|
441
|
+
$effect(() => {
|
|
442
|
+
void columnsC;
|
|
443
|
+
void powerC;
|
|
444
|
+
if (!gridInit) {
|
|
445
|
+
gridInit = true;
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
untrack(measure);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
// Reduced motion flips mid-phase: settle instantly rather than animate.
|
|
452
|
+
$effect(() => {
|
|
453
|
+
if (!reduced.current) return;
|
|
454
|
+
untrack(() => {
|
|
455
|
+
if (phase === "covering") finish("cover");
|
|
456
|
+
else if (phase === "revealing") finish("reveal");
|
|
457
|
+
else if (phase === "covered") {
|
|
458
|
+
stopLoop();
|
|
459
|
+
draw();
|
|
460
|
+
}
|
|
461
|
+
});
|
|
462
|
+
});
|
|
463
|
+
</script>
|
|
464
|
+
|
|
465
|
+
<div
|
|
466
|
+
bind:this={ref}
|
|
467
|
+
class={cn(
|
|
468
|
+
"datamosh-transition inset-0",
|
|
469
|
+
contained ? "absolute" : "fixed",
|
|
470
|
+
phase === "idle" ? "pointer-events-none invisible" : "pointer-events-auto",
|
|
471
|
+
className
|
|
472
|
+
)}
|
|
473
|
+
style:z-index={zIndex}
|
|
474
|
+
data-state={phase}
|
|
475
|
+
aria-hidden="true"
|
|
476
|
+
{...restProps}
|
|
477
|
+
>
|
|
478
|
+
<canvas bind:this={canvasEl} class="block h-full w-full"></canvas>
|
|
479
|
+
</div>
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { HTMLAttributes } from "svelte/elements";
|
|
2
|
+
import type { DatamoshEffect, DatamoshPaletteName, DatamoshSweep } from "./datamosh-transition-core.js";
|
|
3
|
+
export type DatamoshTransitionPhase = "idle" | "covering" | "covered" | "revealing";
|
|
4
|
+
/**
|
|
5
|
+
* DatamoshTransition — a page-transition overlay that looks like a corrupted
|
|
6
|
+
* video decode.
|
|
7
|
+
*
|
|
8
|
+
* A fixed grid of columns (narrow on the left, wide on the right) fills with
|
|
9
|
+
* flat blocks of saturated colour. Inside each column a stack of tiles falls
|
|
10
|
+
* along one sigmoid: slivers at the edges, one tile snapping open through
|
|
11
|
+
* the middle. `cover()` drops the columns over the page like a curtain,
|
|
12
|
+
* right to left; `reveal()` lets them fall away. Nothing moves sideways.
|
|
13
|
+
*
|
|
14
|
+
* Mount once (root layout) and drive it from your router's navigation hook.
|
|
15
|
+
*/
|
|
16
|
+
interface BaseProps {
|
|
17
|
+
/** Additional classes on the overlay */
|
|
18
|
+
class?: string;
|
|
19
|
+
/** Bindable overlay element */
|
|
20
|
+
ref?: HTMLDivElement | null;
|
|
21
|
+
/**
|
|
22
|
+
* Palette name or colour list (hex or rgb()). The lightest and darkest
|
|
23
|
+
* colours recur most often.
|
|
24
|
+
*/
|
|
25
|
+
colors?: string[] | DatamoshPaletteName;
|
|
26
|
+
/** Shape of the covering band: curtain, rise, split or interlace */
|
|
27
|
+
variant?: DatamoshEffect;
|
|
28
|
+
/** Order in which the columns move */
|
|
29
|
+
sweep?: DatamoshSweep;
|
|
30
|
+
/**
|
|
31
|
+
* Picture to decode: tiles take their colours from it instead of the
|
|
32
|
+
* palette. A URL (same-origin or CORS-enabled), an image or a canvas.
|
|
33
|
+
*/
|
|
34
|
+
source?: string | HTMLImageElement | HTMLCanvasElement;
|
|
35
|
+
/** Seed for the colour order — same seed, same strip */
|
|
36
|
+
seed?: number;
|
|
37
|
+
/** Number of columns */
|
|
38
|
+
columns?: number;
|
|
39
|
+
/** Column edge exponent: 1 = uniform grid, higher = wider columns to the right */
|
|
40
|
+
power?: number;
|
|
41
|
+
/** Tiles per column stack (forced odd) */
|
|
42
|
+
tiles?: number;
|
|
43
|
+
/** Time for one column to cover, ms */
|
|
44
|
+
coverDuration?: number;
|
|
45
|
+
/** Time for one column to clear, ms */
|
|
46
|
+
revealDuration?: number;
|
|
47
|
+
/** Delay between neighbouring columns, ms (rightmost first) */
|
|
48
|
+
stagger?: number;
|
|
49
|
+
/** Speed of the falling tiles, 1 = default */
|
|
50
|
+
speed?: number;
|
|
51
|
+
/** Fill the positioned parent instead of the viewport */
|
|
52
|
+
contained?: boolean;
|
|
53
|
+
/** Stacking order of the overlay */
|
|
54
|
+
zIndex?: number;
|
|
55
|
+
/** Current phase (read-only, bindable) */
|
|
56
|
+
phase?: DatamoshTransitionPhase;
|
|
57
|
+
/** Called once the page is fully covered */
|
|
58
|
+
oncovered?: () => void;
|
|
59
|
+
/** Called once the page is fully revealed */
|
|
60
|
+
onrevealed?: () => void;
|
|
61
|
+
}
|
|
62
|
+
export type DatamoshTransitionProps = BaseProps & Omit<HTMLAttributes<HTMLDivElement>, keyof BaseProps>;
|
|
63
|
+
declare const DatamoshTransition: import("svelte").Component<DatamoshTransitionProps, {
|
|
64
|
+
cover: () => Promise<void>;
|
|
65
|
+
reveal: () => Promise<void>;
|
|
66
|
+
play: () => Promise<void>;
|
|
67
|
+
}, "ref" | "phase">;
|
|
68
|
+
type DatamoshTransition = ReturnType<typeof DatamoshTransition>;
|
|
69
|
+
export default DatamoshTransition;
|