@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
package/package.json
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dxos/react-ui-feed",
|
|
3
|
+
"version": "0.11.1",
|
|
4
|
+
"description": "Virtualized feed of message items.",
|
|
5
|
+
"homepage": "https://dxos.org",
|
|
6
|
+
"bugs": "https://github.com/dxos/dxos/issues",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/dxos/dxos"
|
|
10
|
+
},
|
|
11
|
+
"license": "FSL-1.1-Apache-2.0",
|
|
12
|
+
"author": "DXOS.org",
|
|
13
|
+
"type": "module",
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"source": "./src/index.ts",
|
|
17
|
+
"types": "./dist/types/src/index.d.ts",
|
|
18
|
+
"import": "./dist/lib/index.mjs"
|
|
19
|
+
},
|
|
20
|
+
"./testing": {
|
|
21
|
+
"source": "./src/testing/index.ts",
|
|
22
|
+
"types": "./dist/types/src/testing/index.d.ts",
|
|
23
|
+
"import": "./dist/lib/testing.mjs"
|
|
24
|
+
},
|
|
25
|
+
"./virtualizer": {
|
|
26
|
+
"source": "./src/virtualizer/index.ts",
|
|
27
|
+
"types": "./dist/types/src/virtualizer/index.d.ts",
|
|
28
|
+
"import": "./dist/lib/virtualizer.mjs"
|
|
29
|
+
},
|
|
30
|
+
"./debug": {
|
|
31
|
+
"source": "./src/debug/index.ts",
|
|
32
|
+
"types": "./dist/types/src/debug/index.d.ts",
|
|
33
|
+
"import": "./dist/lib/debug.mjs"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"types": "dist/types/src/index.d.ts",
|
|
37
|
+
"files": [
|
|
38
|
+
"dist",
|
|
39
|
+
"src"
|
|
40
|
+
],
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@codemirror/state": "^6.7.1",
|
|
43
|
+
"@codemirror/view": "^6.43.8",
|
|
44
|
+
"@radix-ui/react-context": "1.1.1",
|
|
45
|
+
"dompurify": "^3.3.1",
|
|
46
|
+
"@dxos/types": "0.11.1",
|
|
47
|
+
"@dxos/react-ui": "0.11.1",
|
|
48
|
+
"@dxos/ui-editor": "0.11.1",
|
|
49
|
+
"@dxos/ui-theme": "0.11.1",
|
|
50
|
+
"@dxos/util": "0.11.1"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@storybook/react-vite": "^10.5.8",
|
|
54
|
+
"@types/react": "~19.2.17",
|
|
55
|
+
"@types/react-dom": "~19.2.3",
|
|
56
|
+
"effect": "4.0.0-rc.108",
|
|
57
|
+
"react": "~19.2.7",
|
|
58
|
+
"react-dom": "~19.2.7",
|
|
59
|
+
"vite": "^8.2.1",
|
|
60
|
+
"@dxos/react-ui-components": "0.11.1",
|
|
61
|
+
"@dxos/random": "0.11.1"
|
|
62
|
+
},
|
|
63
|
+
"peerDependencies": {
|
|
64
|
+
"react": "~19.2.7",
|
|
65
|
+
"react-dom": "~19.2.7",
|
|
66
|
+
"@dxos/react-ui": "^0.11.1",
|
|
67
|
+
"@dxos/ui-theme": "^0.11.1"
|
|
68
|
+
},
|
|
69
|
+
"publishConfig": {
|
|
70
|
+
"access": "public"
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import DOMPurify from 'dompurify';
|
|
6
|
+
import React, { memo, useMemo } from 'react';
|
|
7
|
+
|
|
8
|
+
export type HtmlBlockProps = {
|
|
9
|
+
html: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A message rendered as sanitized HTML — the email case, which is not markdown and cannot be a
|
|
14
|
+
* CodeMirror document.
|
|
15
|
+
*
|
|
16
|
+
* Its presence is the reason the item renderer is generalized rather than markdown-only: the
|
|
17
|
+
* engine owns virtualization, chrome and selection anchors, not what a message looks like inside.
|
|
18
|
+
* As plain (non-contenteditable) DOM it is also the control case for cross-item selection.
|
|
19
|
+
*/
|
|
20
|
+
export const HtmlBlock = memo(({ html }: HtmlBlockProps) => {
|
|
21
|
+
const sanitized = useMemo(() => DOMPurify.sanitize(html, { USE_PROFILES: { html: true } }), [html]);
|
|
22
|
+
|
|
23
|
+
return <div className='dx-feed-html' dangerouslySetInnerHTML={{ __html: sanitized }} />;
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
HtmlBlock.displayName = 'HtmlBlock';
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { EditorState, type Extension } from '@codemirror/state';
|
|
6
|
+
import { EditorView } from '@codemirror/view';
|
|
7
|
+
import React, { memo, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
8
|
+
import { createPortal } from 'react-dom';
|
|
9
|
+
|
|
10
|
+
import { useThemeContext } from '@dxos/react-ui';
|
|
11
|
+
import { type XmlWidgetRegistry, type XmlWidgetState } from '@dxos/ui-editor';
|
|
12
|
+
|
|
13
|
+
import { createBlockExtensions } from './extensions';
|
|
14
|
+
import { type HighlightRange, setHighlights } from './highlight';
|
|
15
|
+
import { useSelectionGroup } from './selection-group';
|
|
16
|
+
|
|
17
|
+
export type MarkdownBlockProps = {
|
|
18
|
+
text: string;
|
|
19
|
+
/**
|
|
20
|
+
* When false the view mounts with `EditorView.editable.of(false)`, which drops `contenteditable`
|
|
21
|
+
* from the DOM. Browsers refuse to extend one selection across two contenteditable hosts, so this
|
|
22
|
+
* is what decides whether a drag can span adjacent items.
|
|
23
|
+
*/
|
|
24
|
+
editable?: boolean;
|
|
25
|
+
registry?: XmlWidgetRegistry;
|
|
26
|
+
hits?: readonly HighlightRange[];
|
|
27
|
+
/** Number of block widgets this item currently has mounted; 0 once it unmounts. */
|
|
28
|
+
onWidgetsChange?: (count: number) => void;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* One message as its own markdown document.
|
|
33
|
+
*
|
|
34
|
+
* Each item owns a document, so streaming appends and per-message editing stay local — where a
|
|
35
|
+
* single thread-wide document needs a cursor and a range table to know which message it is touching.
|
|
36
|
+
*/
|
|
37
|
+
export const MarkdownBlock = memo(({ text, editable = false, registry, hits, onWidgetsChange }: MarkdownBlockProps) => {
|
|
38
|
+
const { themeMode } = useThemeContext();
|
|
39
|
+
const [view, setView] = useState<EditorView | null>(null);
|
|
40
|
+
// React widgets render in portals into hosts the extension places in the document, so the item has
|
|
41
|
+
// to own them: a widget's tree belongs to the React root that rendered the item, not to CodeMirror.
|
|
42
|
+
const [widgets, setWidgets] = useState<XmlWidgetState[]>([]);
|
|
43
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
44
|
+
|
|
45
|
+
// Read through a ref so the group never lands in the extension deps: rebuilding extensions
|
|
46
|
+
// reconstructs the view, which would discard the selection the reader just made.
|
|
47
|
+
const selectionGroup = useSelectionGroup();
|
|
48
|
+
const selectionGroupRef = useRef(selectionGroup);
|
|
49
|
+
selectionGroupRef.current = selectionGroup;
|
|
50
|
+
|
|
51
|
+
const extensions = useMemo<Extension[]>(
|
|
52
|
+
() => [
|
|
53
|
+
...createBlockExtensions({ registry, editable, themeMode, setWidgets }),
|
|
54
|
+
EditorView.updateListener.of((update) => {
|
|
55
|
+
if (update.selectionSet && !update.state.selection.main.empty) {
|
|
56
|
+
selectionGroupRef.current.claim(update.view);
|
|
57
|
+
}
|
|
58
|
+
}),
|
|
59
|
+
],
|
|
60
|
+
[editable, themeMode, registry],
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
// Deliberately NOT `useTextEditor`, which builds the view in a passive effect — i.e. after paint.
|
|
64
|
+
// The virtualizer measures each row as it mounts, so a row whose document does not exist yet
|
|
65
|
+
// measures at chrome height and then grows, shifting every row below it. Constructing the view in
|
|
66
|
+
// the layout phase gives the row its real height before the first paint, so it is measured once.
|
|
67
|
+
const initialTextRef = useRef(text);
|
|
68
|
+
useLayoutEffect(() => {
|
|
69
|
+
const parent = rootRef.current;
|
|
70
|
+
if (!parent) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const instance = new EditorView({
|
|
75
|
+
parent,
|
|
76
|
+
state: EditorState.create({ doc: initialTextRef.current, extensions }),
|
|
77
|
+
});
|
|
78
|
+
const unregister = selectionGroupRef.current.register(instance);
|
|
79
|
+
setView(instance);
|
|
80
|
+
|
|
81
|
+
return () => {
|
|
82
|
+
unregister();
|
|
83
|
+
instance.destroy();
|
|
84
|
+
setView(null);
|
|
85
|
+
};
|
|
86
|
+
}, [extensions]);
|
|
87
|
+
|
|
88
|
+
// The document belongs to the model, not the view: a message whose text changes is reconciled
|
|
89
|
+
// rather than remounted, so the virtualizer's measurement for this row survives the update.
|
|
90
|
+
const currentRef = useRef(text);
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
if (!view || currentRef.current === text) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const previous = currentRef.current;
|
|
97
|
+
currentRef.current = text;
|
|
98
|
+
initialTextRef.current = text;
|
|
99
|
+
// A streaming tail only ever extends: dispatch the delta so CodeMirror keeps scroll position and
|
|
100
|
+
// decorations instead of rebuilding the document on every frame.
|
|
101
|
+
view.dispatch(
|
|
102
|
+
text.startsWith(previous)
|
|
103
|
+
? { changes: { from: previous.length, insert: text.slice(previous.length) } }
|
|
104
|
+
: { changes: { from: 0, to: view.state.doc.length, insert: text } },
|
|
105
|
+
);
|
|
106
|
+
}, [view, text]);
|
|
107
|
+
|
|
108
|
+
// Nothing is dispatched to clear highlights that were never set: a transaction makes CodeMirror
|
|
109
|
+
// re-measure the document, and a viewport of rows mounting is a viewport of forced layouts for a
|
|
110
|
+
// search that is not running.
|
|
111
|
+
const highlighted = useRef(false);
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
if (!view || (!hits?.length && !highlighted.current)) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
highlighted.current = Boolean(hits?.length);
|
|
118
|
+
view.dispatch({ effects: setHighlights.of(hits ?? []) });
|
|
119
|
+
}, [view, hits]);
|
|
120
|
+
|
|
121
|
+
// Reported rather than derived: the widgets are portals into the item's own document, so nothing
|
|
122
|
+
// above the item can count them without reaching into the DOM.
|
|
123
|
+
useEffect(() => {
|
|
124
|
+
onWidgetsChange?.(widgets.length);
|
|
125
|
+
return () => onWidgetsChange?.(0);
|
|
126
|
+
}, [widgets.length, onWidgetsChange]);
|
|
127
|
+
|
|
128
|
+
// A block widget is measured once, when CodeMirror mounts it. Its content is React rendered into a
|
|
129
|
+
// portal, so anything that changes height afterwards — a disclosure opening, an image loading —
|
|
130
|
+
// leaves the editor holding a stale height and drawing the lines below it over the widget.
|
|
131
|
+
//
|
|
132
|
+
// Observing the portal roots and asking for a re-measure is what keeps the document's geometry
|
|
133
|
+
// honest; the row's own height then follows through the virtualizer's observer.
|
|
134
|
+
useEffect(() => {
|
|
135
|
+
if (!view || !widgets.length) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const observer = new ResizeObserver(() => view.requestMeasure());
|
|
140
|
+
for (const { root } of widgets) {
|
|
141
|
+
observer.observe(root);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return () => observer.disconnect();
|
|
145
|
+
}, [view, widgets]);
|
|
146
|
+
|
|
147
|
+
return (
|
|
148
|
+
<>
|
|
149
|
+
<div ref={rootRef} />
|
|
150
|
+
{widgets.map(({ Component, root, id, props }) => (
|
|
151
|
+
<div key={id}>{createPortal(<Component view={view ?? undefined} {...props} />, root)}</div>
|
|
152
|
+
))}
|
|
153
|
+
</>
|
|
154
|
+
);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
MarkdownBlock.displayName = 'MarkdownBlock';
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type Meta, type StoryObj } from '@storybook/react-vite';
|
|
6
|
+
import React from 'react';
|
|
7
|
+
import { expect } from 'storybook/test';
|
|
8
|
+
|
|
9
|
+
import { withLayout, withTheme } from '@dxos/react-ui/testing';
|
|
10
|
+
import { trim } from '@dxos/util';
|
|
11
|
+
|
|
12
|
+
import { chatRegistry } from '../../testing';
|
|
13
|
+
import { HtmlBlock } from './HtmlBlock';
|
|
14
|
+
import { MarkdownBlock } from './MarkdownBlock';
|
|
15
|
+
import { WidgetStateProvider, createWidgetStateStore } from './widget-state';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* One story per item kind, outside any list: the item is the unit the feed mounts per message, and
|
|
19
|
+
* this is where its rendering is judged on its own — a defect here is the item's, never the
|
|
20
|
+
* placement's. Kinds mirror `ItemContent`: markdown (a read-only CodeMirror document), markdown
|
|
21
|
+
* with block widgets (the assistant turn's shape), markdown with highlights (what decorations
|
|
22
|
+
* paint), and html (sanitized prose, the email case).
|
|
23
|
+
*/
|
|
24
|
+
type StoryArgs = {
|
|
25
|
+
kind: 'markdown' | 'widgets' | 'highlighted' | 'html';
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const MARKDOWN = trim`
|
|
29
|
+
### A markdown item
|
|
30
|
+
|
|
31
|
+
One read-only CodeMirror document per message: **bold**, *emphasis*, [a link](https://dxos.org),
|
|
32
|
+
\`inline code\`, and a fence —
|
|
33
|
+
|
|
34
|
+
\`\`\`ts
|
|
35
|
+
const answer = 42;
|
|
36
|
+
\`\`\`
|
|
37
|
+
|
|
38
|
+
1. Ordered
|
|
39
|
+
2. Lists
|
|
40
|
+
`;
|
|
41
|
+
|
|
42
|
+
const WIDGETS = trim`
|
|
43
|
+
<prompt>What does a turn look like?</prompt>
|
|
44
|
+
|
|
45
|
+
<reasoning>Considering the block kinds a model emits, in order.</reasoning>
|
|
46
|
+
|
|
47
|
+
<toolCall name="search" input="{}"></toolCall>
|
|
48
|
+
|
|
49
|
+
The answer's prose follows the widgets, as ordinary markdown.
|
|
50
|
+
`;
|
|
51
|
+
|
|
52
|
+
const HIGHLIGHTED = 'The needle sits in the middle of this sentence, and the ranges paint it.';
|
|
53
|
+
|
|
54
|
+
const HTML = trim`
|
|
55
|
+
<h3>An HTML item</h3>
|
|
56
|
+
<p>Sanitized prose for content that arrives as markup — the email case. <b>Tags</b> render,
|
|
57
|
+
<i>scripts</i> do not.</p>
|
|
58
|
+
<ul><li>One</li><li>Two</li></ul>
|
|
59
|
+
`;
|
|
60
|
+
|
|
61
|
+
const store = createWidgetStateStore();
|
|
62
|
+
|
|
63
|
+
const DefaultStory = ({ kind }: StoryArgs) => {
|
|
64
|
+
switch (kind) {
|
|
65
|
+
case 'markdown':
|
|
66
|
+
return <MarkdownBlock text={MARKDOWN} />;
|
|
67
|
+
case 'widgets':
|
|
68
|
+
return (
|
|
69
|
+
<WidgetStateProvider store={store}>
|
|
70
|
+
<MarkdownBlock text={WIDGETS} registry={chatRegistry} />
|
|
71
|
+
</WidgetStateProvider>
|
|
72
|
+
);
|
|
73
|
+
case 'highlighted': {
|
|
74
|
+
const offset = HIGHLIGHTED.indexOf('needle');
|
|
75
|
+
return <MarkdownBlock text={HIGHLIGHTED} hits={[[offset, offset + 'needle'.length]]} />;
|
|
76
|
+
}
|
|
77
|
+
case 'html':
|
|
78
|
+
return <HtmlBlock html={HTML} />;
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const meta: Meta<StoryArgs> = {
|
|
83
|
+
title: 'ui/react-ui-feed/components/Block',
|
|
84
|
+
render: DefaultStory,
|
|
85
|
+
decorators: [withLayout({ layout: 'column', classNames: 'w-[40rem] p-4' }), withTheme()],
|
|
86
|
+
args: { kind: 'markdown' },
|
|
87
|
+
argTypes: {
|
|
88
|
+
kind: { control: 'select', options: ['markdown', 'widgets', 'highlighted', 'html'] },
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export default meta;
|
|
93
|
+
|
|
94
|
+
type Story = StoryObj<StoryArgs>;
|
|
95
|
+
|
|
96
|
+
/** Passive: flip `kind` in the controls. */
|
|
97
|
+
export const Default: Story = {};
|
|
98
|
+
|
|
99
|
+
export const Markdown: Story = {
|
|
100
|
+
args: { kind: 'markdown' },
|
|
101
|
+
play: async ({ canvasElement }) => {
|
|
102
|
+
// The document rendered as markdown, not as its source: decoration replaced the heading marks.
|
|
103
|
+
await expect(canvasElement.querySelector('.cm-content')).toBeTruthy();
|
|
104
|
+
await expect(canvasElement.textContent).toContain('A markdown item');
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export const Widgets: Story = {
|
|
109
|
+
args: { kind: 'widgets' },
|
|
110
|
+
play: async ({ canvasElement }) => {
|
|
111
|
+
// The tags became widgets: the reasoning panel mounts as a React component inside the editor,
|
|
112
|
+
// and the raw angle brackets are gone from what the reader sees.
|
|
113
|
+
for (let frame = 0; frame < 60 && !canvasElement.querySelector('[data-testid="feed.widget"]'); frame++) {
|
|
114
|
+
await new Promise((resolve) => requestAnimationFrame(resolve));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
await expect(canvasElement.querySelector('[data-testid="feed.widget"]')).toBeTruthy();
|
|
118
|
+
await expect(canvasElement.textContent).not.toContain('<reasoning>');
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
export const Highlighted: Story = {
|
|
123
|
+
args: { kind: 'highlighted' },
|
|
124
|
+
play: async ({ canvasElement }) => {
|
|
125
|
+
// What `useDecorations` paints, at the item level: the range carries a highlight mark.
|
|
126
|
+
for (let frame = 0; frame < 60 && !canvasElement.querySelector('.dx-feed-hit'); frame++) {
|
|
127
|
+
await new Promise((resolve) => requestAnimationFrame(resolve));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
await expect(canvasElement.querySelector('.dx-feed-hit')).toBeTruthy();
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
export const Html: Story = {
|
|
135
|
+
args: { kind: 'html' },
|
|
136
|
+
play: async ({ canvasElement }) => {
|
|
137
|
+
await expect(canvasElement.querySelector('h3')).toBeTruthy();
|
|
138
|
+
await expect(canvasElement.textContent).toContain('An HTML item');
|
|
139
|
+
},
|
|
140
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { EditorState, type Extension } from '@codemirror/state';
|
|
6
|
+
import { EditorView } from '@codemirror/view';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* What one item costs to build.
|
|
10
|
+
*
|
|
11
|
+
* The list's first fill mounts a viewport's worth of rows at once, so the wall-clock cost of that
|
|
12
|
+
* fill is the per-item construction cost times the number of rows on screen — which is why this is
|
|
13
|
+
* measured per extension set rather than per story: the answer has to say *what* to drop.
|
|
14
|
+
*
|
|
15
|
+
* Only meaningful in a real browser. Construction is dominated by style resolution and the forced
|
|
16
|
+
* layout that follows it, neither of which a DOM shim performs.
|
|
17
|
+
*/
|
|
18
|
+
export type ConstructionCase = {
|
|
19
|
+
name: string;
|
|
20
|
+
extensions: Extension[];
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type ConstructionResult = {
|
|
24
|
+
name: string;
|
|
25
|
+
/** Mean milliseconds to construct one view and force its layout. */
|
|
26
|
+
mean: number;
|
|
27
|
+
/** Slowest single construction in the run, which is what a dropped frame is made of. */
|
|
28
|
+
worst: number;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type ConstructionProfileOptions = {
|
|
32
|
+
parent: HTMLElement;
|
|
33
|
+
cases: ConstructionCase[];
|
|
34
|
+
doc: string;
|
|
35
|
+
runs?: number;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Times construction of `runs` views per case, including the layout each one forces.
|
|
40
|
+
*
|
|
41
|
+
* Views are destroyed after each case rather than at the end: a hundred live editors in one parent
|
|
42
|
+
* make every subsequent layout progressively more expensive, which would report the harness.
|
|
43
|
+
*/
|
|
44
|
+
export const profileConstruction = ({
|
|
45
|
+
parent,
|
|
46
|
+
cases,
|
|
47
|
+
doc,
|
|
48
|
+
runs = 20,
|
|
49
|
+
}: ConstructionProfileOptions): ConstructionResult[] =>
|
|
50
|
+
cases.map(({ name, extensions }) => {
|
|
51
|
+
// Discarded: the first view of a run pays for style sheets the rest reuse.
|
|
52
|
+
construct(parent, extensions, doc).destroy();
|
|
53
|
+
|
|
54
|
+
const views: EditorView[] = [];
|
|
55
|
+
let worst = 0;
|
|
56
|
+
const start = performance.now();
|
|
57
|
+
for (let index = 0; index < runs; index++) {
|
|
58
|
+
const before = performance.now();
|
|
59
|
+
views.push(construct(parent, extensions, doc));
|
|
60
|
+
worst = Math.max(worst, performance.now() - before);
|
|
61
|
+
}
|
|
62
|
+
const mean = (performance.now() - start) / runs;
|
|
63
|
+
|
|
64
|
+
for (const view of views) {
|
|
65
|
+
view.destroy();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return { name, mean, worst };
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// Reading `offsetHeight` is not incidental: the row is measured the instant it mounts, so the
|
|
72
|
+
// layout the editor forces is part of what the item costs.
|
|
73
|
+
const construct = (parent: HTMLElement, extensions: Extension[], doc: string): EditorView => {
|
|
74
|
+
const view = new EditorView({ parent, state: EditorState.create({ doc, extensions }) });
|
|
75
|
+
void view.dom.offsetHeight;
|
|
76
|
+
return view;
|
|
77
|
+
};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type Extension } from '@codemirror/state';
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
type XmlWidgetRegistry,
|
|
9
|
+
type XmlWidgetState,
|
|
10
|
+
createBasicExtensions,
|
|
11
|
+
createMarkdownExtensions,
|
|
12
|
+
createThemeExtensions,
|
|
13
|
+
decorateMarkdown,
|
|
14
|
+
extendedMarkdown,
|
|
15
|
+
xmlBlockDecoration,
|
|
16
|
+
xmlFormatting,
|
|
17
|
+
xmlTags,
|
|
18
|
+
} from '@dxos/ui-editor';
|
|
19
|
+
|
|
20
|
+
import { highlights, highlightTheme } from './highlight';
|
|
21
|
+
|
|
22
|
+
export type ItemExtensionOptions = {
|
|
23
|
+
registry?: XmlWidgetRegistry;
|
|
24
|
+
editable?: boolean;
|
|
25
|
+
themeMode?: 'light' | 'dark';
|
|
26
|
+
setWidgets?: (widgets: XmlWidgetState[]) => void;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The extension set one item's document is built from.
|
|
31
|
+
*
|
|
32
|
+
* Everything that does not depend on the item is **built once and shared**, and that is the point of
|
|
33
|
+
* this module rather than a tidiness. `EditorView.theme()` mints a new `StyleModule` and a new
|
|
34
|
+
* generated class on every call, so a set built per item injects one stylesheet per row and
|
|
35
|
+
* invalidates the whole document's style each time it does. Measured at ~14ms per row against ~2ms
|
|
36
|
+
* for the identical set shared (`baseline/mount`) — six times the cost of the first fill, for
|
|
37
|
+
* stylesheets that are all the same.
|
|
38
|
+
*
|
|
39
|
+
* Extensions are descriptors, not instances: a `StateField` or `ViewPlugin` in a shared array is
|
|
40
|
+
* still instantiated per view, so sharing changes nothing but the identity CodeMirror dedupes on.
|
|
41
|
+
*/
|
|
42
|
+
export const createBlockExtensions = ({
|
|
43
|
+
registry,
|
|
44
|
+
editable = false,
|
|
45
|
+
themeMode = 'light',
|
|
46
|
+
setWidgets,
|
|
47
|
+
}: ItemExtensionOptions = {}): Extension[] => [
|
|
48
|
+
...sharedExtensions(registry, editable, themeMode),
|
|
49
|
+
// The one part that cannot be shared: the callback that hands this item's widgets back to it.
|
|
50
|
+
...(registry ? [xmlTags({ registry, setWidgets: setWidgets ?? (() => {}), bookmarks: ['prompt'] })] : []),
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
/** Registries are compared by identity, so a feed's single registry is a single cache scope. */
|
|
54
|
+
const NO_REGISTRY = {};
|
|
55
|
+
|
|
56
|
+
const cache = new WeakMap<object, Map<string, Extension[]>>();
|
|
57
|
+
|
|
58
|
+
const sharedExtensions = (
|
|
59
|
+
registry: XmlWidgetRegistry | undefined,
|
|
60
|
+
editable: boolean,
|
|
61
|
+
themeMode: 'light' | 'dark',
|
|
62
|
+
): Extension[] => {
|
|
63
|
+
const scope = registry ?? NO_REGISTRY;
|
|
64
|
+
let byVariant = cache.get(scope);
|
|
65
|
+
if (!byVariant) {
|
|
66
|
+
byVariant = new Map();
|
|
67
|
+
cache.set(scope, byVariant);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const key = `${editable}:${themeMode}`;
|
|
71
|
+
let extensions = byVariant.get(key);
|
|
72
|
+
if (!extensions) {
|
|
73
|
+
extensions = build(registry, editable, themeMode);
|
|
74
|
+
byVariant.set(key, extensions);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return extensions;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const build = (registry: XmlWidgetRegistry | undefined, editable: boolean, themeMode: 'light' | 'dark'): Extension[] =>
|
|
81
|
+
[
|
|
82
|
+
createBasicExtensions({ readOnly: !editable, editable, lineWrapping: true }),
|
|
83
|
+
createThemeExtensions({ themeMode }),
|
|
84
|
+
// A registry changes how the document is *parsed*, not only how it is decorated: registered
|
|
85
|
+
// tags have to survive as single blocks through the markdown parser before `xmlTags` can
|
|
86
|
+
// replace them, and without that they render as the literal angle brackets they are.
|
|
87
|
+
registry ? extendedMarkdown({ registry }) : createMarkdownExtensions(),
|
|
88
|
+
registry && xmlFormatting({ skip: ['prompt'] }),
|
|
89
|
+
decorateMarkdown(),
|
|
90
|
+
// The tags are hidden but the prompt is NOT framed here: the frame is chrome's, which also
|
|
91
|
+
// owns the rewind toolbar under it. Styling it in both places drew the border twice.
|
|
92
|
+
registry?.prompt && xmlBlockDecoration({ tag: 'prompt', hideTags: true }),
|
|
93
|
+
highlights,
|
|
94
|
+
highlightTheme,
|
|
95
|
+
].filter(Boolean) as Extension[];
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { StateEffect, StateField } from '@codemirror/state';
|
|
6
|
+
import { Decoration, type DecorationSet, EditorView } from '@codemirror/view';
|
|
7
|
+
|
|
8
|
+
/** Ranges to highlight, as [from, to) offsets into the item's document. */
|
|
9
|
+
export type HighlightRange = readonly [number, number];
|
|
10
|
+
|
|
11
|
+
export const setHighlights = StateEffect.define<readonly HighlightRange[]>();
|
|
12
|
+
|
|
13
|
+
const mark = Decoration.mark({ class: 'dx-feed-hit' });
|
|
14
|
+
|
|
15
|
+
const clamp = (offset: number, length: number) => Math.max(0, Math.min(offset, length));
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Highlights pushed in from the model.
|
|
19
|
+
*
|
|
20
|
+
* Search runs over the feed's projection rather than per-view (most messages are never mounted), so
|
|
21
|
+
* the item receives ranges instead of owning search state.
|
|
22
|
+
*/
|
|
23
|
+
export const highlights = StateField.define<DecorationSet>({
|
|
24
|
+
create: () => Decoration.none,
|
|
25
|
+
update: (value, tr) => {
|
|
26
|
+
for (const effect of tr.effects) {
|
|
27
|
+
if (effect.is(setHighlights)) {
|
|
28
|
+
const length = tr.state.doc.length;
|
|
29
|
+
return Decoration.set(
|
|
30
|
+
effect.value
|
|
31
|
+
// Clamp against the current document at both ends: a hit computed on the model can
|
|
32
|
+
// outrun a view whose streaming tail has not arrived yet, and a decoration outside
|
|
33
|
+
// `[0, length]` throws rather than being ignored.
|
|
34
|
+
.map(([from, to]) => [clamp(from, length), clamp(to, length)] as const)
|
|
35
|
+
.filter(([from, to]) => from < to)
|
|
36
|
+
.map(([from, to]) => mark.range(from, to)),
|
|
37
|
+
true,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return tr.docChanged ? value.map(tr.changes) : value;
|
|
43
|
+
},
|
|
44
|
+
provide: (field) => EditorView.decorations.from(field),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
export const highlightTheme = EditorView.baseTheme({
|
|
48
|
+
'.dx-feed-hit': {
|
|
49
|
+
backgroundColor: 'var(--dx-accentFill)',
|
|
50
|
+
color: 'var(--dx-accentFillText)',
|
|
51
|
+
borderRadius: '2px',
|
|
52
|
+
},
|
|
53
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type EditorView } from '@codemirror/view';
|
|
6
|
+
import { createContext, useContext, useMemo } from 'react';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Coordinates selection across the items of one feed.
|
|
10
|
+
*
|
|
11
|
+
* Each item is its own editor with its own selection state, and `drawSelection` paints that state
|
|
12
|
+
* whether or not the view has focus — so without a coordinator every message the reader has ever
|
|
13
|
+
* selected in stays highlighted at once. The feed has one selection, so claiming it in one item
|
|
14
|
+
* collapses it everywhere else.
|
|
15
|
+
*/
|
|
16
|
+
export type SelectionGroup = {
|
|
17
|
+
/** Adds a view to the group; returns the disposer. */
|
|
18
|
+
register: (view: EditorView) => () => void;
|
|
19
|
+
/** Makes `view` the sole owner of the feed's selection. */
|
|
20
|
+
claim: (view: EditorView) => void;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** Creates a group; one per feed, provided to its items by `MessageList.Root`. */
|
|
24
|
+
export const createSelectionGroup = (): SelectionGroup => {
|
|
25
|
+
const views = new Set<EditorView>();
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
register: (view) => {
|
|
29
|
+
views.add(view);
|
|
30
|
+
return () => views.delete(view);
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
claim: (view) => {
|
|
34
|
+
for (const other of views) {
|
|
35
|
+
if (other === view || other.state.selection.main.empty) {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
// Collapsing re-enters this listener for `other`, but with an empty selection, which claims
|
|
39
|
+
// nothing — so the cascade terminates after one pass.
|
|
40
|
+
other.dispatch({ selection: { anchor: 0 } });
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const SelectionGroupContext = createContext<SelectionGroup | undefined>(undefined);
|
|
47
|
+
|
|
48
|
+
/** The enclosing feed's selection group, or a private one when an item is used standalone. */
|
|
49
|
+
export const useSelectionGroup = (): SelectionGroup => {
|
|
50
|
+
const provided = useContext(SelectionGroupContext);
|
|
51
|
+
const fallback = useMemo(createSelectionGroup, []);
|
|
52
|
+
return provided ?? fallback;
|
|
53
|
+
};
|