@dxos/react-ui-feed 0.11.1
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/LICENSE +105 -0
- package/dist/lib/chunk-src.mjs +2228 -0
- package/dist/lib/chunk-src.mjs.map +1 -0
- package/dist/lib/index.mjs +2 -0
- package/dist/lib/testing.mjs +1923 -0
- package/dist/lib/testing.mjs.map +1 -0
- package/dist/types/src/components/Block/HtmlBlock.d.ts +14 -0
- package/dist/types/src/components/Block/HtmlBlock.d.ts.map +1 -0
- package/dist/types/src/components/Block/MarkdownBlock.d.ts +24 -0
- package/dist/types/src/components/Block/MarkdownBlock.d.ts.map +1 -0
- package/dist/types/src/components/Block/block.stories.d.ts +21 -0
- package/dist/types/src/components/Block/block.stories.d.ts.map +1 -0
- package/dist/types/src/components/Block/construction.d.ts +36 -0
- package/dist/types/src/components/Block/construction.d.ts.map +1 -0
- package/dist/types/src/components/Block/extensions.d.ts +23 -0
- package/dist/types/src/components/Block/extensions.d.ts.map +1 -0
- package/dist/types/src/components/Block/highlight.d.ts +14 -0
- package/dist/types/src/components/Block/highlight.d.ts.map +1 -0
- package/dist/types/src/components/Block/index.d.ts +6 -0
- package/dist/types/src/components/Block/index.d.ts.map +1 -0
- package/dist/types/src/components/Block/selection-group.d.ts +21 -0
- package/dist/types/src/components/Block/selection-group.d.ts.map +1 -0
- package/dist/types/src/components/Block/widget-state.d.ts +33 -0
- package/dist/types/src/components/Block/widget-state.d.ts.map +1 -0
- package/dist/types/src/components/MessageList/MessageList.d.ts +129 -0
- package/dist/types/src/components/MessageList/MessageList.d.ts.map +1 -0
- package/dist/types/src/components/MessageList/MessageList.stories.d.ts +47 -0
- package/dist/types/src/components/MessageList/MessageList.stories.d.ts.map +1 -0
- package/dist/types/src/components/MessageList/index.d.ts +3 -0
- package/dist/types/src/components/MessageList/index.d.ts.map +1 -0
- package/dist/types/src/components/MessageList/position-log.d.ts +73 -0
- package/dist/types/src/components/MessageList/position-log.d.ts.map +1 -0
- package/dist/types/src/components/Minimap/Minimap.d.ts +28 -0
- package/dist/types/src/components/Minimap/Minimap.d.ts.map +1 -0
- package/dist/types/src/components/Minimap/Minimap.stories.d.ts +21 -0
- package/dist/types/src/components/Minimap/Minimap.stories.d.ts.map +1 -0
- package/dist/types/src/components/Minimap/index.d.ts +2 -0
- package/dist/types/src/components/Minimap/index.d.ts.map +1 -0
- package/dist/types/src/components/Outline/Outline.d.ts +46 -0
- package/dist/types/src/components/Outline/Outline.d.ts.map +1 -0
- package/dist/types/src/components/Outline/Outline.stories.d.ts +37 -0
- package/dist/types/src/components/Outline/Outline.stories.d.ts.map +1 -0
- package/dist/types/src/components/Outline/index.d.ts +2 -0
- package/dist/types/src/components/Outline/index.d.ts.map +1 -0
- package/dist/types/src/components/index.d.ts +5 -0
- package/dist/types/src/components/index.d.ts.map +1 -0
- package/dist/types/src/debug/Debug.d.ts +17 -0
- package/dist/types/src/debug/Debug.d.ts.map +1 -0
- package/dist/types/src/debug/Stats.d.ts +30 -0
- package/dist/types/src/debug/Stats.d.ts.map +1 -0
- package/dist/types/src/debug/debug-model.d.ts +32 -0
- package/dist/types/src/debug/debug-model.d.ts.map +1 -0
- package/dist/types/src/debug/debug-model.test.d.ts +2 -0
- package/dist/types/src/debug/debug-model.test.d.ts.map +1 -0
- package/dist/types/src/debug/frame-meter.d.ts +48 -0
- package/dist/types/src/debug/frame-meter.d.ts.map +1 -0
- package/dist/types/src/debug/frame-meter.test.d.ts +2 -0
- package/dist/types/src/debug/frame-meter.test.d.ts.map +1 -0
- package/dist/types/src/debug/index.d.ts +6 -0
- package/dist/types/src/debug/index.d.ts.map +1 -0
- package/dist/types/src/debug/use-debug.d.ts +19 -0
- package/dist/types/src/debug/use-debug.d.ts.map +1 -0
- package/dist/types/src/hooks/follow.d.ts +89 -0
- package/dist/types/src/hooks/follow.d.ts.map +1 -0
- package/dist/types/src/hooks/follow.stories.d.ts +25 -0
- package/dist/types/src/hooks/follow.stories.d.ts.map +1 -0
- package/dist/types/src/hooks/follow.test.d.ts +2 -0
- package/dist/types/src/hooks/follow.test.d.ts.map +1 -0
- package/dist/types/src/hooks/index.d.ts +6 -0
- package/dist/types/src/hooks/index.d.ts.map +1 -0
- package/dist/types/src/hooks/useDecorations.d.ts +31 -0
- package/dist/types/src/hooks/useDecorations.d.ts.map +1 -0
- package/dist/types/src/hooks/useFeedNavigation.d.ts +44 -0
- package/dist/types/src/hooks/useFeedNavigation.d.ts.map +1 -0
- package/dist/types/src/hooks/useFollow.d.ts +46 -0
- package/dist/types/src/hooks/useFollow.d.ts.map +1 -0
- package/dist/types/src/hooks/useItemSelection.d.ts +34 -0
- package/dist/types/src/hooks/useItemSelection.d.ts.map +1 -0
- package/dist/types/src/index.d.ts +4 -0
- package/dist/types/src/index.d.ts.map +1 -0
- package/dist/types/src/model/feed-model.d.ts +67 -0
- package/dist/types/src/model/feed-model.d.ts.map +1 -0
- package/dist/types/src/model/feed-model.test.d.ts +2 -0
- package/dist/types/src/model/feed-model.test.d.ts.map +1 -0
- package/dist/types/src/model/feed.d.ts +66 -0
- package/dist/types/src/model/feed.d.ts.map +1 -0
- package/dist/types/src/model/feed.test.d.ts +2 -0
- package/dist/types/src/model/feed.test.d.ts.map +1 -0
- package/dist/types/src/model/index.d.ts +5 -0
- package/dist/types/src/model/index.d.ts.map +1 -0
- package/dist/types/src/model/list-model.d.ts +61 -0
- package/dist/types/src/model/list-model.d.ts.map +1 -0
- package/dist/types/src/model/list-model.test.d.ts +2 -0
- package/dist/types/src/model/list-model.test.d.ts.map +1 -0
- package/dist/types/src/model/useListModel.d.ts +22 -0
- package/dist/types/src/model/useListModel.d.ts.map +1 -0
- package/dist/types/src/stories/assistant.stories.d.ts +38 -0
- package/dist/types/src/stories/assistant.stories.d.ts.map +1 -0
- package/dist/types/src/stories/baseline.stories.d.ts +32 -0
- package/dist/types/src/stories/baseline.stories.d.ts.map +1 -0
- package/dist/types/src/stories/bridge.stories.d.ts +72 -0
- package/dist/types/src/stories/bridge.stories.d.ts.map +1 -0
- package/dist/types/src/stories/construction.stories.d.ts +17 -0
- package/dist/types/src/stories/construction.stories.d.ts.map +1 -0
- package/dist/types/src/stories/fill.stories.d.ts +51 -0
- package/dist/types/src/stories/fill.stories.d.ts.map +1 -0
- package/dist/types/src/stories/horizontal.stories.d.ts +17 -0
- package/dist/types/src/stories/horizontal.stories.d.ts.map +1 -0
- package/dist/types/src/stories/mount.stories.d.ts +19 -0
- package/dist/types/src/stories/mount.stories.d.ts.map +1 -0
- package/dist/types/src/stories/navigation.stories.d.ts +33 -0
- package/dist/types/src/stories/navigation.stories.d.ts.map +1 -0
- package/dist/types/src/stories/streaming.stories.d.ts +17 -0
- package/dist/types/src/stories/streaming.stories.d.ts.map +1 -0
- package/dist/types/src/stories/tail.stories.d.ts +47 -0
- package/dist/types/src/stories/tail.stories.d.ts.map +1 -0
- package/dist/types/src/stories/widget.stories.d.ts +16 -0
- package/dist/types/src/stories/widget.stories.d.ts.map +1 -0
- package/dist/types/src/testing/FeedStory.d.ts +30 -0
- package/dist/types/src/testing/FeedStory.d.ts.map +1 -0
- package/dist/types/src/testing/MessageWindow.d.ts +48 -0
- package/dist/types/src/testing/MessageWindow.d.ts.map +1 -0
- package/dist/types/src/testing/controls.d.ts +46 -0
- package/dist/types/src/testing/controls.d.ts.map +1 -0
- package/dist/types/src/testing/debug/FeedStats.d.ts +18 -0
- package/dist/types/src/testing/debug/FeedStats.d.ts.map +1 -0
- package/dist/types/src/testing/debug/index.d.ts +4 -0
- package/dist/types/src/testing/debug/index.d.ts.map +1 -0
- package/dist/types/src/testing/debug/sweep.d.ts +18 -0
- package/dist/types/src/testing/debug/sweep.d.ts.map +1 -0
- package/dist/types/src/testing/debug/use-feed-debug.d.ts +33 -0
- package/dist/types/src/testing/debug/use-feed-debug.d.ts.map +1 -0
- package/dist/types/src/testing/generator.d.ts +16 -0
- package/dist/types/src/testing/generator.d.ts.map +1 -0
- package/dist/types/src/testing/index.d.ts +10 -0
- package/dist/types/src/testing/index.d.ts.map +1 -0
- package/dist/types/src/testing/scenarios.d.ts +58 -0
- package/dist/types/src/testing/scenarios.d.ts.map +1 -0
- package/dist/types/src/testing/stream.d.ts +22 -0
- package/dist/types/src/testing/stream.d.ts.map +1 -0
- package/dist/types/src/testing/turn.d.ts +21 -0
- package/dist/types/src/testing/turn.d.ts.map +1 -0
- package/dist/types/src/testing/widgets.d.ts +3 -0
- package/dist/types/src/testing/widgets.d.ts.map +1 -0
- package/dist/types/src/virtualizer/Window.d.ts +137 -0
- package/dist/types/src/virtualizer/Window.d.ts.map +1 -0
- package/dist/types/src/virtualizer/Window.stories.d.ts +112 -0
- package/dist/types/src/virtualizer/Window.stories.d.ts.map +1 -0
- package/dist/types/src/virtualizer/index.d.ts +3 -0
- package/dist/types/src/virtualizer/index.d.ts.map +1 -0
- package/dist/types/src/virtualizer/placement.d.ts +188 -0
- package/dist/types/src/virtualizer/placement.d.ts.map +1 -0
- package/dist/types/src/virtualizer/placement.test.d.ts +2 -0
- package/dist/types/src/virtualizer/placement.test.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -0
- package/package.json +72 -0
- package/src/components/Block/HtmlBlock.tsx +26 -0
- package/src/components/Block/MarkdownBlock.tsx +157 -0
- package/src/components/Block/block.stories.tsx +140 -0
- package/src/components/Block/construction.ts +77 -0
- package/src/components/Block/extensions.ts +95 -0
- package/src/components/Block/highlight.ts +53 -0
- package/src/components/Block/index.ts +10 -0
- package/src/components/Block/selection-group.ts +53 -0
- package/src/components/Block/widget-state.tsx +75 -0
- package/src/components/MessageList/MessageList.stories.tsx +111 -0
- package/src/components/MessageList/MessageList.tsx +647 -0
- package/src/components/MessageList/index.ts +6 -0
- package/src/components/MessageList/position-log.ts +167 -0
- package/src/components/Minimap/Minimap.stories.tsx +129 -0
- package/src/components/Minimap/Minimap.tsx +84 -0
- package/src/components/Minimap/index.ts +5 -0
- package/src/components/Outline/Outline.stories.tsx +196 -0
- package/src/components/Outline/Outline.tsx +383 -0
- package/src/components/Outline/index.ts +5 -0
- package/src/components/index.ts +8 -0
- package/src/debug/Debug.tsx +90 -0
- package/src/debug/Stats.tsx +57 -0
- package/src/debug/debug-model.test.ts +36 -0
- package/src/debug/debug-model.ts +66 -0
- package/src/debug/frame-meter.test.ts +49 -0
- package/src/debug/frame-meter.ts +188 -0
- package/src/debug/index.ts +9 -0
- package/src/debug/use-debug.tsx +41 -0
- package/src/hooks/follow.stories.tsx +200 -0
- package/src/hooks/follow.test.ts +105 -0
- package/src/hooks/follow.ts +241 -0
- package/src/hooks/index.ts +9 -0
- package/src/hooks/useDecorations.tsx +54 -0
- package/src/hooks/useFeedNavigation.ts +90 -0
- package/src/hooks/useFollow.ts +258 -0
- package/src/hooks/useItemSelection.tsx +53 -0
- package/src/index.ts +7 -0
- package/src/model/feed-model.test.ts +84 -0
- package/src/model/feed-model.ts +214 -0
- package/src/model/feed.test.ts +65 -0
- package/src/model/feed.ts +157 -0
- package/src/model/index.ts +8 -0
- package/src/model/list-model.test.ts +71 -0
- package/src/model/list-model.ts +152 -0
- package/src/model/useListModel.ts +53 -0
- package/src/stories/assistant.stories.tsx +425 -0
- package/src/stories/baseline.stories.tsx +78 -0
- package/src/stories/bridge.stories.tsx +376 -0
- package/src/stories/construction.stories.tsx +147 -0
- package/src/stories/fill.stories.tsx +271 -0
- package/src/stories/horizontal.stories.tsx +129 -0
- package/src/stories/mount.stories.tsx +233 -0
- package/src/stories/navigation.stories.tsx +145 -0
- package/src/stories/streaming.stories.tsx +132 -0
- package/src/stories/tail.stories.tsx +149 -0
- package/src/stories/widget.stories.tsx +107 -0
- package/src/testing/FeedStory.tsx +452 -0
- package/src/testing/MessageWindow.tsx +139 -0
- package/src/testing/controls.tsx +96 -0
- package/src/testing/debug/FeedStats.tsx +182 -0
- package/src/testing/debug/index.ts +16 -0
- package/src/testing/debug/sweep.ts +85 -0
- package/src/testing/debug/use-feed-debug.ts +91 -0
- package/src/testing/generator.ts +90 -0
- package/src/testing/index.ts +13 -0
- package/src/testing/scenarios.tsx +552 -0
- package/src/testing/stream.ts +70 -0
- package/src/testing/turn.ts +91 -0
- package/src/testing/widgets.tsx +183 -0
- package/src/virtualizer/Window.stories.tsx +683 -0
- package/src/virtualizer/Window.tsx +435 -0
- package/src/virtualizer/index.ts +6 -0
- package/src/virtualizer/placement.test.ts +312 -0
- package/src/virtualizer/placement.ts +403 -0
|
@@ -0,0 +1,647 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { createContext } from '@radix-ui/react-context';
|
|
6
|
+
import React, {
|
|
7
|
+
type ComponentType,
|
|
8
|
+
type PropsWithChildren,
|
|
9
|
+
useCallback,
|
|
10
|
+
useEffect,
|
|
11
|
+
useMemo,
|
|
12
|
+
useRef,
|
|
13
|
+
useState,
|
|
14
|
+
} from 'react';
|
|
15
|
+
|
|
16
|
+
import { Column, ScrollArea, type ScrollAreaRootProps, composable, composableProps, setRef } from '@dxos/react-ui';
|
|
17
|
+
import { type Message } from '@dxos/types';
|
|
18
|
+
import { type XmlWidgetRegistry } from '@dxos/ui-editor';
|
|
19
|
+
|
|
20
|
+
import { type FeedNavigation, useDecorations, useFeedNavigation, useFollow, useItemSelectionValue } from '../../hooks';
|
|
21
|
+
import { type FeedModel, type ItemContent, type MessageRenderer, defaultRenderer } from '../../model';
|
|
22
|
+
import { type WindowController, type WindowState, useWindow } from '../../virtualizer';
|
|
23
|
+
import {
|
|
24
|
+
type HighlightRange,
|
|
25
|
+
HtmlBlock,
|
|
26
|
+
MarkdownBlock,
|
|
27
|
+
SelectionGroupContext,
|
|
28
|
+
WidgetScopeProvider,
|
|
29
|
+
WidgetStateProvider,
|
|
30
|
+
createSelectionGroup,
|
|
31
|
+
createWidgetStateStore,
|
|
32
|
+
} from '../Block';
|
|
33
|
+
import { useJumpDetector, usePositionLog } from './position-log';
|
|
34
|
+
|
|
35
|
+
//
|
|
36
|
+
// Context
|
|
37
|
+
//
|
|
38
|
+
|
|
39
|
+
const MESSAGE_LIST_NAME = 'MessageList';
|
|
40
|
+
|
|
41
|
+
/** Per-message chrome (avatar, timestamp, fork/rewind/reply controls), supplied by the host. */
|
|
42
|
+
export type MessageChromeProps = PropsWithChildren<{
|
|
43
|
+
message: Message.Message;
|
|
44
|
+
index: number;
|
|
45
|
+
selected: boolean;
|
|
46
|
+
onSelect: (id: string, additive: boolean) => void;
|
|
47
|
+
}>;
|
|
48
|
+
|
|
49
|
+
export type MessageRange = { startIndex: number; endIndex: number };
|
|
50
|
+
|
|
51
|
+
type MessageListContextValue = {
|
|
52
|
+
/** The feed's model: messages, identity, stops, streaming, iteration. The one source (SPEC F-7). */
|
|
53
|
+
model: FeedModel;
|
|
54
|
+
renderer: MessageRenderer;
|
|
55
|
+
registry?: XmlWidgetRegistry;
|
|
56
|
+
Chrome: ComponentType<MessageChromeProps>;
|
|
57
|
+
Custom?: ComponentType<{ content: ItemContent & { kind: 'custom' }; message: Message.Message }>;
|
|
58
|
+
debug?: boolean;
|
|
59
|
+
/** The mounted window; `undefined` until the viewport has measured. */
|
|
60
|
+
range?: MessageRange;
|
|
61
|
+
/** Index the reader is on: moved by the arrow keys and by any navigation, tracked while scrolling. */
|
|
62
|
+
currentIndex: number;
|
|
63
|
+
/** The one seam every navigation driver calls: toolbar, arrows, outline, minimap (SPEC F-3.2). */
|
|
64
|
+
navigation: FeedNavigation;
|
|
65
|
+
/** Rows mounted right now: the window the reader is paying for. */
|
|
66
|
+
mountedRows: number;
|
|
67
|
+
/** Block widgets mounted across every mounted item — what the visible window actually costs. */
|
|
68
|
+
mountedWidgets: number;
|
|
69
|
+
reportWidgets: (id: string, count: number) => void;
|
|
70
|
+
/** Rows that moved on screen against the scroll, counted per animation frame. */
|
|
71
|
+
jumps: { count: number; worst: number };
|
|
72
|
+
/** Rows that moved after being laid out, and windows whose offsets were out of order. */
|
|
73
|
+
shifts: number;
|
|
74
|
+
breaks: number;
|
|
75
|
+
resetShifts: () => void;
|
|
76
|
+
/** The element holding the mounted rows, which is what gets measured. */
|
|
77
|
+
windowRef: React.RefObject<HTMLDivElement | null>;
|
|
78
|
+
/** Absolute position of the first mounted row: what that element is translated by (§7). */
|
|
79
|
+
offset: number;
|
|
80
|
+
/** Extent of the whole document, reserved space included — what the thumb is scaled against. */
|
|
81
|
+
sizerExtent: number;
|
|
82
|
+
first: number;
|
|
83
|
+
last: number;
|
|
84
|
+
setViewport: (viewport: HTMLElement | null) => void;
|
|
85
|
+
scrollToIndex: (index: number, options?: ScrollToOptions) => void;
|
|
86
|
+
scrollToBottom: (options?: ScrollToOptions) => void;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export type ScrollToOptions = {
|
|
90
|
+
align?: 'start' | 'center' | 'end';
|
|
91
|
+
behavior?: 'auto' | 'smooth';
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const [MessageListProvider, useMessageListContext] = createContext<MessageListContextValue>(MESSAGE_LIST_NAME);
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The list's state and scroll controls, for parts that live outside the viewport — a toolbar's
|
|
98
|
+
* find-next, a statusbar's range readout.
|
|
99
|
+
*/
|
|
100
|
+
export const useMessageList = (consumerName = 'useMessageList') => {
|
|
101
|
+
const {
|
|
102
|
+
range,
|
|
103
|
+
currentIndex,
|
|
104
|
+
navigation,
|
|
105
|
+
mountedRows,
|
|
106
|
+
mountedWidgets,
|
|
107
|
+
jumps,
|
|
108
|
+
shifts,
|
|
109
|
+
breaks,
|
|
110
|
+
resetShifts,
|
|
111
|
+
model,
|
|
112
|
+
scrollToIndex,
|
|
113
|
+
scrollToBottom,
|
|
114
|
+
} = useMessageListContext(consumerName);
|
|
115
|
+
return {
|
|
116
|
+
range,
|
|
117
|
+
currentIndex,
|
|
118
|
+
navigation,
|
|
119
|
+
mountedRows,
|
|
120
|
+
mountedWidgets,
|
|
121
|
+
jumps,
|
|
122
|
+
shifts,
|
|
123
|
+
breaks,
|
|
124
|
+
resetShifts,
|
|
125
|
+
model,
|
|
126
|
+
count: model.count,
|
|
127
|
+
scrollToIndex,
|
|
128
|
+
scrollToBottom,
|
|
129
|
+
};
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
//
|
|
133
|
+
// Root
|
|
134
|
+
//
|
|
135
|
+
|
|
136
|
+
export type MessageListRootProps = PropsWithChildren<{
|
|
137
|
+
/**
|
|
138
|
+
* The feed's model (SPEC F-7): messages and identity, the stops policy, the streaming tail, and
|
|
139
|
+
* iteration (`loadBefore`/`loadAfter`, consumed when the window reaches an edge). `fromMessages`
|
|
140
|
+
* adapts a plain array; ECHO/queue bindings adapt outside this package.
|
|
141
|
+
*
|
|
142
|
+
* What is NOT here, deliberately: search hits (decorations — `DecorationsProvider` + an item's
|
|
143
|
+
* `useDecorations`), selection (`ItemSelectionProvider` + `useItemSelection`), `streamingId` and
|
|
144
|
+
* `isAnchor` (model concerns). The list neither knows nor routes cross-cutting per-item data.
|
|
145
|
+
*/
|
|
146
|
+
model: FeedModel;
|
|
147
|
+
renderer?: MessageRenderer;
|
|
148
|
+
registry?: XmlWidgetRegistry;
|
|
149
|
+
/**
|
|
150
|
+
* Chrome wrapper; receives the item as `children`. Defaults to a bare frame.
|
|
151
|
+
* Chrome must be layout-stable: a control that changes a row's height on hover or focus
|
|
152
|
+
* re-triggers measurement, and a pointer travelling down the list mid-scroll then shifts every
|
|
153
|
+
* row below it. Toggle such affordances with opacity, or take them out of flow.
|
|
154
|
+
*/
|
|
155
|
+
Chrome?: ComponentType<MessageChromeProps>;
|
|
156
|
+
/**
|
|
157
|
+
* Renders a `custom` item. The engine's own kinds are documents — markdown in CodeMirror, HTML —
|
|
158
|
+
* and both build their content asynchronously enough to be measured wrong once. A host component
|
|
159
|
+
* whose height is settled at first paint is the control case: if a feed of those still moves, the
|
|
160
|
+
* fault is the list; if it does not, the fault is in what the item is building.
|
|
161
|
+
*/
|
|
162
|
+
Custom?: ComponentType<{ content: ItemContent & { kind: 'custom' }; message: Message.Message }>;
|
|
163
|
+
/**
|
|
164
|
+
* Outline each item and the blocks inside it, so what the layout is measuring is visible.
|
|
165
|
+
*/
|
|
166
|
+
debug?: boolean;
|
|
167
|
+
/**
|
|
168
|
+
* Estimated row height before measurement. A function is worth supplying whenever the feed's
|
|
169
|
+
* rows differ widely — a one-line prompt beside a long answer — because a single number is wrong
|
|
170
|
+
* for both.
|
|
171
|
+
*/
|
|
172
|
+
estimateSize?: number | ((message: Message.Message, index: number) => number);
|
|
173
|
+
/**
|
|
174
|
+
* Pin to the bottom as messages arrive, and as a streaming message grows (chat behaviour).
|
|
175
|
+
*/
|
|
176
|
+
stickyBottom?: boolean;
|
|
177
|
+
/**
|
|
178
|
+
* Reserve empty space below the last row, so that any message — including the last — can be
|
|
179
|
+
* brought to the top of the viewport. A number in the sizer, not a mode the list has (§7).
|
|
180
|
+
*/
|
|
181
|
+
scrollPastEnd?: boolean;
|
|
182
|
+
overscan?: number;
|
|
183
|
+
onRangeChange?: (range: MessageRange) => void;
|
|
184
|
+
}>;
|
|
185
|
+
|
|
186
|
+
const DefaultChrome = ({ children }: MessageChromeProps) => <>{children}</>;
|
|
187
|
+
|
|
188
|
+
/** Row height assumed before anything has been measured. */
|
|
189
|
+
const DEFAULT_ESTIMATE = 120;
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Headless root of a feed: owns the placement, the selection group and the model-to-item mapping.
|
|
193
|
+
* Renders no DOM of its own, so a toolbar or statusbar that reads its state can sit outside the
|
|
194
|
+
* scroll container — the scrolling part is `MessageList.Viewport`.
|
|
195
|
+
*/
|
|
196
|
+
const MessageListRoot = ({
|
|
197
|
+
children,
|
|
198
|
+
model,
|
|
199
|
+
renderer = defaultRenderer,
|
|
200
|
+
registry,
|
|
201
|
+
Chrome = DefaultChrome,
|
|
202
|
+
Custom,
|
|
203
|
+
debug,
|
|
204
|
+
estimateSize = DEFAULT_ESTIMATE,
|
|
205
|
+
stickyBottom = false,
|
|
206
|
+
scrollPastEnd = false,
|
|
207
|
+
overscan = 8,
|
|
208
|
+
onRangeChange,
|
|
209
|
+
}: MessageListRootProps) => {
|
|
210
|
+
// Owned here so it outlives every row: a widget's state must survive its row being destroyed.
|
|
211
|
+
const widgetStore = useMemo(createWidgetStateStore, []);
|
|
212
|
+
|
|
213
|
+
const [viewport, setViewportState] = useState<HTMLElement | null>(null);
|
|
214
|
+
// The same element as a ref, because the placement is bound to an element rather than to a render.
|
|
215
|
+
const scrollerRef = useRef<HTMLElement | null>(null);
|
|
216
|
+
const setViewport = useCallback((element: HTMLElement | null) => {
|
|
217
|
+
scrollerRef.current = element;
|
|
218
|
+
setViewportState(element);
|
|
219
|
+
}, []);
|
|
220
|
+
|
|
221
|
+
const [range, setRange] = useState<MessageRange | undefined>(undefined);
|
|
222
|
+
const [mounted, setMounted] = useState(0);
|
|
223
|
+
|
|
224
|
+
// One number standing in for the caller's estimate wherever a single value is needed, since a
|
|
225
|
+
// per-row estimator answers about a row and not about the feed.
|
|
226
|
+
const nominalSize = typeof estimateSize === 'number' ? estimateSize : DEFAULT_ESTIMATE;
|
|
227
|
+
|
|
228
|
+
const [currentIndex, setCurrentIndex] = useState(0);
|
|
229
|
+
const currentIndexRef = useRef(0);
|
|
230
|
+
const setCurrent = useCallback((index: number) => {
|
|
231
|
+
currentIndexRef.current = index;
|
|
232
|
+
setCurrentIndex(index);
|
|
233
|
+
}, []);
|
|
234
|
+
|
|
235
|
+
// Widget census, kept per item so an item that unmounts takes its own widgets out of the total.
|
|
236
|
+
// Batched into state rather than counted on every report: a scroll mounts and unmounts several
|
|
237
|
+
// items per frame, and a readout is not worth a render each.
|
|
238
|
+
const widgetCounts = useRef(new Map<string, number>());
|
|
239
|
+
const [mountedWidgets, setMountedWidgets] = useState(0);
|
|
240
|
+
const reportWidgets = useCallback((id: string, count: number) => {
|
|
241
|
+
if (count) {
|
|
242
|
+
widgetCounts.current.set(id, count);
|
|
243
|
+
} else {
|
|
244
|
+
widgetCounts.current.delete(id);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
setMountedWidgets([...widgetCounts.current.values()].reduce((total, value) => total + value, 0));
|
|
248
|
+
}, []);
|
|
249
|
+
|
|
250
|
+
// What the host says, and nothing else.
|
|
251
|
+
//
|
|
252
|
+
// There is no running average here and no re-base built on one. Both existed because the old
|
|
253
|
+
// engine could only revise the rows below the last one it had measured, so a feed opened at its
|
|
254
|
+
// tail kept a document sized by the opening guess and had to be rebuilt from the top to correct
|
|
255
|
+
// it — which moved every offset in the list at once. A measurement is kept against the row's id
|
|
256
|
+
// and consulted from the anchor, so a wrong estimate is wrong only for rows nobody has seen (§8).
|
|
257
|
+
const extents = useMemo(
|
|
258
|
+
() => ({
|
|
259
|
+
of: (index: number) => {
|
|
260
|
+
const message = model.at(index);
|
|
261
|
+
return typeof estimateSize === 'function' && message ? estimateSize(message, index) : nominalSize;
|
|
262
|
+
},
|
|
263
|
+
}),
|
|
264
|
+
[model, estimateSize, nominalSize],
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
// The viewport less a nominal row: enough for the reader to bring the last row to the top.
|
|
268
|
+
//
|
|
269
|
+
// Measured from the element, never read back out of the layout. It is an *input* to the sizer, so
|
|
270
|
+
// anything derived from the sizer would oscillate — and deliberately not the last row's measured
|
|
271
|
+
// size, which the layout also feeds.
|
|
272
|
+
const reserve = useMemo(
|
|
273
|
+
() => (scrollPastEnd && viewport && model.count ? Math.max(0, viewport.clientHeight - nominalSize) : 0),
|
|
274
|
+
[scrollPastEnd, viewport, model.count, nominalSize],
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
const controller = useRef<WindowController>(null);
|
|
278
|
+
const onWindowChange = useCallback(
|
|
279
|
+
(state: WindowState) => {
|
|
280
|
+
const next = state.count ? { startIndex: state.visible.first, endIndex: state.visible.last } : undefined;
|
|
281
|
+
setRange(next);
|
|
282
|
+
setMounted(state.mounted.last - state.mounted.first + 1);
|
|
283
|
+
if (next) {
|
|
284
|
+
onRangeChange?.(next);
|
|
285
|
+
// The cursor is the row at the top of the viewport, derived from the scroll and never set
|
|
286
|
+
// alongside it. That is what makes a press always move: the row containing the offset is the
|
|
287
|
+
// one the stop above begins strictly higher than, and the stop below strictly lower.
|
|
288
|
+
setCurrent(next.startIndex);
|
|
289
|
+
|
|
290
|
+
// Iteration is the model's (SPEC F-7.2): the window reaching an edge asks it for more, the
|
|
291
|
+
// page arrives as a prepend the anchor is told about, and nothing on screen moves. The model
|
|
292
|
+
// serializes and drains, so asking every commit costs nothing once the source is done.
|
|
293
|
+
if (next.startIndex === 0) {
|
|
294
|
+
void model.more('start');
|
|
295
|
+
}
|
|
296
|
+
if (next.endIndex >= model.count - 1) {
|
|
297
|
+
void model.more('end');
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
},
|
|
301
|
+
[onRangeChange, setCurrent, model],
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
// The model *is* the window's model: FeedModel satisfies the structural contract, so there is no
|
|
305
|
+
// adapter and no second copy of the truth.
|
|
306
|
+
const { placement, windowRef, offset, sizerExtent, first, last } = useWindow({
|
|
307
|
+
scrollerRef,
|
|
308
|
+
model,
|
|
309
|
+
extents,
|
|
310
|
+
overscan,
|
|
311
|
+
reserve,
|
|
312
|
+
onChange: onWindowChange,
|
|
313
|
+
controllerRef: controller,
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
// The follow is an aspect the host composes, not part of the window (SPEC §Aspects) — the intent,
|
|
317
|
+
// its withdrawal by a backwards scroll, and the glide all live there.
|
|
318
|
+
const follow = useFollow({
|
|
319
|
+
scrollerRef,
|
|
320
|
+
placement,
|
|
321
|
+
extent: sizerExtent,
|
|
322
|
+
count: model.count,
|
|
323
|
+
reserve,
|
|
324
|
+
enabled: stickyBottom,
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
// What a reader would call flicker: a row moving on screen by more than the scroll moved, sampled
|
|
328
|
+
// once per frame so the reading is of what was painted. Only while debugging — it reads every
|
|
329
|
+
// mounted row's box every frame.
|
|
330
|
+
const jumps = useJumpDetector(viewport, debug);
|
|
331
|
+
|
|
332
|
+
// Where each row was placed, and whether it stayed there.
|
|
333
|
+
const { shifts, breaks, trace: shiftTrace, record: recordPositions, reset: resetShifts } = usePositionLog();
|
|
334
|
+
useEffect(() => {
|
|
335
|
+
const rows = [];
|
|
336
|
+
for (let index = first; index <= last; index++) {
|
|
337
|
+
rows.push({ key: model.getId(index), index, start: placement.positionOf(index) });
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// The offset the layout was computed against, not the element's current `scrollTop`: reading the
|
|
341
|
+
// DOM here samples a scroll that has moved on since, and every row then looks displaced by
|
|
342
|
+
// exactly one frame of travel.
|
|
343
|
+
recordPositions(rows, { scrollOffset: placement.scroll });
|
|
344
|
+
}, [first, last, offset, sizerExtent, model, placement, recordPositions]);
|
|
345
|
+
|
|
346
|
+
const scrollToIndex = useCallback(
|
|
347
|
+
(index: number, { align = 'start', behavior = 'auto' }: ScrollToOptions = {}) => {
|
|
348
|
+
// Before the write, not via the scroll event it raises: asking to be somewhere answers "do
|
|
349
|
+
// you want the tail?", and the correction effect must not get a word in first.
|
|
350
|
+
follow.onNavigate(index);
|
|
351
|
+
controller.current?.scrollToIndex(index, align === 'center' ? 'start' : align, behavior);
|
|
352
|
+
},
|
|
353
|
+
[follow],
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
const scrollToBottom = useCallback(
|
|
357
|
+
(options?: ScrollToOptions) => {
|
|
358
|
+
if (model.count) {
|
|
359
|
+
scrollToIndex(model.count - 1, { align: 'end', ...options });
|
|
360
|
+
}
|
|
361
|
+
},
|
|
362
|
+
[scrollToIndex, model],
|
|
363
|
+
);
|
|
364
|
+
|
|
365
|
+
useEffect(() => {
|
|
366
|
+
if (viewport) {
|
|
367
|
+
// Why the list is where it is, readable from the console as `$0.__feed`.
|
|
368
|
+
(viewport as any).__feed = {
|
|
369
|
+
placement,
|
|
370
|
+
get layout() {
|
|
371
|
+
return placement.layout();
|
|
372
|
+
},
|
|
373
|
+
get shifted() {
|
|
374
|
+
return { shifts, breaks, recent: shiftTrace.current };
|
|
375
|
+
},
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
}, [viewport, placement, shifts, breaks, shiftTrace]);
|
|
379
|
+
|
|
380
|
+
// The one seam every driver calls (SPEC F-3.2): the toolbar's buttons, the arrows below, and the
|
|
381
|
+
// rails all step through this, over the stops the model's policy names (SPEC F-3.1).
|
|
382
|
+
const navigation = useFeedNavigation({
|
|
383
|
+
controller,
|
|
384
|
+
stops: () => model.stops(),
|
|
385
|
+
current: () => currentIndexRef.current,
|
|
386
|
+
count: () => model.count,
|
|
387
|
+
scrollPastEnd,
|
|
388
|
+
// The follow scrolls only while content arrives AND the reader is pinned to the bottom;
|
|
389
|
+
// navigating anywhere but the last row un-pins, before the jump's own scroll event fires.
|
|
390
|
+
onNavigate: follow.onNavigate,
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
// Arrow keys move by message, not by line: a plain arrow steps the cursor to the adjacent message
|
|
394
|
+
// and Cmd/Ctrl + Arrow jumps to the first or last.
|
|
395
|
+
//
|
|
396
|
+
// Bound imperatively to the viewport element (rather than as a React prop) because
|
|
397
|
+
// `ScrollArea.Viewport` narrows its props to the slottable set, and wrapping it in a focusable div
|
|
398
|
+
// would insert a box into the height chain.
|
|
399
|
+
useEffect(() => {
|
|
400
|
+
if (!viewport) {
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// The items are non-editable, so nothing inside the list takes focus on its own; the viewport
|
|
405
|
+
// has to be focusable for the keymap to have somewhere to land.
|
|
406
|
+
viewport.tabIndex = 0;
|
|
407
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
408
|
+
if (event.altKey || event.shiftKey) {
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const delta = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
|
|
413
|
+
if (!delta) {
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
event.preventDefault();
|
|
418
|
+
if (event.metaKey || event.ctrlKey) {
|
|
419
|
+
if (delta > 0) {
|
|
420
|
+
navigation.last();
|
|
421
|
+
} else {
|
|
422
|
+
navigation.first();
|
|
423
|
+
}
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
navigation.step(delta);
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
viewport.addEventListener('keydown', onKeyDown);
|
|
431
|
+
return () => viewport.removeEventListener('keydown', onKeyDown);
|
|
432
|
+
}, [viewport, navigation]);
|
|
433
|
+
|
|
434
|
+
// The feed has one selection even though it has many editors; the group is what enforces that.
|
|
435
|
+
const selectionGroup = useMemo(createSelectionGroup, []);
|
|
436
|
+
|
|
437
|
+
return (
|
|
438
|
+
<SelectionGroupContext.Provider value={selectionGroup}>
|
|
439
|
+
<WidgetStateProvider store={widgetStore}>
|
|
440
|
+
<MessageListProvider
|
|
441
|
+
model={model}
|
|
442
|
+
renderer={renderer}
|
|
443
|
+
registry={registry}
|
|
444
|
+
Chrome={Chrome}
|
|
445
|
+
Custom={Custom}
|
|
446
|
+
debug={debug}
|
|
447
|
+
range={range}
|
|
448
|
+
currentIndex={currentIndex}
|
|
449
|
+
navigation={navigation}
|
|
450
|
+
mountedRows={mounted}
|
|
451
|
+
mountedWidgets={mountedWidgets}
|
|
452
|
+
reportWidgets={reportWidgets}
|
|
453
|
+
jumps={jumps}
|
|
454
|
+
shifts={shifts}
|
|
455
|
+
breaks={breaks}
|
|
456
|
+
resetShifts={resetShifts}
|
|
457
|
+
windowRef={windowRef}
|
|
458
|
+
offset={offset}
|
|
459
|
+
sizerExtent={sizerExtent}
|
|
460
|
+
first={first}
|
|
461
|
+
last={last}
|
|
462
|
+
setViewport={setViewport}
|
|
463
|
+
scrollToIndex={scrollToIndex}
|
|
464
|
+
scrollToBottom={scrollToBottom}
|
|
465
|
+
>
|
|
466
|
+
{children}
|
|
467
|
+
</MessageListProvider>
|
|
468
|
+
</WidgetStateProvider>
|
|
469
|
+
</SelectionGroupContext.Provider>
|
|
470
|
+
);
|
|
471
|
+
};
|
|
472
|
+
|
|
473
|
+
MessageListRoot.displayName = 'MessageList.Root';
|
|
474
|
+
|
|
475
|
+
//
|
|
476
|
+
// Viewport
|
|
477
|
+
//
|
|
478
|
+
|
|
479
|
+
const MESSAGE_LIST_VIEWPORT_NAME = 'MessageList.Viewport';
|
|
480
|
+
|
|
481
|
+
type MessageListViewportExtra = Pick<
|
|
482
|
+
ScrollAreaRootProps,
|
|
483
|
+
'autoHide' | 'centered' | 'native' | 'padding' | 'scrollbars' | 'thin'
|
|
484
|
+
> & {
|
|
485
|
+
/**
|
|
486
|
+
* Gutter size for the three-track layout each row is laid out on (`gutter | content | gutter`).
|
|
487
|
+
*
|
|
488
|
+
* The tracks live inside the row rather than on the container, because the rows are laid out by
|
|
489
|
+
* the browser inside one placed parent and cannot be items of an outer grid. Chrome that wants the
|
|
490
|
+
* gutters — a fork control, an avatar, a resolve toggle — opts in with `Column.Row`; everything
|
|
491
|
+
* else sits in the centre track.
|
|
492
|
+
*/
|
|
493
|
+
gutter?: 'sm' | 'md' | 'lg';
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* The scroll container and the mounted window of rows.
|
|
498
|
+
*
|
|
499
|
+
* Scrolling belongs to `ScrollArea`, which owns the overlay thumbs and the padding tokens; the
|
|
500
|
+
* placement needs only the element being scrolled, which `ScrollArea.Viewport` publishes.
|
|
501
|
+
*/
|
|
502
|
+
const MessageListViewport = composable<HTMLDivElement, MessageListViewportExtra>(
|
|
503
|
+
({ autoHide, centered, native, padding, scrollbars, thin, gutter, ...props }, forwardedRef) => {
|
|
504
|
+
const { model, Chrome, windowRef, offset, sizerExtent, first, last, setViewport } =
|
|
505
|
+
useMessageListContext(MESSAGE_LIST_VIEWPORT_NAME);
|
|
506
|
+
// The value once, per-row state derived: hooks do not run in loops, and the row loop below is
|
|
507
|
+
// one. Item-shaped chrome uses `useItemSelection(id)` instead.
|
|
508
|
+
const { selectedIds, onSelect } = useItemSelectionValue();
|
|
509
|
+
|
|
510
|
+
const handleViewportRef = useCallback(
|
|
511
|
+
(element: HTMLDivElement | null) => {
|
|
512
|
+
setViewport(element);
|
|
513
|
+
setRef(forwardedRef, element);
|
|
514
|
+
},
|
|
515
|
+
[setViewport, forwardedRef],
|
|
516
|
+
);
|
|
517
|
+
|
|
518
|
+
const rows = [];
|
|
519
|
+
for (let index = first; index <= last; index++) {
|
|
520
|
+
const message = model.at(index);
|
|
521
|
+
if (!message) {
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
rows.push(
|
|
526
|
+
// Unpositioned, and that is the point. A row that changes extent reflows the ones after it,
|
|
527
|
+
// in the browser, in the same frame; placing each row ourselves meant re-placing every row
|
|
528
|
+
// below it on every frame of the change — 177 re-placements for one disclosure opening (§6).
|
|
529
|
+
<div key={message.id} data-index={index} data-object-id={message.id}>
|
|
530
|
+
<Column.Root gutter={gutter}>
|
|
531
|
+
<Column.Center>
|
|
532
|
+
<Chrome
|
|
533
|
+
message={message}
|
|
534
|
+
index={index}
|
|
535
|
+
selected={selectedIds?.has(message.id) ?? false}
|
|
536
|
+
onSelect={(id, additive) => onSelect?.(id, additive)}
|
|
537
|
+
>
|
|
538
|
+
<MessageListItem message={message} />
|
|
539
|
+
</Chrome>
|
|
540
|
+
</Column.Center>
|
|
541
|
+
</Column.Root>
|
|
542
|
+
</div>,
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
return (
|
|
547
|
+
<ScrollArea.Root
|
|
548
|
+
{...composableProps(props)}
|
|
549
|
+
orientation='vertical'
|
|
550
|
+
autoHide={autoHide}
|
|
551
|
+
centered={centered}
|
|
552
|
+
native={native}
|
|
553
|
+
padding={padding}
|
|
554
|
+
scrollbars={scrollbars}
|
|
555
|
+
thin={thin}
|
|
556
|
+
>
|
|
557
|
+
<ScrollArea.Viewport
|
|
558
|
+
data-testid='feed.viewport'
|
|
559
|
+
// Off deliberately: the browser adjusting the scroll as well would be a second party
|
|
560
|
+
// anchoring the same thing, and the defect this design exists to remove is exactly that.
|
|
561
|
+
style={{ position: 'relative', overflowAnchor: 'none' }}
|
|
562
|
+
ref={handleViewportRef}
|
|
563
|
+
>
|
|
564
|
+
{/* Holds no rows: it exists only to give the thumb something to measure. */}
|
|
565
|
+
<div style={{ height: sizerExtent }} data-testid='feed.sizer' />
|
|
566
|
+
<div
|
|
567
|
+
ref={windowRef}
|
|
568
|
+
className='absolute top-0 left-0 flex flex-col w-full'
|
|
569
|
+
style={{ transform: `translateY(${offset}px)` }}
|
|
570
|
+
data-testid='feed.window'
|
|
571
|
+
>
|
|
572
|
+
{rows}
|
|
573
|
+
</div>
|
|
574
|
+
</ScrollArea.Viewport>
|
|
575
|
+
</ScrollArea.Root>
|
|
576
|
+
);
|
|
577
|
+
},
|
|
578
|
+
);
|
|
579
|
+
|
|
580
|
+
MessageListViewport.displayName = MESSAGE_LIST_VIEWPORT_NAME;
|
|
581
|
+
|
|
582
|
+
//
|
|
583
|
+
// Item
|
|
584
|
+
//
|
|
585
|
+
|
|
586
|
+
const MESSAGE_LIST_ITEM_NAME = 'MessageList.Item';
|
|
587
|
+
|
|
588
|
+
type MessageListItemExtra = {
|
|
589
|
+
message: Message.Message;
|
|
590
|
+
};
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* One message, rendered by the kind its renderer resolves. Exposed so a host can render a message
|
|
594
|
+
* outside the scrolling window — a pinned message, a preview — through the same path.
|
|
595
|
+
*/
|
|
596
|
+
const MessageListItem = composable<HTMLDivElement, MessageListItemExtra>(({ message, ...props }, forwardedRef) => {
|
|
597
|
+
const { renderer, registry, Custom, debug, reportWidgets } = useMessageListContext(MESSAGE_LIST_ITEM_NAME);
|
|
598
|
+
const content = renderer(message);
|
|
599
|
+
// The item asks for its own cross-cutting data by id (SPEC §Aspects); the list never routed it.
|
|
600
|
+
const decorations = useDecorations(message.id);
|
|
601
|
+
const hits = useMemo<HighlightRange[] | undefined>(
|
|
602
|
+
() =>
|
|
603
|
+
decorations.length
|
|
604
|
+
? decorations.map(({ range }) => [range.offset, range.offset + range.length] as HighlightRange)
|
|
605
|
+
: undefined,
|
|
606
|
+
[decorations],
|
|
607
|
+
);
|
|
608
|
+
const handleWidgetsChange = useCallback(
|
|
609
|
+
(count: number) => reportWidgets(message.id, count),
|
|
610
|
+
[reportWidgets, message.id],
|
|
611
|
+
);
|
|
612
|
+
|
|
613
|
+
return (
|
|
614
|
+
// The outlines are the item and the block-level children of its document — which is exactly what
|
|
615
|
+
// the virtualizer measures and what a widget's late paint changes.
|
|
616
|
+
<div
|
|
617
|
+
{...composableProps(props, {
|
|
618
|
+
classNames: debug
|
|
619
|
+
? 'outline outline-1 outline-dashed outline-primary-500/50 [&_.cm-content>*]:outline [&_.cm-content>*]:outline-1 [&_.cm-content>*]:outline-dashed [&_.cm-content>*]:outline-neutral-500/40'
|
|
620
|
+
: undefined,
|
|
621
|
+
})}
|
|
622
|
+
ref={forwardedRef}
|
|
623
|
+
>
|
|
624
|
+
{content.kind === 'markdown' && (
|
|
625
|
+
<WidgetScopeProvider scope={message.id}>
|
|
626
|
+
<MarkdownBlock text={content.text} registry={registry} hits={hits} onWidgetsChange={handleWidgetsChange} />
|
|
627
|
+
</WidgetScopeProvider>
|
|
628
|
+
)}
|
|
629
|
+
{content.kind === 'html' && <HtmlBlock html={content.html} />}
|
|
630
|
+
{content.kind === 'custom' && Custom && <Custom content={content} message={message} />}
|
|
631
|
+
</div>
|
|
632
|
+
);
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
MessageListItem.displayName = MESSAGE_LIST_ITEM_NAME;
|
|
636
|
+
|
|
637
|
+
//
|
|
638
|
+
// MessageList
|
|
639
|
+
//
|
|
640
|
+
|
|
641
|
+
export const MessageList = {
|
|
642
|
+
Root: MessageListRoot,
|
|
643
|
+
Viewport: MessageListViewport,
|
|
644
|
+
Item: MessageListItem,
|
|
645
|
+
};
|
|
646
|
+
|
|
647
|
+
export type { MessageListItemExtra as MessageListItemProps, MessageListViewportExtra as MessageListViewportProps };
|