what-text 0.8.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/package.json +31 -0
- package/src/index.js +4 -0
- package/src/text/TextCanvas.js +55 -0
- package/src/text/TextFlow.js +49 -0
- package/src/text/TextSVG.js +77 -0
- package/src/text/index.js +4 -0
- package/src/text-engine.js +195 -0
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "what-text",
|
|
3
|
+
"version": "0.8.0",
|
|
4
|
+
"description": "Optional text engine for What Framework, powered by @chenglou/pretext",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"module": "src/index.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./src/index.js"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"files": ["src"],
|
|
14
|
+
"sideEffects": false,
|
|
15
|
+
"peerDependencies": {
|
|
16
|
+
"what-core": ">=0.7.0",
|
|
17
|
+
"@chenglou/pretext": ">=0.1.0"
|
|
18
|
+
},
|
|
19
|
+
"peerDependenciesMeta": {
|
|
20
|
+
"@chenglou/pretext": {
|
|
21
|
+
"optional": true
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"keywords": ["what-framework", "text", "pretext", "layout", "measurement"],
|
|
25
|
+
"author": "ZVN DEV (https://zvndev.com)",
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "https://github.com/CelsianJs/what-framework"
|
|
30
|
+
}
|
|
31
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// What Text - TextCanvas (ALPHA)
|
|
2
|
+
// Renders text to <canvas> via Pretext layout. Requires @chenglou/pretext.
|
|
3
|
+
// No text selection or a11y in alpha.
|
|
4
|
+
// @alpha APIs may change without a major version bump.
|
|
5
|
+
|
|
6
|
+
import { effect } from 'what-core';
|
|
7
|
+
import { ensurePretext } from '../text-engine.js';
|
|
8
|
+
|
|
9
|
+
function parseFontSize(fontStr) {
|
|
10
|
+
const match = fontStr.match(/(\d+(?:\.\d+)?)\s*px/i);
|
|
11
|
+
return match ? parseFloat(match[1]) : 16;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function TextCanvas(props) {
|
|
15
|
+
const width = props.width || 300;
|
|
16
|
+
const height = props.height || 150;
|
|
17
|
+
const font = props.font || '16px sans-serif';
|
|
18
|
+
const children = props.children;
|
|
19
|
+
|
|
20
|
+
const canvas = document.createElement('canvas');
|
|
21
|
+
canvas.width = width;
|
|
22
|
+
canvas.height = height;
|
|
23
|
+
|
|
24
|
+
// Load Pretext async, then set up reactive rendering
|
|
25
|
+
ensurePretext().then((pretext) => {
|
|
26
|
+
effect(() => {
|
|
27
|
+
const text = typeof children === 'function' ? children() : children;
|
|
28
|
+
const ctx = canvas.getContext && canvas.getContext('2d');
|
|
29
|
+
if (!ctx) return;
|
|
30
|
+
ctx.clearRect(0, 0, width, height);
|
|
31
|
+
ctx.font = font;
|
|
32
|
+
const lineHeight = parseFontSize(font) * 1.5;
|
|
33
|
+
const prepared = pretext.prepareWithSegments(String(text || ''), font);
|
|
34
|
+
const layout = pretext.layoutWithLines(prepared, width, lineHeight);
|
|
35
|
+
ctx.fillStyle = getComputedStyle(canvas).color || '#000';
|
|
36
|
+
ctx.textBaseline = 'top';
|
|
37
|
+
if (layout && Array.isArray(layout.lines)) {
|
|
38
|
+
for (const line of layout.lines) {
|
|
39
|
+
ctx.fillText(line.text, 0, line.start ? line.start.segmentIndex * lineHeight : layout.lines.indexOf(line) * lineHeight);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
}).catch((err) => {
|
|
44
|
+
// Show error in canvas
|
|
45
|
+
const ctx = canvas.getContext && canvas.getContext('2d');
|
|
46
|
+
if (ctx) {
|
|
47
|
+
ctx.fillStyle = '#f44';
|
|
48
|
+
ctx.font = '14px sans-serif';
|
|
49
|
+
ctx.fillText('TextCanvas requires @chenglou/pretext', 10, 30);
|
|
50
|
+
ctx.fillText('npm install @chenglou/pretext', 10, 50);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
return canvas;
|
|
55
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// What Text - TextFlow (ALPHA)
|
|
2
|
+
// Magazine-style text layout. Falls back to CSS columns without Pretext.
|
|
3
|
+
// @alpha APIs may change without a major version bump.
|
|
4
|
+
|
|
5
|
+
import { effect } from 'what-core';
|
|
6
|
+
import { ensurePretext, resolveFontInfo, fontInfoToString } from '../text-engine.js';
|
|
7
|
+
|
|
8
|
+
export function TextFlow(props) {
|
|
9
|
+
const columns = props.columns || 1;
|
|
10
|
+
const around = props.around;
|
|
11
|
+
const gap = props.gap || '1rem';
|
|
12
|
+
const children = props.children;
|
|
13
|
+
|
|
14
|
+
const el = document.createElement('div');
|
|
15
|
+
el.style.columnCount = String(columns);
|
|
16
|
+
el.style.columnGap = gap;
|
|
17
|
+
|
|
18
|
+
// Set text content reactively
|
|
19
|
+
let currentText = '';
|
|
20
|
+
effect(() => {
|
|
21
|
+
const text = typeof children === 'function' ? children() : children;
|
|
22
|
+
currentText = String(text || '');
|
|
23
|
+
el.textContent = currentText;
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// Warn if around is set but Pretext is missing — check AFTER the text effect
|
|
27
|
+
// runs so there's no race condition reading el.textContent
|
|
28
|
+
if (around) {
|
|
29
|
+
ensurePretext().then((pretext) => {
|
|
30
|
+
if (!el.isConnected) return;
|
|
31
|
+
const font = resolveFontInfo(el);
|
|
32
|
+
const fontStr = fontInfoToString(font);
|
|
33
|
+
const width = el.clientWidth || 400;
|
|
34
|
+
const lineHeight = parseFloat(font.lineHeight) || parseFloat(font.fontSize) * 1.2;
|
|
35
|
+
const prepared = pretext.prepareWithSegments(currentText, fontStr);
|
|
36
|
+
const layout = pretext.layoutWithLines(prepared, width / columns, lineHeight);
|
|
37
|
+
if (typeof layout === 'object') {
|
|
38
|
+
el.setAttribute('data-pretext', 'laid-out');
|
|
39
|
+
}
|
|
40
|
+
}).catch(() => {
|
|
41
|
+
console.warn(
|
|
42
|
+
'[what-text] TextFlow: `around` prop requires @chenglou/pretext for shape-flow layout. ' +
|
|
43
|
+
'Install it with: npm i @chenglou/pretext'
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return el;
|
|
49
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// What Text - TextSVG (ALPHA)
|
|
2
|
+
// Renders text as SVG <text>/<tspan> elements via Pretext layout.
|
|
3
|
+
// Requires @chenglou/pretext.
|
|
4
|
+
// @alpha APIs may change without a major version bump.
|
|
5
|
+
|
|
6
|
+
import { effect } from 'what-core';
|
|
7
|
+
import { ensurePretext } from '../text-engine.js';
|
|
8
|
+
|
|
9
|
+
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
10
|
+
|
|
11
|
+
function parseFontSize(fontStr) {
|
|
12
|
+
const match = fontStr.match(/(\d+(?:\.\d+)?)\s*px/i);
|
|
13
|
+
return match ? parseFloat(match[1]) : 16;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function TextSVG(props) {
|
|
17
|
+
const width = props.width || 300;
|
|
18
|
+
const height = props.height || 150;
|
|
19
|
+
const font = props.font || '16px sans-serif';
|
|
20
|
+
const children = props.children;
|
|
21
|
+
|
|
22
|
+
const svg = document.createElementNS(SVG_NS, 'svg');
|
|
23
|
+
svg.setAttribute('width', String(width));
|
|
24
|
+
svg.setAttribute('height', String(height));
|
|
25
|
+
svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
|
|
26
|
+
|
|
27
|
+
const textEl = document.createElementNS(SVG_NS, 'text');
|
|
28
|
+
svg.appendChild(textEl);
|
|
29
|
+
|
|
30
|
+
// Pool of tspan elements to avoid DOM thrash on every update
|
|
31
|
+
let tspanPool = [];
|
|
32
|
+
|
|
33
|
+
ensurePretext().then((pretext) => {
|
|
34
|
+
effect(() => {
|
|
35
|
+
const text = typeof children === 'function' ? children() : children;
|
|
36
|
+
const fSize = parseFontSize(font);
|
|
37
|
+
const lineHeight = fSize * 1.5;
|
|
38
|
+
const prepared = pretext.prepareWithSegments(String(text || ''), font);
|
|
39
|
+
const layout = pretext.layoutWithLines(prepared, width, lineHeight);
|
|
40
|
+
const lines = layout && Array.isArray(layout.lines) ? layout.lines : [];
|
|
41
|
+
|
|
42
|
+
// Grow pool if needed
|
|
43
|
+
while (tspanPool.length < lines.length) {
|
|
44
|
+
const tspan = document.createElementNS(SVG_NS, 'tspan');
|
|
45
|
+
textEl.appendChild(tspan);
|
|
46
|
+
tspanPool.push(tspan);
|
|
47
|
+
}
|
|
48
|
+
// Hide excess tspans
|
|
49
|
+
for (let i = lines.length; i < tspanPool.length; i++) {
|
|
50
|
+
tspanPool[i].textContent = '';
|
|
51
|
+
tspanPool[i].setAttribute('display', 'none');
|
|
52
|
+
}
|
|
53
|
+
// Update visible tspans
|
|
54
|
+
for (let i = 0; i < lines.length; i++) {
|
|
55
|
+
const tspan = tspanPool[i];
|
|
56
|
+
const line = lines[i];
|
|
57
|
+
tspan.setAttribute('x', '0');
|
|
58
|
+
tspan.setAttribute('y', String(fSize + i * lineHeight));
|
|
59
|
+
tspan.setAttribute('font-size', String(fSize));
|
|
60
|
+
tspan.setAttribute('font-family', font.replace(/^\d+(?:\.\d+)?\s*px\s*/, '').trim() || 'sans-serif');
|
|
61
|
+
tspan.setAttribute('display', '');
|
|
62
|
+
tspan.textContent = line.text;
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
}).catch(() => {
|
|
66
|
+
// Show error in SVG
|
|
67
|
+
const errText = document.createElementNS(SVG_NS, 'text');
|
|
68
|
+
errText.setAttribute('x', '10');
|
|
69
|
+
errText.setAttribute('y', '30');
|
|
70
|
+
errText.setAttribute('fill', '#f44');
|
|
71
|
+
errText.setAttribute('font-size', '14');
|
|
72
|
+
errText.textContent = 'TextSVG requires @chenglou/pretext';
|
|
73
|
+
svg.appendChild(errText);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
return svg;
|
|
77
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// What Text — Optional text engine for What Framework
|
|
2
|
+
// Powered by @chenglou/pretext. Registers a text insertion hook with what-core.
|
|
3
|
+
// All Pretext access flows through this module.
|
|
4
|
+
|
|
5
|
+
import { _setTextInsertHook } from 'what-core';
|
|
6
|
+
import { isHydrating } from 'what-core/render';
|
|
7
|
+
|
|
8
|
+
// --- Configuration ---
|
|
9
|
+
|
|
10
|
+
const KNOWN_KEYS = new Set(['measure', 'cacheSize']);
|
|
11
|
+
const DEFAULT_CONFIG = { measure: false, cacheSize: 1000 };
|
|
12
|
+
let textConfig = { ...DEFAULT_CONFIG };
|
|
13
|
+
|
|
14
|
+
export function configureText(overrides) {
|
|
15
|
+
if (!overrides || typeof overrides !== 'object') return;
|
|
16
|
+
for (const key of Object.keys(overrides)) {
|
|
17
|
+
if (KNOWN_KEYS.has(key)) {
|
|
18
|
+
textConfig[key] = overrides[key];
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
// Register or unregister the hook with what-core's render.js
|
|
22
|
+
if (textConfig.measure) {
|
|
23
|
+
_setTextInsertHook(measureTextIfEnabled);
|
|
24
|
+
} else {
|
|
25
|
+
_setTextInsertHook(null);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function getTextConfig() {
|
|
30
|
+
return { ...textConfig };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// --- Lazy Pretext loader ---
|
|
34
|
+
|
|
35
|
+
let pretextModule = null;
|
|
36
|
+
let pretextLoadPromise = null;
|
|
37
|
+
|
|
38
|
+
export async function ensurePretext() {
|
|
39
|
+
if (pretextModule) return pretextModule;
|
|
40
|
+
if (pretextLoadPromise) return pretextLoadPromise;
|
|
41
|
+
pretextLoadPromise = import('@chenglou/pretext').then((mod) => {
|
|
42
|
+
pretextModule = mod;
|
|
43
|
+
return mod;
|
|
44
|
+
}).catch((err) => {
|
|
45
|
+
pretextLoadPromise = null;
|
|
46
|
+
throw new Error(
|
|
47
|
+
`[what-text] Failed to load @chenglou/pretext. ` +
|
|
48
|
+
`Install it with: npm install @chenglou/pretext\n` +
|
|
49
|
+
`Original error: ${err.message}`
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
return pretextLoadPromise;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function _setPretextForTests(fake) {
|
|
56
|
+
pretextModule = fake;
|
|
57
|
+
pretextLoadPromise = Promise.resolve(fake);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function _getPretextSync() {
|
|
61
|
+
return pretextModule;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// --- LRU measure cache ---
|
|
65
|
+
|
|
66
|
+
const measureCache = new Map();
|
|
67
|
+
|
|
68
|
+
function cacheGet(key) {
|
|
69
|
+
if (!measureCache.has(key)) return undefined;
|
|
70
|
+
const value = measureCache.get(key);
|
|
71
|
+
measureCache.delete(key);
|
|
72
|
+
measureCache.set(key, value);
|
|
73
|
+
return value;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function cacheSet(key, value) {
|
|
77
|
+
if (measureCache.has(key)) {
|
|
78
|
+
measureCache.delete(key);
|
|
79
|
+
} else if (measureCache.size >= textConfig.cacheSize) {
|
|
80
|
+
const oldest = measureCache.keys().next().value;
|
|
81
|
+
measureCache.delete(oldest);
|
|
82
|
+
}
|
|
83
|
+
measureCache.set(key, value);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function measureText(text, font, containerWidth, lineHeight) {
|
|
87
|
+
await ensureFontsReady();
|
|
88
|
+
const pretext = await ensurePretext();
|
|
89
|
+
const cacheKey = `${font}|${text}`;
|
|
90
|
+
let prepared = cacheGet(cacheKey);
|
|
91
|
+
if (!prepared) {
|
|
92
|
+
prepared = pretext.prepareWithSegments(text, font);
|
|
93
|
+
cacheSet(cacheKey, prepared);
|
|
94
|
+
}
|
|
95
|
+
return pretext.layoutWithLines(prepared, containerWidth, lineHeight);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function clearMeasureCache() {
|
|
99
|
+
measureCache.clear();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// --- Font resolution ---
|
|
103
|
+
|
|
104
|
+
const FONT_DEFAULTS = {
|
|
105
|
+
fontFamily: 'sans-serif',
|
|
106
|
+
fontSize: '16px',
|
|
107
|
+
fontWeight: '400',
|
|
108
|
+
fontStyle: 'normal',
|
|
109
|
+
lineHeight: 'normal',
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
export function resolveFontInfo(el) {
|
|
113
|
+
if (typeof getComputedStyle === 'undefined' || !el) {
|
|
114
|
+
return { ...FONT_DEFAULTS };
|
|
115
|
+
}
|
|
116
|
+
const style = getComputedStyle(el);
|
|
117
|
+
return {
|
|
118
|
+
fontFamily: style.fontFamily || FONT_DEFAULTS.fontFamily,
|
|
119
|
+
fontSize: style.fontSize || FONT_DEFAULTS.fontSize,
|
|
120
|
+
fontWeight: style.fontWeight || FONT_DEFAULTS.fontWeight,
|
|
121
|
+
fontStyle: style.fontStyle || FONT_DEFAULTS.fontStyle,
|
|
122
|
+
lineHeight: style.lineHeight || FONT_DEFAULTS.lineHeight,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function fontInfoToString(info) {
|
|
127
|
+
return `${info.fontStyle} ${info.fontWeight} ${info.fontSize} ${info.fontFamily}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// --- Font size parsing (fixes parseFloat bug) ---
|
|
131
|
+
// parseFloat('700 14px Inter') returns 700, not 14.
|
|
132
|
+
// This extracts the actual font size from a CSS font shorthand.
|
|
133
|
+
|
|
134
|
+
function parseFontSize(fontStr) {
|
|
135
|
+
const match = fontStr.match(/(\d+(?:\.\d+)?)\s*px/i);
|
|
136
|
+
return match ? parseFloat(match[1]) : 16;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// --- Font-ready gate ---
|
|
140
|
+
|
|
141
|
+
let fontsReadyPromise = null;
|
|
142
|
+
|
|
143
|
+
export function ensureFontsReady() {
|
|
144
|
+
if (fontsReadyPromise) return fontsReadyPromise;
|
|
145
|
+
if (typeof document === 'undefined' || !document.fonts || !document.fonts.ready) {
|
|
146
|
+
fontsReadyPromise = Promise.resolve();
|
|
147
|
+
return fontsReadyPromise;
|
|
148
|
+
}
|
|
149
|
+
fontsReadyPromise = document.fonts.ready.then(() => {
|
|
150
|
+
document.fonts.addEventListener('loadingdone', () => {
|
|
151
|
+
clearMeasureCache();
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
return fontsReadyPromise;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// --- Measure hook (registered with what-core via _setTextInsertHook) ---
|
|
158
|
+
|
|
159
|
+
let _hookInvocationCount = 0;
|
|
160
|
+
|
|
161
|
+
export function measureTextIfEnabled(parent, text) {
|
|
162
|
+
if (!textConfig.measure) return;
|
|
163
|
+
if (isHydrating()) return;
|
|
164
|
+
_hookInvocationCount++;
|
|
165
|
+
queueMicrotask(() => {
|
|
166
|
+
if (!parent || !parent.ownerDocument) return;
|
|
167
|
+
if (typeof parent.isConnected === 'boolean' && !parent.isConnected) return;
|
|
168
|
+
const font = resolveFontInfo(parent);
|
|
169
|
+
const fontStr = fontInfoToString(font);
|
|
170
|
+
const width = parent.clientWidth || 0;
|
|
171
|
+
const lh = parseFloat(font.lineHeight) || parseFontSize(font.fontSize) * 1.2;
|
|
172
|
+
if (width === 0) return;
|
|
173
|
+
measureText(text, fontStr, width, lh).catch(() => {});
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function _wasMeasureHookInvoked() {
|
|
178
|
+
return _hookInvocationCount > 0;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function _resetMeasureHookInvocation() {
|
|
182
|
+
_hookInvocationCount = 0;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// --- Test helpers ---
|
|
186
|
+
|
|
187
|
+
export function _resetTextEngineForTests() {
|
|
188
|
+
textConfig = { ...DEFAULT_CONFIG };
|
|
189
|
+
pretextModule = null;
|
|
190
|
+
pretextLoadPromise = null;
|
|
191
|
+
measureCache.clear();
|
|
192
|
+
fontsReadyPromise = null;
|
|
193
|
+
_hookInvocationCount = 0;
|
|
194
|
+
_setTextInsertHook(null);
|
|
195
|
+
}
|