@inshapardaz/likhari-react 0.1.5 → 0.1.6-dev.44

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.
Files changed (45) hide show
  1. package/dist/EditorRoot.d.ts +15 -0
  2. package/dist/EditorRoot.d.ts.map +1 -1
  3. package/dist/EditorRoot.js +18 -3
  4. package/dist/components/Toolbar.d.ts +6 -1
  5. package/dist/components/Toolbar.d.ts.map +1 -1
  6. package/dist/components/Toolbar.js +83 -8
  7. package/dist/fonts/index.d.ts +22 -0
  8. package/dist/fonts/index.d.ts.map +1 -0
  9. package/dist/fonts/index.js +20 -0
  10. package/dist/fonts/urduWebFonts.d.ts +14 -0
  11. package/dist/fonts/urduWebFonts.d.ts.map +1 -0
  12. package/dist/fonts/urduWebFonts.js +66 -0
  13. package/dist/image/ImageComponent.d.ts +22 -0
  14. package/dist/image/ImageComponent.d.ts.map +1 -0
  15. package/dist/image/ImageComponent.js +132 -0
  16. package/dist/image/ImageCropper.d.ts +14 -0
  17. package/dist/image/ImageCropper.d.ts.map +1 -0
  18. package/dist/image/ImageCropper.js +87 -0
  19. package/dist/image/ImageDialog.d.ts +30 -0
  20. package/dist/image/ImageDialog.d.ts.map +1 -0
  21. package/dist/image/ImageDialog.js +194 -0
  22. package/dist/image/ImageNode.d.ts +64 -0
  23. package/dist/image/ImageNode.d.ts.map +1 -0
  24. package/dist/image/ImageNode.js +133 -0
  25. package/dist/image/ImageOptionsContext.d.ts +17 -0
  26. package/dist/image/ImageOptionsContext.d.ts.map +1 -0
  27. package/dist/image/ImageOptionsContext.js +10 -0
  28. package/dist/image/imageEdit.d.ts +43 -0
  29. package/dist/image/imageEdit.d.ts.map +1 -0
  30. package/dist/image/imageEdit.js +103 -0
  31. package/dist/image/imageUrl.d.ts +14 -0
  32. package/dist/image/imageUrl.d.ts.map +1 -0
  33. package/dist/image/imageUrl.js +27 -0
  34. package/dist/index.d.ts +4 -0
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +2 -0
  37. package/dist/nodes.d.ts.map +1 -1
  38. package/dist/nodes.js +2 -0
  39. package/dist/theme/editor.css +80 -0
  40. package/dist/theme/editorTheme.d.ts.map +1 -1
  41. package/dist/theme/editorTheme.js +1 -0
  42. package/dist/utils/style.d.ts +4 -0
  43. package/dist/utils/style.d.ts.map +1 -0
  44. package/dist/utils/style.js +15 -0
  45. package/package.json +1 -1
@@ -0,0 +1,87 @@
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 };
7
+ const clamp = (v) => Math.min(Math.max(v, 0), 1);
8
+ /** 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 }) {
12
+ const imgRef = useRef(null);
13
+ const dragStart = useRef(null);
14
+ const [rect, setRect] = useState(null);
15
+ const [aspect, setAspect] = useState('free');
16
+ const [busy, setBusy] = useState(false);
17
+ const [error, setError] = useState(null);
18
+ const run = async (op) => {
19
+ setBusy(true);
20
+ setError(null);
21
+ try {
22
+ onApply(await applyPixelOp(src, op));
23
+ setRect(null);
24
+ }
25
+ catch (err) {
26
+ setError(err instanceof Error ? err.message : 'Could not edit the image');
27
+ }
28
+ finally {
29
+ setBusy(false);
30
+ }
31
+ };
32
+ const pointerFraction = (event) => {
33
+ const box = imgRef.current?.getBoundingClientRect();
34
+ if (!box || !box.width || !box.height)
35
+ return null;
36
+ return { x: clamp((event.clientX - box.left) / box.width), y: clamp((event.clientY - box.top) / box.height), box };
37
+ };
38
+ const onDown = (event) => {
39
+ const p = pointerFraction(event);
40
+ if (!p)
41
+ return;
42
+ event.preventDefault();
43
+ 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 });
46
+ };
47
+ const onMove = (event) => {
48
+ const start = dragStart.current;
49
+ const p = pointerFraction(event);
50
+ if (!start || !p)
51
+ return;
52
+ let dx = p.x - start.x;
53
+ let dy = p.y - start.y;
54
+ 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) });
68
+ };
69
+ const onUp = () => {
70
+ dragStart.current = null;
71
+ // A click without a drag isn't a selection.
72
+ setRect((r) => (r && (r.w < 0.02 || r.h < 0.02) ? null : r));
73
+ };
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: () => {
75
+ setRect(null);
76
+ setError(null);
77
+ onReset();
78
+ }, children: "Undo edits" })] }), _jsxs(Group, { gap: "xs", align: "center", children: [_jsx(Text, { size: "sm", children: "Crop shape" }), _jsx(SegmentedControl, { size: "xs", value: aspect, onChange: (v) => {
79
+ setAspect(v);
80
+ setRect(null);
81
+ }, 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 }))] }));
87
+ }
@@ -0,0 +1,30 @@
1
+ import type { ImageLinkType } from './ImageNode.js';
2
+ export interface ImageDialogValue {
3
+ src: string;
4
+ altText: string;
5
+ /** null = no caption */
6
+ caption: string | null;
7
+ linkType: ImageLinkType;
8
+ /** Display size in px; null = natural size */
9
+ width: number | null;
10
+ height: number | null;
11
+ }
12
+ export interface ImageDialogProps {
13
+ /** `insert`: pick an image. `edit`: also change its source, size, crop and rotation. */
14
+ mode: 'insert' | 'edit';
15
+ opened: boolean;
16
+ /** The image being edited (edit mode). */
17
+ initial?: ImageDialogValue;
18
+ /** Focus the caption field when the dialog opens. */
19
+ initialFocus?: 'caption';
20
+ onSubmit: (image: ImageDialogValue) => void;
21
+ onClose: () => void;
22
+ }
23
+ /**
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.
28
+ */
29
+ export declare function ImageDialog({ mode, opened, initial, initialFocus, onSubmit, onClose }: ImageDialogProps): import("react").JSX.Element;
30
+ //# sourceMappingURL=ImageDialog.d.ts.map
@@ -0,0 +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"}
@@ -0,0 +1,194 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from 'react';
3
+ import { Alert, Button, Checkbox, FileInput, Group, Modal, NumberInput, SegmentedControl, Stack, Tabs, Text, TextInput, } from '@mantine/core';
4
+ import { ACCEPTED_IMAGE_TYPES, normalizeImageUrl, validateImageFile } from './imageUrl.js';
5
+ import { dataUrlBytes, dataUrlToFile, fitDimension, mimeFromSrc } from './imageEdit.js';
6
+ import { ImageCropper } from './ImageCropper.js';
7
+ import { useImageOptions } from './ImageOptionsContext.js';
8
+ function readAsDataUrl(file) {
9
+ return new Promise((resolve, reject) => {
10
+ const reader = new FileReader();
11
+ reader.onload = () => resolve(String(reader.result));
12
+ reader.onerror = () => reject(reader.error ?? new Error('Could not read the file'));
13
+ reader.readAsDataURL(file);
14
+ });
15
+ }
16
+ /** Natural pixel size of an image URL (for the aspect ratio and size presets). */
17
+ function useNaturalSize(src) {
18
+ const [size, setSize] = useState(null);
19
+ useEffect(() => {
20
+ setSize(null);
21
+ if (!src)
22
+ return;
23
+ let cancelled = false;
24
+ const img = new Image();
25
+ img.onload = () => {
26
+ if (!cancelled && img.naturalWidth)
27
+ setSize({ width: img.naturalWidth, height: img.naturalHeight });
28
+ };
29
+ img.src = src;
30
+ return () => {
31
+ cancelled = true;
32
+ };
33
+ }, [src]);
34
+ return size;
35
+ }
36
+ /**
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.
41
+ */
42
+ export function ImageDialog({ mode, opened, initial, initialFocus, onSubmit, onClose }) {
43
+ const { allowLinked, allowEmbedded, allowCaption, maxSizeMB, onImageUpload } = useImageOptions();
44
+ const editing = mode === 'edit' && initial !== undefined;
45
+ const defaultSource = editing ? 'keep' : allowLinked ? 'linked' : 'embedded';
46
+ const [tab, setTab] = useState('details');
47
+ const [source, setSource] = useState(defaultSource);
48
+ const [url, setUrl] = useState('');
49
+ const [file, setFile] = useState(null);
50
+ const [fileSrc, setFileSrc] = useState(null);
51
+ const [altText, setAltText] = useState('');
52
+ const [caption, setCaption] = useState('');
53
+ const [width, setWidth] = useState(null);
54
+ const [height, setHeight] = useState(null);
55
+ const [lockAspect, setLockAspect] = useState(true);
56
+ const [pixelSrc, setPixelSrc] = useState(null);
57
+ const [error, setError] = useState(null);
58
+ const [busy, setBusy] = useState(false);
59
+ useEffect(() => {
60
+ if (!opened)
61
+ return;
62
+ setTab('details');
63
+ setSource(defaultSource);
64
+ setUrl('');
65
+ setFile(null);
66
+ setFileSrc(null);
67
+ setAltText(initial?.altText ?? '');
68
+ setCaption(initial?.caption ?? '');
69
+ setWidth(initial?.width ?? null);
70
+ setHeight(initial?.height ?? null);
71
+ setLockAspect(true);
72
+ setPixelSrc(null);
73
+ setError(null);
74
+ setBusy(false);
75
+ // eslint-disable-next-line react-hooks/exhaustive-deps
76
+ }, [opened]);
77
+ // The image before any crop/rotate: the current one, the typed URL, or the chosen file.
78
+ const baseSrc = source === 'keep' ? (initial?.src ?? null) : source === 'linked' ? normalizeImageUrl(url) : fileSrc;
79
+ const effectiveSrc = pixelSrc ?? baseSrc;
80
+ const natural = useNaturalSize(effectiveSrc);
81
+ /** A new source or new pixels invalidate any custom size. */
82
+ const resetSize = (toInitial = false) => {
83
+ setWidth(toInitial ? (initial?.width ?? null) : null);
84
+ setHeight(toInitial ? (initial?.height ?? null) : null);
85
+ };
86
+ const changeSource = (next) => {
87
+ setSource(next);
88
+ setPixelSrc(null);
89
+ resetSize(next === 'keep');
90
+ setError(null);
91
+ };
92
+ const changeFile = async (next) => {
93
+ setFile(next);
94
+ setFileSrc(null);
95
+ setPixelSrc(null);
96
+ resetSize();
97
+ setError(null);
98
+ if (!next)
99
+ return;
100
+ const problem = validateImageFile(next, maxSizeMB);
101
+ if (problem) {
102
+ setError(problem);
103
+ return;
104
+ }
105
+ try {
106
+ setFileSrc(await readAsDataUrl(next));
107
+ }
108
+ catch (err) {
109
+ setError(err instanceof Error ? err.message : 'Could not read the file');
110
+ }
111
+ };
112
+ const onSizeChange = (changed, value) => {
113
+ const n = typeof value === 'number' ? value : null;
114
+ if (n === null) {
115
+ changed === 'width' ? setWidth(null) : setHeight(null);
116
+ return;
117
+ }
118
+ if (lockAspect && natural) {
119
+ const fitted = fitDimension(changed, n, natural.width, natural.height);
120
+ setWidth(fitted.width);
121
+ setHeight(fitted.height);
122
+ }
123
+ else {
124
+ changed === 'width' ? setWidth(n) : setHeight(n);
125
+ }
126
+ };
127
+ const handleSubmit = async (e) => {
128
+ e.preventDefault();
129
+ setError(null);
130
+ if (!effectiveSrc) {
131
+ setError(source === 'linked' ? 'Enter an http(s) or relative image URL' : source === 'embedded' ? 'Choose an image file' : 'No image');
132
+ return;
133
+ }
134
+ try {
135
+ 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);
140
+ if (newPixels) {
141
+ if (dataUrlBytes(newPixels) > maxSizeMB * 1024 * 1024) {
142
+ setError(`Image is larger than ${maxSizeMB} MB`);
143
+ return;
144
+ }
145
+ linkType = 'embedded';
146
+ if (onImageUpload) {
147
+ setBusy(true);
148
+ const ext = mimeFromSrc(newPixels).split('/')[1];
149
+ const upload = pixelSrc || !file ? dataUrlToFile(newPixels, `image.${ext}`) : file;
150
+ src = await onImageUpload(upload);
151
+ if (!normalizeImageUrl(src)) {
152
+ setError('The upload handler returned an unusable image URL');
153
+ setBusy(false);
154
+ return;
155
+ }
156
+ }
157
+ else {
158
+ src = newPixels;
159
+ }
160
+ }
161
+ onSubmit({
162
+ src,
163
+ altText: altText.trim(),
164
+ caption: allowCaption ? caption.trim() || null : (initial?.caption ?? null),
165
+ linkType,
166
+ width,
167
+ height,
168
+ });
169
+ }
170
+ catch (err) {
171
+ setError(err instanceof Error ? err.message : 'Could not save the image');
172
+ }
173
+ finally {
174
+ setBusy(false);
175
+ }
176
+ };
177
+ const sourceFields = (_jsxs(Stack, { gap: "sm", children: [(editing || (allowLinked && allowEmbedded)) && (_jsx(SegmentedControl, { fullWidth: true, value: source, onChange: (v) => changeSource(v), data: [
178
+ ...(editing ? [{ value: 'keep', label: 'Current image' }] : []),
179
+ ...(allowLinked ? [{ value: 'linked', label: 'From URL' }] : []),
180
+ ...(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);
184
+ 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' })] })] }) }) }));
194
+ }
@@ -0,0 +1,64 @@
1
+ import type { ReactElement } from 'react';
2
+ import { DecoratorNode, type DOMExportOutput, type EditorConfig, type LexicalNode, type NodeKey, type SerializedLexicalNode, type Spread } from 'lexical';
3
+ /** How the image's bytes are held: an external URL, or embedded in the
4
+ * document (a base64 data URI, or a URL returned by the host's upload handler). */
5
+ export type ImageLinkType = 'linked' | 'embedded';
6
+ export interface ImagePayload {
7
+ src: string;
8
+ altText?: string;
9
+ /** null = no caption; '' = caption enabled but empty */
10
+ caption?: string | null;
11
+ linkType: ImageLinkType;
12
+ width?: number | null;
13
+ height?: number | null;
14
+ key?: NodeKey;
15
+ }
16
+ export type SerializedImageNode = Spread<{
17
+ src: string;
18
+ altText: string;
19
+ caption: string | null;
20
+ linkType: ImageLinkType;
21
+ width: number | null;
22
+ height: number | null;
23
+ }, SerializedLexicalNode>;
24
+ /**
25
+ * Block-level image (editor-architecture-design.md §3.5). The caption is a
26
+ * plain string (null = no caption) edited through the image's right-click menu
27
+ * / edit dialog — not the nested sub-editor the architecture doc leaves open —
28
+ * which keeps every edit in the parent editor's undo history.
29
+ */
30
+ export declare class ImageNode extends DecoratorNode<ReactElement> {
31
+ __src: string;
32
+ __altText: string;
33
+ __caption: string | null;
34
+ __linkType: ImageLinkType;
35
+ __width: number | null;
36
+ __height: number | null;
37
+ static getType(): string;
38
+ static clone(node: ImageNode): ImageNode;
39
+ static importJSON(json: SerializedImageNode): ImageNode;
40
+ constructor(src: string, altText?: string, caption?: string | null, linkType?: ImageLinkType, width?: number | null, height?: number | null, key?: NodeKey);
41
+ exportJSON(): SerializedImageNode;
42
+ /** `<figure><img><figcaption>` — the figure/figcaption semantics from spec §4.7. */
43
+ exportDOM(): DOMExportOutput;
44
+ createDOM(config: EditorConfig): HTMLElement;
45
+ updateDOM(): false;
46
+ isInline(): false;
47
+ getSrc(): string;
48
+ getAltText(): string;
49
+ getCaption(): string | null;
50
+ getLinkType(): ImageLinkType;
51
+ getWidth(): number | null;
52
+ getHeight(): number | null;
53
+ setCaption(caption: string | null): this;
54
+ setAltText(altText: string): this;
55
+ /** Points the node at new image data (a replaced URL/upload, or edited pixels). */
56
+ setSource(src: string, linkType: ImageLinkType): this;
57
+ /** Display size in px; null = the image's natural size. */
58
+ setDimensions(width: number | null, height: number | null): this;
59
+ getTextContent(): string;
60
+ decorate(): ReactElement;
61
+ }
62
+ export declare function $createImageNode({ src, altText, caption, linkType, width, height, key }: ImagePayload): ImageNode;
63
+ export declare function $isImageNode(node: LexicalNode | null | undefined): node is ImageNode;
64
+ //# sourceMappingURL=ImageNode.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ImageNode.d.ts","sourceRoot":"","sources":["../../src/image/ImageNode.tsx"],"names":[],"mappings":"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"}
@@ -0,0 +1,133 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { DecoratorNode, } from 'lexical';
3
+ import { ImageComponent } from './ImageComponent.js';
4
+ /**
5
+ * Block-level image (editor-architecture-design.md §3.5). The caption is a
6
+ * plain string (null = no caption) edited through the image's right-click menu
7
+ * / edit dialog — not the nested sub-editor the architecture doc leaves open —
8
+ * which keeps every edit in the parent editor's undo history.
9
+ */
10
+ export class ImageNode extends DecoratorNode {
11
+ static getType() {
12
+ return 'image';
13
+ }
14
+ static clone(node) {
15
+ return new ImageNode(node.__src, node.__altText, node.__caption, node.__linkType, node.__width, node.__height, node.__key);
16
+ }
17
+ static importJSON(json) {
18
+ return $createImageNode({
19
+ src: json.src,
20
+ altText: json.altText,
21
+ caption: json.caption,
22
+ linkType: json.linkType,
23
+ width: json.width,
24
+ height: json.height,
25
+ });
26
+ }
27
+ constructor(src, altText = '', caption = null, linkType = 'linked', width = null, height = null, key) {
28
+ super(key);
29
+ this.__src = src;
30
+ this.__altText = altText;
31
+ this.__caption = caption;
32
+ this.__linkType = linkType;
33
+ this.__width = width;
34
+ this.__height = height;
35
+ }
36
+ exportJSON() {
37
+ return {
38
+ type: 'image',
39
+ version: 1,
40
+ src: this.__src,
41
+ altText: this.__altText,
42
+ caption: this.__caption,
43
+ linkType: this.__linkType,
44
+ width: this.__width,
45
+ height: this.__height,
46
+ };
47
+ }
48
+ /** `<figure><img><figcaption>` — the figure/figcaption semantics from spec §4.7. */
49
+ exportDOM() {
50
+ const figure = document.createElement('figure');
51
+ const img = document.createElement('img');
52
+ img.setAttribute('src', this.__src);
53
+ img.setAttribute('alt', this.__altText);
54
+ if (this.__width)
55
+ img.setAttribute('width', String(this.__width));
56
+ if (this.__height)
57
+ img.setAttribute('height', String(this.__height));
58
+ figure.appendChild(img);
59
+ if (this.__caption) {
60
+ const caption = document.createElement('figcaption');
61
+ caption.textContent = this.__caption;
62
+ figure.appendChild(caption);
63
+ }
64
+ return { element: figure };
65
+ }
66
+ createDOM(config) {
67
+ const div = document.createElement('div');
68
+ const className = config.theme.image;
69
+ if (typeof className === 'string')
70
+ div.className = className;
71
+ return div;
72
+ }
73
+ updateDOM() {
74
+ return false;
75
+ }
76
+ isInline() {
77
+ return false;
78
+ }
79
+ getSrc() {
80
+ return this.__src;
81
+ }
82
+ getAltText() {
83
+ return this.__altText;
84
+ }
85
+ getCaption() {
86
+ return this.__caption;
87
+ }
88
+ getLinkType() {
89
+ return this.__linkType;
90
+ }
91
+ getWidth() {
92
+ return this.__width;
93
+ }
94
+ getHeight() {
95
+ return this.__height;
96
+ }
97
+ setCaption(caption) {
98
+ const writable = this.getWritable();
99
+ writable.__caption = caption || null;
100
+ return writable;
101
+ }
102
+ setAltText(altText) {
103
+ const writable = this.getWritable();
104
+ writable.__altText = altText;
105
+ return writable;
106
+ }
107
+ /** Points the node at new image data (a replaced URL/upload, or edited pixels). */
108
+ setSource(src, linkType) {
109
+ const writable = this.getWritable();
110
+ writable.__src = src;
111
+ writable.__linkType = linkType;
112
+ return writable;
113
+ }
114
+ /** Display size in px; null = the image's natural size. */
115
+ setDimensions(width, height) {
116
+ const writable = this.getWritable();
117
+ writable.__width = width;
118
+ writable.__height = height;
119
+ return writable;
120
+ }
121
+ getTextContent() {
122
+ return this.__altText || this.__caption || '';
123
+ }
124
+ decorate() {
125
+ return (_jsx(ImageComponent, { nodeKey: this.getKey(), src: this.__src, altText: this.__altText, caption: this.__caption, linkType: this.__linkType, width: this.__width, height: this.__height }));
126
+ }
127
+ }
128
+ export function $createImageNode({ src, altText, caption, linkType, width, height, key }) {
129
+ return new ImageNode(src, altText, caption ?? null, linkType, width ?? null, height ?? null, key);
130
+ }
131
+ export function $isImageNode(node) {
132
+ return node instanceof ImageNode;
133
+ }
@@ -0,0 +1,17 @@
1
+ /** Image settings from `EditorRoot` (feature config + upload handler), shared
2
+ * with the toolbar's insert dialog and with each image's own edit menu/dialog. */
3
+ export interface ImageOptions {
4
+ /** `images.linked` — images may reference an external URL. */
5
+ allowLinked: boolean;
6
+ /** `images.embedded` — images may be uploaded. */
7
+ allowEmbedded: boolean;
8
+ /** `images.caption` — images may have a caption. */
9
+ allowCaption: boolean;
10
+ /** `images.maxSizeMB` */
11
+ maxSizeMB: number;
12
+ /** Host upload handler; without it, uploads are embedded as base64 data URIs. */
13
+ onImageUpload?: (file: File) => Promise<string>;
14
+ }
15
+ export declare const ImageOptionsContext: import("react").Context<ImageOptions>;
16
+ export declare function useImageOptions(): ImageOptions;
17
+ //# sourceMappingURL=ImageOptionsContext.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ImageOptionsContext.d.ts","sourceRoot":"","sources":["../../src/image/ImageOptionsContext.tsx"],"names":[],"mappings":"AAEA;kFACkF;AAClF,MAAM,WAAW,YAAY;IAC3B,8DAA8D;IAC9D,WAAW,EAAE,OAAO,CAAC;IACrB,kDAAkD;IAClD,aAAa,EAAE,OAAO,CAAC;IACvB,oDAAoD;IACpD,YAAY,EAAE,OAAO,CAAC;IACtB,yBAAyB;IACzB,SAAS,EAAE,MAAM,CAAC;IAClB,iFAAiF;IACjF,aAAa,CAAC,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;CACjD;AAED,eAAO,MAAM,mBAAmB,uCAK9B,CAAC;AAEH,wBAAgB,eAAe,IAAI,YAAY,CAE9C"}
@@ -0,0 +1,10 @@
1
+ import { createContext, useContext } from 'react';
2
+ export const ImageOptionsContext = createContext({
3
+ allowLinked: true,
4
+ allowEmbedded: true,
5
+ allowCaption: true,
6
+ maxSizeMB: 5,
7
+ });
8
+ export function useImageOptions() {
9
+ return useContext(ImageOptionsContext);
10
+ }
@@ -0,0 +1,43 @@
1
+ /** A crop rectangle as fractions (0–1) of the image's width and height. */
2
+ export interface CropRect {
3
+ x: number;
4
+ y: number;
5
+ w: number;
6
+ h: number;
7
+ }
8
+ /** Converts a fractional crop rectangle to whole pixels inside a width x height
9
+ * image, clamped so it never leaves the image and is at least 1px. */
10
+ export declare function toPixelRect(rect: CropRect, width: number, height: number): {
11
+ sx: number;
12
+ sy: number;
13
+ sw: number;
14
+ sh: number;
15
+ };
16
+ /** When one dimension changes with the aspect ratio locked, returns both. */
17
+ export declare function fitDimension(changed: 'width' | 'height', value: number, naturalWidth: number, naturalHeight: number): {
18
+ width: number;
19
+ height: number;
20
+ };
21
+ /** MIME type to re-encode an edited image as: its own if it is a lossy/lossless
22
+ * format canvas can write (JPEG, WebP, PNG), otherwise PNG (GIF, SVG, unknown). */
23
+ export declare function mimeFromSrc(src: string): string;
24
+ /** Approximate decoded size, in bytes, of a base64 data URI. */
25
+ export declare function dataUrlBytes(dataUrl: string): number;
26
+ export declare function dataUrlToFile(dataUrl: string, name: string): File;
27
+ export type PixelOp = {
28
+ type: 'rotate';
29
+ direction: 'cw' | 'ccw';
30
+ } | {
31
+ type: 'flip';
32
+ axis: 'horizontal' | 'vertical';
33
+ } | {
34
+ type: 'crop';
35
+ rect: CropRect;
36
+ };
37
+ /** Loads an image for canvas use. External URLs are requested with CORS so the
38
+ * canvas isn't tainted; a server that doesn't allow that makes this reject. */
39
+ export declare function loadImage(src: string): Promise<HTMLImageElement>;
40
+ export declare const CANNOT_EDIT_PIXELS = "This image can't be edited here (its server doesn't allow it). Upload the file instead to crop or rotate it.";
41
+ /** Applies a rotate / flip / crop to an image and returns the result as a data URI. */
42
+ export declare function applyPixelOp(src: string, op: PixelOp): Promise<string>;
43
+ //# sourceMappingURL=imageEdit.d.ts.map
@@ -0,0 +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"}