@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.
@@ -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";