dsh-side-chat-plus 0.3.1 → 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.
@@ -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'