@lapismd/design-core 0.1.2 → 0.2.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/CHANGELOG.md +22 -0
- package/LICENSE.md +3 -3
- package/README.md +20 -0
- package/package.json +24 -9
- package/spec/src/styling-and-themes.md +2 -0
- package/src/shared/ai/chat.css +23 -0
- package/src/shared/ai/chat.ts +12 -0
- package/src/shared/ai/context.svelte.ts +2 -0
- package/src/shared/ai/layout/Layout.svelte +116 -10
- package/src/shared/ai/message-list/MessageList.svelte +123 -43
- package/src/shared/ai/message-list/MessageListHistoryFixture.svelte +112 -0
- package/src/shared/ai/message-list/MessageListLegacyFixture.svelte +52 -0
- package/src/shared/ai/message-list/VirtualizedMessageRows.svelte +299 -0
- package/src/shared/ai/message-list/position.ts +7 -0
- package/src/shared/ai/new-messages.svelte.ts +24 -5
- package/src/shared/ai/stream-scroll.svelte.ts +135 -6
- package/src/shared/ai/tool-call-detail/ToolCallDetail.svelte +71 -0
- package/src/shared/ai/tool-call-detail/tool-call-detail.ts +209 -0
- package/src/shared/diff/core/merge/actions.ts +4 -3
- package/src/shared/diff/core/merge/render-model.ts +14 -10
- package/src/shared/diff/file-diff/FileDiff.css +68 -1
- package/src/shared/diff/file-diff/FileDiff.svelte +22 -6
- package/src/shared/diff/file-diff/FileDiffComposer.svelte +98 -16
- package/src/shared/diff/file-diff/index.ts +2 -0
- package/src/shared/diff/file-diff/types.ts +13 -0
- package/src/shared/diff/index.ts +2 -0
- package/src/shared/forms/confirm-button/ConfirmButton.svelte +38 -0
- package/src/shared/forms/confirm-button/ConfirmButtonExample.svelte +14 -0
- package/src/shared/forms/confirm-button/index.ts +1 -0
- package/src/shared/forms/confirm-dialog/ConfirmDialog.css +21 -0
- package/src/shared/forms/confirm-dialog/ConfirmDialog.svelte +116 -0
- package/src/shared/forms/confirm-dialog/ConfirmDialogExample.svelte +17 -0
- package/src/shared/forms/confirm-dialog/index.ts +2 -0
- package/src/shared/forms/dialog-frame/DialogFrame.css +130 -0
- package/src/shared/forms/dialog-frame/DialogFrame.svelte +108 -0
- package/src/shared/forms/dialog-frame/DialogFrameExample.svelte +37 -0
- package/src/shared/forms/dialog-frame/index.ts +6 -0
- package/src/shared/forms/dialog.ts +8 -0
- package/src/shared/forms/index.ts +11 -0
- package/src/shared/shadcn/button/button.svelte +4 -0
- package/src/shared/shadcn/code-block/highlightRanges.ts +10 -6
- package/src/shared/shadcn/code-block/syntax/defineSyntaxTheme.ts +4 -2
- package/src/shared/shadcn/code-block/tokenizer.ts +3 -3
- package/src/shared/shadcn/dialog/dialog-content.svelte +3 -4
- package/src/shared/shadcn/dialog/dialog-portal.svelte +10 -2
- package/src/shared/shadcn/tooltip/tooltip-content.svelte +10 -4
- package/src/shared/shadcn/tooltip/tooltip.tokens.ts +4 -0
- package/src/shared/shell/app-shell/AppShell.css +112 -32
- package/src/shared/shell/app-shell/AppShellMain.svelte +12 -1
- package/src/shared/shell/app-shell/AppShellRoot.svelte +27 -13
- package/src/shared/shell/app-shell/AppShellSidebar.svelte +121 -19
- package/src/shared/shell/app-shell/AppShellSidebarClose.svelte +17 -17
- package/src/shared/shell/app-shell/AppShellSidebarToggle.svelte +38 -58
- package/src/shared/shell/app-shell/AppShellSurfaceLayer.svelte +173 -0
- package/src/shared/shell/app-shell/AppShellSurfaceLayerBody.svelte +32 -0
- package/src/shared/shell/app-shell/AppShellSurfaceLayerHeader.svelte +26 -0
- package/src/shared/shell/app-shell/app-shell-context.svelte.ts +6 -3
- package/src/shared/shell/app-shell/app-shell-controller.svelte.ts +246 -2
- package/src/shared/shell/app-shell/app-shell-sidebar-context.svelte.ts +1 -0
- package/src/shared/shell/app-shell/examples/AppShellExamples.css +103 -0
- package/src/shared/shell/app-shell/examples/AppShellMultiPanelDemo.svelte +187 -0
- package/src/shared/shell/app-shell/index.ts +14 -0
- package/src/shared/shell/shell.tokens.css +2 -0
- package/src/shared/shell/shell.tokens.ts +5 -0
- package/src/theme.css +10 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Button } from "@lapismd/design-core/shadcn/button";
|
|
3
|
+
import Layout from "../layout/Layout.svelte";
|
|
4
|
+
import MessageList from "./MessageList.svelte";
|
|
5
|
+
import Message from "../message/Message.svelte";
|
|
6
|
+
import MessageBubble from "../message-bubble/MessageBubble.svelte";
|
|
7
|
+
import type { MessageListPosition } from "./position.js";
|
|
8
|
+
let items = $state(
|
|
9
|
+
Array.from({ length: 1000 }, (_, index) => ({
|
|
10
|
+
id: `message-${index}`,
|
|
11
|
+
text: `Message ${index}: ${"Variable-height cached conversation content. ".repeat((index % 7) + 1)}`,
|
|
12
|
+
})),
|
|
13
|
+
);
|
|
14
|
+
let unread = $state(false);
|
|
15
|
+
let expanded = $state(false);
|
|
16
|
+
let position = $state<MessageListPosition>();
|
|
17
|
+
let presentation = $state<{ key: string; position?: MessageListPosition }>({
|
|
18
|
+
key: "channel-a",
|
|
19
|
+
});
|
|
20
|
+
let loads = $state(0);
|
|
21
|
+
let key = $state("channel-a");
|
|
22
|
+
let delay = $state(false);
|
|
23
|
+
let statusUpdates = $state(0);
|
|
24
|
+
let positionReports = $state(0);
|
|
25
|
+
function append(incoming: boolean) {
|
|
26
|
+
items = [
|
|
27
|
+
...items,
|
|
28
|
+
{
|
|
29
|
+
id: `message-${items.length}`,
|
|
30
|
+
text: incoming ? "Incoming message" : "My own message",
|
|
31
|
+
},
|
|
32
|
+
];
|
|
33
|
+
if (incoming) unread = true;
|
|
34
|
+
}
|
|
35
|
+
</script>
|
|
36
|
+
|
|
37
|
+
<div data-story="history-controls">
|
|
38
|
+
<Button onclick={() => append(true)}>Receive message</Button>
|
|
39
|
+
<Button onclick={() => append(false)}>Send own message</Button>
|
|
40
|
+
<Button onclick={() => (expanded = !expanded)}>Resize content</Button>
|
|
41
|
+
<Button
|
|
42
|
+
onclick={() => (key = key === "channel-a" ? "channel-b" : "channel-a")}
|
|
43
|
+
>Switch conversation</Button
|
|
44
|
+
>
|
|
45
|
+
<Button onclick={() => (delay = !delay)}>Toggle delayed history</Button>
|
|
46
|
+
<Button onclick={() => statusUpdates++}>Update unrelated status</Button>
|
|
47
|
+
<output aria-label="Position reports">{positionReports}</output>
|
|
48
|
+
<output aria-label="History loads">{loads}</output>
|
|
49
|
+
<output aria-label="Reading anchor">{position?.messageId ?? "none"}</output>
|
|
50
|
+
</div>
|
|
51
|
+
<div
|
|
52
|
+
data-story="virtual-history-frame"
|
|
53
|
+
style:width={expanded ? "360px" : "640px"}
|
|
54
|
+
style="height: 420px; max-width: 100%; display: flex; flex-direction: column;"
|
|
55
|
+
>
|
|
56
|
+
<Layout
|
|
57
|
+
scrollMode="history"
|
|
58
|
+
conversationKey={presentation.key === key ? presentation.key : key}
|
|
59
|
+
hasNewMessages={unread}
|
|
60
|
+
onViewportChange={({ atLatest, active }) => {
|
|
61
|
+
if (atLatest && active) unread = false;
|
|
62
|
+
}}
|
|
63
|
+
>
|
|
64
|
+
{#snippet composer()}<span>Conversation composer</span>{/snippet}
|
|
65
|
+
<MessageList
|
|
66
|
+
{...{
|
|
67
|
+
items,
|
|
68
|
+
overscan: 6,
|
|
69
|
+
estimateSize: 112,
|
|
70
|
+
retainRowIds: [],
|
|
71
|
+
"data-status": statusUpdates,
|
|
72
|
+
"data-position-reports": positionReports,
|
|
73
|
+
}}
|
|
74
|
+
virtualize
|
|
75
|
+
conversationKey={presentation.key === key ? presentation.key : key}
|
|
76
|
+
onPositionChange={(next) => {
|
|
77
|
+
positionReports++;
|
|
78
|
+
position = next;
|
|
79
|
+
if (
|
|
80
|
+
JSON.stringify(presentation.position) !== JSON.stringify(next) ||
|
|
81
|
+
presentation.key !== key
|
|
82
|
+
)
|
|
83
|
+
presentation = { key, position: next };
|
|
84
|
+
}}
|
|
85
|
+
scrollToTopAction={async () => {
|
|
86
|
+
loads++;
|
|
87
|
+
if (delay) await new Promise((resolve) => setTimeout(resolve, 250));
|
|
88
|
+
const prefix = `older-${loads}`;
|
|
89
|
+
items = [
|
|
90
|
+
...Array.from({ length: 50 }, (_, index) => ({
|
|
91
|
+
id: `${prefix}-${index}`,
|
|
92
|
+
text: `Earlier message ${index}`,
|
|
93
|
+
})),
|
|
94
|
+
...items,
|
|
95
|
+
];
|
|
96
|
+
}}
|
|
97
|
+
>
|
|
98
|
+
{#snippet row(item)}
|
|
99
|
+
<Message sender="user"
|
|
100
|
+
><MessageBubble>
|
|
101
|
+
<p>{item.text}</p>
|
|
102
|
+
{#if expanded}<p>
|
|
103
|
+
Additional image or expanded text changes this row's height
|
|
104
|
+
without adding a message.
|
|
105
|
+
</p>{/if}
|
|
106
|
+
<Button size="xs" variant="ghost">Action for {item.id}</Button>
|
|
107
|
+
</MessageBubble></Message
|
|
108
|
+
>
|
|
109
|
+
{/snippet}
|
|
110
|
+
</MessageList>
|
|
111
|
+
</Layout>
|
|
112
|
+
</div>
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Button } from "@lapismd/design-core/shadcn/button";
|
|
3
|
+
import Layout from "../layout/Layout.svelte";
|
|
4
|
+
import MessageList from "./MessageList.svelte";
|
|
5
|
+
import Message from "../message/Message.svelte";
|
|
6
|
+
import MessageBubble from "../message-bubble/MessageBubble.svelte";
|
|
7
|
+
let items = $state(
|
|
8
|
+
Array.from({ length: 100 }, (_, index) => ({
|
|
9
|
+
id: `legacy-${index}`,
|
|
10
|
+
text: `Existing message ${index}`,
|
|
11
|
+
})),
|
|
12
|
+
);
|
|
13
|
+
let loads = $state(0);
|
|
14
|
+
let expanded = $state(false);
|
|
15
|
+
</script>
|
|
16
|
+
|
|
17
|
+
<Button
|
|
18
|
+
onclick={() =>
|
|
19
|
+
(items = [...items, { id: "incoming", text: "New incoming message" }])}
|
|
20
|
+
>Receive explicit arrival</Button
|
|
21
|
+
>
|
|
22
|
+
<Button onclick={() => (expanded = !expanded)}>Reflow existing content</Button>
|
|
23
|
+
<output aria-label="Legacy history requests">{loads}</output>
|
|
24
|
+
<div
|
|
25
|
+
data-story="legacy-history-frame"
|
|
26
|
+
style="height: 420px; width: 540px; display: flex; flex-direction: column;"
|
|
27
|
+
>
|
|
28
|
+
<Layout>
|
|
29
|
+
{#snippet composer()}<span>Existing composer snippet</span>{/snippet}
|
|
30
|
+
<MessageList
|
|
31
|
+
density="compact"
|
|
32
|
+
gap="8px"
|
|
33
|
+
latestMessageId={items.at(-1)?.id}
|
|
34
|
+
scrollToTopAction={async () => {
|
|
35
|
+
loads++;
|
|
36
|
+
items = [
|
|
37
|
+
{ id: `earlier-${loads}`, text: `Earlier context ${loads}` },
|
|
38
|
+
...items,
|
|
39
|
+
];
|
|
40
|
+
}}
|
|
41
|
+
>
|
|
42
|
+
{#each items as item (item.id)}
|
|
43
|
+
<Message sender="user"
|
|
44
|
+
><MessageBubble>
|
|
45
|
+
{item.text}
|
|
46
|
+
{#if expanded}<p>Reflow without a new message</p>{/if}
|
|
47
|
+
</MessageBubble></Message
|
|
48
|
+
>
|
|
49
|
+
{/each}
|
|
50
|
+
</MessageList>
|
|
51
|
+
</Layout>
|
|
52
|
+
</div>
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
<script lang="ts" generics="T extends { id: string }">
|
|
2
|
+
import {
|
|
3
|
+
createVirtualizer,
|
|
4
|
+
defaultRangeExtractor,
|
|
5
|
+
} from "@tanstack/svelte-virtual";
|
|
6
|
+
import { get } from "svelte/store";
|
|
7
|
+
import { onDestroy, tick, untrack, type Snippet } from "svelte";
|
|
8
|
+
import { useLayoutContext } from "../context.svelte.js";
|
|
9
|
+
import type { MessageListPosition } from "./position.js";
|
|
10
|
+
|
|
11
|
+
let {
|
|
12
|
+
items,
|
|
13
|
+
row,
|
|
14
|
+
overscan,
|
|
15
|
+
estimateSize,
|
|
16
|
+
conversationKey,
|
|
17
|
+
initialPosition,
|
|
18
|
+
onPositionChange,
|
|
19
|
+
retainRowIds,
|
|
20
|
+
hasOlder,
|
|
21
|
+
loading,
|
|
22
|
+
onLoadOlder,
|
|
23
|
+
}: {
|
|
24
|
+
items: readonly T[];
|
|
25
|
+
row: Snippet<[T, number]>;
|
|
26
|
+
overscan: number;
|
|
27
|
+
estimateSize: number;
|
|
28
|
+
conversationKey: string;
|
|
29
|
+
initialPosition?: MessageListPosition | undefined;
|
|
30
|
+
onPositionChange?: ((position: MessageListPosition) => void) | undefined;
|
|
31
|
+
retainRowIds: readonly string[];
|
|
32
|
+
hasOlder: boolean;
|
|
33
|
+
loading: boolean;
|
|
34
|
+
onLoadOlder?: () => Promise<void>;
|
|
35
|
+
} = $props();
|
|
36
|
+
const layout = useLayoutContext();
|
|
37
|
+
const stableKey = $derived(conversationKey);
|
|
38
|
+
const stableItems = $derived(items);
|
|
39
|
+
let virtualRef = $state<HTMLDivElement | null>(null);
|
|
40
|
+
let activeRowId = $state<string>();
|
|
41
|
+
let margin = $state(0);
|
|
42
|
+
let restoredKey: string | undefined;
|
|
43
|
+
let priorKey: string | undefined;
|
|
44
|
+
let priorItems: readonly T[] = [];
|
|
45
|
+
let paginationArmed = false;
|
|
46
|
+
let lastTop = 0;
|
|
47
|
+
let alive = true;
|
|
48
|
+
let priorConfiguration:
|
|
49
|
+
| {
|
|
50
|
+
key: string;
|
|
51
|
+
items: readonly T[];
|
|
52
|
+
margin: number;
|
|
53
|
+
size: number;
|
|
54
|
+
overscan: number;
|
|
55
|
+
retained: string;
|
|
56
|
+
}
|
|
57
|
+
| undefined;
|
|
58
|
+
onDestroy(() => {
|
|
59
|
+
alive = false;
|
|
60
|
+
});
|
|
61
|
+
const virtualizer = createVirtualizer<HTMLElement, HTMLDivElement>({
|
|
62
|
+
count: 0,
|
|
63
|
+
getScrollElement: () => layout?.getScrollContainer() ?? null,
|
|
64
|
+
estimateSize: () => estimateSize,
|
|
65
|
+
enabled: false,
|
|
66
|
+
useAnimationFrameWithResizeObserver: true,
|
|
67
|
+
scrollToFn: (offset, options) =>
|
|
68
|
+
layout?.streamScroll.scrollToOffset?.(
|
|
69
|
+
offset + (options.adjustments ?? 0),
|
|
70
|
+
true,
|
|
71
|
+
),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
$effect(() => {
|
|
75
|
+
layout?.setVirtualized?.(true);
|
|
76
|
+
return () => layout?.setVirtualized?.(false);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
function readPosition(): MessageListPosition | undefined {
|
|
80
|
+
const instance = get(virtualizer);
|
|
81
|
+
const viewport = layout?.getScrollContainer();
|
|
82
|
+
if (!viewport || !items.length) return;
|
|
83
|
+
const first = instance
|
|
84
|
+
.getVirtualItems()
|
|
85
|
+
.find((item) => item.end > viewport.scrollTop);
|
|
86
|
+
const item = first && items[first.index];
|
|
87
|
+
if (!first || !item) return;
|
|
88
|
+
return {
|
|
89
|
+
messageId: item.id,
|
|
90
|
+
index: first.index,
|
|
91
|
+
offset: viewport.scrollTop - first.start,
|
|
92
|
+
atLatest: layout?.streamScroll.isLocked ?? false,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function reportPosition() {
|
|
97
|
+
if (
|
|
98
|
+
restoredKey !== conversationKey ||
|
|
99
|
+
document.visibilityState === "hidden"
|
|
100
|
+
)
|
|
101
|
+
return;
|
|
102
|
+
const position = readPosition();
|
|
103
|
+
if (position) untrack(() => onPositionChange?.(position));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function measure(node: HTMLDivElement) {
|
|
107
|
+
untrack(() => get(virtualizer).measureElement(node));
|
|
108
|
+
return {
|
|
109
|
+
destroy() {
|
|
110
|
+
untrack(() => get(virtualizer).measureElement(null));
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Inputs alone invalidate the virtualizer. Its returned ranges must never restart this effect.
|
|
116
|
+
$effect(() => {
|
|
117
|
+
const nextItems = stableItems,
|
|
118
|
+
key = stableKey,
|
|
119
|
+
enabled = true,
|
|
120
|
+
scrollMargin = margin;
|
|
121
|
+
const keep = [...retainRowIds, ...(activeRowId ? [activeRowId] : [])];
|
|
122
|
+
const extra = keep
|
|
123
|
+
.map((id) => nextItems.findIndex((item) => item.id === id))
|
|
124
|
+
.filter((index) => index >= 0);
|
|
125
|
+
const size = estimateSize,
|
|
126
|
+
scan = overscan;
|
|
127
|
+
untrack(() => {
|
|
128
|
+
const retained = JSON.stringify(extra);
|
|
129
|
+
if (
|
|
130
|
+
priorConfiguration?.key === key &&
|
|
131
|
+
priorConfiguration.items === nextItems &&
|
|
132
|
+
priorConfiguration.margin === scrollMargin &&
|
|
133
|
+
priorConfiguration.size === size &&
|
|
134
|
+
priorConfiguration.overscan === scan &&
|
|
135
|
+
priorConfiguration.retained === retained
|
|
136
|
+
)
|
|
137
|
+
return;
|
|
138
|
+
priorConfiguration = {
|
|
139
|
+
key,
|
|
140
|
+
items: nextItems,
|
|
141
|
+
margin: scrollMargin,
|
|
142
|
+
size,
|
|
143
|
+
overscan: scan,
|
|
144
|
+
retained,
|
|
145
|
+
};
|
|
146
|
+
const instance = get(virtualizer);
|
|
147
|
+
const viewport = layout?.getScrollContainer();
|
|
148
|
+
const oldRow = instance
|
|
149
|
+
.getVirtualItems()
|
|
150
|
+
.find((item) => item.end > (viewport?.scrollTop ?? 0));
|
|
151
|
+
const oldItem = oldRow && priorItems[oldRow.index];
|
|
152
|
+
const offset = oldRow ? (viewport?.scrollTop ?? 0) - oldRow.start : 0;
|
|
153
|
+
const revision = layout?.streamScroll.positionRevision;
|
|
154
|
+
const sameKey = priorKey === key;
|
|
155
|
+
const restore = restoredKey !== key ? initialPosition : undefined;
|
|
156
|
+
const preserve =
|
|
157
|
+
sameKey &&
|
|
158
|
+
nextItems !== priorItems &&
|
|
159
|
+
!layout?.streamScroll.isLocked &&
|
|
160
|
+
oldItem;
|
|
161
|
+
priorKey = key;
|
|
162
|
+
priorItems = nextItems;
|
|
163
|
+
if (!sameKey) {
|
|
164
|
+
restoredKey = undefined;
|
|
165
|
+
paginationArmed = false;
|
|
166
|
+
activeRowId = undefined;
|
|
167
|
+
}
|
|
168
|
+
instance.setOptions({
|
|
169
|
+
enabled,
|
|
170
|
+
count: nextItems.length,
|
|
171
|
+
scrollMargin,
|
|
172
|
+
getScrollElement: () => layout?.getScrollContainer() ?? null,
|
|
173
|
+
getItemKey: (index) => nextItems[index]!.id,
|
|
174
|
+
estimateSize: () => size,
|
|
175
|
+
overscan: scan,
|
|
176
|
+
rangeExtractor: (range) =>
|
|
177
|
+
[...new Set([...defaultRangeExtractor(range), ...extra])].sort(
|
|
178
|
+
(a, b) => a - b,
|
|
179
|
+
),
|
|
180
|
+
});
|
|
181
|
+
void tick().then(() => {
|
|
182
|
+
if (!alive) return;
|
|
183
|
+
if (
|
|
184
|
+
key !== conversationKey ||
|
|
185
|
+
revision !== layout?.streamScroll.positionRevision
|
|
186
|
+
)
|
|
187
|
+
return;
|
|
188
|
+
if (enabled && nextItems.length) {
|
|
189
|
+
const position =
|
|
190
|
+
restore ??
|
|
191
|
+
(preserve
|
|
192
|
+
? {
|
|
193
|
+
messageId: oldItem.id,
|
|
194
|
+
index: oldRow!.index,
|
|
195
|
+
offset,
|
|
196
|
+
atLatest: false,
|
|
197
|
+
}
|
|
198
|
+
: undefined);
|
|
199
|
+
if (position && !position.atLatest) {
|
|
200
|
+
const found = nextItems.findIndex(
|
|
201
|
+
(item) => item.id === position.messageId,
|
|
202
|
+
);
|
|
203
|
+
const index =
|
|
204
|
+
found < 0
|
|
205
|
+
? Math.min(position.index ?? 0, nextItems.length - 1)
|
|
206
|
+
: found;
|
|
207
|
+
const start = get(virtualizer).getOffsetForIndex(
|
|
208
|
+
index,
|
|
209
|
+
"start",
|
|
210
|
+
)?.[0];
|
|
211
|
+
if (start !== undefined)
|
|
212
|
+
layout?.streamScroll.scrollToOffset?.(
|
|
213
|
+
start + position.offset,
|
|
214
|
+
Boolean(preserve),
|
|
215
|
+
);
|
|
216
|
+
} else if (!sameKey || layout?.streamScroll.isLocked)
|
|
217
|
+
layout?.streamScroll.scrollIfLocked();
|
|
218
|
+
}
|
|
219
|
+
if (nextItems.length) {
|
|
220
|
+
restoredKey = key;
|
|
221
|
+
reportPosition();
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
$effect(() => {
|
|
227
|
+
const content = virtualRef;
|
|
228
|
+
const viewport = layout?.getScrollContainer();
|
|
229
|
+
if (!content || !viewport) return;
|
|
230
|
+
const measureMargin = () => {
|
|
231
|
+
const next =
|
|
232
|
+
content.getBoundingClientRect().top -
|
|
233
|
+
viewport.getBoundingClientRect().top +
|
|
234
|
+
viewport.scrollTop;
|
|
235
|
+
if (Math.abs(next - margin) > 0.5) margin = next;
|
|
236
|
+
};
|
|
237
|
+
measureMargin();
|
|
238
|
+
const observer = new ResizeObserver(measureMargin);
|
|
239
|
+
observer.observe(content);
|
|
240
|
+
return () => observer.disconnect();
|
|
241
|
+
});
|
|
242
|
+
$effect(() => {
|
|
243
|
+
const viewport = layout?.getScrollContainer();
|
|
244
|
+
if (!viewport) return;
|
|
245
|
+
const onScroll = () => {
|
|
246
|
+
const top = viewport.scrollTop;
|
|
247
|
+
if (top > 600) paginationArmed = true;
|
|
248
|
+
if (
|
|
249
|
+
top <= 600 &&
|
|
250
|
+
top < lastTop &&
|
|
251
|
+
paginationArmed &&
|
|
252
|
+
hasOlder &&
|
|
253
|
+
!loading
|
|
254
|
+
) {
|
|
255
|
+
paginationArmed = false;
|
|
256
|
+
void onLoadOlder?.();
|
|
257
|
+
}
|
|
258
|
+
lastTop = top;
|
|
259
|
+
reportPosition();
|
|
260
|
+
};
|
|
261
|
+
const rememberInteraction = (event: Event) => {
|
|
262
|
+
const target = event.target;
|
|
263
|
+
if (target instanceof Element)
|
|
264
|
+
activeRowId =
|
|
265
|
+
target.closest<HTMLElement>("[data-message-id]")?.dataset.messageId;
|
|
266
|
+
};
|
|
267
|
+
viewport.addEventListener("scroll", onScroll, { passive: true });
|
|
268
|
+
viewport.addEventListener("focusin", rememberInteraction);
|
|
269
|
+
viewport.addEventListener("pointerdown", rememberInteraction, {
|
|
270
|
+
passive: true,
|
|
271
|
+
});
|
|
272
|
+
return () => {
|
|
273
|
+
viewport.removeEventListener("scroll", onScroll);
|
|
274
|
+
viewport.removeEventListener("focusin", rememberInteraction);
|
|
275
|
+
viewport.removeEventListener("pointerdown", rememberInteraction);
|
|
276
|
+
};
|
|
277
|
+
});
|
|
278
|
+
</script>
|
|
279
|
+
|
|
280
|
+
<div
|
|
281
|
+
bind:this={virtualRef}
|
|
282
|
+
data-ui-part="virtual-content"
|
|
283
|
+
style:height={`${$virtualizer.getTotalSize()}px`}
|
|
284
|
+
>
|
|
285
|
+
{#each $virtualizer.getVirtualItems() as virtualRow (virtualRow.key)}
|
|
286
|
+
{@const item = items[virtualRow.index]}
|
|
287
|
+
{#if item}
|
|
288
|
+
<div
|
|
289
|
+
data-ui-part="virtual-row"
|
|
290
|
+
data-message-id={item.id}
|
|
291
|
+
data-index={virtualRow.index}
|
|
292
|
+
style:transform={`translateY(${virtualRow.start - margin}px)`}
|
|
293
|
+
use:measure
|
|
294
|
+
>
|
|
295
|
+
{@render row(item, virtualRow.index)}
|
|
296
|
+
</div>
|
|
297
|
+
{/if}
|
|
298
|
+
{/each}
|
|
299
|
+
</div>
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
export type NewMessagesOptions = {
|
|
2
2
|
isLocked: () => boolean;
|
|
3
3
|
onResize?: () => void;
|
|
4
|
+
/** Opt in to coalescing geometry work outside the ResizeObserver delivery. */
|
|
5
|
+
deferResize?: boolean | (() => boolean);
|
|
6
|
+
requestAnimationFrame?: typeof globalThis.requestAnimationFrame;
|
|
7
|
+
cancelAnimationFrame?: typeof globalThis.cancelAnimationFrame;
|
|
4
8
|
createResizeObserver?: (
|
|
5
9
|
callback: ResizeObserverCallback,
|
|
6
10
|
) => Pick<ResizeObserver, "observe" | "disconnect">;
|
|
@@ -19,8 +23,8 @@ export function createNewMessages(
|
|
|
19
23
|
): NewMessagesController {
|
|
20
24
|
let element: HTMLElement | null = null;
|
|
21
25
|
let observer: Pick<ResizeObserver, "observe" | "disconnect"> | null = null;
|
|
22
|
-
let initialized = false;
|
|
23
26
|
let hasNewMessages = $state(false);
|
|
27
|
+
let resizeFrame = 0;
|
|
24
28
|
const seenMessageIds = new Set<string>();
|
|
25
29
|
|
|
26
30
|
const makeObserver =
|
|
@@ -44,20 +48,35 @@ export function createNewMessages(
|
|
|
44
48
|
|
|
45
49
|
observer = makeObserver((entries) => {
|
|
46
50
|
if (!entries.some((entry) => entry.target === element)) return;
|
|
47
|
-
|
|
48
|
-
|
|
51
|
+
const deferred =
|
|
52
|
+
typeof options.deferResize === "function"
|
|
53
|
+
? options.deferResize()
|
|
54
|
+
: options.deferResize;
|
|
55
|
+
if (!deferred) {
|
|
56
|
+
options.onResize?.();
|
|
49
57
|
return;
|
|
50
58
|
}
|
|
51
|
-
|
|
59
|
+
if (resizeFrame) return;
|
|
60
|
+
const current = element;
|
|
61
|
+
resizeFrame = (options.requestAnimationFrame ?? requestAnimationFrame)(
|
|
62
|
+
() => {
|
|
63
|
+
resizeFrame = 0;
|
|
64
|
+
if (element === current) options.onResize?.();
|
|
65
|
+
},
|
|
66
|
+
);
|
|
52
67
|
});
|
|
53
68
|
observer?.observe(element);
|
|
54
69
|
}
|
|
55
70
|
|
|
56
71
|
function cleanup(): void {
|
|
72
|
+
if (resizeFrame)
|
|
73
|
+
(options.cancelAnimationFrame ?? cancelAnimationFrame)(resizeFrame);
|
|
74
|
+
resizeFrame = 0;
|
|
57
75
|
observer?.disconnect();
|
|
58
76
|
observer = null;
|
|
59
77
|
element = null;
|
|
60
|
-
|
|
78
|
+
hasNewMessages = false;
|
|
79
|
+
seenMessageIds.clear();
|
|
61
80
|
}
|
|
62
81
|
|
|
63
82
|
return {
|