@kolkrabbi/kol-foundry 0.4.2 → 0.5.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.
@@ -0,0 +1,449 @@
1
+ import React, { useEffect, useRef, useState } from 'react'
2
+ import { FontLoader } from './FontLoader.js'
3
+ import { GlyphAnimator } from './GlyphAnimator.js'
4
+ import { MetricsOverlay } from './MetricsOverlay.js'
5
+ import { UIControls } from './UIControls.js'
6
+ import { FontInfoRenderer } from './FontInfo.js'
7
+
8
+ const DEFAULT_VERTICAL = 50
9
+
10
+ const FontViewer = ({
11
+ fontUrl,
12
+ showControls = true,
13
+ showMetrics = false,
14
+ initialFontSize = 600,
15
+ autoStart = true,
16
+ animationDelay = 1000,
17
+ config = {}
18
+ }) => {
19
+ const mergedConfig = {
20
+ showControls,
21
+ showMetrics,
22
+ initialFontSize,
23
+ autoStart,
24
+ animationDelay,
25
+ ...config
26
+ }
27
+
28
+ const wrapperRef = useRef(null)
29
+ const displayRef = useRef(null)
30
+ const metricsOverlayElementRef = useRef(null)
31
+ const fontInfoContentRef = useRef(null)
32
+ const glyphInfoContentRef = useRef(null)
33
+
34
+ const fontLoaderRef = useRef(null)
35
+ const glyphAnimatorRef = useRef(null)
36
+ const metricsOverlayRef = useRef(null)
37
+ const uiControlsRef = useRef(null)
38
+ const currentFontRef = useRef(null)
39
+
40
+ const fontSizeRef = useRef(mergedConfig.initialFontSize)
41
+ const verticalRef = useRef(DEFAULT_VERTICAL)
42
+ const delayRef = useRef(mergedConfig.animationDelay)
43
+ const metricsVisibleRef = useRef(mergedConfig.showMetrics)
44
+
45
+ const [fontInfoVisible, setFontInfoVisible] = useState(false)
46
+ const [glyphInfoVisible, setGlyphInfoVisible] = useState(false)
47
+ const [metricsVisible, setMetricsVisible] = useState(mergedConfig.showMetrics)
48
+ const [randomOrder, setRandomOrder] = useState(false)
49
+ const [fontLoaded, setFontLoaded] = useState(false)
50
+ const [fontSizeValue, setFontSizeValue] = useState(mergedConfig.initialFontSize)
51
+ const [verticalValue, setVerticalValue] = useState(DEFAULT_VERTICAL)
52
+ const [delayValue, setDelayValue] = useState(mergedConfig.animationDelay)
53
+ const [variationAxes, setVariationAxes] = useState([])
54
+ const [variationValues, setVariationValues] = useState({})
55
+ const [controlsVisible, setControlsVisible] = useState(false)
56
+
57
+ useEffect(() => {
58
+ const displayEl = displayRef.current
59
+ const metricsEl = metricsOverlayElementRef.current
60
+ if (!displayEl || !metricsEl) {
61
+ return undefined
62
+ }
63
+
64
+ uiControlsRef.current = new UIControls()
65
+ metricsOverlayRef.current = new MetricsOverlay(metricsEl)
66
+
67
+ fontLoaderRef.current = new FontLoader({
68
+ onFontLoaded: ({ font, fontInfo, fontFamily }) => {
69
+ currentFontRef.current = font
70
+ displayEl.style.fontFamily = `"${fontFamily}"`
71
+ displayEl.style.fontSize = `${fontSizeRef.current}px`
72
+ displayEl.style.top = `${verticalRef.current - 50}%`
73
+
74
+ FontInfoRenderer.renderFontInfo(fontInfoContentRef.current, fontInfo)
75
+
76
+ if (fontInfo.axes?.length > 0) {
77
+ setVariationAxes(fontInfo.axes.map((axis) => ({ ...axis })))
78
+ const defaults = fontInfo.axes.reduce((acc, axis) => {
79
+ const numericDefault = Number(axis.default)
80
+ acc[axis.tag] = Number.isNaN(numericDefault) ? Number(axis.min) || 0 : numericDefault
81
+ return acc
82
+ }, {})
83
+ setVariationValues(defaults)
84
+ } else {
85
+ setVariationAxes([])
86
+ setVariationValues({})
87
+ }
88
+
89
+ glyphAnimatorRef.current = new GlyphAnimator({
90
+ displayElement: displayEl,
91
+ onGlyphChange: (glyph) => {
92
+ FontInfoRenderer.renderGlyphInfo(
93
+ glyphInfoContentRef.current,
94
+ font,
95
+ glyph
96
+ )
97
+
98
+ if (metricsOverlayRef.current.isVisible) {
99
+ metricsOverlayRef.current.render(font, displayEl)
100
+ }
101
+ }
102
+ })
103
+
104
+ glyphAnimatorRef.current.setGlyphsFromFont(font).then(() => {
105
+ if (mergedConfig.autoStart) {
106
+ if (delayRef.current >= 1000) {
107
+ glyphAnimatorRef.current.stop()
108
+ } else {
109
+ glyphAnimatorRef.current.start(delayRef.current)
110
+ }
111
+ }
112
+ })
113
+
114
+ if (metricsVisibleRef.current) {
115
+ metricsOverlayRef.current.isVisible = true
116
+ metricsEl.style.display = 'block'
117
+ metricsOverlayRef.current.render(font, displayEl)
118
+ } else {
119
+ metricsOverlayRef.current.isVisible = false
120
+ metricsEl.style.display = 'none'
121
+ }
122
+
123
+ setFontLoaded(true)
124
+ },
125
+ onError: (error) => {
126
+ console.error('FontViewer error:', error)
127
+ }
128
+ })
129
+
130
+ return () => {
131
+ fontLoaderRef.current?.cleanup()
132
+ glyphAnimatorRef.current?.stop()
133
+ metricsOverlayElementRef.current?.replaceChildren()
134
+ }
135
+ }, [mergedConfig.autoStart, mergedConfig.initialFontSize, mergedConfig.showMetrics])
136
+
137
+ useEffect(() => {
138
+ if (!fontUrl || !fontLoaderRef.current) {
139
+ return
140
+ }
141
+
142
+ let cancelled = false
143
+
144
+ const loadFont = async () => {
145
+ try {
146
+ const response = await fetch(fontUrl)
147
+ const buffer = await response.arrayBuffer()
148
+ const filename = fontUrl.split('/').pop() || 'font.ttf'
149
+ if (!cancelled) {
150
+ await fontLoaderRef.current.loadFont(buffer, filename)
151
+ }
152
+ } catch (error) {
153
+ console.error('Error loading font:', error)
154
+ }
155
+ }
156
+
157
+ loadFont()
158
+
159
+ return () => {
160
+ cancelled = true
161
+ }
162
+ }, [fontUrl])
163
+
164
+ useEffect(() => {
165
+ fontSizeRef.current = fontSizeValue
166
+ }, [fontSizeValue])
167
+
168
+ useEffect(() => {
169
+ verticalRef.current = verticalValue
170
+ }, [verticalValue])
171
+
172
+ useEffect(() => {
173
+ delayRef.current = delayValue
174
+ }, [delayValue])
175
+
176
+ useEffect(() => {
177
+ metricsVisibleRef.current = metricsVisible
178
+ }, [metricsVisible])
179
+
180
+ useEffect(() => {
181
+ const displayEl = displayRef.current
182
+ if (!displayEl) {
183
+ return
184
+ }
185
+
186
+ const settings = Object.entries(variationValues)
187
+ .filter(([, val]) => typeof val === 'number' && !Number.isNaN(val))
188
+ .map(([tag, val]) => `"${tag}" ${val.toFixed(1)}`)
189
+ .join(', ')
190
+
191
+ displayEl.style.fontVariationSettings = settings || 'normal'
192
+ }, [variationValues])
193
+
194
+ const handleToggleFontInfo = () => {
195
+ setFontInfoVisible((prev) => !prev)
196
+ }
197
+
198
+ const handleToggleGlyphInfo = () => {
199
+ setGlyphInfoVisible((prev) => !prev)
200
+ }
201
+
202
+ const handleToggleMetrics = () => {
203
+ if (!metricsOverlayRef.current || !currentFontRef.current || !displayRef.current) {
204
+ return
205
+ }
206
+
207
+ metricsOverlayRef.current.toggle()
208
+ const isVisible = metricsOverlayRef.current.isVisible
209
+ setMetricsVisible(isVisible)
210
+
211
+ if (isVisible) {
212
+ metricsOverlayRef.current.render(currentFontRef.current, displayRef.current)
213
+ }
214
+ }
215
+
216
+ const handleSwapColors = () => {
217
+ uiControlsRef.current?.toggleColorScheme()
218
+ }
219
+
220
+ const handleRandomize = () => {
221
+ if (!glyphAnimatorRef.current) return
222
+ glyphAnimatorRef.current.toggleOrder()
223
+ setRandomOrder(glyphAnimatorRef.current.isRandomOrder)
224
+ }
225
+
226
+ const handleFontSizeChange = (value) => {
227
+ const numeric = Number(value)
228
+ setFontSizeValue(numeric)
229
+
230
+ if (displayRef.current) {
231
+ displayRef.current.style.fontSize = `${numeric}px`
232
+ }
233
+
234
+ if (metricsOverlayRef.current?.isVisible && currentFontRef.current && displayRef.current) {
235
+ metricsOverlayRef.current.render(currentFontRef.current, displayRef.current)
236
+ }
237
+ }
238
+
239
+ const handleVerticalChange = (value) => {
240
+ const numeric = Number(value)
241
+ setVerticalValue(numeric)
242
+
243
+ if (displayRef.current) {
244
+ const offset = numeric - 50
245
+ displayRef.current.style.top = `${offset}%`
246
+ }
247
+
248
+ if (metricsOverlayRef.current?.isVisible && currentFontRef.current && displayRef.current) {
249
+ metricsOverlayRef.current.render(currentFontRef.current, displayRef.current)
250
+ }
251
+ }
252
+
253
+ const handleDelayChange = (value) => {
254
+ const numeric = Number(value)
255
+ setDelayValue(numeric)
256
+
257
+ if (!glyphAnimatorRef.current) {
258
+ return
259
+ }
260
+
261
+ if (numeric >= 1000) {
262
+ glyphAnimatorRef.current.stop()
263
+ } else {
264
+ glyphAnimatorRef.current.start(numeric)
265
+ }
266
+ }
267
+
268
+ const handleVariationChange = (tag, value) => {
269
+ const numeric = Number(value)
270
+ setVariationValues((prev) => {
271
+ if (Number.isNaN(numeric)) {
272
+ return prev
273
+ }
274
+
275
+ return {
276
+ ...prev,
277
+ [tag]: numeric
278
+ }
279
+ })
280
+ }
281
+
282
+ return (
283
+ <div ref={wrapperRef} className="font-viewer-wrapper">
284
+ <div className="font-viewer-display-container">
285
+ <div
286
+ ref={displayRef}
287
+ className="font-viewer-glyph-buffer"
288
+ style={{ top: `${verticalValue - 50}%`, fontSize: `${fontSizeValue}px` }}
289
+ />
290
+ <div
291
+ ref={metricsOverlayElementRef}
292
+ className="font-viewer-metrics-overlay"
293
+ style={{ display: metricsVisible ? 'block' : 'none' }}
294
+ />
295
+ </div>
296
+
297
+ <div
298
+ className="font-viewer-info-panel font-viewer-font-info"
299
+ aria-hidden={!fontInfoVisible}
300
+ style={{ display: fontInfoVisible ? 'block' : 'none' }}
301
+ >
302
+ <div ref={fontInfoContentRef} className="font-viewer-info-content" />
303
+ </div>
304
+
305
+ <div
306
+ className="font-viewer-info-panel font-viewer-glyph-info"
307
+ aria-hidden={!glyphInfoVisible}
308
+ style={{ display: glyphInfoVisible ? 'block' : 'none' }}
309
+ >
310
+ <div ref={glyphInfoContentRef} className="font-viewer-info-content" />
311
+ </div>
312
+
313
+ {mergedConfig.showControls ? (
314
+ <div
315
+ className={`font-viewer-controls${controlsVisible ? ' is-visible' : ''}`}
316
+ role="toolbar"
317
+ aria-label="Font viewer controls"
318
+ onMouseEnter={() => setControlsVisible(true)}
319
+ onMouseLeave={() => setControlsVisible(false)}
320
+ >
321
+ <div className="font-viewer-buttons">
322
+ <ControlButton active={fontInfoVisible} onClick={handleToggleFontInfo}>
323
+ {fontInfoVisible ? 'Hide font info' : 'Show font info'}
324
+ </ControlButton>
325
+ <ControlButton active={glyphInfoVisible} onClick={handleToggleGlyphInfo}>
326
+ {glyphInfoVisible ? 'Hide glyph info' : 'Show glyph info'}
327
+ </ControlButton>
328
+ <ControlButton active={metricsVisible} onClick={handleToggleMetrics}>
329
+ {metricsVisible ? 'Hide metrics' : 'Show metrics'}
330
+ </ControlButton>
331
+ <ControlButton onClick={handleSwapColors}>Swap colours</ControlButton>
332
+ <ControlButton active={randomOrder} onClick={handleRandomize} disabled={!fontLoaded}>
333
+ {randomOrder ? 'Sequential glyph order' : 'Randomize glyph order'}
334
+ </ControlButton>
335
+ </div>
336
+
337
+ <div className="font-viewer-sliders">
338
+ <SliderControl
339
+ id="font-viewer-font-size"
340
+ label="Font size"
341
+ min={100}
342
+ max={1200}
343
+ value={fontSizeValue}
344
+ onChange={handleFontSizeChange}
345
+ formattedValue={`${fontSizeValue}px`}
346
+ />
347
+
348
+ <SliderControl
349
+ id="font-viewer-vertical"
350
+ label="Vertical position"
351
+ min={0}
352
+ max={100}
353
+ value={verticalValue}
354
+ onChange={handleVerticalChange}
355
+ formattedValue={`${verticalValue}%`}
356
+ />
357
+
358
+ <SliderControl
359
+ id="font-viewer-delay"
360
+ label="Animation delay"
361
+ min={10}
362
+ max={1000}
363
+ value={delayValue}
364
+ onChange={handleDelayChange}
365
+ formattedValue={delayValue >= 1000 ? 'Off' : `${delayValue}ms`}
366
+ />
367
+
368
+ {variationAxes.map((axis) => (
369
+ <VariationControl
370
+ key={axis.tag}
371
+ axis={axis}
372
+ value={variationValues[axis.tag]}
373
+ onChange={(newValue) => handleVariationChange(axis.tag, newValue)}
374
+ />
375
+ ))}
376
+ </div>
377
+ </div>
378
+ ) : null}
379
+ </div>
380
+ )
381
+ }
382
+
383
+ const ControlButton = ({ active, disabled, onClick, children }) => (
384
+ <button
385
+ type="button"
386
+ onClick={onClick}
387
+ disabled={disabled}
388
+ className={`font-viewer-btn${active ? ' active' : ''}${disabled ? ' disabled' : ''}`}
389
+ >
390
+ {children}
391
+ </button>
392
+ )
393
+
394
+ const SliderControl = ({ id, label, min, max, step, value, onChange, formattedValue }) => {
395
+ const handleChange = (event) => {
396
+ onChange(event.target.value)
397
+ }
398
+
399
+ return (
400
+ <div className="font-viewer-slider-group">
401
+ <label htmlFor={id}>{label}</label>
402
+ <input
403
+ id={id}
404
+ type="range"
405
+ min={min}
406
+ max={max}
407
+ step={step}
408
+ value={value}
409
+ onChange={handleChange}
410
+ />
411
+ <span>{formattedValue}</span>
412
+ </div>
413
+ )
414
+ }
415
+
416
+ const VariationControl = ({ axis, value, onChange }) => {
417
+ const sliderId = `font-axis-${axis.tag}`
418
+ const min = Number(axis.min)
419
+ const max = Number(axis.max)
420
+ const defaultValue = Number(axis.default ?? axis.min ?? 0)
421
+ let numericValue = typeof value === 'number' && !Number.isNaN(value) ? value : defaultValue
422
+
423
+ if (!Number.isNaN(min)) {
424
+ numericValue = Math.max(numericValue, min)
425
+ }
426
+
427
+ if (!Number.isNaN(max)) {
428
+ numericValue = Math.min(numericValue, max)
429
+ }
430
+
431
+ const formattedValue = Number.isFinite(numericValue) ? numericValue.toFixed(1) : '0.0'
432
+ const rawStep = axis.step ? Number(axis.step) : 0.1
433
+ const step = Number.isNaN(rawStep) || rawStep === 0 ? 0.1 : rawStep
434
+
435
+ return (
436
+ <SliderControl
437
+ id={sliderId}
438
+ label={`${axis.name} (${axis.tag})`}
439
+ min={Number.isNaN(min) ? undefined : min}
440
+ max={Number.isNaN(max) ? undefined : max}
441
+ step={step}
442
+ value={numericValue}
443
+ onChange={onChange}
444
+ formattedValue={formattedValue}
445
+ />
446
+ )
447
+ }
448
+
449
+ export default FontViewer
@@ -0,0 +1,10 @@
1
+ import React from 'react'
2
+ import FontViewer from './FontViewerComponent.jsx'
3
+
4
+ const FontViewerSection = ({ fontUrl, ...viewerProps }) => (
5
+ <section className="fontviewer-section">
6
+ <FontViewer fontUrl={fontUrl} {...viewerProps} />
7
+ </section>
8
+ )
9
+
10
+ export default FontViewerSection
@@ -0,0 +1,119 @@
1
+ // =============================================================================
2
+ // hyperflip/GlyphAnimator.js
3
+ // =============================================================================
4
+
5
+ export class GlyphAnimator {
6
+ constructor(options) {
7
+ this.displayElement = options.displayElement;
8
+ this.onGlyphChange = options.onGlyphChange;
9
+ this.glyphs = [];
10
+ this.sequentialGlyphs = [];
11
+ this.currentIndex = 0;
12
+ this.isAnimating = false;
13
+ this.animationFrameId = null;
14
+ this.isRandomOrder = false;
15
+
16
+ // Animation timing variables
17
+ this.lastFrameTime = 0;
18
+ this.interval = 100; // Default interval
19
+ }
20
+
21
+ async setGlyphsFromFont(font) {
22
+ if (!font) {
23
+ throw new Error('No font provided');
24
+ }
25
+
26
+ const chars = [];
27
+ for (let i = 0; i < font.glyphs.length; i++) {
28
+ const glyph = font.glyphs.get(i);
29
+ if (glyph.name === '.notdef' || glyph.unicode === undefined) {
30
+ continue;
31
+ }
32
+ const char = String.fromCodePoint(glyph.unicode);
33
+ chars.push(char);
34
+ }
35
+
36
+ this.glyphs = chars;
37
+ this.sequentialGlyphs = [...chars];
38
+ this.currentIndex = 0;
39
+
40
+ if (this.glyphs.length > 0) {
41
+ const firstGlyph = this.glyphs[0];
42
+ this.displayElement.textContent = firstGlyph;
43
+ this.onGlyphChange?.(firstGlyph);
44
+ }
45
+ }
46
+
47
+ start(interval) {
48
+ if (this.glyphs.length === 0) {
49
+ console.error('No glyphs available for animation');
50
+ return;
51
+ }
52
+
53
+ this.interval = interval;
54
+ this.isAnimating = true;
55
+ this.lastFrameTime = performance.now();
56
+ this.animate();
57
+ }
58
+
59
+ stop() {
60
+ this.isAnimating = false;
61
+ if (this.animationFrameId) {
62
+ cancelAnimationFrame(this.animationFrameId);
63
+ this.animationFrameId = null;
64
+ }
65
+ }
66
+
67
+ animate(currentTime = performance.now()) {
68
+ if (!this.isAnimating) return;
69
+
70
+ const elapsed = currentTime - this.lastFrameTime;
71
+
72
+ if (elapsed >= this.interval) {
73
+ // Update the glyph
74
+ const currentChar = this.glyphs[this.currentIndex];
75
+ this.displayElement.textContent = currentChar;
76
+ this.onGlyphChange?.(currentChar);
77
+
78
+ // Move to next glyph
79
+ this.currentIndex = (this.currentIndex + 1) % this.glyphs.length;
80
+
81
+ // Reset timer
82
+ this.lastFrameTime = currentTime - (elapsed % this.interval);
83
+ }
84
+
85
+ this.animationFrameId = requestAnimationFrame(this.animate.bind(this));
86
+ }
87
+
88
+ moveForward(steps = 1) {
89
+ this.currentIndex = (this.currentIndex + steps) % this.glyphs.length;
90
+ const currentChar = this.glyphs[this.currentIndex];
91
+ this.displayElement.textContent = currentChar;
92
+ this.onGlyphChange?.(currentChar);
93
+ }
94
+
95
+ moveBack(steps = 1) {
96
+ this.currentIndex = (this.currentIndex - steps + this.glyphs.length) % this.glyphs.length;
97
+ const currentChar = this.glyphs[this.currentIndex];
98
+ this.displayElement.textContent = currentChar;
99
+ this.onGlyphChange?.(currentChar);
100
+ }
101
+
102
+ toggleOrder() {
103
+ if (this.isRandomOrder) {
104
+ this.glyphs = [...this.sequentialGlyphs];
105
+ } else {
106
+ this.glyphs = this.shuffleArray([...this.glyphs]);
107
+ }
108
+ this.isRandomOrder = !this.isRandomOrder;
109
+ this.currentIndex = 0;
110
+ }
111
+
112
+ shuffleArray(array) {
113
+ for (let i = array.length - 1; i > 0; i--) {
114
+ const j = Math.floor(Math.random() * (i + 1));
115
+ [array[i], array[j]] = [array[j], array[i]];
116
+ }
117
+ return array;
118
+ }
119
+ }