glyphdust 0.2.1 → 0.3.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/CHANGELOG.md +17 -0
- package/README.md +23 -1
- package/dist/index.cjs +63 -19
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +220 -194
- package/dist/index.d.ts +220 -194
- package/dist/index.js +63 -19
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,23 @@ All notable changes to **glyphdust** are documented here.
|
|
|
4
4
|
The format follows [Keep a Changelog](https://keepachangelog.com/), and the
|
|
5
5
|
project adheres to [Semantic Versioning](https://semver.org/).
|
|
6
6
|
|
|
7
|
+
## [0.3.0] — 2026-06-28
|
|
8
|
+
|
|
9
|
+
Non-breaking feature release. Defaults reproduce 0.2.1 exactly.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **Mixed fonts in one glyph (`segments`)** — a `TextKeyframe` can now carry a
|
|
14
|
+
`segments: { text, font? }[]` array. Each run is stamped with its own font and
|
|
15
|
+
flows inline (a `\n` inside any run breaks the line; the next run continues on the
|
|
16
|
+
new line), so a single particle glyph can blend, e.g., a bold serif word with a
|
|
17
|
+
light sans one. `text` stays the accessible/`resolveToDom` string; per-run `font`
|
|
18
|
+
falls back to the keyframe `font`. Works on the normal and `dense` sampling paths;
|
|
19
|
+
ignored under `domSelector` (the DOM provides layout). Defaults unchanged —
|
|
20
|
+
omitting `segments` reproduces prior behavior exactly.
|
|
21
|
+
_Why: particles only ride "ink", so the stamp was never font-bound — the limit was
|
|
22
|
+
the API exposing one font. (提案者: 凜さん)_
|
|
23
|
+
|
|
7
24
|
## [0.2.1] — 2026-06-26
|
|
8
25
|
|
|
9
26
|
Flexibility & polish release. Glyphdust is no longer scroll-and-hero only — it now
|
package/README.md
CHANGED
|
@@ -155,13 +155,35 @@ type Keyframe = TextKeyframe | ScatterKeyframe;
|
|
|
155
155
|
| field | type | description |
|
|
156
156
|
|---|---|---|
|
|
157
157
|
| `text` | `string` | Text to render. Use `\n` for line breaks. |
|
|
158
|
+
| `segments` | `{ text, font? }[]?` | Mix fonts in one glyph (see below). Particles are stamped from the runs; `text` stays the accessible / `resolveToDom` string. |
|
|
158
159
|
| `domSelector` | `string?` | Selector of a real element; particles align pixel-perfect to its rect & font. |
|
|
159
160
|
| `resolveToDom` | `boolean?` | At the finale, cross-fade particles → real DOM text (usually the last keyframe). |
|
|
160
161
|
| `dense` | `boolean?` | High-density, uniform sampling (best for solid wordmarks). |
|
|
161
|
-
| `font` | `string?` | Canvas2D `font` string. Defaults to a density-appropriate value. |
|
|
162
|
+
| `font` | `string?` | Canvas2D `font` string. Defaults to a density-appropriate value. Also the default for `segments` runs. |
|
|
162
163
|
| `worldW` | `number?` | Visible world width to fit the glyph into. |
|
|
163
164
|
| `offsetX` / `offsetY` | `number?` | World-space offset (right / up are positive). |
|
|
164
165
|
|
|
166
|
+
#### Mix fonts in one glyph (`segments`)
|
|
167
|
+
|
|
168
|
+
Particles just ride wherever there's "ink", so a single glyph isn't bound to one
|
|
169
|
+
typeface. Split a keyframe into `segments` and each run gets its own `font`,
|
|
170
|
+
flowing inline (a `\n` inside a run starts a new line; the next run continues there):
|
|
171
|
+
|
|
172
|
+
```tsx
|
|
173
|
+
{
|
|
174
|
+
type: "text",
|
|
175
|
+
text: "Mix fonts", // accessible / resolve string
|
|
176
|
+
segments: [
|
|
177
|
+
{ text: "Mix ", font: "900 200px Georgia, serif" }, // bold serif
|
|
178
|
+
{ text: "fonts", font: "300 150px 'Helvetica Neue', sans-serif" }, // light sans
|
|
179
|
+
],
|
|
180
|
+
}
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Runs without a `font` fall back to the keyframe `font`. `segments` is ignored when
|
|
184
|
+
`domSelector` is set (the DOM provides the layout there). Particle **color** is still
|
|
185
|
+
governed globally by `colors` (ink/accent ratio), not per segment.
|
|
186
|
+
|
|
165
187
|
**`ScatterKeyframe`** (`type: "scatter"`) — scatters particles into a random cloud:
|
|
166
188
|
|
|
167
189
|
| field | type | description |
|
package/dist/index.cjs
CHANGED
|
@@ -54,6 +54,38 @@ function fillScatterCluster(out, count, offsetX, offsetY, random) {
|
|
|
54
54
|
out[i * 3 + 2] = (random() - 0.5) * 0.2;
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
|
+
function segmentsToRunLines(segments, defaultFont) {
|
|
58
|
+
const lines = [[]];
|
|
59
|
+
for (const seg of segments) {
|
|
60
|
+
const font = seg.font ?? defaultFont;
|
|
61
|
+
const parts = seg.text.split("\n");
|
|
62
|
+
parts.forEach((part, i) => {
|
|
63
|
+
if (i > 0) lines.push([]);
|
|
64
|
+
if (part.length > 0) lines[lines.length - 1].push({ text: part, font });
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
return lines;
|
|
68
|
+
}
|
|
69
|
+
function drawSegmentedLines(ctx, runLines, cw, ch, lineHeight, align, leftPad) {
|
|
70
|
+
ctx.fillStyle = "#000";
|
|
71
|
+
ctx.textBaseline = "middle";
|
|
72
|
+
ctx.textAlign = "left";
|
|
73
|
+
const blockH = lineHeight * (runLines.length - 1);
|
|
74
|
+
runLines.forEach((runs, i) => {
|
|
75
|
+
const y = ch / 2 - blockH / 2 + i * lineHeight;
|
|
76
|
+
let total = 0;
|
|
77
|
+
for (const r of runs) {
|
|
78
|
+
ctx.font = r.font;
|
|
79
|
+
total += ctx.measureText(r.text).width;
|
|
80
|
+
}
|
|
81
|
+
let x = align === "left" ? leftPad : cw / 2 - total / 2;
|
|
82
|
+
for (const r of runs) {
|
|
83
|
+
ctx.font = r.font;
|
|
84
|
+
ctx.fillText(r.text, x, y);
|
|
85
|
+
x += ctx.measureText(r.text).width;
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
}
|
|
57
89
|
function buildTextTargets(count, lines, opts) {
|
|
58
90
|
const out = new Float32Array(count * 3);
|
|
59
91
|
const random = opts.random ?? Math.random;
|
|
@@ -62,17 +94,22 @@ function buildTextTargets(count, lines, opts) {
|
|
|
62
94
|
const ctx = createSamplingContext(cw, ch);
|
|
63
95
|
if (!ctx) return out;
|
|
64
96
|
const align = opts.align ?? "center";
|
|
65
|
-
ctx.clearRect(0, 0, cw, ch);
|
|
66
|
-
ctx.fillStyle = "#000";
|
|
67
|
-
ctx.textAlign = align === "left" ? "left" : "center";
|
|
68
|
-
ctx.textBaseline = "middle";
|
|
69
|
-
ctx.font = opts.font;
|
|
70
|
-
const drawX = align === "left" ? cw * 0.04 : cw / 2;
|
|
71
97
|
const lh = opts.lineHeight;
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
98
|
+
ctx.clearRect(0, 0, cw, ch);
|
|
99
|
+
if (opts.segments && opts.segments.length > 0) {
|
|
100
|
+
const runLines = segmentsToRunLines(opts.segments, opts.font);
|
|
101
|
+
drawSegmentedLines(ctx, runLines, cw, ch, lh, align, cw * 0.04);
|
|
102
|
+
} else {
|
|
103
|
+
ctx.fillStyle = "#000";
|
|
104
|
+
ctx.textAlign = align === "left" ? "left" : "center";
|
|
105
|
+
ctx.textBaseline = "middle";
|
|
106
|
+
ctx.font = opts.font;
|
|
107
|
+
const drawX = align === "left" ? cw * 0.04 : cw / 2;
|
|
108
|
+
const blockH = lh * (lines.length - 1);
|
|
109
|
+
lines.forEach((line, i) => {
|
|
110
|
+
ctx.fillText(line, drawX, ch / 2 - blockH / 2 + i * lh);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
76
113
|
const step = opts.step ?? 2;
|
|
77
114
|
const pts = collectFilledPixels(ctx, cw, ch, step);
|
|
78
115
|
const filled = pts.length / 2;
|
|
@@ -106,15 +143,20 @@ function buildDenseTextTargets(count, lines, opts) {
|
|
|
106
143
|
const ctx = createSamplingContext(cw, ch);
|
|
107
144
|
if (!ctx) return out;
|
|
108
145
|
ctx.clearRect(0, 0, cw, ch);
|
|
109
|
-
ctx.fillStyle = "#000";
|
|
110
|
-
ctx.textAlign = "center";
|
|
111
|
-
ctx.textBaseline = "middle";
|
|
112
|
-
ctx.font = opts.font;
|
|
113
146
|
const lh = ch * (opts.lineHeightRatio ?? 0.46);
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
ctx
|
|
117
|
-
}
|
|
147
|
+
if (opts.segments && opts.segments.length > 0) {
|
|
148
|
+
const runLines = segmentsToRunLines(opts.segments, opts.font);
|
|
149
|
+
drawSegmentedLines(ctx, runLines, cw, ch, lh, "center", cw * 0.04);
|
|
150
|
+
} else {
|
|
151
|
+
ctx.fillStyle = "#000";
|
|
152
|
+
ctx.textAlign = "center";
|
|
153
|
+
ctx.textBaseline = "middle";
|
|
154
|
+
ctx.font = opts.font;
|
|
155
|
+
const blockH = lh * (lines.length - 1);
|
|
156
|
+
lines.forEach((line, i) => {
|
|
157
|
+
ctx.fillText(line, cw / 2, ch / 2 - blockH / 2 + i * lh);
|
|
158
|
+
});
|
|
159
|
+
}
|
|
118
160
|
const step = opts.step ?? 1;
|
|
119
161
|
const pts = collectFilledPixels(ctx, cw, ch, step);
|
|
120
162
|
const filled = pts.length / 2;
|
|
@@ -517,6 +559,7 @@ function buildKeyframeTargets(kf, count, ctx) {
|
|
|
517
559
|
if (kf.dense) {
|
|
518
560
|
return buildDenseTextTargets(count, lines, {
|
|
519
561
|
font: kf.font ?? DEFAULT_DENSE_FONT,
|
|
562
|
+
segments: kf.segments,
|
|
520
563
|
worldW: kf.worldW ?? ctx.visW * (ctx.mobile ? 0.86 : 0.62),
|
|
521
564
|
offsetX: kf.offsetX ?? 0,
|
|
522
565
|
offsetY: kf.offsetY ?? 0,
|
|
@@ -528,6 +571,7 @@ function buildKeyframeTargets(kf, count, ctx) {
|
|
|
528
571
|
}
|
|
529
572
|
return buildTextTargets(count, lines, {
|
|
530
573
|
font: kf.font ?? DEFAULT_TEXT_FONT,
|
|
574
|
+
segments: kf.segments,
|
|
531
575
|
worldW: kf.worldW ?? ctx.visW * 0.7,
|
|
532
576
|
lineHeight: 178,
|
|
533
577
|
offsetX: kf.offsetX ?? 0,
|
|
@@ -1144,7 +1188,7 @@ function GlyphDust(props) {
|
|
|
1144
1188
|
}
|
|
1145
1189
|
|
|
1146
1190
|
// src/index.ts
|
|
1147
|
-
var VERSION = "0.
|
|
1191
|
+
var VERSION = "0.3.0";
|
|
1148
1192
|
|
|
1149
1193
|
exports.DEFAULT_TRIGGER_HEIGHT = DEFAULT_TRIGGER_HEIGHT;
|
|
1150
1194
|
exports.FRAGMENT_SHADER = FRAGMENT_SHADER;
|