dsh-side-chat-plus 0.3.2 → 0.3.4
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 +327 -326
- package/README.zh.md +269 -268
- package/dsh.plugin.json +3 -3
- package/lib/client-registry.js +397 -181
- package/lib/client-registry.js.map +1 -1
- package/lib/client.js +397 -181
- package/lib/client.js.map +1 -1
- package/lib/index.js +22 -7
- package/lib/types/client/locales.d.ts +18 -0
- package/lib/types/context-types.d.ts +36 -4
- package/lib/types/settings-shared.d.ts +4 -0
- package/package.json +37 -37
- 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 +1051 -1032
- package/src/client/index.tsx +2209 -1966
- package/src/client/locales.ts +191 -173
- package/src/context-types.ts +415 -384
- package/src/index.ts +29 -9
- package/src/settings-shared.ts +6 -0
|
@@ -1,58 +1,58 @@
|
|
|
1
|
-
import { useEffect, useRef } from 'react'
|
|
2
|
-
import { createPortal } from 'react-dom'
|
|
3
|
-
import { IconCloseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
4
|
-
import css from './ImageLightbox.module.css'
|
|
5
|
-
|
|
6
|
-
/** Preview strings the owner resolves from its own locale namespace. */
|
|
7
|
-
export interface ImageLightboxLabels {
|
|
8
|
-
/** Accessible name of the preview dialog. */
|
|
9
|
-
dialog: string
|
|
10
|
-
/** Accessible label of the close control. */
|
|
11
|
-
close: string
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Document-level original-image preview opened by clicking a thumbnail.
|
|
16
|
-
* Closes on Escape, backdrop press, or the close control, and restores focus
|
|
17
|
-
* to the opener on unmount. Rendered through a body portal: an opener inside
|
|
18
|
-
* a transformed or filtered ancestor would otherwise trap the fixed backdrop
|
|
19
|
-
* in that ancestor's box instead of covering the viewport.
|
|
20
|
-
*/
|
|
21
|
-
export function ImageLightbox({ src, alt, labels, onClose }: {
|
|
22
|
-
src: string
|
|
23
|
-
alt: string
|
|
24
|
-
labels: ImageLightboxLabels
|
|
25
|
-
onClose: () => void
|
|
26
|
-
}) {
|
|
27
|
-
const closeRef = useRef<HTMLButtonElement | null>(null)
|
|
28
|
-
const restoreRef = useRef<HTMLElement | null>(null)
|
|
29
|
-
|
|
30
|
-
useEffect(() => {
|
|
31
|
-
restoreRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null
|
|
32
|
-
closeRef.current?.focus()
|
|
33
|
-
const onKeyDown = (event: globalThis.KeyboardEvent): void => {
|
|
34
|
-
if (event.key === 'Escape') onClose()
|
|
35
|
-
}
|
|
36
|
-
window.addEventListener('keydown', onKeyDown)
|
|
37
|
-
return () => {
|
|
38
|
-
window.removeEventListener('keydown', onKeyDown)
|
|
39
|
-
restoreRef.current?.focus()
|
|
40
|
-
}
|
|
41
|
-
}, [onClose])
|
|
42
|
-
|
|
43
|
-
return createPortal(
|
|
44
|
-
<div
|
|
45
|
-
className={css.backdrop}
|
|
46
|
-
role="dialog"
|
|
47
|
-
aria-modal="true"
|
|
48
|
-
aria-label={labels.dialog}
|
|
49
|
-
>
|
|
50
|
-
<div className={css.mask} aria-hidden="true" onMouseDown={onClose} />
|
|
51
|
-
<img className={css.image} src={src} alt={alt} />
|
|
52
|
-
<button ref={closeRef} type="button" className={css.close} aria-label={labels.close} onClick={onClose}>
|
|
53
|
-
<IconCloseOutline16 size={16} />
|
|
54
|
-
</button>
|
|
55
|
-
</div>,
|
|
56
|
-
document.body,
|
|
57
|
-
)
|
|
58
|
-
}
|
|
1
|
+
import { useEffect, useRef } from 'react'
|
|
2
|
+
import { createPortal } from 'react-dom'
|
|
3
|
+
import { IconCloseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
4
|
+
import css from './ImageLightbox.module.css'
|
|
5
|
+
|
|
6
|
+
/** Preview strings the owner resolves from its own locale namespace. */
|
|
7
|
+
export interface ImageLightboxLabels {
|
|
8
|
+
/** Accessible name of the preview dialog. */
|
|
9
|
+
dialog: string
|
|
10
|
+
/** Accessible label of the close control. */
|
|
11
|
+
close: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Document-level original-image preview opened by clicking a thumbnail.
|
|
16
|
+
* Closes on Escape, backdrop press, or the close control, and restores focus
|
|
17
|
+
* to the opener on unmount. Rendered through a body portal: an opener inside
|
|
18
|
+
* a transformed or filtered ancestor would otherwise trap the fixed backdrop
|
|
19
|
+
* in that ancestor's box instead of covering the viewport.
|
|
20
|
+
*/
|
|
21
|
+
export function ImageLightbox({ src, alt, labels, onClose }: {
|
|
22
|
+
src: string
|
|
23
|
+
alt: string
|
|
24
|
+
labels: ImageLightboxLabels
|
|
25
|
+
onClose: () => void
|
|
26
|
+
}) {
|
|
27
|
+
const closeRef = useRef<HTMLButtonElement | null>(null)
|
|
28
|
+
const restoreRef = useRef<HTMLElement | null>(null)
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
restoreRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null
|
|
32
|
+
closeRef.current?.focus()
|
|
33
|
+
const onKeyDown = (event: globalThis.KeyboardEvent): void => {
|
|
34
|
+
if (event.key === 'Escape') onClose()
|
|
35
|
+
}
|
|
36
|
+
window.addEventListener('keydown', onKeyDown)
|
|
37
|
+
return () => {
|
|
38
|
+
window.removeEventListener('keydown', onKeyDown)
|
|
39
|
+
restoreRef.current?.focus()
|
|
40
|
+
}
|
|
41
|
+
}, [onClose])
|
|
42
|
+
|
|
43
|
+
return createPortal(
|
|
44
|
+
<div
|
|
45
|
+
className={css.backdrop}
|
|
46
|
+
role="dialog"
|
|
47
|
+
aria-modal="true"
|
|
48
|
+
aria-label={labels.dialog}
|
|
49
|
+
>
|
|
50
|
+
<div className={css.mask} aria-hidden="true" onMouseDown={onClose} />
|
|
51
|
+
<img className={css.image} src={src} alt={alt} />
|
|
52
|
+
<button ref={closeRef} type="button" className={css.close} aria-label={labels.close} onClick={onClose}>
|
|
53
|
+
<IconCloseOutline16 size={16} />
|
|
54
|
+
</button>
|
|
55
|
+
</div>,
|
|
56
|
+
document.body,
|
|
57
|
+
)
|
|
58
|
+
}
|
|
@@ -1,61 +1,61 @@
|
|
|
1
|
-
.gallery {
|
|
2
|
-
display: flex;
|
|
3
|
-
flex-wrap: wrap;
|
|
4
|
-
gap: 8px;
|
|
5
|
-
max-width: 100%;
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
.gallery[data-align='start'] {
|
|
9
|
-
justify-content: flex-start;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
.gallery[data-align='end'] {
|
|
13
|
-
justify-content: flex-end;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
.frame {
|
|
17
|
-
padding: 0;
|
|
18
|
-
overflow: hidden;
|
|
19
|
-
border: 1px solid var(--dsw-color-border, rgba(0, 0, 0, 0.08));
|
|
20
|
-
border-radius: 8px;
|
|
21
|
-
background: none;
|
|
22
|
-
cursor: pointer;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
.frame[data-variant='tile'] {
|
|
26
|
-
width: 64px;
|
|
27
|
-
height: 64px;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
.frame img {
|
|
31
|
-
width: 100%;
|
|
32
|
-
height: 100%;
|
|
33
|
-
object-fit: cover;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
.loading {
|
|
37
|
-
display: flex;
|
|
38
|
-
align-items: center;
|
|
39
|
-
justify-content: center;
|
|
40
|
-
width: 100%;
|
|
41
|
-
height: 100%;
|
|
42
|
-
min-width: 64px;
|
|
43
|
-
min-height: 64px;
|
|
44
|
-
font-size: 12px;
|
|
45
|
-
color: var(--dsw-color-text-secondary, #646a73);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
.error {
|
|
49
|
-
display: flex;
|
|
50
|
-
align-items: center;
|
|
51
|
-
justify-content: center;
|
|
52
|
-
min-width: 64px;
|
|
53
|
-
min-height: 64px;
|
|
54
|
-
padding: 0 8px;
|
|
55
|
-
border: 1px dashed var(--dsw-color-border, rgba(0, 0, 0, 0.16));
|
|
56
|
-
border-radius: 8px;
|
|
57
|
-
background: none;
|
|
58
|
-
font-size: 12px;
|
|
59
|
-
color: var(--dsw-color-text-secondary, #646a73);
|
|
60
|
-
cursor: pointer;
|
|
61
|
-
}
|
|
1
|
+
.gallery {
|
|
2
|
+
display: flex;
|
|
3
|
+
flex-wrap: wrap;
|
|
4
|
+
gap: 8px;
|
|
5
|
+
max-width: 100%;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
.gallery[data-align='start'] {
|
|
9
|
+
justify-content: flex-start;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.gallery[data-align='end'] {
|
|
13
|
+
justify-content: flex-end;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.frame {
|
|
17
|
+
padding: 0;
|
|
18
|
+
overflow: hidden;
|
|
19
|
+
border: 1px solid var(--dsw-color-border, rgba(0, 0, 0, 0.08));
|
|
20
|
+
border-radius: 8px;
|
|
21
|
+
background: none;
|
|
22
|
+
cursor: pointer;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.frame[data-variant='tile'] {
|
|
26
|
+
width: 64px;
|
|
27
|
+
height: 64px;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.frame img {
|
|
31
|
+
width: 100%;
|
|
32
|
+
height: 100%;
|
|
33
|
+
object-fit: cover;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.loading {
|
|
37
|
+
display: flex;
|
|
38
|
+
align-items: center;
|
|
39
|
+
justify-content: center;
|
|
40
|
+
width: 100%;
|
|
41
|
+
height: 100%;
|
|
42
|
+
min-width: 64px;
|
|
43
|
+
min-height: 64px;
|
|
44
|
+
font-size: 12px;
|
|
45
|
+
color: var(--dsw-color-text-secondary, #646a73);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.error {
|
|
49
|
+
display: flex;
|
|
50
|
+
align-items: center;
|
|
51
|
+
justify-content: center;
|
|
52
|
+
min-width: 64px;
|
|
53
|
+
min-height: 64px;
|
|
54
|
+
padding: 0 8px;
|
|
55
|
+
border: 1px dashed var(--dsw-color-border, rgba(0, 0, 0, 0.16));
|
|
56
|
+
border-radius: 8px;
|
|
57
|
+
background: none;
|
|
58
|
+
font-size: 12px;
|
|
59
|
+
color: var(--dsw-color-text-secondary, #646a73);
|
|
60
|
+
cursor: pointer;
|
|
61
|
+
}
|
|
@@ -1,120 +1,120 @@
|
|
|
1
|
-
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
2
|
-
import type { SidechatImageRef } from '../api.ts'
|
|
3
|
-
import { ImageLightbox } from './ImageLightbox.tsx'
|
|
4
|
-
import type { ImageLightboxLabels } from './ImageLightbox.tsx'
|
|
5
|
-
import css from './MessageImage.module.css'
|
|
6
|
-
|
|
7
|
-
/** Loads one durable image's bytes into a displayable URL. */
|
|
8
|
-
export type ImageLoader = (ref: SidechatImageRef) => Promise<string>
|
|
9
|
-
|
|
10
|
-
/** Message-image strings the owner resolves from its own locale namespace. */
|
|
11
|
-
export interface MessageImageLabels {
|
|
12
|
-
/** Fallback display name for an unnamed image. */
|
|
13
|
-
image: string
|
|
14
|
-
/** Thumbnail tooltip inviting the original-image preview. */
|
|
15
|
-
open: string
|
|
16
|
-
/** Accessible thumbnail label; receives the image's display name. */
|
|
17
|
-
openNamed: (label: string) => string
|
|
18
|
-
/** Loading placeholder shown until bytes resolve. */
|
|
19
|
-
loading: string
|
|
20
|
-
/** Retry-control label shown when the load fails. */
|
|
21
|
-
loadFailed: string
|
|
22
|
-
/** Lightbox strings forwarded to the opened preview. */
|
|
23
|
-
lightbox: ImageLightboxLabels
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/** Display box for a lone image: long edge 240px with the rendered aspect
|
|
27
|
-
* ratio clamped to [0.25, 4] — the overflow is cropped by
|
|
28
|
-
* `object-fit: cover` — and never upscaled past the image's natural size. The
|
|
29
|
-
* crop anchor keeps the top of very tall images and the left of very wide
|
|
30
|
-
* ones, where the informative content usually starts. */
|
|
31
|
-
function singleFit(
|
|
32
|
-
dimensions: { readonly width: number; readonly height: number },
|
|
33
|
-
): { width: number; height: number; objectPosition: string } {
|
|
34
|
-
const natural = dimensions.width / dimensions.height
|
|
35
|
-
const ratio = Math.min(4, Math.max(0.25, natural))
|
|
36
|
-
const box = ratio >= 1 ? { width: 240, height: 240 / ratio } : { width: 240 * ratio, height: 240 }
|
|
37
|
-
const scale = Math.min(1, dimensions.width / box.width, dimensions.height / box.height)
|
|
38
|
-
return {
|
|
39
|
-
width: Math.max(1, Math.round(box.width * scale)),
|
|
40
|
-
height: Math.max(1, Math.round(box.height * scale)),
|
|
41
|
-
objectPosition: natural < 0.25 ? 'center top' : natural > 4 ? 'left center' : 'center',
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Compact history renderer with retryable loading and click-to-open original
|
|
47
|
-
* preview. A lone image renders at its `singleFit` size; an image among
|
|
48
|
-
* several renders as a fixed 64px square tile.
|
|
49
|
-
*/
|
|
50
|
-
export function MessageImage({ image, load, variant, labels }: {
|
|
51
|
-
image: SidechatImageRef
|
|
52
|
-
load: ImageLoader
|
|
53
|
-
variant: 'single' | 'tile'
|
|
54
|
-
labels: MessageImageLabels
|
|
55
|
-
}) {
|
|
56
|
-
const [loaded, setLoaded] = useState<string | null>(null)
|
|
57
|
-
const [error, setError] = useState(false)
|
|
58
|
-
const [open, setOpen] = useState(false)
|
|
59
|
-
// Retry re-arms the one load effect below, so every attempt — first load or
|
|
60
|
-
// retry — runs under the same liveness guard and the same reset.
|
|
61
|
-
const [attempt, setAttempt] = useState(0)
|
|
62
|
-
const request = useCallback(() => { setAttempt(a => a + 1) }, [])
|
|
63
|
-
const close = useCallback(() => { setOpen(false) }, [])
|
|
64
|
-
const label = image.name === undefined || image.name === '' ? labels.image : image.name
|
|
65
|
-
const fit = useMemo(() => (variant === 'single' ? singleFit(image) : undefined), [image, variant])
|
|
66
|
-
|
|
67
|
-
useEffect(() => {
|
|
68
|
-
let live = true
|
|
69
|
-
setError(false)
|
|
70
|
-
setLoaded(null)
|
|
71
|
-
void load(image).then((url) => { if (live) setLoaded(url) }).catch(() => { if (live) setError(true) })
|
|
72
|
-
return () => { live = false }
|
|
73
|
-
}, [image, load, attempt])
|
|
74
|
-
|
|
75
|
-
if (error) return <button type="button" className={css.error} data-variant={variant} onClick={request}>{labels.loadFailed}</button>
|
|
76
|
-
const src = loaded
|
|
77
|
-
return (
|
|
78
|
-
<>
|
|
79
|
-
<button
|
|
80
|
-
type="button"
|
|
81
|
-
className={css.frame}
|
|
82
|
-
data-variant={variant}
|
|
83
|
-
style={fit === undefined ? undefined : { width: fit.width, height: fit.height }}
|
|
84
|
-
title={labels.open}
|
|
85
|
-
aria-label={labels.openNamed(label)}
|
|
86
|
-
onClick={() => { if (src !== null) setOpen(true) }}
|
|
87
|
-
>
|
|
88
|
-
{src === null
|
|
89
|
-
? <span className={css.loading}>{labels.loading}</span>
|
|
90
|
-
: <img src={src} alt={label} style={fit === undefined ? undefined : { objectPosition: fit.objectPosition }} />}
|
|
91
|
-
</button>
|
|
92
|
-
{open && src !== null && <ImageLightbox src={src} alt={label} labels={labels.lightbox} onClose={close} />}
|
|
93
|
-
</>
|
|
94
|
-
)
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/** Wrapping image group: a lone image renders large, several render as 64px
|
|
98
|
-
* square tiles. */
|
|
99
|
-
export function ImageGallery({ images, load, align, labels }: {
|
|
100
|
-
images: readonly SidechatImageRef[]
|
|
101
|
-
load: ImageLoader
|
|
102
|
-
align: 'start' | 'end'
|
|
103
|
-
labels: MessageImageLabels
|
|
104
|
-
}) {
|
|
105
|
-
if (images.length === 0) return null
|
|
106
|
-
const variant = images.length === 1 ? 'single' : 'tile'
|
|
107
|
-
return (
|
|
108
|
-
<div className={css.gallery} data-align={align}>
|
|
109
|
-
{images.map((image, index) => (
|
|
110
|
-
<MessageImage
|
|
111
|
-
key={`${image.attachmentId}:${index}`}
|
|
112
|
-
image={image}
|
|
113
|
-
load={load}
|
|
114
|
-
variant={variant}
|
|
115
|
-
labels={labels}
|
|
116
|
-
/>
|
|
117
|
-
))}
|
|
118
|
-
</div>
|
|
119
|
-
)
|
|
120
|
-
}
|
|
1
|
+
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
2
|
+
import type { SidechatImageRef } from '../api.ts'
|
|
3
|
+
import { ImageLightbox } from './ImageLightbox.tsx'
|
|
4
|
+
import type { ImageLightboxLabels } from './ImageLightbox.tsx'
|
|
5
|
+
import css from './MessageImage.module.css'
|
|
6
|
+
|
|
7
|
+
/** Loads one durable image's bytes into a displayable URL. */
|
|
8
|
+
export type ImageLoader = (ref: SidechatImageRef) => Promise<string>
|
|
9
|
+
|
|
10
|
+
/** Message-image strings the owner resolves from its own locale namespace. */
|
|
11
|
+
export interface MessageImageLabels {
|
|
12
|
+
/** Fallback display name for an unnamed image. */
|
|
13
|
+
image: string
|
|
14
|
+
/** Thumbnail tooltip inviting the original-image preview. */
|
|
15
|
+
open: string
|
|
16
|
+
/** Accessible thumbnail label; receives the image's display name. */
|
|
17
|
+
openNamed: (label: string) => string
|
|
18
|
+
/** Loading placeholder shown until bytes resolve. */
|
|
19
|
+
loading: string
|
|
20
|
+
/** Retry-control label shown when the load fails. */
|
|
21
|
+
loadFailed: string
|
|
22
|
+
/** Lightbox strings forwarded to the opened preview. */
|
|
23
|
+
lightbox: ImageLightboxLabels
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Display box for a lone image: long edge 240px with the rendered aspect
|
|
27
|
+
* ratio clamped to [0.25, 4] — the overflow is cropped by
|
|
28
|
+
* `object-fit: cover` — and never upscaled past the image's natural size. The
|
|
29
|
+
* crop anchor keeps the top of very tall images and the left of very wide
|
|
30
|
+
* ones, where the informative content usually starts. */
|
|
31
|
+
function singleFit(
|
|
32
|
+
dimensions: { readonly width: number; readonly height: number },
|
|
33
|
+
): { width: number; height: number; objectPosition: string } {
|
|
34
|
+
const natural = dimensions.width / dimensions.height
|
|
35
|
+
const ratio = Math.min(4, Math.max(0.25, natural))
|
|
36
|
+
const box = ratio >= 1 ? { width: 240, height: 240 / ratio } : { width: 240 * ratio, height: 240 }
|
|
37
|
+
const scale = Math.min(1, dimensions.width / box.width, dimensions.height / box.height)
|
|
38
|
+
return {
|
|
39
|
+
width: Math.max(1, Math.round(box.width * scale)),
|
|
40
|
+
height: Math.max(1, Math.round(box.height * scale)),
|
|
41
|
+
objectPosition: natural < 0.25 ? 'center top' : natural > 4 ? 'left center' : 'center',
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Compact history renderer with retryable loading and click-to-open original
|
|
47
|
+
* preview. A lone image renders at its `singleFit` size; an image among
|
|
48
|
+
* several renders as a fixed 64px square tile.
|
|
49
|
+
*/
|
|
50
|
+
export function MessageImage({ image, load, variant, labels }: {
|
|
51
|
+
image: SidechatImageRef
|
|
52
|
+
load: ImageLoader
|
|
53
|
+
variant: 'single' | 'tile'
|
|
54
|
+
labels: MessageImageLabels
|
|
55
|
+
}) {
|
|
56
|
+
const [loaded, setLoaded] = useState<string | null>(null)
|
|
57
|
+
const [error, setError] = useState(false)
|
|
58
|
+
const [open, setOpen] = useState(false)
|
|
59
|
+
// Retry re-arms the one load effect below, so every attempt — first load or
|
|
60
|
+
// retry — runs under the same liveness guard and the same reset.
|
|
61
|
+
const [attempt, setAttempt] = useState(0)
|
|
62
|
+
const request = useCallback(() => { setAttempt(a => a + 1) }, [])
|
|
63
|
+
const close = useCallback(() => { setOpen(false) }, [])
|
|
64
|
+
const label = image.name === undefined || image.name === '' ? labels.image : image.name
|
|
65
|
+
const fit = useMemo(() => (variant === 'single' ? singleFit(image) : undefined), [image, variant])
|
|
66
|
+
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
let live = true
|
|
69
|
+
setError(false)
|
|
70
|
+
setLoaded(null)
|
|
71
|
+
void load(image).then((url) => { if (live) setLoaded(url) }).catch(() => { if (live) setError(true) })
|
|
72
|
+
return () => { live = false }
|
|
73
|
+
}, [image, load, attempt])
|
|
74
|
+
|
|
75
|
+
if (error) return <button type="button" className={css.error} data-variant={variant} onClick={request}>{labels.loadFailed}</button>
|
|
76
|
+
const src = loaded
|
|
77
|
+
return (
|
|
78
|
+
<>
|
|
79
|
+
<button
|
|
80
|
+
type="button"
|
|
81
|
+
className={css.frame}
|
|
82
|
+
data-variant={variant}
|
|
83
|
+
style={fit === undefined ? undefined : { width: fit.width, height: fit.height }}
|
|
84
|
+
title={labels.open}
|
|
85
|
+
aria-label={labels.openNamed(label)}
|
|
86
|
+
onClick={() => { if (src !== null) setOpen(true) }}
|
|
87
|
+
>
|
|
88
|
+
{src === null
|
|
89
|
+
? <span className={css.loading}>{labels.loading}</span>
|
|
90
|
+
: <img src={src} alt={label} style={fit === undefined ? undefined : { objectPosition: fit.objectPosition }} />}
|
|
91
|
+
</button>
|
|
92
|
+
{open && src !== null && <ImageLightbox src={src} alt={label} labels={labels.lightbox} onClose={close} />}
|
|
93
|
+
</>
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Wrapping image group: a lone image renders large, several render as 64px
|
|
98
|
+
* square tiles. */
|
|
99
|
+
export function ImageGallery({ images, load, align, labels }: {
|
|
100
|
+
images: readonly SidechatImageRef[]
|
|
101
|
+
load: ImageLoader
|
|
102
|
+
align: 'start' | 'end'
|
|
103
|
+
labels: MessageImageLabels
|
|
104
|
+
}) {
|
|
105
|
+
if (images.length === 0) return null
|
|
106
|
+
const variant = images.length === 1 ? 'single' : 'tile'
|
|
107
|
+
return (
|
|
108
|
+
<div className={css.gallery} data-align={align}>
|
|
109
|
+
{images.map((image, index) => (
|
|
110
|
+
<MessageImage
|
|
111
|
+
key={`${image.attachmentId}:${index}`}
|
|
112
|
+
image={image}
|
|
113
|
+
load={load}
|
|
114
|
+
variant={variant}
|
|
115
|
+
labels={labels}
|
|
116
|
+
/>
|
|
117
|
+
))}
|
|
118
|
+
</div>
|
|
119
|
+
)
|
|
120
|
+
}
|
|
@@ -1,19 +1,19 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Image presentation owned by this plugin.
|
|
3
|
-
*
|
|
4
|
-
* The DSH attachment UI package registers these through conversation slots and
|
|
5
|
-
* exports no React components (`packages/client/AGENTS.md`: a feature plugin
|
|
6
|
-
* MUST NOT runtime-import another feature plugin's values — UI crosses
|
|
7
|
-
* packages through slots). A standalone side panel sits outside the
|
|
8
|
-
* conversation slot tree and cannot reach those slots, so it carries its own
|
|
9
|
-
* presentation, built only on `ui-primitives` (a sanctioned static owner).
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
export { AttachmentRail } from './AttachmentRail.tsx'
|
|
13
|
-
export type { AttachmentRailItem, AttachmentRailLabels } from './AttachmentRail.tsx'
|
|
14
|
-
export { DropOverlay } from './DropOverlay.tsx'
|
|
15
|
-
export type { DropOverlayLabels } from './DropOverlay.tsx'
|
|
16
|
-
export { ImageGallery, MessageImage } from './MessageImage.tsx'
|
|
17
|
-
export type { ImageLoader, MessageImageLabels } from './MessageImage.tsx'
|
|
18
|
-
export { ImageLightbox } from './ImageLightbox.tsx'
|
|
19
|
-
export type { ImageLightboxLabels } from './ImageLightbox.tsx'
|
|
1
|
+
/**
|
|
2
|
+
* Image presentation owned by this plugin.
|
|
3
|
+
*
|
|
4
|
+
* The DSH attachment UI package registers these through conversation slots and
|
|
5
|
+
* exports no React components (`packages/client/AGENTS.md`: a feature plugin
|
|
6
|
+
* MUST NOT runtime-import another feature plugin's values — UI crosses
|
|
7
|
+
* packages through slots). A standalone side panel sits outside the
|
|
8
|
+
* conversation slot tree and cannot reach those slots, so it carries its own
|
|
9
|
+
* presentation, built only on `ui-primitives` (a sanctioned static owner).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export { AttachmentRail } from './AttachmentRail.tsx'
|
|
13
|
+
export type { AttachmentRailItem, AttachmentRailLabels } from './AttachmentRail.tsx'
|
|
14
|
+
export { DropOverlay } from './DropOverlay.tsx'
|
|
15
|
+
export type { DropOverlayLabels } from './DropOverlay.tsx'
|
|
16
|
+
export { ImageGallery, MessageImage } from './MessageImage.tsx'
|
|
17
|
+
export type { ImageLoader, MessageImageLabels } from './MessageImage.tsx'
|
|
18
|
+
export { ImageLightbox } from './ImageLightbox.tsx'
|
|
19
|
+
export type { ImageLightboxLabels } from './ImageLightbox.tsx'
|