@djangocfg/ui-core 2.1.417 → 2.1.419
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/README.md +29 -2
- package/package.json +12 -4
- package/src/components/data/BalancedText/BalancedText.tsx +74 -0
- package/src/components/data/BalancedText/README.md +29 -0
- package/src/components/data/BalancedText/hooks/useMaxLinesWidth.ts +79 -0
- package/src/components/data/BalancedText/hooks/useMeasuredFont.ts +35 -0
- package/src/components/data/BalancedText/index.ts +5 -0
- package/src/components/data/BalancedText/types.ts +59 -0
- package/src/components/index.ts +2 -0
- package/src/hooks/dom/index.ts +2 -0
- package/src/hooks/dom/useResizeObserver.ts +117 -0
- package/src/lib/index.ts +1 -0
- package/src/lib/intensity.ts +40 -0
- package/src/lib/pretext/index.ts +18 -0
- package/src/lib/pretext/pretext.types.ts +72 -0
- package/src/lib/pretext/use-pretext.ts +211 -0
- package/src/styles/theme/tokens.css +6 -0
package/README.md
CHANGED
|
@@ -60,7 +60,7 @@ Organized in `components/` by category — everything re-exported from the root
|
|
|
60
60
|
| **Navigation** | `Link`, `Breadcrumb`, `BreadcrumbNavigation`, `Pagination`, `StaticPagination`, `SSRPagination`, `Sidebar` (full shadcn primitives), `Tabs`, `Accordion`, `Collapsible`, `Command`, `DropdownMenu`, `ContextMenu`, `Menubar`, `NavigationMenu`, `MenuBuilder` (declarative data-driven menu) |
|
|
61
61
|
| **Theme** | `ThemeProvider`, `ThemeToggle`, `ForceTheme`, `ThemeOverride`, `useThemeContext`, `useForcedTheme` — framework-agnostic theme runtime |
|
|
62
62
|
| **Layout** | `Card`, `Section`, `Sticky`, `ScrollArea`, `Resizable`, `Separator`, `Skeleton`, `AspectRatio` |
|
|
63
|
-
| **Data** | `Table`, `Badge`, `Avatar`, `Progress`, `Carousel`, `Calendar`, `DatePicker`, `DateRangePicker`, `Toggle`, `ToggleGroup`, `Chart
|
|
63
|
+
| **Data** | `Table`, `Badge`, `Avatar`, `Progress`, `Carousel`, `Calendar`, `DatePicker`, `DateRangePicker`, `Toggle`, `ToggleGroup`, `Chart*`, `BalancedText` (pretext-powered line balancing) |
|
|
64
64
|
| **Feedback** | `Alert`, `Spinner`, `Empty`, `Preloader`, `Toaster` (Sonner) |
|
|
65
65
|
| **Boundary** | `Boundary` (React error boundary with `silent`/`inline`/`card`/`fullscreen` variants, `resetKeys`, custom `fallback`) |
|
|
66
66
|
| **Specialized** | `Kbd`, `CopyButton`, `CopyField`, `TokenIcon`, `Item`, `Portal`, `ImageWithFallback`, `Flag`, `LanguageFlag` |
|
|
@@ -97,7 +97,7 @@ import { useNavigate, useLocation, useQueryParams, useRouter, useIsActive } from
|
|
|
97
97
|
| **Media** | `useIsMobile`, `useIsPhone`, `useIsTabletOrBelow`, `useMediaQuery` |
|
|
98
98
|
| **Device** | `useDeviceDetect`, `useBrowserDetect`, `useShortcutModLabel` |
|
|
99
99
|
| **State** | `useDebounce`, `useDebouncedCallback`, `useCountdown`, `useImageLoader`, `useMounted` |
|
|
100
|
-
| **DOM** | `useEventListener` |
|
|
100
|
+
| **DOM** | `useEventListener`, `useResizeObserver` (shared ResizeObserver store, returns `{ width, height }`) |
|
|
101
101
|
| **Theme** | `useResolvedTheme` (light/dark detection), `useThemeColor`, `useThemePalette` (palette-aware hex colors for Canvas/SVG) |
|
|
102
102
|
| **Hotkey** | `useHotkey` (smart `inInput` + `preventDefault` policy), `useHotkeyChord` (sequences), `formatHotkey('mod+k') → ⌘K`, `useHotkeyHelp` (auto cheat-sheet) |
|
|
103
103
|
| **Audio** | `createSoundBus`, `useNotificationSounds`, `useAudioPrefs`, `useSoundEffect` — Safari unlock, mute persist, per-event toggles + volume scale, native-host bridge |
|
|
@@ -235,6 +235,33 @@ const log = createLogger('MyComponent');
|
|
|
235
235
|
log.info('user logged in', { userId: 123 });
|
|
236
236
|
```
|
|
237
237
|
|
|
238
|
+
## Lib utilities
|
|
239
|
+
|
|
240
|
+
```tsx
|
|
241
|
+
import { cn, createLogger, getIntensity } from '@djangocfg/ui-core/lib';
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
| Utility | Purpose |
|
|
245
|
+
|---|---|
|
|
246
|
+
| `cn` | Tailwind class merger (`tailwind-merge` + `clsx`). |
|
|
247
|
+
| `createLogger` | Tagged console logger. |
|
|
248
|
+
| `getIntensity(value, max, thresholds?)` | Quantize a value to an integer bucket (0–N). Used for heatmaps, gauges, thermal-style visualizations. Default thresholds `[0.25, 0.5, 0.75]` map ratios to four non-zero buckets. |
|
|
249
|
+
| `compose-refs`, `compose-event-handlers`, `get-element-ref` | Radix-style ref/handler helpers. |
|
|
250
|
+
| `dialog-service`, `persist`, `env`, `logger` | See dedicated sections. |
|
|
251
|
+
|
|
252
|
+
### Pretext
|
|
253
|
+
|
|
254
|
+
```tsx
|
|
255
|
+
import {
|
|
256
|
+
usePretext,
|
|
257
|
+
usePretextLayout,
|
|
258
|
+
usePretextLines,
|
|
259
|
+
useBalancedWidth,
|
|
260
|
+
} from '@djangocfg/ui-core/lib/pretext';
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
DOM-free text measurement hooks wrapping [`@chenglou/pretext`](https://github.com/chenglou/pretext) (optional peer dependency). `useBalancedWidth` powers `<BalancedText>`. Falls back gracefully on SSR or when `@chenglou/pretext` is not installed.
|
|
264
|
+
|
|
238
265
|
## Styles & Theming
|
|
239
266
|
|
|
240
267
|
```css
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/ui-core",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.419",
|
|
4
4
|
"description": "Pure React UI component library without Next.js dependencies - for Electron, Vite, CRA apps",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ui-components",
|
|
@@ -63,6 +63,11 @@
|
|
|
63
63
|
"import": "./src/lib/dialog-service/index.ts",
|
|
64
64
|
"require": "./src/lib/dialog-service/index.ts"
|
|
65
65
|
},
|
|
66
|
+
"./lib/pretext": {
|
|
67
|
+
"types": "./src/lib/pretext/index.ts",
|
|
68
|
+
"import": "./src/lib/pretext/index.ts",
|
|
69
|
+
"require": "./src/lib/pretext/index.ts"
|
|
70
|
+
},
|
|
66
71
|
"./providers": {
|
|
67
72
|
"types": "./src/providers/index.ts",
|
|
68
73
|
"import": "./src/providers/index.ts",
|
|
@@ -100,7 +105,7 @@
|
|
|
100
105
|
"check": "tsc --noEmit"
|
|
101
106
|
},
|
|
102
107
|
"peerDependencies": {
|
|
103
|
-
"@djangocfg/i18n": "^2.1.
|
|
108
|
+
"@djangocfg/i18n": "^2.1.419",
|
|
104
109
|
"consola": "^3.4.2",
|
|
105
110
|
"lucide-react": "^0.545.0",
|
|
106
111
|
"moment": "^2.30.1",
|
|
@@ -170,9 +175,12 @@
|
|
|
170
175
|
"tailwind-merge": "^3.3.1",
|
|
171
176
|
"vaul": "1.1.2"
|
|
172
177
|
},
|
|
178
|
+
"optionalDependencies": {
|
|
179
|
+
"@chenglou/pretext": "*"
|
|
180
|
+
},
|
|
173
181
|
"devDependencies": {
|
|
174
|
-
"@djangocfg/i18n": "^2.1.
|
|
175
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
182
|
+
"@djangocfg/i18n": "^2.1.419",
|
|
183
|
+
"@djangocfg/typescript-config": "^2.1.419",
|
|
176
184
|
"@types/node": "^25.2.3",
|
|
177
185
|
"@types/react": "^19.2.15",
|
|
178
186
|
"@types/react-dom": "^19.2.3",
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Adapted from jalcoui (MIT) — github.com/jal-co/ui
|
|
2
|
+
//
|
|
3
|
+
// Pretext-powered balanced text. CSS `text-wrap: balance` only works up to
|
|
4
|
+
// ~6 lines and is inconsistent cross-browser; this is deterministic and
|
|
5
|
+
// scales to any length. On SSR / before measurement we fall back to the
|
|
6
|
+
// CSS rule so the first paint is never visibly off — once pretext has
|
|
7
|
+
// measured a width, the inline `maxWidth` style takes over.
|
|
8
|
+
//
|
|
9
|
+
// Powered by Pretext by Cheng Lou — github.com/chenglou/pretext
|
|
10
|
+
|
|
11
|
+
'use client';
|
|
12
|
+
|
|
13
|
+
import * as React from 'react';
|
|
14
|
+
import { cn } from '../../../lib/utils';
|
|
15
|
+
import {
|
|
16
|
+
usePretextWithSegments,
|
|
17
|
+
useBalancedWidth,
|
|
18
|
+
} from '../../../lib/pretext';
|
|
19
|
+
import {
|
|
20
|
+
DEFAULT_BALANCED_FONT,
|
|
21
|
+
DEFAULT_BALANCED_MAX_WIDTH,
|
|
22
|
+
type BalancedTextProps,
|
|
23
|
+
} from './types';
|
|
24
|
+
import { useMeasuredFont } from './hooks/useMeasuredFont';
|
|
25
|
+
import { useMaxLinesWidth } from './hooks/useMaxLinesWidth';
|
|
26
|
+
|
|
27
|
+
function BalancedTextImpl({
|
|
28
|
+
children,
|
|
29
|
+
font,
|
|
30
|
+
maxWidth = DEFAULT_BALANCED_MAX_WIDTH,
|
|
31
|
+
maxLines,
|
|
32
|
+
as,
|
|
33
|
+
className,
|
|
34
|
+
style,
|
|
35
|
+
...rest
|
|
36
|
+
}: BalancedTextProps) {
|
|
37
|
+
const Tag = (as ?? 'span') as React.ElementType;
|
|
38
|
+
const ref = React.useRef<HTMLElement | null>(null);
|
|
39
|
+
|
|
40
|
+
// Resolve the font: explicit prop wins; otherwise read computed style after mount.
|
|
41
|
+
const measuredFont = useMeasuredFont(ref, font);
|
|
42
|
+
const effectiveFont = font ?? measuredFont ?? DEFAULT_BALANCED_FONT;
|
|
43
|
+
|
|
44
|
+
const prepared = usePretextWithSegments(children, effectiveFont);
|
|
45
|
+
// Balanced width that preserves the natural line count at maxWidth.
|
|
46
|
+
const balancedAtMax = useBalancedWidth(prepared, maxWidth);
|
|
47
|
+
// If maxLines is set, search the narrower width that yields ≤ maxLines lines.
|
|
48
|
+
const lineCappedWidth = useMaxLinesWidth(prepared, maxWidth, maxLines);
|
|
49
|
+
|
|
50
|
+
const hasMeasured = balancedAtMax > 0 || lineCappedWidth > 0;
|
|
51
|
+
const finalWidth = lineCappedWidth || balancedAtMax;
|
|
52
|
+
|
|
53
|
+
// SSR / pre-measurement: rely on CSS `text-wrap: balance`. After measurement,
|
|
54
|
+
// the inline `maxWidth` provides a deterministic balanced layout.
|
|
55
|
+
const inlineStyle: React.CSSProperties = hasMeasured
|
|
56
|
+
? { maxWidth: finalWidth, ...style }
|
|
57
|
+
: { textWrap: 'balance', ...style };
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<Tag
|
|
61
|
+
ref={ref}
|
|
62
|
+
data-slot="balanced-text"
|
|
63
|
+
data-measured={hasMeasured ? 'true' : 'false'}
|
|
64
|
+
className={cn(className)}
|
|
65
|
+
style={inlineStyle}
|
|
66
|
+
{...rest}
|
|
67
|
+
>
|
|
68
|
+
{children}
|
|
69
|
+
</Tag>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const BalancedText = React.memo(BalancedTextImpl);
|
|
74
|
+
BalancedText.displayName = 'BalancedText';
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# BalancedText
|
|
2
|
+
|
|
3
|
+
Deterministic line-balancing for headings and short paragraphs. Wraps `@chenglou/pretext`'s `useBalancedWidth` to compute the narrowest container width that preserves the natural line count (or `maxLines`).
|
|
4
|
+
|
|
5
|
+
CSS `text-wrap: balance` is used as the SSR fallback — the balanced width takes over after mount.
|
|
6
|
+
|
|
7
|
+
```tsx
|
|
8
|
+
import { BalancedText } from '@djangocfg/ui-core';
|
|
9
|
+
|
|
10
|
+
<BalancedText as="h1" maxLines={2}>
|
|
11
|
+
A balanced heading that never produces an orphan word
|
|
12
|
+
</BalancedText>
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Props
|
|
16
|
+
|
|
17
|
+
| Prop | Type | Default | Description |
|
|
18
|
+
|---|---|---|---|
|
|
19
|
+
| `children` | `string` | — | Text to balance. **Must be a plain string** — pretext measures glyphs, not React trees. |
|
|
20
|
+
| `font` | `string` | computed style | CSS font shorthand for measurement (e.g. `'16px Inter, sans-serif'`). |
|
|
21
|
+
| `maxWidth` | `number` | parent `clientWidth` or `600` | Hard cap on container width in px. |
|
|
22
|
+
| `maxLines` | `number` | — | Cap on rendered lines. Forces the narrowest width that keeps line count ≤ `maxLines`. |
|
|
23
|
+
| `as` | `ElementType` | `'span'` | Element to render as. |
|
|
24
|
+
|
|
25
|
+
Storybook: `apps/storybook/stories/ui-core/data/BalancedText.stories.tsx`
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
Adapted from jalcoui (MIT). Powered by [`@chenglou/pretext`](https://github.com/chenglou/pretext) (optional peer dependency).
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Adapted from jalcoui (MIT) — github.com/jal-co/ui
|
|
2
|
+
//
|
|
3
|
+
// When `maxLines` is set we want the narrowest width that produces ≤ maxLines.
|
|
4
|
+
// `useBalancedWidth` only preserves the *natural* line count at maxWidth — it
|
|
5
|
+
// won't push the text into fewer lines than the user typed by accident.
|
|
6
|
+
// This hook complements it: it walks line counts and binary-searches for a
|
|
7
|
+
// width ≤ maxWidth that yields exactly `min(natural, maxLines)` lines.
|
|
8
|
+
|
|
9
|
+
'use client';
|
|
10
|
+
|
|
11
|
+
import * as React from 'react';
|
|
12
|
+
import type { PreparedTextWithSegments } from '../../../../lib/pretext';
|
|
13
|
+
|
|
14
|
+
// We replicate the structure of `useBalancedWidth` so that pretext stays an
|
|
15
|
+
// optionalDependency: import the runtime lazily via require, mirror the
|
|
16
|
+
// minimal `walkLineRanges` surface.
|
|
17
|
+
interface WalkLineRange {
|
|
18
|
+
width: number;
|
|
19
|
+
start: number;
|
|
20
|
+
end: number;
|
|
21
|
+
}
|
|
22
|
+
interface PretextWalker {
|
|
23
|
+
walkLineRanges(
|
|
24
|
+
prepared: PreparedTextWithSegments,
|
|
25
|
+
maxWidth: number,
|
|
26
|
+
visit: (line: WalkLineRange) => void,
|
|
27
|
+
): void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let cached: PretextWalker | null = null;
|
|
31
|
+
function getPretext(): PretextWalker {
|
|
32
|
+
if (cached) return cached;
|
|
33
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
34
|
+
cached = require('@chenglou/pretext') as PretextWalker;
|
|
35
|
+
return cached;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function useMaxLinesWidth(
|
|
39
|
+
prepared: PreparedTextWithSegments | null,
|
|
40
|
+
maxWidth: number,
|
|
41
|
+
maxLines: number | undefined,
|
|
42
|
+
): number {
|
|
43
|
+
return React.useMemo(() => {
|
|
44
|
+
if (!prepared || !maxLines || maxLines <= 0 || maxWidth <= 0) return 0;
|
|
45
|
+
|
|
46
|
+
const { walkLineRanges } = getPretext();
|
|
47
|
+
|
|
48
|
+
// Natural line count at maxWidth. If it's already within the cap, defer
|
|
49
|
+
// to `useBalancedWidth` (returning 0 means "don't use my value").
|
|
50
|
+
let naturalCount = 0;
|
|
51
|
+
walkLineRanges(prepared, maxWidth, () => {
|
|
52
|
+
naturalCount++;
|
|
53
|
+
});
|
|
54
|
+
if (naturalCount <= maxLines) return 0;
|
|
55
|
+
|
|
56
|
+
// Binary search: find the narrowest width that produces ≤ maxLines lines.
|
|
57
|
+
// Wider width → fewer lines, narrower width → more lines.
|
|
58
|
+
let lo = 1;
|
|
59
|
+
let hi = Math.ceil(maxWidth);
|
|
60
|
+
let best = hi;
|
|
61
|
+
|
|
62
|
+
while (lo <= hi) {
|
|
63
|
+
const mid = Math.floor((lo + hi) / 2);
|
|
64
|
+
let count = 0;
|
|
65
|
+
walkLineRanges(prepared, mid, () => {
|
|
66
|
+
count++;
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
if (count <= maxLines) {
|
|
70
|
+
best = mid;
|
|
71
|
+
hi = mid - 1; // try narrower
|
|
72
|
+
} else {
|
|
73
|
+
lo = mid + 1;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return best;
|
|
78
|
+
}, [prepared, maxWidth, maxLines]);
|
|
79
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Adapted from jalcoui (MIT) — github.com/jal-co/ui
|
|
2
|
+
//
|
|
3
|
+
// Read the host element's computed font shorthand after mount so the caller
|
|
4
|
+
// doesn't have to duplicate the design tokens that style the element.
|
|
5
|
+
// Returns `null` until the effect has run (SSR + first render), at which point
|
|
6
|
+
// the BalancedText component falls back to a literal default font string.
|
|
7
|
+
|
|
8
|
+
'use client';
|
|
9
|
+
|
|
10
|
+
import * as React from 'react';
|
|
11
|
+
|
|
12
|
+
export function useMeasuredFont(
|
|
13
|
+
ref: React.RefObject<HTMLElement | null>,
|
|
14
|
+
explicitFont: string | undefined,
|
|
15
|
+
): string | null {
|
|
16
|
+
const [font, setFont] = React.useState<string | null>(null);
|
|
17
|
+
|
|
18
|
+
React.useEffect(() => {
|
|
19
|
+
if (explicitFont) return; // caller wins; skip measurement
|
|
20
|
+
const el = ref.current;
|
|
21
|
+
if (!el) return;
|
|
22
|
+
const cs = getComputedStyle(el);
|
|
23
|
+
// `font` shorthand is the most compact; some browsers return '' when
|
|
24
|
+
// individual properties were set separately — fall back to a manual build.
|
|
25
|
+
const shorthand = cs.font;
|
|
26
|
+
if (shorthand && shorthand.trim() !== '') {
|
|
27
|
+
setFont(shorthand);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const composed = `${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize} / ${cs.lineHeight} ${cs.fontFamily}`;
|
|
31
|
+
setFont(composed);
|
|
32
|
+
}, [ref, explicitFont]);
|
|
33
|
+
|
|
34
|
+
return font;
|
|
35
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Adapted from jalcoui (MIT) — github.com/jal-co/ui
|
|
2
|
+
|
|
3
|
+
import type { ComponentPropsWithoutRef, ElementType, ReactNode } from 'react';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* CSS font shorthand for Pretext measurement.
|
|
7
|
+
*
|
|
8
|
+
* Must match the actually rendered font, otherwise the balanced width will be
|
|
9
|
+
* off. When `font` is omitted, the component reads `getComputedStyle()` on the
|
|
10
|
+
* element after mount and falls back to {@link DEFAULT_BALANCED_FONT} on the
|
|
11
|
+
* server.
|
|
12
|
+
*/
|
|
13
|
+
export type BalancedFont = string;
|
|
14
|
+
|
|
15
|
+
export interface BalancedTextOwnProps {
|
|
16
|
+
/** Text to balance. Required as a string — pretext measures runs of glyphs, not React trees. */
|
|
17
|
+
children: string;
|
|
18
|
+
/** CSS font shorthand (`'16px Inter, sans-serif'`). When omitted, reads `getComputedStyle`. */
|
|
19
|
+
font?: BalancedFont;
|
|
20
|
+
/**
|
|
21
|
+
* Hard cap on width in px. Balanced width never exceeds this. When omitted,
|
|
22
|
+
* the parent container's measured `clientWidth` is used. `maxLines` cannot
|
|
23
|
+
* make the text wider than this.
|
|
24
|
+
* @default 600
|
|
25
|
+
*/
|
|
26
|
+
maxWidth?: number;
|
|
27
|
+
/**
|
|
28
|
+
* Cap on number of lines. When set, the balanced width is the *narrowest*
|
|
29
|
+
* width that still keeps ≤ `maxLines` lines (instead of preserving the
|
|
30
|
+
* natural line count at `maxWidth`). Useful for headings.
|
|
31
|
+
*/
|
|
32
|
+
maxLines?: number;
|
|
33
|
+
/** Element to render as. @default 'span' */
|
|
34
|
+
as?: ElementType;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Props for {@link BalancedText}. */
|
|
38
|
+
export type BalancedTextProps = BalancedTextOwnProps &
|
|
39
|
+
Omit<ComponentPropsWithoutRef<'span'>, keyof BalancedTextOwnProps | 'children'> & {
|
|
40
|
+
children: string;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** Fallback used on SSR and when `font` is not provided + measurement hasn't run. */
|
|
44
|
+
export const DEFAULT_BALANCED_FONT: BalancedFont =
|
|
45
|
+
'16px ui-sans-serif, system-ui, -apple-system, sans-serif';
|
|
46
|
+
|
|
47
|
+
export const DEFAULT_BALANCED_MAX_WIDTH = 600;
|
|
48
|
+
|
|
49
|
+
/** Internal: BalancedText render shape (used by subcomponents). */
|
|
50
|
+
export interface InternalRenderProps {
|
|
51
|
+
Tag: ElementType;
|
|
52
|
+
balancedWidth: number;
|
|
53
|
+
hasMeasured: boolean;
|
|
54
|
+
text: string;
|
|
55
|
+
className?: string;
|
|
56
|
+
style?: React.CSSProperties;
|
|
57
|
+
rest: Record<string, unknown>;
|
|
58
|
+
textNode: ReactNode;
|
|
59
|
+
}
|
package/src/components/index.ts
CHANGED
|
@@ -187,6 +187,8 @@ export { CircularProgress, CircularProgressCombined, CircularProgressIndicator,
|
|
|
187
187
|
export type { CircularProgressProps } from './data/circular-progress';
|
|
188
188
|
export { RelativeTimeCard, relativeTimeCardVariants } from './data/relative-time-card';
|
|
189
189
|
export type { RelativeTimeCardProps } from './data/relative-time-card';
|
|
190
|
+
export { BalancedText, DEFAULT_BALANCED_FONT, DEFAULT_BALANCED_MAX_WIDTH } from './data/BalancedText';
|
|
191
|
+
export type { BalancedTextProps, BalancedFont } from './data/BalancedText';
|
|
190
192
|
|
|
191
193
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
192
194
|
// Chart Components
|
package/src/hooks/dom/index.ts
CHANGED
|
@@ -12,5 +12,7 @@ export {
|
|
|
12
12
|
export type { ScrollSnapshot, ScrollDirection, ScrollTarget } from './useScroll';
|
|
13
13
|
export { useLayoutEffect } from './useLayoutEffect';
|
|
14
14
|
export { useSize } from './useSize';
|
|
15
|
+
export { useResizeObserver } from './useResizeObserver';
|
|
16
|
+
export type { Size } from './useResizeObserver';
|
|
15
17
|
export { useFormReset } from './useFormReset';
|
|
16
18
|
export type { UseFormResetParams } from './useFormReset';
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Adapted from jalcoui (MIT) — github.com/jal-co/ui
|
|
2
|
+
//
|
|
3
|
+
// Shared ResizeObserver store with refcounting.
|
|
4
|
+
//
|
|
5
|
+
// Why a shared observer (vs one per `useSize`/`useResizeObserver` call):
|
|
6
|
+
// - A single ResizeObserver instance is cheaper than N when many
|
|
7
|
+
// components subscribe (charts, sparklines, masonry items, …).
|
|
8
|
+
// - Avoids "ResizeObserver loop completed with undelivered notifications"
|
|
9
|
+
// warnings that pile up when many tiny observers fire in the same frame.
|
|
10
|
+
//
|
|
11
|
+
// The store keeps one global RO; per-element listener sets are reference
|
|
12
|
+
// counted so the observer detaches from elements once nobody listens.
|
|
13
|
+
//
|
|
14
|
+
// Inspired by the pattern at activity-graph.tsx:180-188 in jalcoui.
|
|
15
|
+
|
|
16
|
+
'use client';
|
|
17
|
+
|
|
18
|
+
import * as React from 'react';
|
|
19
|
+
import { useLayoutEffect } from './useLayoutEffect';
|
|
20
|
+
|
|
21
|
+
export interface Size {
|
|
22
|
+
width: number;
|
|
23
|
+
height: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type Listener = (size: Size) => void;
|
|
27
|
+
|
|
28
|
+
interface Entry {
|
|
29
|
+
listeners: Set<Listener>;
|
|
30
|
+
last: Size;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let sharedObserver: ResizeObserver | null = null;
|
|
34
|
+
const entries = new WeakMap<Element, Entry>();
|
|
35
|
+
// Mirror keyset so we can resolve elements from RO entries.
|
|
36
|
+
const elementByEntry = new WeakMap<Element, Entry>();
|
|
37
|
+
|
|
38
|
+
function getObserver(): ResizeObserver {
|
|
39
|
+
if (sharedObserver) return sharedObserver;
|
|
40
|
+
sharedObserver = new ResizeObserver((roEntries) => {
|
|
41
|
+
for (const roEntry of roEntries) {
|
|
42
|
+
const el = roEntry.target;
|
|
43
|
+
const entry = elementByEntry.get(el);
|
|
44
|
+
if (!entry) continue;
|
|
45
|
+
|
|
46
|
+
let width: number;
|
|
47
|
+
let height: number;
|
|
48
|
+
const box = (roEntry as ResizeObserverEntry).borderBoxSize;
|
|
49
|
+
if (box) {
|
|
50
|
+
const b = Array.isArray(box) ? box[0] : box;
|
|
51
|
+
width = b.inlineSize;
|
|
52
|
+
height = b.blockSize;
|
|
53
|
+
} else {
|
|
54
|
+
width = (el as HTMLElement).offsetWidth;
|
|
55
|
+
height = (el as HTMLElement).offsetHeight;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
entry.last = { width, height };
|
|
59
|
+
for (const listener of entry.listeners) listener(entry.last);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
return sharedObserver;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function subscribe(element: Element, listener: Listener): () => void {
|
|
66
|
+
const observer = getObserver();
|
|
67
|
+
let entry = entries.get(element);
|
|
68
|
+
if (!entry) {
|
|
69
|
+
entry = {
|
|
70
|
+
listeners: new Set<Listener>(),
|
|
71
|
+
last: {
|
|
72
|
+
width: (element as HTMLElement).offsetWidth ?? 0,
|
|
73
|
+
height: (element as HTMLElement).offsetHeight ?? 0,
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
entries.set(element, entry);
|
|
77
|
+
elementByEntry.set(element, entry);
|
|
78
|
+
observer.observe(element);
|
|
79
|
+
}
|
|
80
|
+
entry.listeners.add(listener);
|
|
81
|
+
// Deliver current size synchronously so consumers don't render with 0.
|
|
82
|
+
listener(entry.last);
|
|
83
|
+
|
|
84
|
+
return () => {
|
|
85
|
+
const e = entries.get(element);
|
|
86
|
+
if (!e) return;
|
|
87
|
+
e.listeners.delete(listener);
|
|
88
|
+
if (e.listeners.size === 0) {
|
|
89
|
+
observer.unobserve(element);
|
|
90
|
+
entries.delete(element);
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Observe an element's border-box size via a shared ResizeObserver.
|
|
97
|
+
*
|
|
98
|
+
* Returns `{ width: 0, height: 0 }` until the first measurement lands.
|
|
99
|
+
* Pass either a ref or a callback-style ref result.
|
|
100
|
+
*
|
|
101
|
+
* @example
|
|
102
|
+
* const ref = React.useRef<HTMLDivElement>(null);
|
|
103
|
+
* const { width, height } = useResizeObserver(ref);
|
|
104
|
+
*/
|
|
105
|
+
export function useResizeObserver<T extends Element = Element>(
|
|
106
|
+
ref: React.RefObject<T | null>,
|
|
107
|
+
): Size {
|
|
108
|
+
const [size, setSize] = React.useState<Size>({ width: 0, height: 0 });
|
|
109
|
+
|
|
110
|
+
useLayoutEffect(() => {
|
|
111
|
+
const el = ref.current;
|
|
112
|
+
if (!el) return;
|
|
113
|
+
return subscribe(el, setSize);
|
|
114
|
+
}, [ref]);
|
|
115
|
+
|
|
116
|
+
return size;
|
|
117
|
+
}
|
package/src/lib/index.ts
CHANGED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Adapted from jalcoui (MIT) — github.com/jal-co/ui
|
|
2
|
+
//
|
|
3
|
+
// Quantize a numeric value into a discrete intensity bucket. Used by
|
|
4
|
+
// heatmaps (activity-graph), gauges, and any thermal-style visualization
|
|
5
|
+
// that needs N colored steps.
|
|
6
|
+
//
|
|
7
|
+
// `thresholds` are upper bounds in ascending order, expressed as fractions
|
|
8
|
+
// of `max` or as absolute values — caller picks. The returned bucket index
|
|
9
|
+
// is in `[0, thresholds.length]`.
|
|
10
|
+
//
|
|
11
|
+
// Inspired by `getIntensity` at activity-graph.tsx:72-80.
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param value Current value.
|
|
15
|
+
* @param thresholds Ascending upper bounds. The returned bucket is the
|
|
16
|
+
* index of the first threshold `value` does not exceed,
|
|
17
|
+
* or `thresholds.length` if it exceeds them all.
|
|
18
|
+
* @returns Bucket index in `[0, thresholds.length]`. `0` for non-positive
|
|
19
|
+
* values (treated as "empty").
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* getIntensity(0, [0.25, 0.5, 0.75]) // 0 (empty)
|
|
23
|
+
* getIntensity(0.1, [0.25, 0.5, 0.75]) // 1
|
|
24
|
+
* getIntensity(0.5, [0.25, 0.5, 0.75]) // 2
|
|
25
|
+
* getIntensity(0.8, [0.25, 0.5, 0.75]) // 3
|
|
26
|
+
* getIntensity(1.0, [0.25, 0.5, 0.75]) // 3
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* // Quantize commits/day into 4 buckets relative to the busiest day:
|
|
30
|
+
* const max = Math.max(...counts);
|
|
31
|
+
* const ratio = count / max;
|
|
32
|
+
* const step = getIntensity(ratio, [0.25, 0.5, 0.75]); // 0..3
|
|
33
|
+
*/
|
|
34
|
+
export function getIntensity(value: number, thresholds: number[]): number {
|
|
35
|
+
if (!(value > 0)) return 0;
|
|
36
|
+
for (let i = 0; i < thresholds.length; i++) {
|
|
37
|
+
if (value <= thresholds[i]) return i + 1;
|
|
38
|
+
}
|
|
39
|
+
return thresholds.length;
|
|
40
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Adapted from jalcoui (MIT) — github.com/jal-co/ui
|
|
2
|
+
|
|
3
|
+
export {
|
|
4
|
+
usePretext,
|
|
5
|
+
usePretextWithSegments,
|
|
6
|
+
usePretextLayout,
|
|
7
|
+
usePretextLines,
|
|
8
|
+
useShrinkwrap,
|
|
9
|
+
useBalancedWidth,
|
|
10
|
+
} from './use-pretext';
|
|
11
|
+
|
|
12
|
+
export type {
|
|
13
|
+
PreparedText,
|
|
14
|
+
PreparedTextWithSegments,
|
|
15
|
+
LayoutResult,
|
|
16
|
+
LayoutLinesResult,
|
|
17
|
+
PrepareOptions,
|
|
18
|
+
} from './use-pretext';
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Adapted from jalcoui (MIT) — github.com/jal-co/ui
|
|
2
|
+
//
|
|
3
|
+
// Local mirror of the @chenglou/pretext public API. The runtime dependency
|
|
4
|
+
// is declared `optional` (see ui-tools/package.json), so consumers that
|
|
5
|
+
// never use BalancedText / pretext-powered tools don't pay for it. To keep
|
|
6
|
+
// `pnpm check` green without the dependency installed, we redeclare just
|
|
7
|
+
// the types we touch — no `import type from '@chenglou/pretext'`.
|
|
8
|
+
//
|
|
9
|
+
// Mirrors the public surface as of @chenglou/pretext 0.0.7. If the upstream
|
|
10
|
+
// types drift, update here.
|
|
11
|
+
|
|
12
|
+
export interface PrepareOptions {
|
|
13
|
+
whiteSpace?: 'normal' | 'pre' | 'pre-wrap' | 'pre-line';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Opaque handles — internal shape doesn't matter to consumers of the hooks.
|
|
17
|
+
// We brand them so the two flavours don't get accidentally swapped.
|
|
18
|
+
export interface PreparedText {
|
|
19
|
+
readonly __brand: 'PreparedText';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface PreparedTextWithSegments {
|
|
23
|
+
readonly __brand: 'PreparedTextWithSegments';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface LayoutResult {
|
|
27
|
+
lineCount: number;
|
|
28
|
+
height: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface LayoutLine {
|
|
32
|
+
text: string;
|
|
33
|
+
width: number;
|
|
34
|
+
start: number;
|
|
35
|
+
end: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface LayoutLinesResult {
|
|
39
|
+
lineCount: number;
|
|
40
|
+
height: number;
|
|
41
|
+
lines: LayoutLine[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface WalkLineRange {
|
|
45
|
+
width: number;
|
|
46
|
+
start: number;
|
|
47
|
+
end: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface PretextModule {
|
|
51
|
+
prepare(text: string, font: string, options?: PrepareOptions): PreparedText;
|
|
52
|
+
prepareWithSegments(
|
|
53
|
+
text: string,
|
|
54
|
+
font: string,
|
|
55
|
+
options?: PrepareOptions,
|
|
56
|
+
): PreparedTextWithSegments;
|
|
57
|
+
layout(
|
|
58
|
+
prepared: PreparedText,
|
|
59
|
+
maxWidth: number,
|
|
60
|
+
lineHeight: number,
|
|
61
|
+
): LayoutResult;
|
|
62
|
+
layoutWithLines(
|
|
63
|
+
prepared: PreparedTextWithSegments,
|
|
64
|
+
maxWidth: number,
|
|
65
|
+
lineHeight: number,
|
|
66
|
+
): LayoutLinesResult;
|
|
67
|
+
walkLineRanges(
|
|
68
|
+
prepared: PreparedTextWithSegments,
|
|
69
|
+
maxWidth: number,
|
|
70
|
+
visit: (line: WalkLineRange) => void,
|
|
71
|
+
): void;
|
|
72
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// Adapted from jalcoui (MIT) — github.com/jal-co/ui
|
|
2
|
+
//
|
|
3
|
+
// React hooks wrapping @chenglou/pretext for DOM-free text measurement.
|
|
4
|
+
// Provides prepare/layout lifecycle management, shrinkwrap search,
|
|
5
|
+
// and line-balanced width computation.
|
|
6
|
+
//
|
|
7
|
+
// Powered by Pretext by Cheng Lou — github.com/chenglou/pretext
|
|
8
|
+
//
|
|
9
|
+
// `@chenglou/pretext` is declared as an optionalDependency in
|
|
10
|
+
// `packages/ui-tools/package.json` — consumers that never use BalancedText
|
|
11
|
+
// (or other pretext-based tools) can install ui-tools without it. The
|
|
12
|
+
// `getPretext()` helper uses a CommonJS `require()` so the optional
|
|
13
|
+
// dependency is resolved lazily at first use; if it is missing, the
|
|
14
|
+
// `require` throws at call site, never at module-load.
|
|
15
|
+
|
|
16
|
+
'use client';
|
|
17
|
+
|
|
18
|
+
import * as React from 'react';
|
|
19
|
+
import type {
|
|
20
|
+
PreparedText,
|
|
21
|
+
PreparedTextWithSegments,
|
|
22
|
+
LayoutResult,
|
|
23
|
+
LayoutLinesResult,
|
|
24
|
+
PrepareOptions,
|
|
25
|
+
PretextModule,
|
|
26
|
+
} from './pretext.types';
|
|
27
|
+
|
|
28
|
+
export type {
|
|
29
|
+
PreparedText,
|
|
30
|
+
PreparedTextWithSegments,
|
|
31
|
+
LayoutResult,
|
|
32
|
+
LayoutLinesResult,
|
|
33
|
+
PrepareOptions,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const isBrowser = typeof window !== 'undefined';
|
|
37
|
+
|
|
38
|
+
let cachedPretext: PretextModule | null = null;
|
|
39
|
+
|
|
40
|
+
function getPretext(): PretextModule {
|
|
41
|
+
if (cachedPretext) return cachedPretext;
|
|
42
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
43
|
+
cachedPretext = require('@chenglou/pretext') as PretextModule;
|
|
44
|
+
return cachedPretext;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const EMPTY_LAYOUT: LayoutResult = { lineCount: 0, height: 0 };
|
|
48
|
+
const EMPTY_LINES: LayoutLinesResult = { lineCount: 0, height: 0, lines: [] };
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Prepare text for Pretext measurement. Runs `prepare()` once and caches
|
|
52
|
+
* the result until `text`, `font`, or `options` change.
|
|
53
|
+
*
|
|
54
|
+
* The returned handle is opaque — pass it to `usePretextLayout`.
|
|
55
|
+
*/
|
|
56
|
+
export function usePretext(
|
|
57
|
+
text: string,
|
|
58
|
+
font: string,
|
|
59
|
+
options?: PrepareOptions,
|
|
60
|
+
): PreparedText | null {
|
|
61
|
+
const whiteSpace = options?.whiteSpace ?? 'normal';
|
|
62
|
+
|
|
63
|
+
return React.useMemo(() => {
|
|
64
|
+
if (!isBrowser) return null;
|
|
65
|
+
return getPretext().prepare(text, font, options);
|
|
66
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
67
|
+
}, [text, font, whiteSpace]);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Prepare text with segment data for advanced layout (line-by-line rendering,
|
|
72
|
+
* shrinkwrap, balancing). Same as `usePretext` but returns the richer handle.
|
|
73
|
+
*/
|
|
74
|
+
export function usePretextWithSegments(
|
|
75
|
+
text: string,
|
|
76
|
+
font: string,
|
|
77
|
+
options?: PrepareOptions,
|
|
78
|
+
): PreparedTextWithSegments | null {
|
|
79
|
+
const whiteSpace = options?.whiteSpace ?? 'normal';
|
|
80
|
+
|
|
81
|
+
return React.useMemo(() => {
|
|
82
|
+
if (!isBrowser) return null;
|
|
83
|
+
return getPretext().prepareWithSegments(text, font, options);
|
|
84
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
85
|
+
}, [text, font, whiteSpace]);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Layout prepared text at a given width and line height. Pure arithmetic —
|
|
90
|
+
* no DOM reads. Returns line count and total height.
|
|
91
|
+
*
|
|
92
|
+
* Re-runs on every `maxWidth` or `lineHeight` change (~0.0002ms).
|
|
93
|
+
*/
|
|
94
|
+
export function usePretextLayout(
|
|
95
|
+
prepared: PreparedText | null,
|
|
96
|
+
maxWidth: number,
|
|
97
|
+
lineHeight: number,
|
|
98
|
+
): LayoutResult {
|
|
99
|
+
return React.useMemo(() => {
|
|
100
|
+
if (!prepared) return EMPTY_LAYOUT;
|
|
101
|
+
return getPretext().layout(prepared, maxWidth, lineHeight);
|
|
102
|
+
}, [prepared, maxWidth, lineHeight]);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Layout prepared text and return full line data (text, width, cursors).
|
|
107
|
+
* Heavier than `usePretextLayout` — use when you need per-line info
|
|
108
|
+
* for custom rendering.
|
|
109
|
+
*/
|
|
110
|
+
export function usePretextLines(
|
|
111
|
+
prepared: PreparedTextWithSegments | null,
|
|
112
|
+
maxWidth: number,
|
|
113
|
+
lineHeight: number,
|
|
114
|
+
): LayoutLinesResult {
|
|
115
|
+
return React.useMemo(() => {
|
|
116
|
+
if (!prepared) return EMPTY_LINES;
|
|
117
|
+
return getPretext().layoutWithLines(prepared, maxWidth, lineHeight);
|
|
118
|
+
}, [prepared, maxWidth, lineHeight]);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Find the tightest width that produces the same line count as `maxWidth`.
|
|
123
|
+
* Binary-searches widths using `walkLineRanges` — no DOM measurement.
|
|
124
|
+
*
|
|
125
|
+
* Returns the shrinkwrapped width in pixels.
|
|
126
|
+
*/
|
|
127
|
+
export function useShrinkwrap(
|
|
128
|
+
prepared: PreparedTextWithSegments | null,
|
|
129
|
+
maxWidth: number,
|
|
130
|
+
): number {
|
|
131
|
+
return React.useMemo(() => {
|
|
132
|
+
if (!prepared || maxWidth <= 0) return 0;
|
|
133
|
+
|
|
134
|
+
const { walkLineRanges } = getPretext();
|
|
135
|
+
|
|
136
|
+
let baseLineCount = 0;
|
|
137
|
+
walkLineRanges(prepared, maxWidth, () => {
|
|
138
|
+
baseLineCount++;
|
|
139
|
+
});
|
|
140
|
+
if (baseLineCount <= 1) {
|
|
141
|
+
let singleLineWidth = 0;
|
|
142
|
+
walkLineRanges(prepared, maxWidth, (line) => {
|
|
143
|
+
singleLineWidth = line.width;
|
|
144
|
+
});
|
|
145
|
+
return Math.ceil(singleLineWidth) || 0;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
let lo = 1;
|
|
149
|
+
let hi = Math.ceil(maxWidth);
|
|
150
|
+
|
|
151
|
+
while (lo < hi) {
|
|
152
|
+
const mid = Math.floor((lo + hi) / 2);
|
|
153
|
+
let midLineCount = 0;
|
|
154
|
+
walkLineRanges(prepared, mid, () => {
|
|
155
|
+
midLineCount++;
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
if (midLineCount <= baseLineCount) {
|
|
159
|
+
hi = mid;
|
|
160
|
+
} else {
|
|
161
|
+
lo = mid + 1;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return lo;
|
|
166
|
+
}, [prepared, maxWidth]);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Find the width where all lines are roughly equal length (balanced text).
|
|
171
|
+
* Binary-searches for the narrowest width that keeps the same line count
|
|
172
|
+
* as `maxWidth`, then returns that width.
|
|
173
|
+
*
|
|
174
|
+
* CSS `text-wrap: balance` only works up to ~6 lines and is inconsistent
|
|
175
|
+
* cross-browser. This works on any length and is deterministic.
|
|
176
|
+
*/
|
|
177
|
+
export function useBalancedWidth(
|
|
178
|
+
prepared: PreparedTextWithSegments | null,
|
|
179
|
+
maxWidth: number,
|
|
180
|
+
): number {
|
|
181
|
+
return React.useMemo(() => {
|
|
182
|
+
if (!prepared || maxWidth <= 0) return 0;
|
|
183
|
+
|
|
184
|
+
const { walkLineRanges } = getPretext();
|
|
185
|
+
|
|
186
|
+
let baseLineCount = 0;
|
|
187
|
+
walkLineRanges(prepared, maxWidth, () => {
|
|
188
|
+
baseLineCount++;
|
|
189
|
+
});
|
|
190
|
+
if (baseLineCount <= 1) return maxWidth;
|
|
191
|
+
|
|
192
|
+
let lo = 1;
|
|
193
|
+
let hi = Math.ceil(maxWidth);
|
|
194
|
+
|
|
195
|
+
while (lo < hi) {
|
|
196
|
+
const mid = Math.floor((lo + hi) / 2);
|
|
197
|
+
let midLineCount = 0;
|
|
198
|
+
walkLineRanges(prepared, mid, () => {
|
|
199
|
+
midLineCount++;
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
if (midLineCount <= baseLineCount) {
|
|
203
|
+
hi = mid;
|
|
204
|
+
} else {
|
|
205
|
+
lo = mid + 1;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return lo;
|
|
210
|
+
}, [prepared, maxWidth]);
|
|
211
|
+
}
|
|
@@ -18,6 +18,12 @@
|
|
|
18
18
|
* fully-wrapped CSS color (`hsl(0 0% 94%)`, not bare `0 0% 94%`).
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
+
/* Tailwind v4: bind the `dark:` variant to the `.dark` class on any
|
|
22
|
+
* ancestor (Storybook + ThemeToggle set `<html class="dark">`).
|
|
23
|
+
* Without this, `dark:` defaults to `@media (prefers-color-scheme: dark)`
|
|
24
|
+
* and every `dark:text-X` utility is a no-op when toggling via class. */
|
|
25
|
+
@custom-variant dark (&:where(.dark, .dark *));
|
|
26
|
+
|
|
21
27
|
@theme inline {
|
|
22
28
|
/* Base semantic tokens — references into :root / .dark */
|
|
23
29
|
--color-background: var(--background);
|