@inshapardaz/likhari-react 0.1.6-dev.43 → 0.1.6-dev.46

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.
@@ -12,8 +12,9 @@ interface ImageComponentProps {
12
12
  }
13
13
  /**
14
14
  * An image in the document. Click selects it (Delete/Backspace removes it),
15
- * drag the corner handle to resize, double-click or right-click for the edit
16
- * dialog and menu (edit image, add/edit/remove caption, delete). The caption
15
+ * drag the corner handle to resize an embedded image, double-click or
16
+ * right-click for the edit dialog and menu (edit image, convert a linked image
17
+ * to embedded, add/edit/remove caption, delete). The caption
17
18
  * is shown as plain text under the image — it is edited in the dialog, not in
18
19
  * place.
19
20
  */
@@ -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;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,EAAE,OAAO,EAAE,GAAG,EAAE,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,KAAK,EAAE,MAAM,EAAE,EAAE,mBAAmB,+BAsM9G"}
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,7 +1,7 @@
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 { IconPhotoEdit, IconTextCaption, IconTrash, IconX } from '@tabler/icons-react';
4
+ import { 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';
@@ -15,8 +15,9 @@ function isImageNode(node) {
15
15
  const MIN_WIDTH = 32;
16
16
  /**
17
17
  * An image in the document. Click selects it (Delete/Backspace removes it),
18
- * drag the corner handle to resize, double-click or right-click for the edit
19
- * dialog and menu (edit image, add/edit/remove caption, delete). The caption
18
+ * drag the corner handle to resize an embedded image, double-click or
19
+ * right-click for the edit dialog and menu (edit image, convert a linked image
20
+ * to embedded, add/edit/remove caption, delete). The caption
20
21
  * is shown as plain text under the image — it is edited in the dialog, not in
21
22
  * place.
22
23
  */
@@ -122,10 +123,10 @@ export function ImageComponent({ nodeKey, src, altText, caption, linkType, width
122
123
  clearSelection();
123
124
  setSelected(true);
124
125
  setMenu({ x: e.clientX, y: e.clientY });
125
- }, children: [_jsxs("span", { className: "likhari-image-wrap", children: [_jsx("img", { ref: imageRef, className: "likhari-image", src: src, alt: altText, width: shownWidth, draggable: false }), isSelected && (_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) => {
126
+ }, 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) => {
126
127
  if (!opened)
127
128
  setMenu(null);
128
- }, 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" }), options.allowCaption && (_jsx(Menu.Item, { leftSection: _jsx(IconTextCaption, { size: 16, stroke: 1.75 }), onClick: () => setDialog({ focus: '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 }, initialFocus: dialog?.focus, onSubmit: applyEdit, onClose: () => {
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: () => {
129
130
  setDialog(null);
130
131
  editor.focus();
131
132
  } })] }));
@@ -8,7 +8,8 @@ export interface ImageCropperProps {
8
8
  onReset: () => void;
9
9
  }
10
10
  /** Rotate, flip and crop. Drag on the preview to choose the area to keep, then
11
- * "Apply crop"; each operation re-renders the pixels, so it needs an image the
12
- * canvas may read (uploaded files and same-origin/CORS-enabled URLs). */
13
- export declare function ImageCropper({ src, onApply, canReset, onReset }: ImageCropperProps): import("react").JSX.Element;
11
+ * drag its handles to resize it or drag inside it to move it, then "Apply crop".
12
+ * Each operation re-renders the pixels, so it needs an image the canvas may read
13
+ * (an embedded image). */
14
+ export declare function ImageCropper({ src, onApply, canReset, onReset, }: ImageCropperProps): import("react").JSX.Element;
14
15
  //# sourceMappingURL=ImageCropper.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"ImageCropper.d.ts","sourceRoot":"","sources":["../../src/image/ImageCropper.tsx"],"names":[],"mappings":"AAgBA,MAAM,WAAW,iBAAiB;IAChC,4DAA4D;IAC5D,GAAG,EAAE,MAAM,CAAC;IACZ,kDAAkD;IAClD,OAAO,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;IAClC,uCAAuC;IACvC,QAAQ,EAAE,OAAO,CAAC;IAClB,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB;AAED;;yEAEyE;AACzE,wBAAgB,YAAY,CAAC,EAAE,GAAG,EAAE,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,EAAE,iBAAiB,+BA+JlF"}
1
+ {"version":3,"file":"ImageCropper.d.ts","sourceRoot":"","sources":["../../src/image/ImageCropper.tsx"],"names":[],"mappings":"AA+CA,MAAM,WAAW,iBAAiB;IAChC,4DAA4D;IAC5D,GAAG,EAAE,MAAM,CAAC;IACZ,kDAAkD;IAClD,OAAO,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;IAClC,uCAAuC;IACvC,QAAQ,EAAE,OAAO,CAAC;IAClB,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB;AAED;;;0BAG0B;AAC1B,wBAAgB,YAAY,CAAC,EAC3B,GAAG,EACH,OAAO,EACP,QAAQ,EACR,OAAO,GACR,EAAE,iBAAiB,+BAyOnB"}
@@ -1,18 +1,27 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useRef, useState } from 'react';
3
- import { ActionIcon, Alert, Button, Group, SegmentedControl, Stack, Text, Tooltip } from '@mantine/core';
4
- import { IconCrop, IconFlipHorizontal, IconFlipVertical, IconRestore, IconRotate2, IconRotateClockwise2, } from '@tabler/icons-react';
5
- import { applyPixelOp } from './imageEdit.js';
6
- const ASPECTS = { free: null, '1:1': 1, '4:3': 4 / 3, '16:9': 16 / 9 };
2
+ import { useRef, useState, } from "react";
3
+ import { ActionIcon, Alert, Button, Group, SegmentedControl, Stack, Text, Tooltip, } from "@mantine/core";
4
+ import { IconCrop, IconFlipHorizontal, IconFlipVertical, IconRestore, IconRotate2, IconRotateClockwise2, } from "@tabler/icons-react";
5
+ import { applyPixelOp } from "./imageEdit.js";
6
+ import { MIN_CROP, moveRect, rectFromDrag, resizeRect, } from "./cropMath.js";
7
+ const ASPECTS = {
8
+ free: null,
9
+ "1:1": 1,
10
+ "4:3": 4 / 3,
11
+ "16:9": 16 / 9,
12
+ };
13
+ const HANDLES = ["nw", "n", "ne", "e", "se", "s", "sw", "w"];
7
14
  const clamp = (v) => Math.min(Math.max(v, 0), 1);
8
15
  /** Rotate, flip and crop. Drag on the preview to choose the area to keep, then
9
- * "Apply crop"; each operation re-renders the pixels, so it needs an image the
10
- * canvas may read (uploaded files and same-origin/CORS-enabled URLs). */
11
- export function ImageCropper({ src, onApply, canReset, onReset }) {
16
+ * drag its handles to resize it or drag inside it to move it, then "Apply crop".
17
+ * Each operation re-renders the pixels, so it needs an image the canvas may read
18
+ * (an embedded image). */
19
+ export function ImageCropper({ src, onApply, canReset, onReset, }) {
12
20
  const imgRef = useRef(null);
13
- const dragStart = useRef(null);
21
+ const drag = useRef(null);
14
22
  const [rect, setRect] = useState(null);
15
- const [aspect, setAspect] = useState('free');
23
+ const [aspect, setAspect] = useState("free");
24
+ const [natural, setNatural] = useState(null);
16
25
  const [busy, setBusy] = useState(false);
17
26
  const [error, setError] = useState(null);
18
27
  const run = async (op) => {
@@ -23,55 +32,67 @@ export function ImageCropper({ src, onApply, canReset, onReset }) {
23
32
  setRect(null);
24
33
  }
25
34
  catch (err) {
26
- setError(err instanceof Error ? err.message : 'Could not edit the image');
35
+ setError(err instanceof Error ? err.message : "Could not edit the image");
27
36
  }
28
37
  finally {
29
38
  setBusy(false);
30
39
  }
31
40
  };
32
- const pointerFraction = (event) => {
41
+ const pointer = (event) => {
33
42
  const box = imgRef.current?.getBoundingClientRect();
34
43
  if (!box || !box.width || !box.height)
35
44
  return null;
36
- return { x: clamp((event.clientX - box.left) / box.width), y: clamp((event.clientY - box.top) / box.height), box };
45
+ return {
46
+ point: {
47
+ x: clamp((event.clientX - box.left) / box.width),
48
+ y: clamp((event.clientY - box.top) / box.height),
49
+ },
50
+ box: { width: box.width, height: box.height },
51
+ };
37
52
  };
38
53
  const onDown = (event) => {
39
- const p = pointerFraction(event);
54
+ const p = pointer(event);
40
55
  if (!p)
41
56
  return;
42
57
  event.preventDefault();
43
58
  event.currentTarget.setPointerCapture(event.pointerId);
44
- dragStart.current = { x: p.x, y: p.y };
45
- setRect({ x: p.x, y: p.y, w: 0, h: 0 });
59
+ const target = event.target;
60
+ const handle = target.dataset.handle;
61
+ if (handle && rect) {
62
+ drag.current = { mode: "resize", handle, origin: rect };
63
+ }
64
+ else if (target.dataset.move && rect) {
65
+ drag.current = { mode: "move", start: p.point, origin: rect };
66
+ }
67
+ else {
68
+ drag.current = { mode: "new", anchor: p.point };
69
+ setRect({ x: p.point.x, y: p.point.y, w: 0, h: 0 });
70
+ }
46
71
  };
47
72
  const onMove = (event) => {
48
- const start = dragStart.current;
49
- const p = pointerFraction(event);
50
- if (!start || !p)
73
+ const current = drag.current;
74
+ const p = pointer(event);
75
+ if (!current || !p)
51
76
  return;
52
- let dx = p.x - start.x;
53
- let dy = p.y - start.y;
54
77
  const ratio = ASPECTS[aspect];
55
- if (ratio) {
56
- // Keep the chosen aspect ratio in pixels, shrinking to fit inside the image.
57
- let widthPx = Math.abs(dx) * p.box.width;
58
- let heightPx = widthPx / ratio;
59
- const maxHeightPx = (dy >= 0 ? 1 - start.y : start.y) * p.box.height;
60
- if (heightPx > maxHeightPx) {
61
- heightPx = maxHeightPx;
62
- widthPx = heightPx * ratio;
63
- }
64
- dx = (dx >= 0 ? 1 : -1) * (widthPx / p.box.width);
65
- dy = (dy >= 0 ? 1 : -1) * (heightPx / p.box.height);
66
- }
67
- setRect({ x: Math.min(start.x, start.x + dx), y: Math.min(start.y, start.y + dy), w: Math.abs(dx), h: Math.abs(dy) });
78
+ if (current.mode === "new")
79
+ setRect(rectFromDrag(current.anchor, p.point, ratio, p.box));
80
+ else if (current.mode === "move")
81
+ setRect(moveRect(current.origin, p.point.x - current.start.x, p.point.y - current.start.y));
82
+ else
83
+ setRect(resizeRect(current.origin, current.handle, p.point, ratio, p.box));
68
84
  };
69
85
  const onUp = () => {
70
- dragStart.current = null;
86
+ const wasNew = drag.current?.mode === "new";
87
+ drag.current = null;
71
88
  // A click without a drag isn't a selection.
72
- setRect((r) => (r && (r.w < 0.02 || r.h < 0.02) ? null : r));
89
+ if (wasNew)
90
+ setRect((r) => (r && (r.w < MIN_CROP || r.h < MIN_CROP) ? null : r));
73
91
  };
74
- return (_jsxs(Stack, { gap: "sm", children: [_jsxs(Group, { gap: "xs", children: [_jsx(Tooltip, { label: "Rotate left", children: _jsx(ActionIcon, { variant: "default", "aria-label": "Rotate left", disabled: busy, onClick: () => run({ type: 'rotate', direction: 'ccw' }), children: _jsx(IconRotate2, { size: 16 }) }) }), _jsx(Tooltip, { label: "Rotate right", children: _jsx(ActionIcon, { variant: "default", "aria-label": "Rotate right", disabled: busy, onClick: () => run({ type: 'rotate', direction: 'cw' }), children: _jsx(IconRotateClockwise2, { size: 16 }) }) }), _jsx(Tooltip, { label: "Flip horizontally", children: _jsx(ActionIcon, { variant: "default", "aria-label": "Flip horizontally", disabled: busy, onClick: () => run({ type: 'flip', axis: 'horizontal' }), children: _jsx(IconFlipHorizontal, { size: 16 }) }) }), _jsx(Tooltip, { label: "Flip vertically", children: _jsx(ActionIcon, { variant: "default", "aria-label": "Flip vertically", disabled: busy, onClick: () => run({ type: 'flip', axis: 'vertical' }), children: _jsx(IconFlipVertical, { size: 16 }) }) }), _jsx(Button, { ml: "auto", size: "xs", variant: "subtle", leftSection: _jsx(IconRestore, { size: 14 }), disabled: !canReset || busy, onClick: () => {
92
+ const selectionSize = rect && natural && rect.w > 0 && rect.h > 0
93
+ ? `${Math.max(1, Math.round(rect.w * natural.width))} × ${Math.max(1, Math.round(rect.h * natural.height))} px`
94
+ : null;
95
+ return (_jsxs(Stack, { gap: "sm", children: [_jsxs(Group, { gap: "xs", children: [_jsx(Tooltip, { label: "Rotate left", children: _jsx(ActionIcon, { variant: "default", "aria-label": "Rotate left", disabled: busy, onClick: () => run({ type: "rotate", direction: "ccw" }), children: _jsx(IconRotate2, { size: 16 }) }) }), _jsx(Tooltip, { label: "Rotate right", children: _jsx(ActionIcon, { variant: "default", "aria-label": "Rotate right", disabled: busy, onClick: () => run({ type: "rotate", direction: "cw" }), children: _jsx(IconRotateClockwise2, { size: 16 }) }) }), _jsx(Tooltip, { label: "Flip horizontally", children: _jsx(ActionIcon, { variant: "default", "aria-label": "Flip horizontally", disabled: busy, onClick: () => run({ type: "flip", axis: "horizontal" }), children: _jsx(IconFlipHorizontal, { size: 16 }) }) }), _jsx(Tooltip, { label: "Flip vertically", children: _jsx(ActionIcon, { variant: "default", "aria-label": "Flip vertically", disabled: busy, onClick: () => run({ type: "flip", axis: "vertical" }), children: _jsx(IconFlipVertical, { size: 16 }) }) }), _jsx(Button, { ml: "auto", size: "xs", variant: "subtle", leftSection: _jsx(IconRestore, { size: 14 }), disabled: !canReset || busy, onClick: () => {
75
96
  setRect(null);
76
97
  setError(null);
77
98
  onReset();
@@ -79,9 +100,19 @@ export function ImageCropper({ src, onApply, canReset, onReset }) {
79
100
  setAspect(v);
80
101
  setRect(null);
81
102
  }, data: [
82
- { value: 'free', label: 'Free' },
83
- { value: '1:1', label: '1:1' },
84
- { value: '4:3', label: '4:3' },
85
- { value: '16:9', label: '16:9' },
86
- ] })] }), _jsxs("div", { className: "likhari-cropper-stage", onPointerDown: onDown, onPointerMove: onMove, onPointerUp: onUp, onPointerCancel: onUp, children: [_jsx("img", { ref: imgRef, src: src, alt: "", draggable: false, className: "likhari-cropper-image" }), rect && rect.w > 0 && rect.h > 0 && (_jsx("div", { className: "likhari-cropper-selection", style: { left: `${rect.x * 100}%`, top: `${rect.y * 100}%`, width: `${rect.w * 100}%`, height: `${rect.h * 100}%` } }))] }), _jsxs(Group, { justify: "space-between", children: [_jsx(Text, { size: "xs", c: "dimmed", children: "Drag on the image to choose the area to keep." }), _jsx(Button, { size: "xs", leftSection: _jsx(IconCrop, { size: 14 }), disabled: !rect || busy, loading: busy, onClick: () => rect && run({ type: 'crop', rect }), children: "Apply crop" })] }), error && (_jsx(Alert, { color: "red", variant: "light", p: "xs", children: error }))] }));
103
+ { value: "free", label: "Free" },
104
+ { value: "1:1", label: "1:1" },
105
+ { value: "4:3", label: "4:3" },
106
+ { value: "16:9", label: "16:9" },
107
+ ] })] }), _jsx("div", { className: "likhari-cropper-stage", onPointerDown: onDown, onPointerMove: onMove, onPointerUp: onUp, onPointerCancel: onUp, children: _jsxs("div", { className: "likhari-cropper-frame", children: [_jsx("img", { ref: imgRef, src: src, alt: "", draggable: false, className: "likhari-cropper-image", onLoad: (e) => setNatural({
108
+ width: e.currentTarget.naturalWidth,
109
+ height: e.currentTarget.naturalHeight,
110
+ }) }), rect && rect.w > 0 && rect.h > 0 && (_jsx("div", { className: "likhari-cropper-selection", "data-move": "true", style: {
111
+ left: `${rect.x * 100}%`,
112
+ top: `${rect.y * 100}%`,
113
+ width: `${rect.w * 100}%`,
114
+ height: `${rect.h * 100}%`,
115
+ }, children: HANDLES.map((handle) => (_jsx("span", { className: "likhari-cropper-handle", "data-handle": handle }, handle))) }))] }) }), _jsxs(Group, { justify: "space-between", children: [_jsx(Text, { size: "xs", c: "dimmed", children: selectionSize
116
+ ? `Selection: ${selectionSize}. Drag the handles to resize it, or drag inside to move it.`
117
+ : "Drag on the image to choose the area to keep." }), _jsx(Button, { size: "xs", leftSection: _jsx(IconCrop, { size: 14 }), disabled: !rect || busy, loading: busy, onClick: () => rect && run({ type: "crop", rect }), children: "Apply crop" })] }), error && (_jsx(Alert, { color: "red", variant: "light", p: "xs", children: error }))] }));
87
118
  }
@@ -10,21 +10,30 @@ export interface ImageDialogValue {
10
10
  height: number | null;
11
11
  }
12
12
  export interface ImageDialogProps {
13
- /** `insert`: pick an image. `edit`: also change its source, size, crop and rotation. */
13
+ /** `insert`: pick an image. `edit`: also change its source, and — for embedded
14
+ * images only — size, crop and rotation. */
14
15
  mode: 'insert' | 'edit';
15
16
  opened: boolean;
16
17
  /** The image being edited (edit mode). */
17
18
  initial?: ImageDialogValue;
18
- /** Focus the caption field when the dialog opens. */
19
- initialFocus?: 'caption';
19
+ /** What to do as the dialog opens: focus the caption field, or start converting
20
+ * a linked image to an embedded one. */
21
+ intent?: 'caption' | 'convert';
20
22
  onSubmit: (image: ImageDialogValue) => void;
21
23
  onClose: () => void;
22
24
  }
23
25
  /**
24
- * Insert-image dialog (UI spec §6) and, in edit mode, the image editor: change
25
- * the source (URL or upload), alt text, caption, display size, and crop/rotate.
26
- * Edits that change pixels (crop, rotate, flip) are embedded as a data URI, or
27
- * stored through the host's `onImageUpload` when there is one.
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.
28
37
  */
29
- export declare function ImageDialog({ mode, opened, initial, initialFocus, onSubmit, onClose }: ImageDialogProps): import("react").JSX.Element;
38
+ export declare function ImageDialog({ mode, opened, initial, intent, onSubmit, onClose }: ImageDialogProps): import("react").JSX.Element;
30
39
  //# sourceMappingURL=ImageDialog.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"ImageDialog.d.ts","sourceRoot":"","sources":["../../src/image/ImageDialog.tsx"],"names":[],"mappings":"AAmBA,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,wFAAwF;IACxF,IAAI,EAAE,QAAQ,GAAG,MAAM,CAAC;IACxB,MAAM,EAAE,OAAO,CAAC;IAChB,0CAA0C;IAC1C,OAAO,CAAC,EAAE,gBAAgB,CAAC;IAC3B,qDAAqD;IACrD,YAAY,CAAC,EAAE,SAAS,CAAC;IACzB,QAAQ,EAAE,CAAC,KAAK,EAAE,gBAAgB,KAAK,IAAI,CAAC;IAC5C,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB;AAgCD;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,YAAY,EAAE,QAAQ,EAAE,OAAO,EAAE,EAAE,gBAAgB,+BA4SvG"}
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,8 +1,9 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
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, FileInput, Group, Modal, NumberInput, SegmentedControl, Stack, Tabs, Text, TextInput, } from '@mantine/core';
3
+ import { Alert, Button, Checkbox, Divider, FileInput, Group, Modal, NumberInput, SegmentedControl, Stack, Tabs, Text, TextInput, } from '@mantine/core';
4
+ import { IconPhotoDown } from '@tabler/icons-react';
4
5
  import { ACCEPTED_IMAGE_TYPES, normalizeImageUrl, validateImageFile } from './imageUrl.js';
5
- import { dataUrlBytes, dataUrlToFile, fitDimension, mimeFromSrc } from './imageEdit.js';
6
+ import { dataUrlBytes, dataUrlToFile, fetchImageAsDataUrl, fitDimension, mimeFromSrc } from './imageEdit.js';
6
7
  import { ImageCropper } from './ImageCropper.js';
7
8
  import { useImageOptions } from './ImageOptionsContext.js';
8
9
  function readAsDataUrl(file) {
@@ -34,18 +35,26 @@ function useNaturalSize(src) {
34
35
  return size;
35
36
  }
36
37
  /**
37
- * Insert-image dialog (UI spec §6) and, in edit mode, the image editor: change
38
- * the source (URL or upload), alt text, caption, display size, and crop/rotate.
39
- * Edits that change pixels (crop, rotate, flip) are embedded as a data URI, or
40
- * stored through the host's `onImageUpload` when there is one.
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.
41
49
  */
42
- export function ImageDialog({ mode, opened, initial, initialFocus, onSubmit, onClose }) {
50
+ export function ImageDialog({ mode, opened, initial, intent, onSubmit, onClose }) {
43
51
  const { allowLinked, allowEmbedded, allowCaption, maxSizeMB, onImageUpload } = useImageOptions();
44
52
  const editing = mode === 'edit' && initial !== undefined;
45
53
  const defaultSource = editing ? 'keep' : allowLinked ? 'linked' : 'embedded';
46
54
  const [tab, setTab] = useState('details');
47
55
  const [source, setSource] = useState(defaultSource);
48
56
  const [url, setUrl] = useState('');
57
+ const [embedCopy, setEmbedCopy] = useState(false);
49
58
  const [file, setFile] = useState(null);
50
59
  const [fileSrc, setFileSrc] = useState(null);
51
60
  const [altText, setAltText] = useState('');
@@ -56,12 +65,14 @@ export function ImageDialog({ mode, opened, initial, initialFocus, onSubmit, onC
56
65
  const [pixelSrc, setPixelSrc] = useState(null);
57
66
  const [error, setError] = useState(null);
58
67
  const [busy, setBusy] = useState(false);
68
+ const [converting, setConverting] = useState(false);
59
69
  useEffect(() => {
60
70
  if (!opened)
61
71
  return;
62
72
  setTab('details');
63
73
  setSource(defaultSource);
64
74
  setUrl('');
75
+ setEmbedCopy(false);
65
76
  setFile(null);
66
77
  setFileSrc(null);
67
78
  setAltText(initial?.altText ?? '');
@@ -72,12 +83,19 @@ export function ImageDialog({ mode, opened, initial, initialFocus, onSubmit, onC
72
83
  setPixelSrc(null);
73
84
  setError(null);
74
85
  setBusy(false);
86
+ setConverting(false);
87
+ if (intent === 'convert' && initial?.linkType === 'linked')
88
+ void convert(initial.src);
75
89
  // eslint-disable-next-line react-hooks/exhaustive-deps
76
90
  }, [opened]);
77
91
  // The image before any crop/rotate: the current one, the typed URL, or the chosen file.
78
92
  const baseSrc = source === 'keep' ? (initial?.src ?? null) : source === 'linked' ? normalizeImageUrl(url) : fileSrc;
79
93
  const effectiveSrc = pixelSrc ?? baseSrc;
80
94
  const natural = useNaturalSize(effectiveSrc);
95
+ // Only embedded images have pixels of their own to crop, rotate and resize.
96
+ const effectiveLinkType = source === 'keep' ? (initial?.linkType ?? 'linked') : source === 'linked' ? 'linked' : 'embedded';
97
+ const canEditPixels = effectiveLinkType === 'embedded' && effectiveSrc !== null;
98
+ const convertedFromUrl = source === 'embedded' && fileSrc !== null && file === null;
81
99
  /** A new source or new pixels invalidate any custom size. */
82
100
  const resetSize = (toInitial = false) => {
83
101
  setWidth(toInitial ? (initial?.width ?? null) : null);
@@ -86,6 +104,8 @@ export function ImageDialog({ mode, opened, initial, initialFocus, onSubmit, onC
86
104
  const changeSource = (next) => {
87
105
  setSource(next);
88
106
  setPixelSrc(null);
107
+ setFile(null);
108
+ setFileSrc(null);
89
109
  resetSize(next === 'keep');
90
110
  setError(null);
91
111
  };
@@ -109,6 +129,28 @@ export function ImageDialog({ mode, opened, initial, initialFocus, onSubmit, onC
109
129
  setError(err instanceof Error ? err.message : 'Could not read the file');
110
130
  }
111
131
  };
132
+ /** Downloads a linked image and switches the dialog to its embedded copy. */
133
+ const convert = async (src) => {
134
+ if (!src)
135
+ return;
136
+ setConverting(true);
137
+ setError(null);
138
+ try {
139
+ const dataUrl = await fetchImageAsDataUrl(src);
140
+ if (dataUrlBytes(dataUrl) > maxSizeMB * 1024 * 1024)
141
+ throw new Error(`Image is larger than ${maxSizeMB} MB`);
142
+ setSource('embedded');
143
+ setFile(null);
144
+ setFileSrc(dataUrl);
145
+ setPixelSrc(null);
146
+ }
147
+ catch (err) {
148
+ setError(err instanceof Error ? err.message : 'Could not convert the image');
149
+ }
150
+ finally {
151
+ setConverting(false);
152
+ }
153
+ };
112
154
  const onSizeChange = (changed, value) => {
113
155
  const n = typeof value === 'number' ? value : null;
114
156
  if (n === null) {
@@ -133,10 +175,15 @@ export function ImageDialog({ mode, opened, initial, initialFocus, onSubmit, onC
133
175
  }
134
176
  try {
135
177
  let src = effectiveSrc;
136
- let linkType = source === 'keep' ? (initial?.linkType ?? 'linked') : source;
137
- // Pixels the document doesn't reference yet (an upload, or an edit) are
138
- // stored: through the host's handler, or embedded as a data URI.
139
- const newPixels = pixelSrc ?? (source === 'embedded' ? fileSrc : null);
178
+ 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);
183
+ if (!newPixels && source === 'linked' && embedCopy) {
184
+ setBusy(true);
185
+ newPixels = await fetchImageAsDataUrl(effectiveSrc);
186
+ }
140
187
  if (newPixels) {
141
188
  if (dataUrlBytes(newPixels) > maxSizeMB * 1024 * 1024) {
142
189
  setError(`Image is larger than ${maxSizeMB} MB`);
@@ -150,7 +197,6 @@ export function ImageDialog({ mode, opened, initial, initialFocus, onSubmit, onC
150
197
  src = await onImageUpload(upload);
151
198
  if (!normalizeImageUrl(src)) {
152
199
  setError('The upload handler returned an unusable image URL');
153
- setBusy(false);
154
200
  return;
155
201
  }
156
202
  }
@@ -174,21 +220,29 @@ export function ImageDialog({ mode, opened, initial, initialFocus, onSubmit, onC
174
220
  setBusy(false);
175
221
  }
176
222
  };
177
- const sourceFields = (_jsxs(Stack, { gap: "sm", children: [(editing || (allowLinked && allowEmbedded)) && (_jsx(SegmentedControl, { fullWidth: true, value: source, onChange: (v) => changeSource(v), data: [
223
+ 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: [
178
225
  ...(editing ? [{ value: 'keep', label: 'Current image' }] : []),
179
226
  ...(allowLinked ? [{ value: 'linked', label: 'From URL' }] : []),
180
227
  ...(allowEmbedded ? [{ value: 'embedded', label: 'Upload' }] : []),
181
- ] })), source === 'keep' && (_jsx(Text, { size: "sm", c: "dimmed", lineClamp: 2, style: { wordBreak: 'break-all' }, children: initial?.src.startsWith('data:') ? 'Embedded in the document' : initial?.src })), source === 'linked' && (_jsx(TextInput, { label: "Image URL", placeholder: "https://example.com/photo.jpg", value: url, onChange: (e) => {
182
- setUrl(e.currentTarget.value);
183
- setPixelSrc(null);
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);
184
242
  resetSize();
185
- }, "data-autofocus": !editing || initialFocus !== 'caption' ? true : undefined })), source === 'embedded' && (_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": initialFocus === 'caption' ? true : undefined }))] }));
186
- const sizeFields = (_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: () => natural && (setWidth(Math.round((natural.width * pct) / 100)), setHeight(Math.round((natural.height * pct) / 100))), 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.` : ''] })] }));
187
- 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: "size", disabled: !effectiveSrc, children: "Size" }), _jsx(Tabs.Tab, { value: "crop", disabled: !effectiveSrc, children: "Crop & rotate" })] }), _jsx(Tabs.Panel, { value: "details", children: sourceFields }), _jsx(Tabs.Panel, { value: "size", children: sizeFields }), _jsx(Tabs.Panel, { value: "crop", children: effectiveSrc && (_jsx(ImageCropper, { src: effectiveSrc, canReset: pixelSrc !== null, onApply: (edited) => {
188
- setPixelSrc(edited);
189
- resetSize();
190
- }, onReset: () => {
191
- setPixelSrc(null);
192
- resetSize(source === 'keep');
193
- } })) })] })) : (sourceFields), 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' })] })] }) }) }));
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' })] })] }) }) }));
194
248
  }
@@ -0,0 +1,35 @@
1
+ import type { CropRect } from './imageEdit.js';
2
+ export type CropHandle = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw';
3
+ /** Smallest selection, as a fraction of the image. */
4
+ export declare const MIN_CROP = 0.02;
5
+ /** Size of the image's on-screen box in px; needed to keep an aspect ratio,
6
+ * since rectangle coordinates are fractions. */
7
+ export interface Box {
8
+ width: number;
9
+ height: number;
10
+ }
11
+ /**
12
+ * A new selection dragged from `anchor` to `point` (both fractions). With a
13
+ * `ratio` (width / height, in pixels) the selection keeps it, shrinking to stay
14
+ * inside the image.
15
+ */
16
+ export declare function rectFromDrag(anchor: {
17
+ x: number;
18
+ y: number;
19
+ }, point: {
20
+ x: number;
21
+ y: number;
22
+ }, ratio: number | null, box: Box): CropRect;
23
+ /** Moves a selection by (dx, dy) fractions, keeping it inside the image. */
24
+ export declare function moveRect(rect: CropRect, dx: number, dy: number): CropRect;
25
+ /**
26
+ * Resizes a selection by dragging one of its handles to `point` (a fraction).
27
+ * Corners anchor the opposite corner; edges move one side. With a `ratio` the
28
+ * aspect is kept (an edge handle then grows the other dimension around the
29
+ * selection's centre). Never smaller than MIN_CROP, never outside the image.
30
+ */
31
+ export declare function resizeRect(rect: CropRect, handle: CropHandle, point: {
32
+ x: number;
33
+ y: number;
34
+ }, ratio: number | null, box: Box): CropRect;
35
+ //# sourceMappingURL=cropMath.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cropMath.d.ts","sourceRoot":"","sources":["../../src/image/cropMath.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAE5C,MAAM,MAAM,UAAU,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,IAAI,GAAG,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC;AAE3E,sDAAsD;AACtD,eAAO,MAAM,QAAQ,OAAO,CAAC;AAE7B;gDACgD;AAChD,MAAM,WAAW,GAAG;IAClB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB;AAID;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE;IAAE,CAAC,EAAE,MAAM,CAAC;IAAC,CAAC,EAAE,MAAM,CAAA;CAAE,EAAE,KAAK,EAAE;IAAE,CAAC,EAAE,MAAM,CAAC;IAAC,CAAC,EAAE,MAAM,CAAA;CAAE,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,EAAE,GAAG,EAAE,GAAG,GAAG,QAAQ,CAexI;AAED,4EAA4E;AAC5E,wBAAgB,QAAQ,CAAC,IAAI,EAAE,QAAQ,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,QAAQ,CAEzE;AAED;;;;;GAKG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,QAAQ,EAAE,MAAM,EAAE,UAAU,EAAE,KAAK,EAAE;IAAE,CAAC,EAAE,MAAM,CAAC;IAAC,CAAC,EAAE,MAAM,CAAA;CAAE,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,EAAE,GAAG,EAAE,GAAG,GAAG,QAAQ,CAwDxI"}
@@ -0,0 +1,92 @@
1
+ /** Smallest selection, as a fraction of the image. */
2
+ export const MIN_CROP = 0.02;
3
+ const clamp = (v, min = 0, max = 1) => Math.min(Math.max(v, min), max);
4
+ /**
5
+ * A new selection dragged from `anchor` to `point` (both fractions). With a
6
+ * `ratio` (width / height, in pixels) the selection keeps it, shrinking to stay
7
+ * inside the image.
8
+ */
9
+ export function rectFromDrag(anchor, point, ratio, box) {
10
+ let dx = clamp(point.x) - anchor.x;
11
+ let dy = clamp(point.y) - anchor.y;
12
+ if (ratio) {
13
+ let widthPx = Math.abs(dx) * box.width;
14
+ let heightPx = widthPx / ratio;
15
+ const maxHeightPx = (dy >= 0 ? 1 - anchor.y : anchor.y) * box.height;
16
+ if (heightPx > maxHeightPx) {
17
+ heightPx = maxHeightPx;
18
+ widthPx = heightPx * ratio;
19
+ }
20
+ dx = (dx >= 0 ? 1 : -1) * (widthPx / box.width);
21
+ dy = (dy >= 0 ? 1 : -1) * (heightPx / box.height);
22
+ }
23
+ return { x: Math.min(anchor.x, anchor.x + dx), y: Math.min(anchor.y, anchor.y + dy), w: Math.abs(dx), h: Math.abs(dy) };
24
+ }
25
+ /** Moves a selection by (dx, dy) fractions, keeping it inside the image. */
26
+ export function moveRect(rect, dx, dy) {
27
+ return { ...rect, x: clamp(rect.x + dx, 0, 1 - rect.w), y: clamp(rect.y + dy, 0, 1 - rect.h) };
28
+ }
29
+ /**
30
+ * Resizes a selection by dragging one of its handles to `point` (a fraction).
31
+ * Corners anchor the opposite corner; edges move one side. With a `ratio` the
32
+ * aspect is kept (an edge handle then grows the other dimension around the
33
+ * selection's centre). Never smaller than MIN_CROP, never outside the image.
34
+ */
35
+ export function resizeRect(rect, handle, point, ratio, box) {
36
+ const left = rect.x;
37
+ const right = rect.x + rect.w;
38
+ const top = rect.y;
39
+ const bottom = rect.y + rect.h;
40
+ const px = clamp(point.x);
41
+ const py = clamp(point.y);
42
+ if (handle.length === 2) {
43
+ // Corner: the opposite corner stays put.
44
+ const anchor = { x: handle.includes('w') ? right : left, y: handle.includes('n') ? bottom : top };
45
+ return rectFromDrag(anchor, { x: px, y: py }, ratio, box);
46
+ }
47
+ const horizontal = handle === 'e' || handle === 'w';
48
+ if (!ratio) {
49
+ const next = { left, right, top, bottom };
50
+ if (handle === 'w')
51
+ next.left = Math.min(px, right - MIN_CROP);
52
+ if (handle === 'e')
53
+ next.right = Math.max(px, left + MIN_CROP);
54
+ if (handle === 'n')
55
+ next.top = Math.min(py, bottom - MIN_CROP);
56
+ if (handle === 's')
57
+ next.bottom = Math.max(py, top + MIN_CROP);
58
+ return { x: next.left, y: next.top, w: next.right - next.left, h: next.bottom - next.top };
59
+ }
60
+ // Edge with a locked ratio: the dragged side sets one dimension, the other follows.
61
+ const cx = left + rect.w / 2;
62
+ const cy = top + rect.h / 2;
63
+ if (horizontal) {
64
+ const fixed = handle === 'e' ? left : right;
65
+ let w = Math.max(Math.abs(px - fixed), MIN_CROP);
66
+ let h = w * box.width / ratio / box.height;
67
+ if (h > 1) {
68
+ h = 1;
69
+ w = (h * box.height * ratio) / box.width;
70
+ }
71
+ // Stay inside the image horizontally.
72
+ const room = handle === 'e' ? 1 - left : right;
73
+ if (w > room) {
74
+ w = room;
75
+ h = (w * box.width) / ratio / box.height;
76
+ }
77
+ return { x: handle === 'e' ? left : right - w, y: clamp(cy - h / 2, 0, 1 - h), w, h };
78
+ }
79
+ const fixed = handle === 's' ? top : bottom;
80
+ let h = Math.max(Math.abs(py - fixed), MIN_CROP);
81
+ let w = (h * box.height * ratio) / box.width;
82
+ if (w > 1) {
83
+ w = 1;
84
+ h = (w * box.width) / ratio / box.height;
85
+ }
86
+ const room = handle === 's' ? 1 - top : bottom;
87
+ if (h > room) {
88
+ h = room;
89
+ w = (h * box.height * ratio) / box.width;
90
+ }
91
+ return { x: clamp(cx - w / 2, 0, 1 - w), y: handle === 's' ? top : bottom - h, w, h };
92
+ }
@@ -24,6 +24,10 @@ export declare function mimeFromSrc(src: string): string;
24
24
  /** Approximate decoded size, in bytes, of a base64 data URI. */
25
25
  export declare function dataUrlBytes(dataUrl: string): number;
26
26
  export declare function dataUrlToFile(dataUrl: string, name: string): File;
27
+ export declare const CANNOT_DOWNLOAD = "Couldn't download this image (its server doesn't allow it). Download the file and upload it instead.";
28
+ /** Downloads an image and returns it as a base64 data URI, to embed it in the
29
+ * document. Needs the image's server to allow cross-origin reads (CORS). */
30
+ export declare function fetchImageAsDataUrl(url: string, fetchImpl?: typeof fetch): Promise<string>;
27
31
  export type PixelOp = {
28
32
  type: 'rotate';
29
33
  direction: 'cw' | 'ccw';
@@ -1 +1 @@
1
- {"version":3,"file":"imageEdit.d.ts","sourceRoot":"","sources":["../../src/image/imageEdit.ts"],"names":[],"mappings":"AAAA,2EAA2E;AAC3E,MAAM,WAAW,QAAQ;IACvB,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;CACX;AAED;sEACsE;AACtE,wBAAgB,WAAW,CAAC,IAAI,EAAE,QAAQ,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM;;;;;EAMxE;AAED,6EAA6E;AAC7E,wBAAgB,YAAY,CAC1B,OAAO,EAAE,OAAO,GAAG,QAAQ,EAC3B,KAAK,EAAE,MAAM,EACb,YAAY,EAAE,MAAM,EACpB,aAAa,EAAE,MAAM,GACpB;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAKnC;AAED;mFACmF;AACnF,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAI/C;AASD,gEAAgE;AAChE,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAKpD;AAED,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI,CAOjE;AAED,MAAM,MAAM,OAAO,GACf;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,SAAS,EAAE,IAAI,GAAG,KAAK,CAAA;CAAE,GAC3C;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,YAAY,GAAG,UAAU,CAAA;CAAE,GACjD;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,QAAQ,CAAA;CAAE,CAAC;AAErC;+EAC+E;AAC/E,wBAAgB,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAQhE;AAED,eAAO,MAAM,kBAAkB,iHACiF,CAAC;AAEjH,uFAAuF;AACvF,wBAAsB,YAAY,CAAC,GAAG,EAAE,MAAM,EAAE,EAAE,EAAE,OAAO,GAAG,OAAO,CAAC,MAAM,CAAC,CAuC5E"}
1
+ {"version":3,"file":"imageEdit.d.ts","sourceRoot":"","sources":["../../src/image/imageEdit.ts"],"names":[],"mappings":"AAEA,2EAA2E;AAC3E,MAAM,WAAW,QAAQ;IACvB,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;CACX;AAED;sEACsE;AACtE,wBAAgB,WAAW,CAAC,IAAI,EAAE,QAAQ,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM;;;;;EAMxE;AAED,6EAA6E;AAC7E,wBAAgB,YAAY,CAC1B,OAAO,EAAE,OAAO,GAAG,QAAQ,EAC3B,KAAK,EAAE,MAAM,EACb,YAAY,EAAE,MAAM,EACpB,aAAa,EAAE,MAAM,GACpB;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAKnC;AAED;mFACmF;AACnF,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAI/C;AASD,gEAAgE;AAChE,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAKpD;AAED,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI,CAOjE;AAED,eAAO,MAAM,eAAe,yGAC4E,CAAC;AAEzG;4EAC4E;AAC5E,wBAAsB,mBAAmB,CAAC,GAAG,EAAE,MAAM,EAAE,SAAS,GAAE,OAAO,KAAa,GAAG,OAAO,CAAC,MAAM,CAAC,CAiBvG;AAED,MAAM,MAAM,OAAO,GACf;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,SAAS,EAAE,IAAI,GAAG,KAAK,CAAA;CAAE,GAC3C;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,YAAY,GAAG,UAAU,CAAA;CAAE,GACjD;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,QAAQ,CAAA;CAAE,CAAC;AAErC;+EAC+E;AAC/E,wBAAgB,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAQhE;AAED,eAAO,MAAM,kBAAkB,iHACiF,CAAC;AAEjH,uFAAuF;AACvF,wBAAsB,YAAY,CAAC,GAAG,EAAE,MAAM,EAAE,EAAE,EAAE,OAAO,GAAG,OAAO,CAAC,MAAM,CAAC,CAuC5E"}
@@ -1,3 +1,4 @@
1
+ import { ACCEPTED_IMAGE_TYPES } from './imageUrl.js';
1
2
  /** Converts a fractional crop rectangle to whole pixels inside a width x height
2
3
  * image, clamped so it never leaves the image and is at least 1px. */
3
4
  export function toPixelRect(rect, width, height) {
@@ -44,6 +45,31 @@ export function dataUrlToFile(dataUrl, name) {
44
45
  bytes[i] = binary.charCodeAt(i);
45
46
  return new File([bytes], name, { type: mime });
46
47
  }
48
+ export const CANNOT_DOWNLOAD = "Couldn't download this image (its server doesn't allow it). Download the file and upload it instead.";
49
+ /** Downloads an image and returns it as a base64 data URI, to embed it in the
50
+ * document. Needs the image's server to allow cross-origin reads (CORS). */
51
+ export async function fetchImageAsDataUrl(url, fetchImpl = fetch) {
52
+ if (/^data:image\//i.test(url))
53
+ return url;
54
+ let blob;
55
+ try {
56
+ const response = await fetchImpl(url, { mode: 'cors' });
57
+ if (!response.ok)
58
+ throw new Error('bad status');
59
+ blob = await response.blob();
60
+ }
61
+ catch {
62
+ throw new Error(CANNOT_DOWNLOAD);
63
+ }
64
+ if (!ACCEPTED_IMAGE_TYPES.includes(blob.type))
65
+ throw new Error("That URL doesn't point to a PNG, JPEG, GIF, WebP or SVG image");
66
+ return new Promise((resolve, reject) => {
67
+ const reader = new FileReader();
68
+ reader.onload = () => resolve(String(reader.result));
69
+ reader.onerror = () => reject(new Error(CANNOT_DOWNLOAD));
70
+ reader.readAsDataURL(blob);
71
+ });
72
+ }
47
73
  /** Loads an image for canvas use. External URLs are requested with CORS so the
48
74
  * canvas isn't tainted; a server that doesn't allow that makes this reject. */
49
75
  export function loadImage(src) {
@@ -294,6 +294,7 @@
294
294
  display: inline-block;
295
295
  align-self: center;
296
296
  max-width: 100%;
297
+ padding: 8px;
297
298
  overflow: hidden;
298
299
  line-height: 0;
299
300
  cursor: crosshair;
@@ -301,6 +302,12 @@
301
302
  user-select: none;
302
303
  }
303
304
 
305
+ .likhari-cropper-frame {
306
+ position: relative;
307
+ display: inline-block;
308
+ max-width: 100%;
309
+ }
310
+
304
311
  .likhari-cropper-image {
305
312
  display: block;
306
313
  max-width: 100%;
@@ -312,9 +319,29 @@
312
319
  border: 2px solid #fff;
313
320
  outline: 1px solid rgba(0, 0, 0, 0.5);
314
321
  box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.5);
315
- pointer-events: none;
322
+ cursor: move;
316
323
  }
317
324
 
325
+ .likhari-cropper-handle {
326
+ position: absolute;
327
+ width: 12px;
328
+ height: 12px;
329
+ border: 1px solid rgba(0, 0, 0, 0.6);
330
+ border-radius: 2px;
331
+ background: #fff;
332
+ /* 12px handles on small selections would overlap; keep them on the border. */
333
+ transform: translate(-50%, -50%);
334
+ }
335
+
336
+ .likhari-cropper-handle[data-handle='nw'] { left: 0; top: 0; cursor: nwse-resize; }
337
+ .likhari-cropper-handle[data-handle='n'] { left: 50%; top: 0; cursor: ns-resize; }
338
+ .likhari-cropper-handle[data-handle='ne'] { left: 100%; top: 0; cursor: nesw-resize; }
339
+ .likhari-cropper-handle[data-handle='e'] { left: 100%; top: 50%; cursor: ew-resize; }
340
+ .likhari-cropper-handle[data-handle='se'] { left: 100%; top: 100%; cursor: nwse-resize; }
341
+ .likhari-cropper-handle[data-handle='s'] { left: 50%; top: 100%; cursor: ns-resize; }
342
+ .likhari-cropper-handle[data-handle='sw'] { left: 0; top: 100%; cursor: nesw-resize; }
343
+ .likhari-cropper-handle[data-handle='w'] { left: 0; top: 50%; cursor: ew-resize; }
344
+
318
345
  /* Link menu: the URL item shows one truncated line. */
319
346
  .likhari-link-menu-url {
320
347
  max-width: 100%;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inshapardaz/likhari-react",
3
- "version": "0.1.6-dev.43",
3
+ "version": "0.1.6-dev.46",
4
4
  "description": "React implementation of the Likhari editor, built on @lexical/react.",
5
5
  "license": "MIT",
6
6
  "repository": {