@inshapardaz/likhari-react 0.1.6-dev.54 → 0.1.6-dev.58
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/EditorRoot.d.ts.map +1 -1
- package/dist/EditorRoot.js +2 -1
- package/dist/image/ImageComponent.d.ts.map +1 -1
- package/dist/image/ImageComponent.js +22 -6
- package/dist/image/ImageCropDialog.d.ts +22 -0
- package/dist/image/ImageCropDialog.d.ts.map +1 -0
- package/dist/image/ImageCropDialog.js +113 -0
- package/dist/image/ImageDialog.d.ts +15 -17
- package/dist/image/ImageDialog.d.ts.map +1 -1
- package/dist/image/ImageDialog.js +22 -108
- package/package.json +1 -1
package/dist/EditorRoot.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"EditorRoot.d.ts","sourceRoot":"","sources":["../src/EditorRoot.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,eAAe,CAAC;
|
|
1
|
+
{"version":3,"file":"EditorRoot.d.ts","sourceRoot":"","sources":["../src/EditorRoot.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,eAAe,CAAC;AAU1D,OAAO,KAAK,EAAe,qBAAqB,EAAE,MAAM,SAAS,CAAC;AAClE,OAAO,EAAyB,KAAK,QAAQ,EAAE,MAAM,iCAAiC,CAAC;AACvF,OAAO,EAAwB,KAAK,mBAAmB,EAAE,KAAK,uBAAuB,EAAE,MAAM,2BAA2B,CAAC;AAKzH,OAAO,EAAyB,KAAK,UAAU,EAAE,MAAM,SAAS,CAAC;AAIjE,MAAM,WAAW,oBAAoB;IACnC,MAAM,EAAE,OAAO,CAAC,QAAQ,EAAE,cAAc,GAAG,YAAY,CAAC,CAAC;IACzD,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,eAAe;IAC9B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,cAAc,CAAC,EAAE,oBAAoB,CAAC;IACtC,aAAa,CAAC,EAAE,mBAAmB,CAAC;IACpC,aAAa,CAAC,EAAE,uBAAuB,CAAC;IACxC,KAAK,CAAC,EAAE,oBAAoB,CAAC;IAC7B,WAAW,CAAC,EAAE,OAAO,GAAG,MAAM,CAAC;IAC/B,MAAM,CAAC,EAAE,IAAI,GAAG,IAAI,GAAG,cAAc,CAAC;IACtC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;;;;OAOG;IACH,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACzB,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,qBAAqB,KAAK,IAAI,CAAC;IAClD;;;OAGG;IACH,MAAM,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,KAAK,IAAI,CAAC;IACrD;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;;OAIG;IACH,aAAa,CAAC,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;IAChD;;;;;;;OAOG;IACH,UAAU,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5C;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,UAAU,EAAE,CAAC;CAC5B;AAED,MAAM,WAAW,SAAS;IACxB,UAAU,CAAC,MAAM,EAAE,QAAQ,GAAG,MAAM,CAAC;IACrC,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,GAAG,IAAI,CAAC;IAClD,iBAAiB,IAAI,OAAO,CAAC;IAC7B,cAAc,IAAI,OAAO,CAAC,OAAO,CAAC,CAAC;IACnC,KAAK,IAAI,IAAI,CAAC;CACf;AAQD,eAAO,MAAM,UAAU,uGA8IrB,CAAC"}
|
package/dist/EditorRoot.js
CHANGED
|
@@ -5,6 +5,7 @@ import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
|
|
|
5
5
|
import { ContentEditable } from '@lexical/react/LexicalContentEditable';
|
|
6
6
|
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin';
|
|
7
7
|
import { ListPlugin } from '@lexical/react/LexicalListPlugin';
|
|
8
|
+
import { CheckListPlugin } from '@lexical/react/LexicalCheckListPlugin';
|
|
8
9
|
import { LinkPlugin } from '@lexical/react/LexicalLinkPlugin';
|
|
9
10
|
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin';
|
|
10
11
|
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary';
|
|
@@ -101,5 +102,5 @@ export const EditorRoot = forwardRef(function EditorRoot({ documentId, initialCo
|
|
|
101
102
|
onSave?.(content, format);
|
|
102
103
|
};
|
|
103
104
|
const dir = locale === 'en' ? 'ltr' : 'rtl';
|
|
104
|
-
return (_jsx(EditorThemeProvider, { theme: theme, colorScheme: colorScheme, children: _jsx("div", { className: "likhari-root", dir: dir, ref: rootElementRef, "data-document-id": documentId, style: { height: typeof height === 'number' ? `${height}px` : height }, children: _jsx(ImageOptionsContext.Provider, { value: imageOptions, children: _jsxs(LexicalComposer, { initialConfig: initialConfig, children: [_jsx(Toolbar, { config: config, onSave: handleSave, isDirty: isDirty, showSave: showSave, fontOptions: fontOptions, direction: dir }), _jsx("div", { className: "likhari-canvas", children: _jsx(RichTextPlugin, { contentEditable: _jsx(ContentEditable, { className: "likhari-content-editable", dir: dir, "aria-label": "Editor content" }), placeholder: _jsx("div", { className: "likhari-placeholder", children: placeholder }), ErrorBoundary: LexicalErrorBoundary }) }), config.history && _jsx(HistoryPlugin, {}), (config.lists.bullet || config.lists.numbered || config.lists.check) && _jsx(ListPlugin, {}), config.links && _jsx(LinkPlugin, {}), config.links && _jsx(LinkPastePlugin, {}), _jsx(OnChangePlugin, { onChange: handleChange })] }) }) }) }));
|
|
105
|
+
return (_jsx(EditorThemeProvider, { theme: theme, colorScheme: colorScheme, children: _jsx("div", { className: "likhari-root", dir: dir, ref: rootElementRef, "data-document-id": documentId, style: { height: typeof height === 'number' ? `${height}px` : height }, children: _jsx(ImageOptionsContext.Provider, { value: imageOptions, children: _jsxs(LexicalComposer, { initialConfig: initialConfig, children: [_jsx(Toolbar, { config: config, onSave: handleSave, isDirty: isDirty, showSave: showSave, fontOptions: fontOptions, direction: dir }), _jsx("div", { className: "likhari-canvas", children: _jsx(RichTextPlugin, { contentEditable: _jsx(ContentEditable, { className: "likhari-content-editable", dir: dir, "aria-label": "Editor content" }), placeholder: _jsx("div", { className: "likhari-placeholder", children: placeholder }), ErrorBoundary: LexicalErrorBoundary }) }), config.history && _jsx(HistoryPlugin, {}), (config.lists.bullet || config.lists.numbered || config.lists.check) && _jsx(ListPlugin, {}), config.lists.check && _jsx(CheckListPlugin, {}), config.links && _jsx(LinkPlugin, {}), config.links && _jsx(LinkPastePlugin, {}), _jsx(OnChangePlugin, { onChange: handleChange })] }) }) }) }));
|
|
105
106
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ImageComponent.d.ts","sourceRoot":"","sources":["../../src/image/ImageComponent.tsx"],"names":[],"mappings":"AAKA,OAAO,EASL,KAAK,OAAO,EACb,MAAM,SAAS,CAAC;
|
|
1
|
+
{"version":3,"file":"ImageComponent.d.ts","sourceRoot":"","sources":["../../src/image/ImageComponent.tsx"],"names":[],"mappings":"AAKA,OAAO,EASL,KAAK,OAAO,EACb,MAAM,SAAS,CAAC;AAKjB,OAAO,KAAK,EAAE,aAAa,EAAa,MAAM,aAAa,CAAC;AAU5D,UAAU,mBAAmB;IAC3B,OAAO,EAAE,OAAO,CAAC;IACjB,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,QAAQ,EAAE,aAAa,CAAC;IACxB,yFAAyF;IACzF,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;CACvB;AAED;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAAC,EAAE,OAAO,EAAE,GAAG,EAAE,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,KAAK,EAAE,MAAM,EAAE,EAAE,mBAAmB,+BAyP9G"}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
3
3
|
import { Menu } from '@mantine/core';
|
|
4
|
-
import { IconPhotoDown, IconPhotoEdit, IconTextCaption, IconTrash, IconX } from '@tabler/icons-react';
|
|
4
|
+
import { IconCrop, IconPhotoDown, IconPhotoEdit, IconTextCaption, IconTrash, IconX } from '@tabler/icons-react';
|
|
5
5
|
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
|
6
6
|
import { useLexicalNodeSelection } from '@lexical/react/useLexicalNodeSelection';
|
|
7
7
|
import { $getNodeByKey, $getSelection, $isNodeSelection, CLICK_COMMAND, COMMAND_PRIORITY_LOW, KEY_BACKSPACE_COMMAND, KEY_DELETE_COMMAND, } from 'lexical';
|
|
8
|
+
import { ImageCropDialog } from './ImageCropDialog.js';
|
|
8
9
|
import { ImageDialog } from './ImageDialog.js';
|
|
9
10
|
import { useImageOptions } from './ImageOptionsContext.js';
|
|
10
11
|
import { dataUrlBytes, fetchImageAsDataUrl } from './imageEdit.js';
|
|
@@ -30,6 +31,7 @@ export function ImageComponent({ nodeKey, src, altText, caption, linkType, width
|
|
|
30
31
|
const figureRef = useRef(null);
|
|
31
32
|
const [menu, setMenu] = useState(null);
|
|
32
33
|
const [dialog, setDialog] = useState(null);
|
|
34
|
+
const [cropDialogOpen, setCropDialogOpen] = useState(false);
|
|
33
35
|
const [liveWidth, setLiveWidth] = useState(null);
|
|
34
36
|
// Selecting must go through CLICK_COMMAND (returning true) rather than a
|
|
35
37
|
// React onClick: otherwise Lexical's own click handling turns the DOM
|
|
@@ -49,14 +51,14 @@ export function ImageComponent({ nodeKey, src, altText, caption, linkType, width
|
|
|
49
51
|
});
|
|
50
52
|
}, [editor, nodeKey]);
|
|
51
53
|
const onDelete = useCallback((event) => {
|
|
52
|
-
if (!isSelected || dialog || menu)
|
|
54
|
+
if (!isSelected || dialog || cropDialogOpen || menu)
|
|
53
55
|
return false;
|
|
54
56
|
if (!$isNodeSelection($getSelection()))
|
|
55
57
|
return false;
|
|
56
58
|
event.preventDefault();
|
|
57
59
|
$getNodeByKey(nodeKey)?.remove();
|
|
58
60
|
return true;
|
|
59
|
-
}, [isSelected, dialog, menu, nodeKey]);
|
|
61
|
+
}, [isSelected, dialog, cropDialogOpen, menu, nodeKey]);
|
|
60
62
|
useEffect(() => {
|
|
61
63
|
const unregisterDelete = editor.registerCommand(KEY_DELETE_COMMAND, onDelete, COMMAND_PRIORITY_LOW);
|
|
62
64
|
const unregisterBackspace = editor.registerCommand(KEY_BACKSPACE_COMMAND, onDelete, COMMAND_PRIORITY_LOW);
|
|
@@ -94,11 +96,22 @@ export function ImageComponent({ nodeKey, src, altText, caption, linkType, width
|
|
|
94
96
|
node.setSource(value.src, value.linkType);
|
|
95
97
|
node.setAltText(value.altText);
|
|
96
98
|
node.setCaption(value.caption);
|
|
97
|
-
|
|
99
|
+
// A changed source has new pixels the current display size may no
|
|
100
|
+
// longer fit — reset to auto. Editing alt text/caption only keeps it.
|
|
101
|
+
if (value.sourceChanged)
|
|
102
|
+
node.setDimensions(null, null);
|
|
98
103
|
});
|
|
99
104
|
setDialog(null);
|
|
100
105
|
editor.focus();
|
|
101
106
|
};
|
|
107
|
+
const applyCrop = (value) => {
|
|
108
|
+
updateNode((node) => {
|
|
109
|
+
node.setSource(value.src, 'embedded');
|
|
110
|
+
node.setDimensions(value.width, value.height);
|
|
111
|
+
});
|
|
112
|
+
setCropDialogOpen(false);
|
|
113
|
+
editor.focus();
|
|
114
|
+
};
|
|
102
115
|
// --- drag-to-resize: bottom-right handle, aspect ratio kept ---------------
|
|
103
116
|
const resizeStart = useRef(null);
|
|
104
117
|
const onResizeDown = (event) => {
|
|
@@ -144,8 +157,11 @@ export function ImageComponent({ nodeKey, src, altText, caption, linkType, width
|
|
|
144
157
|
}, children: [_jsxs("span", { className: "likhari-image-wrap", children: [_jsx("img", { ref: imageRef, className: "likhari-image", src: src, alt: altText, width: shownWidth, draggable: false }), isSelected && linkType === 'embedded' && (_jsx("span", { className: "likhari-image-resize-handle", role: "separator", "aria-label": "Resize image", onPointerDown: onResizeDown, onPointerMove: onResizeMove, onPointerUp: onResizeUp, onPointerCancel: onResizeUp }))] }), caption && _jsx("figcaption", { className: "likhari-image-caption", children: caption }), _jsxs(Menu, { opened: menu !== null, onChange: (opened) => {
|
|
145
158
|
if (!opened)
|
|
146
159
|
setMenu(null);
|
|
147
|
-
}, position: "bottom-start", withinPortal: true, shadow: "sm", width: 200, children: [_jsx(Menu.Target, { children: _jsx("span", { "aria-hidden": "true", style: { position: 'fixed', left: menu?.x ?? -9999, top: menu?.y ?? -9999, width: 1, height: 1, pointerEvents: 'none' } }) }), _jsxs(Menu.Dropdown, { children: [_jsx(Menu.Item, { leftSection: _jsx(IconPhotoEdit, { size: 16, stroke: 1.75 }), onClick: () => setDialog({}), children: "Edit image\u2026" }), linkType === 'linked' && options.allowEmbedded && (_jsx(Menu.Item, { leftSection: _jsx(IconPhotoDown, { size: 16, stroke: 1.75 }), onClick: () => void convertToEmbedded(), children: "Convert to embedded image" })), options.allowCaption && (_jsx(Menu.Item, { leftSection: _jsx(IconTextCaption, { size: 16, stroke: 1.75 }), onClick: () => setDialog({ intent: 'caption' }), children: caption ? 'Edit caption…' : 'Add caption…' })), options.allowCaption && caption && (_jsx(Menu.Item, { leftSection: _jsx(IconX, { size: 16, stroke: 1.75 }), onClick: () => updateNode((node) => node.setCaption(null)), children: "Remove caption" })), _jsx(Menu.Divider, {}), _jsx(Menu.Item, { color: "red", leftSection: _jsx(IconTrash, { size: 16, stroke: 1.75 }), onClick: removeImage, children: "Delete image" })] })] }), _jsx(ImageDialog, { mode: "edit", opened: dialog !== null, initial: { src, altText, caption, linkType
|
|
160
|
+
}, position: "bottom-start", withinPortal: true, shadow: "sm", width: 200, children: [_jsx(Menu.Target, { children: _jsx("span", { "aria-hidden": "true", style: { position: 'fixed', left: menu?.x ?? -9999, top: menu?.y ?? -9999, width: 1, height: 1, pointerEvents: 'none' } }) }), _jsxs(Menu.Dropdown, { children: [_jsx(Menu.Item, { leftSection: _jsx(IconPhotoEdit, { size: 16, stroke: 1.75 }), onClick: () => setDialog({}), children: "Edit image\u2026" }), linkType === 'linked' && options.allowEmbedded && (_jsx(Menu.Item, { leftSection: _jsx(IconPhotoDown, { size: 16, stroke: 1.75 }), onClick: () => void convertToEmbedded(), children: "Convert to embedded image" })), linkType === 'embedded' && (_jsx(Menu.Item, { leftSection: _jsx(IconCrop, { size: 16, stroke: 1.75 }), onClick: () => setCropDialogOpen(true), children: "Crop & resize\u2026" })), options.allowCaption && (_jsx(Menu.Item, { leftSection: _jsx(IconTextCaption, { size: 16, stroke: 1.75 }), onClick: () => setDialog({ intent: 'caption' }), children: caption ? 'Edit caption…' : 'Add caption…' })), options.allowCaption && caption && (_jsx(Menu.Item, { leftSection: _jsx(IconX, { size: 16, stroke: 1.75 }), onClick: () => updateNode((node) => node.setCaption(null)), children: "Remove caption" })), _jsx(Menu.Divider, {}), _jsx(Menu.Item, { color: "red", leftSection: _jsx(IconTrash, { size: 16, stroke: 1.75 }), onClick: removeImage, children: "Delete image" })] })] }), _jsx(ImageDialog, { mode: "edit", opened: dialog !== null, initial: { src, altText, caption, linkType }, intent: dialog?.intent, onSubmit: applyEdit, onClose: () => {
|
|
148
161
|
setDialog(null);
|
|
149
162
|
editor.focus();
|
|
150
|
-
} })
|
|
163
|
+
} }), linkType === 'embedded' && (_jsx(ImageCropDialog, { opened: cropDialogOpen, initial: { src, width, height }, onSubmit: applyCrop, onClose: () => {
|
|
164
|
+
setCropDialogOpen(false);
|
|
165
|
+
editor.focus();
|
|
166
|
+
} }))] }));
|
|
151
167
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export interface ImageCropDialogValue {
|
|
2
|
+
src: string;
|
|
3
|
+
width: number | null;
|
|
4
|
+
height: number | null;
|
|
5
|
+
}
|
|
6
|
+
export interface ImageCropDialogProps {
|
|
7
|
+
opened: boolean;
|
|
8
|
+
/** The embedded image being edited — only images with pixels of their own
|
|
9
|
+
* (not linked ones) can be cropped, rotated or resized here. */
|
|
10
|
+
initial: {
|
|
11
|
+
src: string;
|
|
12
|
+
width: number | null;
|
|
13
|
+
height: number | null;
|
|
14
|
+
};
|
|
15
|
+
onSubmit: (value: ImageCropDialogValue) => void;
|
|
16
|
+
onClose: () => void;
|
|
17
|
+
}
|
|
18
|
+
/** Crop, rotate, flip and resize dialog for an embedded image, reached from
|
|
19
|
+
* its context menu. Edits that change pixels are re-embedded as a data URI,
|
|
20
|
+
* or stored through the host's `onImageUpload` when there is one. */
|
|
21
|
+
export declare function ImageCropDialog({ opened, initial, onSubmit, onClose }: ImageCropDialogProps): import("react").JSX.Element;
|
|
22
|
+
//# sourceMappingURL=ImageCropDialog.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ImageCropDialog.d.ts","sourceRoot":"","sources":["../../src/image/ImageCropDialog.tsx"],"names":[],"mappings":"AAOA,MAAM,WAAW,oBAAoB;IACnC,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;CACvB;AAED,MAAM,WAAW,oBAAoB;IACnC,MAAM,EAAE,OAAO,CAAC;IAChB;oEACgE;IAChE,OAAO,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;QAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,CAAC;IACtE,QAAQ,EAAE,CAAC,KAAK,EAAE,oBAAoB,KAAK,IAAI,CAAC;IAChD,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB;AAqBD;;qEAEqE;AACrE,wBAAgB,eAAe,CAAC,EAAE,MAAM,EAAE,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,EAAE,oBAAoB,+BAyJ3F"}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useState } from 'react';
|
|
3
|
+
import { Alert, Button, Checkbox, Divider, Group, Modal, NumberInput, Stack, Text } from '@mantine/core';
|
|
4
|
+
import { normalizeImageUrl } from './imageUrl.js';
|
|
5
|
+
import { dataUrlBytes, dataUrlToFile, fitDimension, mimeFromSrc } from './imageEdit.js';
|
|
6
|
+
import { ImageCropper } from './ImageCropper.js';
|
|
7
|
+
import { useImageOptions } from './ImageOptionsContext.js';
|
|
8
|
+
/** Natural pixel size of an image URL (for the aspect ratio and size presets). */
|
|
9
|
+
function useNaturalSize(src) {
|
|
10
|
+
const [size, setSize] = useState(null);
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
setSize(null);
|
|
13
|
+
if (!src)
|
|
14
|
+
return;
|
|
15
|
+
let cancelled = false;
|
|
16
|
+
const img = new Image();
|
|
17
|
+
img.onload = () => {
|
|
18
|
+
if (!cancelled && img.naturalWidth)
|
|
19
|
+
setSize({ width: img.naturalWidth, height: img.naturalHeight });
|
|
20
|
+
};
|
|
21
|
+
img.src = src;
|
|
22
|
+
return () => {
|
|
23
|
+
cancelled = true;
|
|
24
|
+
};
|
|
25
|
+
}, [src]);
|
|
26
|
+
return size;
|
|
27
|
+
}
|
|
28
|
+
/** Crop, rotate, flip and resize dialog for an embedded image, reached from
|
|
29
|
+
* its context menu. Edits that change pixels are re-embedded as a data URI,
|
|
30
|
+
* or stored through the host's `onImageUpload` when there is one. */
|
|
31
|
+
export function ImageCropDialog({ opened, initial, onSubmit, onClose }) {
|
|
32
|
+
const { maxSizeMB, onImageUpload } = useImageOptions();
|
|
33
|
+
const [pixelSrc, setPixelSrc] = useState(null);
|
|
34
|
+
const [width, setWidth] = useState(null);
|
|
35
|
+
const [height, setHeight] = useState(null);
|
|
36
|
+
const [lockAspect, setLockAspect] = useState(true);
|
|
37
|
+
const [error, setError] = useState(null);
|
|
38
|
+
const [busy, setBusy] = useState(false);
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
if (!opened)
|
|
41
|
+
return;
|
|
42
|
+
setPixelSrc(null);
|
|
43
|
+
setWidth(initial.width);
|
|
44
|
+
setHeight(initial.height);
|
|
45
|
+
setLockAspect(true);
|
|
46
|
+
setError(null);
|
|
47
|
+
setBusy(false);
|
|
48
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
49
|
+
}, [opened]);
|
|
50
|
+
const effectiveSrc = pixelSrc ?? initial.src;
|
|
51
|
+
const natural = useNaturalSize(effectiveSrc);
|
|
52
|
+
const resetSize = () => {
|
|
53
|
+
setWidth(null);
|
|
54
|
+
setHeight(null);
|
|
55
|
+
};
|
|
56
|
+
const onSizeChange = (changed, value) => {
|
|
57
|
+
const n = typeof value === 'number' ? value : null;
|
|
58
|
+
if (n === null) {
|
|
59
|
+
changed === 'width' ? setWidth(null) : setHeight(null);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (lockAspect && natural) {
|
|
63
|
+
const fitted = fitDimension(changed, n, natural.width, natural.height);
|
|
64
|
+
setWidth(fitted.width);
|
|
65
|
+
setHeight(fitted.height);
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
changed === 'width' ? setWidth(n) : setHeight(n);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
const handleSubmit = async (e) => {
|
|
72
|
+
e.preventDefault();
|
|
73
|
+
setError(null);
|
|
74
|
+
try {
|
|
75
|
+
let src = effectiveSrc;
|
|
76
|
+
if (pixelSrc) {
|
|
77
|
+
if (dataUrlBytes(pixelSrc) > maxSizeMB * 1024 * 1024) {
|
|
78
|
+
setError(`Image is larger than ${maxSizeMB} MB`);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (onImageUpload) {
|
|
82
|
+
setBusy(true);
|
|
83
|
+
const ext = mimeFromSrc(pixelSrc).split('/')[1];
|
|
84
|
+
src = await onImageUpload(dataUrlToFile(pixelSrc, `image.${ext}`));
|
|
85
|
+
if (!normalizeImageUrl(src)) {
|
|
86
|
+
setError('The upload handler returned an unusable image URL');
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
onSubmit({ src, width, height });
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
setError(err instanceof Error ? err.message : 'Could not save the image');
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
setBusy(false);
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
return (_jsx(Modal, { opened: opened, onClose: onClose, title: "Crop & resize", centered: true, size: "lg", children: _jsx("form", { onSubmit: handleSubmit, children: _jsxs(Stack, { gap: "md", children: [_jsx(ImageCropper, { src: effectiveSrc, canReset: pixelSrc !== null, onApply: (edited) => {
|
|
101
|
+
setPixelSrc(edited);
|
|
102
|
+
resetSize();
|
|
103
|
+
}, onReset: () => {
|
|
104
|
+
setPixelSrc(null);
|
|
105
|
+
setWidth(initial.width);
|
|
106
|
+
setHeight(initial.height);
|
|
107
|
+
} }), _jsx(Divider, { label: "Size", labelPosition: "left" }), _jsxs(Stack, { gap: "sm", children: [_jsxs(Group, { grow: true, align: "flex-end", children: [_jsx(NumberInput, { label: "Width (px)", min: 1, max: 10000, hideControls: true, placeholder: natural ? String(natural.width) : 'Auto', value: width ?? '', onChange: (v) => onSizeChange('width', v) }), _jsx(NumberInput, { label: "Height (px)", min: 1, max: 10000, hideControls: true, placeholder: natural ? String(natural.height) : 'Auto', value: height ?? '', onChange: (v) => onSizeChange('height', v) })] }), _jsx(Checkbox, { label: "Keep proportions", checked: lockAspect, onChange: (e) => setLockAspect(e.currentTarget.checked) }), _jsxs(Group, { gap: "xs", children: [[25, 50, 75, 100].map((pct) => (_jsxs(Button, { size: "xs", variant: "default", disabled: !natural, onClick: () => {
|
|
108
|
+
if (!natural)
|
|
109
|
+
return;
|
|
110
|
+
setWidth(Math.round((natural.width * pct) / 100));
|
|
111
|
+
setHeight(Math.round((natural.height * pct) / 100));
|
|
112
|
+
}, children: [pct, "%"] }, pct))), _jsx(Button, { size: "xs", variant: "subtle", onClick: resetSize, children: "Original size" })] }), _jsxs(Text, { size: "xs", c: "dimmed", children: ["You can also drag the corner handle on a selected image. ", natural ? `Original: ${natural.width} × ${natural.height} px.` : ''] })] }), error && (_jsx(Alert, { color: "red", variant: "light", p: "xs", children: error })), _jsxs(Group, { justify: "flex-end", gap: "xs", children: [_jsx(Button, { variant: "default", onClick: onClose, children: "Cancel" }), _jsx(Button, { type: "submit", loading: busy, children: "Save" })] })] }) }) }));
|
|
113
|
+
}
|
|
@@ -5,17 +5,21 @@ export interface ImageDialogValue {
|
|
|
5
5
|
/** null = no caption */
|
|
6
6
|
caption: string | null;
|
|
7
7
|
linkType: ImageLinkType;
|
|
8
|
-
/**
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
/** Whether the pixels changed (a new URL/upload, or a conversion) — the
|
|
9
|
+
* display size no longer matches, so the caller should reset it to auto. */
|
|
10
|
+
sourceChanged: boolean;
|
|
11
11
|
}
|
|
12
12
|
export interface ImageDialogProps {
|
|
13
|
-
/** `insert`: pick an image. `edit`: also change its source, and
|
|
14
|
-
* images only — size, crop and rotation. */
|
|
13
|
+
/** `insert`: pick an image. `edit`: also change its source, alt text and caption. */
|
|
15
14
|
mode: 'insert' | 'edit';
|
|
16
15
|
opened: boolean;
|
|
17
16
|
/** The image being edited (edit mode). */
|
|
18
|
-
initial?:
|
|
17
|
+
initial?: {
|
|
18
|
+
src: string;
|
|
19
|
+
altText: string;
|
|
20
|
+
caption: string | null;
|
|
21
|
+
linkType: ImageLinkType;
|
|
22
|
+
};
|
|
19
23
|
/** What to do as the dialog opens: focus the caption field, or start converting
|
|
20
24
|
* a linked image to an embedded one. */
|
|
21
25
|
intent?: 'caption' | 'convert';
|
|
@@ -23,17 +27,11 @@ export interface ImageDialogProps {
|
|
|
23
27
|
onClose: () => void;
|
|
24
28
|
}
|
|
25
29
|
/**
|
|
26
|
-
* Insert-image dialog (UI spec §6) and, in edit mode, the image
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* handles, and set the display size.
|
|
32
|
-
*
|
|
33
|
-
* Linked images are never pixel-edited: there is no copy of the pixels in the
|
|
34
|
-
* document to change. Converting one downloads it and embeds the copy. Edits
|
|
35
|
-
* that change pixels are embedded as a data URI, or stored through the host's
|
|
36
|
-
* `onImageUpload` when there is one.
|
|
30
|
+
* Insert-image dialog (UI spec §6) and, in edit mode, the image's details:
|
|
31
|
+
* replace the source (URL or upload), alt text, caption, and — for a linked
|
|
32
|
+
* image — convert it to an embedded one. Cropping, rotating and resizing an
|
|
33
|
+
* embedded image is a separate dialog (`ImageCropDialog`), reached from the
|
|
34
|
+
* image's context menu once it has pixels of its own to edit.
|
|
37
35
|
*/
|
|
38
36
|
export declare function ImageDialog({ mode, opened, initial, intent, onSubmit, onClose }: ImageDialogProps): import("react").JSX.Element;
|
|
39
37
|
//# sourceMappingURL=ImageDialog.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ImageDialog.d.ts","sourceRoot":"","sources":["../../src/image/ImageDialog.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"ImageDialog.d.ts","sourceRoot":"","sources":["../../src/image/ImageDialog.tsx"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAEjD,MAAM,WAAW,gBAAgB;IAC/B,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC;IAChB,wBAAwB;IACxB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,QAAQ,EAAE,aAAa,CAAC;IACxB;gFAC4E;IAC5E,aAAa,EAAE,OAAO,CAAC;CACxB;AAED,MAAM,WAAW,gBAAgB;IAC/B,qFAAqF;IACrF,IAAI,EAAE,QAAQ,GAAG,MAAM,CAAC;IACxB,MAAM,EAAE,OAAO,CAAC;IAChB,0CAA0C;IAC1C,OAAO,CAAC,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;QAAC,QAAQ,EAAE,aAAa,CAAA;KAAE,CAAC;IAC5F;4CACwC;IACxC,MAAM,CAAC,EAAE,SAAS,GAAG,SAAS,CAAC;IAC/B,QAAQ,EAAE,CAAC,KAAK,EAAE,gBAAgB,KAAK,IAAI,CAAC;IAC5C,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB;AAaD;;;;;;GAMG;AACH,wBAAgB,WAAW,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,OAAO,EAAE,EAAE,gBAAgB,+BAmPjG"}
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { useEffect, useState } from 'react';
|
|
3
|
-
import { Alert, Button, Checkbox,
|
|
3
|
+
import { Alert, Button, Checkbox, FileInput, Group, Modal, SegmentedControl, Stack, Text, TextInput } from '@mantine/core';
|
|
4
4
|
import { IconPhotoDown } from '@tabler/icons-react';
|
|
5
5
|
import { ACCEPTED_IMAGE_TYPES, normalizeImageUrl, validateImageFile } from './imageUrl.js';
|
|
6
|
-
import { dataUrlBytes, dataUrlToFile, fetchImageAsDataUrl,
|
|
7
|
-
import { ImageCropper } from './ImageCropper.js';
|
|
6
|
+
import { dataUrlBytes, dataUrlToFile, fetchImageAsDataUrl, mimeFromSrc } from './imageEdit.js';
|
|
8
7
|
import { useImageOptions } from './ImageOptionsContext.js';
|
|
9
8
|
function readAsDataUrl(file) {
|
|
10
9
|
return new Promise((resolve, reject) => {
|
|
@@ -14,44 +13,17 @@ function readAsDataUrl(file) {
|
|
|
14
13
|
reader.readAsDataURL(file);
|
|
15
14
|
});
|
|
16
15
|
}
|
|
17
|
-
/** Natural pixel size of an image URL (for the aspect ratio and size presets). */
|
|
18
|
-
function useNaturalSize(src) {
|
|
19
|
-
const [size, setSize] = useState(null);
|
|
20
|
-
useEffect(() => {
|
|
21
|
-
setSize(null);
|
|
22
|
-
if (!src)
|
|
23
|
-
return;
|
|
24
|
-
let cancelled = false;
|
|
25
|
-
const img = new Image();
|
|
26
|
-
img.onload = () => {
|
|
27
|
-
if (!cancelled && img.naturalWidth)
|
|
28
|
-
setSize({ width: img.naturalWidth, height: img.naturalHeight });
|
|
29
|
-
};
|
|
30
|
-
img.src = src;
|
|
31
|
-
return () => {
|
|
32
|
-
cancelled = true;
|
|
33
|
-
};
|
|
34
|
-
}, [src]);
|
|
35
|
-
return size;
|
|
36
|
-
}
|
|
37
16
|
/**
|
|
38
|
-
* Insert-image dialog (UI spec §6) and, in edit mode, the image
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* handles, and set the display size.
|
|
44
|
-
*
|
|
45
|
-
* Linked images are never pixel-edited: there is no copy of the pixels in the
|
|
46
|
-
* document to change. Converting one downloads it and embeds the copy. Edits
|
|
47
|
-
* that change pixels are embedded as a data URI, or stored through the host's
|
|
48
|
-
* `onImageUpload` when there is one.
|
|
17
|
+
* Insert-image dialog (UI spec §6) and, in edit mode, the image's details:
|
|
18
|
+
* replace the source (URL or upload), alt text, caption, and — for a linked
|
|
19
|
+
* image — convert it to an embedded one. Cropping, rotating and resizing an
|
|
20
|
+
* embedded image is a separate dialog (`ImageCropDialog`), reached from the
|
|
21
|
+
* image's context menu once it has pixels of its own to edit.
|
|
49
22
|
*/
|
|
50
23
|
export function ImageDialog({ mode, opened, initial, intent, onSubmit, onClose }) {
|
|
51
24
|
const { allowLinked, allowEmbedded, allowCaption, maxSizeMB, onImageUpload, fetchImage } = useImageOptions();
|
|
52
25
|
const editing = mode === 'edit' && initial !== undefined;
|
|
53
26
|
const defaultSource = editing ? 'keep' : allowLinked ? 'linked' : 'embedded';
|
|
54
|
-
const [tab, setTab] = useState('details');
|
|
55
27
|
const [source, setSource] = useState(defaultSource);
|
|
56
28
|
const [url, setUrl] = useState('');
|
|
57
29
|
const [embedCopy, setEmbedCopy] = useState(false);
|
|
@@ -59,17 +31,12 @@ export function ImageDialog({ mode, opened, initial, intent, onSubmit, onClose }
|
|
|
59
31
|
const [fileSrc, setFileSrc] = useState(null);
|
|
60
32
|
const [altText, setAltText] = useState('');
|
|
61
33
|
const [caption, setCaption] = useState('');
|
|
62
|
-
const [width, setWidth] = useState(null);
|
|
63
|
-
const [height, setHeight] = useState(null);
|
|
64
|
-
const [lockAspect, setLockAspect] = useState(true);
|
|
65
|
-
const [pixelSrc, setPixelSrc] = useState(null);
|
|
66
34
|
const [error, setError] = useState(null);
|
|
67
35
|
const [busy, setBusy] = useState(false);
|
|
68
36
|
const [converting, setConverting] = useState(false);
|
|
69
37
|
useEffect(() => {
|
|
70
38
|
if (!opened)
|
|
71
39
|
return;
|
|
72
|
-
setTab('details');
|
|
73
40
|
setSource(defaultSource);
|
|
74
41
|
setUrl('');
|
|
75
42
|
setEmbedCopy(false);
|
|
@@ -77,10 +44,6 @@ export function ImageDialog({ mode, opened, initial, intent, onSubmit, onClose }
|
|
|
77
44
|
setFileSrc(null);
|
|
78
45
|
setAltText(initial?.altText ?? '');
|
|
79
46
|
setCaption(initial?.caption ?? '');
|
|
80
|
-
setWidth(initial?.width ?? null);
|
|
81
|
-
setHeight(initial?.height ?? null);
|
|
82
|
-
setLockAspect(true);
|
|
83
|
-
setPixelSrc(null);
|
|
84
47
|
setError(null);
|
|
85
48
|
setBusy(false);
|
|
86
49
|
setConverting(false);
|
|
@@ -88,32 +51,18 @@ export function ImageDialog({ mode, opened, initial, intent, onSubmit, onClose }
|
|
|
88
51
|
void convert(initial.src);
|
|
89
52
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
90
53
|
}, [opened]);
|
|
91
|
-
// The image before any crop/rotate: the current one, the typed URL, or the chosen file.
|
|
92
54
|
const baseSrc = source === 'keep' ? (initial?.src ?? null) : source === 'linked' ? normalizeImageUrl(url) : fileSrc;
|
|
93
|
-
const effectiveSrc = pixelSrc ?? baseSrc;
|
|
94
|
-
const natural = useNaturalSize(effectiveSrc);
|
|
95
|
-
// Only embedded images have pixels of their own to crop, rotate and resize.
|
|
96
55
|
const effectiveLinkType = source === 'keep' ? (initial?.linkType ?? 'linked') : source === 'linked' ? 'linked' : 'embedded';
|
|
97
|
-
const canEditPixels = effectiveLinkType === 'embedded' && effectiveSrc !== null;
|
|
98
56
|
const convertedFromUrl = source === 'embedded' && fileSrc !== null && file === null;
|
|
99
|
-
/** A new source or new pixels invalidate any custom size. */
|
|
100
|
-
const resetSize = (toInitial = false) => {
|
|
101
|
-
setWidth(toInitial ? (initial?.width ?? null) : null);
|
|
102
|
-
setHeight(toInitial ? (initial?.height ?? null) : null);
|
|
103
|
-
};
|
|
104
57
|
const changeSource = (next) => {
|
|
105
58
|
setSource(next);
|
|
106
|
-
setPixelSrc(null);
|
|
107
59
|
setFile(null);
|
|
108
60
|
setFileSrc(null);
|
|
109
|
-
resetSize(next === 'keep');
|
|
110
61
|
setError(null);
|
|
111
62
|
};
|
|
112
63
|
const changeFile = async (next) => {
|
|
113
64
|
setFile(next);
|
|
114
65
|
setFileSrc(null);
|
|
115
|
-
setPixelSrc(null);
|
|
116
|
-
resetSize();
|
|
117
66
|
setError(null);
|
|
118
67
|
if (!next)
|
|
119
68
|
return;
|
|
@@ -142,7 +91,6 @@ export function ImageDialog({ mode, opened, initial, intent, onSubmit, onClose }
|
|
|
142
91
|
setSource('embedded');
|
|
143
92
|
setFile(null);
|
|
144
93
|
setFileSrc(dataUrl);
|
|
145
|
-
setPixelSrc(null);
|
|
146
94
|
}
|
|
147
95
|
catch (err) {
|
|
148
96
|
setError(err instanceof Error ? err.message : 'Could not convert the image');
|
|
@@ -151,38 +99,23 @@ export function ImageDialog({ mode, opened, initial, intent, onSubmit, onClose }
|
|
|
151
99
|
setConverting(false);
|
|
152
100
|
}
|
|
153
101
|
};
|
|
154
|
-
const onSizeChange = (changed, value) => {
|
|
155
|
-
const n = typeof value === 'number' ? value : null;
|
|
156
|
-
if (n === null) {
|
|
157
|
-
changed === 'width' ? setWidth(null) : setHeight(null);
|
|
158
|
-
return;
|
|
159
|
-
}
|
|
160
|
-
if (lockAspect && natural) {
|
|
161
|
-
const fitted = fitDimension(changed, n, natural.width, natural.height);
|
|
162
|
-
setWidth(fitted.width);
|
|
163
|
-
setHeight(fitted.height);
|
|
164
|
-
}
|
|
165
|
-
else {
|
|
166
|
-
changed === 'width' ? setWidth(n) : setHeight(n);
|
|
167
|
-
}
|
|
168
|
-
};
|
|
169
102
|
const handleSubmit = async (e) => {
|
|
170
103
|
e.preventDefault();
|
|
171
104
|
setError(null);
|
|
172
|
-
if (!
|
|
105
|
+
if (!baseSrc) {
|
|
173
106
|
setError(source === 'linked' ? 'Enter an http(s) or relative image URL' : source === 'embedded' ? 'Choose an image file' : 'No image');
|
|
174
107
|
return;
|
|
175
108
|
}
|
|
176
109
|
try {
|
|
177
|
-
let src =
|
|
110
|
+
let src = baseSrc;
|
|
178
111
|
let linkType = effectiveLinkType;
|
|
179
|
-
// Pixels the document doesn't reference yet (an upload,
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
let newPixels =
|
|
112
|
+
// Pixels the document doesn't reference yet (an upload, or a downloaded
|
|
113
|
+
// copy of a linked image) are stored: through the host's handler, or
|
|
114
|
+
// embedded as a data URI.
|
|
115
|
+
let newPixels = source === 'embedded' ? fileSrc : null;
|
|
183
116
|
if (!newPixels && source === 'linked' && embedCopy) {
|
|
184
117
|
setBusy(true);
|
|
185
|
-
newPixels = await fetchImageAsDataUrl(
|
|
118
|
+
newPixels = await fetchImageAsDataUrl(baseSrc, fetchImage);
|
|
186
119
|
}
|
|
187
120
|
if (newPixels) {
|
|
188
121
|
if (dataUrlBytes(newPixels) > maxSizeMB * 1024 * 1024) {
|
|
@@ -193,7 +126,7 @@ export function ImageDialog({ mode, opened, initial, intent, onSubmit, onClose }
|
|
|
193
126
|
if (onImageUpload) {
|
|
194
127
|
setBusy(true);
|
|
195
128
|
const ext = mimeFromSrc(newPixels).split('/')[1];
|
|
196
|
-
const upload =
|
|
129
|
+
const upload = !file ? dataUrlToFile(newPixels, `image.${ext}`) : file;
|
|
197
130
|
src = await onImageUpload(upload);
|
|
198
131
|
if (!normalizeImageUrl(src)) {
|
|
199
132
|
setError('The upload handler returned an unusable image URL');
|
|
@@ -209,8 +142,7 @@ export function ImageDialog({ mode, opened, initial, intent, onSubmit, onClose }
|
|
|
209
142
|
altText: altText.trim(),
|
|
210
143
|
caption: allowCaption ? caption.trim() || null : (initial?.caption ?? null),
|
|
211
144
|
linkType,
|
|
212
|
-
|
|
213
|
-
height,
|
|
145
|
+
sourceChanged: source !== 'keep',
|
|
214
146
|
});
|
|
215
147
|
}
|
|
216
148
|
catch (err) {
|
|
@@ -221,28 +153,10 @@ export function ImageDialog({ mode, opened, initial, intent, onSubmit, onClose }
|
|
|
221
153
|
}
|
|
222
154
|
};
|
|
223
155
|
const convertButton = (_jsx(Button, { size: "xs", variant: "light", leftSection: _jsx(IconPhotoDown, { size: 14 }), loading: converting, disabled: !baseSrc, onClick: () => convert(baseSrc), children: "Convert to embedded image" }));
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
setPixelSrc(null);
|
|
231
|
-
resetSize();
|
|
232
|
-
}, "data-autofocus": intent !== 'caption' ? true : undefined }), allowEmbedded && (_jsx(Checkbox, { label: "Embed a copy in the document", description: "Downloads the image so it can be edited and no longer depends on the URL.", checked: embedCopy, onChange: (e) => setEmbedCopy(e.currentTarget.checked) }))] })), source === 'embedded' &&
|
|
233
|
-
(convertedFromUrl ? (_jsx(Text, { size: "sm", c: "dimmed", children: "Converted from the URL: a copy will be embedded in the document." })) : (_jsx(FileInput, { label: "Image file", placeholder: "Choose an image", accept: ACCEPTED_IMAGE_TYPES.join(','), value: file, onChange: changeFile, description: `Up to ${maxSizeMB} MB${onImageUpload ? '' : '; stored inside the document'}` }))), _jsx(TextInput, { label: "Alt text", description: "Describes the image for screen readers", value: altText, onChange: (e) => setAltText(e.currentTarget.value) }), allowCaption && (_jsx(TextInput, { label: "Caption", description: "Shown under the image. Leave empty for none.", value: caption, onChange: (e) => setCaption(e.currentTarget.value), "data-autofocus": intent === 'caption' ? true : undefined }))] }));
|
|
234
|
-
const sizeSection = (_jsxs(Stack, { gap: "sm", children: [_jsxs(Group, { grow: true, align: "flex-end", children: [_jsx(NumberInput, { label: "Width (px)", min: 1, max: 10000, hideControls: true, placeholder: natural ? String(natural.width) : 'Auto', value: width ?? '', onChange: (v) => onSizeChange('width', v) }), _jsx(NumberInput, { label: "Height (px)", min: 1, max: 10000, hideControls: true, placeholder: natural ? String(natural.height) : 'Auto', value: height ?? '', onChange: (v) => onSizeChange('height', v) })] }), _jsx(Checkbox, { label: "Keep proportions", checked: lockAspect, onChange: (e) => setLockAspect(e.currentTarget.checked) }), _jsxs(Group, { gap: "xs", children: [[25, 50, 75, 100].map((pct) => (_jsxs(Button, { size: "xs", variant: "default", disabled: !natural, onClick: () => {
|
|
235
|
-
if (!natural)
|
|
236
|
-
return;
|
|
237
|
-
setWidth(Math.round((natural.width * pct) / 100));
|
|
238
|
-
setHeight(Math.round((natural.height * pct) / 100));
|
|
239
|
-
}, children: [pct, "%"] }, pct))), _jsx(Button, { size: "xs", variant: "subtle", onClick: () => resetSize(), children: "Original size" })] }), _jsxs(Text, { size: "xs", c: "dimmed", children: ["You can also drag the corner handle on a selected image. ", natural ? `Original: ${natural.width} × ${natural.height} px.` : ''] })] }));
|
|
240
|
-
const editPanel = canEditPixels ? (_jsxs(Stack, { gap: "md", children: [_jsx(ImageCropper, { src: effectiveSrc, canReset: pixelSrc !== null, onApply: (edited) => {
|
|
241
|
-
setPixelSrc(edited);
|
|
242
|
-
resetSize();
|
|
243
|
-
}, onReset: () => {
|
|
244
|
-
setPixelSrc(null);
|
|
245
|
-
resetSize(source === 'keep');
|
|
246
|
-
} }), _jsx(Divider, { label: "Size", labelPosition: "left" }), sizeSection] })) : (_jsxs(Stack, { gap: "sm", align: "flex-start", children: [_jsx(Text, { size: "sm", children: "This image is linked from a URL, so it can't be cropped, rotated or resized here. Convert it to an embedded image to edit it." }), convertButton] }));
|
|
247
|
-
return (_jsx(Modal, { opened: opened, onClose: onClose, title: editing ? 'Edit image' : 'Insert image', centered: true, size: editing ? 'lg' : 'sm', children: _jsx("form", { onSubmit: handleSubmit, children: _jsxs(Stack, { gap: "sm", children: [editing ? (_jsxs(Tabs, { value: tab, onChange: (v) => setTab(v ?? 'details'), keepMounted: false, children: [_jsxs(Tabs.List, { mb: "sm", children: [_jsx(Tabs.Tab, { value: "details", children: "Details" }), _jsx(Tabs.Tab, { value: "edit", disabled: !effectiveSrc, children: "Crop & size" })] }), _jsx(Tabs.Panel, { value: "details", children: detailsPanel }), _jsx(Tabs.Panel, { value: "edit", children: editPanel })] })) : (detailsPanel), error && (_jsx(Alert, { color: "red", variant: "light", p: "xs", children: error })), _jsxs(Group, { justify: "flex-end", gap: "xs", children: [_jsx(Button, { variant: "default", onClick: onClose, children: "Cancel" }), _jsx(Button, { type: "submit", loading: busy, children: editing ? 'Save' : 'Insert' })] })] }) }) }));
|
|
156
|
+
return (_jsx(Modal, { opened: opened, onClose: onClose, title: editing ? 'Edit image' : 'Insert image', centered: true, size: "sm", children: _jsx("form", { onSubmit: handleSubmit, children: _jsxs(Stack, { gap: "sm", children: [(editing || (allowLinked && allowEmbedded)) && (_jsx(SegmentedControl, { fullWidth: true, value: source, onChange: (v) => changeSource(v), data: [
|
|
157
|
+
...(editing ? [{ value: 'keep', label: 'Current image' }] : []),
|
|
158
|
+
...(allowLinked ? [{ value: 'linked', label: 'From URL' }] : []),
|
|
159
|
+
...(allowEmbedded ? [{ value: 'embedded', label: 'Upload' }] : []),
|
|
160
|
+
] })), source === 'keep' && (_jsxs(Stack, { gap: 6, children: [_jsx(Text, { size: "sm", c: "dimmed", lineClamp: 2, style: { wordBreak: 'break-all' }, children: initial?.linkType === 'embedded' ? 'Embedded in the document' : `Linked from ${initial?.src}` }), initial?.linkType === 'linked' && allowEmbedded && (_jsxs(Group, { gap: "xs", children: [convertButton, _jsx(Text, { size: "xs", c: "dimmed", children: "Downloads a copy into the document, so it can be cropped, rotated and resized." })] }))] })), source === 'linked' && (_jsxs(_Fragment, { children: [_jsx(TextInput, { label: "Image URL", placeholder: "https://example.com/photo.jpg", value: url, onChange: (e) => setUrl(e.currentTarget.value), "data-autofocus": intent !== 'caption' ? true : undefined }), allowEmbedded && (_jsx(Checkbox, { label: "Embed a copy in the document", description: "Downloads the image so it can be edited and no longer depends on the URL.", checked: embedCopy, onChange: (e) => setEmbedCopy(e.currentTarget.checked) }))] })), source === 'embedded' &&
|
|
161
|
+
(convertedFromUrl ? (_jsx(Text, { size: "sm", c: "dimmed", children: "Converted from the URL: a copy will be embedded in the document." })) : (_jsx(FileInput, { label: "Image file", placeholder: "Choose an image", accept: ACCEPTED_IMAGE_TYPES.join(','), value: file, onChange: changeFile, description: `Up to ${maxSizeMB} MB${onImageUpload ? '' : '; stored inside the document'}` }))), _jsx(TextInput, { label: "Alt text", description: "Describes the image for screen readers", value: altText, onChange: (e) => setAltText(e.currentTarget.value) }), allowCaption && (_jsx(TextInput, { label: "Caption", description: "Shown under the image. Leave empty for none.", value: caption, onChange: (e) => setCaption(e.currentTarget.value), "data-autofocus": intent === 'caption' ? true : undefined })), error && (_jsx(Alert, { color: "red", variant: "light", p: "xs", children: error })), _jsxs(Group, { justify: "flex-end", gap: "xs", children: [_jsx(Button, { variant: "default", onClick: onClose, children: "Cancel" }), _jsx(Button, { type: "submit", loading: busy, children: editing ? 'Save' : 'Insert' })] })] }) }) }));
|
|
248
162
|
}
|