@inshapardaz/likhari-react 0.1.6-dev.51 → 0.1.6-dev.56

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.
@@ -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;AAGjB,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,+BA2M9G"}
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,12 +1,14 @@
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';
11
+ import { dataUrlBytes, fetchImageAsDataUrl } from './imageEdit.js';
10
12
  // A type-only import of ImageNode (ImageNode itself imports this file), so the
11
13
  // node is recognised by its type string rather than `instanceof`.
12
14
  function isImageNode(node) {
@@ -29,6 +31,7 @@ export function ImageComponent({ nodeKey, src, altText, caption, linkType, width
29
31
  const figureRef = useRef(null);
30
32
  const [menu, setMenu] = useState(null);
31
33
  const [dialog, setDialog] = useState(null);
34
+ const [cropDialogOpen, setCropDialogOpen] = useState(false);
32
35
  const [liveWidth, setLiveWidth] = useState(null);
33
36
  // Selecting must go through CLICK_COMMAND (returning true) rather than a
34
37
  // React onClick: otherwise Lexical's own click handling turns the DOM
@@ -48,14 +51,14 @@ export function ImageComponent({ nodeKey, src, altText, caption, linkType, width
48
51
  });
49
52
  }, [editor, nodeKey]);
50
53
  const onDelete = useCallback((event) => {
51
- if (!isSelected || dialog || menu)
54
+ if (!isSelected || dialog || cropDialogOpen || menu)
52
55
  return false;
53
56
  if (!$isNodeSelection($getSelection()))
54
57
  return false;
55
58
  event.preventDefault();
56
59
  $getNodeByKey(nodeKey)?.remove();
57
60
  return true;
58
- }, [isSelected, dialog, menu, nodeKey]);
61
+ }, [isSelected, dialog, cropDialogOpen, menu, nodeKey]);
59
62
  useEffect(() => {
60
63
  const unregisterDelete = editor.registerCommand(KEY_DELETE_COMMAND, onDelete, COMMAND_PRIORITY_LOW);
61
64
  const unregisterBackspace = editor.registerCommand(KEY_BACKSPACE_COMMAND, onDelete, COMMAND_PRIORITY_LOW);
@@ -71,16 +74,44 @@ export function ImageComponent({ nodeKey, src, altText, caption, linkType, width
71
74
  change(node);
72
75
  });
73
76
  };
77
+ // Converting from the context menu (as opposed to the "Crop & size" tab's
78
+ // checkbox) needs no review: just download and swap the source in place.
79
+ // Only on failure do we fall back to the full dialog, which shows the error
80
+ // and lets the person retry or pick a different image.
81
+ const convertToEmbedded = useCallback(async () => {
82
+ try {
83
+ const dataUrl = await fetchImageAsDataUrl(src, options.fetchImage);
84
+ if (dataUrlBytes(dataUrl) > options.maxSizeMB * 1024 * 1024) {
85
+ throw new Error(`Image is larger than ${options.maxSizeMB} MB`);
86
+ }
87
+ updateNode((node) => node.setSource(dataUrl, 'embedded'));
88
+ }
89
+ catch {
90
+ setDialog({ intent: 'convert' });
91
+ }
92
+ // eslint-disable-next-line react-hooks/exhaustive-deps
93
+ }, [src, options.fetchImage, options.maxSizeMB]);
74
94
  const applyEdit = (value) => {
75
95
  updateNode((node) => {
76
96
  node.setSource(value.src, value.linkType);
77
97
  node.setAltText(value.altText);
78
98
  node.setCaption(value.caption);
79
- node.setDimensions(value.width, value.height);
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);
80
103
  });
81
104
  setDialog(null);
82
105
  editor.focus();
83
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
+ };
84
115
  // --- drag-to-resize: bottom-right handle, aspect ratio kept ---------------
85
116
  const resizeStart = useRef(null);
86
117
  const onResizeDown = (event) => {
@@ -126,8 +157,11 @@ export function ImageComponent({ nodeKey, src, altText, caption, linkType, width
126
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) => {
127
158
  if (!opened)
128
159
  setMenu(null);
129
- }, 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: () => setDialog({ intent: 'convert' }), children: "Convert to embedded image\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, width, height }, intent: dialog?.intent, onSubmit: applyEdit, onClose: () => {
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: () => {
130
161
  setDialog(null);
131
162
  editor.focus();
132
- } })] }));
163
+ } }), linkType === 'embedded' && (_jsx(ImageCropDialog, { opened: cropDialogOpen, initial: { src, width, height }, onSubmit: applyCrop, onClose: () => {
164
+ setCropDialogOpen(false);
165
+ editor.focus();
166
+ } }))] }));
133
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
- /** Display size in px; null = natural size */
9
- width: number | null;
10
- height: number | null;
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 — for embedded
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?: ImageDialogValue;
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 editor.
27
- *
28
- * - *Details*: replace the image (URL or upload), alt text, caption, and — for a
29
- * linked image — convert it to an embedded one.
30
- * - *Crop & size* (embedded images only): rotate, flip, crop with draggable
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":"AAqBA,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,8CAA8C;IAC9C,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;CACvB;AAED,MAAM,WAAW,gBAAgB;IAC/B;gDAC4C;IAC5C,IAAI,EAAE,QAAQ,GAAG,MAAM,CAAC;IACxB,MAAM,EAAE,OAAO,CAAC;IAChB,0CAA0C;IAC1C,OAAO,CAAC,EAAE,gBAAgB,CAAC;IAC3B;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;AAgCD;;;;;;;;;;;;GAYG;AACH,wBAAgB,WAAW,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,OAAO,EAAE,EAAE,gBAAgB,+BAgYjG"}
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, Divider, FileInput, Group, Modal, NumberInput, SegmentedControl, Stack, Tabs, Text, TextInput, } from '@mantine/core';
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, fitDimension, mimeFromSrc } from './imageEdit.js';
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 editor.
39
- *
40
- * - *Details*: replace the image (URL or upload), alt text, caption, and — for a
41
- * linked image — convert it to an embedded one.
42
- * - *Crop & size* (embedded images only): rotate, flip, crop with draggable
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 (!effectiveSrc) {
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 = effectiveSrc;
110
+ let src = baseSrc;
178
111
  let linkType = effectiveLinkType;
179
- // Pixels the document doesn't reference yet (an upload, an edit, or a
180
- // downloaded copy of a linked image) are stored: through the host's
181
- // handler, or embedded as a data URI.
182
- let newPixels = pixelSrc ?? (source === 'embedded' ? fileSrc : null);
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(effectiveSrc, fetchImage);
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 = pixelSrc || !file ? dataUrlToFile(newPixels, `image.${ext}`) : file;
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
- width,
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
- const detailsPanel = (_jsxs(Stack, { gap: "sm", children: [(editing || (allowLinked && allowEmbedded)) && (_jsx(SegmentedControl, { fullWidth: true, value: source, onChange: (v) => changeSource(v), data: [
225
- ...(editing ? [{ value: 'keep', label: 'Current image' }] : []),
226
- ...(allowLinked ? [{ value: 'linked', label: 'From URL' }] : []),
227
- ...(allowEmbedded ? [{ value: 'embedded', label: 'Upload' }] : []),
228
- ] })), 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) => {
229
- setUrl(e.currentTarget.value);
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inshapardaz/likhari-react",
3
- "version": "0.1.6-dev.51",
3
+ "version": "0.1.6-dev.56",
4
4
  "description": "React implementation of the Likhari editor, built on @lexical/react.",
5
5
  "license": "MIT",
6
6
  "repository": {