@inshapardaz/likhari-react 0.1.5-dev.41 → 0.1.6-dev.43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/EditorRoot.d.ts.map +1 -1
- package/dist/EditorRoot.js +9 -1
- package/dist/components/Toolbar.d.ts +1 -2
- package/dist/components/Toolbar.d.ts.map +1 -1
- package/dist/components/Toolbar.js +2 -2
- package/dist/image/ImageComponent.d.ts +22 -0
- package/dist/image/ImageComponent.d.ts.map +1 -0
- package/dist/image/ImageComponent.js +132 -0
- package/dist/image/ImageCropper.d.ts +14 -0
- package/dist/image/ImageCropper.d.ts.map +1 -0
- package/dist/image/ImageCropper.js +87 -0
- package/dist/image/ImageDialog.d.ts +19 -14
- package/dist/image/ImageDialog.d.ts.map +1 -1
- package/dist/image/ImageDialog.js +160 -42
- package/dist/image/ImageNode.d.ts +12 -4
- package/dist/image/ImageNode.d.ts.map +1 -1
- package/dist/image/ImageNode.js +36 -54
- package/dist/image/ImageOptionsContext.d.ts +17 -0
- package/dist/image/ImageOptionsContext.d.ts.map +1 -0
- package/dist/image/ImageOptionsContext.js +10 -0
- package/dist/image/imageEdit.d.ts +43 -0
- package/dist/image/imageEdit.d.ts.map +1 -0
- package/dist/image/imageEdit.js +103 -0
- package/dist/theme/editor.css +49 -13
- package/package.json +1 -1
package/dist/EditorRoot.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"EditorRoot.d.ts","sourceRoot":"","sources":["../src/EditorRoot.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,eAAe,CAAC;AAS1D,OAAO,KAAK,EAAe,qBAAqB,EAAE,MAAM,SAAS,CAAC;AAClE,OAAO,EAAyB,KAAK,QAAQ,EAAE,MAAM,iCAAiC,CAAC;AACvF,OAAO,EAAwB,KAAK,mBAAmB,EAAE,KAAK,uBAAuB,EAAE,MAAM,2BAA2B,CAAC;AAKzH,OAAO,EAAyB,KAAK,UAAU,EAAE,MAAM,SAAS,CAAC;
|
|
1
|
+
{"version":3,"file":"EditorRoot.d.ts","sourceRoot":"","sources":["../src/EditorRoot.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,eAAe,CAAC;AAS1D,OAAO,KAAK,EAAe,qBAAqB,EAAE,MAAM,SAAS,CAAC;AAClE,OAAO,EAAyB,KAAK,QAAQ,EAAE,MAAM,iCAAiC,CAAC;AACvF,OAAO,EAAwB,KAAK,mBAAmB,EAAE,KAAK,uBAAuB,EAAE,MAAM,2BAA2B,CAAC;AAKzH,OAAO,EAAyB,KAAK,UAAU,EAAE,MAAM,SAAS,CAAC;AAIjE,MAAM,WAAW,oBAAoB;IACnC,MAAM,EAAE,OAAO,CAAC,QAAQ,EAAE,cAAc,GAAG,YAAY,CAAC,CAAC;IACzD,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,eAAe;IAC9B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,cAAc,CAAC,EAAE,oBAAoB,CAAC;IACtC,aAAa,CAAC,EAAE,mBAAmB,CAAC;IACpC,aAAa,CAAC,EAAE,uBAAuB,CAAC;IACxC,KAAK,CAAC,EAAE,oBAAoB,CAAC;IAC7B,WAAW,CAAC,EAAE,OAAO,GAAG,MAAM,CAAC;IAC/B,MAAM,CAAC,EAAE,IAAI,GAAG,IAAI,GAAG,cAAc,CAAC;IACtC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;;;;OAOG;IACH,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACzB,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,qBAAqB,KAAK,IAAI,CAAC;IAClD;;;OAGG;IACH,MAAM,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,KAAK,IAAI,CAAC;IACrD;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;;OAIG;IACH,aAAa,CAAC,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;IAChD;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,UAAU,EAAE,CAAC;CAC5B;AAED,MAAM,WAAW,SAAS;IACxB,UAAU,CAAC,MAAM,EAAE,QAAQ,GAAG,MAAM,CAAC;IACrC,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,GAAG,IAAI,CAAC;IAClD,iBAAiB,IAAI,OAAO,CAAC;IAC7B,cAAc,IAAI,OAAO,CAAC,OAAO,CAAC,CAAC;IACnC,KAAK,IAAI,IAAI,CAAC;CACf;AAQD,eAAO,MAAM,UAAU,uGA2IrB,CAAC"}
|
package/dist/EditorRoot.js
CHANGED
|
@@ -16,6 +16,7 @@ import { EditorThemeProvider } from './theme/EditorThemeProvider.js';
|
|
|
16
16
|
import { Toolbar } from './components/Toolbar.js';
|
|
17
17
|
import { injectUrduWebFontsCss } from './fonts/index.js';
|
|
18
18
|
import { LinkPastePlugin } from './plugins/LinkPastePlugin.js';
|
|
19
|
+
import { ImageOptionsContext } from './image/ImageOptionsContext.js';
|
|
19
20
|
function initialEditorStateJson(initialContent) {
|
|
20
21
|
if (!initialContent)
|
|
21
22
|
return undefined;
|
|
@@ -30,6 +31,13 @@ export const EditorRoot = forwardRef(function EditorRoot({ documentId, initialCo
|
|
|
30
31
|
if (config.font.family || locale !== 'en')
|
|
31
32
|
injectUrduWebFontsCss();
|
|
32
33
|
}, [config.font.family, locale]);
|
|
34
|
+
const imageOptions = useMemo(() => ({
|
|
35
|
+
allowLinked: config.images.linked,
|
|
36
|
+
allowEmbedded: config.images.embedded,
|
|
37
|
+
allowCaption: config.images.caption,
|
|
38
|
+
maxSizeMB: config.images.maxSizeMB,
|
|
39
|
+
onImageUpload,
|
|
40
|
+
}), [config.images.linked, config.images.embedded, config.images.caption, config.images.maxSizeMB, onImageUpload]);
|
|
33
41
|
const editorStateRef = useRef(null);
|
|
34
42
|
const lastSavedJsonRef = useRef(null);
|
|
35
43
|
const [isDirty, setIsDirty] = useState(false);
|
|
@@ -92,5 +100,5 @@ export const EditorRoot = forwardRef(function EditorRoot({ documentId, initialCo
|
|
|
92
100
|
onSave?.(content, format);
|
|
93
101
|
};
|
|
94
102
|
const dir = locale === 'en' ? 'ltr' : 'rtl';
|
|
95
|
-
return (_jsx(EditorThemeProvider, { theme: theme, colorScheme: colorScheme, children: _jsx("div", { className: "likhari-root", dir: dir, ref: rootElementRef, "data-document-id": documentId, style: { height: typeof height === 'number' ? `${height}px` : height }, children: _jsxs(LexicalComposer, { initialConfig: initialConfig, children: [_jsx(Toolbar, { config: config, onSave: handleSave, isDirty: isDirty, showSave: showSave, fontOptions: fontOptions,
|
|
103
|
+
return (_jsx(EditorThemeProvider, { theme: theme, colorScheme: colorScheme, children: _jsx("div", { className: "likhari-root", dir: dir, ref: rootElementRef, "data-document-id": documentId, style: { height: typeof height === 'number' ? `${height}px` : height }, children: _jsx(ImageOptionsContext.Provider, { value: imageOptions, children: _jsxs(LexicalComposer, { initialConfig: initialConfig, children: [_jsx(Toolbar, { config: config, onSave: handleSave, isDirty: isDirty, showSave: showSave, fontOptions: fontOptions, direction: dir }), _jsx("div", { className: "likhari-canvas", children: _jsx(RichTextPlugin, { contentEditable: _jsx(ContentEditable, { className: "likhari-content-editable", dir: dir, "aria-label": "Editor content" }), placeholder: _jsx("div", { className: "likhari-placeholder", children: placeholder }), ErrorBoundary: LexicalErrorBoundary }) }), config.history && _jsx(HistoryPlugin, {}), (config.lists.bullet || config.lists.numbered || config.lists.check) && _jsx(ListPlugin, {}), config.links && _jsx(LinkPlugin, {}), config.links && _jsx(LinkPastePlugin, {}), _jsx(OnChangePlugin, { onChange: handleChange })] }) }) }) }));
|
|
96
104
|
});
|
|
@@ -9,7 +9,6 @@ export interface ToolbarProps {
|
|
|
9
9
|
fontOptions?: FontOption[];
|
|
10
10
|
/** Text direction of the canvas; picks which default font and size are pre-selected. */
|
|
11
11
|
direction?: 'ltr' | 'rtl';
|
|
12
|
-
onImageUpload?: (file: File) => Promise<string>;
|
|
13
12
|
}
|
|
14
|
-
export declare function Toolbar({ config, onSave, isDirty, showSave,
|
|
13
|
+
export declare function Toolbar({ config, onSave, isDirty, showSave, fontOptions, direction }: ToolbarProps): import("react").JSX.Element;
|
|
15
14
|
//# sourceMappingURL=Toolbar.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Toolbar.d.ts","sourceRoot":"","sources":["../../src/components/Toolbar.tsx"],"names":[],"mappings":"AA4CA,OAAO,KAAK,EAAE,2BAA2B,EAAE,MAAM,2BAA2B,CAAC;AAC7E,OAAO,EAA6D,KAAK,UAAU,EAAE,MAAM,UAAU,CAAC;AAmPtG,MAAM,WAAW,YAAY;IAC3B,MAAM,EAAE,2BAA2B,CAAC;IACpC,MAAM,CAAC,EAAE,MAAM,IAAI,CAAC;IACpB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,sEAAsE;IACtE,WAAW,CAAC,EAAE,UAAU,EAAE,CAAC;IAC3B,wFAAwF;IACxF,SAAS,CAAC,EAAE,KAAK,GAAG,KAAK,CAAC;
|
|
1
|
+
{"version":3,"file":"Toolbar.d.ts","sourceRoot":"","sources":["../../src/components/Toolbar.tsx"],"names":[],"mappings":"AA4CA,OAAO,KAAK,EAAE,2BAA2B,EAAE,MAAM,2BAA2B,CAAC;AAC7E,OAAO,EAA6D,KAAK,UAAU,EAAE,MAAM,UAAU,CAAC;AAmPtG,MAAM,WAAW,YAAY;IAC3B,MAAM,EAAE,2BAA2B,CAAC;IACpC,MAAM,CAAC,EAAE,MAAM,IAAI,CAAC;IACpB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,sEAAsE;IACtE,WAAW,CAAC,EAAE,UAAU,EAAE,CAAC;IAC3B,wFAAwF;IACxF,SAAS,CAAC,EAAE,KAAK,GAAG,KAAK,CAAC;CAC3B;AAED,wBAAgB,OAAO,CAAC,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,QAAQ,EAAE,WAAkC,EAAE,SAAiB,EAAE,EAAE,YAAY,+BAgoBjI"}
|
|
@@ -73,7 +73,7 @@ function LinkMenuItems({ url, onEdit, onRemove }) {
|
|
|
73
73
|
// Not a URL the editor would accept (e.g. loaded from a document): show it, don't make it clickable.
|
|
74
74
|
_jsx(Menu.Item, { disabled: true, title: url, className: "likhari-link-menu-url", children: url || '(no URL)' })), _jsx(Menu.Item, { leftSection: _jsx(IconPencil, { size: ICON_SIZE, stroke: ICON_STROKE }), onMouseDown: (e) => e.preventDefault(), onClick: onEdit, children: "Edit link" }), _jsx(Menu.Item, { color: "red", leftSection: _jsx(IconLinkOff, { size: ICON_SIZE, stroke: ICON_STROKE }), onMouseDown: (e) => e.preventDefault(), onClick: onRemove, children: "Remove link" })] }));
|
|
75
75
|
}
|
|
76
|
-
export function Toolbar({ config, onSave, isDirty, showSave,
|
|
76
|
+
export function Toolbar({ config, onSave, isDirty, showSave, fontOptions = DEFAULT_FONT_OPTIONS, direction = 'ltr' }) {
|
|
77
77
|
const [editor] = useLexicalComposerContext();
|
|
78
78
|
const [state, setState] = useState(INITIAL_STATE);
|
|
79
79
|
const updateToolbar = useCallback(() => {
|
|
@@ -425,5 +425,5 @@ export function Toolbar({ config, onSave, isDirty, showSave, onImageUpload, font
|
|
|
425
425
|
}, position: "bottom-start", withinPortal: true, shadow: "sm", width: 240, children: [_jsx(Menu.Target, { children: _jsx("span", { "aria-hidden": "true", style: { position: 'fixed', left: linkContext?.x ?? -9999, top: linkContext?.y ?? -9999, width: 1, height: 1, pointerEvents: 'none' } }) }), _jsx(Menu.Dropdown, { children: _jsx(LinkMenuItems, { url: linkContext?.url ?? '', onEdit: () => {
|
|
426
426
|
restoreSelection();
|
|
427
427
|
openLinkDialog();
|
|
428
|
-
}, onRemove: removeLink }) })] })), showStubInsertGroup && (_jsxs("div", { className: "likhari-toolbar-group likhari-toolbar-group--collapse-tablet", children: [(config.images.linked || config.images.embedded) && (_jsx(ToolbarButton, { icon: IconPhoto, title: "Insert image", onClick: openImageDialog })), config.poetry.enabled && _jsx(StubButton, { icon: IconFeather, title: "Poetry blocks" })] })), showLanguageGroup && (_jsxs("div", { className: "likhari-toolbar-group likhari-toolbar-group--collapse-tablet", children: [config.language.autocorrect && _jsx(StubButton, { icon: IconWand, title: "Auto-correct" }), config.language.textCleanup && _jsx(StubButton, { icon: IconSparkles, title: "Text cleanup" }), config.language.spellCheck && _jsx(StubButton, { icon: IconAbc, title: "Spell-checker" })] })), showOverflowMenu && (_jsxs(Menu, { position: "bottom-end", withinPortal: true, shadow: "sm", width: 210, closeOnItemClick: true, onOpen: snapshotSelection, children: [_jsx(Menu.Target, { children: _jsx("button", { type: "button", className: "likhari-toolbar-button", "aria-label": "More formatting", title: "More formatting", onMouseDown: (e) => e.preventDefault(), children: _jsx(IconDots, { size: ICON_SIZE, stroke: ICON_STROKE }) }) }), _jsxs(Menu.Dropdown, { children: [fmt.strikethrough && (_jsx(OverflowItem, { icon: IconStrikethrough, label: "Strikethrough", active: state.activeFormats.has('strikethrough'), onClick: runOverflowAction(() => formatText('strikethrough')) })), fmt.superscript && (_jsx(OverflowItem, { icon: IconSuperscript, label: "Superscript", active: state.activeFormats.has('superscript'), onClick: runOverflowAction(() => formatText('superscript')) })), fmt.subscript && (_jsx(OverflowItem, { icon: IconSubscript, label: "Subscript", active: state.activeFormats.has('subscript'), onClick: runOverflowAction(() => formatText('subscript')) })), fmt.caseTransforms && (_jsxs(_Fragment, { children: [_jsx(OverflowItem, { icon: IconLetterCaseUpper, label: "UPPERCASE", onClick: runOverflowAction(() => applyCaseTransform('upper')) }), _jsx(OverflowItem, { icon: IconLetterCaseLower, label: "lowercase", onClick: runOverflowAction(() => applyCaseTransform('lower')) }), _jsx(OverflowItem, { icon: IconLetterCase, label: "Capitalize", onClick: runOverflowAction(() => applyCaseTransform('capitalize')) })] })), fmt.clearFormatting && _jsx(OverflowItem, { icon: IconClearFormatting, label: "Clear formatting", onClick: runOverflowAction(clearFormatting) }), config.indent && (_jsxs(_Fragment, { children: [_jsx(OverflowItem, { icon: IconIndentDecrease, label: "Outdent", onClick: runOverflowAction(() => editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined)) }), _jsx(OverflowItem, { icon: IconIndentIncrease, label: "Indent", onClick: runOverflowAction(() => editor.dispatchCommand(INDENT_CONTENT_COMMAND, undefined)) })] }))] })] })), (config.images.linked || config.images.embedded) && (_jsx(ImageDialog, {
|
|
428
|
+
}, onRemove: removeLink }) })] })), showStubInsertGroup && (_jsxs("div", { className: "likhari-toolbar-group likhari-toolbar-group--collapse-tablet", children: [(config.images.linked || config.images.embedded) && (_jsx(ToolbarButton, { icon: IconPhoto, title: "Insert image", onClick: openImageDialog })), config.poetry.enabled && _jsx(StubButton, { icon: IconFeather, title: "Poetry blocks" })] })), showLanguageGroup && (_jsxs("div", { className: "likhari-toolbar-group likhari-toolbar-group--collapse-tablet", children: [config.language.autocorrect && _jsx(StubButton, { icon: IconWand, title: "Auto-correct" }), config.language.textCleanup && _jsx(StubButton, { icon: IconSparkles, title: "Text cleanup" }), config.language.spellCheck && _jsx(StubButton, { icon: IconAbc, title: "Spell-checker" })] })), showOverflowMenu && (_jsxs(Menu, { position: "bottom-end", withinPortal: true, shadow: "sm", width: 210, closeOnItemClick: true, onOpen: snapshotSelection, children: [_jsx(Menu.Target, { children: _jsx("button", { type: "button", className: "likhari-toolbar-button", "aria-label": "More formatting", title: "More formatting", onMouseDown: (e) => e.preventDefault(), children: _jsx(IconDots, { size: ICON_SIZE, stroke: ICON_STROKE }) }) }), _jsxs(Menu.Dropdown, { children: [fmt.strikethrough && (_jsx(OverflowItem, { icon: IconStrikethrough, label: "Strikethrough", active: state.activeFormats.has('strikethrough'), onClick: runOverflowAction(() => formatText('strikethrough')) })), fmt.superscript && (_jsx(OverflowItem, { icon: IconSuperscript, label: "Superscript", active: state.activeFormats.has('superscript'), onClick: runOverflowAction(() => formatText('superscript')) })), fmt.subscript && (_jsx(OverflowItem, { icon: IconSubscript, label: "Subscript", active: state.activeFormats.has('subscript'), onClick: runOverflowAction(() => formatText('subscript')) })), fmt.caseTransforms && (_jsxs(_Fragment, { children: [_jsx(OverflowItem, { icon: IconLetterCaseUpper, label: "UPPERCASE", onClick: runOverflowAction(() => applyCaseTransform('upper')) }), _jsx(OverflowItem, { icon: IconLetterCaseLower, label: "lowercase", onClick: runOverflowAction(() => applyCaseTransform('lower')) }), _jsx(OverflowItem, { icon: IconLetterCase, label: "Capitalize", onClick: runOverflowAction(() => applyCaseTransform('capitalize')) })] })), fmt.clearFormatting && _jsx(OverflowItem, { icon: IconClearFormatting, label: "Clear formatting", onClick: runOverflowAction(clearFormatting) }), config.indent && (_jsxs(_Fragment, { children: [_jsx(OverflowItem, { icon: IconIndentDecrease, label: "Outdent", onClick: runOverflowAction(() => editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined)) }), _jsx(OverflowItem, { icon: IconIndentIncrease, label: "Indent", onClick: runOverflowAction(() => editor.dispatchCommand(INDENT_CONTENT_COMMAND, undefined)) })] }))] })] })), (config.images.linked || config.images.embedded) && (_jsx(ImageDialog, { mode: "insert", opened: imageDialogOpen, onSubmit: insertImage, onClose: closeImageDialog })), config.links && (_jsx(LinkDialog, { opened: linkDialogOpen, initialUrl: state.linkUrl, showTextField: linkNeedsText, onSubmit: applyLink, onRemove: state.isLink ? removeLink : undefined, onClose: closeLinkDialog }))] }));
|
|
429
429
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type NodeKey } from 'lexical';
|
|
2
|
+
import type { ImageLinkType } from './ImageNode.js';
|
|
3
|
+
interface ImageComponentProps {
|
|
4
|
+
nodeKey: NodeKey;
|
|
5
|
+
src: string;
|
|
6
|
+
altText: string;
|
|
7
|
+
caption: string | null;
|
|
8
|
+
linkType: ImageLinkType;
|
|
9
|
+
/** Display width in px; null = natural size (height always follows the aspect ratio). */
|
|
10
|
+
width: number | null;
|
|
11
|
+
height: number | null;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
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
|
|
17
|
+
* is shown as plain text under the image — it is edited in the dialog, not in
|
|
18
|
+
* place.
|
|
19
|
+
*/
|
|
20
|
+
export declare function ImageComponent({ nodeKey, src, altText, caption, linkType, width, height }: ImageComponentProps): import("react").JSX.Element;
|
|
21
|
+
export {};
|
|
22
|
+
//# sourceMappingURL=ImageComponent.d.ts.map
|
|
@@ -0,0 +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"}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { Menu } from '@mantine/core';
|
|
4
|
+
import { IconPhotoEdit, IconTextCaption, IconTrash, IconX } from '@tabler/icons-react';
|
|
5
|
+
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
|
6
|
+
import { useLexicalNodeSelection } from '@lexical/react/useLexicalNodeSelection';
|
|
7
|
+
import { $getNodeByKey, $getSelection, $isNodeSelection, CLICK_COMMAND, COMMAND_PRIORITY_LOW, KEY_BACKSPACE_COMMAND, KEY_DELETE_COMMAND, } from 'lexical';
|
|
8
|
+
import { ImageDialog } from './ImageDialog.js';
|
|
9
|
+
import { useImageOptions } from './ImageOptionsContext.js';
|
|
10
|
+
// A type-only import of ImageNode (ImageNode itself imports this file), so the
|
|
11
|
+
// node is recognised by its type string rather than `instanceof`.
|
|
12
|
+
function isImageNode(node) {
|
|
13
|
+
return node?.getType() === 'image';
|
|
14
|
+
}
|
|
15
|
+
const MIN_WIDTH = 32;
|
|
16
|
+
/**
|
|
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
|
|
20
|
+
* is shown as plain text under the image — it is edited in the dialog, not in
|
|
21
|
+
* place.
|
|
22
|
+
*/
|
|
23
|
+
export function ImageComponent({ nodeKey, src, altText, caption, linkType, width, height }) {
|
|
24
|
+
const [editor] = useLexicalComposerContext();
|
|
25
|
+
const options = useImageOptions();
|
|
26
|
+
const [isSelected, setSelected, clearSelection] = useLexicalNodeSelection(nodeKey);
|
|
27
|
+
const imageRef = useRef(null);
|
|
28
|
+
const figureRef = useRef(null);
|
|
29
|
+
const [menu, setMenu] = useState(null);
|
|
30
|
+
const [dialog, setDialog] = useState(null);
|
|
31
|
+
const [liveWidth, setLiveWidth] = useState(null);
|
|
32
|
+
// Selecting must go through CLICK_COMMAND (returning true) rather than a
|
|
33
|
+
// React onClick: otherwise Lexical's own click handling turns the DOM
|
|
34
|
+
// selection into a range selection right after, undoing the node selection.
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
return editor.registerCommand(CLICK_COMMAND, (event) => {
|
|
37
|
+
if (event.target !== imageRef.current)
|
|
38
|
+
return false;
|
|
39
|
+
clearSelection();
|
|
40
|
+
setSelected(true);
|
|
41
|
+
return true;
|
|
42
|
+
}, COMMAND_PRIORITY_LOW);
|
|
43
|
+
}, [editor, clearSelection, setSelected]);
|
|
44
|
+
const removeImage = useCallback(() => {
|
|
45
|
+
editor.update(() => {
|
|
46
|
+
$getNodeByKey(nodeKey)?.remove();
|
|
47
|
+
});
|
|
48
|
+
}, [editor, nodeKey]);
|
|
49
|
+
const onDelete = useCallback((event) => {
|
|
50
|
+
if (!isSelected || dialog || menu)
|
|
51
|
+
return false;
|
|
52
|
+
if (!$isNodeSelection($getSelection()))
|
|
53
|
+
return false;
|
|
54
|
+
event.preventDefault();
|
|
55
|
+
$getNodeByKey(nodeKey)?.remove();
|
|
56
|
+
return true;
|
|
57
|
+
}, [isSelected, dialog, menu, nodeKey]);
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
const unregisterDelete = editor.registerCommand(KEY_DELETE_COMMAND, onDelete, COMMAND_PRIORITY_LOW);
|
|
60
|
+
const unregisterBackspace = editor.registerCommand(KEY_BACKSPACE_COMMAND, onDelete, COMMAND_PRIORITY_LOW);
|
|
61
|
+
return () => {
|
|
62
|
+
unregisterDelete();
|
|
63
|
+
unregisterBackspace();
|
|
64
|
+
};
|
|
65
|
+
}, [editor, onDelete]);
|
|
66
|
+
const updateNode = (change) => {
|
|
67
|
+
editor.update(() => {
|
|
68
|
+
const node = $getNodeByKey(nodeKey);
|
|
69
|
+
if (isImageNode(node))
|
|
70
|
+
change(node);
|
|
71
|
+
});
|
|
72
|
+
};
|
|
73
|
+
const applyEdit = (value) => {
|
|
74
|
+
updateNode((node) => {
|
|
75
|
+
node.setSource(value.src, value.linkType);
|
|
76
|
+
node.setAltText(value.altText);
|
|
77
|
+
node.setCaption(value.caption);
|
|
78
|
+
node.setDimensions(value.width, value.height);
|
|
79
|
+
});
|
|
80
|
+
setDialog(null);
|
|
81
|
+
editor.focus();
|
|
82
|
+
};
|
|
83
|
+
// --- drag-to-resize: bottom-right handle, aspect ratio kept ---------------
|
|
84
|
+
const resizeStart = useRef(null);
|
|
85
|
+
const onResizeDown = (event) => {
|
|
86
|
+
const img = imageRef.current;
|
|
87
|
+
if (!img)
|
|
88
|
+
return;
|
|
89
|
+
event.preventDefault();
|
|
90
|
+
event.stopPropagation();
|
|
91
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
92
|
+
const box = img.getBoundingClientRect();
|
|
93
|
+
resizeStart.current = {
|
|
94
|
+
x: event.clientX,
|
|
95
|
+
width: box.width,
|
|
96
|
+
ratio: img.naturalHeight && img.naturalWidth ? img.naturalHeight / img.naturalWidth : box.height / box.width,
|
|
97
|
+
max: figureRef.current?.clientWidth ?? box.width,
|
|
98
|
+
};
|
|
99
|
+
setLiveWidth(box.width);
|
|
100
|
+
};
|
|
101
|
+
const onResizeMove = (event) => {
|
|
102
|
+
const start = resizeStart.current;
|
|
103
|
+
if (!start)
|
|
104
|
+
return;
|
|
105
|
+
setLiveWidth(Math.round(Math.min(Math.max(start.width + (event.clientX - start.x), MIN_WIDTH), start.max)));
|
|
106
|
+
};
|
|
107
|
+
const onResizeUp = () => {
|
|
108
|
+
const start = resizeStart.current;
|
|
109
|
+
resizeStart.current = null;
|
|
110
|
+
if (start && liveWidth !== null) {
|
|
111
|
+
const w = Math.round(liveWidth);
|
|
112
|
+
updateNode((node) => node.setDimensions(w, Math.round(w * start.ratio)));
|
|
113
|
+
}
|
|
114
|
+
setLiveWidth(null);
|
|
115
|
+
};
|
|
116
|
+
const shownWidth = liveWidth ?? width ?? undefined;
|
|
117
|
+
return (_jsxs("figure", { ref: figureRef, className: "likhari-image-figure", "data-selected": isSelected ? 'true' : 'false', contentEditable: false, onDoubleClick: (e) => {
|
|
118
|
+
if (e.target === imageRef.current)
|
|
119
|
+
setDialog({});
|
|
120
|
+
}, onContextMenu: (e) => {
|
|
121
|
+
e.preventDefault();
|
|
122
|
+
clearSelection();
|
|
123
|
+
setSelected(true);
|
|
124
|
+
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
|
+
if (!opened)
|
|
127
|
+
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
|
+
setDialog(null);
|
|
130
|
+
editor.focus();
|
|
131
|
+
} })] }));
|
|
132
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface ImageCropperProps {
|
|
2
|
+
/** The image as it is now (original, or already edited). */
|
|
3
|
+
src: string;
|
|
4
|
+
/** Called with the edited image as a data URI. */
|
|
5
|
+
onApply: (edited: string) => void;
|
|
6
|
+
/** Whether there are edits to undo. */
|
|
7
|
+
canReset: boolean;
|
|
8
|
+
onReset: () => void;
|
|
9
|
+
}
|
|
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;
|
|
14
|
+
//# sourceMappingURL=ImageCropper.d.ts.map
|
|
@@ -0,0 +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"}
|
|
@@ -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
|
+
}
|
|
@@ -1,25 +1,30 @@
|
|
|
1
1
|
import type { ImageLinkType } from './ImageNode.js';
|
|
2
|
-
export interface
|
|
2
|
+
export interface ImageDialogValue {
|
|
3
3
|
src: string;
|
|
4
4
|
altText: string;
|
|
5
|
+
/** null = no caption */
|
|
5
6
|
caption: string | null;
|
|
6
7
|
linkType: ImageLinkType;
|
|
8
|
+
/** Display size in px; null = natural size */
|
|
9
|
+
width: number | null;
|
|
10
|
+
height: number | null;
|
|
7
11
|
}
|
|
8
12
|
export interface ImageDialogProps {
|
|
13
|
+
/** `insert`: pick an image. `edit`: also change its source, size, crop and rotation. */
|
|
14
|
+
mode: 'insert' | 'edit';
|
|
9
15
|
opened: boolean;
|
|
10
|
-
/**
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
allowCaption: boolean;
|
|
16
|
-
/** `images.maxSizeMB` */
|
|
17
|
-
maxSizeMB: number;
|
|
18
|
-
/** Host upload handler; when absent, uploads are embedded as base64 data URIs. */
|
|
19
|
-
onImageUpload?: (file: File) => Promise<string>;
|
|
20
|
-
onSubmit: (image: ImageDialogResult) => void;
|
|
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
21
|
onClose: () => void;
|
|
22
22
|
}
|
|
23
|
-
/**
|
|
24
|
-
|
|
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;
|
|
25
30
|
//# 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":"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,7 +1,10 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useEffect, useState } from 'react';
|
|
3
|
-
import { Alert, Button, FileInput, Group, Modal, SegmentedControl, Stack, TextInput } from '@mantine/core';
|
|
3
|
+
import { Alert, Button, Checkbox, FileInput, Group, Modal, NumberInput, SegmentedControl, Stack, Tabs, Text, TextInput, } from '@mantine/core';
|
|
4
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';
|
|
5
8
|
function readAsDataUrl(file) {
|
|
6
9
|
return new Promise((resolve, reject) => {
|
|
7
10
|
const reader = new FileReader();
|
|
@@ -10,67 +13,182 @@ function readAsDataUrl(file) {
|
|
|
10
13
|
reader.readAsDataURL(file);
|
|
11
14
|
});
|
|
12
15
|
}
|
|
13
|
-
/**
|
|
14
|
-
|
|
15
|
-
const
|
|
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');
|
|
16
47
|
const [source, setSource] = useState(defaultSource);
|
|
17
48
|
const [url, setUrl] = useState('');
|
|
18
49
|
const [file, setFile] = useState(null);
|
|
50
|
+
const [fileSrc, setFileSrc] = useState(null);
|
|
19
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);
|
|
20
57
|
const [error, setError] = useState(null);
|
|
21
58
|
const [busy, setBusy] = useState(false);
|
|
22
59
|
useEffect(() => {
|
|
23
|
-
if (opened)
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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');
|
|
30
110
|
}
|
|
31
|
-
}
|
|
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
|
+
};
|
|
32
127
|
const handleSubmit = async (e) => {
|
|
33
128
|
e.preventDefault();
|
|
34
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
|
+
}
|
|
35
134
|
try {
|
|
36
|
-
let src;
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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`);
|
|
41
143
|
return;
|
|
42
144
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
+
}
|
|
53
156
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
if (!normalizeImageUrl(src)) {
|
|
57
|
-
setError('The upload handler returned an unusable image URL');
|
|
58
|
-
setBusy(false);
|
|
59
|
-
return;
|
|
157
|
+
else {
|
|
158
|
+
src = newPixels;
|
|
60
159
|
}
|
|
61
160
|
}
|
|
62
|
-
onSubmit({
|
|
161
|
+
onSubmit({
|
|
162
|
+
src,
|
|
163
|
+
altText: altText.trim(),
|
|
164
|
+
caption: allowCaption ? caption.trim() || null : (initial?.caption ?? null),
|
|
165
|
+
linkType,
|
|
166
|
+
width,
|
|
167
|
+
height,
|
|
168
|
+
});
|
|
63
169
|
}
|
|
64
170
|
catch (err) {
|
|
65
|
-
setError(err instanceof Error ? err.message : 'Could not
|
|
171
|
+
setError(err instanceof Error ? err.message : 'Could not save the image');
|
|
172
|
+
}
|
|
173
|
+
finally {
|
|
66
174
|
setBusy(false);
|
|
67
175
|
}
|
|
68
176
|
};
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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' })] })] }) }) }));
|
|
76
194
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { ReactElement } from 'react';
|
|
2
2
|
import { DecoratorNode, type DOMExportOutput, type EditorConfig, type LexicalNode, type NodeKey, type SerializedLexicalNode, type Spread } from 'lexical';
|
|
3
3
|
/** How the image's bytes are held: an external URL, or embedded in the
|
|
4
4
|
* document (a base64 data URI, or a URL returned by the host's upload handler). */
|
|
@@ -23,9 +23,9 @@ export type SerializedImageNode = Spread<{
|
|
|
23
23
|
}, SerializedLexicalNode>;
|
|
24
24
|
/**
|
|
25
25
|
* Block-level image (editor-architecture-design.md §3.5). The caption is a
|
|
26
|
-
* plain string
|
|
27
|
-
* sub-editor the architecture doc leaves open —
|
|
28
|
-
* the parent editor's undo history.
|
|
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
29
|
*/
|
|
30
30
|
export declare class ImageNode extends DecoratorNode<ReactElement> {
|
|
31
31
|
__src: string;
|
|
@@ -47,7 +47,15 @@ export declare class ImageNode extends DecoratorNode<ReactElement> {
|
|
|
47
47
|
getSrc(): string;
|
|
48
48
|
getAltText(): string;
|
|
49
49
|
getCaption(): string | null;
|
|
50
|
+
getLinkType(): ImageLinkType;
|
|
51
|
+
getWidth(): number | null;
|
|
52
|
+
getHeight(): number | null;
|
|
50
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;
|
|
51
59
|
getTextContent(): string;
|
|
52
60
|
decorate(): ReactElement;
|
|
53
61
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ImageNode.d.ts","sourceRoot":"","sources":["../../src/image/ImageNode.tsx"],"names":[],"mappings":"AAAA,OAAO,
|
|
1
|
+
{"version":3,"file":"ImageNode.d.ts","sourceRoot":"","sources":["../../src/image/ImageNode.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,OAAO,CAAC;AAC1C,OAAO,EACL,aAAa,EACb,KAAK,eAAe,EACpB,KAAK,YAAY,EACjB,KAAK,WAAW,EAChB,KAAK,OAAO,EACZ,KAAK,qBAAqB,EAC1B,KAAK,MAAM,EACZ,MAAM,SAAS,CAAC;AAGjB;mFACmF;AACnF,MAAM,MAAM,aAAa,GAAG,QAAQ,GAAG,UAAU,CAAC;AAElD,MAAM,WAAW,YAAY;IAC3B,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,wDAAwD;IACxD,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,QAAQ,EAAE,aAAa,CAAC;IACxB,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,GAAG,CAAC,EAAE,OAAO,CAAC;CACf;AAED,MAAM,MAAM,mBAAmB,GAAG,MAAM,CACtC;IACE,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,QAAQ,EAAE,aAAa,CAAC;IACxB,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;CACvB,EACD,qBAAqB,CACtB,CAAC;AAEF;;;;;GAKG;AACH,qBAAa,SAAU,SAAQ,aAAa,CAAC,YAAY,CAAC;IACxD,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,UAAU,EAAE,aAAa,CAAC;IAC1B,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IAExB,MAAM,CAAC,OAAO,IAAI,MAAM;IAIxB,MAAM,CAAC,KAAK,CAAC,IAAI,EAAE,SAAS,GAAG,SAAS;IAIxC,MAAM,CAAC,UAAU,CAAC,IAAI,EAAE,mBAAmB,GAAG,SAAS;gBAYrD,GAAG,EAAE,MAAM,EACX,OAAO,SAAK,EACZ,OAAO,GAAE,MAAM,GAAG,IAAW,EAC7B,QAAQ,GAAE,aAAwB,EAClC,KAAK,GAAE,MAAM,GAAG,IAAW,EAC3B,MAAM,GAAE,MAAM,GAAG,IAAW,EAC5B,GAAG,CAAC,EAAE,OAAO;IAWf,UAAU,IAAI,mBAAmB;IAajC,oFAAoF;IACpF,SAAS,IAAI,eAAe;IAgB5B,SAAS,CAAC,MAAM,EAAE,YAAY,GAAG,WAAW;IAO5C,SAAS,IAAI,KAAK;IAIlB,QAAQ,IAAI,KAAK;IAIjB,MAAM,IAAI,MAAM;IAIhB,UAAU,IAAI,MAAM;IAIpB,UAAU,IAAI,MAAM,GAAG,IAAI;IAI3B,WAAW,IAAI,aAAa;IAI5B,QAAQ,IAAI,MAAM,GAAG,IAAI;IAIzB,SAAS,IAAI,MAAM,GAAG,IAAI;IAI1B,UAAU,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,GAAG,IAAI;IAMxC,UAAU,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI;IAMjC,mFAAmF;IACnF,SAAS,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,EAAE,aAAa,GAAG,IAAI;IAOrD,2DAA2D;IAC3D,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,IAAI;IAOhE,cAAc,IAAI,MAAM;IAIxB,QAAQ,IAAI,YAAY;CAazB;AAED,wBAAgB,gBAAgB,CAAC,EAAE,GAAG,EAAE,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,EAAE,YAAY,GAAG,SAAS,CAEjH;AAED,wBAAgB,YAAY,CAAC,IAAI,EAAE,WAAW,GAAG,IAAI,GAAG,SAAS,GAAG,IAAI,IAAI,SAAS,CAEpF"}
|
package/dist/image/ImageNode.js
CHANGED
|
@@ -1,13 +1,11 @@
|
|
|
1
|
-
import { jsx as _jsx
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { useLexicalNodeSelection } from '@lexical/react/useLexicalNodeSelection';
|
|
5
|
-
import { $getNodeByKey, $getSelection, $isNodeSelection, CLICK_COMMAND, COMMAND_PRIORITY_LOW, DecoratorNode, KEY_BACKSPACE_COMMAND, KEY_DELETE_COMMAND, } from 'lexical';
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { DecoratorNode, } from 'lexical';
|
|
3
|
+
import { ImageComponent } from './ImageComponent.js';
|
|
6
4
|
/**
|
|
7
5
|
* Block-level image (editor-architecture-design.md §3.5). The caption is a
|
|
8
|
-
* plain string
|
|
9
|
-
* sub-editor the architecture doc leaves open —
|
|
10
|
-
* the parent editor's undo history.
|
|
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.
|
|
11
9
|
*/
|
|
12
10
|
export class ImageNode extends DecoratorNode {
|
|
13
11
|
static getType() {
|
|
@@ -87,16 +85,44 @@ export class ImageNode extends DecoratorNode {
|
|
|
87
85
|
getCaption() {
|
|
88
86
|
return this.__caption;
|
|
89
87
|
}
|
|
88
|
+
getLinkType() {
|
|
89
|
+
return this.__linkType;
|
|
90
|
+
}
|
|
91
|
+
getWidth() {
|
|
92
|
+
return this.__width;
|
|
93
|
+
}
|
|
94
|
+
getHeight() {
|
|
95
|
+
return this.__height;
|
|
96
|
+
}
|
|
90
97
|
setCaption(caption) {
|
|
91
98
|
const writable = this.getWritable();
|
|
92
|
-
writable.__caption = caption;
|
|
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;
|
|
93
119
|
return writable;
|
|
94
120
|
}
|
|
95
121
|
getTextContent() {
|
|
96
122
|
return this.__altText || this.__caption || '';
|
|
97
123
|
}
|
|
98
124
|
decorate() {
|
|
99
|
-
return (_jsx(ImageComponent, { nodeKey: this.getKey(), src: this.__src, altText: this.__altText, caption: this.__caption, width: this.__width, height: this.__height }));
|
|
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 }));
|
|
100
126
|
}
|
|
101
127
|
}
|
|
102
128
|
export function $createImageNode({ src, altText, caption, linkType, width, height, key }) {
|
|
@@ -105,47 +131,3 @@ export function $createImageNode({ src, altText, caption, linkType, width, heigh
|
|
|
105
131
|
export function $isImageNode(node) {
|
|
106
132
|
return node instanceof ImageNode;
|
|
107
133
|
}
|
|
108
|
-
function ImageComponent({ nodeKey, src, altText, caption, width, height }) {
|
|
109
|
-
const [editor] = useLexicalComposerContext();
|
|
110
|
-
const [isSelected, setSelected, clearSelection] = useLexicalNodeSelection(nodeKey);
|
|
111
|
-
const imageRef = useRef(null);
|
|
112
|
-
// Selecting must go through CLICK_COMMAND (returning true) rather than a
|
|
113
|
-
// React onClick: otherwise Lexical's own click handling turns the DOM
|
|
114
|
-
// selection into a range selection right after, undoing the node selection.
|
|
115
|
-
useEffect(() => {
|
|
116
|
-
return editor.registerCommand(CLICK_COMMAND, (event) => {
|
|
117
|
-
if (event.target !== imageRef.current)
|
|
118
|
-
return false;
|
|
119
|
-
clearSelection();
|
|
120
|
-
setSelected(true);
|
|
121
|
-
return true;
|
|
122
|
-
}, COMMAND_PRIORITY_LOW);
|
|
123
|
-
}, [editor, clearSelection, setSelected]);
|
|
124
|
-
// Delete/Backspace removes the selected image — but not while typing in the
|
|
125
|
-
// caption input, whose keystrokes also reach the editor's root listener.
|
|
126
|
-
const onDelete = useCallback((event) => {
|
|
127
|
-
if (!isSelected || event.target instanceof HTMLInputElement)
|
|
128
|
-
return false;
|
|
129
|
-
if (!$isNodeSelection($getSelection()))
|
|
130
|
-
return false;
|
|
131
|
-
event.preventDefault();
|
|
132
|
-
$getNodeByKey(nodeKey)?.remove();
|
|
133
|
-
return true;
|
|
134
|
-
}, [isSelected, nodeKey]);
|
|
135
|
-
useEffect(() => {
|
|
136
|
-
const unregisterDelete = editor.registerCommand(KEY_DELETE_COMMAND, onDelete, COMMAND_PRIORITY_LOW);
|
|
137
|
-
const unregisterBackspace = editor.registerCommand(KEY_BACKSPACE_COMMAND, onDelete, COMMAND_PRIORITY_LOW);
|
|
138
|
-
return () => {
|
|
139
|
-
unregisterDelete();
|
|
140
|
-
unregisterBackspace();
|
|
141
|
-
};
|
|
142
|
-
}, [editor, onDelete]);
|
|
143
|
-
const setCaption = (value) => {
|
|
144
|
-
editor.update(() => {
|
|
145
|
-
const node = $getNodeByKey(nodeKey);
|
|
146
|
-
if ($isImageNode(node))
|
|
147
|
-
node.setCaption(value);
|
|
148
|
-
});
|
|
149
|
-
};
|
|
150
|
-
return (_jsxs("figure", { className: "likhari-image-figure", "data-selected": isSelected ? 'true' : 'false', contentEditable: false, children: [_jsx("img", { ref: imageRef, className: "likhari-image", src: src, alt: altText, width: width ?? undefined, height: height ?? undefined, draggable: false }), caption !== null && (_jsx("input", { className: "likhari-image-caption", type: "text", "aria-label": "Image caption", placeholder: "Add a caption\u2026", value: caption, onChange: (e) => setCaption(e.currentTarget.value) }))] }));
|
|
151
|
-
}
|
|
@@ -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"}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/** Converts a fractional crop rectangle to whole pixels inside a width x height
|
|
2
|
+
* image, clamped so it never leaves the image and is at least 1px. */
|
|
3
|
+
export function toPixelRect(rect, width, height) {
|
|
4
|
+
const sx = Math.min(Math.max(Math.round(rect.x * width), 0), width - 1);
|
|
5
|
+
const sy = Math.min(Math.max(Math.round(rect.y * height), 0), height - 1);
|
|
6
|
+
const sw = Math.min(Math.max(Math.round(rect.w * width), 1), width - sx);
|
|
7
|
+
const sh = Math.min(Math.max(Math.round(rect.h * height), 1), height - sy);
|
|
8
|
+
return { sx, sy, sw, sh };
|
|
9
|
+
}
|
|
10
|
+
/** When one dimension changes with the aspect ratio locked, returns both. */
|
|
11
|
+
export function fitDimension(changed, value, naturalWidth, naturalHeight) {
|
|
12
|
+
const v = Math.max(1, Math.round(value));
|
|
13
|
+
return changed === 'width'
|
|
14
|
+
? { width: v, height: Math.max(1, Math.round((v * naturalHeight) / naturalWidth)) }
|
|
15
|
+
: { width: Math.max(1, Math.round((v * naturalWidth) / naturalHeight)), height: v };
|
|
16
|
+
}
|
|
17
|
+
/** MIME type to re-encode an edited image as: its own if it is a lossy/lossless
|
|
18
|
+
* format canvas can write (JPEG, WebP, PNG), otherwise PNG (GIF, SVG, unknown). */
|
|
19
|
+
export function mimeFromSrc(src) {
|
|
20
|
+
const data = /^data:(image\/[a-z0-9.+-]+)[;,]/i.exec(src);
|
|
21
|
+
const type = data ? data[1].toLowerCase() : extensionMime(src);
|
|
22
|
+
return type === 'image/jpeg' || type === 'image/webp' ? type : 'image/png';
|
|
23
|
+
}
|
|
24
|
+
function extensionMime(src) {
|
|
25
|
+
const match = /\.(jpe?g|webp|png|gif|svg)(?:[?#].*)?$/i.exec(src);
|
|
26
|
+
if (!match)
|
|
27
|
+
return 'image/png';
|
|
28
|
+
const ext = match[1].toLowerCase();
|
|
29
|
+
return ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg' : ext === 'webp' ? 'image/webp' : 'image/png';
|
|
30
|
+
}
|
|
31
|
+
/** Approximate decoded size, in bytes, of a base64 data URI. */
|
|
32
|
+
export function dataUrlBytes(dataUrl) {
|
|
33
|
+
const comma = dataUrl.indexOf(',');
|
|
34
|
+
const base64 = comma === -1 ? '' : dataUrl.slice(comma + 1);
|
|
35
|
+
const padding = base64.endsWith('==') ? 2 : base64.endsWith('=') ? 1 : 0;
|
|
36
|
+
return Math.floor((base64.length * 3) / 4) - padding;
|
|
37
|
+
}
|
|
38
|
+
export function dataUrlToFile(dataUrl, name) {
|
|
39
|
+
const comma = dataUrl.indexOf(',');
|
|
40
|
+
const mime = /^data:([^;,]+)/.exec(dataUrl)?.[1] ?? 'image/png';
|
|
41
|
+
const binary = atob(dataUrl.slice(comma + 1));
|
|
42
|
+
const bytes = new Uint8Array(binary.length);
|
|
43
|
+
for (let i = 0; i < binary.length; i++)
|
|
44
|
+
bytes[i] = binary.charCodeAt(i);
|
|
45
|
+
return new File([bytes], name, { type: mime });
|
|
46
|
+
}
|
|
47
|
+
/** Loads an image for canvas use. External URLs are requested with CORS so the
|
|
48
|
+
* canvas isn't tainted; a server that doesn't allow that makes this reject. */
|
|
49
|
+
export function loadImage(src) {
|
|
50
|
+
return new Promise((resolve, reject) => {
|
|
51
|
+
const img = new Image();
|
|
52
|
+
if (/^https?:/i.test(src))
|
|
53
|
+
img.crossOrigin = 'anonymous';
|
|
54
|
+
img.onload = () => resolve(img);
|
|
55
|
+
img.onerror = () => reject(new Error('IMAGE_LOAD_FAILED'));
|
|
56
|
+
img.src = src;
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
export 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.";
|
|
60
|
+
/** Applies a rotate / flip / crop to an image and returns the result as a data URI. */
|
|
61
|
+
export async function applyPixelOp(src, op) {
|
|
62
|
+
let img;
|
|
63
|
+
try {
|
|
64
|
+
img = await loadImage(src);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
throw new Error(CANNOT_EDIT_PIXELS);
|
|
68
|
+
}
|
|
69
|
+
const width = img.naturalWidth;
|
|
70
|
+
const height = img.naturalHeight;
|
|
71
|
+
if (!width || !height)
|
|
72
|
+
throw new Error('This image has no pixel size to edit (some SVGs). Upload a PNG or JPEG instead.');
|
|
73
|
+
const canvas = document.createElement('canvas');
|
|
74
|
+
const ctx = canvas.getContext('2d');
|
|
75
|
+
if (!ctx)
|
|
76
|
+
throw new Error('Image editing is not available in this browser');
|
|
77
|
+
if (op.type === 'rotate') {
|
|
78
|
+
canvas.width = height;
|
|
79
|
+
canvas.height = width;
|
|
80
|
+
ctx.translate(height / 2, width / 2);
|
|
81
|
+
ctx.rotate(op.direction === 'cw' ? Math.PI / 2 : -Math.PI / 2);
|
|
82
|
+
ctx.drawImage(img, -width / 2, -height / 2);
|
|
83
|
+
}
|
|
84
|
+
else if (op.type === 'flip') {
|
|
85
|
+
canvas.width = width;
|
|
86
|
+
canvas.height = height;
|
|
87
|
+
ctx.translate(op.axis === 'horizontal' ? width : 0, op.axis === 'vertical' ? height : 0);
|
|
88
|
+
ctx.scale(op.axis === 'horizontal' ? -1 : 1, op.axis === 'vertical' ? -1 : 1);
|
|
89
|
+
ctx.drawImage(img, 0, 0);
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
const { sx, sy, sw, sh } = toPixelRect(op.rect, width, height);
|
|
93
|
+
canvas.width = sw;
|
|
94
|
+
canvas.height = sh;
|
|
95
|
+
ctx.drawImage(img, sx, sy, sw, sh, 0, 0, sw, sh);
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
return canvas.toDataURL(mimeFromSrc(src), 0.92);
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
throw new Error(CANNOT_EDIT_PIXELS);
|
|
102
|
+
}
|
|
103
|
+
}
|
package/dist/theme/editor.css
CHANGED
|
@@ -235,7 +235,8 @@
|
|
|
235
235
|
margin: 1.5em 0;
|
|
236
236
|
}
|
|
237
237
|
|
|
238
|
-
/* Images — block-level figure
|
|
238
|
+
/* Images — block-level figure; the caption is static text (edited in the
|
|
239
|
+
image's dialog), and a selected image gets a corner resize handle. */
|
|
239
240
|
.likhari-image-block {
|
|
240
241
|
margin: 0 0 1em 0;
|
|
241
242
|
}
|
|
@@ -246,9 +247,16 @@
|
|
|
246
247
|
text-align: center;
|
|
247
248
|
}
|
|
248
249
|
|
|
249
|
-
.likhari-image {
|
|
250
|
+
.likhari-image-wrap {
|
|
251
|
+
position: relative;
|
|
250
252
|
display: inline-block;
|
|
251
253
|
max-width: 100%;
|
|
254
|
+
line-height: 0;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
.likhari-image {
|
|
258
|
+
display: block;
|
|
259
|
+
max-width: 100%;
|
|
252
260
|
height: auto;
|
|
253
261
|
border-radius: 4px;
|
|
254
262
|
cursor: default;
|
|
@@ -259,24 +267,52 @@
|
|
|
259
267
|
outline-offset: 2px;
|
|
260
268
|
}
|
|
261
269
|
|
|
270
|
+
.likhari-image-resize-handle {
|
|
271
|
+
position: absolute;
|
|
272
|
+
right: -6px;
|
|
273
|
+
bottom: -6px;
|
|
274
|
+
width: 12px;
|
|
275
|
+
height: 12px;
|
|
276
|
+
border: 2px solid var(--editor-paper);
|
|
277
|
+
border-radius: 3px;
|
|
278
|
+
background: var(--editor-accent);
|
|
279
|
+
cursor: nwse-resize;
|
|
280
|
+
touch-action: none;
|
|
281
|
+
}
|
|
282
|
+
|
|
262
283
|
.likhari-image-caption {
|
|
263
|
-
display: block;
|
|
264
|
-
width: 100%;
|
|
265
284
|
margin-top: 6px;
|
|
266
|
-
padding: 2px 4px;
|
|
267
|
-
border: none;
|
|
268
|
-
border-bottom: 1px dashed transparent;
|
|
269
|
-
background: transparent;
|
|
270
285
|
color: var(--editor-ink-soft);
|
|
271
|
-
font: inherit;
|
|
272
286
|
font-size: 0.85em;
|
|
287
|
+
line-height: 1.4;
|
|
273
288
|
text-align: center;
|
|
274
289
|
}
|
|
275
290
|
|
|
276
|
-
.
|
|
277
|
-
.likhari-
|
|
278
|
-
|
|
279
|
-
|
|
291
|
+
/* Image dialog — crop stage. The selection dims everything outside it. */
|
|
292
|
+
.likhari-cropper-stage {
|
|
293
|
+
position: relative;
|
|
294
|
+
display: inline-block;
|
|
295
|
+
align-self: center;
|
|
296
|
+
max-width: 100%;
|
|
297
|
+
overflow: hidden;
|
|
298
|
+
line-height: 0;
|
|
299
|
+
cursor: crosshair;
|
|
300
|
+
touch-action: none;
|
|
301
|
+
user-select: none;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
.likhari-cropper-image {
|
|
305
|
+
display: block;
|
|
306
|
+
max-width: 100%;
|
|
307
|
+
max-height: 320px;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
.likhari-cropper-selection {
|
|
311
|
+
position: absolute;
|
|
312
|
+
border: 2px solid #fff;
|
|
313
|
+
outline: 1px solid rgba(0, 0, 0, 0.5);
|
|
314
|
+
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.5);
|
|
315
|
+
pointer-events: none;
|
|
280
316
|
}
|
|
281
317
|
|
|
282
318
|
/* Link menu: the URL item shows one truncated line. */
|