@inshapardaz/likhari-react 0.1.6-dev.44 → 0.1.6-dev.48
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/image/ImageComponent.d.ts +3 -2
- package/dist/image/ImageComponent.d.ts.map +1 -1
- package/dist/image/ImageComponent.js +6 -5
- package/dist/image/ImageCropper.d.ts +4 -3
- package/dist/image/ImageCropper.d.ts.map +1 -1
- package/dist/image/ImageCropper.js +73 -42
- package/dist/image/ImageDialog.d.ts +17 -8
- package/dist/image/ImageDialog.d.ts.map +1 -1
- package/dist/image/ImageDialog.js +80 -26
- package/dist/image/cropMath.d.ts +35 -0
- package/dist/image/cropMath.d.ts.map +1 -0
- package/dist/image/cropMath.js +92 -0
- package/dist/image/imageEdit.d.ts +4 -0
- package/dist/image/imageEdit.d.ts.map +1 -1
- package/dist/image/imageEdit.js +26 -0
- package/dist/theme/editor.css +28 -1
- package/package.json +2 -1
|
@@ -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
|
|
16
|
-
* dialog and menu (edit image,
|
|
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
|
|
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
|
|
19
|
-
* dialog and menu (edit image,
|
|
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({
|
|
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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
|
|
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":"
|
|
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
|
|
3
|
-
import { ActionIcon, Alert, Button, Group, SegmentedControl, Stack, Text, Tooltip } from
|
|
4
|
-
import { IconCrop, IconFlipHorizontal, IconFlipVertical, IconRestore, IconRotate2, IconRotateClockwise2, } from
|
|
5
|
-
import { applyPixelOp } from
|
|
6
|
-
|
|
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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
|
|
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
|
|
21
|
+
const drag = useRef(null);
|
|
14
22
|
const [rect, setRect] = useState(null);
|
|
15
|
-
const [aspect, setAspect] = useState(
|
|
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 :
|
|
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
|
|
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 {
|
|
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 =
|
|
54
|
+
const p = pointer(event);
|
|
40
55
|
if (!p)
|
|
41
56
|
return;
|
|
42
57
|
event.preventDefault();
|
|
43
58
|
event.currentTarget.setPointerCapture(event.pointerId);
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
49
|
-
const p =
|
|
50
|
-
if (!
|
|
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 (
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
86
|
+
const wasNew = drag.current?.mode === "new";
|
|
87
|
+
drag.current = null;
|
|
71
88
|
// A click without a drag isn't a selection.
|
|
72
|
-
|
|
89
|
+
if (wasNew)
|
|
90
|
+
setRect((r) => (r && (r.w < MIN_CROP || r.h < MIN_CROP) ? null : r));
|
|
73
91
|
};
|
|
74
|
-
|
|
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:
|
|
83
|
-
{ value:
|
|
84
|
-
{ value:
|
|
85
|
-
{ value:
|
|
86
|
-
] })] }),
|
|
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,
|
|
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
|
-
/**
|
|
19
|
-
|
|
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
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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,
|
|
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":"
|
|
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
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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,
|
|
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 =
|
|
137
|
-
// Pixels the document doesn't reference yet (an upload,
|
|
138
|
-
// stored: through the host's
|
|
139
|
-
|
|
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
|
|
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?.
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
},
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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":"
|
|
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"}
|
package/dist/image/imageEdit.js
CHANGED
|
@@ -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) {
|
package/dist/theme/editor.css
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
3
|
+
"version": "0.1.6-dev.48",
|
|
4
4
|
"description": "React implementation of the Likhari editor, built on @lexical/react.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"exports": {
|
|
14
14
|
".": {
|
|
15
15
|
"types": "./dist/index.d.ts",
|
|
16
|
+
"development": "./src/index.ts",
|
|
16
17
|
"import": "./dist/index.js"
|
|
17
18
|
},
|
|
18
19
|
"./package.json": "./package.json"
|