@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,46 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
3
|
+
|
|
4
|
+
export function useCopyButton(onCopy: () => unknown): {
|
|
5
|
+
checked: boolean;
|
|
6
|
+
isLoading: boolean;
|
|
7
|
+
error: boolean;
|
|
8
|
+
onClick: () => void;
|
|
9
|
+
} {
|
|
10
|
+
const [isLoading, setLoading] = useState(false);
|
|
11
|
+
const [checked, setChecked] = useState(false);
|
|
12
|
+
const [error, setError] = useState(false);
|
|
13
|
+
const callbackRef = useRef(onCopy);
|
|
14
|
+
const timeoutRef = useRef<number | null>(null);
|
|
15
|
+
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
callbackRef.current = onCopy;
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const onClick = useCallback(() => {
|
|
21
|
+
if (timeoutRef.current) window.clearTimeout(timeoutRef.current);
|
|
22
|
+
setChecked(false);
|
|
23
|
+
setError(false);
|
|
24
|
+
setLoading(true);
|
|
25
|
+
Promise.resolve()
|
|
26
|
+
.then(() => callbackRef.current())
|
|
27
|
+
.then(() => {
|
|
28
|
+
setChecked(true);
|
|
29
|
+
timeoutRef.current = window.setTimeout(() => setChecked(false), 1500);
|
|
30
|
+
})
|
|
31
|
+
.catch((err: unknown) => {
|
|
32
|
+
console.error('Failed to copy:', err);
|
|
33
|
+
setError(true);
|
|
34
|
+
timeoutRef.current = window.setTimeout(() => setError(false), 1500);
|
|
35
|
+
})
|
|
36
|
+
.finally(() => setLoading(false));
|
|
37
|
+
}, []);
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
return () => {
|
|
41
|
+
if (timeoutRef.current) window.clearTimeout(timeoutRef.current);
|
|
42
|
+
};
|
|
43
|
+
}, []);
|
|
44
|
+
|
|
45
|
+
return { checked, isLoading, error, onClick };
|
|
46
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
useCallback,
|
|
5
|
+
useState,
|
|
6
|
+
useSyncExternalStore,
|
|
7
|
+
} from 'react';
|
|
8
|
+
|
|
9
|
+
const INTERVAL_MS = 4000;
|
|
10
|
+
const TICK_MS = 250;
|
|
11
|
+
|
|
12
|
+
const listeners = new Set<() => void>();
|
|
13
|
+
let timer: number | undefined;
|
|
14
|
+
let ticks = 0;
|
|
15
|
+
|
|
16
|
+
function subscribeTick(listener: () => void) {
|
|
17
|
+
listeners.add(listener);
|
|
18
|
+
timer ??= window.setInterval(() => {
|
|
19
|
+
ticks += 1;
|
|
20
|
+
for (const listener of listeners) listener();
|
|
21
|
+
}, TICK_MS);
|
|
22
|
+
return () => {
|
|
23
|
+
listeners.delete(listener);
|
|
24
|
+
if (!listeners.size && timer !== undefined) {
|
|
25
|
+
window.clearInterval(timer);
|
|
26
|
+
timer = undefined;
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const readTickServer = () => 0;
|
|
32
|
+
|
|
33
|
+
export function useCycleIndex(
|
|
34
|
+
length: number,
|
|
35
|
+
{
|
|
36
|
+
enabled = true,
|
|
37
|
+
paused = false,
|
|
38
|
+
intervalMs = INTERVAL_MS,
|
|
39
|
+
}: { enabled?: boolean; paused?: boolean; intervalMs?: number } = {},
|
|
40
|
+
) {
|
|
41
|
+
const running = enabled && !paused && length > 1;
|
|
42
|
+
const interval = intervalMs > 0 ? intervalMs : INTERVAL_MS;
|
|
43
|
+
const span = Math.max(1, Math.round(interval / TICK_MS));
|
|
44
|
+
const [offset, setOffset] = useState(0);
|
|
45
|
+
|
|
46
|
+
const subscribe = useCallback(
|
|
47
|
+
(listener: () => void) =>
|
|
48
|
+
running ? subscribeTick(listener) : () => {},
|
|
49
|
+
[running],
|
|
50
|
+
);
|
|
51
|
+
const readStep = useCallback(
|
|
52
|
+
() => Math.floor(ticks / span),
|
|
53
|
+
[span],
|
|
54
|
+
);
|
|
55
|
+
const base = useSyncExternalStore(
|
|
56
|
+
subscribe,
|
|
57
|
+
readStep,
|
|
58
|
+
readTickServer,
|
|
59
|
+
);
|
|
60
|
+
const index =
|
|
61
|
+
length > 0
|
|
62
|
+
? (((base + offset) % length) + length) % length
|
|
63
|
+
: 0;
|
|
64
|
+
|
|
65
|
+
const setIndex = useCallback(
|
|
66
|
+
(next: number | ((current: number) => number)) => {
|
|
67
|
+
const size = Math.max(length, 1);
|
|
68
|
+
const target = typeof next === 'function' ? next(index) : next;
|
|
69
|
+
setOffset((((target - base) % size) + size) % size);
|
|
70
|
+
},
|
|
71
|
+
[index, base, length],
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
return [index, setIndex] as const;
|
|
75
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState, type RefObject } from 'react';
|
|
4
|
+
|
|
5
|
+
export function useElementSize(ref: RefObject<HTMLElement | null>) {
|
|
6
|
+
const [size, setSize] = useState({ w: 0, h: 0 });
|
|
7
|
+
const observed = useRef<{ el: Element; ro: ResizeObserver } | null>(null);
|
|
8
|
+
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
const el = ref.current;
|
|
11
|
+
if (!el || observed.current?.el === el) return;
|
|
12
|
+
observed.current?.ro.disconnect();
|
|
13
|
+
const apply = (width: number, height: number) =>
|
|
14
|
+
setSize((prev) =>
|
|
15
|
+
prev.w === width && prev.h === height ? prev : { w: width, h: height },
|
|
16
|
+
);
|
|
17
|
+
const ro = new ResizeObserver((entries) => {
|
|
18
|
+
const rect = entries[0]?.contentRect;
|
|
19
|
+
if (rect) apply(rect.width, rect.height);
|
|
20
|
+
});
|
|
21
|
+
ro.observe(el);
|
|
22
|
+
observed.current = { el, ro };
|
|
23
|
+
apply(el.clientWidth, el.clientHeight);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
useEffect(
|
|
27
|
+
() => () => {
|
|
28
|
+
observed.current?.ro.disconnect();
|
|
29
|
+
observed.current = null;
|
|
30
|
+
},
|
|
31
|
+
[],
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
return size;
|
|
35
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useEffect, useState } from 'react';
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_EXTERNAL_META_PROVIDERS,
|
|
5
|
+
uiConfig,
|
|
6
|
+
type ExternalMetaProvider,
|
|
7
|
+
} from '@terrafirmagreg-team/ui/lib/config';
|
|
8
|
+
import { createAsyncCache } from '@terrafirmagreg-team/ui/lib/utils/async-cache';
|
|
9
|
+
|
|
10
|
+
export interface ExternalMeta {
|
|
11
|
+
title: string;
|
|
12
|
+
description?: string;
|
|
13
|
+
host: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const metaCache = createAsyncCache((href: string) =>
|
|
17
|
+
resolveMeta(new URL(href), AbortSignal.timeout(6000)),
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
function pick(data: unknown, path: string): unknown {
|
|
21
|
+
return path
|
|
22
|
+
.split('.')
|
|
23
|
+
.reduce<unknown>(
|
|
24
|
+
(acc, key) =>
|
|
25
|
+
acc && typeof acc === 'object'
|
|
26
|
+
? (acc as Record<string, unknown>)[key]
|
|
27
|
+
: undefined,
|
|
28
|
+
data,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function pickText(data: unknown, path?: string): string | undefined {
|
|
33
|
+
const value = path ? pick(data, path) : undefined;
|
|
34
|
+
const text = typeof value === 'string' ? value.trim() : '';
|
|
35
|
+
return text || undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function buildUrl(
|
|
39
|
+
template: string,
|
|
40
|
+
url: URL,
|
|
41
|
+
match: RegExpMatchArray | null,
|
|
42
|
+
): string {
|
|
43
|
+
return template.replace(/\{(\w+)\}/g, (_, key: string) => {
|
|
44
|
+
if (key === 'href') return encodeURIComponent(url.href);
|
|
45
|
+
if (key === 'host') return url.hostname;
|
|
46
|
+
const group = match?.[Number(key)];
|
|
47
|
+
return group !== undefined ? encodeURIComponent(group) : '';
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function fetchMeta(
|
|
52
|
+
provider: ExternalMetaProvider,
|
|
53
|
+
url: URL,
|
|
54
|
+
match: RegExpMatchArray | null,
|
|
55
|
+
signal: AbortSignal,
|
|
56
|
+
): Promise<ExternalMeta> {
|
|
57
|
+
const res = await fetch(buildUrl(provider.url, url, match), { signal });
|
|
58
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
59
|
+
const host = url.hostname;
|
|
60
|
+
if (provider.kind === 'html') {
|
|
61
|
+
const doc = new DOMParser().parseFromString(await res.text(), 'text/html');
|
|
62
|
+
return {
|
|
63
|
+
title:
|
|
64
|
+
doc
|
|
65
|
+
.querySelector("meta[property='og:title']")
|
|
66
|
+
?.getAttribute('content')
|
|
67
|
+
?.trim() ||
|
|
68
|
+
doc.title ||
|
|
69
|
+
host,
|
|
70
|
+
description:
|
|
71
|
+
doc
|
|
72
|
+
.querySelector(
|
|
73
|
+
"meta[property='og:description'], meta[name='description']",
|
|
74
|
+
)
|
|
75
|
+
?.getAttribute('content') || undefined,
|
|
76
|
+
host,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
const data = await res.json();
|
|
80
|
+
let title = pickText(data, provider.title) || host;
|
|
81
|
+
for (const [pattern, to] of provider.replace ?? []) {
|
|
82
|
+
title = title.replace(pattern, to);
|
|
83
|
+
}
|
|
84
|
+
return { title, description: pickText(data, provider.description), host };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function resolveMeta(url: URL, signal: AbortSignal): Promise<ExternalMeta> {
|
|
88
|
+
const host = url.hostname;
|
|
89
|
+
for (const provider of uiConfig().externalMetaProviders ??
|
|
90
|
+
DEFAULT_EXTERNAL_META_PROVIDERS) {
|
|
91
|
+
if (!provider.host.test(host)) continue;
|
|
92
|
+
const match = provider.path ? url.pathname.match(provider.path) : null;
|
|
93
|
+
if (provider.path && !match) continue;
|
|
94
|
+
try {
|
|
95
|
+
return await fetchMeta(provider, url, match, signal);
|
|
96
|
+
} catch {
|
|
97
|
+
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return { title: host, host };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function loadExternalMeta(href: string): Promise<ExternalMeta> {
|
|
104
|
+
let url: URL;
|
|
105
|
+
try {
|
|
106
|
+
url = new URL(href);
|
|
107
|
+
} catch {
|
|
108
|
+
return Promise.resolve({ title: href, host: '' });
|
|
109
|
+
}
|
|
110
|
+
if (!/^https?:$/i.test(url.protocol)) {
|
|
111
|
+
return Promise.resolve({ title: href, host: url.hostname });
|
|
112
|
+
}
|
|
113
|
+
return metaCache(href);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function useExternalMeta(
|
|
117
|
+
href: string,
|
|
118
|
+
active: boolean,
|
|
119
|
+
): ExternalMeta | null {
|
|
120
|
+
const [meta, setMeta] = useState<ExternalMeta | null>(null);
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
if (!active) return;
|
|
123
|
+
let mounted = true;
|
|
124
|
+
void loadExternalMeta(href).then((m) => {
|
|
125
|
+
if (mounted) setMeta(m);
|
|
126
|
+
});
|
|
127
|
+
return () => {
|
|
128
|
+
mounted = false;
|
|
129
|
+
};
|
|
130
|
+
}, [href, active]);
|
|
131
|
+
return meta;
|
|
132
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { useEffect, useEffectEvent } from 'react';
|
|
2
|
+
|
|
3
|
+
export function useHotKey(
|
|
4
|
+
onKeyDown: (e: KeyboardEvent) => void,
|
|
5
|
+
{
|
|
6
|
+
enabled = true,
|
|
7
|
+
ignoreTyping = false,
|
|
8
|
+
}: {
|
|
9
|
+
enabled?: boolean;
|
|
10
|
+
|
|
11
|
+
ignoreTyping?: boolean;
|
|
12
|
+
} = {},
|
|
13
|
+
) {
|
|
14
|
+
const listener = useEffectEvent((e: KeyboardEvent) => {
|
|
15
|
+
if (e.defaultPrevented || e.isComposing || e.keyCode === 229) return;
|
|
16
|
+
if (ignoreTyping && isTypingTarget(e.target)) return;
|
|
17
|
+
onKeyDown(e);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
if (!enabled) return;
|
|
22
|
+
window.addEventListener('keydown', listener);
|
|
23
|
+
return () => window.removeEventListener('keydown', listener);
|
|
24
|
+
}, [enabled]);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isTypingTarget(target: EventTarget | null): boolean {
|
|
28
|
+
if (!(target instanceof HTMLElement)) return false;
|
|
29
|
+
if (target.isContentEditable) return true;
|
|
30
|
+
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)) return true;
|
|
31
|
+
|
|
32
|
+
return target.closest('[role="dialog"]') !== null;
|
|
33
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useCallback, type RefCallback } from 'react';
|
|
3
|
+
|
|
4
|
+
export function useHoverFollow(): RefCallback<HTMLElement> {
|
|
5
|
+
return useCallback<RefCallback<HTMLElement>>((area) => {
|
|
6
|
+
const block = area?.firstElementChild;
|
|
7
|
+
if (!area || !(block instanceof HTMLElement)) return;
|
|
8
|
+
let current: Element | null = null;
|
|
9
|
+
const show = (item: Element | null) => {
|
|
10
|
+
if (item === current) return;
|
|
11
|
+
block.style.transitionProperty = current ? '' : 'opacity';
|
|
12
|
+
current = item;
|
|
13
|
+
if (!item) {
|
|
14
|
+
block.style.opacity = '0';
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
const rect = item.getBoundingClientRect();
|
|
18
|
+
const origin = area.getBoundingClientRect();
|
|
19
|
+
block.style.translate = `${rect.left - origin.left}px ${rect.top - origin.top}px`;
|
|
20
|
+
block.style.width = `${rect.width}px`;
|
|
21
|
+
block.style.height = `${rect.height}px`;
|
|
22
|
+
block.style.opacity = item.matches('[data-active=true]') ? '0' : '1';
|
|
23
|
+
};
|
|
24
|
+
const itemOf = (target: unknown) =>
|
|
25
|
+
target instanceof Element ? target.closest('a[href], button') : null;
|
|
26
|
+
const controller = new AbortController();
|
|
27
|
+
const { signal } = controller;
|
|
28
|
+
area.addEventListener(
|
|
29
|
+
'pointerover',
|
|
30
|
+
(e) => {
|
|
31
|
+
const item = itemOf(e.target);
|
|
32
|
+
if (item && e.pointerType === 'mouse') show(item);
|
|
33
|
+
},
|
|
34
|
+
{ signal },
|
|
35
|
+
);
|
|
36
|
+
area.addEventListener('pointerleave', () => show(null), { signal });
|
|
37
|
+
area.addEventListener(
|
|
38
|
+
'focusin',
|
|
39
|
+
(e) => {
|
|
40
|
+
if (e.target instanceof Element && e.target.matches(':focus-visible'))
|
|
41
|
+
show(itemOf(e.target));
|
|
42
|
+
},
|
|
43
|
+
{ signal },
|
|
44
|
+
);
|
|
45
|
+
area.addEventListener(
|
|
46
|
+
'focusout',
|
|
47
|
+
() => {
|
|
48
|
+
if (!area.matches(':hover')) show(null);
|
|
49
|
+
},
|
|
50
|
+
{ signal },
|
|
51
|
+
);
|
|
52
|
+
return () => controller.abort();
|
|
53
|
+
}, []);
|
|
54
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState, type RefObject } from 'react';
|
|
4
|
+
|
|
5
|
+
export function useInView<T extends Element>(
|
|
6
|
+
ref: RefObject<T | null>,
|
|
7
|
+
rootMargin = '240px',
|
|
8
|
+
): boolean {
|
|
9
|
+
const [inView, setInView] = useState(false);
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
const el = ref.current;
|
|
12
|
+
if (!el || inView) return;
|
|
13
|
+
if (typeof IntersectionObserver === 'undefined') {
|
|
14
|
+
const id = requestAnimationFrame(() => setInView(true));
|
|
15
|
+
return () => cancelAnimationFrame(id);
|
|
16
|
+
}
|
|
17
|
+
const io = new IntersectionObserver(
|
|
18
|
+
([entry]) => {
|
|
19
|
+
if (entry?.isIntersecting) {
|
|
20
|
+
setInView(true);
|
|
21
|
+
io.disconnect();
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
{ rootMargin },
|
|
25
|
+
);
|
|
26
|
+
io.observe(el);
|
|
27
|
+
return () => io.disconnect();
|
|
28
|
+
}, [ref, rootMargin, inView]);
|
|
29
|
+
return inView;
|
|
30
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
|
|
5
|
+
export function useLastValue<T>(
|
|
6
|
+
value: T | undefined,
|
|
7
|
+
key?: string,
|
|
8
|
+
): T | undefined {
|
|
9
|
+
const [held, setHeld] = useState<{ key?: string; value: T } | undefined>();
|
|
10
|
+
const stale = held === undefined || held.key !== key;
|
|
11
|
+
if (value !== undefined && (stale || !Object.is(held?.value, value))) {
|
|
12
|
+
setHeld({ key, value });
|
|
13
|
+
}
|
|
14
|
+
return value ?? (stale ? undefined : held?.value);
|
|
15
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useEffect, useRef, type RefObject } from 'react';
|
|
3
|
+
|
|
4
|
+
export function useMarquee<T extends HTMLElement>(): RefObject<T | null> {
|
|
5
|
+
const ref = useRef<T>(null);
|
|
6
|
+
|
|
7
|
+
useEffect(() => {
|
|
8
|
+
const outer = ref.current;
|
|
9
|
+
const inner = outer?.firstElementChild;
|
|
10
|
+
if (!outer || !(inner instanceof HTMLElement)) return;
|
|
11
|
+
const target =
|
|
12
|
+
outer.closest(
|
|
13
|
+
'a, button, [role="menuitem"], [role="menuitemradio"], [role="menuitemcheckbox"], [role="option"]',
|
|
14
|
+
) ?? outer;
|
|
15
|
+
|
|
16
|
+
const measure = () => {
|
|
17
|
+
const overflow = inner.scrollWidth - outer.clientWidth;
|
|
18
|
+
outer.classList.toggle('marquee', overflow > 1);
|
|
19
|
+
if (overflow > 1) {
|
|
20
|
+
inner.style.setProperty('--marquee-x', `${-overflow}px`);
|
|
21
|
+
inner.style.setProperty(
|
|
22
|
+
'--marquee-duration',
|
|
23
|
+
`${Math.max(1.5, overflow / 30)}s`,
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
const start = () => {
|
|
28
|
+
measure();
|
|
29
|
+
outer.classList.add('marquee-run');
|
|
30
|
+
};
|
|
31
|
+
const stop = () => outer.classList.remove('marquee-run');
|
|
32
|
+
|
|
33
|
+
measure();
|
|
34
|
+
const observer = new ResizeObserver(measure);
|
|
35
|
+
observer.observe(outer);
|
|
36
|
+
observer.observe(inner);
|
|
37
|
+
target.addEventListener('pointerenter', start);
|
|
38
|
+
target.addEventListener('pointerleave', stop);
|
|
39
|
+
target.addEventListener('focusin', start);
|
|
40
|
+
target.addEventListener('focusout', stop);
|
|
41
|
+
return () => {
|
|
42
|
+
observer.disconnect();
|
|
43
|
+
target.removeEventListener('pointerenter', start);
|
|
44
|
+
target.removeEventListener('pointerleave', stop);
|
|
45
|
+
target.removeEventListener('focusin', start);
|
|
46
|
+
target.removeEventListener('focusout', stop);
|
|
47
|
+
};
|
|
48
|
+
}, []);
|
|
49
|
+
|
|
50
|
+
return ref;
|
|
51
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useSyncExternalStore } from 'react';
|
|
3
|
+
|
|
4
|
+
export function useMediaQuery(query: string): boolean {
|
|
5
|
+
return useSyncExternalStore(
|
|
6
|
+
(onChange) => {
|
|
7
|
+
const mql = window.matchMedia(query);
|
|
8
|
+
mql.addEventListener('change', onChange);
|
|
9
|
+
return () => mql.removeEventListener('change', onChange);
|
|
10
|
+
},
|
|
11
|
+
() => window.matchMedia(query).matches,
|
|
12
|
+
() => false,
|
|
13
|
+
);
|
|
14
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
|
|
3
|
+
const MOBILE_BREAKPOINT = 768
|
|
4
|
+
|
|
5
|
+
export function useIsMobile() {
|
|
6
|
+
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
|
7
|
+
|
|
8
|
+
React.useEffect(() => {
|
|
9
|
+
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
|
10
|
+
const onChange = () => {
|
|
11
|
+
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
|
12
|
+
}
|
|
13
|
+
mql.addEventListener("change", onChange)
|
|
14
|
+
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
|
15
|
+
return () => mql.removeEventListener("change", onChange)
|
|
16
|
+
}, [])
|
|
17
|
+
|
|
18
|
+
return !!isMobile
|
|
19
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
export type ModifierKeys = {
|
|
6
|
+
shift: boolean;
|
|
7
|
+
ctrl: boolean;
|
|
8
|
+
alt: boolean;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const IDLE: ModifierKeys = { shift: false, ctrl: false, alt: false };
|
|
12
|
+
|
|
13
|
+
export function useModifierKeys(active = true): ModifierKeys {
|
|
14
|
+
const [keys, setKeys] = useState<ModifierKeys>(IDLE);
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
if (!active) return;
|
|
17
|
+
const update = (e: KeyboardEvent) => {
|
|
18
|
+
setKeys({ shift: e.shiftKey, ctrl: e.ctrlKey, alt: e.altKey });
|
|
19
|
+
};
|
|
20
|
+
const reset = () => setKeys(IDLE);
|
|
21
|
+
window.addEventListener('keydown', update);
|
|
22
|
+
window.addEventListener('keyup', update);
|
|
23
|
+
window.addEventListener('blur', reset);
|
|
24
|
+
return () => {
|
|
25
|
+
window.removeEventListener('keydown', update);
|
|
26
|
+
window.removeEventListener('keyup', update);
|
|
27
|
+
window.removeEventListener('blur', reset);
|
|
28
|
+
};
|
|
29
|
+
}, [active]);
|
|
30
|
+
return keys;
|
|
31
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
|
|
3
|
+
function isEqualShallow(a: unknown, b: unknown): boolean {
|
|
4
|
+
if (Object.is(a, b)) return true;
|
|
5
|
+
if (
|
|
6
|
+
!a ||
|
|
7
|
+
!b ||
|
|
8
|
+
typeof a !== 'object' ||
|
|
9
|
+
typeof b !== 'object' ||
|
|
10
|
+
Array.isArray(a) !== Array.isArray(b)
|
|
11
|
+
)
|
|
12
|
+
return false;
|
|
13
|
+
const aKeys = Object.keys(a);
|
|
14
|
+
const bKeys = Object.keys(b);
|
|
15
|
+
if (aKeys.length !== bKeys.length) return false;
|
|
16
|
+
return aKeys.every((key) =>
|
|
17
|
+
Object.is(
|
|
18
|
+
(a as Record<string, unknown>)[key],
|
|
19
|
+
(b as Record<string, unknown>)[key],
|
|
20
|
+
),
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function useOnChange<T>(
|
|
25
|
+
value: T,
|
|
26
|
+
onChange: (current: T, previous: T) => void,
|
|
27
|
+
isUpdated: (prev: T, current: T) => boolean = (a, b) =>
|
|
28
|
+
!isEqualShallow(a, b),
|
|
29
|
+
) {
|
|
30
|
+
const [prev, setPrev] = useState(value);
|
|
31
|
+
if (isUpdated(prev, value)) {
|
|
32
|
+
onChange(value, prev);
|
|
33
|
+
setPrev(value);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect } from 'react';
|
|
4
|
+
|
|
5
|
+
export function usePaneKeys<T extends string>(
|
|
6
|
+
picks: Partial<Record<'r' | 'u' | 't', T>>,
|
|
7
|
+
onSelect: (id: T) => void,
|
|
8
|
+
) {
|
|
9
|
+
const { r, u, t } = picks;
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
const map = { r, u, t };
|
|
12
|
+
const onKey = (event: KeyboardEvent) => {
|
|
13
|
+
if (event.ctrlKey || event.metaKey || event.altKey) return;
|
|
14
|
+
const target = event.target as HTMLElement | null;
|
|
15
|
+
if (
|
|
16
|
+
target?.closest(
|
|
17
|
+
'input, textarea, select, [contenteditable="true"], [role="textbox"]',
|
|
18
|
+
)
|
|
19
|
+
) {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const id = map[event.key.toLowerCase() as 'r' | 'u' | 't'];
|
|
23
|
+
if (id) onSelect(id);
|
|
24
|
+
};
|
|
25
|
+
window.addEventListener('keydown', onKey);
|
|
26
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
27
|
+
}, [r, u, t, onSelect]);
|
|
28
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useRef, type TouchEvent } from 'react';
|
|
4
|
+
|
|
5
|
+
const MIN_DX = 48;
|
|
6
|
+
|
|
7
|
+
export function useSwipe(onSwipe: (dir: -1 | 1) => void) {
|
|
8
|
+
const start = useRef<{ x: number; y: number } | null>(null);
|
|
9
|
+
return {
|
|
10
|
+
onTouchStart: (event: TouchEvent) => {
|
|
11
|
+
const touch = event.touches[0];
|
|
12
|
+
if (!touch) return;
|
|
13
|
+
start.current = { x: touch.clientX, y: touch.clientY };
|
|
14
|
+
},
|
|
15
|
+
onTouchEnd: (event: TouchEvent) => {
|
|
16
|
+
const from = start.current;
|
|
17
|
+
start.current = null;
|
|
18
|
+
const touch = event.changedTouches[0];
|
|
19
|
+
if (!from || !touch) return;
|
|
20
|
+
const dx = touch.clientX - from.x;
|
|
21
|
+
const dy = touch.clientY - from.y;
|
|
22
|
+
if (Math.abs(dx) < MIN_DX || Math.abs(dx) < Math.abs(dy) * 1.5) return;
|
|
23
|
+
onSwipe(dx < 0 ? 1 : -1);
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|