@terrafirmagreg-team/ui 0.0.0-stage → 0.1.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 +93 -3
- package/src/components/blocks/button/icon-button.tsx +48 -0
- package/src/components/blocks/canvas/chevron-edge.tsx +134 -0
- package/src/components/blocks/canvas/flow-canvas.tsx +177 -0
- package/src/components/blocks/canvas/flow-toolbar.tsx +32 -0
- package/src/components/blocks/canvas/flow-viewport.tsx +243 -0
- package/src/components/blocks/git/github-info.tsx +77 -0
- package/src/components/blocks/lang/content.tsx +22 -0
- package/src/components/blocks/lang/flag.tsx +24 -0
- package/src/components/blocks/lang/link.tsx +22 -0
- package/src/components/blocks/lang/redirect.tsx +47 -0
- package/src/components/blocks/lang/switcher.tsx +53 -0
- package/src/components/blocks/lang/untranslated-callout.tsx +28 -0
- package/src/components/blocks/link/link-preview.tsx +256 -0
- package/src/components/blocks/list/row.tsx +91 -0
- package/src/components/blocks/media/glb/model.tsx +243 -0
- package/src/components/blocks/media/glb/scene.tsx +372 -0
- package/src/components/blocks/media/glb/viewer.tsx +51 -0
- package/src/components/blocks/media/image.tsx +135 -0
- package/src/components/blocks/media/slideshow.tsx +177 -0
- package/src/components/blocks/nav/breadcrumb.tsx +50 -0
- package/src/components/blocks/nav/link.tsx +85 -0
- package/src/components/blocks/nav/site-menu.tsx +77 -0
- package/src/components/blocks/nav/site-title.tsx +60 -0
- package/src/components/blocks/nav/switcher.tsx +205 -0
- package/src/components/blocks/page/empty-state.tsx +31 -0
- package/src/components/blocks/page/heading.tsx +53 -0
- package/src/components/blocks/page/pager.tsx +112 -0
- package/src/components/blocks/readability/menu.tsx +431 -0
- package/src/components/blocks/readability/track.tsx +164 -0
- package/src/components/blocks/search/command.tsx +79 -0
- package/src/components/blocks/search/dialog.tsx +712 -0
- package/src/components/blocks/search/provider.tsx +117 -0
- package/src/components/blocks/sidebar.tsx +406 -0
- package/src/components/blocks/theme/toggle.tsx +71 -0
- package/src/components/blocks/tooltip/hint.tsx +27 -0
- package/src/components/blocks/viewer/controls.tsx +279 -0
- package/src/components/blocks/viewer/float-bar.tsx +50 -0
- package/src/components/layouts/layout.tsx +93 -0
- package/src/components/layouts/left-sidebar/content.tsx +62 -0
- package/src/components/layouts/left-sidebar/context.tsx +54 -0
- package/src/components/layouts/left-sidebar/expand.tsx +22 -0
- package/src/components/layouts/left-sidebar/footer.tsx +105 -0
- package/src/components/layouts/left-sidebar/index.ts +28 -0
- package/src/components/layouts/left-sidebar/items.tsx +102 -0
- package/src/components/layouts/left-sidebar/search.tsx +74 -0
- package/src/components/layouts/left-sidebar/sidebar.tsx +188 -0
- package/src/components/layouts/left-sidebar/tree.tsx +156 -0
- package/src/components/layouts/right-sidebar.tsx +95 -0
- package/src/components/layouts/tree-context.tsx +41 -0
- package/src/components/mdx/accordion.tsx +184 -0
- package/src/components/mdx/banner.tsx +154 -0
- package/src/components/mdx/callout.tsx +96 -0
- package/src/components/mdx/card.tsx +49 -0
- package/src/components/mdx/codeblock.tsx +249 -0
- package/src/components/mdx/collapsible.tsx +32 -0
- package/src/components/mdx/files.tsx +62 -0
- package/src/components/mdx/glow-header.tsx +40 -0
- package/src/components/mdx/gradient-text.tsx +27 -0
- package/src/components/mdx/image-zoom.tsx +314 -0
- package/src/components/mdx/mermaid.tsx +16 -0
- package/src/components/mdx/multiblock.tsx +18 -0
- package/src/components/mdx/preview.tsx +100 -0
- package/src/components/mdx/steps.tsx +9 -0
- package/src/components/mdx/table.tsx +13 -0
- package/src/components/mdx/tabs.tsx +353 -0
- package/src/components/mdx/type-table.tsx +170 -0
- package/src/components/ui/accordion.tsx +71 -0
- package/src/components/ui/alert-dialog.tsx +187 -0
- package/src/components/ui/alert.tsx +75 -0
- package/src/components/ui/aspect-ratio.tsx +22 -0
- package/src/components/ui/attachment.tsx +207 -0
- package/src/components/ui/avatar.tsx +108 -0
- package/src/components/ui/badge.tsx +51 -0
- package/src/components/ui/breadcrumb.tsx +124 -0
- package/src/components/ui/bubble.tsx +127 -0
- package/src/components/ui/button-group.tsx +87 -0
- package/src/components/ui/button.tsx +57 -0
- package/src/components/ui/calendar.tsx +221 -0
- package/src/components/ui/card.tsx +102 -0
- package/src/components/ui/carousel.tsx +242 -0
- package/src/components/ui/chart.tsx +371 -0
- package/src/components/ui/checkbox.tsx +28 -0
- package/src/components/ui/collapsible.tsx +21 -0
- package/src/components/ui/combobox.tsx +297 -0
- package/src/components/ui/command.tsx +198 -0
- package/src/components/ui/context-menu.tsx +270 -0
- package/src/components/ui/dialog.tsx +160 -0
- package/src/components/ui/direction.tsx +6 -0
- package/src/components/ui/drawer.tsx +227 -0
- package/src/components/ui/dropdown-menu.tsx +269 -0
- package/src/components/ui/empty.tsx +103 -0
- package/src/components/ui/field.tsx +238 -0
- package/src/components/ui/hover-card.tsx +50 -0
- package/src/components/ui/input-group.tsx +158 -0
- package/src/components/ui/input-otp.tsx +86 -0
- package/src/components/ui/input.tsx +19 -0
- package/src/components/ui/item.tsx +201 -0
- package/src/components/ui/kbd.tsx +26 -0
- package/src/components/ui/label.tsx +19 -0
- package/src/components/ui/marker.tsx +70 -0
- package/src/components/ui/menubar.tsx +280 -0
- package/src/components/ui/message-scroller.tsx +131 -0
- package/src/components/ui/message.tsx +91 -0
- package/src/components/ui/native-select.tsx +60 -0
- package/src/components/ui/navigation-menu.tsx +167 -0
- package/src/components/ui/pagination.tsx +130 -0
- package/src/components/ui/popover.tsx +91 -0
- package/src/components/ui/progress.tsx +82 -0
- package/src/components/ui/questionnaire.tsx +324 -0
- package/src/components/ui/radio-group.tsx +37 -0
- package/src/components/ui/resizable.tsx +49 -0
- package/src/components/ui/scroll-area.tsx +54 -0
- package/src/components/ui/select.tsx +200 -0
- package/src/components/ui/separator.tsx +24 -0
- package/src/components/ui/sheet.tsx +138 -0
- package/src/components/ui/sidebar.tsx +730 -0
- package/src/components/ui/skeleton.tsx +13 -0
- package/src/components/ui/slider.tsx +51 -0
- package/src/components/ui/spinner.tsx +10 -0
- package/src/components/ui/switch.tsx +31 -0
- package/src/components/ui/table.tsx +115 -0
- package/src/components/ui/tabs.tsx +81 -0
- package/src/components/ui/textarea.tsx +17 -0
- package/src/components/ui/toast.tsx +234 -0
- package/src/components/ui/toggle-group.tsx +89 -0
- package/src/components/ui/toggle.tsx +44 -0
- package/src/components/ui/tooltip.tsx +65 -0
- package/src/hooks/canvas/use-flow-hover.ts +18 -0
- package/src/hooks/canvas/use-flow-viewport-persist.ts +33 -0
- package/src/hooks/lang.ts +8 -0
- package/src/hooks/layout.ts +18 -0
- package/src/hooks/page/use-fit-pages.ts +91 -0
- package/src/hooks/page/use-location-search.ts +25 -0
- package/src/hooks/page/use-page-preview.ts +40 -0
- package/src/hooks/page/use-paged-list.ts +20 -0
- package/src/hooks/readability/provider.ts +86 -0
- package/src/hooks/search/dialog.ts +41 -0
- package/src/hooks/search/provider.ts +25 -0
- package/src/hooks/search/use-highlight-query.ts +51 -0
- package/src/hooks/sidebar.ts +63 -0
- package/src/hooks/use-async-value.ts +57 -0
- package/src/hooks/use-close-on-scroll.ts +23 -0
- package/src/hooks/use-copy-button.ts +46 -0
- package/src/hooks/use-cycle-index.ts +75 -0
- package/src/hooks/use-document-title.ts +9 -0
- package/src/hooks/use-element-size.ts +35 -0
- package/src/hooks/use-external-meta.ts +132 -0
- package/src/hooks/use-hot-key.ts +33 -0
- package/src/hooks/use-hover-follow.ts +54 -0
- package/src/hooks/use-in-view.ts +30 -0
- package/src/hooks/use-last-value.ts +15 -0
- package/src/hooks/use-marquee.ts +51 -0
- package/src/hooks/use-media-query.ts +14 -0
- package/src/hooks/use-mobile.ts +19 -0
- package/src/hooks/use-modifier-keys.ts +31 -0
- package/src/hooks/use-on-change.ts +35 -0
- package/src/hooks/use-pane-keys.ts +28 -0
- package/src/hooks/use-swipe.ts +26 -0
- package/src/hooks/use-tree.ts +102 -0
- package/src/hooks/viewer/use-in-float-bar.ts +6 -0
- package/src/hooks/viewer/use-viewer-prefs.ts +77 -0
- package/src/lib/canvas/edge.ts +173 -0
- package/src/lib/canvas/geometry.ts +53 -0
- package/src/lib/canvas/state.ts +47 -0
- package/src/lib/canvas/viewport.ts +37 -0
- package/src/lib/canvas/xyflow.ts +1 -0
- package/src/lib/config.ts +81 -0
- package/src/lib/mdx/callout.ts +20 -0
- package/src/lib/mdx/gradient.ts +15 -0
- package/src/lib/mdx/mermaid.ts +14 -0
- package/src/lib/media/crop-opaque.ts +76 -0
- package/src/lib/media/nearby-indexes.ts +6 -0
- package/src/lib/media/trim-transparent.ts +117 -0
- package/src/lib/readability/config.ts +10 -0
- package/src/lib/readability/spotlight.ts +186 -0
- package/src/lib/readability/store.ts +146 -0
- package/src/lib/tree.ts +92 -0
- package/src/lib/utils/async-cache.ts +32 -0
- package/src/lib/utils/cn.ts +1 -0
- package/src/lib/utils/format.ts +22 -0
- package/src/lib/utils/history.ts +9 -0
- package/src/lib/utils/merge-refs.ts +13 -0
- package/src/lib/utils/nav-active.ts +58 -0
- package/src/lib/utils/stored-value.ts +136 -0
- package/src/lib/utils/urls.ts +17 -0
- package/src/locales/en_us.json +119 -0
- package/src/locales/pl_pl.json +35 -0
- package/src/locales/ru_ru.json +119 -0
- package/src/locales/uk_ua.json +35 -0
- package/src/locales/zh_cn.json +17 -0
- package/src/locales/zh_tw.json +17 -0
- package/src/locales.ts +5 -0
- package/src/provider.tsx +141 -0
- package/src/types/canvas.ts +49 -0
- package/src/types/glb.ts +25 -0
- package/src/types/nav.ts +95 -0
- package/src/types/preview.ts +9 -0
- package/src/types/readability.ts +28 -0
- package/src/types/search.ts +56 -0
- package/src/types/tree.ts +18 -0
- package/src/types/viewer.ts +53 -0
- package/styles/content/base.css +194 -0
- package/styles/content/default-colors.css +51 -0
- package/styles/content/shiki.css +108 -0
- package/styles/content/typography.mjs +3941 -0
- package/styles/content.css +7 -0
- package/styles/image-zoom.css +83 -0
- package/styles/layout.css +28 -0
- package/styles/left-sidebar.css +140 -0
- package/styles/readability.css +50 -0
- package/styles/right-sidebar.css +6 -0
- package/styles/workspace.css +26 -0
- package/styles.css +413 -0
- package/README.md +0 -3
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
type OpaqueBBox = {
|
|
2
|
+
x: number;
|
|
3
|
+
y: number;
|
|
4
|
+
width: number;
|
|
5
|
+
height: number;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
function opaqueBBox(
|
|
9
|
+
pixels: ArrayLike<number>,
|
|
10
|
+
width: number,
|
|
11
|
+
height: number,
|
|
12
|
+
): OpaqueBBox | undefined {
|
|
13
|
+
let minX = width;
|
|
14
|
+
let minY = height;
|
|
15
|
+
let maxX = -1;
|
|
16
|
+
let maxY = -1;
|
|
17
|
+
for (let i = 0; i < width * height; i++) {
|
|
18
|
+
if ((pixels[i * 4 + 3] ?? 0) === 0) continue;
|
|
19
|
+
const x = i % width;
|
|
20
|
+
const y = (i / width) | 0;
|
|
21
|
+
if (x < minX) minX = x;
|
|
22
|
+
if (y < minY) minY = y;
|
|
23
|
+
if (x > maxX) maxX = x;
|
|
24
|
+
if (y > maxY) maxY = y;
|
|
25
|
+
}
|
|
26
|
+
if (maxX < 0) return undefined;
|
|
27
|
+
return { x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1 };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function cropOpaqueImageDataUrl(
|
|
31
|
+
image: HTMLImageElement,
|
|
32
|
+
): string | undefined {
|
|
33
|
+
const width = image.naturalWidth;
|
|
34
|
+
const height = image.naturalHeight;
|
|
35
|
+
if (width < 1 || height < 1) return undefined;
|
|
36
|
+
const canvas = document.createElement('canvas');
|
|
37
|
+
canvas.width = width;
|
|
38
|
+
canvas.height = height;
|
|
39
|
+
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
|
40
|
+
if (!ctx) return undefined;
|
|
41
|
+
ctx.drawImage(image, 0, 0);
|
|
42
|
+
let pixels: ImageData;
|
|
43
|
+
try {
|
|
44
|
+
pixels = ctx.getImageData(0, 0, width, height);
|
|
45
|
+
} catch {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
const box = opaqueBBox(pixels.data, width, height);
|
|
49
|
+
if (!box) return undefined;
|
|
50
|
+
if (
|
|
51
|
+
box.x === 0 &&
|
|
52
|
+
box.y === 0 &&
|
|
53
|
+
box.width === width &&
|
|
54
|
+
box.height === height
|
|
55
|
+
) {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
const out = document.createElement('canvas');
|
|
59
|
+
out.width = box.width;
|
|
60
|
+
out.height = box.height;
|
|
61
|
+
const outCtx = out.getContext('2d');
|
|
62
|
+
if (!outCtx) return undefined;
|
|
63
|
+
outCtx.imageSmoothingEnabled = false;
|
|
64
|
+
outCtx.drawImage(
|
|
65
|
+
image,
|
|
66
|
+
box.x,
|
|
67
|
+
box.y,
|
|
68
|
+
box.width,
|
|
69
|
+
box.height,
|
|
70
|
+
0,
|
|
71
|
+
0,
|
|
72
|
+
box.width,
|
|
73
|
+
box.height,
|
|
74
|
+
);
|
|
75
|
+
return out.toDataURL('image/png');
|
|
76
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
const TRIM_TOL = 8;
|
|
2
|
+
|
|
3
|
+
export function trimTransparent(src: string): Promise<string> {
|
|
4
|
+
return new Promise((resolve) => {
|
|
5
|
+
const image = new Image();
|
|
6
|
+
image.onload = () => {
|
|
7
|
+
const width = image.naturalWidth;
|
|
8
|
+
const height = image.naturalHeight;
|
|
9
|
+
const canvas = document.createElement('canvas');
|
|
10
|
+
canvas.width = width;
|
|
11
|
+
canvas.height = height;
|
|
12
|
+
const ctx = canvas.getContext('2d');
|
|
13
|
+
if (!ctx) {
|
|
14
|
+
resolve(src);
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
ctx.drawImage(image, 0, 0);
|
|
18
|
+
let imageData: ImageData;
|
|
19
|
+
try {
|
|
20
|
+
imageData = ctx.getImageData(0, 0, width, height);
|
|
21
|
+
} catch {
|
|
22
|
+
resolve(src);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const { data } = imageData;
|
|
26
|
+
const at = (x: number, y: number) => (y * width + x) * 4;
|
|
27
|
+
const closeRgb = (i: number, j: number) =>
|
|
28
|
+
Math.abs(data[i]! - data[j]!) <= TRIM_TOL &&
|
|
29
|
+
Math.abs(data[i + 1]! - data[j + 1]!) <= TRIM_TOL &&
|
|
30
|
+
Math.abs(data[i + 2]! - data[j + 2]!) <= TRIM_TOL;
|
|
31
|
+
const empty = (x: number, y: number, ref: number) => {
|
|
32
|
+
const i = at(x, y);
|
|
33
|
+
if (data[i + 3]! <= TRIM_TOL) return true;
|
|
34
|
+
if (data[ref + 3]! <= TRIM_TOL) return false;
|
|
35
|
+
return closeRgb(i, ref);
|
|
36
|
+
};
|
|
37
|
+
const rowPad = (y: number, x0: number, x1: number) => {
|
|
38
|
+
let ref = -1;
|
|
39
|
+
for (let x = x0; x <= x1; x++) {
|
|
40
|
+
const i = at(x, y);
|
|
41
|
+
if (data[i + 3]! > TRIM_TOL) {
|
|
42
|
+
ref = i;
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (ref < 0) return true;
|
|
47
|
+
for (let x = x0; x <= x1; x++) {
|
|
48
|
+
if (!empty(x, y, ref)) return false;
|
|
49
|
+
}
|
|
50
|
+
return true;
|
|
51
|
+
};
|
|
52
|
+
const colPad = (x: number, y0: number, y1: number) => {
|
|
53
|
+
let ref = -1;
|
|
54
|
+
for (let y = y0; y <= y1; y++) {
|
|
55
|
+
const i = at(x, y);
|
|
56
|
+
if (data[i + 3]! > TRIM_TOL) {
|
|
57
|
+
ref = i;
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (ref < 0) return true;
|
|
62
|
+
for (let y = y0; y <= y1; y++) {
|
|
63
|
+
if (!empty(x, y, ref)) return false;
|
|
64
|
+
}
|
|
65
|
+
return true;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
let minX = 0;
|
|
69
|
+
let minY = 0;
|
|
70
|
+
let maxX = width - 1;
|
|
71
|
+
let maxY = height - 1;
|
|
72
|
+
let changed = true;
|
|
73
|
+
while (changed && minX < maxX && minY < maxY) {
|
|
74
|
+
changed = false;
|
|
75
|
+
if (rowPad(minY, minX, maxX)) {
|
|
76
|
+
minY += 1;
|
|
77
|
+
changed = true;
|
|
78
|
+
}
|
|
79
|
+
if (minY < maxY && rowPad(maxY, minX, maxX)) {
|
|
80
|
+
maxY -= 1;
|
|
81
|
+
changed = true;
|
|
82
|
+
}
|
|
83
|
+
if (colPad(minX, minY, maxY)) {
|
|
84
|
+
minX += 1;
|
|
85
|
+
changed = true;
|
|
86
|
+
}
|
|
87
|
+
if (minX < maxX && colPad(maxX, minY, maxY)) {
|
|
88
|
+
maxX -= 1;
|
|
89
|
+
changed = true;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (
|
|
93
|
+
maxX <= minX ||
|
|
94
|
+
maxY <= minY ||
|
|
95
|
+
(minX === 0 && minY === 0 && maxX === width - 1 && maxY === height - 1)
|
|
96
|
+
) {
|
|
97
|
+
resolve(src);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const tw = maxX - minX + 1;
|
|
101
|
+
const th = maxY - minY + 1;
|
|
102
|
+
const out = document.createElement('canvas');
|
|
103
|
+
out.width = tw;
|
|
104
|
+
out.height = th;
|
|
105
|
+
const outCtx = out.getContext('2d');
|
|
106
|
+
if (!outCtx) {
|
|
107
|
+
resolve(src);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
outCtx.drawImage(image, minX, minY, tw, th, 0, 0, tw, th);
|
|
111
|
+
resolve(out.toDataURL('image/png'));
|
|
112
|
+
};
|
|
113
|
+
image.onerror = () => resolve(src);
|
|
114
|
+
image.crossOrigin = 'anonymous';
|
|
115
|
+
image.src = src;
|
|
116
|
+
});
|
|
117
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { createConfigRegistry } from '@terrafirmagreg-team/ui/lib/config';
|
|
2
|
+
|
|
3
|
+
export type ReadabilityConfig = {
|
|
4
|
+
isSpotlightDisabled?: (pathname: string) => boolean;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
const { register: registerReadabilityConfig, get: readabilityConfig } =
|
|
8
|
+
createConfigRegistry<ReadabilityConfig>();
|
|
9
|
+
|
|
10
|
+
export { registerReadabilityConfig, readabilityConfig };
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import type { SpotlightStyle } from '@terrafirmagreg-team/ui/types/readability';
|
|
3
|
+
|
|
4
|
+
const UNIT_SELECTOR =
|
|
5
|
+
'figure, table, blockquote, details, ul, ol, video, iframe, .codeblock';
|
|
6
|
+
const PHRASING_SELECTOR =
|
|
7
|
+
'a, abbr, area, audio, b, bdi, bdo, br, button, canvas, cite, code, ' +
|
|
8
|
+
'data, datalist, del, dfn, em, embed, i, iframe, img, input, ins, kbd, ' +
|
|
9
|
+
'label, map, mark, math, meter, noscript, object, output, picture, ' +
|
|
10
|
+
'progress, q, rp, rt, ruby, s, samp, script, select, slot, small, span, ' +
|
|
11
|
+
'strong, sub, sup, svg, template, textarea, time, u, var, video, wbr';
|
|
12
|
+
const MEDIA_SELECTOR = 'img, svg, video, canvas, audio, picture';
|
|
13
|
+
|
|
14
|
+
export function contentRoot(): HTMLElement | null {
|
|
15
|
+
return document.getElementById('page')?.parentElement ?? null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isBlockish(el: HTMLElement): boolean {
|
|
19
|
+
const display = getComputedStyle(el).display;
|
|
20
|
+
return !display.startsWith('inline') && display !== 'none';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isStructural(el: HTMLElement): boolean {
|
|
24
|
+
if (el.matches(MEDIA_SELECTOR)) return false;
|
|
25
|
+
for (const child of el.children) {
|
|
26
|
+
if (!child.matches(PHRASING_SELECTOR)) return true;
|
|
27
|
+
}
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function hasDirectText(el: HTMLElement): boolean {
|
|
32
|
+
for (const node of el.childNodes) {
|
|
33
|
+
if (node.nodeType === Node.TEXT_NODE && node.textContent?.trim())
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isInteractive(el: HTMLElement): boolean {
|
|
40
|
+
const tag = el.tagName;
|
|
41
|
+
if (tag !== 'A' && tag !== 'BUTTON' && el.getAttribute('role') !== 'button')
|
|
42
|
+
return false;
|
|
43
|
+
const parent = el.parentElement;
|
|
44
|
+
if (!parent) return false;
|
|
45
|
+
return !hasDirectText(parent);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isBoundary(el: HTMLElement, root: HTMLElement): boolean {
|
|
49
|
+
return (
|
|
50
|
+
el === root ||
|
|
51
|
+
el.parentElement === root ||
|
|
52
|
+
el.matches('article, nav, .prose, #toc')
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isUnit(el: HTMLElement): boolean {
|
|
57
|
+
if (isStructural(el)) return true;
|
|
58
|
+
if (hasDirectText(el)) return true;
|
|
59
|
+
return el.matches(UNIT_SELECTOR);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function closestBlock(el: HTMLElement, root: HTMLElement): HTMLElement | null {
|
|
63
|
+
const found = el.closest(UNIT_SELECTOR);
|
|
64
|
+
return found instanceof HTMLElement && root.contains(found) ? found : null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function findElementToBeHighlighted(
|
|
68
|
+
element: HTMLElement | null,
|
|
69
|
+
rootContainer: HTMLElement,
|
|
70
|
+
): HTMLElement | null {
|
|
71
|
+
if (!element) return null;
|
|
72
|
+
const isAncestorValid = (current: HTMLElement | null): boolean => {
|
|
73
|
+
if (!current || current === rootContainer) return false;
|
|
74
|
+
const parent = current.parentElement;
|
|
75
|
+
if (!parent) return false;
|
|
76
|
+
if (parent === rootContainer) return true;
|
|
77
|
+
const parentDisplay = getComputedStyle(parent).display;
|
|
78
|
+
if (
|
|
79
|
+
parentDisplay.startsWith('inline') ||
|
|
80
|
+
parentDisplay === 'none' ||
|
|
81
|
+
hasDirectText(parent)
|
|
82
|
+
)
|
|
83
|
+
return false;
|
|
84
|
+
if (parent.tagName === 'DIV') {
|
|
85
|
+
if (parent.classList.contains('codeblock')) return false;
|
|
86
|
+
for (const child of parent.children) {
|
|
87
|
+
if (child.tagName === 'PRE') return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (
|
|
91
|
+
parent.tagName === 'LI' &&
|
|
92
|
+
current.tagName !== 'P' &&
|
|
93
|
+
!isBlockish(current)
|
|
94
|
+
) {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
return isAncestorValid(parent);
|
|
98
|
+
};
|
|
99
|
+
return isAncestorValid(element)
|
|
100
|
+
? element
|
|
101
|
+
: findElementToBeHighlighted(element.parentElement, rootContainer);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function interactiveUnit(
|
|
105
|
+
el: HTMLElement,
|
|
106
|
+
root: HTMLElement,
|
|
107
|
+
): HTMLElement | null {
|
|
108
|
+
let current: HTMLElement | null = el;
|
|
109
|
+
while (current && !isBoundary(current, root)) {
|
|
110
|
+
if (isInteractive(current) && isStructural(current)) return current;
|
|
111
|
+
current = current.parentElement;
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function resolveAside(el: HTMLElement, root: HTMLElement): HTMLElement | null {
|
|
117
|
+
const block = closestBlock(el, root);
|
|
118
|
+
if (block) return block;
|
|
119
|
+
const unit = interactiveUnit(el, root);
|
|
120
|
+
if (unit) return unit;
|
|
121
|
+
const found = findElementToBeHighlighted(el, root);
|
|
122
|
+
if (!found || isBoundary(found, root)) return null;
|
|
123
|
+
if (isStructural(found) && found.tagName !== 'A' && found.tagName !== 'BUTTON')
|
|
124
|
+
return null;
|
|
125
|
+
return found;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function resolveUnder(el: HTMLElement, root: HTMLElement): HTMLElement | null {
|
|
129
|
+
let interactive: HTMLElement | null = null;
|
|
130
|
+
let current: HTMLElement | null = el;
|
|
131
|
+
while (current && !isBoundary(current, root)) {
|
|
132
|
+
if (isInteractive(current)) interactive = current;
|
|
133
|
+
current = current.parentElement;
|
|
134
|
+
}
|
|
135
|
+
if (interactive) return interactive;
|
|
136
|
+
current = el;
|
|
137
|
+
let leaf: HTMLElement | null = null;
|
|
138
|
+
while (current && !isBoundary(current, root)) {
|
|
139
|
+
const leafish = isBlockish(current) && !isStructural(current);
|
|
140
|
+
if (!leafish && leaf) return leaf;
|
|
141
|
+
const inText =
|
|
142
|
+
current.parentElement && hasDirectText(current.parentElement);
|
|
143
|
+
if (leafish && !inText) leaf = current;
|
|
144
|
+
if (
|
|
145
|
+
!inText &&
|
|
146
|
+
isUnit(current) &&
|
|
147
|
+
!current.matches(PHRASING_SELECTOR)
|
|
148
|
+
)
|
|
149
|
+
return current;
|
|
150
|
+
current = current.parentElement;
|
|
151
|
+
}
|
|
152
|
+
if (leaf) return leaf;
|
|
153
|
+
const found = findElementToBeHighlighted(el, root);
|
|
154
|
+
return found && !isBoundary(found, root) ? found : null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function resolveSpotlight(
|
|
158
|
+
el: HTMLElement,
|
|
159
|
+
root: HTMLElement,
|
|
160
|
+
style: SpotlightStyle,
|
|
161
|
+
): HTMLElement | null {
|
|
162
|
+
return style === 'under' ? resolveUnder(el, root) : resolveAside(el, root);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function rowBand(
|
|
166
|
+
el: HTMLElement,
|
|
167
|
+
root: HTMLElement,
|
|
168
|
+
style: SpotlightStyle,
|
|
169
|
+
): { top: number; left: number; width: number; height: number } | null {
|
|
170
|
+
if (style !== 'aside' || el.tagName === 'P') return null;
|
|
171
|
+
const parent = el.parentElement;
|
|
172
|
+
if (!parent || parent === root) return null;
|
|
173
|
+
const rect = el.getBoundingClientRect();
|
|
174
|
+
let top = rect.top;
|
|
175
|
+
let bottom = rect.bottom;
|
|
176
|
+
for (const child of parent.children) {
|
|
177
|
+
if (child === el || !(child instanceof HTMLElement)) continue;
|
|
178
|
+
const r = child.getBoundingClientRect();
|
|
179
|
+
if (r.height === 0 || r.top >= rect.bottom || r.bottom <= rect.top)
|
|
180
|
+
continue;
|
|
181
|
+
top = Math.min(top, r.top);
|
|
182
|
+
bottom = Math.max(bottom, r.bottom);
|
|
183
|
+
}
|
|
184
|
+
const pr = parent.getBoundingClientRect();
|
|
185
|
+
return { top, left: pr.left, width: pr.width, height: bottom - top };
|
|
186
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import {
|
|
3
|
+
readStoredValue,
|
|
4
|
+
subscribeStoredValue,
|
|
5
|
+
writeStoredValue,
|
|
6
|
+
} from '@terrafirmagreg-team/ui/lib/utils/stored-value';
|
|
7
|
+
import {
|
|
8
|
+
readabilityLayouts,
|
|
9
|
+
spotlightStyles,
|
|
10
|
+
type ReadabilityLayout,
|
|
11
|
+
type ReadabilityState,
|
|
12
|
+
type SpotlightStyle,
|
|
13
|
+
} from '@terrafirmagreg-team/ui/types/readability';
|
|
14
|
+
|
|
15
|
+
const STORAGE = 'readability';
|
|
16
|
+
const WIDTH_MIN = 60;
|
|
17
|
+
const WIDTH_MAX = 100;
|
|
18
|
+
const WIDTH_DEBOUNCE_MS = 1000;
|
|
19
|
+
const ANIM_DURATION_MS = 5000;
|
|
20
|
+
|
|
21
|
+
export const defaults: ReadabilityState = {
|
|
22
|
+
layout: 'original',
|
|
23
|
+
pageWidth: 100,
|
|
24
|
+
contentWidth: 80,
|
|
25
|
+
spotlight: false,
|
|
26
|
+
spotlightStyle: 'aside',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
let cached: ReadabilityState = defaults;
|
|
30
|
+
|
|
31
|
+
export function getServerSnapshot() {
|
|
32
|
+
return defaults;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function snapshot(next: ReadabilityState): ReadabilityState {
|
|
36
|
+
if (
|
|
37
|
+
cached.layout === next.layout &&
|
|
38
|
+
cached.pageWidth === next.pageWidth &&
|
|
39
|
+
cached.contentWidth === next.contentWidth &&
|
|
40
|
+
cached.spotlight === next.spotlight &&
|
|
41
|
+
cached.spotlightStyle === next.spotlightStyle
|
|
42
|
+
) {
|
|
43
|
+
return cached;
|
|
44
|
+
}
|
|
45
|
+
cached = next;
|
|
46
|
+
return cached;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function subscribe(onStoreChange: () => void) {
|
|
50
|
+
return subscribeStoredValue(STORAGE, onStoreChange);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isLayout(value: unknown): value is ReadabilityLayout {
|
|
54
|
+
return readabilityLayouts.includes(value as ReadabilityLayout);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isSpotlightStyle(value: unknown): value is SpotlightStyle {
|
|
58
|
+
return spotlightStyles.includes(value as SpotlightStyle);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function clampWidth(value: unknown, fallback: number): number {
|
|
62
|
+
const n = typeof value === 'number' ? value : Number(value);
|
|
63
|
+
if (!Number.isFinite(n)) return fallback;
|
|
64
|
+
return Math.min(WIDTH_MAX, Math.max(WIDTH_MIN, Math.round(n)));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function readState(): ReadabilityState {
|
|
68
|
+
return snapshot(
|
|
69
|
+
readStoredValue(STORAGE, (raw) => {
|
|
70
|
+
if (!raw) return defaults;
|
|
71
|
+
try {
|
|
72
|
+
const parsed = JSON.parse(raw) as Partial<ReadabilityState>;
|
|
73
|
+
return {
|
|
74
|
+
layout: isLayout(parsed.layout) ? parsed.layout : defaults.layout,
|
|
75
|
+
pageWidth: clampWidth(parsed.pageWidth, defaults.pageWidth),
|
|
76
|
+
contentWidth: clampWidth(parsed.contentWidth, defaults.contentWidth),
|
|
77
|
+
spotlight: parsed.spotlight === true,
|
|
78
|
+
spotlightStyle: isSpotlightStyle(parsed.spotlightStyle)
|
|
79
|
+
? parsed.spotlightStyle
|
|
80
|
+
: defaults.spotlightStyle,
|
|
81
|
+
};
|
|
82
|
+
} catch {
|
|
83
|
+
return defaults;
|
|
84
|
+
}
|
|
85
|
+
}),
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function writeState(patch: Partial<ReadabilityState>) {
|
|
90
|
+
writeStoredValue(
|
|
91
|
+
STORAGE,
|
|
92
|
+
{ ...readState(), ...patch },
|
|
93
|
+
JSON.stringify,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let widthTimer: ReturnType<typeof setTimeout> | undefined;
|
|
98
|
+
let animTimer: ReturnType<typeof setTimeout> | undefined;
|
|
99
|
+
let initialized = false;
|
|
100
|
+
|
|
101
|
+
function writeWidths(state: ReadabilityState) {
|
|
102
|
+
const root = document.documentElement;
|
|
103
|
+
root.style.setProperty('--page-width', `${state.pageWidth}%`);
|
|
104
|
+
root.style.setProperty('--content-width', `${state.contentWidth}%`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function applyWidths(state: ReadabilityState) {
|
|
108
|
+
clearTimeout(widthTimer);
|
|
109
|
+
widthTimer = setTimeout(() => writeWidths(state), WIDTH_DEBOUNCE_MS);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function triggerAnimation() {
|
|
113
|
+
const root = document.documentElement;
|
|
114
|
+
root.setAttribute('data-readability-anim', '');
|
|
115
|
+
clearTimeout(animTimer);
|
|
116
|
+
animTimer = setTimeout(() => {
|
|
117
|
+
root.removeAttribute('data-readability-anim');
|
|
118
|
+
}, ANIM_DURATION_MS);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function applyState(state: ReadabilityState) {
|
|
122
|
+
const root = document.documentElement;
|
|
123
|
+
root.dataset.layout = state.layout;
|
|
124
|
+
root.toggleAttribute('data-spotlight', state.spotlight);
|
|
125
|
+
root.dataset.spotlightStyle = state.spotlightStyle;
|
|
126
|
+
if (!initialized) {
|
|
127
|
+
initialized = true;
|
|
128
|
+
writeWidths(state);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
applyWidths(state);
|
|
132
|
+
triggerAnimation();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function clearState() {
|
|
136
|
+
const root = document.documentElement;
|
|
137
|
+
initialized = false;
|
|
138
|
+
clearTimeout(widthTimer);
|
|
139
|
+
clearTimeout(animTimer);
|
|
140
|
+
delete root.dataset.layout;
|
|
141
|
+
delete root.dataset.spotlightStyle;
|
|
142
|
+
root.removeAttribute('data-spotlight');
|
|
143
|
+
root.removeAttribute('data-readability-anim');
|
|
144
|
+
root.style.removeProperty('--page-width');
|
|
145
|
+
root.style.removeProperty('--content-width');
|
|
146
|
+
}
|
package/src/lib/tree.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type * as PageTree from '@terrafirmagreg-team/translate/types/tree';
|
|
2
|
+
|
|
3
|
+
export function normalizeUrl(url: string): string {
|
|
4
|
+
if (url.startsWith('http://') || url.startsWith('https://')) return url;
|
|
5
|
+
if (!url.startsWith('/')) url = `/${url}`;
|
|
6
|
+
if (url.length > 1 && url.endsWith('/')) url = url.slice(0, -1);
|
|
7
|
+
return url;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
export function findPath(
|
|
12
|
+
nodes: PageTree.Node[],
|
|
13
|
+
matcher: (node: PageTree.Node) => boolean,
|
|
14
|
+
options: { includeSeparator?: boolean } = {},
|
|
15
|
+
): PageTree.Node[] | null {
|
|
16
|
+
const { includeSeparator = true } = options;
|
|
17
|
+
|
|
18
|
+
function run(nodes: PageTree.Node[]): PageTree.Node[] | undefined {
|
|
19
|
+
let separator: PageTree.Node | undefined;
|
|
20
|
+
for (const node of nodes) {
|
|
21
|
+
if (matcher(node)) {
|
|
22
|
+
const items: PageTree.Node[] = [];
|
|
23
|
+
if (separator) items.push(separator);
|
|
24
|
+
items.push(node);
|
|
25
|
+
return items;
|
|
26
|
+
}
|
|
27
|
+
if (node.type === 'separator' && includeSeparator) {
|
|
28
|
+
separator = node;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (node.type === 'folder') {
|
|
32
|
+
const items =
|
|
33
|
+
node.index && matcher(node.index)
|
|
34
|
+
? [node.index]
|
|
35
|
+
: run(node.children);
|
|
36
|
+
if (items) {
|
|
37
|
+
items.unshift(node);
|
|
38
|
+
if (separator) items.unshift(separator);
|
|
39
|
+
return items;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return run(nodes) ?? null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
export function searchPath(
|
|
50
|
+
nodes: PageTree.Node[],
|
|
51
|
+
url: string,
|
|
52
|
+
): PageTree.Node[] | null {
|
|
53
|
+
const normalizedUrl = normalizeUrl(url);
|
|
54
|
+
return findPath(
|
|
55
|
+
nodes,
|
|
56
|
+
(node) => node.type === 'page' && node.url === normalizedUrl,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
export function findProjection(
|
|
62
|
+
from: PageTree.Folder,
|
|
63
|
+
to: PageTree.Folder,
|
|
64
|
+
page: PageTree.Item,
|
|
65
|
+
): PageTree.Item | undefined {
|
|
66
|
+
const path = findPath(from.children, (node) => node === page, {
|
|
67
|
+
includeSeparator: false,
|
|
68
|
+
});
|
|
69
|
+
if (!path) return;
|
|
70
|
+
|
|
71
|
+
let target: PageTree.Node[] = to.children;
|
|
72
|
+
let projection: PageTree.Item | undefined;
|
|
73
|
+
outer: for (let i = 1; i < path.length; i++) {
|
|
74
|
+
const segment = path[i];
|
|
75
|
+
for (const node of target) {
|
|
76
|
+
if (node.$id === segment.$id && node.type === 'page') {
|
|
77
|
+
projection = node;
|
|
78
|
+
continue outer;
|
|
79
|
+
}
|
|
80
|
+
if (
|
|
81
|
+
node.type === 'folder' &&
|
|
82
|
+
segment.type === 'folder' &&
|
|
83
|
+
(node.$id === segment.$id || node === segment)
|
|
84
|
+
) {
|
|
85
|
+
target = node.children;
|
|
86
|
+
continue outer;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
return projection;
|
|
92
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export type AsyncCache<A extends unknown[], V> = ((
|
|
2
|
+
...args: A
|
|
3
|
+
) => Promise<V>) & {
|
|
4
|
+
peek: (...args: A) => V | undefined;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export function createAsyncCache<A extends unknown[], V>(
|
|
8
|
+
loader: (...args: A) => Promise<V>,
|
|
9
|
+
keyOf: (...args: A) => string = (...args) => JSON.stringify(args),
|
|
10
|
+
): AsyncCache<A, V> {
|
|
11
|
+
const cache = new Map<string, Promise<V>>();
|
|
12
|
+
const resolved = new Map<string, V>();
|
|
13
|
+
const call = (...args: A) => {
|
|
14
|
+
const key = keyOf(...args);
|
|
15
|
+
let pending = cache.get(key);
|
|
16
|
+
if (!pending) {
|
|
17
|
+
pending = loader(...args)
|
|
18
|
+
.then((value) => {
|
|
19
|
+
resolved.set(key, value);
|
|
20
|
+
return value;
|
|
21
|
+
})
|
|
22
|
+
.catch((err) => {
|
|
23
|
+
cache.delete(key);
|
|
24
|
+
throw err;
|
|
25
|
+
});
|
|
26
|
+
cache.set(key, pending);
|
|
27
|
+
}
|
|
28
|
+
return pending;
|
|
29
|
+
};
|
|
30
|
+
call.peek = (...args: A) => resolved.get(keyOf(...args));
|
|
31
|
+
return call;
|
|
32
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { cn } from 'cn';
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
|
|
2
|
+
export function formatCount(value: number): string {
|
|
3
|
+
if (!Number.isFinite(value)) return String(value);
|
|
4
|
+
const abs = Math.abs(value);
|
|
5
|
+
if (abs < 10_000) return String(Math.trunc(value));
|
|
6
|
+
const units: [number, string][] = [
|
|
7
|
+
[1_000_000_000, 'B'],
|
|
8
|
+
[1_000_000, 'M'],
|
|
9
|
+
[1_000, 'k'],
|
|
10
|
+
];
|
|
11
|
+
for (const [div, suffix] of units) {
|
|
12
|
+
if (abs >= div) {
|
|
13
|
+
const rounded = value / div;
|
|
14
|
+
const text =
|
|
15
|
+
Math.abs(rounded) >= 100
|
|
16
|
+
? String(Math.trunc(rounded))
|
|
17
|
+
: String(Math.round(rounded * 10) / 10).replace(/\.0$/, '');
|
|
18
|
+
return `${text}${suffix}`;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return String(value);
|
|
22
|
+
}
|