@djangocfg/widget-media 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +58 -0
- package/package.json +81 -0
- package/src/code/code.tsx +116 -0
- package/src/code/index.ts +10 -0
- package/src/diagram/diagram-source.tsx +29 -0
- package/src/diagram/diagram.tsx +64 -0
- package/src/diagram/index.ts +22 -0
- package/src/diagram/lazy.tsx +45 -0
- package/src/diagram/plugin.ts +62 -0
- package/src/diagram/render.ts +328 -0
- package/src/i18n/index.ts +60 -0
- package/src/image-viewer/components/ImageInfo.tsx +55 -0
- package/src/image-viewer/components/ImageToolbar.tsx +167 -0
- package/src/image-viewer/components/ImageViewer.tsx +508 -0
- package/src/image-viewer/components/LightboxChrome.tsx +157 -0
- package/src/image-viewer/components/MiddleEllipsis.tsx +46 -0
- package/src/image-viewer/components/index.ts +9 -0
- package/src/image-viewer/hooks/index.ts +12 -0
- package/src/image-viewer/hooks/useIdleChrome.ts +73 -0
- package/src/image-viewer/hooks/useImageLoading.ts +220 -0
- package/src/image-viewer/hooks/useImageTransform.ts +102 -0
- package/src/image-viewer/index.ts +65 -0
- package/src/image-viewer/lazy.tsx +33 -0
- package/src/image-viewer/types.ts +139 -0
- package/src/image-viewer/utils/constants.ts +90 -0
- package/src/image-viewer/utils/debug.ts +14 -0
- package/src/image-viewer/utils/index.ts +22 -0
- package/src/image-viewer/utils/lqip.ts +47 -0
- package/src/index.ts +10 -0
- package/src/internal/media-cache.ts +537 -0
- package/src/markdown/index.ts +6 -0
- package/src/markdown/markdown.tsx +113 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ImageViewer type definitions
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { MediaCopy } from '../i18n';
|
|
6
|
+
|
|
7
|
+
// =============================================================================
|
|
8
|
+
// FILE TYPES
|
|
9
|
+
// =============================================================================
|
|
10
|
+
|
|
11
|
+
export interface ImageFile {
|
|
12
|
+
/** Display name for the image */
|
|
13
|
+
name: string;
|
|
14
|
+
/** File path used for change detection and caching */
|
|
15
|
+
path: string;
|
|
16
|
+
/** Optional MIME type */
|
|
17
|
+
mimeType?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ImageItem {
|
|
21
|
+
/** File info for this image */
|
|
22
|
+
file: ImageFile;
|
|
23
|
+
/** Direct image URL (use when serving from CDN/server) */
|
|
24
|
+
src?: string;
|
|
25
|
+
/**
|
|
26
|
+
* Where the SAVE button reads its bytes, when that is not `src`.
|
|
27
|
+
*
|
|
28
|
+
* A browser honours `<a download>` only for a same-origin address; given a
|
|
29
|
+
* foreign one it silently drops both the attribute and the filename and
|
|
30
|
+
* navigates instead, so the picture opens in a tab. A host that can serve the
|
|
31
|
+
* same bytes from its own origin supplies that address here.
|
|
32
|
+
*
|
|
33
|
+
* Absent means `src` is already savable — the ordinary case for a blob this
|
|
34
|
+
* page hosts.
|
|
35
|
+
*/
|
|
36
|
+
downloadSrc?: string;
|
|
37
|
+
/** Raw image content — ArrayBuffer or base64/binary string (use when serving from memory) */
|
|
38
|
+
content?: string | ArrayBuffer;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// =============================================================================
|
|
42
|
+
// COMPONENT PROPS
|
|
43
|
+
// =============================================================================
|
|
44
|
+
|
|
45
|
+
export interface ImageViewerProps {
|
|
46
|
+
/**
|
|
47
|
+
* Gallery images array.
|
|
48
|
+
* When provided, enables gallery mode with prev/next navigation.
|
|
49
|
+
* Single-image mode: pass one item.
|
|
50
|
+
* Each item can have `src` (URL) or `content` (ArrayBuffer/base64) or both.
|
|
51
|
+
*/
|
|
52
|
+
images: ImageItem[];
|
|
53
|
+
/**
|
|
54
|
+
* Initial index to show (default: 0).
|
|
55
|
+
* Useful when opening a specific image from a grid.
|
|
56
|
+
*/
|
|
57
|
+
initialIndex?: number;
|
|
58
|
+
/** Hide expand button when already in dialog */
|
|
59
|
+
inDialog?: boolean;
|
|
60
|
+
/**
|
|
61
|
+
* Render the restrained macOS-grade fullscreen lightbox chrome instead of
|
|
62
|
+
* the embedded editing toolbar: a clean dark backdrop (no checkerboard),
|
|
63
|
+
* idle-fading floating controls (close + prev/next + status pill), and no
|
|
64
|
+
* rotate/flip slab. Intended for the click-to-expand image lightbox.
|
|
65
|
+
* Implies `inDialog`.
|
|
66
|
+
*/
|
|
67
|
+
lightbox?: boolean;
|
|
68
|
+
/** Close request from the lightbox chrome (Esc / close button). */
|
|
69
|
+
onRequestClose?: () => void;
|
|
70
|
+
/**
|
|
71
|
+
* Fraction of the viewport the image may occupy at rest, in the LIGHTBOX
|
|
72
|
+
* presentation only (0 < fitScale ≤ 1). `1` (default) lets the image fill
|
|
73
|
+
* edge-to-edge; `0.8` caps it at 80% so it opens with a comfortable margin
|
|
74
|
+
* (Preview / Quick Look style) instead of slamming into the window edges.
|
|
75
|
+
* Zoom / pan still operate from this fitted size. Ignored in the embedded
|
|
76
|
+
* (non-lightbox) presentation.
|
|
77
|
+
*/
|
|
78
|
+
fitScale?: number;
|
|
79
|
+
/** Focus the viewer container on mount so its keyboard scope is active
|
|
80
|
+
* immediately (zoom/rotate/gallery hotkeys). Pair with `key={src}`
|
|
81
|
+
* upstream when the parent wants a fresh focus per source change. */
|
|
82
|
+
autoFocus?: boolean;
|
|
83
|
+
/**
|
|
84
|
+
* Override any subset of the widget's English copy. Keys omitted fall back
|
|
85
|
+
* to the defaults, so a host translating one label does not blank the rest.
|
|
86
|
+
*/
|
|
87
|
+
copy?: Partial<MediaCopy>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface ImageToolbarProps {
|
|
91
|
+
/** Current zoom scale */
|
|
92
|
+
scale: number;
|
|
93
|
+
/** Current transform state */
|
|
94
|
+
transform: ImageTransform;
|
|
95
|
+
/** Rotate image callback */
|
|
96
|
+
onRotate: () => void;
|
|
97
|
+
/** Flip horizontal callback */
|
|
98
|
+
onFlipH: () => void;
|
|
99
|
+
/** Flip vertical callback */
|
|
100
|
+
onFlipV: () => void;
|
|
101
|
+
/** Zoom preset selection callback */
|
|
102
|
+
onZoomPreset: (value: number | 'fit') => void;
|
|
103
|
+
/** Expand to fullscreen callback (undefined hides button) */
|
|
104
|
+
onExpand?: () => void;
|
|
105
|
+
/**
|
|
106
|
+
* Override any subset of the widget's English copy. Keys omitted fall back
|
|
107
|
+
* to the defaults, so a host translating one label does not blank the rest.
|
|
108
|
+
*/
|
|
109
|
+
copy?: Partial<MediaCopy>;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface ImageInfoProps {
|
|
113
|
+
/** Image source URL */
|
|
114
|
+
src: string;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// =============================================================================
|
|
118
|
+
// STATE TYPES
|
|
119
|
+
// =============================================================================
|
|
120
|
+
|
|
121
|
+
export interface ImageTransform {
|
|
122
|
+
/** Rotation angle: 0, 90, 180, or 270 degrees */
|
|
123
|
+
rotation: number;
|
|
124
|
+
/** Horizontal flip state */
|
|
125
|
+
flipH: boolean;
|
|
126
|
+
/** Vertical flip state */
|
|
127
|
+
flipV: boolean;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// =============================================================================
|
|
131
|
+
// UI TYPES
|
|
132
|
+
// =============================================================================
|
|
133
|
+
|
|
134
|
+
export interface ZoomPreset {
|
|
135
|
+
/** Display label (e.g., "100%", "Fit") */
|
|
136
|
+
label: string;
|
|
137
|
+
/** Zoom value or 'fit' for fit-to-view */
|
|
138
|
+
value: number | 'fit';
|
|
139
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ImageViewer constants
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ZoomPreset, ImageTransform } from '../types';
|
|
6
|
+
|
|
7
|
+
// =============================================================================
|
|
8
|
+
// SIZE LIMITS
|
|
9
|
+
// =============================================================================
|
|
10
|
+
|
|
11
|
+
/** Maximum image size before blocking (50MB) */
|
|
12
|
+
export const MAX_IMAGE_SIZE = 50 * 1024 * 1024;
|
|
13
|
+
|
|
14
|
+
/** Image size threshold for warning (10MB) */
|
|
15
|
+
export const WARNING_IMAGE_SIZE = 10 * 1024 * 1024;
|
|
16
|
+
|
|
17
|
+
/** Progressive loading threshold - use LQIP for images > 500KB */
|
|
18
|
+
export const PROGRESSIVE_LOADING_THRESHOLD = 500 * 1024;
|
|
19
|
+
|
|
20
|
+
// =============================================================================
|
|
21
|
+
// LQIP CONFIGURATION
|
|
22
|
+
// =============================================================================
|
|
23
|
+
|
|
24
|
+
/** Low-quality placeholder size (32x32) */
|
|
25
|
+
export const LQIP_SIZE = 32;
|
|
26
|
+
|
|
27
|
+
/** LQIP JPEG quality */
|
|
28
|
+
export const LQIP_QUALITY = 0.5;
|
|
29
|
+
|
|
30
|
+
// =============================================================================
|
|
31
|
+
// ZOOM CONFIGURATION
|
|
32
|
+
// =============================================================================
|
|
33
|
+
|
|
34
|
+
/** Minimum zoom level */
|
|
35
|
+
export const MIN_ZOOM = 0.1;
|
|
36
|
+
|
|
37
|
+
/** Maximum zoom level */
|
|
38
|
+
export const MAX_ZOOM = 8;
|
|
39
|
+
|
|
40
|
+
/** Pixels to pan per arrow-key press */
|
|
41
|
+
export const KEYBOARD_PAN_STEP = 60;
|
|
42
|
+
|
|
43
|
+
// =============================================================================
|
|
44
|
+
// GESTURE SENSITIVITY (macOS-grade pinch / trackpad zoom)
|
|
45
|
+
// =============================================================================
|
|
46
|
+
//
|
|
47
|
+
// react-zoom-pan-pinch computes the per-event wheel zoom magnitude as
|
|
48
|
+
// `smoothStep * |event.deltaY|` (its `smooth` path), which means the scale
|
|
49
|
+
// tracks the gesture 1:1 — a harder pinch / faster wheel moves more. The
|
|
50
|
+
// library default `smoothStep` is `0.001`, which on a macOS trackpad
|
|
51
|
+
// (whose pinch `deltaY` arrives as small ~±1–6 values with `ctrlKey=true`)
|
|
52
|
+
// produces ~0.005 scale/frame — imperceptible, the "weak pinch" symptom.
|
|
53
|
+
//
|
|
54
|
+
// We raise it so a natural trackpad pinch reaches a meaningful zoom in a
|
|
55
|
+
// single gesture while a mouse-wheel-with-ctrl notch (large |deltaY| ~100)
|
|
56
|
+
// stays a sensible discrete step. The library clamps to min/max scale, so
|
|
57
|
+
// the larger coefficient cannot overshoot the bounds.
|
|
58
|
+
|
|
59
|
+
/** Wheel/trackpad-pinch zoom sensitivity. scaleΔ = WHEEL_SMOOTH_STEP * |deltaY|. */
|
|
60
|
+
export const WHEEL_SMOOTH_STEP = 0.0055;
|
|
61
|
+
|
|
62
|
+
/** Touch pinch sensitivity (two-finger pinch on touchscreens). */
|
|
63
|
+
export const PINCH_STEP = 5;
|
|
64
|
+
|
|
65
|
+
/** Double-click / double-tap zoom multiplier (fit ↔ zoomed toward the point). */
|
|
66
|
+
export const DOUBLE_CLICK_STEP = 1.4;
|
|
67
|
+
|
|
68
|
+
/** Idle delay (ms) before the lightbox chrome fades out when the pointer rests. */
|
|
69
|
+
export const CHROME_IDLE_MS = 2200;
|
|
70
|
+
|
|
71
|
+
/** Available zoom presets */
|
|
72
|
+
export const ZOOM_PRESETS: readonly ZoomPreset[] = [
|
|
73
|
+
{ label: 'Fit', value: 'fit' },
|
|
74
|
+
{ label: '25%', value: 0.25 },
|
|
75
|
+
{ label: '50%', value: 0.5 },
|
|
76
|
+
{ label: '100%', value: 1 },
|
|
77
|
+
{ label: '200%', value: 2 },
|
|
78
|
+
{ label: '400%', value: 4 },
|
|
79
|
+
] as const;
|
|
80
|
+
|
|
81
|
+
// =============================================================================
|
|
82
|
+
// DEFAULT VALUES
|
|
83
|
+
// =============================================================================
|
|
84
|
+
|
|
85
|
+
/** Default transform state */
|
|
86
|
+
export const DEFAULT_TRANSFORM: ImageTransform = {
|
|
87
|
+
rotation: 0,
|
|
88
|
+
flipH: false,
|
|
89
|
+
flipV: false,
|
|
90
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ImageViewer Debug Logger
|
|
5
|
+
*
|
|
6
|
+
* Uses universal logger with media-specific helpers.
|
|
7
|
+
* Logs go to both console (dev) and zustand store (for Console panel).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createMediaLogger } from '@djangocfg/ui-core/lib';
|
|
11
|
+
|
|
12
|
+
export const imageDebug = createMediaLogger('ImageViewer');
|
|
13
|
+
|
|
14
|
+
export default imageDebug;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ImageViewer utilities - Public API
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export { createLQIP } from './lqip';
|
|
6
|
+
export {
|
|
7
|
+
MAX_IMAGE_SIZE,
|
|
8
|
+
WARNING_IMAGE_SIZE,
|
|
9
|
+
PROGRESSIVE_LOADING_THRESHOLD,
|
|
10
|
+
LQIP_SIZE,
|
|
11
|
+
LQIP_QUALITY,
|
|
12
|
+
MIN_ZOOM,
|
|
13
|
+
MAX_ZOOM,
|
|
14
|
+
KEYBOARD_PAN_STEP,
|
|
15
|
+
WHEEL_SMOOTH_STEP,
|
|
16
|
+
PINCH_STEP,
|
|
17
|
+
DOUBLE_CLICK_STEP,
|
|
18
|
+
CHROME_IDLE_MS,
|
|
19
|
+
ZOOM_PRESETS,
|
|
20
|
+
DEFAULT_TRANSFORM,
|
|
21
|
+
} from './constants';
|
|
22
|
+
export { imageDebug } from './debug';
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LQIP (Low-Quality Image Placeholder) generator
|
|
3
|
+
*
|
|
4
|
+
* Creates a tiny blurred preview image for progressive loading.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { LQIP_QUALITY, LQIP_SIZE } from './constants';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Create a low-quality image placeholder from source URL
|
|
11
|
+
*
|
|
12
|
+
* @param imageSrc - Full quality image URL
|
|
13
|
+
* @returns Data URL of tiny preview, or null on error
|
|
14
|
+
*/
|
|
15
|
+
export async function createLQIP(imageSrc: string): Promise<string | null> {
|
|
16
|
+
try {
|
|
17
|
+
// Load the full image
|
|
18
|
+
const img = new Image();
|
|
19
|
+
img.crossOrigin = 'anonymous';
|
|
20
|
+
|
|
21
|
+
await new Promise<void>((resolve, reject) => {
|
|
22
|
+
img.onload = () => resolve();
|
|
23
|
+
img.onerror = () => reject(new Error('Failed to load image for LQIP'));
|
|
24
|
+
img.src = imageSrc;
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// Calculate aspect ratio preserving dimensions
|
|
28
|
+
const aspect = img.naturalWidth / img.naturalHeight;
|
|
29
|
+
const width = aspect >= 1 ? LQIP_SIZE : Math.round(LQIP_SIZE * aspect);
|
|
30
|
+
const height = aspect >= 1 ? Math.round(LQIP_SIZE / aspect) : LQIP_SIZE;
|
|
31
|
+
|
|
32
|
+
// Create canvas for downscaling
|
|
33
|
+
const canvas = document.createElement('canvas');
|
|
34
|
+
canvas.width = width;
|
|
35
|
+
canvas.height = height;
|
|
36
|
+
const ctx = canvas.getContext('2d');
|
|
37
|
+
if (!ctx) return null;
|
|
38
|
+
|
|
39
|
+
// Draw scaled down image
|
|
40
|
+
ctx.drawImage(img, 0, 0, width, height);
|
|
41
|
+
|
|
42
|
+
// Return as data URL (very small, ~1-2KB)
|
|
43
|
+
return canvas.toDataURL('image/jpeg', LQIP_QUALITY);
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Viewers presented inside a conversation or a file preview.
|
|
2
|
+
//
|
|
3
|
+
// The root barrel exports the image viewer ONLY. `./diagram`, `./markdown` and
|
|
4
|
+
// `./code` are subpath-only on purpose: each pulls a parser or a highlighter
|
|
5
|
+
// (mermaid ~2.5 MB, shiki, katex), and a host mounting a lightbox must not pay
|
|
6
|
+
// for any of them. A barrel re-export is a static edge — naming them here would
|
|
7
|
+
// undo the split silently.
|
|
8
|
+
export * from "./image-viewer";
|
|
9
|
+
export type { MediaCopy } from "./i18n";
|
|
10
|
+
export { DEFAULT_MEDIA_COPY } from "./i18n";
|