@inkra/react 0.1.0 → 0.2.0
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/README.md +104 -1
- package/dist/PencilIcon.js +14 -16
- package/dist/PencilLine.js +18 -12
- package/dist/PencilText.js +192 -59
- package/dist/alert-dialog/index.d.ts +64 -0
- package/dist/alert-dialog/index.js +109 -0
- package/dist/audio-player/index.d.ts +60 -0
- package/dist/audio-player/index.js +101 -0
- package/dist/carousel/index.d.ts +51 -0
- package/dist/carousel/index.js +290 -0
- package/dist/choice-grid/index.d.ts +38 -0
- package/dist/choice-grid/index.js +85 -0
- package/dist/color-picker/color.d.ts +15 -0
- package/dist/color-picker/color.js +55 -0
- package/dist/color-picker/index.d.ts +31 -0
- package/dist/color-picker/index.js +69 -0
- package/dist/command/index.d.ts +23 -0
- package/dist/command/index.js +39 -0
- package/dist/context-menu/index.d.ts +76 -0
- package/dist/context-menu/index.js +240 -0
- package/dist/data-table/index.d.ts +47 -0
- package/dist/data-table/index.js +91 -0
- package/dist/date-picker/index.d.ts +50 -0
- package/dist/date-picker/index.js +180 -0
- package/dist/display/ProgressRing.d.ts +6 -0
- package/dist/display/ProgressRing.js +146 -0
- package/dist/display/index.d.ts +27 -0
- package/dist/display/index.js +18 -2
- package/dist/dockable-panel/index.d.ts +41 -0
- package/dist/dockable-panel/index.js +253 -0
- package/dist/dockable-panel/model.d.ts +21 -0
- package/dist/dockable-panel/model.js +61 -0
- package/dist/drawer/index.d.ts +63 -0
- package/dist/drawer/index.js +86 -0
- package/dist/editable/index.d.ts +53 -0
- package/dist/editable/index.js +162 -0
- package/dist/empty/index.d.ts +21 -0
- package/dist/empty/index.js +23 -0
- package/dist/flip/index.d.ts +49 -0
- package/dist/flip/index.js +138 -0
- package/dist/form/EditablePencilText.js +157 -146
- package/dist/form/Textarea.d.ts +12 -0
- package/dist/form/Textarea.js +83 -4
- package/dist/form/index.d.ts +1 -1
- package/dist/heatmap/index.d.ts +59 -0
- package/dist/heatmap/index.js +248 -0
- package/dist/hotkey/index.d.ts +37 -0
- package/dist/hotkey/index.js +130 -0
- package/dist/hotkey/model.d.ts +27 -0
- package/dist/hotkey/model.js +97 -0
- package/dist/hover-card/index.d.ts +33 -0
- package/dist/hover-card/index.js +141 -0
- package/dist/image-cropper/index.d.ts +82 -0
- package/dist/image-cropper/index.js +261 -0
- package/dist/image-cropper/model.d.ts +42 -0
- package/dist/image-cropper/model.js +94 -0
- package/dist/image-viewer/index.d.ts +79 -0
- package/dist/image-viewer/index.js +220 -0
- package/dist/image-viewer/model.d.ts +9 -0
- package/dist/image-viewer/model.js +18 -0
- package/dist/index.d.ts +47 -0
- package/dist/index.js +47 -0
- package/dist/internal/cache.d.ts +1 -1
- package/dist/internal/cache.js +1 -1
- package/dist/internal/renderTokens.d.ts +10 -0
- package/dist/internal/renderTokens.js +13 -0
- package/dist/internal/surfaceRenderer.d.ts +14 -6
- package/dist/internal/surfaceRenderer.js +15 -12
- package/dist/internal/textRenderer.d.ts +1 -0
- package/dist/internal/textRenderer.js +33 -7
- package/dist/internal/usePencilSurface.js +16 -8
- package/dist/internal/useSelectionDrawing.js +15 -11
- package/dist/journey-map/MapScale.js +3 -1
- package/dist/journey-map/index.js +3 -1
- package/dist/keypad/index.d.ts +23 -0
- package/dist/keypad/index.js +63 -0
- package/dist/keypad/model.d.ts +20 -0
- package/dist/keypad/model.js +33 -0
- package/dist/layout/index.d.ts +63 -0
- package/dist/layout/index.js +62 -0
- package/dist/log/index.d.ts +45 -0
- package/dist/log/index.js +159 -0
- package/dist/log/model.d.ts +16 -0
- package/dist/log/model.js +13 -0
- package/dist/node-graph/index.d.ts +123 -0
- package/dist/node-graph/index.js +304 -0
- package/dist/number-ticker/index.d.ts +29 -0
- package/dist/number-ticker/index.js +132 -0
- package/dist/number-ticker/model.d.ts +8 -0
- package/dist/number-ticker/model.js +34 -0
- package/dist/overflow-list/index.d.ts +39 -0
- package/dist/overflow-list/index.js +230 -0
- package/dist/overflow-list/layout.d.ts +19 -0
- package/dist/overflow-list/layout.js +44 -0
- package/dist/pagination/index.d.ts +23 -0
- package/dist/pagination/index.js +41 -0
- package/dist/pagination/model.d.ts +4 -0
- package/dist/pagination/model.js +42 -0
- package/dist/pan-zoom/index.d.ts +71 -0
- package/dist/pan-zoom/index.js +175 -0
- package/dist/radar/index.d.ts +54 -0
- package/dist/radar/index.js +220 -0
- package/dist/range-slider/index.d.ts +16 -0
- package/dist/range-slider/index.js +22 -0
- package/dist/rating/index.d.ts +28 -0
- package/dist/rating/index.js +127 -0
- package/dist/resizable/index.d.ts +32 -0
- package/dist/resizable/index.js +50 -0
- package/dist/scroll-area/index.d.ts +36 -0
- package/dist/scroll-area/index.js +44 -0
- package/dist/segmented-progress/SegmentedProgress.d.ts +17 -0
- package/dist/segmented-progress/SegmentedProgress.js +42 -0
- package/dist/slider/index.js +1 -1
- package/dist/slot/index.d.ts +24 -0
- package/dist/slot/index.js +29 -0
- package/dist/sortable/index.d.ts +50 -0
- package/dist/sortable/index.js +278 -0
- package/dist/sortable/model.d.ts +30 -0
- package/dist/sortable/model.js +64 -0
- package/dist/sparkline/index.d.ts +19 -0
- package/dist/sparkline/index.js +240 -0
- package/dist/sparkline/model.d.ts +16 -0
- package/dist/sparkline/model.js +28 -0
- package/dist/steps/index.d.ts +16 -0
- package/dist/steps/index.js +37 -0
- package/dist/styles.css +4850 -287
- package/dist/tag-input/index.d.ts +69 -0
- package/dist/tag-input/index.js +325 -0
- package/dist/tag-input/model.d.ts +14 -0
- package/dist/tag-input/model.js +38 -0
- package/dist/time-picker/index.d.ts +15 -0
- package/dist/time-picker/index.js +168 -0
- package/dist/timeline/index.d.ts +14 -0
- package/dist/timeline/index.js +16 -0
- package/dist/toast/index.d.ts +27 -0
- package/dist/toast/index.js +36 -0
- package/dist/toggle-group/index.d.ts +17 -0
- package/dist/toggle-group/index.js +26 -0
- package/dist/tokens.css +10 -9
- package/dist/transfer/index.d.ts +64 -0
- package/dist/transfer/index.js +174 -0
- package/dist/transfer/model.d.ts +12 -0
- package/dist/transfer/model.js +33 -0
- package/dist/tree/index.d.ts +39 -0
- package/dist/tree/index.js +36 -0
- package/dist/tree-select/index.d.ts +59 -0
- package/dist/tree-select/index.js +142 -0
- package/dist/tree-select/model.d.ts +31 -0
- package/dist/tree-select/model.js +135 -0
- package/dist/typewriter/index.d.ts +21 -0
- package/dist/typewriter/index.js +101 -0
- package/dist/typewriter/model.d.ts +7 -0
- package/dist/typewriter/model.js +45 -0
- package/dist/upload/index.d.ts +59 -0
- package/dist/upload/index.js +230 -0
- package/dist/virtual-list/index.d.ts +44 -0
- package/dist/virtual-list/index.js +128 -0
- package/dist/virtual-list/model.d.ts +7 -0
- package/dist/virtual-list/model.js +27 -0
- package/package.json +22 -3
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { createContext, forwardRef, useContext, useEffect, useRef, useState, } from 'react';
|
|
3
|
+
import { Popover, PopoverClose, PopoverContent, PopoverDescription, PopoverTitle, PopoverTrigger, } from '../overlay/index.js';
|
|
4
|
+
const classes = (...values) => values.filter(Boolean).join(' ');
|
|
5
|
+
const HoverCardContext = createContext(null);
|
|
6
|
+
function useHoverCardContext(part) {
|
|
7
|
+
const context = useContext(HoverCardContext);
|
|
8
|
+
if (!context)
|
|
9
|
+
throw new Error(`${part} must be used inside HoverCard.Root.`);
|
|
10
|
+
return context;
|
|
11
|
+
}
|
|
12
|
+
export function HoverCardRoot({ children, closeDelay = 300, defaultOpen = false, onOpenChange, open, openDelay = 700, ...props }) {
|
|
13
|
+
const controlled = open !== undefined;
|
|
14
|
+
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
|
|
15
|
+
const currentOpen = controlled ? open : uncontrolledOpen;
|
|
16
|
+
const currentOpenRef = useRef(currentOpen);
|
|
17
|
+
const openReasonRef = useRef('programmatic');
|
|
18
|
+
const timerRef = useRef(null);
|
|
19
|
+
currentOpenRef.current = currentOpen;
|
|
20
|
+
const cancelPending = () => {
|
|
21
|
+
if (timerRef.current === null)
|
|
22
|
+
return;
|
|
23
|
+
clearTimeout(timerRef.current);
|
|
24
|
+
timerRef.current = null;
|
|
25
|
+
};
|
|
26
|
+
const commitOpen = (nextOpen) => {
|
|
27
|
+
cancelPending();
|
|
28
|
+
if (nextOpen === currentOpenRef.current)
|
|
29
|
+
return;
|
|
30
|
+
if (!controlled) {
|
|
31
|
+
currentOpenRef.current = nextOpen;
|
|
32
|
+
setUncontrolledOpen(nextOpen);
|
|
33
|
+
}
|
|
34
|
+
onOpenChange?.(nextOpen);
|
|
35
|
+
if (!nextOpen)
|
|
36
|
+
openReasonRef.current = 'programmatic';
|
|
37
|
+
};
|
|
38
|
+
const schedule = (nextOpen, delay, reason) => {
|
|
39
|
+
cancelPending();
|
|
40
|
+
if (nextOpen === currentOpenRef.current)
|
|
41
|
+
return;
|
|
42
|
+
openReasonRef.current = reason;
|
|
43
|
+
const safeDelay = Number.isFinite(delay) ? Math.max(0, delay) : 0;
|
|
44
|
+
if (safeDelay === 0) {
|
|
45
|
+
commitOpen(nextOpen);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
timerRef.current = setTimeout(() => {
|
|
49
|
+
timerRef.current = null;
|
|
50
|
+
commitOpen(nextOpen);
|
|
51
|
+
}, safeDelay);
|
|
52
|
+
};
|
|
53
|
+
useEffect(() => cancelPending, []);
|
|
54
|
+
return (_jsx(HoverCardContext.Provider, { value: {
|
|
55
|
+
cancelPending,
|
|
56
|
+
getOpenReason: () => openReasonRef.current,
|
|
57
|
+
markExplicitOpen: () => {
|
|
58
|
+
cancelPending();
|
|
59
|
+
openReasonRef.current = 'press';
|
|
60
|
+
},
|
|
61
|
+
scheduleClose: () => schedule(false, closeDelay, 'programmatic'),
|
|
62
|
+
scheduleOpen: reason => schedule(true, openDelay, reason),
|
|
63
|
+
}, children: _jsx(Popover.Root, { ...props, modal: false, open: currentOpen, onOpenChange: commitOpen, children: children }) }));
|
|
64
|
+
}
|
|
65
|
+
export const HoverCardTrigger = forwardRef(function HoverCardTrigger({ onBlur, onClick, onFocus, onPointerDown, onPointerEnter, onPointerLeave, ...props }, ref) {
|
|
66
|
+
const context = useHoverCardContext('HoverCard.Trigger');
|
|
67
|
+
return (_jsx(Popover.Trigger, { ...props, ref: ref, onBlur: event => {
|
|
68
|
+
onBlur?.(event);
|
|
69
|
+
if (!event.defaultPrevented)
|
|
70
|
+
context.scheduleClose();
|
|
71
|
+
}, onFocus: event => {
|
|
72
|
+
onFocus?.(event);
|
|
73
|
+
if (!event.defaultPrevented)
|
|
74
|
+
context.scheduleOpen('focus');
|
|
75
|
+
}, onClick: event => {
|
|
76
|
+
onClick?.(event);
|
|
77
|
+
if (!event.defaultPrevented)
|
|
78
|
+
context.markExplicitOpen();
|
|
79
|
+
}, onPointerDown: event => {
|
|
80
|
+
onPointerDown?.(event);
|
|
81
|
+
if (!event.defaultPrevented)
|
|
82
|
+
context.cancelPending();
|
|
83
|
+
}, onPointerEnter: event => {
|
|
84
|
+
onPointerEnter?.(event);
|
|
85
|
+
if (!event.defaultPrevented && event.pointerType !== 'touch')
|
|
86
|
+
context.scheduleOpen('hover');
|
|
87
|
+
}, onPointerLeave: event => {
|
|
88
|
+
onPointerLeave?.(event);
|
|
89
|
+
if (!event.defaultPrevented && event.pointerType !== 'touch')
|
|
90
|
+
context.scheduleClose();
|
|
91
|
+
} }));
|
|
92
|
+
});
|
|
93
|
+
export const HoverCardContent = forwardRef(function HoverCardContent({ avoidCollisions = true, children, className, collisionPadding = 16, focusOnOpen = 'press', onBlurCapture, onFocusCapture, onOpenAutoFocus, onPointerDown, onPointerEnter, onPointerLeave, sideOffset = 8, ...props }, ref) {
|
|
94
|
+
const context = useHoverCardContext('HoverCard.Content');
|
|
95
|
+
return (_jsx(Popover.Portal, { children: _jsx(Popover.Content, { ...props, avoidCollisions: avoidCollisions, className: classes('inkra-hover-card__content', className), collisionPadding: collisionPadding, ref: ref, sideOffset: sideOffset, onBlurCapture: event => {
|
|
96
|
+
onBlurCapture?.(event);
|
|
97
|
+
if (event.defaultPrevented)
|
|
98
|
+
return;
|
|
99
|
+
const nextTarget = event.relatedTarget;
|
|
100
|
+
if (!(nextTarget instanceof Node) || !event.currentTarget.contains(nextTarget)) {
|
|
101
|
+
context.scheduleClose();
|
|
102
|
+
}
|
|
103
|
+
}, onFocusCapture: event => {
|
|
104
|
+
onFocusCapture?.(event);
|
|
105
|
+
if (!event.defaultPrevented)
|
|
106
|
+
context.cancelPending();
|
|
107
|
+
}, onOpenAutoFocus: event => {
|
|
108
|
+
onOpenAutoFocus?.(event);
|
|
109
|
+
if (event.defaultPrevented)
|
|
110
|
+
return;
|
|
111
|
+
const reason = context.getOpenReason();
|
|
112
|
+
const shouldFocus = focusOnOpen === 'always'
|
|
113
|
+
|| focusOnOpen === 'press' && reason === 'press'
|
|
114
|
+
|| focusOnOpen === 'focus-and-press' && (reason === 'focus' || reason === 'press');
|
|
115
|
+
if (!shouldFocus)
|
|
116
|
+
event.preventDefault();
|
|
117
|
+
}, onPointerDown: event => {
|
|
118
|
+
onPointerDown?.(event);
|
|
119
|
+
if (!event.defaultPrevented)
|
|
120
|
+
context.cancelPending();
|
|
121
|
+
}, onPointerEnter: event => {
|
|
122
|
+
onPointerEnter?.(event);
|
|
123
|
+
if (!event.defaultPrevented && event.pointerType !== 'touch')
|
|
124
|
+
context.cancelPending();
|
|
125
|
+
}, onPointerLeave: event => {
|
|
126
|
+
onPointerLeave?.(event);
|
|
127
|
+
if (!event.defaultPrevented && event.pointerType !== 'touch')
|
|
128
|
+
context.scheduleClose();
|
|
129
|
+
}, children: children }) }));
|
|
130
|
+
});
|
|
131
|
+
export const HoverCardTitle = PopoverTitle;
|
|
132
|
+
export const HoverCardDescription = PopoverDescription;
|
|
133
|
+
export const HoverCardClose = PopoverClose;
|
|
134
|
+
export const HoverCard = Object.freeze({
|
|
135
|
+
Root: HoverCardRoot,
|
|
136
|
+
Trigger: HoverCardTrigger,
|
|
137
|
+
Content: HoverCardContent,
|
|
138
|
+
Title: HoverCardTitle,
|
|
139
|
+
Description: HoverCardDescription,
|
|
140
|
+
Close: HoverCardClose,
|
|
141
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { HTMLAttributeReferrerPolicy, HTMLAttributes } from 'react';
|
|
2
|
+
import { ImageCropperExportError } from './model.js';
|
|
3
|
+
import type { ImageCropperArea, ImageCropperExportOptions, ImageCropperExportType, ImageCropperPoint, ImageCropperSize } from './model.js';
|
|
4
|
+
export type ImageCropperAspect = number | 'free';
|
|
5
|
+
export type ImageCropperLoadState = 'loading' | 'ready' | 'error';
|
|
6
|
+
export type ImageCropperInteractionSource = 'mouse' | 'touch' | 'wheel' | 'keyboard';
|
|
7
|
+
export type ImageCropperChangeReason = ImageCropperInteractionSource | 'control' | 'reset' | 'source-change';
|
|
8
|
+
export interface ImageCropperChangeDetails {
|
|
9
|
+
reason: ImageCropperChangeReason;
|
|
10
|
+
}
|
|
11
|
+
export interface ImageCropperSelection {
|
|
12
|
+
/** Percentage coordinates relative to the original image. */
|
|
13
|
+
percentages: ImageCropperArea;
|
|
14
|
+
/** Integer coordinates in the original image's natural pixel space. */
|
|
15
|
+
pixels: ImageCropperArea;
|
|
16
|
+
naturalSize: ImageCropperSize;
|
|
17
|
+
}
|
|
18
|
+
export interface ImageCropperExportResult {
|
|
19
|
+
blob: Blob;
|
|
20
|
+
width: number;
|
|
21
|
+
height: number;
|
|
22
|
+
type: ImageCropperExportType;
|
|
23
|
+
selection: ImageCropperSelection;
|
|
24
|
+
}
|
|
25
|
+
export interface ImageCropperLabels {
|
|
26
|
+
cropper: string;
|
|
27
|
+
instructions: string;
|
|
28
|
+
loading: string;
|
|
29
|
+
failedTitle: string;
|
|
30
|
+
failedDescription: string;
|
|
31
|
+
retry: string;
|
|
32
|
+
zoom: string;
|
|
33
|
+
cropWidth: string;
|
|
34
|
+
cropHeight: string;
|
|
35
|
+
reset: string;
|
|
36
|
+
export: string;
|
|
37
|
+
exporting: string;
|
|
38
|
+
}
|
|
39
|
+
export interface ImageCropperHandle {
|
|
40
|
+
exportCrop(options?: ImageCropperExportOptions): Promise<ImageCropperExportResult>;
|
|
41
|
+
getSelection(): ImageCropperSelection | null;
|
|
42
|
+
reset(): void;
|
|
43
|
+
}
|
|
44
|
+
export interface ImageCropperProps extends Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'onError'> {
|
|
45
|
+
source: string | Blob;
|
|
46
|
+
alt: string;
|
|
47
|
+
aspect?: ImageCropperAspect;
|
|
48
|
+
crop?: ImageCropperPoint;
|
|
49
|
+
defaultCrop?: ImageCropperPoint;
|
|
50
|
+
onCropChange?: (crop: ImageCropperPoint, details: ImageCropperChangeDetails) => void;
|
|
51
|
+
zoom?: number;
|
|
52
|
+
defaultZoom?: number;
|
|
53
|
+
minZoom?: number;
|
|
54
|
+
maxZoom?: number;
|
|
55
|
+
zoomStep?: number;
|
|
56
|
+
onZoomChange?: (zoom: number, details: ImageCropperChangeDetails) => void;
|
|
57
|
+
cropAreaSize?: ImageCropperSize;
|
|
58
|
+
defaultCropAreaSize?: ImageCropperSize;
|
|
59
|
+
onCropAreaSizeChange?: (size: ImageCropperSize, details: ImageCropperChangeDetails) => void;
|
|
60
|
+
onSelectionChange?: (selection: ImageCropperSelection) => void;
|
|
61
|
+
onSelectionCommit?: (selection: ImageCropperSelection) => void;
|
|
62
|
+
onLoadStateChange?: (state: ImageCropperLoadState) => void;
|
|
63
|
+
onMediaLoaded?: (naturalSize: ImageCropperSize) => void;
|
|
64
|
+
onLoadError?: (error: Error) => void;
|
|
65
|
+
onExport?: (result: ImageCropperExportResult) => void;
|
|
66
|
+
onExportError?: (error: ImageCropperExportError) => void;
|
|
67
|
+
exportOptions?: ImageCropperExportOptions;
|
|
68
|
+
showControls?: boolean;
|
|
69
|
+
showExport?: boolean;
|
|
70
|
+
showGrid?: boolean;
|
|
71
|
+
zoomWithScroll?: boolean;
|
|
72
|
+
resetOnSourceChange?: boolean;
|
|
73
|
+
disabled?: boolean;
|
|
74
|
+
readOnly?: boolean;
|
|
75
|
+
crossOrigin?: 'anonymous' | 'use-credentials';
|
|
76
|
+
referrerPolicy?: HTMLAttributeReferrerPolicy;
|
|
77
|
+
labels?: Partial<ImageCropperLabels>;
|
|
78
|
+
nonce?: string;
|
|
79
|
+
}
|
|
80
|
+
export declare const ImageCropper: import("react").ForwardRefExoticComponent<ImageCropperProps & import("react").RefAttributes<ImageCropperHandle>>;
|
|
81
|
+
export { IMAGE_CROPPER_DEFAULT_OUTPUT_PIXELS, IMAGE_CROPPER_MAX_OUTPUT_EDGE, IMAGE_CROPPER_MAX_OUTPUT_PIXELS, ImageCropperExportError, clampImageCropperArea, exportImageCrop, resolveImageCropperOutputSize, } from './model.js';
|
|
82
|
+
export type { ImageCropperArea, ImageCropperExportErrorCode, ImageCropperExportOptions, ImageCropperExportType, ImageCropperPoint, ImageCropperSize, } from './model.js';
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { __iconData as downloadIcon } from 'lucide-react/dist/esm/icons/download.mjs';
|
|
3
|
+
import { __iconData as imageOffIcon } from 'lucide-react/dist/esm/icons/image-off.mjs';
|
|
4
|
+
import { __iconData as rotateCcwIcon } from 'lucide-react/dist/esm/icons/rotate-ccw.mjs';
|
|
5
|
+
import Cropper from 'react-easy-crop';
|
|
6
|
+
import { forwardRef, useCallback, useEffect, useImperativeHandle, useId, useRef, useState, } from 'react';
|
|
7
|
+
import { Button } from '../Button.js';
|
|
8
|
+
import { PencilIcon } from '../PencilIcon.js';
|
|
9
|
+
import { PencilSurface } from '../PencilSurface.js';
|
|
10
|
+
import { PencilText } from '../PencilText.js';
|
|
11
|
+
import { Slider } from '../slider/index.js';
|
|
12
|
+
import { ImageCropperExportError, exportImageCrop, } from './model.js';
|
|
13
|
+
const downloadPaths = downloadIcon.node.map(([, attributes]) => ({ d: attributes.d }));
|
|
14
|
+
const imageOffPaths = imageOffIcon.node.map(([, attributes]) => ({ d: attributes.d }));
|
|
15
|
+
const resetPaths = rotateCcwIcon.node.map(([, attributes]) => ({ d: attributes.d }));
|
|
16
|
+
// Lucide LoaderCircle geometry, rendered through the shared PencilIcon engine.
|
|
17
|
+
const loadingPaths = [{ d: 'M21 12a9 9 0 1 1-6.219-8.56' }];
|
|
18
|
+
const DEFAULT_CROP = Object.freeze({ x: 0, y: 0 });
|
|
19
|
+
const MIN_FREE_CROP_SIZE = 48;
|
|
20
|
+
const defaultLabels = {
|
|
21
|
+
cropper: 'Image crop area',
|
|
22
|
+
instructions: 'Use pointer gestures or arrow keys to move the image. Use the zoom control. In free aspect mode, use the size controls or Alt plus arrow keys to resize the crop area.',
|
|
23
|
+
loading: 'Loading image',
|
|
24
|
+
failedTitle: 'Image unavailable',
|
|
25
|
+
failedDescription: 'The image could not be loaded. Check the source and try again.',
|
|
26
|
+
retry: 'Retry image',
|
|
27
|
+
zoom: 'Zoom',
|
|
28
|
+
cropWidth: 'Crop width',
|
|
29
|
+
cropHeight: 'Crop height',
|
|
30
|
+
reset: 'Reset crop',
|
|
31
|
+
export: 'Export crop',
|
|
32
|
+
exporting: 'Exporting crop',
|
|
33
|
+
};
|
|
34
|
+
function samePoint(left, right) {
|
|
35
|
+
return left.x === right.x && left.y === right.y;
|
|
36
|
+
}
|
|
37
|
+
function sameSize(left, right) {
|
|
38
|
+
return left === right || Boolean(left && right && left.width === right.width && left.height === right.height);
|
|
39
|
+
}
|
|
40
|
+
function validAspect(aspect) {
|
|
41
|
+
return aspect === 'free' || !Number.isFinite(aspect) || aspect <= 0 ? 1 : aspect;
|
|
42
|
+
}
|
|
43
|
+
function exportFailure(error) {
|
|
44
|
+
return error instanceof ImageCropperExportError
|
|
45
|
+
? error
|
|
46
|
+
: new ImageCropperExportError('canvas', 'The crop could not be exported.', { cause: error });
|
|
47
|
+
}
|
|
48
|
+
export const ImageCropper = forwardRef(function ImageCropper({ alt, aspect = 1, className, crop: cropProp, cropAreaSize: cropAreaSizeProp, crossOrigin, defaultCrop = DEFAULT_CROP, defaultCropAreaSize, defaultZoom = 1, disabled = false, exportOptions, labels: labelOverrides, maxZoom = 3, minZoom = 1, nonce, onCropAreaSizeChange, onCropChange, onExport, onExportError, onLoadError, onLoadStateChange, onMediaLoaded, onSelectionChange, onSelectionCommit, onZoomChange, readOnly = false, referrerPolicy, resetOnSourceChange = true, showControls = true, showExport = Boolean(onExport), showGrid = true, source, zoom: zoomProp, zoomStep = 0.1, zoomWithScroll = true, ...props }, forwardedRef) {
|
|
49
|
+
const labels = { ...defaultLabels, ...labelOverrides };
|
|
50
|
+
const generatedId = useId();
|
|
51
|
+
const resolvedId = props.id ?? `inkra-image-cropper-${generatedId}`;
|
|
52
|
+
const stageRef = useRef(null);
|
|
53
|
+
const imageRef = useRef(null);
|
|
54
|
+
const selectionRef = useRef(null);
|
|
55
|
+
const interactionSourceRef = useRef('mouse');
|
|
56
|
+
const initialSourceRef = useRef(source);
|
|
57
|
+
const [blobUrl, setBlobUrl] = useState(null);
|
|
58
|
+
const [attempt, setAttempt] = useState(0);
|
|
59
|
+
const [loadState, setLoadState] = useState('loading');
|
|
60
|
+
const [internalCrop, setInternalCrop] = useState(defaultCrop);
|
|
61
|
+
const [internalZoom, setInternalZoom] = useState(defaultZoom);
|
|
62
|
+
const [internalCropAreaSize, setInternalCropAreaSize] = useState(defaultCropAreaSize ?? null);
|
|
63
|
+
const [naturalSize, setNaturalSize] = useState(null);
|
|
64
|
+
const [stageSize, setStageSize] = useState({ width: 640, height: 400 });
|
|
65
|
+
const [exporting, setExporting] = useState(false);
|
|
66
|
+
const crop = cropProp ?? internalCrop;
|
|
67
|
+
const zoom = zoomProp ?? internalZoom;
|
|
68
|
+
const cropAreaSize = cropAreaSizeProp ?? internalCropAreaSize;
|
|
69
|
+
const freeCropAreaSize = cropAreaSize ?? (aspect === 'free'
|
|
70
|
+
? { width: Math.min(stageSize.width, stageSize.height), height: Math.min(stageSize.width, stageSize.height) }
|
|
71
|
+
: null);
|
|
72
|
+
const sourceUrl = typeof source === 'string' ? source : blobUrl;
|
|
73
|
+
const interactionDisabled = disabled || readOnly || loadState !== 'ready';
|
|
74
|
+
const announceLoadState = useCallback((state) => {
|
|
75
|
+
setLoadState(state);
|
|
76
|
+
onLoadStateChange?.(state);
|
|
77
|
+
}, [onLoadStateChange]);
|
|
78
|
+
const commitCrop = useCallback((next, reason) => {
|
|
79
|
+
if (cropProp === undefined)
|
|
80
|
+
setInternalCrop(next);
|
|
81
|
+
onCropChange?.(next, { reason });
|
|
82
|
+
}, [cropProp, onCropChange]);
|
|
83
|
+
const commitZoom = useCallback((next, reason) => {
|
|
84
|
+
const bounded = Math.min(maxZoom, Math.max(minZoom, next));
|
|
85
|
+
if (zoomProp === undefined)
|
|
86
|
+
setInternalZoom(bounded);
|
|
87
|
+
onZoomChange?.(bounded, { reason });
|
|
88
|
+
}, [maxZoom, minZoom, onZoomChange, zoomProp]);
|
|
89
|
+
const commitCropAreaSize = useCallback((next, reason) => {
|
|
90
|
+
const bounded = {
|
|
91
|
+
width: Math.round(Math.min(Math.max(MIN_FREE_CROP_SIZE, next.width), Math.max(MIN_FREE_CROP_SIZE, stageSize.width))),
|
|
92
|
+
height: Math.round(Math.min(Math.max(MIN_FREE_CROP_SIZE, next.height), Math.max(MIN_FREE_CROP_SIZE, stageSize.height))),
|
|
93
|
+
};
|
|
94
|
+
if (cropAreaSizeProp === undefined)
|
|
95
|
+
setInternalCropAreaSize(bounded);
|
|
96
|
+
onCropAreaSizeChange?.(bounded, { reason });
|
|
97
|
+
}, [cropAreaSizeProp, onCropAreaSizeChange, stageSize.height, stageSize.width]);
|
|
98
|
+
const reset = useCallback((reason = 'reset') => {
|
|
99
|
+
if (!samePoint(crop, defaultCrop))
|
|
100
|
+
commitCrop(defaultCrop, reason);
|
|
101
|
+
if (zoom !== defaultZoom)
|
|
102
|
+
commitZoom(defaultZoom, reason);
|
|
103
|
+
const nextSize = defaultCropAreaSize ?? null;
|
|
104
|
+
if (!sameSize(cropAreaSize, nextSize)) {
|
|
105
|
+
if (nextSize)
|
|
106
|
+
commitCropAreaSize(nextSize, reason);
|
|
107
|
+
else if (cropAreaSizeProp === undefined)
|
|
108
|
+
setInternalCropAreaSize(null);
|
|
109
|
+
}
|
|
110
|
+
selectionRef.current = null;
|
|
111
|
+
}, [commitCrop, commitCropAreaSize, commitZoom, crop, cropAreaSize, cropAreaSizeProp, defaultCrop, defaultCropAreaSize, defaultZoom, zoom]);
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
if (typeof source === 'string') {
|
|
114
|
+
setBlobUrl(null);
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
const nextUrl = URL.createObjectURL(source);
|
|
118
|
+
setBlobUrl(nextUrl);
|
|
119
|
+
return () => URL.revokeObjectURL(nextUrl);
|
|
120
|
+
}, [source]);
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
const stage = stageRef.current;
|
|
123
|
+
if (!stage || typeof ResizeObserver === 'undefined')
|
|
124
|
+
return undefined;
|
|
125
|
+
const update = () => {
|
|
126
|
+
const rect = stage.getBoundingClientRect();
|
|
127
|
+
if (rect.width > 0 && rect.height > 0)
|
|
128
|
+
setStageSize({ width: rect.width, height: rect.height });
|
|
129
|
+
};
|
|
130
|
+
update();
|
|
131
|
+
const observer = new ResizeObserver(update);
|
|
132
|
+
observer.observe(stage);
|
|
133
|
+
return () => observer.disconnect();
|
|
134
|
+
}, []);
|
|
135
|
+
useEffect(() => {
|
|
136
|
+
if (initialSourceRef.current === source)
|
|
137
|
+
return;
|
|
138
|
+
initialSourceRef.current = source;
|
|
139
|
+
setAttempt(0);
|
|
140
|
+
setNaturalSize(null);
|
|
141
|
+
selectionRef.current = null;
|
|
142
|
+
announceLoadState('loading');
|
|
143
|
+
if (resetOnSourceChange)
|
|
144
|
+
reset('source-change');
|
|
145
|
+
}, [announceLoadState, reset, resetOnSourceChange, source]);
|
|
146
|
+
const makeSelection = useCallback((percentages, pixels) => {
|
|
147
|
+
if (!naturalSize)
|
|
148
|
+
return null;
|
|
149
|
+
return {
|
|
150
|
+
percentages: { ...percentages },
|
|
151
|
+
pixels: { ...pixels },
|
|
152
|
+
naturalSize,
|
|
153
|
+
};
|
|
154
|
+
}, [naturalSize]);
|
|
155
|
+
const exportCurrent = useCallback(async (options = exportOptions ?? {}) => {
|
|
156
|
+
const image = imageRef.current;
|
|
157
|
+
const selection = selectionRef.current;
|
|
158
|
+
if (!selection)
|
|
159
|
+
throw new ImageCropperExportError('invalid-selection', 'No completed crop selection is available.');
|
|
160
|
+
if (!image)
|
|
161
|
+
throw new ImageCropperExportError('image-not-ready', 'The image element is not available.');
|
|
162
|
+
const result = await exportImageCrop(image, selection.pixels, options);
|
|
163
|
+
return { ...result, selection };
|
|
164
|
+
}, [exportOptions]);
|
|
165
|
+
useImperativeHandle(forwardedRef, () => ({
|
|
166
|
+
exportCrop: exportCurrent,
|
|
167
|
+
getSelection: () => selectionRef.current,
|
|
168
|
+
reset: () => reset(),
|
|
169
|
+
}), [exportCurrent, reset]);
|
|
170
|
+
const handleExport = async () => {
|
|
171
|
+
setExporting(true);
|
|
172
|
+
try {
|
|
173
|
+
onExport?.(await exportCurrent());
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
onExportError?.(exportFailure(error));
|
|
177
|
+
}
|
|
178
|
+
finally {
|
|
179
|
+
setExporting(false);
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
const resizeFreeCrop = (axis, delta) => {
|
|
183
|
+
if (aspect !== 'free' || interactionDisabled)
|
|
184
|
+
return;
|
|
185
|
+
const base = cropAreaSize ?? {
|
|
186
|
+
width: Math.min(stageSize.width, stageSize.height),
|
|
187
|
+
height: Math.min(stageSize.width, stageSize.height),
|
|
188
|
+
};
|
|
189
|
+
commitCropAreaSize({ ...base, [axis]: base[axis] + delta }, 'control');
|
|
190
|
+
};
|
|
191
|
+
const handleRootKeyDown = (event) => {
|
|
192
|
+
props.onKeyDown?.(event);
|
|
193
|
+
if (event.defaultPrevented || !event.altKey || aspect !== 'free')
|
|
194
|
+
return;
|
|
195
|
+
if (!stageRef.current?.contains(event.target))
|
|
196
|
+
return;
|
|
197
|
+
const step = event.shiftKey ? 24 : 8;
|
|
198
|
+
if (event.key === 'ArrowLeft')
|
|
199
|
+
resizeFreeCrop('width', -step);
|
|
200
|
+
else if (event.key === 'ArrowRight')
|
|
201
|
+
resizeFreeCrop('width', step);
|
|
202
|
+
else if (event.key === 'ArrowUp')
|
|
203
|
+
resizeFreeCrop('height', -step);
|
|
204
|
+
else if (event.key === 'ArrowDown')
|
|
205
|
+
resizeFreeCrop('height', step);
|
|
206
|
+
else
|
|
207
|
+
return;
|
|
208
|
+
event.preventDefault();
|
|
209
|
+
};
|
|
210
|
+
const engineAspect = validAspect(aspect);
|
|
211
|
+
const engineCropSize = aspect === 'free' && freeCropAreaSize ? freeCropAreaSize : undefined;
|
|
212
|
+
const rootProps = { ...props };
|
|
213
|
+
delete rootProps.onKeyDown;
|
|
214
|
+
return _jsxs("div", { ...rootProps, className: ['inkra-image-cropper', className].filter(Boolean).join(' '), "data-disabled": disabled || undefined, "data-read-only": readOnly || undefined, "data-state": loadState, onKeyDown: handleRootKeyDown, children: [_jsxs("div", { "aria-busy": loadState === 'loading' || undefined, "aria-describedby": `${resolvedId}-instructions`, "aria-disabled": disabled || undefined, "aria-label": labels.cropper, className: "inkra-image-cropper__stage", ref: stageRef, children: [_jsx(PencilSurface, { as: "span", "aria-hidden": "true", className: "inkra-image-cropper__stage-surface", radius: 8, variant: "outline" }), sourceUrl ? _jsx(Cropper, { aspect: engineAspect, classes: {
|
|
215
|
+
containerClassName: 'inkra-image-cropper__engine',
|
|
216
|
+
mediaClassName: 'inkra-image-cropper__media',
|
|
217
|
+
cropAreaClassName: 'inkra-image-cropper__area',
|
|
218
|
+
}, crop: crop, cropSize: engineCropSize, image: sourceUrl, keyboardStep: 1, maxZoom: maxZoom, mediaProps: { alt, crossOrigin, referrerPolicy }, minZoom: minZoom, nonce: nonce, onCropAreaChange: (percentages, pixels) => {
|
|
219
|
+
const selection = makeSelection(percentages, pixels);
|
|
220
|
+
if (!selection)
|
|
221
|
+
return;
|
|
222
|
+
selectionRef.current = selection;
|
|
223
|
+
onSelectionChange?.(selection);
|
|
224
|
+
}, onCropChange: next => {
|
|
225
|
+
if (!interactionDisabled)
|
|
226
|
+
commitCrop(next, interactionSourceRef.current);
|
|
227
|
+
}, onCropComplete: (percentages, pixels) => {
|
|
228
|
+
const selection = makeSelection(percentages, pixels);
|
|
229
|
+
if (!selection)
|
|
230
|
+
return;
|
|
231
|
+
selectionRef.current = selection;
|
|
232
|
+
onSelectionCommit?.(selection);
|
|
233
|
+
}, onCropSizeChange: next => {
|
|
234
|
+
if (aspect === 'free' && !cropAreaSize)
|
|
235
|
+
commitCropAreaSize(next, 'control');
|
|
236
|
+
}, onInteractionStart: ({ source: interactionSource }) => {
|
|
237
|
+
interactionSourceRef.current = interactionSource;
|
|
238
|
+
}, onMediaLoaded: (media) => {
|
|
239
|
+
const nextNaturalSize = { width: media.naturalWidth, height: media.naturalHeight };
|
|
240
|
+
setNaturalSize(nextNaturalSize);
|
|
241
|
+
announceLoadState('ready');
|
|
242
|
+
onMediaLoaded?.(nextNaturalSize);
|
|
243
|
+
}, onZoomChange: next => {
|
|
244
|
+
if (!interactionDisabled)
|
|
245
|
+
commitZoom(next, interactionSourceRef.current);
|
|
246
|
+
}, disableAutomaticStylesInjection: true, restrictPosition: true, roundCropAreaPixels: true, setImageRef: (ref) => {
|
|
247
|
+
imageRef.current = ref.current;
|
|
248
|
+
}, showGrid: showGrid, zoom: zoom, zoomWithScroll: zoomWithScroll && !interactionDisabled }, `${sourceUrl}:${attempt}`) : null, loadState === 'loading' ? _jsxs("div", { className: "inkra-image-cropper__state", role: "status", children: [_jsx(PencilIcon, { "aria-hidden": "true", className: "inkra-image-cropper__spinner", paths: loadingPaths, seed: `${resolvedId}-loading`, size: 40, sourceSize: 24 }), _jsx(PencilText, { children: labels.loading })] }) : null, loadState === 'error' ? _jsxs("div", { className: "inkra-image-cropper__state", role: "alert", children: [_jsx(PencilIcon, { decorative: true, paths: imageOffPaths, size: 48, sourceSize: 24 }), _jsx(PencilText, { as: "h3", children: labels.failedTitle }), _jsx(PencilText, { as: "p", children: labels.failedDescription }), _jsx(Button, { variant: "outline", onClick: () => {
|
|
249
|
+
setAttempt(current => current + 1);
|
|
250
|
+
announceLoadState('loading');
|
|
251
|
+
}, children: labels.retry })] }) : null, sourceUrl && loadState !== 'error' ? _jsx("img", { alt: "", "aria-hidden": "true", className: "inkra-image-cropper__load-probe", crossOrigin: crossOrigin, onError: () => {
|
|
252
|
+
const error = new Error('The crop image could not be loaded.');
|
|
253
|
+
announceLoadState('error');
|
|
254
|
+
onLoadError?.(error);
|
|
255
|
+
}, referrerPolicy: referrerPolicy, src: sourceUrl }, `probe:${sourceUrl}:${attempt}`) : null] }), _jsx("span", { className: "inkra-image-cropper__instructions", id: `${resolvedId}-instructions`, children: labels.instructions }), showControls ? _jsxs("div", { className: "inkra-image-cropper__controls", children: [_jsxs("label", { className: "inkra-image-cropper__control", children: [_jsxs("span", { className: "inkra-image-cropper__control-label", children: [_jsx(PencilText, { children: labels.zoom }), _jsxs("output", { children: [Math.round(zoom * 100), "%"] })] }), _jsx(Slider, { "aria-label": labels.zoom, disabled: interactionDisabled, getValueLabel: value => `${Math.round(value * 100)}%`, max: maxZoom, min: minZoom, onValueChange: values => commitZoom(values[0], 'control'), step: zoomStep, value: [zoom] })] }), aspect === 'free' && freeCropAreaSize ? _jsx("div", { className: "inkra-image-cropper__free-controls", children: ['width', 'height'].map(axis => {
|
|
256
|
+
const label = axis === 'width' ? labels.cropWidth : labels.cropHeight;
|
|
257
|
+
const maximum = Math.max(MIN_FREE_CROP_SIZE, axis === 'width' ? stageSize.width : stageSize.height);
|
|
258
|
+
return _jsxs("label", { className: "inkra-image-cropper__control", children: [_jsxs("span", { className: "inkra-image-cropper__control-label", children: [_jsx(PencilText, { children: label }), _jsxs("output", { children: [Math.round(freeCropAreaSize[axis]), "px"] })] }), _jsx(Slider, { "aria-label": label, disabled: interactionDisabled, max: maximum, min: MIN_FREE_CROP_SIZE, onValueChange: values => commitCropAreaSize({ ...freeCropAreaSize, [axis]: values[0] }, 'control'), step: 4, value: [Math.min(maximum, freeCropAreaSize[axis])] })] }, axis);
|
|
259
|
+
}) }) : null, _jsxs("div", { className: "inkra-image-cropper__actions", children: [_jsx(Button, { "aria-label": labels.reset, disabled: interactionDisabled, onClick: () => reset(), size: "icon-sm", title: labels.reset, variant: "outline", children: _jsx(PencilIcon, { decorative: true, paths: resetPaths, size: 22, sourceSize: 24 }) }), showExport ? _jsx(Button, { "aria-label": labels.export, disabled: interactionDisabled || selectionRef.current === null, loading: exporting, onClick: () => void handleExport(), startIcon: _jsx(PencilIcon, { decorative: true, paths: downloadPaths, size: 22, sourceSize: 24 }), variant: "primary", children: exporting ? labels.exporting : labels.export }) : null] })] }) : null] });
|
|
260
|
+
});
|
|
261
|
+
export { IMAGE_CROPPER_DEFAULT_OUTPUT_PIXELS, IMAGE_CROPPER_MAX_OUTPUT_EDGE, IMAGE_CROPPER_MAX_OUTPUT_PIXELS, ImageCropperExportError, clampImageCropperArea, exportImageCrop, resolveImageCropperOutputSize, } from './model.js';
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export interface ImageCropperPoint {
|
|
2
|
+
x: number;
|
|
3
|
+
y: number;
|
|
4
|
+
}
|
|
5
|
+
export interface ImageCropperSize {
|
|
6
|
+
width: number;
|
|
7
|
+
height: number;
|
|
8
|
+
}
|
|
9
|
+
export interface ImageCropperArea extends ImageCropperPoint, ImageCropperSize {
|
|
10
|
+
}
|
|
11
|
+
export type ImageCropperExportType = 'image/png' | 'image/jpeg' | 'image/webp';
|
|
12
|
+
export interface ImageCropperExportOptions {
|
|
13
|
+
/** Maximum output width. The crop aspect ratio is always preserved. */
|
|
14
|
+
maxWidth?: number;
|
|
15
|
+
/** Maximum output height. The crop aspect ratio is always preserved. */
|
|
16
|
+
maxHeight?: number;
|
|
17
|
+
/** Maximum output pixel count. Defaults to 16,777,216 and never exceeds the hard 33,554,432 limit. */
|
|
18
|
+
maxPixels?: number;
|
|
19
|
+
/** Permit enlarging beyond the selected original pixels. Defaults to false. */
|
|
20
|
+
allowUpscale?: boolean;
|
|
21
|
+
type?: ImageCropperExportType;
|
|
22
|
+
quality?: number;
|
|
23
|
+
}
|
|
24
|
+
export type ImageCropperExportErrorCode = 'canvas' | 'cors' | 'image-not-ready' | 'invalid-selection' | 'unsupported-format';
|
|
25
|
+
export declare class ImageCropperExportError extends Error {
|
|
26
|
+
readonly code: ImageCropperExportErrorCode;
|
|
27
|
+
readonly cause?: unknown;
|
|
28
|
+
constructor(code: ImageCropperExportErrorCode, message: string, options?: {
|
|
29
|
+
cause?: unknown;
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
export declare const IMAGE_CROPPER_MAX_OUTPUT_EDGE = 8192;
|
|
33
|
+
export declare const IMAGE_CROPPER_MAX_OUTPUT_PIXELS = 33554432;
|
|
34
|
+
export declare const IMAGE_CROPPER_DEFAULT_OUTPUT_PIXELS = 16777216;
|
|
35
|
+
export declare function clampImageCropperArea(area: ImageCropperArea, naturalSize: ImageCropperSize): ImageCropperArea;
|
|
36
|
+
export declare function resolveImageCropperOutputSize(area: ImageCropperSize, options?: ImageCropperExportOptions): ImageCropperSize;
|
|
37
|
+
export declare function exportImageCrop(image: HTMLImageElement, area: ImageCropperArea, options?: ImageCropperExportOptions): Promise<{
|
|
38
|
+
blob: Blob;
|
|
39
|
+
width: number;
|
|
40
|
+
height: number;
|
|
41
|
+
type: ImageCropperExportType;
|
|
42
|
+
}>;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
export class ImageCropperExportError extends Error {
|
|
2
|
+
constructor(code, message, options) {
|
|
3
|
+
super(message);
|
|
4
|
+
this.name = 'ImageCropperExportError';
|
|
5
|
+
this.code = code;
|
|
6
|
+
this.cause = options?.cause;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export const IMAGE_CROPPER_MAX_OUTPUT_EDGE = 8192;
|
|
10
|
+
export const IMAGE_CROPPER_MAX_OUTPUT_PIXELS = 33554432;
|
|
11
|
+
export const IMAGE_CROPPER_DEFAULT_OUTPUT_PIXELS = 16777216;
|
|
12
|
+
const EXPORT_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
|
|
13
|
+
function finitePositive(value, fallback, maximum) {
|
|
14
|
+
if (value === undefined)
|
|
15
|
+
return fallback;
|
|
16
|
+
if (!Number.isFinite(value) || value <= 0)
|
|
17
|
+
return fallback;
|
|
18
|
+
return Math.min(maximum, value);
|
|
19
|
+
}
|
|
20
|
+
export function clampImageCropperArea(area, naturalSize) {
|
|
21
|
+
const naturalWidth = Math.max(0, Math.floor(naturalSize.width));
|
|
22
|
+
const naturalHeight = Math.max(0, Math.floor(naturalSize.height));
|
|
23
|
+
const x = Math.min(naturalWidth, Math.max(0, Math.round(area.x)));
|
|
24
|
+
const y = Math.min(naturalHeight, Math.max(0, Math.round(area.y)));
|
|
25
|
+
const width = Math.min(naturalWidth - x, Math.max(0, Math.round(area.width)));
|
|
26
|
+
const height = Math.min(naturalHeight - y, Math.max(0, Math.round(area.height)));
|
|
27
|
+
return { x, y, width, height };
|
|
28
|
+
}
|
|
29
|
+
export function resolveImageCropperOutputSize(area, options = {}) {
|
|
30
|
+
const sourceWidth = Math.floor(area.width);
|
|
31
|
+
const sourceHeight = Math.floor(area.height);
|
|
32
|
+
if (sourceWidth < 1 || sourceHeight < 1) {
|
|
33
|
+
throw new ImageCropperExportError('invalid-selection', 'The crop selection has no exportable pixels.');
|
|
34
|
+
}
|
|
35
|
+
const maxWidth = finitePositive(options.maxWidth, IMAGE_CROPPER_MAX_OUTPUT_EDGE, IMAGE_CROPPER_MAX_OUTPUT_EDGE);
|
|
36
|
+
const maxHeight = finitePositive(options.maxHeight, IMAGE_CROPPER_MAX_OUTPUT_EDGE, IMAGE_CROPPER_MAX_OUTPUT_EDGE);
|
|
37
|
+
const maxPixels = finitePositive(options.maxPixels, IMAGE_CROPPER_DEFAULT_OUTPUT_PIXELS, IMAGE_CROPPER_MAX_OUTPUT_PIXELS);
|
|
38
|
+
const upscaleLimit = options.allowUpscale ? Number.POSITIVE_INFINITY : 1;
|
|
39
|
+
const scale = Math.min(upscaleLimit, maxWidth / sourceWidth, maxHeight / sourceHeight, Math.sqrt(maxPixels / (sourceWidth * sourceHeight)));
|
|
40
|
+
return {
|
|
41
|
+
width: Math.max(1, Math.floor(sourceWidth * scale)),
|
|
42
|
+
height: Math.max(1, Math.floor(sourceHeight * scale)),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
function exportError(error) {
|
|
46
|
+
if (error instanceof ImageCropperExportError)
|
|
47
|
+
return error;
|
|
48
|
+
if (error instanceof DOMException && error.name === 'SecurityError') {
|
|
49
|
+
return new ImageCropperExportError('cors', 'The image can be displayed, but its server did not permit canvas export. Configure CORS or use a same-origin Blob.', { cause: error });
|
|
50
|
+
}
|
|
51
|
+
return new ImageCropperExportError('canvas', 'The browser could not render the crop to a canvas.', { cause: error });
|
|
52
|
+
}
|
|
53
|
+
export async function exportImageCrop(image, area, options = {}) {
|
|
54
|
+
if (!image.complete || image.naturalWidth < 1 || image.naturalHeight < 1) {
|
|
55
|
+
throw new ImageCropperExportError('image-not-ready', 'The image has not finished loading.');
|
|
56
|
+
}
|
|
57
|
+
const type = options.type ?? 'image/png';
|
|
58
|
+
if (!EXPORT_TYPES.has(type)) {
|
|
59
|
+
throw new ImageCropperExportError('unsupported-format', `Unsupported crop export format: ${String(type)}`);
|
|
60
|
+
}
|
|
61
|
+
const selection = clampImageCropperArea(area, {
|
|
62
|
+
width: image.naturalWidth,
|
|
63
|
+
height: image.naturalHeight,
|
|
64
|
+
});
|
|
65
|
+
const output = resolveImageCropperOutputSize(selection, options);
|
|
66
|
+
const canvas = document.createElement('canvas');
|
|
67
|
+
canvas.width = output.width;
|
|
68
|
+
canvas.height = output.height;
|
|
69
|
+
const context = canvas.getContext('2d');
|
|
70
|
+
if (!context)
|
|
71
|
+
throw new ImageCropperExportError('canvas', 'The browser did not provide a 2D canvas context.');
|
|
72
|
+
try {
|
|
73
|
+
context.drawImage(image, selection.x, selection.y, selection.width, selection.height, 0, 0, output.width, output.height);
|
|
74
|
+
const quality = options.quality === undefined
|
|
75
|
+
? undefined
|
|
76
|
+
: Math.min(1, Math.max(0, options.quality));
|
|
77
|
+
const blob = await new Promise((resolve, reject) => {
|
|
78
|
+
try {
|
|
79
|
+
canvas.toBlob(value => value ? resolve(value) : reject(new ImageCropperExportError('canvas', 'Canvas export returned no image data.')), type, quality);
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
reject(error);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
return { blob, width: output.width, height: output.height, type };
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
throw exportError(error);
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
canvas.width = 0;
|
|
92
|
+
canvas.height = 0;
|
|
93
|
+
}
|
|
94
|
+
}
|