dsh-side-chat-plus 0.3.2 → 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +326 -326
- package/README.zh.md +268 -268
- package/dsh.plugin.json +1 -1
- package/lib/client-registry.js +125 -125
- package/lib/client-registry.js.map +1 -1
- package/lib/client.js +125 -125
- package/lib/client.js.map +1 -1
- package/lib/index.js +7 -2
- package/package.json +1 -1
- package/src/client/attachments/AttachmentRail.module.css +89 -89
- package/src/client/attachments/AttachmentRail.tsx +173 -173
- package/src/client/attachments/DropOverlay.module.css +38 -38
- package/src/client/attachments/DropOverlay.tsx +62 -62
- package/src/client/attachments/ImageLightbox.module.css +44 -44
- package/src/client/attachments/ImageLightbox.tsx +58 -58
- package/src/client/attachments/MessageImage.module.css +61 -61
- package/src/client/attachments/MessageImage.tsx +120 -120
- package/src/client/attachments/index.ts +19 -19
- package/src/client/client.module.css +1032 -1032
- package/src/client/index.tsx +1966 -1966
- package/src/client/locales.ts +173 -173
- package/src/context-types.ts +384 -384
- package/src/index.ts +14 -6
package/lib/index.js
CHANGED
|
@@ -519,10 +519,15 @@ function buildApi(ctx, sideChats, getSettings) {
|
|
|
519
519
|
const items = [];
|
|
520
520
|
for (const record of sideChats.values()) {
|
|
521
521
|
if (record.parentSessionId !== parentSessionId) continue;
|
|
522
|
-
const
|
|
522
|
+
const agent = record.handle.agent;
|
|
523
|
+
const status = agent.status;
|
|
524
|
+
const statusRunning = status === "running";
|
|
525
|
+
const eventRunningSince = openTurnStart(agent.session.events ?? []);
|
|
526
|
+
const running = statusRunning || status === void 0 && eventRunningSince !== void 0;
|
|
527
|
+
const runningSince = running ? eventRunningSince ?? Date.now() : void 0;
|
|
523
528
|
items.push({
|
|
524
529
|
childId: record.childId,
|
|
525
|
-
running
|
|
530
|
+
running,
|
|
526
531
|
...runningSince !== void 0 ? { runningSince } : {}
|
|
527
532
|
});
|
|
528
533
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-side-chat-plus",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "侧边聊天 Plus (Side chat Plus) — DSH web plugin: select text in a conversation and ask it in a per-session side chat (hidden ordinary session, own right-side panel, inherits model/effort/permissions, lookup toggle).",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "module",
|
|
@@ -1,89 +1,89 @@
|
|
|
1
|
-
.root {
|
|
2
|
-
position: relative;
|
|
3
|
-
display: flex;
|
|
4
|
-
align-items: center;
|
|
5
|
-
min-width: 0;
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
.rail {
|
|
9
|
-
display: flex;
|
|
10
|
-
gap: 8px;
|
|
11
|
-
overflow-x: auto;
|
|
12
|
-
scrollbar-width: none;
|
|
13
|
-
padding: 2px;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
.rail::-webkit-scrollbar {
|
|
17
|
-
display: none;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
.item {
|
|
21
|
-
position: relative;
|
|
22
|
-
flex: none;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
.thumbnail {
|
|
26
|
-
display: block;
|
|
27
|
-
width: 56px;
|
|
28
|
-
height: 56px;
|
|
29
|
-
padding: 0;
|
|
30
|
-
overflow: hidden;
|
|
31
|
-
border: 1px solid var(--dsw-color-border, rgba(0, 0, 0, 0.1));
|
|
32
|
-
border-radius: 8px;
|
|
33
|
-
background: none;
|
|
34
|
-
cursor: pointer;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
.thumbnail img {
|
|
38
|
-
width: 100%;
|
|
39
|
-
height: 100%;
|
|
40
|
-
object-fit: cover;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
.remove {
|
|
44
|
-
position: absolute;
|
|
45
|
-
top: -6px;
|
|
46
|
-
right: -6px;
|
|
47
|
-
display: flex;
|
|
48
|
-
align-items: center;
|
|
49
|
-
justify-content: center;
|
|
50
|
-
width: 18px;
|
|
51
|
-
height: 18px;
|
|
52
|
-
padding: 0;
|
|
53
|
-
border: none;
|
|
54
|
-
border-radius: 50%;
|
|
55
|
-
background: var(--dsw-color-surface-raised, #fff);
|
|
56
|
-
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
|
|
57
|
-
color: var(--dsw-color-text-secondary, #646a73);
|
|
58
|
-
cursor: pointer;
|
|
59
|
-
opacity: 0;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
.item:hover .remove,
|
|
63
|
-
.remove:focus-visible {
|
|
64
|
-
opacity: 1;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
.arrow {
|
|
68
|
-
position: absolute;
|
|
69
|
-
z-index: 1;
|
|
70
|
-
display: flex;
|
|
71
|
-
align-items: center;
|
|
72
|
-
justify-content: center;
|
|
73
|
-
width: 22px;
|
|
74
|
-
height: 22px;
|
|
75
|
-
padding: 0;
|
|
76
|
-
border: 1px solid var(--dsw-color-border, rgba(0, 0, 0, 0.1));
|
|
77
|
-
border-radius: 50%;
|
|
78
|
-
background: var(--dsw-color-surface-raised, #fff);
|
|
79
|
-
color: var(--dsw-color-text-secondary, #646a73);
|
|
80
|
-
cursor: pointer;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
.arrowLeft {
|
|
84
|
-
left: -10px;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
.arrowRight {
|
|
88
|
-
right: -10px;
|
|
89
|
-
}
|
|
1
|
+
.root {
|
|
2
|
+
position: relative;
|
|
3
|
+
display: flex;
|
|
4
|
+
align-items: center;
|
|
5
|
+
min-width: 0;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
.rail {
|
|
9
|
+
display: flex;
|
|
10
|
+
gap: 8px;
|
|
11
|
+
overflow-x: auto;
|
|
12
|
+
scrollbar-width: none;
|
|
13
|
+
padding: 2px;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.rail::-webkit-scrollbar {
|
|
17
|
+
display: none;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.item {
|
|
21
|
+
position: relative;
|
|
22
|
+
flex: none;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.thumbnail {
|
|
26
|
+
display: block;
|
|
27
|
+
width: 56px;
|
|
28
|
+
height: 56px;
|
|
29
|
+
padding: 0;
|
|
30
|
+
overflow: hidden;
|
|
31
|
+
border: 1px solid var(--dsw-color-border, rgba(0, 0, 0, 0.1));
|
|
32
|
+
border-radius: 8px;
|
|
33
|
+
background: none;
|
|
34
|
+
cursor: pointer;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.thumbnail img {
|
|
38
|
+
width: 100%;
|
|
39
|
+
height: 100%;
|
|
40
|
+
object-fit: cover;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.remove {
|
|
44
|
+
position: absolute;
|
|
45
|
+
top: -6px;
|
|
46
|
+
right: -6px;
|
|
47
|
+
display: flex;
|
|
48
|
+
align-items: center;
|
|
49
|
+
justify-content: center;
|
|
50
|
+
width: 18px;
|
|
51
|
+
height: 18px;
|
|
52
|
+
padding: 0;
|
|
53
|
+
border: none;
|
|
54
|
+
border-radius: 50%;
|
|
55
|
+
background: var(--dsw-color-surface-raised, #fff);
|
|
56
|
+
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
|
|
57
|
+
color: var(--dsw-color-text-secondary, #646a73);
|
|
58
|
+
cursor: pointer;
|
|
59
|
+
opacity: 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.item:hover .remove,
|
|
63
|
+
.remove:focus-visible {
|
|
64
|
+
opacity: 1;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.arrow {
|
|
68
|
+
position: absolute;
|
|
69
|
+
z-index: 1;
|
|
70
|
+
display: flex;
|
|
71
|
+
align-items: center;
|
|
72
|
+
justify-content: center;
|
|
73
|
+
width: 22px;
|
|
74
|
+
height: 22px;
|
|
75
|
+
padding: 0;
|
|
76
|
+
border: 1px solid var(--dsw-color-border, rgba(0, 0, 0, 0.1));
|
|
77
|
+
border-radius: 50%;
|
|
78
|
+
background: var(--dsw-color-surface-raised, #fff);
|
|
79
|
+
color: var(--dsw-color-text-secondary, #646a73);
|
|
80
|
+
cursor: pointer;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.arrowLeft {
|
|
84
|
+
left: -10px;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.arrowRight {
|
|
88
|
+
right: -10px;
|
|
89
|
+
}
|
|
@@ -1,173 +1,173 @@
|
|
|
1
|
-
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
|
2
|
-
import {
|
|
3
|
-
IconChevronLeftOutline14,
|
|
4
|
-
IconChevronRightOutline14,
|
|
5
|
-
IconCloseFill14,
|
|
6
|
-
} from '@deepseek-ai/dsh-client-ui-primitives'
|
|
7
|
-
import css from './AttachmentRail.module.css'
|
|
8
|
-
|
|
9
|
-
/** One rail thumbnail; strings arrive resolved (zero-cordis atom). */
|
|
10
|
-
export interface AttachmentRailItem {
|
|
11
|
-
/** Stable identity for the React key. */
|
|
12
|
-
id: string
|
|
13
|
-
/** Object or data URL rendered as the thumbnail. */
|
|
14
|
-
previewUrl: string
|
|
15
|
-
/** Image alt text (display name with the owner's fallback applied). */
|
|
16
|
-
alt: string
|
|
17
|
-
/** Accessible label of the item's remove control. */
|
|
18
|
-
removeLabel: string
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/** Rail-level strings the owner resolves from its own locale namespace. */
|
|
22
|
-
export interface AttachmentRailLabels {
|
|
23
|
-
/** Accessible name of the rail group. */
|
|
24
|
-
group: string
|
|
25
|
-
/** Thumbnail tooltip inviting the original-image preview. */
|
|
26
|
-
open: string
|
|
27
|
-
/** Accessible label of the left paging arrow. */
|
|
28
|
-
scrollLeft: string
|
|
29
|
-
/** Accessible label of the right paging arrow. */
|
|
30
|
-
scrollRight: string
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** Approximate pixels per wheel step for `deltaMode` LINE deltas (Firefox notch wheels report lines). */
|
|
34
|
-
const WHEEL_LINE_PX = 16
|
|
35
|
-
|
|
36
|
-
/** Smooth paging unless the user asked for reduced motion. */
|
|
37
|
-
function pageBehavior(): ScrollBehavior {
|
|
38
|
-
return window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth'
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Horizontal thumbnail rail over the caller's draft attachments.
|
|
43
|
-
*
|
|
44
|
-
* The rail scrolls with its scrollbar hidden; overflow is announced by edge
|
|
45
|
-
* arrows recomputed from scroll geometry on scroll, item-count changes, and
|
|
46
|
-
* rail size changes (a ResizeObserver on the rail element, so sidebar or panel
|
|
47
|
-
* resizes count, not only window resizes). A vertical wheel pans the rail
|
|
48
|
-
* horizontally and is consumed exclusively, a newly added item is revealed at
|
|
49
|
-
* the rail's end, and each thumbnail opens on a single click while its remove
|
|
50
|
-
* control sits inside the card. The owner decides mounting.
|
|
51
|
-
*/
|
|
52
|
-
export function AttachmentRail<T extends AttachmentRailItem>({ items, labels, onOpen, onRemove }: {
|
|
53
|
-
items: readonly T[]
|
|
54
|
-
labels: AttachmentRailLabels
|
|
55
|
-
onOpen: (item: T) => void
|
|
56
|
-
onRemove: (item: T) => void
|
|
57
|
-
}) {
|
|
58
|
-
const railRef = useRef<HTMLDivElement | null>(null)
|
|
59
|
-
// null marks the first layout pass: a rail that MOUNTS over an existing
|
|
60
|
-
// draft is initial display, not growth, and must not jump to the end.
|
|
61
|
-
const countRef = useRef<number | null>(null)
|
|
62
|
-
const [edges, setEdges] = useState({ left: false, right: false })
|
|
63
|
-
const updateEdges = useCallback(() => {
|
|
64
|
-
const el = railRef.current
|
|
65
|
-
if (el === null) return
|
|
66
|
-
// 1px slack: engines report fractional scroll positions at the edges.
|
|
67
|
-
const left = el.scrollLeft > 1
|
|
68
|
-
const right = el.scrollLeft < el.scrollWidth - el.clientWidth - 1
|
|
69
|
-
setEdges(prev => prev.left === left && prev.right === right ? prev : { left, right })
|
|
70
|
-
}, [])
|
|
71
|
-
useLayoutEffect(() => {
|
|
72
|
-
const grew = countRef.current !== null && items.length > countRef.current
|
|
73
|
-
countRef.current = items.length
|
|
74
|
-
const el = railRef.current
|
|
75
|
-
if (el === null) return
|
|
76
|
-
// A newly added attachment lands at the rail's end: reveal it.
|
|
77
|
-
if (grew) el.scrollLeft = el.scrollWidth - el.clientWidth
|
|
78
|
-
updateEdges()
|
|
79
|
-
}, [items.length, updateEdges])
|
|
80
|
-
useEffect(() => {
|
|
81
|
-
const el = railRef.current
|
|
82
|
-
if (el === null) return
|
|
83
|
-
// The rail's width follows the composer, which resizes with sidebars and
|
|
84
|
-
// panels, not only the window — observe the element itself.
|
|
85
|
-
let disconnect = (): void => {}
|
|
86
|
-
if (typeof ResizeObserver !== 'undefined') {
|
|
87
|
-
const observer = new ResizeObserver(updateEdges)
|
|
88
|
-
observer.observe(el)
|
|
89
|
-
disconnect = () => { observer.disconnect() }
|
|
90
|
-
}
|
|
91
|
-
// The rail scrolls horizontally ONLY: any wheel tick with a vertical
|
|
92
|
-
// component is consumed — without preventDefault it would also scroll the
|
|
93
|
-
// conversation behind the composer, and React's root wheel listener is
|
|
94
|
-
// passive, so the exclusion needs this manually attached non-passive
|
|
95
|
-
// listener.
|
|
96
|
-
const onWheel = (event: globalThis.WheelEvent): void => {
|
|
97
|
-
if (event.deltaY === 0) return
|
|
98
|
-
const scale = event.deltaMode === WheelEvent.DOM_DELTA_LINE
|
|
99
|
-
? WHEEL_LINE_PX
|
|
100
|
-
: event.deltaMode === WheelEvent.DOM_DELTA_PAGE ? el.clientWidth : 1
|
|
101
|
-
event.preventDefault()
|
|
102
|
-
el.scrollBy({
|
|
103
|
-
left: event.deltaX !== 0
|
|
104
|
-
? event.deltaX * scale
|
|
105
|
-
: Math.sign(event.deltaY) * Math.min(Math.abs(event.deltaY) * scale, 60),
|
|
106
|
-
behavior: 'auto',
|
|
107
|
-
})
|
|
108
|
-
}
|
|
109
|
-
el.addEventListener('wheel', onWheel, { passive: false })
|
|
110
|
-
return () => {
|
|
111
|
-
disconnect()
|
|
112
|
-
el.removeEventListener('wheel', onWheel)
|
|
113
|
-
}
|
|
114
|
-
}, [updateEdges])
|
|
115
|
-
const page = (direction: -1 | 1): void => {
|
|
116
|
-
const el = railRef.current
|
|
117
|
-
if (el === null) return
|
|
118
|
-
// One viewport minus a card keeps the last visible thumbnail as context.
|
|
119
|
-
el.scrollBy({ left: direction * Math.max(el.clientWidth - 64, 200), behavior: pageBehavior() })
|
|
120
|
-
}
|
|
121
|
-
return (
|
|
122
|
-
<div className={css.root}>
|
|
123
|
-
{edges.left && (
|
|
124
|
-
<button
|
|
125
|
-
type="button"
|
|
126
|
-
className={`${css.arrow} ${css.arrowLeft}`}
|
|
127
|
-
aria-label={labels.scrollLeft}
|
|
128
|
-
onClick={() => { page(-1) }}
|
|
129
|
-
>
|
|
130
|
-
<IconChevronLeftOutline14 />
|
|
131
|
-
</button>
|
|
132
|
-
)}
|
|
133
|
-
<div
|
|
134
|
-
ref={railRef}
|
|
135
|
-
className={css.rail}
|
|
136
|
-
role="group"
|
|
137
|
-
aria-label={labels.group}
|
|
138
|
-
onScroll={updateEdges}
|
|
139
|
-
>
|
|
140
|
-
{items.map(item => (
|
|
141
|
-
<div key={item.id} className={css.item}>
|
|
142
|
-
<button
|
|
143
|
-
type="button"
|
|
144
|
-
className={css.thumbnail}
|
|
145
|
-
title={labels.open}
|
|
146
|
-
onClick={() => { onOpen(item) }}
|
|
147
|
-
>
|
|
148
|
-
<img src={item.previewUrl} alt={item.alt} />
|
|
149
|
-
</button>
|
|
150
|
-
<button
|
|
151
|
-
type="button"
|
|
152
|
-
className={css.remove}
|
|
153
|
-
aria-label={item.removeLabel}
|
|
154
|
-
onClick={() => { onRemove(item) }}
|
|
155
|
-
>
|
|
156
|
-
<IconCloseFill14 size={12} />
|
|
157
|
-
</button>
|
|
158
|
-
</div>
|
|
159
|
-
))}
|
|
160
|
-
</div>
|
|
161
|
-
{edges.right && (
|
|
162
|
-
<button
|
|
163
|
-
type="button"
|
|
164
|
-
className={`${css.arrow} ${css.arrowRight}`}
|
|
165
|
-
aria-label={labels.scrollRight}
|
|
166
|
-
onClick={() => { page(1) }}
|
|
167
|
-
>
|
|
168
|
-
<IconChevronRightOutline14 />
|
|
169
|
-
</button>
|
|
170
|
-
)}
|
|
171
|
-
</div>
|
|
172
|
-
)
|
|
173
|
-
}
|
|
1
|
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
IconChevronLeftOutline14,
|
|
4
|
+
IconChevronRightOutline14,
|
|
5
|
+
IconCloseFill14,
|
|
6
|
+
} from '@deepseek-ai/dsh-client-ui-primitives'
|
|
7
|
+
import css from './AttachmentRail.module.css'
|
|
8
|
+
|
|
9
|
+
/** One rail thumbnail; strings arrive resolved (zero-cordis atom). */
|
|
10
|
+
export interface AttachmentRailItem {
|
|
11
|
+
/** Stable identity for the React key. */
|
|
12
|
+
id: string
|
|
13
|
+
/** Object or data URL rendered as the thumbnail. */
|
|
14
|
+
previewUrl: string
|
|
15
|
+
/** Image alt text (display name with the owner's fallback applied). */
|
|
16
|
+
alt: string
|
|
17
|
+
/** Accessible label of the item's remove control. */
|
|
18
|
+
removeLabel: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Rail-level strings the owner resolves from its own locale namespace. */
|
|
22
|
+
export interface AttachmentRailLabels {
|
|
23
|
+
/** Accessible name of the rail group. */
|
|
24
|
+
group: string
|
|
25
|
+
/** Thumbnail tooltip inviting the original-image preview. */
|
|
26
|
+
open: string
|
|
27
|
+
/** Accessible label of the left paging arrow. */
|
|
28
|
+
scrollLeft: string
|
|
29
|
+
/** Accessible label of the right paging arrow. */
|
|
30
|
+
scrollRight: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Approximate pixels per wheel step for `deltaMode` LINE deltas (Firefox notch wheels report lines). */
|
|
34
|
+
const WHEEL_LINE_PX = 16
|
|
35
|
+
|
|
36
|
+
/** Smooth paging unless the user asked for reduced motion. */
|
|
37
|
+
function pageBehavior(): ScrollBehavior {
|
|
38
|
+
return window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth'
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Horizontal thumbnail rail over the caller's draft attachments.
|
|
43
|
+
*
|
|
44
|
+
* The rail scrolls with its scrollbar hidden; overflow is announced by edge
|
|
45
|
+
* arrows recomputed from scroll geometry on scroll, item-count changes, and
|
|
46
|
+
* rail size changes (a ResizeObserver on the rail element, so sidebar or panel
|
|
47
|
+
* resizes count, not only window resizes). A vertical wheel pans the rail
|
|
48
|
+
* horizontally and is consumed exclusively, a newly added item is revealed at
|
|
49
|
+
* the rail's end, and each thumbnail opens on a single click while its remove
|
|
50
|
+
* control sits inside the card. The owner decides mounting.
|
|
51
|
+
*/
|
|
52
|
+
export function AttachmentRail<T extends AttachmentRailItem>({ items, labels, onOpen, onRemove }: {
|
|
53
|
+
items: readonly T[]
|
|
54
|
+
labels: AttachmentRailLabels
|
|
55
|
+
onOpen: (item: T) => void
|
|
56
|
+
onRemove: (item: T) => void
|
|
57
|
+
}) {
|
|
58
|
+
const railRef = useRef<HTMLDivElement | null>(null)
|
|
59
|
+
// null marks the first layout pass: a rail that MOUNTS over an existing
|
|
60
|
+
// draft is initial display, not growth, and must not jump to the end.
|
|
61
|
+
const countRef = useRef<number | null>(null)
|
|
62
|
+
const [edges, setEdges] = useState({ left: false, right: false })
|
|
63
|
+
const updateEdges = useCallback(() => {
|
|
64
|
+
const el = railRef.current
|
|
65
|
+
if (el === null) return
|
|
66
|
+
// 1px slack: engines report fractional scroll positions at the edges.
|
|
67
|
+
const left = el.scrollLeft > 1
|
|
68
|
+
const right = el.scrollLeft < el.scrollWidth - el.clientWidth - 1
|
|
69
|
+
setEdges(prev => prev.left === left && prev.right === right ? prev : { left, right })
|
|
70
|
+
}, [])
|
|
71
|
+
useLayoutEffect(() => {
|
|
72
|
+
const grew = countRef.current !== null && items.length > countRef.current
|
|
73
|
+
countRef.current = items.length
|
|
74
|
+
const el = railRef.current
|
|
75
|
+
if (el === null) return
|
|
76
|
+
// A newly added attachment lands at the rail's end: reveal it.
|
|
77
|
+
if (grew) el.scrollLeft = el.scrollWidth - el.clientWidth
|
|
78
|
+
updateEdges()
|
|
79
|
+
}, [items.length, updateEdges])
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
const el = railRef.current
|
|
82
|
+
if (el === null) return
|
|
83
|
+
// The rail's width follows the composer, which resizes with sidebars and
|
|
84
|
+
// panels, not only the window — observe the element itself.
|
|
85
|
+
let disconnect = (): void => {}
|
|
86
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
87
|
+
const observer = new ResizeObserver(updateEdges)
|
|
88
|
+
observer.observe(el)
|
|
89
|
+
disconnect = () => { observer.disconnect() }
|
|
90
|
+
}
|
|
91
|
+
// The rail scrolls horizontally ONLY: any wheel tick with a vertical
|
|
92
|
+
// component is consumed — without preventDefault it would also scroll the
|
|
93
|
+
// conversation behind the composer, and React's root wheel listener is
|
|
94
|
+
// passive, so the exclusion needs this manually attached non-passive
|
|
95
|
+
// listener.
|
|
96
|
+
const onWheel = (event: globalThis.WheelEvent): void => {
|
|
97
|
+
if (event.deltaY === 0) return
|
|
98
|
+
const scale = event.deltaMode === WheelEvent.DOM_DELTA_LINE
|
|
99
|
+
? WHEEL_LINE_PX
|
|
100
|
+
: event.deltaMode === WheelEvent.DOM_DELTA_PAGE ? el.clientWidth : 1
|
|
101
|
+
event.preventDefault()
|
|
102
|
+
el.scrollBy({
|
|
103
|
+
left: event.deltaX !== 0
|
|
104
|
+
? event.deltaX * scale
|
|
105
|
+
: Math.sign(event.deltaY) * Math.min(Math.abs(event.deltaY) * scale, 60),
|
|
106
|
+
behavior: 'auto',
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
el.addEventListener('wheel', onWheel, { passive: false })
|
|
110
|
+
return () => {
|
|
111
|
+
disconnect()
|
|
112
|
+
el.removeEventListener('wheel', onWheel)
|
|
113
|
+
}
|
|
114
|
+
}, [updateEdges])
|
|
115
|
+
const page = (direction: -1 | 1): void => {
|
|
116
|
+
const el = railRef.current
|
|
117
|
+
if (el === null) return
|
|
118
|
+
// One viewport minus a card keeps the last visible thumbnail as context.
|
|
119
|
+
el.scrollBy({ left: direction * Math.max(el.clientWidth - 64, 200), behavior: pageBehavior() })
|
|
120
|
+
}
|
|
121
|
+
return (
|
|
122
|
+
<div className={css.root}>
|
|
123
|
+
{edges.left && (
|
|
124
|
+
<button
|
|
125
|
+
type="button"
|
|
126
|
+
className={`${css.arrow} ${css.arrowLeft}`}
|
|
127
|
+
aria-label={labels.scrollLeft}
|
|
128
|
+
onClick={() => { page(-1) }}
|
|
129
|
+
>
|
|
130
|
+
<IconChevronLeftOutline14 />
|
|
131
|
+
</button>
|
|
132
|
+
)}
|
|
133
|
+
<div
|
|
134
|
+
ref={railRef}
|
|
135
|
+
className={css.rail}
|
|
136
|
+
role="group"
|
|
137
|
+
aria-label={labels.group}
|
|
138
|
+
onScroll={updateEdges}
|
|
139
|
+
>
|
|
140
|
+
{items.map(item => (
|
|
141
|
+
<div key={item.id} className={css.item}>
|
|
142
|
+
<button
|
|
143
|
+
type="button"
|
|
144
|
+
className={css.thumbnail}
|
|
145
|
+
title={labels.open}
|
|
146
|
+
onClick={() => { onOpen(item) }}
|
|
147
|
+
>
|
|
148
|
+
<img src={item.previewUrl} alt={item.alt} />
|
|
149
|
+
</button>
|
|
150
|
+
<button
|
|
151
|
+
type="button"
|
|
152
|
+
className={css.remove}
|
|
153
|
+
aria-label={item.removeLabel}
|
|
154
|
+
onClick={() => { onRemove(item) }}
|
|
155
|
+
>
|
|
156
|
+
<IconCloseFill14 size={12} />
|
|
157
|
+
</button>
|
|
158
|
+
</div>
|
|
159
|
+
))}
|
|
160
|
+
</div>
|
|
161
|
+
{edges.right && (
|
|
162
|
+
<button
|
|
163
|
+
type="button"
|
|
164
|
+
className={`${css.arrow} ${css.arrowRight}`}
|
|
165
|
+
aria-label={labels.scrollRight}
|
|
166
|
+
onClick={() => { page(1) }}
|
|
167
|
+
>
|
|
168
|
+
<IconChevronRightOutline14 />
|
|
169
|
+
</button>
|
|
170
|
+
)}
|
|
171
|
+
</div>
|
|
172
|
+
)
|
|
173
|
+
}
|
|
@@ -1,38 +1,38 @@
|
|
|
1
|
-
.mask {
|
|
2
|
-
position: fixed;
|
|
3
|
-
inset: 0;
|
|
4
|
-
z-index: 999;
|
|
5
|
-
display: flex;
|
|
6
|
-
align-items: center;
|
|
7
|
-
justify-content: center;
|
|
8
|
-
/* Decoration only: drag targeting must stay on the page below. */
|
|
9
|
-
pointer-events: none;
|
|
10
|
-
background: rgba(255, 255, 255, 0.72);
|
|
11
|
-
border: 2px dashed var(--dsw-color-border-strong, #4d6bfe);
|
|
12
|
-
border-radius: 12px;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
.wrap {
|
|
16
|
-
display: flex;
|
|
17
|
-
flex-direction: column;
|
|
18
|
-
align-items: center;
|
|
19
|
-
gap: 12px;
|
|
20
|
-
text-align: center;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
.illustration {
|
|
24
|
-
display: flex;
|
|
25
|
-
align-items: center;
|
|
26
|
-
justify-content: center;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
.title {
|
|
30
|
-
font-size: 16px;
|
|
31
|
-
font-weight: 600;
|
|
32
|
-
color: var(--dsw-color-text, #1f2329);
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
.desc {
|
|
36
|
-
font-size: 13px;
|
|
37
|
-
color: var(--dsw-color-text-secondary, #646a73);
|
|
38
|
-
}
|
|
1
|
+
.mask {
|
|
2
|
+
position: fixed;
|
|
3
|
+
inset: 0;
|
|
4
|
+
z-index: 999;
|
|
5
|
+
display: flex;
|
|
6
|
+
align-items: center;
|
|
7
|
+
justify-content: center;
|
|
8
|
+
/* Decoration only: drag targeting must stay on the page below. */
|
|
9
|
+
pointer-events: none;
|
|
10
|
+
background: rgba(255, 255, 255, 0.72);
|
|
11
|
+
border: 2px dashed var(--dsw-color-border-strong, #4d6bfe);
|
|
12
|
+
border-radius: 12px;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.wrap {
|
|
16
|
+
display: flex;
|
|
17
|
+
flex-direction: column;
|
|
18
|
+
align-items: center;
|
|
19
|
+
gap: 12px;
|
|
20
|
+
text-align: center;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.illustration {
|
|
24
|
+
display: flex;
|
|
25
|
+
align-items: center;
|
|
26
|
+
justify-content: center;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.title {
|
|
30
|
+
font-size: 16px;
|
|
31
|
+
font-weight: 600;
|
|
32
|
+
color: var(--dsw-color-text, #1f2329);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.desc {
|
|
36
|
+
font-size: 13px;
|
|
37
|
+
color: var(--dsw-color-text-secondary, #646a73);
|
|
38
|
+
}
|