@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,46 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* MiddleEllipsis - Finder/Quick-Look-style middle truncation for filenames.
|
|
5
|
+
*
|
|
6
|
+
* macOS truncates a too-long filename in the MIDDLE so the start AND the
|
|
7
|
+
* extension stay readable ("screencapture-referee…very-long-name.png"),
|
|
8
|
+
* unlike a plain CSS `truncate` which clips the (most meaningful) extension.
|
|
9
|
+
*
|
|
10
|
+
* This is a CSS-only technique — no JS measurement, no ResizeObserver:
|
|
11
|
+
* - The "head" span flex-shrinks and truncates with an end-ellipsis.
|
|
12
|
+
* - The "tail" span is pinned (flex-shrink-0) and always shows the last
|
|
13
|
+
* `tailChars` characters, which include the extension.
|
|
14
|
+
* When the name fits, the head shows in full and no ellipsis appears; when it
|
|
15
|
+
* overflows, the head clips while the tail (extension) is preserved.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
19
|
+
|
|
20
|
+
interface MiddleEllipsisProps {
|
|
21
|
+
/** Full text to display. The container caps the width; CSS does the rest. */
|
|
22
|
+
text: string;
|
|
23
|
+
/**
|
|
24
|
+
* How many trailing characters to keep pinned (and thus always visible).
|
|
25
|
+
* Sized to comfortably cover an extension plus a little of the name tail.
|
|
26
|
+
*/
|
|
27
|
+
tailChars?: number;
|
|
28
|
+
className?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function MiddleEllipsis({ text, tailChars = 8, className }: MiddleEllipsisProps) {
|
|
32
|
+
// Split so the tail keeps the extension visible. Guard against short names
|
|
33
|
+
// (where head/tail would overlap) by rendering them whole in the head.
|
|
34
|
+
const splitAt = Math.max(0, text.length - tailChars);
|
|
35
|
+
const head = text.slice(0, splitAt);
|
|
36
|
+
const tail = text.slice(splitAt);
|
|
37
|
+
const short = splitAt === 0;
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
// `title` gives the full, lossless name on hover.
|
|
41
|
+
<span className={cn('flex min-w-0 items-center', className)} title={text}>
|
|
42
|
+
<span className="min-w-0 flex-1 truncate">{short ? text : head}</span>
|
|
43
|
+
{!short && <span className="flex-shrink-0 whitespace-pre">{tail}</span>}
|
|
44
|
+
</span>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ImageViewer components - Public API
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export { ImageViewer } from './ImageViewer';
|
|
6
|
+
export { ImageToolbar } from './ImageToolbar';
|
|
7
|
+
export { ImageInfo } from './ImageInfo';
|
|
8
|
+
export { LightboxChrome } from './LightboxChrome';
|
|
9
|
+
export { MiddleEllipsis } from './MiddleEllipsis';
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ImageViewer hooks - Public API
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export { useImageTransform } from './useImageTransform';
|
|
6
|
+
export type { UseImageTransformOptions, UseImageTransformReturn } from './useImageTransform';
|
|
7
|
+
|
|
8
|
+
export { useImageLoading } from './useImageLoading';
|
|
9
|
+
export type { UseImageLoadingOptions, UseImageLoadingReturn } from './useImageLoading';
|
|
10
|
+
|
|
11
|
+
export { useIdleChrome } from './useIdleChrome';
|
|
12
|
+
export type { UseIdleChromeReturn } from './useIdleChrome';
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* useIdleChrome - Auto-hides floating lightbox chrome when the pointer rests.
|
|
5
|
+
*
|
|
6
|
+
* macOS Quick Look / Preview hide their controls after a short idle period and
|
|
7
|
+
* bring them back on the slightest pointer movement. This hook tracks that
|
|
8
|
+
* "active" flag and gives back the pointer handlers to wire onto the canvas.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
12
|
+
|
|
13
|
+
import { CHROME_IDLE_MS } from '../utils';
|
|
14
|
+
|
|
15
|
+
export interface UseIdleChromeReturn {
|
|
16
|
+
/** True while the chrome should be visible (recent pointer activity). */
|
|
17
|
+
active: boolean;
|
|
18
|
+
/** Pointer-move handler — call to mark activity (resets the idle timer). */
|
|
19
|
+
onActivity: () => void;
|
|
20
|
+
/** Pin the chrome open (e.g. while hovering a control) regardless of idle. */
|
|
21
|
+
setPinned: (pinned: boolean) => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function useIdleChrome(enabled: boolean): UseIdleChromeReturn {
|
|
25
|
+
const [active, setActive] = useState(true);
|
|
26
|
+
const pinnedRef = useRef(false);
|
|
27
|
+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
28
|
+
|
|
29
|
+
const clear = useCallback(() => {
|
|
30
|
+
if (timerRef.current) {
|
|
31
|
+
clearTimeout(timerRef.current);
|
|
32
|
+
timerRef.current = null;
|
|
33
|
+
}
|
|
34
|
+
}, []);
|
|
35
|
+
|
|
36
|
+
const schedule = useCallback(() => {
|
|
37
|
+
clear();
|
|
38
|
+
if (!enabled) return;
|
|
39
|
+
timerRef.current = setTimeout(() => {
|
|
40
|
+
if (!pinnedRef.current) setActive(false);
|
|
41
|
+
}, CHROME_IDLE_MS);
|
|
42
|
+
}, [clear, enabled]);
|
|
43
|
+
|
|
44
|
+
const onActivity = useCallback(() => {
|
|
45
|
+
setActive(true);
|
|
46
|
+
schedule();
|
|
47
|
+
}, [schedule]);
|
|
48
|
+
|
|
49
|
+
const setPinned = useCallback(
|
|
50
|
+
(pinned: boolean) => {
|
|
51
|
+
pinnedRef.current = pinned;
|
|
52
|
+
if (pinned) {
|
|
53
|
+
clear();
|
|
54
|
+
setActive(true);
|
|
55
|
+
} else {
|
|
56
|
+
schedule();
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
[clear, schedule],
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
// Start the idle countdown on mount; chrome shows briefly then fades.
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (!enabled) {
|
|
65
|
+
setActive(true);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
schedule();
|
|
69
|
+
return clear;
|
|
70
|
+
}, [enabled, schedule, clear]);
|
|
71
|
+
|
|
72
|
+
return { active, onActivity, setPinned };
|
|
73
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* useImageLoading - Manages image loading state with LQIP
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { useEffect, useRef, useState } from 'react';
|
|
8
|
+
|
|
9
|
+
import { generateContentKey, useMediaCacheStore } from '../../internal/media-cache';
|
|
10
|
+
import {
|
|
11
|
+
createLQIP, imageDebug, MAX_IMAGE_SIZE, PROGRESSIVE_LOADING_THRESHOLD, WARNING_IMAGE_SIZE
|
|
12
|
+
} from '../utils';
|
|
13
|
+
|
|
14
|
+
// =============================================================================
|
|
15
|
+
// TYPES
|
|
16
|
+
// =============================================================================
|
|
17
|
+
|
|
18
|
+
export interface UseImageLoadingOptions {
|
|
19
|
+
/** Image content (ArrayBuffer or string) */
|
|
20
|
+
content: string | ArrayBuffer;
|
|
21
|
+
/** MIME type for blob creation */
|
|
22
|
+
mimeType?: string;
|
|
23
|
+
/**
|
|
24
|
+
* Direct image URL (bypasses content→blob conversion).
|
|
25
|
+
* When provided, content is ignored and URL is used directly.
|
|
26
|
+
*/
|
|
27
|
+
src?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface UseImageLoadingReturn {
|
|
31
|
+
/** Blob URL source for the image */
|
|
32
|
+
src: string | null;
|
|
33
|
+
/** Low-quality placeholder URL */
|
|
34
|
+
lqip: string | null;
|
|
35
|
+
/** Whether full image is loaded */
|
|
36
|
+
isFullyLoaded: boolean;
|
|
37
|
+
/** Whether to use progressive loading */
|
|
38
|
+
useProgressiveLoading: boolean;
|
|
39
|
+
/** Error message if any */
|
|
40
|
+
error: string | null;
|
|
41
|
+
/** Content key for caching */
|
|
42
|
+
contentKey: string | null;
|
|
43
|
+
/** Image size in bytes */
|
|
44
|
+
size: number;
|
|
45
|
+
/** Whether content exists */
|
|
46
|
+
hasContent: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// =============================================================================
|
|
50
|
+
// HOOK
|
|
51
|
+
// =============================================================================
|
|
52
|
+
|
|
53
|
+
export function useImageLoading(options: UseImageLoadingOptions): UseImageLoadingReturn {
|
|
54
|
+
const { content, mimeType, src: directSrc } = options;
|
|
55
|
+
|
|
56
|
+
// Get stable function references from store (not from hook to avoid re-renders)
|
|
57
|
+
const getOrCreateBlobUrl = useMediaCacheStore.getState().getOrCreateBlobUrl;
|
|
58
|
+
const releaseBlobUrl = useMediaCacheStore.getState().releaseBlobUrl;
|
|
59
|
+
|
|
60
|
+
const [src, setSrc] = useState<string | null>(null);
|
|
61
|
+
const [lqip, setLqip] = useState<string | null>(null);
|
|
62
|
+
const [isFullyLoaded, setIsFullyLoaded] = useState(false);
|
|
63
|
+
const [error, setError] = useState<string | null>(null);
|
|
64
|
+
|
|
65
|
+
const contentKeyRef = useRef<string | null>(null);
|
|
66
|
+
const isMountedRef = useRef(true);
|
|
67
|
+
|
|
68
|
+
// Calculate size and flags
|
|
69
|
+
const size = content ? (typeof content === 'string' ? content.length : content.byteLength) : 0;
|
|
70
|
+
// When directSrc is provided, we have content (the URL itself)
|
|
71
|
+
const hasContent = directSrc ? true : size > 0;
|
|
72
|
+
const useProgressiveLoading = directSrc ? false : size > PROGRESSIVE_LOADING_THRESHOLD;
|
|
73
|
+
|
|
74
|
+
// Track unmount for cleanup
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
isMountedRef.current = true;
|
|
77
|
+
return () => {
|
|
78
|
+
isMountedRef.current = false;
|
|
79
|
+
// Release blob URL only on actual unmount
|
|
80
|
+
if (contentKeyRef.current) {
|
|
81
|
+
useMediaCacheStore.getState().releaseBlobUrl(contentKeyRef.current);
|
|
82
|
+
contentKeyRef.current = null;
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
}, []);
|
|
86
|
+
|
|
87
|
+
// Create blob URL with caching and size validation
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
// Reset error state
|
|
90
|
+
setError(null);
|
|
91
|
+
|
|
92
|
+
// Direct URL mode - use as-is without blob conversion
|
|
93
|
+
if (directSrc) {
|
|
94
|
+
imageDebug.load(directSrc, 'url');
|
|
95
|
+
setSrc(directSrc);
|
|
96
|
+
setIsFullyLoaded(true);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (!hasContent) {
|
|
101
|
+
setSrc(null);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Size validation - reject oversized images
|
|
106
|
+
if (size > MAX_IMAGE_SIZE) {
|
|
107
|
+
const sizeMB = (size / 1024 / 1024).toFixed(1);
|
|
108
|
+
const errorMsg = `Image too large: ${sizeMB}MB (maximum: 50MB)`;
|
|
109
|
+
imageDebug.error(errorMsg, { size, sizeMB, maxSize: MAX_IMAGE_SIZE });
|
|
110
|
+
setError(errorMsg);
|
|
111
|
+
setSrc(null);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Warn about large images
|
|
116
|
+
if (size > WARNING_IMAGE_SIZE) {
|
|
117
|
+
const sizeMB = (size / 1024 / 1024).toFixed(1);
|
|
118
|
+
imageDebug.warn(`Large image: ${sizeMB}MB - may impact performance`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Handle string content (data URLs or binary strings)
|
|
122
|
+
if (typeof content === 'string') {
|
|
123
|
+
// Pass through data URLs directly
|
|
124
|
+
if (content.startsWith('data:')) {
|
|
125
|
+
imageDebug.load(content.slice(0, 50) + '...', 'data-url');
|
|
126
|
+
setSrc(content);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Convert binary string to ArrayBuffer and use Blob URL
|
|
131
|
+
const encoder = new TextEncoder();
|
|
132
|
+
const buffer = encoder.encode(content).buffer;
|
|
133
|
+
const contentKey = generateContentKey(buffer);
|
|
134
|
+
|
|
135
|
+
// Release previous blob URL if content changed
|
|
136
|
+
if (contentKeyRef.current && contentKeyRef.current !== contentKey) {
|
|
137
|
+
releaseBlobUrl(contentKeyRef.current);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
contentKeyRef.current = contentKey;
|
|
141
|
+
const url = getOrCreateBlobUrl(contentKey, buffer, mimeType || 'image/png');
|
|
142
|
+
imageDebug.load(url, 'blob');
|
|
143
|
+
imageDebug.state('loaded', { size, mimeType, contentKey });
|
|
144
|
+
setSrc(url);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Handle ArrayBuffer with cached blob URL
|
|
149
|
+
const contentKey = generateContentKey(content);
|
|
150
|
+
|
|
151
|
+
// Release previous blob URL if content changed
|
|
152
|
+
if (contentKeyRef.current && contentKeyRef.current !== contentKey) {
|
|
153
|
+
releaseBlobUrl(contentKeyRef.current);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
contentKeyRef.current = contentKey;
|
|
157
|
+
const url = getOrCreateBlobUrl(contentKey, content, mimeType || 'image/png');
|
|
158
|
+
imageDebug.load(url, 'blob');
|
|
159
|
+
imageDebug.state('loaded', { size, mimeType, contentKey });
|
|
160
|
+
setSrc(url);
|
|
161
|
+
|
|
162
|
+
// No cleanup here - cleanup happens in unmount effect above
|
|
163
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
164
|
+
}, [content, mimeType, hasContent, size, directSrc]);
|
|
165
|
+
|
|
166
|
+
// Create LQIP for progressive loading
|
|
167
|
+
useEffect(() => {
|
|
168
|
+
if (!src || !useProgressiveLoading) {
|
|
169
|
+
setLqip(null);
|
|
170
|
+
setIsFullyLoaded(true);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
setLqip(null);
|
|
175
|
+
setIsFullyLoaded(false);
|
|
176
|
+
imageDebug.state('progressive loading', { size });
|
|
177
|
+
|
|
178
|
+
// Guard against state updates after src change / unmount
|
|
179
|
+
let cancelled = false;
|
|
180
|
+
|
|
181
|
+
// Create low-quality placeholder
|
|
182
|
+
createLQIP(src).then((placeholder) => {
|
|
183
|
+
if (cancelled || !isMountedRef.current) return;
|
|
184
|
+
if (placeholder) {
|
|
185
|
+
imageDebug.debug('LQIP created');
|
|
186
|
+
setLqip(placeholder);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// Pre-load full image
|
|
191
|
+
const img = new Image();
|
|
192
|
+
img.onload = () => {
|
|
193
|
+
if (cancelled || !isMountedRef.current) return;
|
|
194
|
+
imageDebug.state('fully loaded');
|
|
195
|
+
setIsFullyLoaded(true);
|
|
196
|
+
};
|
|
197
|
+
img.onerror = () => {
|
|
198
|
+
imageDebug.error('Failed to load full image');
|
|
199
|
+
};
|
|
200
|
+
img.src = src;
|
|
201
|
+
|
|
202
|
+
return () => {
|
|
203
|
+
cancelled = true;
|
|
204
|
+
img.onload = null;
|
|
205
|
+
img.onerror = null;
|
|
206
|
+
img.src = '';
|
|
207
|
+
};
|
|
208
|
+
}, [src, useProgressiveLoading, size]);
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
src,
|
|
212
|
+
lqip,
|
|
213
|
+
isFullyLoaded,
|
|
214
|
+
useProgressiveLoading,
|
|
215
|
+
error,
|
|
216
|
+
contentKey: contentKeyRef.current,
|
|
217
|
+
size,
|
|
218
|
+
hasContent,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* useImageTransform - Manages image rotation and flip state
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
8
|
+
|
|
9
|
+
import { DEFAULT_TRANSFORM } from '../utils';
|
|
10
|
+
|
|
11
|
+
import type { ImageTransform } from '../types';
|
|
12
|
+
// =============================================================================
|
|
13
|
+
// TYPES
|
|
14
|
+
// =============================================================================
|
|
15
|
+
|
|
16
|
+
export interface UseImageTransformOptions {
|
|
17
|
+
/** Reset transform when this key changes */
|
|
18
|
+
resetKey?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface UseImageTransformReturn {
|
|
22
|
+
/** Current transform state */
|
|
23
|
+
transform: ImageTransform;
|
|
24
|
+
/** Rotate 90 degrees clockwise */
|
|
25
|
+
rotate: () => void;
|
|
26
|
+
/** Toggle horizontal flip */
|
|
27
|
+
flipH: () => void;
|
|
28
|
+
/** Toggle vertical flip */
|
|
29
|
+
flipV: () => void;
|
|
30
|
+
/** Reset all transforms */
|
|
31
|
+
reset: () => void;
|
|
32
|
+
/** CSS transform string for applying to image */
|
|
33
|
+
transformStyle: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// =============================================================================
|
|
37
|
+
// HOOK
|
|
38
|
+
// =============================================================================
|
|
39
|
+
|
|
40
|
+
export function useImageTransform(
|
|
41
|
+
options: UseImageTransformOptions = {}
|
|
42
|
+
): UseImageTransformReturn {
|
|
43
|
+
const { resetKey } = options;
|
|
44
|
+
|
|
45
|
+
const [transform, setTransform] = useState<ImageTransform>(DEFAULT_TRANSFORM);
|
|
46
|
+
|
|
47
|
+
// Reset transform when key changes
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
setTransform(DEFAULT_TRANSFORM);
|
|
50
|
+
}, [resetKey]);
|
|
51
|
+
|
|
52
|
+
const rotate = useCallback(() => {
|
|
53
|
+
setTransform((prev) => ({
|
|
54
|
+
...prev,
|
|
55
|
+
rotation: (prev.rotation + 90) % 360,
|
|
56
|
+
}));
|
|
57
|
+
}, []);
|
|
58
|
+
|
|
59
|
+
const flipH = useCallback(() => {
|
|
60
|
+
setTransform((prev) => ({
|
|
61
|
+
...prev,
|
|
62
|
+
flipH: !prev.flipH,
|
|
63
|
+
}));
|
|
64
|
+
}, []);
|
|
65
|
+
|
|
66
|
+
const flipV = useCallback(() => {
|
|
67
|
+
setTransform((prev) => ({
|
|
68
|
+
...prev,
|
|
69
|
+
flipV: !prev.flipV,
|
|
70
|
+
}));
|
|
71
|
+
}, []);
|
|
72
|
+
|
|
73
|
+
const reset = useCallback(() => {
|
|
74
|
+
setTransform(DEFAULT_TRANSFORM);
|
|
75
|
+
}, []);
|
|
76
|
+
|
|
77
|
+
// Build CSS transform string
|
|
78
|
+
const transformStyle = useMemo(() => {
|
|
79
|
+
const transforms: string[] = [];
|
|
80
|
+
|
|
81
|
+
if (transform.rotation !== 0) {
|
|
82
|
+
transforms.push(`rotate(${transform.rotation}deg)`);
|
|
83
|
+
}
|
|
84
|
+
if (transform.flipH) {
|
|
85
|
+
transforms.push('scaleX(-1)');
|
|
86
|
+
}
|
|
87
|
+
if (transform.flipV) {
|
|
88
|
+
transforms.push('scaleY(-1)');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return transforms.join(' ') || 'none';
|
|
92
|
+
}, [transform]);
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
transform,
|
|
96
|
+
rotate,
|
|
97
|
+
flipH,
|
|
98
|
+
flipV,
|
|
99
|
+
reset,
|
|
100
|
+
transformStyle,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ImageViewer - Image viewer with zoom, pan, rotate, flip
|
|
3
|
+
*
|
|
4
|
+
* Features:
|
|
5
|
+
* - Zoom with mouse wheel and presets (25%, 50%, 100%, 200%, 400%)
|
|
6
|
+
* - Pan with drag
|
|
7
|
+
* - Rotate 90°
|
|
8
|
+
* - Flip horizontal/vertical
|
|
9
|
+
* - Fullscreen dialog mode
|
|
10
|
+
* - Keyboard shortcuts (+/-, 0 for reset, r for rotate)
|
|
11
|
+
* - Progressive loading with LQIP for large images
|
|
12
|
+
* - Checkerboard background for transparency
|
|
13
|
+
* - Image dimensions display with caching
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// =============================================================================
|
|
17
|
+
// COMPONENTS
|
|
18
|
+
// =============================================================================
|
|
19
|
+
|
|
20
|
+
export { ImageViewer, ImageToolbar, ImageInfo } from './components';
|
|
21
|
+
|
|
22
|
+
// The lazy wrapper is what every caller here imports: the viewer carries
|
|
23
|
+
// react-zoom-pan-pinch, and no chat turn needs it until a reader opens a
|
|
24
|
+
// picture. Exported from the same entry so choosing it costs no extra subpath.
|
|
25
|
+
export { LazyImageViewer } from './lazy';
|
|
26
|
+
|
|
27
|
+
// =============================================================================
|
|
28
|
+
// HOOKS
|
|
29
|
+
// =============================================================================
|
|
30
|
+
|
|
31
|
+
export { useImageTransform, useImageLoading } from './hooks';
|
|
32
|
+
export type {
|
|
33
|
+
UseImageTransformOptions,
|
|
34
|
+
UseImageTransformReturn,
|
|
35
|
+
UseImageLoadingOptions,
|
|
36
|
+
UseImageLoadingReturn,
|
|
37
|
+
} from './hooks';
|
|
38
|
+
|
|
39
|
+
// =============================================================================
|
|
40
|
+
// TYPES
|
|
41
|
+
// =============================================================================
|
|
42
|
+
|
|
43
|
+
export type {
|
|
44
|
+
ImageFile,
|
|
45
|
+
ImageViewerProps,
|
|
46
|
+
ImageToolbarProps,
|
|
47
|
+
ImageInfoProps,
|
|
48
|
+
ImageTransform,
|
|
49
|
+
ZoomPreset,
|
|
50
|
+
} from './types';
|
|
51
|
+
|
|
52
|
+
// =============================================================================
|
|
53
|
+
// UTILS
|
|
54
|
+
// =============================================================================
|
|
55
|
+
|
|
56
|
+
export {
|
|
57
|
+
createLQIP,
|
|
58
|
+
MAX_IMAGE_SIZE,
|
|
59
|
+
WARNING_IMAGE_SIZE,
|
|
60
|
+
PROGRESSIVE_LOADING_THRESHOLD,
|
|
61
|
+
MIN_ZOOM,
|
|
62
|
+
MAX_ZOOM,
|
|
63
|
+
ZOOM_PRESETS,
|
|
64
|
+
DEFAULT_TRANSFORM,
|
|
65
|
+
} from './utils';
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Lazy-loaded ImageViewer.
|
|
5
|
+
*
|
|
6
|
+
* The viewer carries react-zoom-pan-pinch (~50KB), which no chat turn needs
|
|
7
|
+
* until a reader actually opens a picture — so it is split out and mounted on
|
|
8
|
+
* demand rather than shipped in the first paint.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { lazy, Suspense } from 'react';
|
|
12
|
+
|
|
13
|
+
import type { ImageViewerProps } from './types';
|
|
14
|
+
|
|
15
|
+
export type { ImageViewerProps };
|
|
16
|
+
|
|
17
|
+
const ImageViewerImpl = lazy(() =>
|
|
18
|
+
import('./components').then((mod) => ({ default: mod.ImageViewer })),
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
// Reserves the viewer's minimum height while the chunk arrives: a zero-height
|
|
22
|
+
// fallback collapses the dialog and the image then pops it back open.
|
|
23
|
+
function Fallback() {
|
|
24
|
+
return <div aria-hidden className="min-h-[200px]" />;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function LazyImageViewer(props: ImageViewerProps) {
|
|
28
|
+
return (
|
|
29
|
+
<Suspense fallback={<Fallback />}>
|
|
30
|
+
<ImageViewerImpl {...props} />
|
|
31
|
+
</Suspense>
|
|
32
|
+
);
|
|
33
|
+
}
|