shaderfont 0.0.0-stage → 0.5.3
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/LICENSE +21 -0
- package/README.ko.md +47 -0
- package/README.md +46 -2
- package/package.json +21 -4
- package/src/browser.d.ts +5 -0
- package/src/core.js +316 -0
- package/src/index.d.ts +6 -0
- package/src/index.js +26 -0
- package/src/mask-renderer.js +87 -0
- package/src/material-core.js +161 -0
- package/src/material-renderer.js +52 -0
- package/src/options.d.ts +28 -0
- package/src/options.js +75 -0
- package/src/paint-layout.js +24 -0
- package/src/presets.js +19 -0
- package/src/reveal-core.js +69 -0
- package/src/reveal-renderer.js +64 -0
- package/src/selection-gesture.js +64 -0
- package/src/selection-indicator.js +73 -0
- package/src/selection-range.js +17 -0
- package/src/shader-font-element.js +1329 -0
- package/src/shader-font.js +6 -0
- package/src/webgl-surface.js +103 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { clamp } from "./core.js";
|
|
2
|
+
import { SHADER_PRESETS } from "./presets.js";
|
|
3
|
+
|
|
4
|
+
export const MATERIAL_NOISE_SIZE = 32;
|
|
5
|
+
export const MAX_MATERIAL_PIXELS = 65_536;
|
|
6
|
+
const ids = new Map(SHADER_PRESETS.map(preset => [preset.name, preset.id]));
|
|
7
|
+
const fract = value => value - Math.floor(value);
|
|
8
|
+
function smooth(a, b, value) {
|
|
9
|
+
const t = clamp((value - a) / (b - a), 0, 1);
|
|
10
|
+
return t * t * (3 - 2 * t);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function materialDimensions(width, height, resolution = 0.5) {
|
|
14
|
+
const w = Math.max(1, width), h = Math.max(1, height);
|
|
15
|
+
const ratio = Math.min(resolution, 512 / w, 256 / h, Math.sqrt(MAX_MATERIAL_PIXELS / (w * h)));
|
|
16
|
+
return { width: Math.max(1, Math.floor(w * ratio)), height: Math.max(1, Math.floor(h * ratio)) };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function createMaterialNoise() {
|
|
20
|
+
const noise = new Uint8Array(MATERIAL_NOISE_SIZE ** 2);
|
|
21
|
+
for (let i = 0; i < noise.length; i++) {
|
|
22
|
+
let n = Math.imul(i ^ 0x68bc21eb, 0x27d4eb2d);
|
|
23
|
+
n ^= n >>> 15;
|
|
24
|
+
n = Math.imul(n, 0x85ebca6b);
|
|
25
|
+
n ^= n >>> 13;
|
|
26
|
+
noise[i] = n >>> 24;
|
|
27
|
+
}
|
|
28
|
+
return noise;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function noiseAt(data, u, v) {
|
|
32
|
+
const size = MATERIAL_NOISE_SIZE;
|
|
33
|
+
const x = fract(u) * size, y = fract(v) * size;
|
|
34
|
+
const ix = Math.floor(x), iy = Math.floor(y);
|
|
35
|
+
const fx = smooth(0, 1, fract(x)), fy = smooth(0, 1, fract(y));
|
|
36
|
+
const a = data[iy * size + ix] / 255;
|
|
37
|
+
const b = data[iy * size + (ix + 1) % size] / 255;
|
|
38
|
+
const c = data[((iy + 1) % size) * size + ix] / 255;
|
|
39
|
+
const d = data[((iy + 1) % size) * size + (ix + 1) % size] / 255;
|
|
40
|
+
return (a + (b - a) * fx) * (1 - fy) + (c + (d - c) * fx) * fy;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function signalAt(id, u, v, time, scale, aspect, noise) {
|
|
44
|
+
const x = u * aspect * 3 / scale, y = v * 3 / scale;
|
|
45
|
+
if (id === 2) {
|
|
46
|
+
const n = noiseAt(noise, x * 0.08 + time * 0.012, y * 0.08 - time * 0.023);
|
|
47
|
+
return n * 0.7 + noiseAt(noise, x * 0.19 + n * 0.04, y * 0.19 + time * 0.035) * 0.3;
|
|
48
|
+
}
|
|
49
|
+
if (id === 3) return 0.5 + (Math.sin(x * 2.2 + time) + Math.sin(y * 3.4 - time * 0.7)
|
|
50
|
+
+ Math.sin((x + y) * 1.8 + Math.cos(time * 0.6) * 2)) / 6;
|
|
51
|
+
if (id === 4) {
|
|
52
|
+
const curtain = Math.sin(x * 2.8 + time * 0.35 + noiseAt(noise, x * 0.04, time * 0.01) * 3);
|
|
53
|
+
return 0.15 + Math.exp(-Math.abs(y - 1.5 - curtain * 0.7) * 2.1) * 0.7
|
|
54
|
+
+ noiseAt(noise, x * 0.13 + time * 0.01, y * 0.06) * 0.15;
|
|
55
|
+
}
|
|
56
|
+
if (id === 5) {
|
|
57
|
+
const fx = fract(x * 2), fy = fract(y * 2);
|
|
58
|
+
const edge = Math.min(fx, 1 - fx, fy, 1 - fy);
|
|
59
|
+
const grid = 1 - smooth(0.01, 0.07, edge);
|
|
60
|
+
const pulse = 0.5 + 0.5 * Math.sin(x * 3.4 + y * 5.8 - time * 2);
|
|
61
|
+
return 0.28 + grid * 0.42 + pulse * 0.3;
|
|
62
|
+
}
|
|
63
|
+
if (id === 6) {
|
|
64
|
+
const n = noiseAt(noise, x * 0.055, y * 0.08 + time * 0.07);
|
|
65
|
+
const detail = noiseAt(noise, x * 0.12 + n * 0.05, y * 0.16 + time * 0.12);
|
|
66
|
+
return clamp(v * 0.6 + 0.3 + (n * 0.7 + detail * 0.3 - 0.5) * 1.5, 0, 1);
|
|
67
|
+
}
|
|
68
|
+
if (id === 7) return 0.5 + Math.sin(x * 2.1 + Math.sin(y * 2.8 + time) * 0.75 + time) * 0.22
|
|
69
|
+
+ Math.sin(y * 4 - x * 0.6 - time * 0.8) * 0.18;
|
|
70
|
+
throw new RangeError("A procedural shader preset is required");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Mirrors the GLSL color kernel; text clipping remains the browser's job. */
|
|
74
|
+
export function renderMaterialPixels(width, height, settings, output, noise = createMaterialNoise()) {
|
|
75
|
+
const id = ids.get(settings.shader);
|
|
76
|
+
if (!id || id < 2) throw new RangeError("A procedural shader preset is required");
|
|
77
|
+
if (output.length !== width * height * 4) throw new RangeError("Material output size does not match the surface");
|
|
78
|
+
const band = 0.08 + settings.softness * 0.4;
|
|
79
|
+
const time = settings.time * settings.speed;
|
|
80
|
+
for (let y = 0; y < height; y++) {
|
|
81
|
+
for (let x = 0; x < width; x++) {
|
|
82
|
+
const signal = clamp(signalAt(id, (x + 0.5) / width, (y + 0.5) / height,
|
|
83
|
+
time, settings.scale, settings.aspect, noise), 0, 1);
|
|
84
|
+
const ramp = smooth(0.5 - band, 0.5 + band, signal);
|
|
85
|
+
const first = ramp < 0.5 ? 0 : 1, second = first + 1;
|
|
86
|
+
const weight = ramp < 0.5 ? ramp * 2 : ramp * 2 - 1;
|
|
87
|
+
const brightness = 0.3 + 0.7 * signal;
|
|
88
|
+
const index = (y * width + x) * 4;
|
|
89
|
+
for (let c = 0; c < 3; c++) {
|
|
90
|
+
output[index + c] = Math.round((settings.colors[first][c] * (1 - weight)
|
|
91
|
+
+ settings.colors[second][c] * weight) * brightness * 255);
|
|
92
|
+
}
|
|
93
|
+
output[index + 3] = 255;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return output;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export const MATERIAL_FRAGMENT_SHADER = `#version 300 es
|
|
100
|
+
precision highp float;
|
|
101
|
+
precision highp int;
|
|
102
|
+
uniform highp sampler2D u_noise;
|
|
103
|
+
uniform vec2 u_resolution;
|
|
104
|
+
uniform float u_aspect;
|
|
105
|
+
uniform float u_time;
|
|
106
|
+
uniform float u_scale;
|
|
107
|
+
uniform float u_softness;
|
|
108
|
+
uniform int u_preset;
|
|
109
|
+
uniform vec3 u_primary;
|
|
110
|
+
uniform vec3 u_secondary;
|
|
111
|
+
uniform vec3 u_accent;
|
|
112
|
+
out vec4 fragColor;
|
|
113
|
+
float noiseAt(vec2 uv) {
|
|
114
|
+
vec2 p = fract(uv) * 32.0;
|
|
115
|
+
ivec2 cell = ivec2(floor(p));
|
|
116
|
+
vec2 f = fract(p); f = f * f * (3.0 - 2.0 * f);
|
|
117
|
+
float a = texelFetch(u_noise, cell, 0).r;
|
|
118
|
+
float b = texelFetch(u_noise, ivec2((cell.x + 1) % 32, cell.y), 0).r;
|
|
119
|
+
float c = texelFetch(u_noise, ivec2(cell.x, (cell.y + 1) % 32), 0).r;
|
|
120
|
+
float d = texelFetch(u_noise, ivec2((cell.x + 1) % 32, (cell.y + 1) % 32), 0).r;
|
|
121
|
+
return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
|
|
122
|
+
}
|
|
123
|
+
float signalAt(vec2 uv) {
|
|
124
|
+
float x = uv.x * u_aspect * 3.0 / u_scale;
|
|
125
|
+
float y = uv.y * 3.0 / u_scale;
|
|
126
|
+
float t = u_time;
|
|
127
|
+
if (u_preset == 2) {
|
|
128
|
+
float n = noiseAt(vec2(x * .08 + t * .012, y * .08 - t * .023));
|
|
129
|
+
return n * .7 + noiseAt(vec2(x * .19 + n * .04, y * .19 + t * .035)) * .3;
|
|
130
|
+
}
|
|
131
|
+
if (u_preset == 3) return .5 + (sin(x * 2.2 + t) + sin(y * 3.4 - t * .7)
|
|
132
|
+
+ sin((x + y) * 1.8 + cos(t * .6) * 2.0)) / 6.0;
|
|
133
|
+
if (u_preset == 4) {
|
|
134
|
+
float curtain = sin(x * 2.8 + t * .35 + noiseAt(vec2(x * .04, t * .01)) * 3.0);
|
|
135
|
+
return .15 + exp(-abs(y - 1.5 - curtain * .7) * 2.1) * .7
|
|
136
|
+
+ noiseAt(vec2(x * .13 + t * .01, y * .06)) * .15;
|
|
137
|
+
}
|
|
138
|
+
if (u_preset == 5) {
|
|
139
|
+
vec2 cell = fract(vec2(x, y) * 2.0);
|
|
140
|
+
float edge = min(min(cell.x, 1.0-cell.x), min(cell.y, 1.0-cell.y));
|
|
141
|
+
float grid = 1.0 - smoothstep(.01, .07, edge);
|
|
142
|
+
float pulse = .5 + .5 * sin(x * 3.4 + y * 5.8 - t * 2.0);
|
|
143
|
+
return .28 + grid * .42 + pulse * .3;
|
|
144
|
+
}
|
|
145
|
+
if (u_preset == 6) {
|
|
146
|
+
float n = noiseAt(vec2(x * .055, y * .08 + t * .07));
|
|
147
|
+
float detail = noiseAt(vec2(x * .12 + n * .05, y * .16 + t * .12));
|
|
148
|
+
return clamp(uv.y * .6 + .3 + (n * .7 + detail * .3 - .5) * 1.5, 0.0, 1.0);
|
|
149
|
+
}
|
|
150
|
+
return .5 + sin(x * 2.1 + sin(y * 2.8 + t) * .75 + t) * .22
|
|
151
|
+
+ sin(y * 4.0 - x * .6 - t * .8) * .18;
|
|
152
|
+
}
|
|
153
|
+
void main() {
|
|
154
|
+
vec2 uv = vec2(gl_FragCoord.x, u_resolution.y - gl_FragCoord.y) / u_resolution;
|
|
155
|
+
float signal = clamp(signalAt(uv), 0.0, 1.0);
|
|
156
|
+
float band = .08 + u_softness * .4;
|
|
157
|
+
float ramp = smoothstep(.5 - band, .5 + band, signal);
|
|
158
|
+
vec3 color = ramp < .5 ? mix(u_primary, u_secondary, ramp * 2.0)
|
|
159
|
+
: mix(u_secondary, u_accent, ramp * 2.0 - 1.0);
|
|
160
|
+
fragColor = vec4(color * (.3 + .7 * signal), 1.0);
|
|
161
|
+
}`;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { createMaterialNoise, MATERIAL_NOISE_SIZE, MATERIAL_FRAGMENT_SHADER, renderMaterialPixels } from "./material-core.js";
|
|
2
|
+
import { SHADER_PRESETS } from "./presets.js";
|
|
3
|
+
import { createWebGLSurface, exportSurface, rendererError } from "./webgl-surface.js";
|
|
4
|
+
|
|
5
|
+
export function createMaterialRenderer(kind, width, height, onContextLost) {
|
|
6
|
+
const noise = createMaterialNoise();
|
|
7
|
+
if (kind === "canvas2d") {
|
|
8
|
+
const canvas = document.createElement("canvas");
|
|
9
|
+
canvas.width = width; canvas.height = height;
|
|
10
|
+
const context = canvas.getContext("2d", { alpha: false });
|
|
11
|
+
if (!context) throw rendererError("CANVAS2D_UNAVAILABLE", "A Canvas 2D context is unavailable");
|
|
12
|
+
const image = context.createImageData(width, height);
|
|
13
|
+
return {
|
|
14
|
+
kind, canvas,
|
|
15
|
+
render(settings) {
|
|
16
|
+
renderMaterialPixels(width, height, settings, image.data, noise);
|
|
17
|
+
context.putImageData(image, 0, 0);
|
|
18
|
+
return exportSurface(canvas);
|
|
19
|
+
},
|
|
20
|
+
dispose() { canvas.width = canvas.height = 0; }
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
if (kind !== "webgl") throw rendererError("INVALID_RENDERER", `Unknown renderer: ${kind}`);
|
|
24
|
+
const surface = createWebGLSurface(width, height, MATERIAL_FRAGMENT_SHADER, onContextLost);
|
|
25
|
+
const { gl } = surface;
|
|
26
|
+
try {
|
|
27
|
+
surface.texture();
|
|
28
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, gl.R8, MATERIAL_NOISE_SIZE, MATERIAL_NOISE_SIZE, 0, gl.RED, gl.UNSIGNED_BYTE, noise);
|
|
29
|
+
if (gl.getError() !== gl.NO_ERROR) throw rendererError("WEBGL_TEXTURE_FAILED", "Material noise could not be uploaded");
|
|
30
|
+
gl.uniform1i(surface.uniform("u_noise"), 0);
|
|
31
|
+
gl.uniform2f(surface.uniform("u_resolution"), width, height);
|
|
32
|
+
const uniforms = Object.fromEntries(["aspect", "time", "scale", "softness", "preset", "primary", "secondary", "accent"]
|
|
33
|
+
.map(name => [name, surface.uniform(`u_${name}`)]));
|
|
34
|
+
return {
|
|
35
|
+
kind, canvas: surface.canvas, dispose: surface.dispose,
|
|
36
|
+
render(settings) {
|
|
37
|
+
const preset = SHADER_PRESETS.find(preset => preset.name === settings.shader);
|
|
38
|
+
if (!preset || preset.id < 2) throw rendererError("INVALID_MATERIAL", "A procedural material is required");
|
|
39
|
+
gl.uniform1i(uniforms.preset, preset.id);
|
|
40
|
+
gl.uniform1f(uniforms.aspect, settings.aspect);
|
|
41
|
+
gl.uniform1f(uniforms.time, settings.time * settings.speed);
|
|
42
|
+
gl.uniform1f(uniforms.scale, settings.scale);
|
|
43
|
+
gl.uniform1f(uniforms.softness, settings.softness);
|
|
44
|
+
for (const [index, name] of ["primary", "secondary", "accent"].entries()) gl.uniform3fv(uniforms[name], settings.colors[index]);
|
|
45
|
+
return surface.draw();
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
} catch (error) {
|
|
49
|
+
surface.dispose();
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
}
|
package/src/options.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export type ShaderFontOptions = {
|
|
2
|
+
shader: 'none' | 'hologram' | 'noise' | 'plasma' | 'aurora' | 'cyber' | 'flame' | 'waves';
|
|
3
|
+
renderer: 'canvas2d' | 'webgl';
|
|
4
|
+
speed: number;
|
|
5
|
+
scale: number;
|
|
6
|
+
resolution: number;
|
|
7
|
+
softness: number;
|
|
8
|
+
dragEffect: 'none' | 'dissolve' | 'move' | 'shake' | 'elastic';
|
|
9
|
+
revealEffect: 'none' | 'burn';
|
|
10
|
+
revealMs: number;
|
|
11
|
+
revealIntervalMs: number;
|
|
12
|
+
primary: string;
|
|
13
|
+
secondary: string;
|
|
14
|
+
accent: string;
|
|
15
|
+
flow: 'reading' | 'ltr' | 'rtl' | 'random';
|
|
16
|
+
roughness: number;
|
|
17
|
+
dissolveMs: number;
|
|
18
|
+
restoreMs: number;
|
|
19
|
+
};
|
|
20
|
+
export const SHADER_FONT_DEFAULTS: Readonly<ShaderFontOptions>;
|
|
21
|
+
export const SHADER_FONT_PALETTES: ReadonlyArray<Readonly<{
|
|
22
|
+
name: 'light' | 'dark'; label: string; primary: string; secondary: string; accent: string;
|
|
23
|
+
}>>;
|
|
24
|
+
export const SHADER_PRESETS: ReadonlyArray<Readonly<{ name: ShaderFontOptions['shader']; label: string; id: number }>>;
|
|
25
|
+
export function parseShaderFontOptions(input?: unknown):
|
|
26
|
+
| { ok: true; value: ShaderFontOptions }
|
|
27
|
+
| { ok: false; error: string };
|
|
28
|
+
export function shaderFontAttributes(options: ShaderFontOptions): Record<string, string>;
|
package/src/options.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { SHADER_PRESETS } from "./presets.js";
|
|
2
|
+
export { SHADER_PRESETS } from "./presets.js";
|
|
3
|
+
|
|
4
|
+
/** Background choice is explicit; never guess a consumer's surrounding CSS. */
|
|
5
|
+
export const SHADER_FONT_PALETTES = Object.freeze([
|
|
6
|
+
Object.freeze({ name: "light", label: "밝은 배경", primary: "#075985", secondary: "#9f1239", accent: "#5b21b6" }),
|
|
7
|
+
Object.freeze({ name: "dark", label: "어두운 배경", primary: "#69efff", secondary: "#ff946a", accent: "#fff4bd" })
|
|
8
|
+
]);
|
|
9
|
+
|
|
10
|
+
/** Persisted options contain values, never CSS expressions or arbitrary HTML. */
|
|
11
|
+
export const SHADER_FONT_DEFAULTS = Object.freeze({
|
|
12
|
+
shader: "hologram",
|
|
13
|
+
renderer: "canvas2d",
|
|
14
|
+
speed: 1,
|
|
15
|
+
scale: 1,
|
|
16
|
+
resolution: 0.5,
|
|
17
|
+
softness: 0.35,
|
|
18
|
+
dragEffect: "dissolve",
|
|
19
|
+
revealEffect: "none",
|
|
20
|
+
revealMs: 1800,
|
|
21
|
+
revealIntervalMs: 5000,
|
|
22
|
+
primary: SHADER_FONT_PALETTES[0].primary,
|
|
23
|
+
secondary: SHADER_FONT_PALETTES[0].secondary,
|
|
24
|
+
accent: SHADER_FONT_PALETTES[0].accent,
|
|
25
|
+
flow: "reading",
|
|
26
|
+
roughness: 0.26,
|
|
27
|
+
dissolveMs: 2400,
|
|
28
|
+
restoreMs: 1800
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const choices = {
|
|
32
|
+
shader: SHADER_PRESETS.map(preset => preset.name),
|
|
33
|
+
renderer: ["canvas2d", "webgl"],
|
|
34
|
+
dragEffect: ["none", "dissolve", "move", "shake", "elastic"],
|
|
35
|
+
revealEffect: ["none", "burn"],
|
|
36
|
+
flow: ["reading", "ltr", "rtl", "random"]
|
|
37
|
+
};
|
|
38
|
+
const numericLimits = {
|
|
39
|
+
roughness: [0, 0.48], dissolveMs: [200, 10000], restoreMs: [100, 10000],
|
|
40
|
+
speed: [0, 4], scale: [0.25, 4], resolution: [0.25, 1], softness: [0, 1],
|
|
41
|
+
revealMs: [200, 10000], revealIntervalMs: [0, 60000]
|
|
42
|
+
};
|
|
43
|
+
const attributeNames = {
|
|
44
|
+
dragEffect: "drag-effect", dissolveMs: "dissolve-ms", restoreMs: "restore-ms",
|
|
45
|
+
revealEffect: "reveal-effect", revealMs: "reveal-ms", revealIntervalMs: "reveal-interval-ms"
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export function parseShaderFontOptions(input = {}) {
|
|
49
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
50
|
+
return { ok: false, error: "OPTIONS_MUST_BE_AN_OBJECT" };
|
|
51
|
+
}
|
|
52
|
+
const value = {};
|
|
53
|
+
for (const [key, fallback] of Object.entries(SHADER_FONT_DEFAULTS)) {
|
|
54
|
+
const option = input[key] === undefined || input[key] === null ? fallback : input[key];
|
|
55
|
+
if (choices[key] && !choices[key].includes(option)) return { ok: false, error: `INVALID_${key}` };
|
|
56
|
+
if (["primary", "secondary", "accent"].includes(key)
|
|
57
|
+
&& (typeof option !== "string" || !/^#[0-9a-f]{6}$/i.test(option))) {
|
|
58
|
+
return { ok: false, error: `INVALID_${key}` };
|
|
59
|
+
}
|
|
60
|
+
if (numericLimits[key]) {
|
|
61
|
+
const [minimum, maximum] = numericLimits[key];
|
|
62
|
+
if (typeof option !== "number" || !Number.isFinite(option) || option < minimum || option > maximum) {
|
|
63
|
+
return { ok: false, error: `INVALID_${key}` };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
value[key] = option;
|
|
67
|
+
}
|
|
68
|
+
return { ok: true, value };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Input is the value of a successful parseShaderFontOptions result. */
|
|
72
|
+
export function shaderFontAttributes(options) {
|
|
73
|
+
return Object.fromEntries(Object.keys(SHADER_FONT_DEFAULTS)
|
|
74
|
+
.map(key => [attributeNames[key] ?? key, String(options[key])]));
|
|
75
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** CSS slices one unbroken decoration strip across native inline fragments. */
|
|
2
|
+
export function createPaintLayout(bounds, fragments, inline, direction = "ltr") {
|
|
3
|
+
const boxes = (inline ? fragments : [bounds]).filter(rect => rect.width > 0 && rect.height > 0);
|
|
4
|
+
const width = inline ? boxes.reduce((sum, rect) => sum + rect.width, 0) : bounds.width;
|
|
5
|
+
const height = inline ? boxes.reduce((maximum, rect) => Math.max(maximum, rect.height), 0) : bounds.height;
|
|
6
|
+
let offset = 0;
|
|
7
|
+
return { width, height, fragments: boxes.map(rect => {
|
|
8
|
+
const x = inline && direction === "rtl" ? width - offset - rect.width : offset;
|
|
9
|
+
if (inline) offset += rect.width;
|
|
10
|
+
return { left: rect.left, top: rect.top, width: rect.width, height: rect.height, x };
|
|
11
|
+
}) };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Preserve only intersections with actual selection rectangles, never a whole-host guess. */
|
|
15
|
+
export function projectSelectionRects(layout, rects) {
|
|
16
|
+
return rects.flatMap(rect => layout.fragments.map(fragment => {
|
|
17
|
+
const left = Math.max(rect.left, fragment.left);
|
|
18
|
+
const top = Math.max(rect.top, fragment.top);
|
|
19
|
+
const right = Math.min(rect.right, fragment.left + fragment.width);
|
|
20
|
+
const bottom = Math.min(rect.bottom, fragment.top + fragment.height);
|
|
21
|
+
return { x: fragment.x + left - fragment.left, y: top - fragment.top,
|
|
22
|
+
width: right - left, height: bottom - top };
|
|
23
|
+
}).filter(region => region.width > 0.25 && region.height > 0.25));
|
|
24
|
+
}
|
package/src/presets.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export const ShaderPreset = Object.freeze({
|
|
2
|
+
NONE: "none", HOLOGRAM: "hologram", NOISE: "noise", PLASMA: "plasma",
|
|
3
|
+
AURORA: "aurora", CYBER: "cyber", FLAME: "flame", WAVES: "waves"
|
|
4
|
+
});
|
|
5
|
+
|
|
6
|
+
export const SHADER_PRESETS = Object.freeze([
|
|
7
|
+
{ name: "none", label: "없음 (일반 글자)", id: 0 },
|
|
8
|
+
{ name: "hologram", label: "홀로그램", id: 1 },
|
|
9
|
+
{ name: "noise", label: "노이즈", id: 2 },
|
|
10
|
+
{ name: "plasma", label: "플라즈마", id: 3 },
|
|
11
|
+
{ name: "aurora", label: "오로라", id: 4 },
|
|
12
|
+
{ name: "cyber", label: "사이버", id: 5 },
|
|
13
|
+
{ name: "flame", label: "불꽃", id: 6 },
|
|
14
|
+
{ name: "waves", label: "물결", id: 7 }
|
|
15
|
+
].map(Object.freeze));
|
|
16
|
+
|
|
17
|
+
export function isProceduralShader(name) {
|
|
18
|
+
return SHADER_PRESETS.some(preset => preset.name === name && preset.id >= 2);
|
|
19
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { clamp, createErosionField } from "./core.js";
|
|
2
|
+
|
|
3
|
+
/** intervalMs is fully-visible waiting time; zero means a single reveal. */
|
|
4
|
+
export function revealSample(elapsedMs, durationMs, intervalMs) {
|
|
5
|
+
if (![elapsedMs, durationMs, intervalMs].every(Number.isFinite)
|
|
6
|
+
|| elapsedMs < 0 || durationMs <= 0 || intervalMs < 0) {
|
|
7
|
+
throw new RangeError("Reveal timing must be finite and nonnegative with a positive duration");
|
|
8
|
+
}
|
|
9
|
+
if (intervalMs === 0 && elapsedMs >= durationMs) {
|
|
10
|
+
return { phase: "complete", progress: 1, remainingMs: 0 };
|
|
11
|
+
}
|
|
12
|
+
const cycle = durationMs + intervalMs;
|
|
13
|
+
const local = intervalMs === 0 ? elapsedMs : elapsedMs % cycle;
|
|
14
|
+
if (local >= durationMs) return { phase: "holding", progress: 1, remainingMs: cycle - local };
|
|
15
|
+
return { phase: "burning", progress: local / durationMs, remainingMs: durationMs - local };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function createRevealField(width, height, seed) {
|
|
19
|
+
return { width, height, thresholds: createErosionField(width, height, { seed }), feather: 0.012, edge: 0.085 };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function smooth(value) {
|
|
23
|
+
const t = clamp(value, 0, 1);
|
|
24
|
+
return t * t * (3 - 2 * t);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Stable noise reveals more pixels as progress grows; only the frontier glows. */
|
|
28
|
+
export function renderRevealPixels(field, progress, edgeOnly, output) {
|
|
29
|
+
const { thresholds, feather, edge } = field;
|
|
30
|
+
if (!Number.isFinite(progress)) throw new RangeError("Reveal progress must be finite");
|
|
31
|
+
const threshold = clamp(progress, 0, 1) * (1 + 2 * feather) - feather;
|
|
32
|
+
for (let index = 0; index < thresholds.length; index++) {
|
|
33
|
+
const distance = threshold - thresholds[index];
|
|
34
|
+
const coverage = progress <= 0 ? 0 : progress >= 1 ? 1 : smooth((distance + feather) / (2 * feather));
|
|
35
|
+
const heat = 1 - smooth(Math.max(0, distance) / edge);
|
|
36
|
+
const glow = edgeOnly && progress > 0 && progress < 1 ? coverage * heat : 0;
|
|
37
|
+
const offset = index * 4;
|
|
38
|
+
output[offset] = 255;
|
|
39
|
+
output[offset + 1] = edgeOnly ? Math.round(64 + 180 * heat) : 255;
|
|
40
|
+
output[offset + 2] = edgeOnly ? Math.round(12 + 175 * heat * heat) : 255;
|
|
41
|
+
output[offset + 3] = Math.round((edgeOnly ? glow : coverage) * 255);
|
|
42
|
+
}
|
|
43
|
+
return output;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const REVEAL_FRAGMENT_SHADER = `#version 300 es
|
|
47
|
+
precision highp float;
|
|
48
|
+
precision highp int;
|
|
49
|
+
uniform highp sampler2D u_field;
|
|
50
|
+
uniform int u_height;
|
|
51
|
+
uniform float u_progress;
|
|
52
|
+
uniform float u_feather;
|
|
53
|
+
uniform float u_edge;
|
|
54
|
+
uniform bool u_edge_only;
|
|
55
|
+
out vec4 fragColor;
|
|
56
|
+
void main() {
|
|
57
|
+
ivec2 pixel = ivec2(int(gl_FragCoord.x), u_height - 1 - int(gl_FragCoord.y));
|
|
58
|
+
float noise = texelFetch(u_field, pixel, 0).r;
|
|
59
|
+
float threshold = clamp(u_progress, 0.0, 1.0) * (1.0 + 2.0 * u_feather) - u_feather;
|
|
60
|
+
float distance = threshold - noise;
|
|
61
|
+
float coverage = u_progress <= 0.0 ? 0.0 : u_progress >= 1.0 ? 1.0
|
|
62
|
+
: smoothstep(-u_feather, u_feather, distance);
|
|
63
|
+
float heat = 1.0 - smoothstep(0.0, u_edge, max(0.0, distance));
|
|
64
|
+
float glow = u_progress > 0.0 && u_progress < 1.0 ? coverage * heat : 0.0;
|
|
65
|
+
fragColor = u_edge_only ? vec4(1.0, (64.0 + 180.0 * heat) / 255.0,
|
|
66
|
+
(12.0 + 175.0 * heat * heat) / 255.0, glow) : vec4(1.0, 1.0, 1.0, coverage);
|
|
67
|
+
// Match ImageData's byte quantization before exporting translucent PNG edges.
|
|
68
|
+
fragColor = floor(fragColor * 255.0 + 0.5) / 255.0;
|
|
69
|
+
}`;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { REVEAL_FRAGMENT_SHADER, renderRevealPixels } from "./reveal-core.js";
|
|
2
|
+
import { createWebGLSurface, exportSurface, rendererError } from "./webgl-surface.js";
|
|
3
|
+
|
|
4
|
+
function canvasReveal(width, height) {
|
|
5
|
+
const canvas = document.createElement("canvas");
|
|
6
|
+
canvas.width = width; canvas.height = height;
|
|
7
|
+
const context = canvas.getContext("2d", { alpha: true });
|
|
8
|
+
if (!context) throw rendererError("CANVAS2D_UNAVAILABLE", "A Canvas 2D context is unavailable");
|
|
9
|
+
const image = context.createImageData(width, height);
|
|
10
|
+
function paint(field, progress, edgeOnly) {
|
|
11
|
+
renderRevealPixels(field, progress, edgeOnly, image.data);
|
|
12
|
+
context.putImageData(image, 0, 0);
|
|
13
|
+
return exportSurface(canvas);
|
|
14
|
+
}
|
|
15
|
+
return {
|
|
16
|
+
kind: "canvas2d", canvas,
|
|
17
|
+
render(field, progress) {
|
|
18
|
+
return { mask: paint(field, progress, false), edge: paint(field, progress, true) };
|
|
19
|
+
},
|
|
20
|
+
dispose() { canvas.width = canvas.height = 0; }
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function webglReveal(width, height, onContextLost) {
|
|
25
|
+
const surface = createWebGLSurface(width, height, REVEAL_FRAGMENT_SHADER, onContextLost);
|
|
26
|
+
const { gl } = surface;
|
|
27
|
+
let uploadedField = null;
|
|
28
|
+
try {
|
|
29
|
+
surface.texture();
|
|
30
|
+
gl.uniform1i(surface.uniform("u_field"), 0);
|
|
31
|
+
gl.uniform1i(surface.uniform("u_height"), height);
|
|
32
|
+
const progressUniform = surface.uniform("u_progress");
|
|
33
|
+
const featherUniform = surface.uniform("u_feather");
|
|
34
|
+
const edgeUniform = surface.uniform("u_edge");
|
|
35
|
+
const edgeOnlyUniform = surface.uniform("u_edge_only");
|
|
36
|
+
function paint(edgeOnly) {
|
|
37
|
+
gl.uniform1i(edgeOnlyUniform, edgeOnly ? 1 : 0);
|
|
38
|
+
return surface.draw();
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
kind: "webgl", canvas: surface.canvas, dispose: surface.dispose,
|
|
42
|
+
render(field, progress) {
|
|
43
|
+
if (uploadedField !== field) {
|
|
44
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, gl.R32F, width, height, 0, gl.RED, gl.FLOAT, field.thresholds);
|
|
45
|
+
if (gl.getError() !== gl.NO_ERROR) throw rendererError("WEBGL_TEXTURE_FAILED", "The reveal field could not be uploaded");
|
|
46
|
+
uploadedField = field;
|
|
47
|
+
}
|
|
48
|
+
gl.uniform1f(progressUniform, progress);
|
|
49
|
+
gl.uniform1f(featherUniform, field.feather);
|
|
50
|
+
gl.uniform1f(edgeUniform, field.edge);
|
|
51
|
+
return { mask: paint(false), edge: paint(true) };
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
} catch (error) {
|
|
55
|
+
surface.dispose();
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function createRevealRenderer(kind, width, height, onContextLost) {
|
|
61
|
+
if (kind === "webgl") return webglReveal(width, height, onContextLost);
|
|
62
|
+
if (kind === "canvas2d") return canvasReveal(width, height);
|
|
63
|
+
throw rendererError("INVALID_RENDERER", `Unknown reveal renderer: ${kind}`);
|
|
64
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
const documents = new WeakMap();
|
|
2
|
+
const DRAG_THRESHOLD = 5;
|
|
3
|
+
const capture = { capture: true };
|
|
4
|
+
|
|
5
|
+
function selectionBounds(document) {
|
|
6
|
+
const selection = document.getSelection();
|
|
7
|
+
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return null;
|
|
8
|
+
const range = selection.getRangeAt(0);
|
|
9
|
+
return [range.startContainer, range.startOffset, range.endContainer, range.endOffset];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** One capture-phase tracker per document, not one global listener per font. */
|
|
13
|
+
export function observeSelectionDrag(document, onFinished) {
|
|
14
|
+
let tracker = documents.get(document);
|
|
15
|
+
if (!tracker) {
|
|
16
|
+
const listeners = new Set();
|
|
17
|
+
let pointer = null;
|
|
18
|
+
function getDrag() {
|
|
19
|
+
if (!pointer?.moved) return null;
|
|
20
|
+
const current = selectionBounds(document);
|
|
21
|
+
if (!current || pointer.selection?.every((value, index) => value === current[index])) return null;
|
|
22
|
+
return Object.freeze({ pointerId: pointer.id, startX: pointer.x, startY: pointer.y });
|
|
23
|
+
}
|
|
24
|
+
function down(event) {
|
|
25
|
+
if (event.button !== 0 || event.isPrimary === false) return;
|
|
26
|
+
pointer = { id: event.pointerId, x: event.clientX, y: event.clientY, moved: false, selection: selectionBounds(document) };
|
|
27
|
+
}
|
|
28
|
+
function move(event) {
|
|
29
|
+
if (pointer?.id !== event.pointerId) return;
|
|
30
|
+
pointer.moved ||= Math.hypot(event.clientX - pointer.x, event.clientY - pointer.y) >= DRAG_THRESHOLD;
|
|
31
|
+
}
|
|
32
|
+
function end(event) {
|
|
33
|
+
if (pointer?.id !== event.pointerId) return;
|
|
34
|
+
const drag = event.type === "pointerup" ? getDrag() : null;
|
|
35
|
+
pointer = null;
|
|
36
|
+
if (drag) for (const listener of listeners) listener(drag);
|
|
37
|
+
}
|
|
38
|
+
function blur() { pointer = null; }
|
|
39
|
+
const events = [["pointerdown", down], ["pointermove", move], ["pointerup", end], ["pointercancel", end]];
|
|
40
|
+
for (const [name, listener] of events) document.addEventListener(name, listener, capture);
|
|
41
|
+
document.defaultView?.addEventListener("blur", blur);
|
|
42
|
+
tracker = {
|
|
43
|
+
listeners, getDrag,
|
|
44
|
+
dispose() {
|
|
45
|
+
pointer = null;
|
|
46
|
+
for (const [name, listener] of events) document.removeEventListener(name, listener, capture);
|
|
47
|
+
document.defaultView?.removeEventListener("blur", blur);
|
|
48
|
+
documents.delete(document);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
documents.set(document, tracker);
|
|
52
|
+
}
|
|
53
|
+
tracker.listeners.add(onFinished);
|
|
54
|
+
let disposed = false;
|
|
55
|
+
return {
|
|
56
|
+
getDrag: tracker.getDrag,
|
|
57
|
+
dispose() {
|
|
58
|
+
if (disposed) return;
|
|
59
|
+
disposed = true;
|
|
60
|
+
tracker.listeners.delete(onFinished);
|
|
61
|
+
if (tracker.listeners.size === 0) tracker.dispose();
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/** Native Range owns selection. This empty, non-interactive layer is never masked with glyphs. */
|
|
2
|
+
export function createSelectionIndicator(owner, readRects, onError) {
|
|
3
|
+
let layer = null;
|
|
4
|
+
let frame = 0;
|
|
5
|
+
let previous = "";
|
|
6
|
+
const scrollOptions = { capture: true, passive: true };
|
|
7
|
+
|
|
8
|
+
function clear() {
|
|
9
|
+
if (frame) cancelAnimationFrame(frame);
|
|
10
|
+
frame = 0;
|
|
11
|
+
layer?.remove();
|
|
12
|
+
layer = null;
|
|
13
|
+
previous = "";
|
|
14
|
+
window.removeEventListener("scroll", schedule, scrollOptions);
|
|
15
|
+
window.removeEventListener("resize", schedule);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function schedule() {
|
|
19
|
+
if (frame) return;
|
|
20
|
+
frame = requestAnimationFrame(() => {
|
|
21
|
+
frame = 0;
|
|
22
|
+
try { update(readRects()); }
|
|
23
|
+
catch (error) { clear(); onError(error); }
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function update(rects) {
|
|
28
|
+
const clip = { left: 0, top: 0, right: window.innerWidth, bottom: window.innerHeight };
|
|
29
|
+
let level = 1;
|
|
30
|
+
for (let parent = owner.parentElement; parent; parent = parent.parentElement) {
|
|
31
|
+
const computed = getComputedStyle(parent);
|
|
32
|
+
const z = Number.parseInt(computed.zIndex, 10);
|
|
33
|
+
if (Number.isFinite(z)) level = Math.max(level, z + 1);
|
|
34
|
+
if (![computed.overflowX, computed.overflowY].some(value => /^(hidden|clip|auto|scroll)$/.test(value))) continue;
|
|
35
|
+
const bounds = parent.getBoundingClientRect();
|
|
36
|
+
if (/^(hidden|clip|auto|scroll)$/.test(computed.overflowX)) {
|
|
37
|
+
clip.left = Math.max(clip.left, bounds.left);
|
|
38
|
+
clip.right = Math.min(clip.right, bounds.right);
|
|
39
|
+
}
|
|
40
|
+
if (/^(hidden|clip|auto|scroll)$/.test(computed.overflowY)) {
|
|
41
|
+
clip.top = Math.max(clip.top, bounds.top);
|
|
42
|
+
clip.bottom = Math.min(clip.bottom, bounds.bottom);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const visible = (rects ?? []).flatMap(rect => {
|
|
46
|
+
if (![rect.left, rect.top, rect.right, rect.bottom].every(Number.isFinite)) return [];
|
|
47
|
+
const left = Math.max(clip.left, rect.left), top = Math.max(clip.top, rect.top);
|
|
48
|
+
const right = Math.min(clip.right, rect.right), bottom = Math.min(clip.bottom, rect.bottom);
|
|
49
|
+
return right > left && bottom > top ? [{ left, top, width: right - left, height: bottom - top }] : [];
|
|
50
|
+
});
|
|
51
|
+
if (visible.length === 0) { clear(); return; }
|
|
52
|
+
const signature = JSON.stringify({ visible, level });
|
|
53
|
+
if (signature === previous) return;
|
|
54
|
+
previous = signature;
|
|
55
|
+
if (!layer) {
|
|
56
|
+
layer = document.createElement("div");
|
|
57
|
+
layer.className = "shaderfont-selection-indicator";
|
|
58
|
+
layer.setAttribute("aria-hidden", "true");
|
|
59
|
+
document.body.append(layer);
|
|
60
|
+
window.addEventListener("scroll", schedule, scrollOptions);
|
|
61
|
+
window.addEventListener("resize", schedule, { passive: true });
|
|
62
|
+
}
|
|
63
|
+
layer.style.zIndex = String(level);
|
|
64
|
+
layer.replaceChildren(...visible.map(rect => {
|
|
65
|
+
const fragment = document.createElement("span");
|
|
66
|
+
fragment.dataset.selectionRect = "true";
|
|
67
|
+
for (const [name, value] of Object.entries(rect)) fragment.style[name] = `${value}px`;
|
|
68
|
+
return fragment;
|
|
69
|
+
}));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return { update, clear };
|
|
73
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Selection stays native; each font reads only the intersecting text Range. */
|
|
2
|
+
export function selectionWithin(element, document) {
|
|
3
|
+
const selection = document.getSelection();
|
|
4
|
+
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return null;
|
|
5
|
+
if (!selection.anchorNode || !selection.focusNode) return null;
|
|
6
|
+
const source = selection.getRangeAt(0);
|
|
7
|
+
if (element.contains(selection.anchorNode) && element.contains(selection.focusNode)) {
|
|
8
|
+
return { selection, range: source };
|
|
9
|
+
}
|
|
10
|
+
if (!source.intersectsNode(element)) return null;
|
|
11
|
+
const local = source.cloneRange();
|
|
12
|
+
local.selectNodeContents(element);
|
|
13
|
+
if (source.compareBoundaryPoints(0, local) > 0) local.setStart(source.startContainer, source.startOffset);
|
|
14
|
+
if (source.compareBoundaryPoints(2, local) < 0) local.setEnd(source.endContainer, source.endOffset);
|
|
15
|
+
if (local.collapsed || local.toString().length === 0) return null;
|
|
16
|
+
return { selection, range: local };
|
|
17
|
+
}
|