@byline/ui 1.6.2 → 1.7.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/dist/fields/image/image-field.js +37 -4
- package/dist/fields/image/image-field.module.js +2 -0
- package/dist/fields/image/image-field_module.css +24 -0
- package/dist/uikit.d.ts +1 -0
- package/dist/uikit.js +1 -0
- package/dist/widgets/image-lightbox/image-lightbox.d.ts +25 -0
- package/dist/widgets/image-lightbox/image-lightbox.js +94 -0
- package/dist/widgets/image-lightbox/image-lightbox.module.js +17 -0
- package/dist/widgets/image-lightbox/image-lightbox_module.css +82 -0
- package/package.json +4 -4
- package/src/fields/image/image-field.module.css +28 -0
- package/src/fields/image/image-field.tsx +69 -14
- package/src/uikit.ts +1 -0
- package/src/widgets/image-lightbox/image-lightbox.module.css +80 -0
- package/src/widgets/image-lightbox/image-lightbox.tsx +118 -0
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from "react";
|
|
2
3
|
import { isPendingStoredFileValue } from "@byline/core";
|
|
3
4
|
import classnames from "classnames";
|
|
4
5
|
import { useFieldError, useFieldValue, useFormContext, useIsDirty } from "../../forms/form-context.js";
|
|
5
6
|
import { ErrorText } from "../../uikit.js";
|
|
7
|
+
import { ImageLightbox } from "../../widgets/image-lightbox/image-lightbox.js";
|
|
6
8
|
import { useFieldChangeHandler } from "../use-field-change-handler.js";
|
|
7
9
|
import image_field_module from "./image-field.module.js";
|
|
8
10
|
import { ImageUploadField } from "./image-upload-field.js";
|
|
@@ -21,10 +23,14 @@ const ImageField = ({ field, collectionPath, value, defaultValue, onChange: _onC
|
|
|
21
23
|
return 'placeholder' === maybe.storageProvider && 'pending' === maybe.storagePath;
|
|
22
24
|
};
|
|
23
25
|
const showUploadWidget = null == incomingValue || isOldPlaceholder(incomingValue);
|
|
26
|
+
const thumbVariant = incomingValue && !isPendingStoredFileValue(incomingValue) ? incomingValue.variants?.find((v)=>'thumbnail' === v.name) : void 0;
|
|
27
|
+
const previewUrl = thumbVariant?.storageUrl ?? incomingValue?.storageUrl;
|
|
24
28
|
const handleRemove = ()=>{
|
|
25
29
|
if (isPending) removePendingUpload(fieldPath);
|
|
26
30
|
handleChange(null);
|
|
27
31
|
};
|
|
32
|
+
const [lightboxOpen, setLightboxOpen] = useState(false);
|
|
33
|
+
const canOpenLightbox = !isPending && !!incomingValue?.storageUrl;
|
|
28
34
|
return /*#__PURE__*/ jsxs("div", {
|
|
29
35
|
className: `byline-field-image ${field.name}`,
|
|
30
36
|
children: [
|
|
@@ -67,11 +73,24 @@ const ImageField = ({ field, collectionPath, value, defaultValue, onChange: _onC
|
|
|
67
73
|
}) : /*#__PURE__*/ jsxs("div", {
|
|
68
74
|
className: classnames('byline-field-image-tile', image_field_module.tile),
|
|
69
75
|
children: [
|
|
70
|
-
|
|
76
|
+
previewUrl && /*#__PURE__*/ jsxs("div", {
|
|
71
77
|
className: classnames('byline-field-image-preview-wrap', image_field_module["preview-wrap"]),
|
|
72
78
|
children: [
|
|
73
|
-
/*#__PURE__*/ jsx("
|
|
74
|
-
|
|
79
|
+
canOpenLightbox ? /*#__PURE__*/ jsx("button", {
|
|
80
|
+
type: "button",
|
|
81
|
+
onClick: ()=>setLightboxOpen(true),
|
|
82
|
+
"aria-label": "Open full-size preview",
|
|
83
|
+
className: classnames('byline-field-image-preview-button', image_field_module["preview-button"]),
|
|
84
|
+
children: /*#__PURE__*/ jsx("img", {
|
|
85
|
+
src: previewUrl,
|
|
86
|
+
alt: incomingValue.originalFilename ?? incomingValue.filename,
|
|
87
|
+
className: classnames('byline-field-image-preview', image_field_module.preview, 'image/svg+xml' === incomingValue.mimeType && [
|
|
88
|
+
'byline-field-image-preview-svg',
|
|
89
|
+
image_field_module["preview-svg"]
|
|
90
|
+
])
|
|
91
|
+
})
|
|
92
|
+
}) : /*#__PURE__*/ jsx("img", {
|
|
93
|
+
src: previewUrl,
|
|
75
94
|
alt: incomingValue.originalFilename ?? incomingValue.filename,
|
|
76
95
|
className: classnames('byline-field-image-preview', image_field_module.preview, 'image/svg+xml' === incomingValue.mimeType && [
|
|
77
96
|
'byline-field-image-preview-svg',
|
|
@@ -179,7 +198,7 @@ const ImageField = ({ field, collectionPath, value, defaultValue, onChange: _onC
|
|
|
179
198
|
children: "Thumbnail:"
|
|
180
199
|
}),
|
|
181
200
|
' ',
|
|
182
|
-
|
|
201
|
+
thumbVariant ? 'Generated' : 'Pending'
|
|
183
202
|
]
|
|
184
203
|
})
|
|
185
204
|
]
|
|
@@ -191,6 +210,20 @@ const ImageField = ({ field, collectionPath, value, defaultValue, onChange: _onC
|
|
|
191
210
|
fieldError && /*#__PURE__*/ jsx(ErrorText, {
|
|
192
211
|
id: `${field.name}-error`,
|
|
193
212
|
text: fieldError
|
|
213
|
+
}),
|
|
214
|
+
canOpenLightbox && incomingValue?.storageUrl && /*#__PURE__*/ jsx(ImageLightbox, {
|
|
215
|
+
isOpen: lightboxOpen,
|
|
216
|
+
onDismiss: ()=>setLightboxOpen(false),
|
|
217
|
+
src: incomingValue.storageUrl,
|
|
218
|
+
alt: incomingValue.originalFilename ?? incomingValue.filename,
|
|
219
|
+
downloadFilename: incomingValue.originalFilename ?? incomingValue.filename,
|
|
220
|
+
title: incomingValue.originalFilename ?? incomingValue.filename,
|
|
221
|
+
meta: {
|
|
222
|
+
width: incomingValue.imageWidth,
|
|
223
|
+
height: incomingValue.imageHeight,
|
|
224
|
+
fileSize: incomingValue.fileSize,
|
|
225
|
+
mimeType: incomingValue.mimeType
|
|
226
|
+
}
|
|
194
227
|
})
|
|
195
228
|
]
|
|
196
229
|
});
|
|
@@ -8,6 +8,8 @@ const image_field_module = {
|
|
|
8
8
|
tile: "tile-izbLn0",
|
|
9
9
|
"preview-wrap": "preview-wrap-Cjr0PD",
|
|
10
10
|
previewWrap: "preview-wrap-Cjr0PD",
|
|
11
|
+
"preview-button": "preview-button-LAsENX",
|
|
12
|
+
previewButton: "preview-button-LAsENX",
|
|
11
13
|
preview: "preview-DIASGB",
|
|
12
14
|
"preview-svg": "preview-svg-sCwreb",
|
|
13
15
|
previewSvg: "preview-svg-sCwreb",
|
|
@@ -51,6 +51,30 @@
|
|
|
51
51
|
position: relative;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
:is(.preview-button-LAsENX, .byline-field-image-preview-button) {
|
|
55
|
+
appearance: none;
|
|
56
|
+
cursor: pointer;
|
|
57
|
+
border-radius: var(--border-radius-sm);
|
|
58
|
+
background: none;
|
|
59
|
+
border: none;
|
|
60
|
+
margin: 0;
|
|
61
|
+
padding: 0;
|
|
62
|
+
display: block;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
:is(.preview-button-LAsENX:focus-visible, .byline-field-image-preview-button:focus-visible) {
|
|
66
|
+
outline: 2px solid var(--primary-500);
|
|
67
|
+
outline-offset: 2px;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
:is(.preview-button-LAsENX img, .byline-field-image-preview-button img) {
|
|
71
|
+
transition: opacity .12s;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
:is(.preview-button-LAsENX:hover img, .byline-field-image-preview-button:hover img) {
|
|
75
|
+
opacity: .85;
|
|
76
|
+
}
|
|
77
|
+
|
|
54
78
|
:is(.preview-DIASGB, .byline-field-image-preview) {
|
|
55
79
|
border: var(--border-width-thin) var(--border-style-solid) var(--gray-600);
|
|
56
80
|
border-radius: var(--border-radius-sm);
|
package/dist/uikit.d.ts
CHANGED
|
@@ -103,6 +103,7 @@ export * from './loaders/spinner.js';
|
|
|
103
103
|
export * from './widgets/datepicker/datepicker.js';
|
|
104
104
|
export * from './widgets/drawer/drawer.js';
|
|
105
105
|
export * from './widgets/drawer/drawer-context.js';
|
|
106
|
+
export * from './widgets/image-lightbox/image-lightbox.js';
|
|
106
107
|
export * from './widgets/modal/modal.js';
|
|
107
108
|
export * from './widgets/search/search.js';
|
|
108
109
|
export * from './widgets/timeline/timeline.js';
|
package/dist/uikit.js
CHANGED
|
@@ -98,6 +98,7 @@ export * from "./loaders/spinner.js";
|
|
|
98
98
|
export * from "./widgets/datepicker/datepicker.js";
|
|
99
99
|
export * from "./widgets/drawer/drawer.js";
|
|
100
100
|
export * from "./widgets/drawer/drawer-context.js";
|
|
101
|
+
export * from "./widgets/image-lightbox/image-lightbox.js";
|
|
101
102
|
export * from "./widgets/modal/modal.js";
|
|
102
103
|
export * from "./widgets/search/search.js";
|
|
103
104
|
export * from "./widgets/timeline/timeline.js";
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export interface ImageLightboxProps {
|
|
2
|
+
isOpen: boolean;
|
|
3
|
+
onDismiss: () => void;
|
|
4
|
+
/** Full-resolution image URL. */
|
|
5
|
+
src: string;
|
|
6
|
+
/** Alt text — falls back to filename. */
|
|
7
|
+
alt?: string;
|
|
8
|
+
/** Used as the `download` attribute on the download link. */
|
|
9
|
+
downloadFilename?: string;
|
|
10
|
+
/** Header label. Defaults to `downloadFilename`. */
|
|
11
|
+
title?: string;
|
|
12
|
+
/** Optional metadata row beneath the image. */
|
|
13
|
+
meta?: {
|
|
14
|
+
width?: number | null;
|
|
15
|
+
height?: number | null;
|
|
16
|
+
fileSize?: number | null;
|
|
17
|
+
mimeType?: string | null;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* ImageLightbox — modal preview of a full-resolution image with a download
|
|
22
|
+
* affordance. Right-click on the image works natively (browser "Save image
|
|
23
|
+
* as…") because it's a regular `<img src>`.
|
|
24
|
+
*/
|
|
25
|
+
export declare function ImageLightbox({ isOpen, onDismiss, src, alt, downloadFilename, title, meta, }: ImageLightboxProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import classnames from "classnames";
|
|
4
|
+
import { IconButton } from "../../components/button/icon-button.js";
|
|
5
|
+
import { CloseIcon } from "../../icons/close-icon.js";
|
|
6
|
+
import { DownloadIcon } from "../../icons/download-icon.js";
|
|
7
|
+
import { Modal } from "../modal/modal.js";
|
|
8
|
+
import image_lightbox_module from "./image-lightbox.module.js";
|
|
9
|
+
function formatFileSize(bytes) {
|
|
10
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
11
|
+
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
12
|
+
return `${(bytes / 1048576).toFixed(1)} MB`;
|
|
13
|
+
}
|
|
14
|
+
function triggerDownload(url, filename) {
|
|
15
|
+
if ("u" < typeof document) return;
|
|
16
|
+
const a = document.createElement('a');
|
|
17
|
+
a.href = url;
|
|
18
|
+
if (filename) a.download = filename;
|
|
19
|
+
a.target = '_blank';
|
|
20
|
+
a.rel = 'noreferrer';
|
|
21
|
+
document.body.appendChild(a);
|
|
22
|
+
a.click();
|
|
23
|
+
document.body.removeChild(a);
|
|
24
|
+
}
|
|
25
|
+
function ImageLightbox({ isOpen, onDismiss, src, alt, downloadFilename, title, meta }) {
|
|
26
|
+
const headingText = title ?? downloadFilename ?? 'Image';
|
|
27
|
+
const altText = alt ?? downloadFilename ?? 'Image preview';
|
|
28
|
+
return /*#__PURE__*/ jsx(Modal, {
|
|
29
|
+
isOpen: isOpen,
|
|
30
|
+
closeOnOverlayClick: true,
|
|
31
|
+
onDismiss: onDismiss,
|
|
32
|
+
children: /*#__PURE__*/ jsxs(Modal.Container, {
|
|
33
|
+
className: classnames('byline-image-lightbox-container', image_lightbox_module.container),
|
|
34
|
+
children: [
|
|
35
|
+
/*#__PURE__*/ jsxs(Modal.Header, {
|
|
36
|
+
className: classnames('byline-image-lightbox-header', image_lightbox_module.header),
|
|
37
|
+
children: [
|
|
38
|
+
/*#__PURE__*/ jsxs("div", {
|
|
39
|
+
className: classnames('byline-image-lightbox-title-stack', image_lightbox_module["title-stack"]),
|
|
40
|
+
children: [
|
|
41
|
+
/*#__PURE__*/ jsx("h3", {
|
|
42
|
+
className: classnames('byline-image-lightbox-title', image_lightbox_module.title),
|
|
43
|
+
children: headingText
|
|
44
|
+
}),
|
|
45
|
+
meta?.width != null && meta?.height != null && /*#__PURE__*/ jsxs("p", {
|
|
46
|
+
className: classnames('byline-image-lightbox-subtitle', image_lightbox_module.subtitle),
|
|
47
|
+
children: [
|
|
48
|
+
meta.width,
|
|
49
|
+
"\xd7",
|
|
50
|
+
meta.height,
|
|
51
|
+
null != meta.fileSize ? ` · ${formatFileSize(meta.fileSize)}` : '',
|
|
52
|
+
meta.mimeType ? ` · ${meta.mimeType}` : ''
|
|
53
|
+
]
|
|
54
|
+
})
|
|
55
|
+
]
|
|
56
|
+
}),
|
|
57
|
+
/*#__PURE__*/ jsxs("div", {
|
|
58
|
+
className: classnames('byline-image-lightbox-actions', image_lightbox_module["header-actions"]),
|
|
59
|
+
children: [
|
|
60
|
+
/*#__PURE__*/ jsx(IconButton, {
|
|
61
|
+
onClick: ()=>triggerDownload(src, downloadFilename),
|
|
62
|
+
size: "xs",
|
|
63
|
+
"aria-label": "Download original",
|
|
64
|
+
children: /*#__PURE__*/ jsx(DownloadIcon, {
|
|
65
|
+
width: "15px",
|
|
66
|
+
height: "15px"
|
|
67
|
+
})
|
|
68
|
+
}),
|
|
69
|
+
/*#__PURE__*/ jsx(IconButton, {
|
|
70
|
+
onClick: onDismiss,
|
|
71
|
+
size: "xs",
|
|
72
|
+
"aria-label": "Close preview",
|
|
73
|
+
children: /*#__PURE__*/ jsx(CloseIcon, {
|
|
74
|
+
width: "15px",
|
|
75
|
+
height: "15px"
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
]
|
|
79
|
+
})
|
|
80
|
+
]
|
|
81
|
+
}),
|
|
82
|
+
/*#__PURE__*/ jsx(Modal.Content, {
|
|
83
|
+
className: classnames('byline-image-lightbox-content', image_lightbox_module.content),
|
|
84
|
+
children: /*#__PURE__*/ jsx("img", {
|
|
85
|
+
src: src,
|
|
86
|
+
alt: altText,
|
|
87
|
+
className: classnames('byline-image-lightbox-image', image_lightbox_module.image)
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
]
|
|
91
|
+
})
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
export { ImageLightbox };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import "./image-lightbox_module.css";
|
|
2
|
+
const image_lightbox_module = {
|
|
3
|
+
container: "container-E7tMnX",
|
|
4
|
+
header: "header-soW8da",
|
|
5
|
+
"title-stack": "title-stack-zc7tfO",
|
|
6
|
+
titleStack: "title-stack-zc7tfO",
|
|
7
|
+
title: "title-o7aRVk",
|
|
8
|
+
subtitle: "subtitle-a_cB7m",
|
|
9
|
+
"header-actions": "header-actions-Pm2hb3",
|
|
10
|
+
headerActions: "header-actions-Pm2hb3",
|
|
11
|
+
content: "content-r1WUuT",
|
|
12
|
+
image: "image-CFBDM6",
|
|
13
|
+
meta: "meta-M5aIfQ",
|
|
14
|
+
"meta-key": "meta-key-ohNmZ4",
|
|
15
|
+
metaKey: "meta-key-ohNmZ4"
|
|
16
|
+
};
|
|
17
|
+
export default image_lightbox_module;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
@layer byline-base, byline-utilities, byline-theme;
|
|
2
|
+
|
|
3
|
+
@layer byline-components {
|
|
4
|
+
.container-E7tMnX {
|
|
5
|
+
flex-direction: column;
|
|
6
|
+
width: auto;
|
|
7
|
+
max-width: 96vw;
|
|
8
|
+
max-height: 96vh;
|
|
9
|
+
display: flex;
|
|
10
|
+
overflow: hidden;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.header-soW8da {
|
|
14
|
+
justify-content: space-between;
|
|
15
|
+
align-items: center;
|
|
16
|
+
gap: var(--gap-3);
|
|
17
|
+
display: flex;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.title-stack-zc7tfO {
|
|
21
|
+
flex: 1;
|
|
22
|
+
min-width: 0;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.title-o7aRVk {
|
|
26
|
+
font-size: var(--font-size-md);
|
|
27
|
+
text-overflow: ellipsis;
|
|
28
|
+
white-space: nowrap;
|
|
29
|
+
margin: 0;
|
|
30
|
+
font-weight: 600;
|
|
31
|
+
overflow: hidden;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.subtitle-a_cB7m {
|
|
35
|
+
font-size: var(--font-size-xs);
|
|
36
|
+
color: var(--text-color-muted);
|
|
37
|
+
margin: 0;
|
|
38
|
+
margin-top: var(--spacing-1);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.header-actions-Pm2hb3 {
|
|
42
|
+
align-items: center;
|
|
43
|
+
gap: var(--gap-2);
|
|
44
|
+
flex-shrink: 0;
|
|
45
|
+
display: flex;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.content-r1WUuT {
|
|
49
|
+
min-height: 0;
|
|
50
|
+
padding: var(--spacing-8);
|
|
51
|
+
background: var(--surface-canvas, #0b0b0b);
|
|
52
|
+
justify-content: center;
|
|
53
|
+
align-items: center;
|
|
54
|
+
display: flex;
|
|
55
|
+
overflow: auto;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.image-CFBDM6 {
|
|
59
|
+
object-fit: contain;
|
|
60
|
+
-webkit-user-select: none;
|
|
61
|
+
user-select: none;
|
|
62
|
+
width: auto;
|
|
63
|
+
max-width: 100%;
|
|
64
|
+
height: auto;
|
|
65
|
+
max-height: calc(96vh - 140px);
|
|
66
|
+
display: block;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.meta-M5aIfQ {
|
|
70
|
+
gap: var(--gap-3);
|
|
71
|
+
font-size: var(--font-size-xs);
|
|
72
|
+
color: var(--text-color-muted);
|
|
73
|
+
flex-wrap: wrap;
|
|
74
|
+
display: flex;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.meta-key-ohNmZ4 {
|
|
78
|
+
margin-right: var(--spacing-1);
|
|
79
|
+
font-weight: 600;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"private": false,
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MPL-2.0",
|
|
6
|
-
"version": "1.
|
|
6
|
+
"version": "1.7.1",
|
|
7
7
|
"engines": {
|
|
8
8
|
"node": ">=20.9.0"
|
|
9
9
|
},
|
|
@@ -65,9 +65,9 @@
|
|
|
65
65
|
"react-diff-viewer-continued": "^4.2.2",
|
|
66
66
|
"zod": "^4.4.2",
|
|
67
67
|
"zod-form-data": "^3.0.1",
|
|
68
|
-
"@byline/
|
|
69
|
-
"@byline/
|
|
70
|
-
"@byline/client": "1.
|
|
68
|
+
"@byline/admin": "1.7.1",
|
|
69
|
+
"@byline/core": "1.7.1",
|
|
70
|
+
"@byline/client": "1.7.1"
|
|
71
71
|
},
|
|
72
72
|
"peerDependencies": {
|
|
73
73
|
"react": "^19.0.0",
|
|
@@ -79,6 +79,34 @@
|
|
|
79
79
|
position: relative;
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
.preview-button,
|
|
83
|
+
:global(.byline-field-image-preview-button) {
|
|
84
|
+
appearance: none;
|
|
85
|
+
background: none;
|
|
86
|
+
border: none;
|
|
87
|
+
padding: 0;
|
|
88
|
+
margin: 0;
|
|
89
|
+
display: block;
|
|
90
|
+
cursor: pointer;
|
|
91
|
+
border-radius: var(--border-radius-sm);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.preview-button:focus-visible,
|
|
95
|
+
:global(.byline-field-image-preview-button):focus-visible {
|
|
96
|
+
outline: 2px solid var(--primary-500);
|
|
97
|
+
outline-offset: 2px;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.preview-button img,
|
|
101
|
+
:global(.byline-field-image-preview-button) img {
|
|
102
|
+
transition: opacity 120ms ease;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.preview-button:hover img,
|
|
106
|
+
:global(.byline-field-image-preview-button):hover img {
|
|
107
|
+
opacity: 0.85;
|
|
108
|
+
}
|
|
109
|
+
|
|
82
110
|
.preview,
|
|
83
111
|
:global(.byline-field-image-preview) {
|
|
84
112
|
max-height: 10rem;
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { useState } from 'react'
|
|
10
|
+
|
|
9
11
|
import {
|
|
10
12
|
type ImageField as FieldType,
|
|
11
13
|
isPendingStoredFileValue,
|
|
@@ -15,6 +17,7 @@ import cx from 'classnames'
|
|
|
15
17
|
|
|
16
18
|
import { useFieldError, useFieldValue, useFormContext, useIsDirty } from '../../forms/form-context'
|
|
17
19
|
import { ErrorText } from '../../uikit.js'
|
|
20
|
+
import { ImageLightbox } from '../../widgets/image-lightbox/image-lightbox.js'
|
|
18
21
|
import { useFieldChangeHandler } from '../use-field-change-handler'
|
|
19
22
|
import styles from './image-field.module.css'
|
|
20
23
|
import { ImageUploadField } from './image-upload-field'
|
|
@@ -68,6 +71,14 @@ export const ImageField = ({
|
|
|
68
71
|
// Show upload widget only if no value or old placeholder
|
|
69
72
|
const showUploadWidget = incomingValue == null || isOldPlaceholder(incomingValue)
|
|
70
73
|
|
|
74
|
+
// Prefer the generated thumbnail variant for the preview tile. SVGs and
|
|
75
|
+
// other bypass types have no variants — fall back to the original.
|
|
76
|
+
const thumbVariant =
|
|
77
|
+
incomingValue && !isPendingStoredFileValue(incomingValue)
|
|
78
|
+
? incomingValue.variants?.find((v) => v.name === 'thumbnail')
|
|
79
|
+
: undefined
|
|
80
|
+
const previewUrl = thumbVariant?.storageUrl ?? incomingValue?.storageUrl
|
|
81
|
+
|
|
71
82
|
// Handle remove, including cleanup of pending uploads
|
|
72
83
|
const handleRemove = () => {
|
|
73
84
|
if (isPending) {
|
|
@@ -76,6 +87,11 @@ export const ImageField = ({
|
|
|
76
87
|
handleChange(null)
|
|
77
88
|
}
|
|
78
89
|
|
|
90
|
+
// Lightbox state — only enabled for stored (non-pending) images that have a
|
|
91
|
+
// resolvable original storageUrl.
|
|
92
|
+
const [lightboxOpen, setLightboxOpen] = useState(false)
|
|
93
|
+
const canOpenLightbox = !isPending && !!incomingValue?.storageUrl
|
|
94
|
+
|
|
79
95
|
return (
|
|
80
96
|
<div className={`byline-field-image ${field.name}`}>
|
|
81
97
|
<div className={cx('byline-field-image-header', styles.header)}>
|
|
@@ -116,20 +132,42 @@ export const ImageField = ({
|
|
|
116
132
|
) : (
|
|
117
133
|
<div className={cx('byline-field-image-tile', styles.tile)}>
|
|
118
134
|
{/* Preview */}
|
|
119
|
-
{
|
|
135
|
+
{previewUrl && (
|
|
120
136
|
<div className={cx('byline-field-image-preview-wrap', styles['preview-wrap'])}>
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
styles
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
137
|
+
{canOpenLightbox ? (
|
|
138
|
+
<button
|
|
139
|
+
type="button"
|
|
140
|
+
onClick={() => setLightboxOpen(true)}
|
|
141
|
+
aria-label="Open full-size preview"
|
|
142
|
+
className={cx('byline-field-image-preview-button', styles['preview-button'])}
|
|
143
|
+
>
|
|
144
|
+
<img
|
|
145
|
+
src={previewUrl}
|
|
146
|
+
alt={incomingValue.originalFilename ?? incomingValue.filename}
|
|
147
|
+
className={cx(
|
|
148
|
+
'byline-field-image-preview',
|
|
149
|
+
styles.preview,
|
|
150
|
+
incomingValue.mimeType === 'image/svg+xml' && [
|
|
151
|
+
'byline-field-image-preview-svg',
|
|
152
|
+
styles['preview-svg'],
|
|
153
|
+
]
|
|
154
|
+
)}
|
|
155
|
+
/>
|
|
156
|
+
</button>
|
|
157
|
+
) : (
|
|
158
|
+
<img
|
|
159
|
+
src={previewUrl}
|
|
160
|
+
alt={incomingValue.originalFilename ?? incomingValue.filename}
|
|
161
|
+
className={cx(
|
|
162
|
+
'byline-field-image-preview',
|
|
163
|
+
styles.preview,
|
|
164
|
+
incomingValue.mimeType === 'image/svg+xml' && [
|
|
165
|
+
'byline-field-image-preview-svg',
|
|
166
|
+
styles['preview-svg'],
|
|
167
|
+
]
|
|
168
|
+
)}
|
|
169
|
+
/>
|
|
170
|
+
)}
|
|
133
171
|
{/* Pending upload badge */}
|
|
134
172
|
{isPending && (
|
|
135
173
|
<div className={cx('byline-field-image-pending', styles.pending)}>
|
|
@@ -198,7 +236,7 @@ export const ImageField = ({
|
|
|
198
236
|
<span className={cx('byline-field-image-meta-key', styles['meta-key'])}>
|
|
199
237
|
Thumbnail:
|
|
200
238
|
</span>{' '}
|
|
201
|
-
{
|
|
239
|
+
{thumbVariant ? 'Generated' : 'Pending'}
|
|
202
240
|
</div>
|
|
203
241
|
</>
|
|
204
242
|
)}
|
|
@@ -207,6 +245,23 @@ export const ImageField = ({
|
|
|
207
245
|
)}
|
|
208
246
|
|
|
209
247
|
{fieldError && <ErrorText id={`${field.name}-error`} text={fieldError} />}
|
|
248
|
+
|
|
249
|
+
{canOpenLightbox && incomingValue?.storageUrl && (
|
|
250
|
+
<ImageLightbox
|
|
251
|
+
isOpen={lightboxOpen}
|
|
252
|
+
onDismiss={() => setLightboxOpen(false)}
|
|
253
|
+
src={incomingValue.storageUrl}
|
|
254
|
+
alt={incomingValue.originalFilename ?? incomingValue.filename}
|
|
255
|
+
downloadFilename={incomingValue.originalFilename ?? incomingValue.filename}
|
|
256
|
+
title={incomingValue.originalFilename ?? incomingValue.filename}
|
|
257
|
+
meta={{
|
|
258
|
+
width: incomingValue.imageWidth,
|
|
259
|
+
height: incomingValue.imageHeight,
|
|
260
|
+
fileSize: incomingValue.fileSize,
|
|
261
|
+
mimeType: incomingValue.mimeType,
|
|
262
|
+
}}
|
|
263
|
+
/>
|
|
264
|
+
)}
|
|
210
265
|
</div>
|
|
211
266
|
)
|
|
212
267
|
}
|
package/src/uikit.ts
CHANGED
|
@@ -111,6 +111,7 @@ export * from './loaders/spinner.js'
|
|
|
111
111
|
export * from './widgets/datepicker/datepicker.js'
|
|
112
112
|
export * from './widgets/drawer/drawer.js'
|
|
113
113
|
export * from './widgets/drawer/drawer-context.js'
|
|
114
|
+
export * from './widgets/image-lightbox/image-lightbox.js'
|
|
114
115
|
export * from './widgets/modal/modal.js'
|
|
115
116
|
export * from './widgets/search/search.js'
|
|
116
117
|
export * from './widgets/timeline/timeline.js'
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
@layer byline-base, byline-utilities, byline-theme, byline-components;
|
|
2
|
+
|
|
3
|
+
@layer byline-components {
|
|
4
|
+
.container {
|
|
5
|
+
width: auto;
|
|
6
|
+
max-width: 96vw;
|
|
7
|
+
max-height: 96vh;
|
|
8
|
+
display: flex;
|
|
9
|
+
flex-direction: column;
|
|
10
|
+
overflow: hidden;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.header {
|
|
14
|
+
display: flex;
|
|
15
|
+
align-items: center;
|
|
16
|
+
justify-content: space-between;
|
|
17
|
+
gap: var(--gap-3);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.title-stack {
|
|
21
|
+
min-width: 0;
|
|
22
|
+
flex: 1;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.title {
|
|
26
|
+
font-size: var(--font-size-md);
|
|
27
|
+
font-weight: 600;
|
|
28
|
+
margin: 0;
|
|
29
|
+
overflow: hidden;
|
|
30
|
+
text-overflow: ellipsis;
|
|
31
|
+
white-space: nowrap;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.subtitle {
|
|
35
|
+
font-size: var(--font-size-xs);
|
|
36
|
+
color: var(--text-color-muted);
|
|
37
|
+
margin: 0;
|
|
38
|
+
margin-top: var(--spacing-1);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.header-actions {
|
|
42
|
+
display: flex;
|
|
43
|
+
align-items: center;
|
|
44
|
+
gap: var(--gap-2);
|
|
45
|
+
flex-shrink: 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.content {
|
|
49
|
+
display: flex;
|
|
50
|
+
align-items: center;
|
|
51
|
+
justify-content: center;
|
|
52
|
+
min-height: 0;
|
|
53
|
+
overflow: auto;
|
|
54
|
+
padding: var(--spacing-8);
|
|
55
|
+
background: var(--surface-canvas, #0b0b0b);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.image {
|
|
59
|
+
max-width: 100%;
|
|
60
|
+
max-height: calc(96vh - 140px);
|
|
61
|
+
width: auto;
|
|
62
|
+
height: auto;
|
|
63
|
+
object-fit: contain;
|
|
64
|
+
display: block;
|
|
65
|
+
user-select: none;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.meta {
|
|
69
|
+
display: flex;
|
|
70
|
+
flex-wrap: wrap;
|
|
71
|
+
gap: var(--gap-3);
|
|
72
|
+
font-size: var(--font-size-xs);
|
|
73
|
+
color: var(--text-color-muted);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.meta-key {
|
|
77
|
+
font-weight: 600;
|
|
78
|
+
margin-right: var(--spacing-1);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
5
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
6
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
7
|
+
*
|
|
8
|
+
* Copyright (c) Infonomic Company Limited
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import cx from 'classnames'
|
|
12
|
+
|
|
13
|
+
import { IconButton } from '../../components/button/icon-button.js'
|
|
14
|
+
import { CloseIcon } from '../../icons/close-icon.js'
|
|
15
|
+
import { DownloadIcon } from '../../icons/download-icon.js'
|
|
16
|
+
import { Modal } from '../modal/modal.js'
|
|
17
|
+
import styles from './image-lightbox.module.css'
|
|
18
|
+
|
|
19
|
+
export interface ImageLightboxProps {
|
|
20
|
+
isOpen: boolean
|
|
21
|
+
onDismiss: () => void
|
|
22
|
+
/** Full-resolution image URL. */
|
|
23
|
+
src: string
|
|
24
|
+
/** Alt text — falls back to filename. */
|
|
25
|
+
alt?: string
|
|
26
|
+
/** Used as the `download` attribute on the download link. */
|
|
27
|
+
downloadFilename?: string
|
|
28
|
+
/** Header label. Defaults to `downloadFilename`. */
|
|
29
|
+
title?: string
|
|
30
|
+
/** Optional metadata row beneath the image. */
|
|
31
|
+
meta?: {
|
|
32
|
+
width?: number | null
|
|
33
|
+
height?: number | null
|
|
34
|
+
fileSize?: number | null
|
|
35
|
+
mimeType?: string | null
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function formatFileSize(bytes: number): string {
|
|
40
|
+
if (bytes < 1024) return `${bytes} B`
|
|
41
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
|
42
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Trigger a download via a temporary anchor. Uses the `download` attribute so
|
|
47
|
+
* same-origin URLs save with the suggested filename; cross-origin URLs that
|
|
48
|
+
* don't honour the attribute will navigate to the file in a new tab, where
|
|
49
|
+
* the user can still right-click to save.
|
|
50
|
+
*/
|
|
51
|
+
function triggerDownload(url: string, filename?: string) {
|
|
52
|
+
if (typeof document === 'undefined') return
|
|
53
|
+
const a = document.createElement('a')
|
|
54
|
+
a.href = url
|
|
55
|
+
if (filename) a.download = filename
|
|
56
|
+
a.target = '_blank'
|
|
57
|
+
a.rel = 'noreferrer'
|
|
58
|
+
document.body.appendChild(a)
|
|
59
|
+
a.click()
|
|
60
|
+
document.body.removeChild(a)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* ImageLightbox — modal preview of a full-resolution image with a download
|
|
65
|
+
* affordance. Right-click on the image works natively (browser "Save image
|
|
66
|
+
* as…") because it's a regular `<img src>`.
|
|
67
|
+
*/
|
|
68
|
+
export function ImageLightbox({
|
|
69
|
+
isOpen,
|
|
70
|
+
onDismiss,
|
|
71
|
+
src,
|
|
72
|
+
alt,
|
|
73
|
+
downloadFilename,
|
|
74
|
+
title,
|
|
75
|
+
meta,
|
|
76
|
+
}: ImageLightboxProps) {
|
|
77
|
+
const headingText = title ?? downloadFilename ?? 'Image'
|
|
78
|
+
const altText = alt ?? downloadFilename ?? 'Image preview'
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<Modal isOpen={isOpen} closeOnOverlayClick={true} onDismiss={onDismiss}>
|
|
82
|
+
<Modal.Container className={cx('byline-image-lightbox-container', styles.container)}>
|
|
83
|
+
<Modal.Header className={cx('byline-image-lightbox-header', styles.header)}>
|
|
84
|
+
<div className={cx('byline-image-lightbox-title-stack', styles['title-stack'])}>
|
|
85
|
+
<h3 className={cx('byline-image-lightbox-title', styles.title)}>{headingText}</h3>
|
|
86
|
+
{meta?.width != null && meta?.height != null && (
|
|
87
|
+
<p className={cx('byline-image-lightbox-subtitle', styles.subtitle)}>
|
|
88
|
+
{meta.width}×{meta.height}
|
|
89
|
+
{meta.fileSize != null ? ` · ${formatFileSize(meta.fileSize)}` : ''}
|
|
90
|
+
{meta.mimeType ? ` · ${meta.mimeType}` : ''}
|
|
91
|
+
</p>
|
|
92
|
+
)}
|
|
93
|
+
</div>
|
|
94
|
+
<div className={cx('byline-image-lightbox-actions', styles['header-actions'])}>
|
|
95
|
+
<IconButton
|
|
96
|
+
onClick={() => triggerDownload(src, downloadFilename)}
|
|
97
|
+
size="xs"
|
|
98
|
+
aria-label="Download original"
|
|
99
|
+
>
|
|
100
|
+
<DownloadIcon width="15px" height="15px" />
|
|
101
|
+
</IconButton>
|
|
102
|
+
<IconButton onClick={onDismiss} size="xs" aria-label="Close preview">
|
|
103
|
+
<CloseIcon width="15px" height="15px" />
|
|
104
|
+
</IconButton>
|
|
105
|
+
</div>
|
|
106
|
+
</Modal.Header>
|
|
107
|
+
|
|
108
|
+
<Modal.Content className={cx('byline-image-lightbox-content', styles.content)}>
|
|
109
|
+
<img
|
|
110
|
+
src={src}
|
|
111
|
+
alt={altText}
|
|
112
|
+
className={cx('byline-image-lightbox-image', styles.image)}
|
|
113
|
+
/>
|
|
114
|
+
</Modal.Content>
|
|
115
|
+
</Modal.Container>
|
|
116
|
+
</Modal>
|
|
117
|
+
)
|
|
118
|
+
}
|