birdclaw 0.8.1 → 0.8.3
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/CHANGELOG.md +25 -0
- package/package.json +2 -4
- package/scripts/browser-perf.mjs +27 -0
- package/src/cli/command-context.ts +17 -0
- package/src/cli/register-analysis.ts +500 -0
- package/src/cli/register-compose.ts +40 -0
- package/src/cli/register-graph.ts +132 -0
- package/src/cli/register-inbox.ts +41 -0
- package/src/cli/register-storage.ts +106 -0
- package/src/cli.ts +30 -750
- package/src/components/AccountSwitcher.tsx +7 -15
- package/src/components/AvatarChip.tsx +1 -1
- package/src/components/AvatarPreload.ts +149 -0
- package/src/components/ConversationThread.tsx +4 -0
- package/src/components/EmbeddedTweetCard.tsx +4 -0
- package/src/components/FloatingPreview.tsx +382 -0
- package/src/components/MarkdownCitations.tsx +680 -0
- package/src/components/MarkdownViewer.tsx +7 -715
- package/src/components/ProfileAnalysisClient.ts +191 -0
- package/src/components/ProfileAnalysisStream.tsx +16 -185
- package/src/components/ProfilePreview.tsx +41 -81
- package/src/components/TimelineCard.tsx +18 -2
- package/src/components/TweetArticleCard.tsx +66 -0
- package/src/components/TweetRichText.tsx +33 -2
- package/src/components/links-controller.ts +162 -0
- package/src/components/links-model.ts +198 -0
- package/src/components/network-map-controller.ts +84 -0
- package/src/components/network-map-model.ts +255 -0
- package/src/components/useDebouncedValue.ts +12 -0
- package/src/components/useTimelineRouteData.ts +142 -170
- package/src/lib/analysis-runtime.ts +238 -0
- package/src/lib/api-client.ts +16 -100
- package/src/lib/api-contracts.ts +328 -0
- package/src/lib/archive-finder.ts +38 -0
- package/src/lib/archive-import-plan.ts +102 -0
- package/src/lib/archive-import.ts +170 -239
- package/src/lib/authored-live.ts +77 -182
- package/src/lib/backup.ts +335 -424
- package/src/lib/bird.ts +32 -1
- package/src/lib/blocks-write.ts +30 -26
- package/src/lib/blocks.ts +18 -20
- package/src/lib/database-metrics.ts +88 -0
- package/src/lib/database-migrations.ts +34 -0
- package/src/lib/database-schema.ts +312 -0
- package/src/lib/database-writer.ts +69 -0
- package/src/lib/db.ts +141 -334
- package/src/lib/dm-read-model.ts +533 -0
- package/src/lib/dms-live.ts +34 -97
- package/src/lib/follow-graph.ts +17 -27
- package/src/lib/import-repository.ts +138 -0
- package/src/lib/inbox.ts +2 -1
- package/src/lib/live-sync-engine.ts +209 -0
- package/src/lib/live-transport-gateway.ts +128 -0
- package/src/lib/mention-threads-live.ts +90 -176
- package/src/lib/mentions-export.ts +1 -1
- package/src/lib/mentions-live.ts +57 -225
- package/src/lib/moderation-target.ts +15 -4
- package/src/lib/moderation-write.ts +1 -1
- package/src/lib/mutes-write.ts +30 -26
- package/src/lib/openai-response-runtime.ts +251 -0
- package/src/lib/paginated-sync.ts +93 -0
- package/src/lib/period-digest.ts +116 -304
- package/src/lib/profile-analysis.ts +37 -111
- package/src/lib/queries.ts +6 -2380
- package/src/lib/query-actions.ts +437 -0
- package/src/lib/query-client.tsx +47 -0
- package/src/lib/query-read-model-shared.ts +52 -0
- package/src/lib/query-read-models.ts +5 -0
- package/src/lib/query-resource.ts +41 -0
- package/src/lib/query-status.ts +164 -0
- package/src/lib/research.ts +1 -1
- package/src/lib/runtime-services.ts +20 -0
- package/src/lib/search-discussion.ts +75 -279
- package/src/lib/server-runtime-services.ts +30 -0
- package/src/lib/sqlite.ts +53 -14
- package/src/lib/streaming-ingestion.ts +240 -0
- package/src/lib/sync-cache.ts +6 -1
- package/src/lib/sync-plan.ts +175 -0
- package/src/lib/timeline-collections-live.ts +83 -256
- package/src/lib/timeline-live.ts +86 -235
- package/src/lib/timeline-read-model.ts +1191 -0
- package/src/lib/tweet-render.ts +78 -0
- package/src/lib/tweet-repository.ts +156 -0
- package/src/lib/tweet-search-live.ts +63 -166
- package/src/lib/types.ts +8 -0
- package/src/lib/ui.ts +1 -1
- package/src/lib/web-sync.ts +67 -50
- package/src/lib/whois.ts +2 -1
- package/src/router.tsx +1 -1
- package/src/routes/__root.tsx +11 -21
- package/src/routes/api/action.tsx +1 -1
- package/src/routes/api/conversation.tsx +1 -1
- package/src/routes/api/query.tsx +32 -26
- package/src/routes/api/status.tsx +6 -2
- package/src/routes/api/sync.tsx +5 -2
- package/src/routes/blocks.tsx +97 -131
- package/src/routes/data-sources.tsx +17 -25
- package/src/routes/dms.tsx +168 -167
- package/src/routes/inbox.tsx +63 -57
- package/src/routes/links.tsx +31 -329
- package/src/routes/network-map.tsx +41 -344
- package/src/routes/rate-limits.tsx +17 -21
- package/vite.config.ts +0 -2
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { Check, ChevronDown } from "lucide-react";
|
|
2
|
+
import { useQuery } from "@tanstack/react-query";
|
|
2
3
|
import type { ReactNode } from "react";
|
|
3
4
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
4
5
|
import { fetchQueryEnvelope } from "#/lib/api-client";
|
|
6
|
+
import { queryKeys } from "#/lib/query-client";
|
|
5
7
|
import type { AccountRecord } from "#/lib/types";
|
|
6
8
|
import { cx } from "#/lib/ui";
|
|
7
9
|
import { AvatarChip } from "./AvatarChip";
|
|
@@ -25,29 +27,19 @@ function avatarName(account: AccountRecord) {
|
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
export function AccountSwitcher({ action }: { action?: ReactNode } = {}) {
|
|
28
|
-
const [accounts, setAccounts] = useState<AccountRecord[]>([]);
|
|
29
30
|
const [open, setOpen] = useState(false);
|
|
30
31
|
const switcherRef = useRef<HTMLDivElement>(null);
|
|
32
|
+
const statusQuery = useQuery({
|
|
33
|
+
queryKey: queryKeys.status,
|
|
34
|
+
queryFn: ({ signal }) => fetchQueryEnvelope({ signal }),
|
|
35
|
+
});
|
|
36
|
+
const accounts = statusQuery.data?.accounts ?? [];
|
|
31
37
|
const selectedAccountId = useSelectedAccountId(accounts);
|
|
32
38
|
const selectedAccount = useMemo(
|
|
33
39
|
() => accounts.find((account) => account.id === selectedAccountId),
|
|
34
40
|
[accounts, selectedAccountId],
|
|
35
41
|
);
|
|
36
42
|
|
|
37
|
-
useEffect(() => {
|
|
38
|
-
let active = true;
|
|
39
|
-
fetchQueryEnvelope()
|
|
40
|
-
.then((meta) => {
|
|
41
|
-
if (active) setAccounts(meta.accounts);
|
|
42
|
-
})
|
|
43
|
-
.catch(() => {
|
|
44
|
-
if (active) setAccounts([]);
|
|
45
|
-
});
|
|
46
|
-
return () => {
|
|
47
|
-
active = false;
|
|
48
|
-
};
|
|
49
|
-
}, []);
|
|
50
|
-
|
|
51
43
|
useEffect(() => {
|
|
52
44
|
if (!open) return;
|
|
53
45
|
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { type RefObject, useEffect } from "react";
|
|
2
|
+
import { avatarPath } from "./AvatarChip";
|
|
3
|
+
|
|
4
|
+
const MAX_CONCURRENT_PRELOADS = 4;
|
|
5
|
+
const IDLE_TIMEOUT_MS = 2500;
|
|
6
|
+
const PRELOAD_ROOT_MARGIN = "800px 0px";
|
|
7
|
+
|
|
8
|
+
const queuedSources: string[] = [];
|
|
9
|
+
const knownSources = new Set<string>();
|
|
10
|
+
const activeImages = new Set<HTMLImageElement>();
|
|
11
|
+
|
|
12
|
+
let activePreloads = 0;
|
|
13
|
+
let listeningForLoad = false;
|
|
14
|
+
let idleId: number | null = null;
|
|
15
|
+
let fallbackTimerId: number | null = null;
|
|
16
|
+
|
|
17
|
+
function finishPreload(image: HTMLImageElement) {
|
|
18
|
+
if (!activeImages.delete(image)) return;
|
|
19
|
+
activePreloads -= 1;
|
|
20
|
+
drainPreloads();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function drainPreloads() {
|
|
24
|
+
while (activePreloads < MAX_CONCURRENT_PRELOADS && queuedSources.length > 0) {
|
|
25
|
+
const src = queuedSources.shift();
|
|
26
|
+
if (!src) return;
|
|
27
|
+
|
|
28
|
+
const image = new Image();
|
|
29
|
+
activePreloads += 1;
|
|
30
|
+
activeImages.add(image);
|
|
31
|
+
image.decoding = "async";
|
|
32
|
+
image.onerror = () => finishPreload(image);
|
|
33
|
+
image.onload = () => {
|
|
34
|
+
if (typeof image.decode !== "function") {
|
|
35
|
+
finishPreload(image);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
void image
|
|
39
|
+
.decode()
|
|
40
|
+
.catch(() => undefined)
|
|
41
|
+
.finally(() => finishPreload(image));
|
|
42
|
+
};
|
|
43
|
+
image.src = src;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function runIdlePreloads() {
|
|
48
|
+
idleId = null;
|
|
49
|
+
fallbackTimerId = null;
|
|
50
|
+
drainPreloads();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function scheduleIdlePreloads() {
|
|
54
|
+
if (
|
|
55
|
+
queuedSources.length === 0 ||
|
|
56
|
+
idleId !== null ||
|
|
57
|
+
fallbackTimerId !== null
|
|
58
|
+
) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const requestIdle = window.requestIdleCallback?.bind(window);
|
|
63
|
+
if (requestIdle) {
|
|
64
|
+
idleId = requestIdle(runIdlePreloads, {
|
|
65
|
+
timeout: IDLE_TIMEOUT_MS,
|
|
66
|
+
});
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
fallbackTimerId = window.setTimeout(runIdlePreloads, 0);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function handleWindowLoad() {
|
|
74
|
+
listeningForLoad = false;
|
|
75
|
+
scheduleIdlePreloads();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function scheduleAfterPageLoad() {
|
|
79
|
+
if (document.readyState === "complete") {
|
|
80
|
+
scheduleIdlePreloads();
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (listeningForLoad) return;
|
|
84
|
+
listeningForLoad = true;
|
|
85
|
+
window.addEventListener("load", handleWindowLoad, { once: true });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function queueAvatarPreload(profileId?: string, avatarUrl?: string) {
|
|
89
|
+
if (!profileId || !avatarUrl || typeof window === "undefined") return;
|
|
90
|
+
const src = avatarPath(profileId, avatarUrl);
|
|
91
|
+
if (knownSources.has(src)) return;
|
|
92
|
+
knownSources.add(src);
|
|
93
|
+
queuedSources.push(src);
|
|
94
|
+
scheduleAfterPageLoad();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function useAvatarPreload(
|
|
98
|
+
reference: RefObject<Element | null>,
|
|
99
|
+
profileId?: string,
|
|
100
|
+
avatarUrl?: string,
|
|
101
|
+
) {
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
if (!profileId || !avatarUrl) return;
|
|
104
|
+
const node = reference.current;
|
|
105
|
+
if (!node || typeof IntersectionObserver === "undefined") {
|
|
106
|
+
queueAvatarPreload(profileId, avatarUrl);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const observer = new IntersectionObserver(
|
|
111
|
+
(entries) => {
|
|
112
|
+
if (!entries.some((entry) => entry.isIntersecting)) return;
|
|
113
|
+
observer.disconnect();
|
|
114
|
+
queueAvatarPreload(profileId, avatarUrl);
|
|
115
|
+
},
|
|
116
|
+
{ rootMargin: PRELOAD_ROOT_MARGIN },
|
|
117
|
+
);
|
|
118
|
+
observer.observe(node);
|
|
119
|
+
return () => observer.disconnect();
|
|
120
|
+
}, [avatarUrl, profileId, reference]);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function resetPreloader() {
|
|
124
|
+
if (listeningForLoad) {
|
|
125
|
+
window.removeEventListener("load", handleWindowLoad);
|
|
126
|
+
}
|
|
127
|
+
if (idleId !== null && "cancelIdleCallback" in window) {
|
|
128
|
+
window.cancelIdleCallback(idleId);
|
|
129
|
+
}
|
|
130
|
+
if (fallbackTimerId !== null) {
|
|
131
|
+
window.clearTimeout(fallbackTimerId);
|
|
132
|
+
}
|
|
133
|
+
for (const image of activeImages) {
|
|
134
|
+
image.onload = null;
|
|
135
|
+
image.onerror = null;
|
|
136
|
+
}
|
|
137
|
+
queuedSources.length = 0;
|
|
138
|
+
knownSources.clear();
|
|
139
|
+
activeImages.clear();
|
|
140
|
+
activePreloads = 0;
|
|
141
|
+
listeningForLoad = false;
|
|
142
|
+
idleId = null;
|
|
143
|
+
fallbackTimerId = null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export const __test__ = {
|
|
147
|
+
queueAvatarPreload,
|
|
148
|
+
resetPreloader,
|
|
149
|
+
};
|
|
@@ -11,6 +11,7 @@ import { AvatarChip } from "./AvatarChip";
|
|
|
11
11
|
import { BirdclawEmpty, BirdclawLoading } from "./BrandMark";
|
|
12
12
|
import { ProfilePreview } from "./ProfilePreview";
|
|
13
13
|
import { SmartTimestamp } from "./SmartTimestamp";
|
|
14
|
+
import { TweetArticleCard } from "./TweetArticleCard";
|
|
14
15
|
import { TweetMediaGrid } from "./TweetMediaGrid";
|
|
15
16
|
import { TweetRichText } from "./TweetRichText";
|
|
16
17
|
|
|
@@ -117,6 +118,9 @@ export function ConversationThread({
|
|
|
117
118
|
text={tweet.text}
|
|
118
119
|
/>
|
|
119
120
|
<TweetMediaGrid items={tweet.media} />
|
|
121
|
+
{tweet.entities.article ? (
|
|
122
|
+
<TweetArticleCard article={tweet.entities.article} />
|
|
123
|
+
) : null}
|
|
120
124
|
</div>
|
|
121
125
|
</div>
|
|
122
126
|
);
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
} from "#/lib/ui";
|
|
11
11
|
import { ProfilePreview } from "./ProfilePreview";
|
|
12
12
|
import { SmartTimestamp } from "./SmartTimestamp";
|
|
13
|
+
import { TweetArticleCard } from "./TweetArticleCard";
|
|
13
14
|
import { TweetMediaGrid } from "./TweetMediaGrid";
|
|
14
15
|
import { TweetRichText } from "./TweetRichText";
|
|
15
16
|
|
|
@@ -46,6 +47,9 @@ export function EmbeddedTweetCard({
|
|
|
46
47
|
text={item.text}
|
|
47
48
|
/>
|
|
48
49
|
<TweetMediaGrid items={item.media} />
|
|
50
|
+
{item.entities.article ? (
|
|
51
|
+
<TweetArticleCard article={item.entities.article} />
|
|
52
|
+
) : null}
|
|
49
53
|
</section>
|
|
50
54
|
);
|
|
51
55
|
}
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type CSSProperties,
|
|
3
|
+
type FocusEvent,
|
|
4
|
+
type KeyboardEvent,
|
|
5
|
+
type PointerEvent,
|
|
6
|
+
type RefObject,
|
|
7
|
+
useCallback,
|
|
8
|
+
useEffect,
|
|
9
|
+
useId,
|
|
10
|
+
useLayoutEffect,
|
|
11
|
+
useRef,
|
|
12
|
+
useState,
|
|
13
|
+
} from "react";
|
|
14
|
+
|
|
15
|
+
type PreviewSide = "top" | "bottom";
|
|
16
|
+
|
|
17
|
+
type Rect = {
|
|
18
|
+
top: number;
|
|
19
|
+
right: number;
|
|
20
|
+
bottom: number;
|
|
21
|
+
left: number;
|
|
22
|
+
width: number;
|
|
23
|
+
height: number;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type PreviewPosition = {
|
|
27
|
+
top: number;
|
|
28
|
+
left: number;
|
|
29
|
+
maxWidth: number;
|
|
30
|
+
maxHeight: number;
|
|
31
|
+
side: PreviewSide;
|
|
32
|
+
measured: boolean;
|
|
33
|
+
ready: boolean;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
type PlacementInput = {
|
|
37
|
+
referenceRects: Rect[];
|
|
38
|
+
floatingWidth: number;
|
|
39
|
+
floatingHeight: number;
|
|
40
|
+
viewport: Rect;
|
|
41
|
+
gap?: number;
|
|
42
|
+
gutter?: number;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const DEFAULT_GAP = 10;
|
|
46
|
+
const DEFAULT_GUTTER = 12;
|
|
47
|
+
const DEFAULT_CLOSE_DELAY_MS = 120;
|
|
48
|
+
|
|
49
|
+
const initialPosition: PreviewPosition = {
|
|
50
|
+
top: 0,
|
|
51
|
+
left: 0,
|
|
52
|
+
maxWidth: 0,
|
|
53
|
+
maxHeight: 0,
|
|
54
|
+
side: "bottom",
|
|
55
|
+
measured: false,
|
|
56
|
+
ready: false,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
function edgeRect(rects: Rect[], side: PreviewSide) {
|
|
60
|
+
return rects.reduce((best, rect) => {
|
|
61
|
+
if (side === "bottom") {
|
|
62
|
+
if (rect.bottom > best.bottom) return rect;
|
|
63
|
+
if (rect.bottom === best.bottom && rect.width > best.width) return rect;
|
|
64
|
+
return best;
|
|
65
|
+
}
|
|
66
|
+
if (rect.top < best.top) return rect;
|
|
67
|
+
if (rect.top === best.top && rect.width > best.width) return rect;
|
|
68
|
+
return best;
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function clamp(value: number, minimum: number, maximum: number) {
|
|
73
|
+
if (maximum < minimum) return minimum;
|
|
74
|
+
return Math.min(Math.max(value, minimum), maximum);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function calculatePreviewPosition({
|
|
78
|
+
referenceRects,
|
|
79
|
+
floatingWidth,
|
|
80
|
+
floatingHeight,
|
|
81
|
+
viewport,
|
|
82
|
+
gap = DEFAULT_GAP,
|
|
83
|
+
gutter = DEFAULT_GUTTER,
|
|
84
|
+
}: PlacementInput): PreviewPosition {
|
|
85
|
+
const referenceTop = Math.min(...referenceRects.map((rect) => rect.top));
|
|
86
|
+
const referenceBottom = Math.max(
|
|
87
|
+
...referenceRects.map((rect) => rect.bottom),
|
|
88
|
+
);
|
|
89
|
+
const availableBelow = Math.max(
|
|
90
|
+
0,
|
|
91
|
+
viewport.bottom - gutter - referenceBottom - gap,
|
|
92
|
+
);
|
|
93
|
+
const availableAbove = Math.max(
|
|
94
|
+
0,
|
|
95
|
+
referenceTop - viewport.top - gutter - gap,
|
|
96
|
+
);
|
|
97
|
+
const side: PreviewSide =
|
|
98
|
+
availableBelow >= floatingHeight || availableBelow >= availableAbove
|
|
99
|
+
? "bottom"
|
|
100
|
+
: "top";
|
|
101
|
+
const availableHeight = side === "bottom" ? availableBelow : availableAbove;
|
|
102
|
+
const renderedHeight = Math.min(floatingHeight, availableHeight);
|
|
103
|
+
const anchor = edgeRect(referenceRects, side);
|
|
104
|
+
const maxWidth = Math.max(0, viewport.width - gutter * 2);
|
|
105
|
+
const renderedWidth = Math.min(floatingWidth, maxWidth);
|
|
106
|
+
const minimumLeft = viewport.left + gutter;
|
|
107
|
+
const maximumLeft = viewport.right - gutter - renderedWidth;
|
|
108
|
+
const left = clamp(
|
|
109
|
+
anchor.left + anchor.width / 2 - renderedWidth / 2,
|
|
110
|
+
minimumLeft,
|
|
111
|
+
maximumLeft,
|
|
112
|
+
);
|
|
113
|
+
const top =
|
|
114
|
+
side === "bottom"
|
|
115
|
+
? referenceBottom + gap
|
|
116
|
+
: referenceTop - gap - renderedHeight;
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
top,
|
|
120
|
+
left,
|
|
121
|
+
maxWidth,
|
|
122
|
+
maxHeight: availableHeight,
|
|
123
|
+
side,
|
|
124
|
+
measured: true,
|
|
125
|
+
ready: true,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function viewportRect(): Rect {
|
|
130
|
+
const viewport = window.visualViewport;
|
|
131
|
+
const left = viewport?.offsetLeft ?? 0;
|
|
132
|
+
const top = viewport?.offsetTop ?? 0;
|
|
133
|
+
const width = viewport?.width ?? window.innerWidth;
|
|
134
|
+
const height = viewport?.height ?? window.innerHeight;
|
|
135
|
+
return {
|
|
136
|
+
top,
|
|
137
|
+
right: left + width,
|
|
138
|
+
bottom: top + height,
|
|
139
|
+
left,
|
|
140
|
+
width,
|
|
141
|
+
height,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function elementRects(element: HTMLElement): Rect[] {
|
|
146
|
+
const fragments = Array.from(element.getClientRects()).filter(
|
|
147
|
+
(rect) => rect.width > 0 || rect.height > 0,
|
|
148
|
+
);
|
|
149
|
+
return fragments.length > 0 ? fragments : [element.getBoundingClientRect()];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function referenceIntersectsViewport(rects: Rect[], viewport: Rect) {
|
|
153
|
+
return rects.some(
|
|
154
|
+
(rect) =>
|
|
155
|
+
rect.right >= viewport.left &&
|
|
156
|
+
rect.left <= viewport.right &&
|
|
157
|
+
rect.bottom >= viewport.top &&
|
|
158
|
+
rect.top <= viewport.bottom,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function samePosition(left: PreviewPosition, right: PreviewPosition) {
|
|
163
|
+
return (
|
|
164
|
+
left.top === right.top &&
|
|
165
|
+
left.left === right.left &&
|
|
166
|
+
left.maxWidth === right.maxWidth &&
|
|
167
|
+
left.maxHeight === right.maxHeight &&
|
|
168
|
+
left.side === right.side &&
|
|
169
|
+
left.measured === right.measured &&
|
|
170
|
+
left.ready === right.ready
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function floatingStyle(position: PreviewPosition): CSSProperties {
|
|
175
|
+
return {
|
|
176
|
+
position: "fixed",
|
|
177
|
+
top: position.top,
|
|
178
|
+
left: position.left,
|
|
179
|
+
// Apply constraints during the hidden first pass so remeasurement can settle.
|
|
180
|
+
maxWidth: position.measured ? position.maxWidth : undefined,
|
|
181
|
+
maxHeight: position.measured ? position.maxHeight : undefined,
|
|
182
|
+
visibility: position.ready ? "visible" : "hidden",
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function containsTarget(
|
|
187
|
+
target: EventTarget | null,
|
|
188
|
+
...elements: Array<HTMLElement | null>
|
|
189
|
+
) {
|
|
190
|
+
return (
|
|
191
|
+
target instanceof Node &&
|
|
192
|
+
elements.some((element) => element?.contains(target))
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function useFloatingPreview(options: { closeDelayMs?: number } = {}): {
|
|
197
|
+
open: boolean;
|
|
198
|
+
referenceRef: RefObject<HTMLSpanElement | null>;
|
|
199
|
+
floatingRef: RefObject<HTMLSpanElement | null>;
|
|
200
|
+
floatingStyle: CSSProperties;
|
|
201
|
+
referenceProps: {
|
|
202
|
+
onPointerEnter: (event: PointerEvent<HTMLElement>) => void;
|
|
203
|
+
onPointerLeave: (event: PointerEvent<HTMLElement>) => void;
|
|
204
|
+
onFocus: (event: FocusEvent<HTMLElement>) => void;
|
|
205
|
+
onBlur: (event: FocusEvent<HTMLElement>) => void;
|
|
206
|
+
onKeyDown: (event: KeyboardEvent<HTMLElement>) => void;
|
|
207
|
+
};
|
|
208
|
+
floatingProps: {
|
|
209
|
+
onPointerEnter: (event: PointerEvent<HTMLElement>) => void;
|
|
210
|
+
onPointerLeave: (event: PointerEvent<HTMLElement>) => void;
|
|
211
|
+
onFocus: (event: FocusEvent<HTMLElement>) => void;
|
|
212
|
+
onBlur: (event: FocusEvent<HTMLElement>) => void;
|
|
213
|
+
onKeyDown: (event: KeyboardEvent<HTMLElement>) => void;
|
|
214
|
+
};
|
|
215
|
+
floatingId: string;
|
|
216
|
+
closePreview: () => void;
|
|
217
|
+
} {
|
|
218
|
+
const closeDelayMs = options.closeDelayMs ?? DEFAULT_CLOSE_DELAY_MS;
|
|
219
|
+
const [open, setOpen] = useState(false);
|
|
220
|
+
const [position, setPosition] = useState(initialPosition);
|
|
221
|
+
const floatingId = useId();
|
|
222
|
+
const referenceRef = useRef<HTMLSpanElement | null>(null);
|
|
223
|
+
const floatingRef = useRef<HTMLSpanElement | null>(null);
|
|
224
|
+
const closeTimerRef = useRef<number | null>(null);
|
|
225
|
+
|
|
226
|
+
const clearCloseTimer = useCallback(() => {
|
|
227
|
+
if (closeTimerRef.current === null) return;
|
|
228
|
+
window.clearTimeout(closeTimerRef.current);
|
|
229
|
+
closeTimerRef.current = null;
|
|
230
|
+
}, []);
|
|
231
|
+
|
|
232
|
+
const openPreview = useCallback(() => {
|
|
233
|
+
clearCloseTimer();
|
|
234
|
+
if (!open) setPosition(initialPosition);
|
|
235
|
+
setOpen(true);
|
|
236
|
+
}, [clearCloseTimer, open]);
|
|
237
|
+
|
|
238
|
+
const closePreview = useCallback(() => {
|
|
239
|
+
clearCloseTimer();
|
|
240
|
+
setOpen(false);
|
|
241
|
+
}, [clearCloseTimer]);
|
|
242
|
+
|
|
243
|
+
const scheduleClose = useCallback(() => {
|
|
244
|
+
clearCloseTimer();
|
|
245
|
+
closeTimerRef.current = window.setTimeout(() => {
|
|
246
|
+
closeTimerRef.current = null;
|
|
247
|
+
const reference = referenceRef.current;
|
|
248
|
+
const floating = floatingRef.current;
|
|
249
|
+
const focusInside = containsTarget(
|
|
250
|
+
document.activeElement,
|
|
251
|
+
reference,
|
|
252
|
+
floating,
|
|
253
|
+
);
|
|
254
|
+
const pointerInside =
|
|
255
|
+
Boolean(reference?.matches(":hover")) ||
|
|
256
|
+
Boolean(floating?.matches(":hover"));
|
|
257
|
+
if (!focusInside && !pointerInside) setOpen(false);
|
|
258
|
+
}, closeDelayMs);
|
|
259
|
+
}, [clearCloseTimer, closeDelayMs]);
|
|
260
|
+
|
|
261
|
+
const handleBlur = useCallback(
|
|
262
|
+
(event: FocusEvent<HTMLElement>) => {
|
|
263
|
+
if (
|
|
264
|
+
containsTarget(
|
|
265
|
+
event.relatedTarget,
|
|
266
|
+
referenceRef.current,
|
|
267
|
+
floatingRef.current,
|
|
268
|
+
)
|
|
269
|
+
) {
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
scheduleClose();
|
|
273
|
+
},
|
|
274
|
+
[scheduleClose],
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
const handleKeyDown = useCallback(
|
|
278
|
+
(event: KeyboardEvent<HTMLElement>) => {
|
|
279
|
+
if (event.key !== "Escape") return;
|
|
280
|
+
closePreview();
|
|
281
|
+
referenceRef.current?.querySelector<HTMLElement>("a,button")?.focus();
|
|
282
|
+
},
|
|
283
|
+
[closePreview],
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
const updatePlacement = useCallback(() => {
|
|
287
|
+
const reference = referenceRef.current;
|
|
288
|
+
const floating = floatingRef.current;
|
|
289
|
+
if (!reference || !floating) return;
|
|
290
|
+
const viewport = viewportRect();
|
|
291
|
+
const referenceRects = elementRects(reference);
|
|
292
|
+
if (!referenceIntersectsViewport(referenceRects, viewport)) {
|
|
293
|
+
setPosition((current) =>
|
|
294
|
+
current.ready ? { ...current, ready: false } : current,
|
|
295
|
+
);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const floatingRect = floating.getBoundingClientRect();
|
|
299
|
+
const content = floating.querySelector<HTMLElement>(
|
|
300
|
+
"[data-floating-preview-content]",
|
|
301
|
+
);
|
|
302
|
+
const style = window.getComputedStyle(floating);
|
|
303
|
+
const verticalChrome = [
|
|
304
|
+
style.paddingTop,
|
|
305
|
+
style.paddingBottom,
|
|
306
|
+
style.borderTopWidth,
|
|
307
|
+
style.borderBottomWidth,
|
|
308
|
+
].reduce((total, value) => total + (Number.parseFloat(value) || 0), 0);
|
|
309
|
+
// Measure the unclipped child so a previously constrained card can grow again.
|
|
310
|
+
const naturalHeight = Math.max(
|
|
311
|
+
floatingRect.height,
|
|
312
|
+
(content?.getBoundingClientRect().height ?? 0) + verticalChrome,
|
|
313
|
+
);
|
|
314
|
+
const next = calculatePreviewPosition({
|
|
315
|
+
referenceRects,
|
|
316
|
+
floatingWidth: floatingRect.width,
|
|
317
|
+
floatingHeight: naturalHeight,
|
|
318
|
+
viewport,
|
|
319
|
+
});
|
|
320
|
+
if (floatingRect.width > next.maxWidth + 0.5) next.ready = false;
|
|
321
|
+
setPosition((current) => (samePosition(current, next) ? current : next));
|
|
322
|
+
}, []);
|
|
323
|
+
|
|
324
|
+
useLayoutEffect(() => {
|
|
325
|
+
if (!open) return;
|
|
326
|
+
updatePlacement();
|
|
327
|
+
let frame = 0;
|
|
328
|
+
const trackLayout = () => {
|
|
329
|
+
updatePlacement();
|
|
330
|
+
frame = window.requestAnimationFrame(trackLayout);
|
|
331
|
+
};
|
|
332
|
+
// Fixed positioning needs explicit tracking when surrounding content shifts.
|
|
333
|
+
frame = window.requestAnimationFrame(trackLayout);
|
|
334
|
+
const viewport = window.visualViewport;
|
|
335
|
+
window.addEventListener("resize", updatePlacement);
|
|
336
|
+
window.addEventListener("scroll", updatePlacement, true);
|
|
337
|
+
viewport?.addEventListener("resize", updatePlacement);
|
|
338
|
+
viewport?.addEventListener("scroll", updatePlacement);
|
|
339
|
+
const observer =
|
|
340
|
+
typeof ResizeObserver === "undefined"
|
|
341
|
+
? null
|
|
342
|
+
: new ResizeObserver(updatePlacement);
|
|
343
|
+
if (referenceRef.current) observer?.observe(referenceRef.current);
|
|
344
|
+
if (floatingRef.current) observer?.observe(floatingRef.current);
|
|
345
|
+
|
|
346
|
+
return () => {
|
|
347
|
+
window.cancelAnimationFrame(frame);
|
|
348
|
+
window.removeEventListener("resize", updatePlacement);
|
|
349
|
+
window.removeEventListener("scroll", updatePlacement, true);
|
|
350
|
+
viewport?.removeEventListener("resize", updatePlacement);
|
|
351
|
+
viewport?.removeEventListener("scroll", updatePlacement);
|
|
352
|
+
observer?.disconnect();
|
|
353
|
+
};
|
|
354
|
+
}, [open, updatePlacement]);
|
|
355
|
+
|
|
356
|
+
useEffect(() => () => clearCloseTimer(), [clearCloseTimer]);
|
|
357
|
+
|
|
358
|
+
const interactionProps = {
|
|
359
|
+
onPointerEnter: (_event: PointerEvent<HTMLElement>) => openPreview(),
|
|
360
|
+
onPointerLeave: (_event: PointerEvent<HTMLElement>) => scheduleClose(),
|
|
361
|
+
onFocus: (_event: FocusEvent<HTMLElement>) => openPreview(),
|
|
362
|
+
onBlur: handleBlur,
|
|
363
|
+
onKeyDown: handleKeyDown,
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
return {
|
|
367
|
+
open,
|
|
368
|
+
referenceRef,
|
|
369
|
+
floatingRef,
|
|
370
|
+
floatingStyle: floatingStyle(position),
|
|
371
|
+
referenceProps: interactionProps,
|
|
372
|
+
floatingProps: interactionProps,
|
|
373
|
+
floatingId,
|
|
374
|
+
closePreview,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export const __test__ = {
|
|
379
|
+
calculatePreviewPosition,
|
|
380
|
+
floatingStyle,
|
|
381
|
+
referenceIntersectsViewport,
|
|
382
|
+
};
|