shaderfont 0.0.0-stage → 0.5.4
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 +63 -0
- package/README.md +62 -2
- package/package.json +23 -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 +25 -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 +1379 -0
- package/src/shader-font.js +6 -0
- package/src/webgl-surface.js +103 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dongwoo Jeong
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.ko.md
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Shaderfont
|
|
2
|
+
|
|
3
|
+
[English](./README.md)
|
|
4
|
+
|
|
5
|
+
일반 웹 텍스트에 움직이는 재질과 드래그 효과를 붙이는 Web Component입니다. 기존 폰트를 그대로 쓰며, 텍스트를 선택·복사할 수 있습니다. 직접 편집하려면 `contenteditable="true"`를 넣습니다.
|
|
6
|
+
|
|
7
|
+
## 사용
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install shaderfont
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
브라우저에서 등록한 뒤 HTML에 넣습니다.
|
|
14
|
+
|
|
15
|
+
```js
|
|
16
|
+
import { registerShaderFont } from "shaderfont";
|
|
17
|
+
|
|
18
|
+
const result = await registerShaderFont();
|
|
19
|
+
if (!result.ok) throw new Error(result.error);
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
```html
|
|
23
|
+
<shader-font shader="hologram" drag-effect="dissolve">
|
|
24
|
+
안녕하세요
|
|
25
|
+
</shader-font>
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`shader`에는 `noise`, `plasma`, `aurora`, `cyber`, `flame`, `waves` 같은 재질을 고릅니다. 디졸브는 선택한 글자만 서서히 가리고 선택을 해제하면 복구하며, 나머지 드래그 효과(`move`, `shake`, `elastic`)는 요소 전체를 움직입니다. `reveal-effect="burn"`으로 시간에 따른 등장도 넣을 수 있습니다. 각 효과는 `none`으로 끄고, 색상과 시간은 따로 조절합니다.
|
|
29
|
+
|
|
30
|
+
## 동작 원리
|
|
31
|
+
|
|
32
|
+
브라우저가 글자를 배치하고, CSS의 `background-clip: text`가 움직이는 무늬를 글자 안에만 보여 줍니다. 디졸브는 브라우저의 선택 범위(`Selection/Range`)를 읽어 해당 부분에 노이즈 마스크를 씌웁니다. 기본 렌더러는 Canvas 2D이며 `renderer="webgl"`을 고르면 절차적 재질과 마스크에 WebGL 2를 사용합니다. 홀로그램과 이동 효과는 CSS로 처리하며, 폰트 파일을 만들거나 METAFONT를 실행하지는 않습니다.
|
|
33
|
+
|
|
34
|
+
루트 import는 SSR에서도 가능합니다. `registerShaderFont()`는 브라우저에서만 호출하고, 즉시 등록하는 `shaderfont/browser`도 브라우저에서만 사용합니다.
|
|
35
|
+
|
|
36
|
+
### CSP와 색상
|
|
37
|
+
|
|
38
|
+
인라인 스타일을 제한하는 사이트에서는 `<shader-font>`를 등록할 때 서버가 발급한 nonce를 전달합니다.
|
|
39
|
+
|
|
40
|
+
```js
|
|
41
|
+
const result = await registerShaderFont({ styleNonce: trustedNonce });
|
|
42
|
+
if (!result.ok) throw new Error(result.error);
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
패키지는 스타일시트가 실제로 적용됐는지 확인합니다. 차단되면 `STYLES_BLOCKED`를 반환하며, 올바른 nonce로 다시 호출할 수 있습니다. DOM에서 임의의 nonce를 읽거나 CSP를 `unsafe-inline`으로 완화할 필요는 없습니다. 즉시 등록하는 `shaderfont/browser`에는 nonce 인자가 없으므로 CSP가 엄격한 페이지에서는 루트 등록 API를 사용합니다. 이미지도 제한한다면 절차적 재질과 디졸브·등장 마스크를 위해 `img-src`에 `data:`를 허용해야 합니다.
|
|
46
|
+
|
|
47
|
+
색상 우선순위는 `primary`·`secondary`·`accent` 속성, 요소나 상위에서 지정한 `--sf-primary`·`--sf-secondary`·`--sf-accent` CSS 변수, 기본 밝은 팔레트 순서입니다. 페이지 테마가 바뀌어도 저장된 색상은 자동으로 덮지 않습니다. 어두운 배경에서는 `SHADER_FONT_PALETTES.find(p => p.name === "dark")`의 세 색을 요소 속성에 지정하고, 밝은 배경으로 돌아올 때 다시 밝은 팔레트를 지정하면 됩니다. 전역 흰색 변수가 글자에 직접 지정한 색을 덮지도 않습니다.
|
|
48
|
+
|
|
49
|
+
## 데모
|
|
50
|
+
|
|
51
|
+
저장소를 복제한 뒤 실행합니다.
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
npm run demo
|
|
55
|
+
npm run check
|
|
56
|
+
npm run check:browser
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
브라우저 검사는 로컬 Chrome에 엄격한 CSP를 적용해 nonce 실패·재시도, 밝고 어두운 색상, 홀로그램 애니메이션, 절차적 재질, 선택 디졸브, 타오르는 등장 효과와 움직임 줄이기를 확인합니다.
|
|
60
|
+
|
|
61
|
+
[영어 데모](http://127.0.0.1:8766/demo/index.html)와 [한글 데모](http://127.0.0.1:8766/demo/index.ko.html)는 같은 설정과 편집 가능한 텍스트를 제공합니다.
|
|
62
|
+
|
|
63
|
+
[MIT 라이선스](./LICENSE).
|
package/README.md
CHANGED
|
@@ -1,3 +1,63 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Shaderfont
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[한국어](./README.ko.md)
|
|
4
|
+
|
|
5
|
+
A Web Component that adds animated materials and drag effects to ordinary DOM text. It uses your existing font and keeps text selectable and copyable. Add `contenteditable="true"` to edit it in place.
|
|
6
|
+
|
|
7
|
+
## Use it
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install shaderfont
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Register it in the browser, then use the element in your HTML:
|
|
14
|
+
|
|
15
|
+
```js
|
|
16
|
+
import { registerShaderFont } from "shaderfont";
|
|
17
|
+
|
|
18
|
+
const result = await registerShaderFont();
|
|
19
|
+
if (!result.ok) throw new Error(result.error);
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
```html
|
|
23
|
+
<shader-font shader="hologram" drag-effect="dissolve">
|
|
24
|
+
Hello
|
|
25
|
+
</shader-font>
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Choose a `shader` such as `noise`, `plasma`, `aurora`, `cyber`, `flame` or `waves`. Dissolve fades only the selected letters and restores them when you clear the selection. The other drag effects (`move`, `shake`, `elastic`) move the whole element. Set `reveal-effect="burn"` for a timed reveal. Each effect can be turned off with `none`; colors and timing are separate options.
|
|
29
|
+
|
|
30
|
+
## How it works
|
|
31
|
+
|
|
32
|
+
The browser lays out the text. CSS `background-clip: text` clips animated patterns to the letters, while dissolve reads the native `Selection/Range` and masks the selected part with noise. Canvas 2D is the default renderer; `renderer="webgl"` uses WebGL 2 for procedural materials and masks. Hologram and movement effects use CSS. The component does not generate font files or run METAFONT.
|
|
33
|
+
|
|
34
|
+
The root import is safe for SSR. Call `registerShaderFont()` only in the browser; `shaderfont/browser` registers immediately and is browser-only.
|
|
35
|
+
|
|
36
|
+
### CSP and colors
|
|
37
|
+
|
|
38
|
+
On a site that disallows inline styles, pass the nonce supplied by your server **when registering** `<shader-font>`:
|
|
39
|
+
|
|
40
|
+
```js
|
|
41
|
+
const result = await registerShaderFont({ styleNonce: trustedNonce });
|
|
42
|
+
if (!result.ok) throw new Error(result.error);
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The package checks that its stylesheet actually loaded. A blocked style returns `STYLES_BLOCKED`; a later call with the right nonce can retry. Do not read an arbitrary nonce from the DOM or relax CSP with `unsafe-inline`. The immediate `shaderfont/browser` entry has no nonce parameter, so use the root registration API on strict-CSP pages. If your policy also restricts images, allow `data:` in `img-src` for procedural materials and dissolve/reveal masks.
|
|
46
|
+
|
|
47
|
+
Colors follow this order: `primary`/`secondary`/`accent` attributes, then element or inherited `--sf-primary`/`--sf-secondary`/`--sf-accent` CSS variables, then the light palette. The package does not silently replace stored colors when the page changes theme. For a dark background, opt into `SHADER_FONT_PALETTES.find(p => p.name === "dark")` and set its three colors on the element; switch back to the light palette when needed. This also keeps an unrelated global white CSS variable from covering authored colors.
|
|
48
|
+
|
|
49
|
+
## Try the demo
|
|
50
|
+
|
|
51
|
+
Clone this repository and run:
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
npm run demo
|
|
55
|
+
npm run check
|
|
56
|
+
npm run check:browser
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The browser check uses local Chrome and sends a strict CSP header. It covers nonce rejection/retry, light and dark colors, hologram animation, procedural material, selection dissolve, burn reveal and reduced motion.
|
|
60
|
+
|
|
61
|
+
Open the [English demo](http://127.0.0.1:8766/demo/index.html) or [Korean demo](http://127.0.0.1:8766/demo/index.ko.html). Both have the same controls and editable text.
|
|
62
|
+
|
|
63
|
+
[MIT license](./LICENSE).
|
package/package.json
CHANGED
|
@@ -1,6 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shaderfont",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.5.4",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Animated materials and selection effects on DOM text, with Canvas 2D and WebGL renderers.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": { "type": "git", "url": "git+https://github.com/srtdog64/shaderfont.git" },
|
|
8
|
+
"homepage": "https://github.com/srtdog64/shaderfont#readme",
|
|
9
|
+
"bugs": { "url": "https://github.com/srtdog64/shaderfont/issues" },
|
|
10
|
+
"publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" },
|
|
11
|
+
"exports": {
|
|
12
|
+
".": { "types": "./src/index.d.ts", "import": "./src/index.js", "default": "./src/index.js" },
|
|
13
|
+
"./options": { "types": "./src/options.d.ts", "import": "./src/options.js", "default": "./src/options.js" },
|
|
14
|
+
"./browser": { "types": "./src/browser.d.ts", "import": "./src/shader-font.js", "default": "./src/shader-font.js" }
|
|
15
|
+
},
|
|
16
|
+
"files": ["src", "README.md", "README.ko.md", "LICENSE"],
|
|
17
|
+
"sideEffects": ["./src/shader-font.js"],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"check": "node --check src/core.js && node --check src/index.js && node --check src/options.js && node --check src/presets.js && node --check src/paint-layout.js && node --check src/selection-range.js && node --check src/selection-gesture.js && node --check src/selection-indicator.js && node --check src/webgl-surface.js && node --check src/material-core.js && node --check src/material-renderer.js && node --check src/mask-renderer.js && node --check src/reveal-core.js && node --check src/reveal-renderer.js && node --check src/shader-font-element.js && node --check demo/demo.js && node --check demo/copy.js && node --test",
|
|
20
|
+
"check:browser": "node tools/csp-browser-check.mjs",
|
|
21
|
+
"demo": "node tools/serve.mjs",
|
|
22
|
+
"prepublishOnly": "npm run check"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": { "playwright": "1.62.1" }
|
|
25
|
+
}
|
package/src/browser.d.ts
ADDED
package/src/core.js
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import { ShaderPreset } from "./presets.js";
|
|
2
|
+
export { ShaderPreset } from "./presets.js";
|
|
3
|
+
|
|
4
|
+
const UINT32_MAX = 0xffffffff;
|
|
5
|
+
|
|
6
|
+
export const GestureTag = Object.freeze({
|
|
7
|
+
IDLE: "idle",
|
|
8
|
+
PRESSED: "pressed",
|
|
9
|
+
ACTIVE: "active",
|
|
10
|
+
SELECTED: "selected",
|
|
11
|
+
RESTORING: "restoring"
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
export const DragEffect = Object.freeze({
|
|
15
|
+
NONE: "none",
|
|
16
|
+
DISSOLVE: "dissolve",
|
|
17
|
+
MOVE: "move",
|
|
18
|
+
SHAKE: "shake",
|
|
19
|
+
ELASTIC: "elastic"
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const DRAG_EFFECTS = new Set(Object.values(DragEffect));
|
|
23
|
+
const SHADER_PRESETS = new Set(Object.values(ShaderPreset));
|
|
24
|
+
|
|
25
|
+
const ALLOWED_TRANSITIONS = Object.freeze({
|
|
26
|
+
[GestureTag.IDLE]: new Set([GestureTag.IDLE, GestureTag.PRESSED, GestureTag.RESTORING]),
|
|
27
|
+
[GestureTag.PRESSED]: new Set([GestureTag.IDLE, GestureTag.ACTIVE, GestureTag.RESTORING]),
|
|
28
|
+
[GestureTag.ACTIVE]: new Set([GestureTag.IDLE, GestureTag.SELECTED, GestureTag.RESTORING]),
|
|
29
|
+
[GestureTag.SELECTED]: new Set([GestureTag.IDLE, GestureTag.PRESSED, GestureTag.RESTORING]),
|
|
30
|
+
[GestureTag.RESTORING]: new Set([GestureTag.IDLE, GestureTag.PRESSED, GestureTag.RESTORING])
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export function idleGesture() {
|
|
34
|
+
return Object.freeze({ tag: GestureTag.IDLE });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function pressedGesture(mode, pointerId, startX, startY, baseX, baseY, effect = null) {
|
|
38
|
+
return Object.freeze({
|
|
39
|
+
tag: GestureTag.PRESSED,
|
|
40
|
+
mode,
|
|
41
|
+
effect: effect ?? (mode === "move" ? DragEffect.MOVE : DragEffect.DISSOLVE),
|
|
42
|
+
pointerId,
|
|
43
|
+
startX,
|
|
44
|
+
startY,
|
|
45
|
+
baseX,
|
|
46
|
+
baseY
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function resolveDragEffect(value, legacyInteraction = "select") {
|
|
51
|
+
if (DRAG_EFFECTS.has(value)) return value;
|
|
52
|
+
return legacyInteraction === "move" ? DragEffect.MOVE : DragEffect.DISSOLVE;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function resolveShaderPreset(value) {
|
|
56
|
+
return SHADER_PRESETS.has(value) ? value : ShaderPreset.HOLOGRAM;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function dissolveProgress(elapsedMs, durationMs) {
|
|
60
|
+
const elapsed = Math.max(0, Number(elapsedMs) || 0);
|
|
61
|
+
const duration = Math.max(1, Number(durationMs) || 1);
|
|
62
|
+
return clamp(elapsed / duration, 0, 1);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function numberOption(value, fallback, minimum, maximum) {
|
|
66
|
+
if (value === null || value === undefined || String(value).trim() === "") return fallback;
|
|
67
|
+
const parsed = Number(value);
|
|
68
|
+
return Number.isFinite(parsed) ? clamp(parsed, minimum, maximum) : fallback;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function activeGesture(pressed) {
|
|
72
|
+
if (pressed.tag !== GestureTag.PRESSED) {
|
|
73
|
+
throw new TypeError(`activeGesture expects pressed, received ${pressed.tag}`);
|
|
74
|
+
}
|
|
75
|
+
return Object.freeze({ ...pressed, tag: GestureTag.ACTIVE });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function selectedGesture(effect = DragEffect.DISSOLVE) {
|
|
79
|
+
return Object.freeze({ tag: GestureTag.SELECTED, effect });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function restoringGesture(token) {
|
|
83
|
+
return Object.freeze({ tag: GestureTag.RESTORING, token });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function assertGestureTransition(current, next) {
|
|
87
|
+
if (!ALLOWED_TRANSITIONS[current.tag]?.has(next.tag)) {
|
|
88
|
+
throw new Error(`Invalid shader-font gesture transition: ${current.tag} -> ${next.tag}`);
|
|
89
|
+
}
|
|
90
|
+
return next;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function clamp(value, minimum, maximum) {
|
|
94
|
+
return Math.min(maximum, Math.max(minimum, value));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function createElasticTransform(dx, dy, width) {
|
|
98
|
+
const safeWidth = Math.max(1, Number(width) || 1);
|
|
99
|
+
const safeDx = Number.isFinite(dx) ? dx : 0;
|
|
100
|
+
const safeDy = Number.isFinite(dy) ? dy : 0;
|
|
101
|
+
const distance = Math.hypot(safeDx, safeDy);
|
|
102
|
+
const amount = clamp(distance / (safeWidth * 0.58), 0, 1);
|
|
103
|
+
return Object.freeze({
|
|
104
|
+
x: clamp(safeDx * 0.14, -42, 42),
|
|
105
|
+
y: clamp(safeDy * 0.14, -30, 30),
|
|
106
|
+
scaleX: 1 + amount * 0.3,
|
|
107
|
+
scaleY: 1 - amount * 0.12,
|
|
108
|
+
rotate: clamp((safeDx / safeWidth) * 8, -5, 5),
|
|
109
|
+
amount
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function dampedSpringFactor(progress) {
|
|
114
|
+
const elapsed = clamp(Number(progress) || 0, 0, 1);
|
|
115
|
+
if (elapsed >= 1) return 0;
|
|
116
|
+
return Math.exp(-5.2 * elapsed) * Math.cos(elapsed * Math.PI * 5);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function smoothstep01(value) {
|
|
120
|
+
const t = clamp(value, 0, 1);
|
|
121
|
+
return t * t * (3 - 2 * t);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function quintic01(t) {
|
|
125
|
+
return t * t * t * (t * (t * 6 - 15) + 10);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function hash2(x, y, seed) {
|
|
129
|
+
let value = Math.imul((x | 0) ^ (seed | 0) ^ 0x68bc21eb, 0x27d4eb2d);
|
|
130
|
+
value ^= Math.imul((y | 0) ^ 0x02e5be93, 0x165667b1);
|
|
131
|
+
value ^= value >>> 15;
|
|
132
|
+
value = Math.imul(value, 0x85ebca6b);
|
|
133
|
+
value ^= value >>> 13;
|
|
134
|
+
return (value >>> 0) / UINT32_MAX;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function valueNoise(x, y, scale, seed) {
|
|
138
|
+
const px = x / scale;
|
|
139
|
+
const py = y / scale;
|
|
140
|
+
const ix = Math.floor(px);
|
|
141
|
+
const iy = Math.floor(py);
|
|
142
|
+
const fx = quintic01(px - ix);
|
|
143
|
+
const fy = quintic01(py - iy);
|
|
144
|
+
const a = hash2(ix, iy, seed);
|
|
145
|
+
const b = hash2(ix + 1, iy, seed);
|
|
146
|
+
const c = hash2(ix, iy + 1, seed);
|
|
147
|
+
const d = hash2(ix + 1, iy + 1, seed);
|
|
148
|
+
const top = a + (b - a) * fx;
|
|
149
|
+
const bottom = c + (d - c) * fx;
|
|
150
|
+
return top + (bottom - top) * fy;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function erosionNoise(x, y, baseScale, seed) {
|
|
154
|
+
const warpScale = baseScale * 1.7;
|
|
155
|
+
const warpX = (valueNoise(x + 43, y - 19, warpScale, seed ^ 0x27d4eb2d) - 0.5) * baseScale * 1.15;
|
|
156
|
+
const warpY = (valueNoise(x - 67, y + 31, warpScale, seed ^ 0x165667b1) - 0.5) * baseScale * 1.15;
|
|
157
|
+
const warpedX = x + warpX;
|
|
158
|
+
const warpedY = y + warpY;
|
|
159
|
+
const coarse = valueNoise(warpedX, warpedY, baseScale, seed);
|
|
160
|
+
const medium = valueNoise(warpedX + 131, warpedY - 79, Math.max(4, baseScale * 0.43), seed ^ 0x5bd1e995);
|
|
161
|
+
const fine = valueNoise(warpedX - 211, warpedY + 157, Math.max(2, baseScale * 0.18), seed ^ 0x85ebca6b);
|
|
162
|
+
return clamp(coarse * 0.54 + medium * 0.31 + fine * 0.15, 0, 1);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function validateMaskDimensions(width, height) {
|
|
166
|
+
if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) {
|
|
167
|
+
throw new RangeError("Mask dimensions must be positive integers");
|
|
168
|
+
}
|
|
169
|
+
if (width * height > 4_194_304) {
|
|
170
|
+
throw new RangeError("Mask dimensions exceed the 4-megapixel safety limit");
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function createErosionField(width, height, options = {}) {
|
|
175
|
+
validateMaskDimensions(width, height);
|
|
176
|
+
const seed = Number(options.seed ?? 0) | 0;
|
|
177
|
+
const baseScale = Math.max(10, Math.min(width, height) * 0.26);
|
|
178
|
+
const texture = new Float32Array(width * height);
|
|
179
|
+
for (let y = 0; y < height; y += 1) {
|
|
180
|
+
for (let x = 0; x < width; x += 1) {
|
|
181
|
+
texture[y * width + x] = erosionNoise(x, y, baseScale, seed);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return texture;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function hashText(text) {
|
|
188
|
+
let hash = 0x811c9dc5;
|
|
189
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
190
|
+
hash ^= text.charCodeAt(index);
|
|
191
|
+
hash = Math.imul(hash, 0x01000193);
|
|
192
|
+
}
|
|
193
|
+
return hash >>> 0;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function readingCoordinate(x, y, width, height, lineHeight, direction = "ltr") {
|
|
197
|
+
const safeWidth = Math.max(1, width);
|
|
198
|
+
const safeHeight = Math.max(1, height);
|
|
199
|
+
const safeLineHeight = clamp(lineHeight || safeHeight, 1, safeHeight);
|
|
200
|
+
const lineCount = Math.max(1, Math.ceil(safeHeight / safeLineHeight));
|
|
201
|
+
const line = clamp(Math.floor(y / safeLineHeight), 0, lineCount - 1);
|
|
202
|
+
const horizontal = clamp(x / Math.max(1, safeWidth - 1), 0, 1);
|
|
203
|
+
const withinLine = direction === "rtl" ? 1 - horizontal : horizontal;
|
|
204
|
+
return clamp((line + withinLine) / lineCount, 0, 1);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function alphaAt(signal, progress, feather) {
|
|
208
|
+
return Math.round(smoothstep01((signal - progress + feather) / (feather * 2)) * 255);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function normalizeRegions(width, height, regions) {
|
|
212
|
+
if (regions === undefined || regions === null) return [];
|
|
213
|
+
if (!Array.isArray(regions)) throw new TypeError("Mask regions must be an array");
|
|
214
|
+
return regions.flatMap(region => {
|
|
215
|
+
const x = Number(region?.x);
|
|
216
|
+
const y = Number(region?.y);
|
|
217
|
+
const regionWidth = Number(region?.width);
|
|
218
|
+
const regionHeight = Number(region?.height);
|
|
219
|
+
if (![x, y, regionWidth, regionHeight].every(Number.isFinite)) {
|
|
220
|
+
throw new TypeError("Mask region coordinates must be finite numbers");
|
|
221
|
+
}
|
|
222
|
+
if (regionWidth <= 0 || regionHeight <= 0) return [];
|
|
223
|
+
const left = clamp(x, 0, width);
|
|
224
|
+
const top = clamp(y, 0, height);
|
|
225
|
+
const right = clamp(x + regionWidth, 0, width);
|
|
226
|
+
const bottom = clamp(y + regionHeight, 0, height);
|
|
227
|
+
return right > left && bottom > top ? [{ left, top, right, bottom }] : [];
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function createMaskField(width, height, options = {}) {
|
|
232
|
+
validateMaskDimensions(width, height);
|
|
233
|
+
const selectedOnly = options.regions !== undefined && options.regions !== null;
|
|
234
|
+
const regions = normalizeRegions(width, height, options.regions);
|
|
235
|
+
const thresholds = new Float32Array(width * height);
|
|
236
|
+
thresholds.fill(Infinity);
|
|
237
|
+
const flow = options.flow ?? "reading";
|
|
238
|
+
const direction = options.direction === "rtl" || flow === "rtl" ? "rtl" : "ltr";
|
|
239
|
+
const lineHeight = clamp(Number(options.lineHeight ?? height), 1, height);
|
|
240
|
+
const roughness = clamp(Number(options.roughness ?? 0.26), 0, 0.48);
|
|
241
|
+
const feather = 0.025 + roughness * 0.08;
|
|
242
|
+
if (selectedOnly && regions.length === 0) return { width, height, thresholds, feather };
|
|
243
|
+
const seed = Number(options.seed ?? 0) | 0;
|
|
244
|
+
const texture = options.texture ?? createErosionField(width, height, { seed });
|
|
245
|
+
if (texture.length !== width * height) {
|
|
246
|
+
throw new RangeError("Erosion texture dimensions do not match the mask");
|
|
247
|
+
}
|
|
248
|
+
const erosionBand = clamp(0.34 + roughness * 1.35, 0.34, 0.98);
|
|
249
|
+
|
|
250
|
+
if (selectedOnly) {
|
|
251
|
+
const selectedErosionBand = 1.1 + roughness * 3;
|
|
252
|
+
const totalWidth = regions.reduce((sum, region) => sum + region.right - region.left, 0);
|
|
253
|
+
let precedingWidth = 0;
|
|
254
|
+
for (const region of regions) {
|
|
255
|
+
const firstX = Math.max(0, Math.ceil(region.left - 0.5));
|
|
256
|
+
const lastX = Math.min(width, Math.ceil(region.right - 0.5));
|
|
257
|
+
const firstY = Math.max(0, Math.ceil(region.top - 0.5));
|
|
258
|
+
const lastY = Math.min(height, Math.ceil(region.bottom - 0.5));
|
|
259
|
+
for (let y = firstY; y < lastY; y += 1) {
|
|
260
|
+
for (let x = firstX; x < lastX; x += 1) {
|
|
261
|
+
const index = y * width + x;
|
|
262
|
+
const distance = direction === "rtl" ? region.right - x - 0.5 : x + 0.5 - region.left;
|
|
263
|
+
const reading = (precedingWidth + distance) / totalWidth;
|
|
264
|
+
thresholds[index] = flow === "random" ? texture[index]
|
|
265
|
+
: (reading + selectedErosionBand * texture[index]) / (1 + selectedErosionBand);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
precedingWidth += region.right - region.left;
|
|
269
|
+
}
|
|
270
|
+
// Spread burn times across the animation instead of concentrating them at mid-progress.
|
|
271
|
+
const bins = new Uint32Array(512);
|
|
272
|
+
let count = 0;
|
|
273
|
+
for (const threshold of thresholds) {
|
|
274
|
+
if (!Number.isFinite(threshold)) continue;
|
|
275
|
+
bins[Math.min(511, Math.floor(threshold * 512))] += 1;
|
|
276
|
+
count += 1;
|
|
277
|
+
}
|
|
278
|
+
const cumulative = new Uint32Array(512);
|
|
279
|
+
for (let bin = 1; bin < 512; bin += 1) cumulative[bin] = cumulative[bin - 1] + bins[bin - 1];
|
|
280
|
+
for (let index = 0; index < thresholds.length; index += 1) {
|
|
281
|
+
const threshold = thresholds[index];
|
|
282
|
+
if (!Number.isFinite(threshold)) continue;
|
|
283
|
+
const position = Math.min(511.999, threshold * 512);
|
|
284
|
+
const bin = Math.floor(position);
|
|
285
|
+
thresholds[index] = (cumulative[bin] + (position - bin) * bins[bin]) / count;
|
|
286
|
+
}
|
|
287
|
+
} else {
|
|
288
|
+
for (let y = 0; y < height; y += 1) {
|
|
289
|
+
for (let x = 0; x < width; x += 1) {
|
|
290
|
+
const index = y * width + x;
|
|
291
|
+
const reading = readingCoordinate(x, y, width, height, lineHeight, direction);
|
|
292
|
+
thresholds[index] = flow === "random" ? texture[index]
|
|
293
|
+
: (reading + erosionBand * texture[index]) / (1 + erosionBand);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return { width, height, thresholds, feather };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export function renderMaskAlpha(field, progress, output = new Uint8ClampedArray(field.width * field.height)) {
|
|
301
|
+
if (output.length !== field.thresholds.length) throw new RangeError("Mask output dimensions do not match the field");
|
|
302
|
+
const amount = clamp(Number(progress) || 0, 0, 1);
|
|
303
|
+
const thresholdProgress = amount * (1 + 2 * field.feather) - field.feather;
|
|
304
|
+
for (let index = 0; index < output.length; index += 1) {
|
|
305
|
+
const threshold = field.thresholds[index];
|
|
306
|
+
output[index] = !Number.isFinite(threshold) || amount === 0 ? 255
|
|
307
|
+
: amount === 1 ? 0 : alphaAt(threshold, thresholdProgress, field.feather);
|
|
308
|
+
}
|
|
309
|
+
return output;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export function createMaskAlpha(width, height, options = {}) {
|
|
313
|
+
const field = createMaskField(width, height, options);
|
|
314
|
+
const alpha = renderMaskAlpha(field, options.progress ?? 0);
|
|
315
|
+
return alpha;
|
|
316
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { SHADER_FONT_DEFAULTS, SHADER_FONT_PALETTES, SHADER_PRESETS, parseShaderFontOptions, shaderFontAttributes } from './options.js';
|
|
2
|
+
export type { ShaderFontOptions } from './options.js';
|
|
3
|
+
export function registerShaderFont(options?: { styleNonce?: string }): Promise<
|
|
4
|
+
| { ok: true }
|
|
5
|
+
| { ok: false; error: 'BROWSER_REQUIRED' | 'BROWSER_FEATURE_UNAVAILABLE' | 'INVALID_STYLE_NONCE' | 'STYLES_CONFLICT' | 'STYLES_BLOCKED' | 'REGISTRATION_FAILED'; cause?: string }
|
|
6
|
+
>;
|
package/src/index.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export { SHADER_FONT_DEFAULTS, SHADER_FONT_PALETTES, SHADER_PRESETS, parseShaderFontOptions, shaderFontAttributes } from "./options.js";
|
|
2
|
+
|
|
3
|
+
const STYLE_ERRORS = new Set(["INVALID_STYLE_NONCE", "STYLES_CONFLICT", "STYLES_BLOCKED"]);
|
|
4
|
+
|
|
5
|
+
/** No DOM globals are evaluated until a browser explicitly requests effects. */
|
|
6
|
+
export async function registerShaderFont({ styleNonce } = {}) {
|
|
7
|
+
if (typeof window === "undefined" || typeof document === "undefined") {
|
|
8
|
+
return { ok: false, error: "BROWSER_REQUIRED" };
|
|
9
|
+
}
|
|
10
|
+
if (typeof customElements === "undefined" || typeof HTMLElement === "undefined"
|
|
11
|
+
|| typeof ResizeObserver === "undefined" || typeof MutationObserver === "undefined"
|
|
12
|
+
|| typeof IntersectionObserver === "undefined" || typeof matchMedia !== "function") {
|
|
13
|
+
return { ok: false, error: "BROWSER_FEATURE_UNAVAILABLE" };
|
|
14
|
+
}
|
|
15
|
+
return import("./shader-font-element.js")
|
|
16
|
+
.then(module => {
|
|
17
|
+
module.registerShaderFontElement({ styleNonce });
|
|
18
|
+
return { ok: true };
|
|
19
|
+
})
|
|
20
|
+
.catch(cause => ({
|
|
21
|
+
ok: false,
|
|
22
|
+
error: STYLE_ERRORS.has(cause?.code) ? cause.code : "REGISTRATION_FAILED",
|
|
23
|
+
cause: cause instanceof Error ? cause.name : typeof cause
|
|
24
|
+
}));
|
|
25
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { renderMaskAlpha } from "./core.js";
|
|
2
|
+
|
|
3
|
+
import { createWebGLSurface, exportSurface, rendererError } from "./webgl-surface.js";
|
|
4
|
+
|
|
5
|
+
const FRAGMENT_SHADER = `#version 300 es
|
|
6
|
+
precision highp float;
|
|
7
|
+
precision highp int;
|
|
8
|
+
uniform highp sampler2D u_field;
|
|
9
|
+
uniform int u_height;
|
|
10
|
+
uniform float u_progress;
|
|
11
|
+
uniform float u_feather;
|
|
12
|
+
out vec4 fragColor;
|
|
13
|
+
void main() {
|
|
14
|
+
// Field rows start at the top; the WebGL framebuffer starts at the bottom.
|
|
15
|
+
ivec2 pixel = ivec2(int(gl_FragCoord.x), u_height - 1 - int(gl_FragCoord.y));
|
|
16
|
+
float threshold = texelFetch(u_field, pixel, 0).r;
|
|
17
|
+
float alpha = 1.0;
|
|
18
|
+
if (threshold >= 0.0 && u_progress > 0.0) {
|
|
19
|
+
if (u_progress >= 1.0) alpha = 0.0;
|
|
20
|
+
else {
|
|
21
|
+
float progress = u_progress * (1.0 + 2.0 * u_feather) - u_feather;
|
|
22
|
+
float t = clamp((threshold - progress + u_feather) / (2.0 * u_feather), 0.0, 1.0);
|
|
23
|
+
alpha = t * t * (3.0 - 2.0 * t);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
fragColor = vec4(1.0, 1.0, 1.0, alpha);
|
|
27
|
+
}`;
|
|
28
|
+
|
|
29
|
+
function canvasMask(canvas) {
|
|
30
|
+
const context = canvas.getContext("2d", { alpha: true });
|
|
31
|
+
if (!context) throw rendererError("CANVAS2D_UNAVAILABLE", "A Canvas 2D context is unavailable");
|
|
32
|
+
const image = context.createImageData(canvas.width, canvas.height);
|
|
33
|
+
image.data.fill(255);
|
|
34
|
+
const alpha = new Uint8ClampedArray(canvas.width * canvas.height);
|
|
35
|
+
return {
|
|
36
|
+
kind: "canvas2d", canvas,
|
|
37
|
+
render(field, progress) {
|
|
38
|
+
renderMaskAlpha(field, progress, alpha);
|
|
39
|
+
for (let index = 0; index < alpha.length; index += 1) image.data[index * 4 + 3] = alpha[index];
|
|
40
|
+
context.putImageData(image, 0, 0);
|
|
41
|
+
return exportSurface(canvas);
|
|
42
|
+
},
|
|
43
|
+
dispose() { canvas.width = canvas.height = 0; }
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function webglMask(width, height, onContextLost) {
|
|
48
|
+
const surface = createWebGLSurface(width, height, FRAGMENT_SHADER, onContextLost);
|
|
49
|
+
const { gl } = surface;
|
|
50
|
+
let uploadedField = null;
|
|
51
|
+
try {
|
|
52
|
+
surface.texture();
|
|
53
|
+
gl.uniform1i(surface.uniform("u_field"), 0);
|
|
54
|
+
gl.uniform1i(surface.uniform("u_height"), height);
|
|
55
|
+
const progressUniform = surface.uniform("u_progress");
|
|
56
|
+
const featherUniform = surface.uniform("u_feather");
|
|
57
|
+
return {
|
|
58
|
+
kind: "webgl", canvas: surface.canvas, dispose: surface.dispose,
|
|
59
|
+
render(field, progress) {
|
|
60
|
+
if (uploadedField !== field) {
|
|
61
|
+
const thresholds = Float32Array.from(field.thresholds, value => Number.isFinite(value) ? value : -1);
|
|
62
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, gl.R32F, field.width, field.height, 0, gl.RED, gl.FLOAT, thresholds);
|
|
63
|
+
if (gl.getError() !== gl.NO_ERROR) throw rendererError("WEBGL_TEXTURE_FAILED", "The mask field could not be uploaded");
|
|
64
|
+
uploadedField = field;
|
|
65
|
+
}
|
|
66
|
+
gl.uniform1f(progressUniform, progress);
|
|
67
|
+
gl.uniform1f(featherUniform, field.feather);
|
|
68
|
+
return surface.draw();
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
} catch (error) {
|
|
72
|
+
surface.dispose();
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The caller owns field geometry and time; this boundary only renders alpha. */
|
|
78
|
+
export function createMaskRenderer(kind, width, height, onContextLost) {
|
|
79
|
+
if (kind !== "canvas2d" && kind !== "webgl") {
|
|
80
|
+
throw rendererError("INVALID_RENDERER", `Unknown mask renderer: ${kind}`);
|
|
81
|
+
}
|
|
82
|
+
if (kind === "webgl") return webglMask(width, height, onContextLost);
|
|
83
|
+
const canvas = document.createElement("canvas");
|
|
84
|
+
canvas.width = width;
|
|
85
|
+
canvas.height = height;
|
|
86
|
+
return canvasMask(canvas);
|
|
87
|
+
}
|