@hypen-space/web 0.4.955 → 0.4.980
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/dist/canvas/layout.js +55 -12
- package/dist/canvas/layout.js.map +1 -1
- package/dist/canvas/paint.js +11 -9
- package/dist/canvas/paint.js.map +1 -1
- package/dist/canvas/text.js +9 -2
- package/dist/canvas/text.js.map +1 -1
- package/dist/canvas/utils.d.ts +15 -0
- package/dist/canvas/utils.js +33 -0
- package/dist/canvas/utils.js.map +1 -1
- package/dist/dom/components/badge.js +2 -1
- package/dist/dom/components/badge.js.map +1 -1
- package/dist/dom/components/heading.js +2 -1
- package/dist/dom/components/heading.js.map +1 -1
- package/dist/dom/components/paragraph.js +2 -1
- package/dist/dom/components/paragraph.js.map +1 -1
- package/dist/dom/components/text.js +5 -2
- package/dist/dom/components/text.js.map +1 -1
- package/package.json +2 -2
- package/src/canvas/layout.ts +54 -11
- package/src/canvas/paint.ts +11 -9
- package/src/canvas/text.ts +9 -2
- package/src/canvas/utils.ts +32 -0
- package/src/dom/components/badge.ts +2 -1
- package/src/dom/components/heading.ts +2 -1
- package/src/dom/components/paragraph.ts +2 -1
- package/src/dom/components/text.ts +5 -2
package/src/canvas/layout.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
parseSize,
|
|
13
13
|
cssLengthToPx,
|
|
14
14
|
cssLengthToDimension,
|
|
15
|
+
resolveLineHeight,
|
|
15
16
|
setCurrentViewport,
|
|
16
17
|
getCurrentViewport,
|
|
17
18
|
} from "./utils.js";
|
|
@@ -179,9 +180,22 @@ function buildTaffyStyle(
|
|
|
179
180
|
// `List` is the DSL's vertical-stack iterator (see dom/components/list.ts
|
|
180
181
|
// — `flex-direction: column` is its default). Match that here so feeds
|
|
181
182
|
// like Notifications stack their rows vertically instead of flowing
|
|
182
|
-
// sideways. The `direction` prop can
|
|
183
|
-
|
|
184
|
-
|
|
183
|
+
// sideways. The legacy `direction` prop can flip it to row, and so can
|
|
184
|
+
// a Tailwind `flex-row` class which lands on `props.flexDirection`
|
|
185
|
+
// (without this, top-tab strips built as `List.flex-row` stacked
|
|
186
|
+
// vertically because the List default won the tie-break).
|
|
187
|
+
const isListColumn =
|
|
188
|
+
type === "list" &&
|
|
189
|
+
props.direction !== "horizontal" &&
|
|
190
|
+
props.flexDirection !== "row";
|
|
191
|
+
// Button defaults to `flex-direction: column` to match the DOM renderer
|
|
192
|
+
// (see dom/components/button.ts). With Taffy's default `align-items:
|
|
193
|
+
// stretch`, treating Button as a row caused inner `Column { Icon, Text }`
|
|
194
|
+
// children to collapse onto each other (icon overlapping label). Only
|
|
195
|
+
// apply the column default when no explicit `flexDirection` is set so a
|
|
196
|
+
// Tailwind `flex-row` on a button still wins.
|
|
197
|
+
const isButtonColumn = type === "button" && props.flexDirection === undefined;
|
|
198
|
+
const isColumn = !isStack && (type === "column" || isListColumn || isButtonColumn || props.flexDirection === "column");
|
|
185
199
|
const isGrid = !isStack && (type === "grid" || props.display === "grid");
|
|
186
200
|
|
|
187
201
|
if (isStack) {
|
|
@@ -372,7 +386,7 @@ function buildTaffyStyle(
|
|
|
372
386
|
// Taffy defaults to `box-sizing: border-box`, so we add the
|
|
373
387
|
// padding/border ourselves into `size.height`.
|
|
374
388
|
const fontSize = cssLengthToPx(props.fontSize) ?? 16;
|
|
375
|
-
const lineHeight =
|
|
389
|
+
const lineHeight = resolveLineHeight(props.lineHeight, fontSize) ?? fontSize * 1.5;
|
|
376
390
|
const minRows = type === "textarea" ? Math.max(1, Number(props.rows) || 3) : 1;
|
|
377
391
|
const padTop = cssLengthToPx(props.paddingTop ?? props.padding) ?? 0;
|
|
378
392
|
const padBottom = cssLengthToPx(props.paddingBottom ?? props.padding) ?? 0;
|
|
@@ -394,7 +408,30 @@ function buildTaffyStyle(
|
|
|
394
408
|
(parent.type.toLowerCase() === "grid" || parent.props.display === "grid");
|
|
395
409
|
const widthIn = parentIsGridLayout && props.width === "100%" ? undefined : props.width;
|
|
396
410
|
const heightIn = parentIsGridLayout && props.height === "100%" ? undefined : props.height;
|
|
397
|
-
|
|
411
|
+
|
|
412
|
+
// Mirror the DOM rule `[data-hypen-type="row"]:has(> [data-hypen-flex])
|
|
413
|
+
// { width: 100% }` (and the symmetric column case). When a flex
|
|
414
|
+
// container has no explicit main-axis size and any child opts into
|
|
415
|
+
// weighted growth (`flex` / `flexGrow > 0`), Taffy would otherwise
|
|
416
|
+
// size the container to its min-content — so a Row of `flex-1`
|
|
417
|
+
// segmented-control buttons stayed pill-sized instead of stretching
|
|
418
|
+
// across the parent. Only do this when at least one child explicitly
|
|
419
|
+
// asks for flex, matching the DOM `:has(> [data-hypen-flex])` guard.
|
|
420
|
+
const isRowContainer = !isStack && !isGrid && !isColumn;
|
|
421
|
+
const isColumnContainer = !isStack && !isGrid && isColumn;
|
|
422
|
+
const childAsksFlex = node.children.some((c) => {
|
|
423
|
+
const fg = parseFloat(c.props.flexGrow);
|
|
424
|
+
if (Number.isFinite(fg) && fg > 0) return true;
|
|
425
|
+
const f = parseFloat(c.props.flex);
|
|
426
|
+
return Number.isFinite(f) && f > 0;
|
|
427
|
+
});
|
|
428
|
+
let resolvedWidth = toDimension(widthIn);
|
|
429
|
+
let resolvedHeight = toDimension(heightIn);
|
|
430
|
+
if (childAsksFlex) {
|
|
431
|
+
if (isRowContainer && widthIn === undefined) resolvedWidth = "100%";
|
|
432
|
+
if (isColumnContainer && heightIn === undefined) resolvedHeight = "100%";
|
|
433
|
+
}
|
|
434
|
+
style.size = { width: resolvedWidth, height: resolvedHeight };
|
|
398
435
|
}
|
|
399
436
|
|
|
400
437
|
// --- Min / Max constraints -------------------------------------------------
|
|
@@ -769,12 +806,17 @@ function buildTree(
|
|
|
769
806
|
// container with size 0×0, and downstream siblings were placed on top of
|
|
770
807
|
// each other ("1,431 likes" overlapping the caption row was the visible
|
|
771
808
|
// symptom).
|
|
772
|
-
|
|
773
|
-
|
|
809
|
+
// `props[0] != null` — not `props[0]` truthy. The number `0`, the empty
|
|
810
|
+
// string, and `false` are valid text content; falsy guards here let
|
|
811
|
+
// those cases skip the measurement leaf and inherit a 0-height container,
|
|
812
|
+
// which made siblings stack on top of each other (a bound `0` overlapping
|
|
813
|
+
// the next line was the visible symptom in calorie-counter's hero).
|
|
814
|
+
if (node.type.toLowerCase() === "text" && props[0] != null && node.children.length === 0) {
|
|
815
|
+
const text = String(props[0]);
|
|
774
816
|
const fontSize = cssLengthToPx(props.fontSize) ?? 16;
|
|
775
817
|
const fontWeight = props.fontWeight || "normal";
|
|
776
818
|
const fontFamily = props.fontFamily || "system-ui, sans-serif";
|
|
777
|
-
const lineHeight =
|
|
819
|
+
const lineHeight = resolveLineHeight(props.lineHeight, fontSize) ?? fontSize * 1.2;
|
|
778
820
|
|
|
779
821
|
const p = parseSpacing(props.padding || 0);
|
|
780
822
|
if (props.paddingTop !== undefined) p.top = cssLengthToPx(props.paddingTop) ?? 0;
|
|
@@ -1201,12 +1243,13 @@ function computeLayoutFallback(
|
|
|
1201
1243
|
if (height === null) height = size;
|
|
1202
1244
|
}
|
|
1203
1245
|
|
|
1204
|
-
|
|
1205
|
-
|
|
1246
|
+
// Same `!= null` rule as above — `0` / `""` / `false` are valid text.
|
|
1247
|
+
if (node.type.toLowerCase() === "text" && node.props[0] != null) {
|
|
1248
|
+
const text = String(node.props[0]);
|
|
1206
1249
|
const fontSize = cssLengthToPx(props.fontSize) ?? 16;
|
|
1207
1250
|
const fontWeight = props.fontWeight || "normal";
|
|
1208
1251
|
const fontFamily = props.fontFamily || "system-ui, sans-serif";
|
|
1209
|
-
const lineHeight =
|
|
1252
|
+
const lineHeight = resolveLineHeight(props.lineHeight, fontSize) ?? fontSize * 1.2;
|
|
1210
1253
|
|
|
1211
1254
|
const maxWidth = width || availableAfterMargin.width - padding.left - padding.right;
|
|
1212
1255
|
const maxLinesRaw = props.maxLines;
|
package/src/canvas/paint.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { renderText } from "./text.js";
|
|
|
9
9
|
import { ScrollManager, isScrollable } from "./scroll.js";
|
|
10
10
|
import { getVisibleChildren, VIRTUALIZE_THRESHOLD } from "./virtualize.js";
|
|
11
11
|
import type { SelectionManager } from "./selection.js";
|
|
12
|
-
import { cssLengthToPx } from "./utils.js";
|
|
12
|
+
import { cssLengthToPx, resolveLineHeight } from "./utils.js";
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* Module-level reference to the active SelectionManager so paintText
|
|
@@ -271,13 +271,14 @@ function paintText(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
|
|
|
271
271
|
const layout = node.layout!;
|
|
272
272
|
const props = node.props;
|
|
273
273
|
|
|
274
|
-
|
|
274
|
+
// `??` (not `||`) so a bound `0`, `false`, or `""` still renders.
|
|
275
|
+
let text = String(props[0] ?? props.text ?? "");
|
|
275
276
|
const color = props.color || "#000000";
|
|
276
277
|
const fontSize = cssLengthToPx(props.fontSize) ?? 16;
|
|
277
278
|
const fontWeight = props.fontWeight || "normal";
|
|
278
279
|
const fontFamily = props.fontFamily || "system-ui, sans-serif";
|
|
279
280
|
const textAlign = props.textAlign || "left";
|
|
280
|
-
const lineHeight =
|
|
281
|
+
const lineHeight = resolveLineHeight(props.lineHeight, fontSize) ?? fontSize * 1.2;
|
|
281
282
|
const textDecoration = props.textDecoration || "none";
|
|
282
283
|
const textTransform = props.textTransform || "none";
|
|
283
284
|
const letterSpacing = cssLengthToPx(props.letterSpacing) ?? 0;
|
|
@@ -474,7 +475,7 @@ function paintInput(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
|
|
|
474
475
|
const fontSize = cssLengthToPx(props.fontSize) ?? 16;
|
|
475
476
|
const fontWeight = props.fontWeight || "normal";
|
|
476
477
|
const fontFamily = props.fontFamily || "system-ui, sans-serif";
|
|
477
|
-
const lineHeight =
|
|
478
|
+
const lineHeight = resolveLineHeight(props.lineHeight, fontSize) ?? fontSize * 1.2;
|
|
478
479
|
|
|
479
480
|
renderText(
|
|
480
481
|
ctx,
|
|
@@ -1332,8 +1333,8 @@ function paintBadge(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
|
|
|
1332
1333
|
drawRoundedRect(ctx, x, y, width, height, radius);
|
|
1333
1334
|
ctx.fill();
|
|
1334
1335
|
|
|
1335
|
-
//
|
|
1336
|
-
const text = String(props[0]
|
|
1336
|
+
// `??` so a literal `0`/`false`/`""` value still renders.
|
|
1337
|
+
const text = String(props[0] ?? props.text ?? "");
|
|
1337
1338
|
if (text) {
|
|
1338
1339
|
ctx.fillStyle = props.color || "#ffffff";
|
|
1339
1340
|
ctx.font = `${props.fontWeight || "bold"} ${props.fontSize || 10}px ${props.fontFamily || "sans-serif"}`;
|
|
@@ -1368,8 +1369,8 @@ function paintAvatar(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
|
|
|
1368
1369
|
ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);
|
|
1369
1370
|
ctx.fill();
|
|
1370
1371
|
|
|
1371
|
-
//
|
|
1372
|
-
const text = String(props[0]
|
|
1372
|
+
// `??` so a literal `0`/`false`/`""` value still renders.
|
|
1373
|
+
const text = String(props[0] ?? props.text ?? props.initials ?? "");
|
|
1373
1374
|
if (text) {
|
|
1374
1375
|
ctx.fillStyle = props.color || "#ffffff";
|
|
1375
1376
|
ctx.font = `${props.fontWeight || "bold"} ${props.fontSize || size / 2.5}px ${props.fontFamily || "sans-serif"}`;
|
|
@@ -1509,7 +1510,8 @@ function paintLink(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
|
|
|
1509
1510
|
const layout = node.layout!;
|
|
1510
1511
|
const props = node.props;
|
|
1511
1512
|
|
|
1512
|
-
|
|
1513
|
+
// `??` so a literal `0`/`false`/`""` value still renders.
|
|
1514
|
+
const text = String(props[0] ?? props.text ?? "");
|
|
1513
1515
|
const color = node.hovered ? (props.hoverColor || "#0056b3") : (props.color || "#007bff");
|
|
1514
1516
|
const fontSize = cssLengthToPx(props.fontSize) ?? 16;
|
|
1515
1517
|
const fontWeight = props.fontWeight || "normal";
|
package/src/canvas/text.ts
CHANGED
|
@@ -191,7 +191,13 @@ export function renderText(
|
|
|
191
191
|
ctx.save();
|
|
192
192
|
ctx.font = font;
|
|
193
193
|
ctx.fillStyle = style.color;
|
|
194
|
-
|
|
194
|
+
// Use `middle` baseline so we can position each glyph at the visual centre
|
|
195
|
+
// of its line box. With `top`, canvas draws from the top of the EM square
|
|
196
|
+
// and any extra `line-height − font-size` space lands as padding BELOW the
|
|
197
|
+
// glyph — visible as `+` inside a round FAB drifting toward the top of
|
|
198
|
+
// the circle. CSS centres glyphs within the line box; matching that
|
|
199
|
+
// requires `middle` + half-lineHeight offset.
|
|
200
|
+
ctx.textBaseline = "middle";
|
|
195
201
|
|
|
196
202
|
const metrics = measureText(ctx, text, style, width, maxLines, textOverflow);
|
|
197
203
|
|
|
@@ -206,7 +212,8 @@ export function renderText(
|
|
|
206
212
|
// Render each line
|
|
207
213
|
for (let i = 0; i < metrics.lines.length; i++) {
|
|
208
214
|
const line = metrics.lines[i];
|
|
209
|
-
|
|
215
|
+
// Centre of the line box (top of line + half of line-height).
|
|
216
|
+
const lineY = startY + i * metrics.lineHeight + metrics.lineHeight / 2;
|
|
210
217
|
|
|
211
218
|
// Calculate X based on text alignment
|
|
212
219
|
let lineX = x;
|
package/src/canvas/utils.ts
CHANGED
|
@@ -86,6 +86,38 @@ export function cssLengthToPx(value: any): number | null {
|
|
|
86
86
|
}
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Resolve a CSS `line-height` value to a pixel value.
|
|
91
|
+
*
|
|
92
|
+
* Unitless numbers (e.g. `1`, `1.5`, `"1.25"`) are CSS multipliers of the
|
|
93
|
+
* font-size — `line-height: 1` (Tailwind's `leading-none`) means line box =
|
|
94
|
+
* 1 × font-size, NOT 1 pixel. Without this branch, `cssLengthToPx("1")`
|
|
95
|
+
* returned literally `1`, and a Column of `leading-none` Texts collapsed
|
|
96
|
+
* each line to 1px tall — emoji + label in a bottom-tab button rendered at
|
|
97
|
+
* the same y and overlapped.
|
|
98
|
+
*
|
|
99
|
+
* Anything with an explicit unit (`16px`, `1.5em`, `1rem`, `120%` is
|
|
100
|
+
* rejected → null since Taffy/measure expects px) falls through to
|
|
101
|
+
* `cssLengthToPx`.
|
|
102
|
+
*/
|
|
103
|
+
export function resolveLineHeight(value: any, fontSize: number): number | null {
|
|
104
|
+
if (value === undefined || value === null) return null;
|
|
105
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
106
|
+
// Unitless number: multiplier of font-size (matches CSS spec).
|
|
107
|
+
return value * fontSize;
|
|
108
|
+
}
|
|
109
|
+
if (typeof value === "string") {
|
|
110
|
+
const s = value.trim();
|
|
111
|
+
if (s === "" || s === "auto" || s === "normal") return null;
|
|
112
|
+
// Pure numeric string with no unit → unitless multiplier.
|
|
113
|
+
if (/^-?\d*\.?\d+$/.test(s)) {
|
|
114
|
+
const n = parseFloat(s);
|
|
115
|
+
return Number.isFinite(n) ? n * fontSize : null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return cssLengthToPx(value);
|
|
119
|
+
}
|
|
120
|
+
|
|
89
121
|
/**
|
|
90
122
|
* Parse a CSS length string for Taffy's Dimension type. Keeps `%` values as
|
|
91
123
|
* the tagged percentage string, turns `"auto"`/empty/invalid into `"auto"`,
|
|
@@ -35,7 +35,8 @@ export const badgeHandler: ComponentHandler = {
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
// Text content
|
|
38
|
-
|
|
38
|
+
// `??` (not `||`) so the literal `0`, `false`, `""` still render.
|
|
39
|
+
const text = props["0"] ?? props.text;
|
|
39
40
|
if (text !== undefined) {
|
|
40
41
|
el.textContent = String(text);
|
|
41
42
|
}
|
|
@@ -32,7 +32,8 @@ export const headingHandler: ComponentHandler = {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
// Text content
|
|
35
|
-
|
|
35
|
+
// `??` (not `||`) so the literal `0`, `false`, `""` still render.
|
|
36
|
+
const text = props["0"] ?? props.text;
|
|
36
37
|
if (text !== undefined) {
|
|
37
38
|
el.textContent = String(text);
|
|
38
39
|
}
|
|
@@ -13,7 +13,8 @@ export const paragraphHandler: ComponentHandler = {
|
|
|
13
13
|
|
|
14
14
|
applyProps(el: HTMLElement, props: Record<string, any>): void {
|
|
15
15
|
// Text content
|
|
16
|
-
|
|
16
|
+
// `??` (not `||`) so the literal `0`, `false`, `""` still render.
|
|
17
|
+
const text = props["0"] ?? props.text;
|
|
17
18
|
if (text !== undefined) {
|
|
18
19
|
el.textContent = String(text);
|
|
19
20
|
}
|
|
@@ -20,8 +20,11 @@ export const textHandler: ComponentHandler = {
|
|
|
20
20
|
},
|
|
21
21
|
|
|
22
22
|
applyProps(el: HTMLElement, props: Record<string, any>): void {
|
|
23
|
-
// Text content from first positional arg or "text" prop
|
|
24
|
-
|
|
23
|
+
// Text content from first positional arg or "text" prop. Use `??` (not
|
|
24
|
+
// `||`) so the literal number `0`, `false`, and the empty string still
|
|
25
|
+
// render — `||` falls through on falsy values and a bound `0` was
|
|
26
|
+
// disappearing entirely on web while iOS rendered it correctly.
|
|
27
|
+
const text = props["0"] ?? props.text;
|
|
25
28
|
if (text !== undefined) {
|
|
26
29
|
// Store the original text template for state interpolation
|
|
27
30
|
el.dataset.textTemplate = String(text);
|