@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.
@@ -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;AAGjE,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,uGA8HrB,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"}
@@ -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, onImageUpload: onImageUpload, 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 })] }) }) }));
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, onImageUpload, fontOptions, direction }: ToolbarProps): import("react").JSX.Element;
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;IAC1B,aAAa,CAAC,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;CACjD;AAED,wBAAgB,OAAO,CAAC,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,QAAQ,EAAE,aAAa,EAAE,WAAkC,EAAE,SAAiB,EAAE,EAAE,YAAY,+BAyoBhJ"}
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, onImageUpload, fontOptions = DEFAULT_FONT_OPTIONS, direction = 'ltr' }) {
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, { opened: imageDialogOpen, allowLinked: config.images.linked, allowEmbedded: config.images.embedded, allowCaption: config.images.caption, maxSizeMB: config.images.maxSizeMB, onImageUpload: onImageUpload, onSubmit: insertImage, onClose: closeImageDialog })), config.links && (_jsx(LinkDialog, { opened: linkDialogOpen, initialUrl: state.linkUrl, showTextField: linkNeedsText, onSubmit: applyLink, onRemove: state.isLink ? removeLink : undefined, onClose: closeLinkDialog }))] }));
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 ImageDialogResult {
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
- /** `images.linked` — allow an external URL. */
11
- allowLinked: boolean;
12
- /** `images.embedded` — allow uploading a file. */
13
- allowEmbedded: boolean;
14
- /** `images.caption` — image gets an (initially empty) caption. */
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
- /** Insert-image dialog (UI spec §6): an external URL and/or an uploaded file. */
24
- export declare function ImageDialog({ opened, allowLinked, allowEmbedded, allowCaption, maxSizeMB, onImageUpload, onSubmit, onClose, }: ImageDialogProps): import("react").JSX.Element;
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":"AAGA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAEjD,MAAM,WAAW,iBAAiB;IAChC,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,QAAQ,EAAE,aAAa,CAAC;CACzB;AAED,MAAM,WAAW,gBAAgB;IAC/B,MAAM,EAAE,OAAO,CAAC;IAChB,+CAA+C;IAC/C,WAAW,EAAE,OAAO,CAAC;IACrB,kDAAkD;IAClD,aAAa,EAAE,OAAO,CAAC;IACvB,kEAAkE;IAClE,YAAY,EAAE,OAAO,CAAC;IACtB,yBAAyB;IACzB,SAAS,EAAE,MAAM,CAAC;IAClB,kFAAkF;IAClF,aAAa,CAAC,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;IAChD,QAAQ,EAAE,CAAC,KAAK,EAAE,iBAAiB,KAAK,IAAI,CAAC;IAC7C,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB;AAWD,iFAAiF;AACjF,wBAAgB,WAAW,CAAC,EAC1B,MAAM,EACN,WAAW,EACX,aAAa,EACb,YAAY,EACZ,SAAS,EACT,aAAa,EACb,QAAQ,EACR,OAAO,GACR,EAAE,gBAAgB,+BAmHlB"}
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
- /** Insert-image dialog (UI spec §6): an external URL and/or an uploaded file. */
14
- export function ImageDialog({ opened, allowLinked, allowEmbedded, allowCaption, maxSizeMB, onImageUpload, onSubmit, onClose, }) {
15
- const defaultSource = allowLinked ? 'linked' : 'embedded';
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
- setSource(defaultSource);
25
- setUrl('');
26
- setFile(null);
27
- setAltText('');
28
- setError(null);
29
- setBusy(false);
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
- }, [opened, defaultSource]);
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
- if (source === 'linked') {
38
- src = normalizeImageUrl(url);
39
- if (!src) {
40
- setError('Enter an http(s) or relative image URL');
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
- else {
45
- if (!file) {
46
- setError('Choose an image file');
47
- return;
48
- }
49
- const problem = validateImageFile(file, maxSizeMB);
50
- if (problem) {
51
- setError(problem);
52
- return;
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
- setBusy(true);
55
- src = onImageUpload ? await onImageUpload(file) : await readAsDataUrl(file);
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({ src, altText: altText.trim(), caption: allowCaption ? '' : null, linkType: source });
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 add the image');
171
+ setError(err instanceof Error ? err.message : 'Could not save the image');
172
+ }
173
+ finally {
66
174
  setBusy(false);
67
175
  }
68
176
  };
69
- return (_jsx(Modal, { opened: opened, onClose: onClose, title: "Insert image", centered: true, size: "sm", children: _jsx("form", { onSubmit: handleSubmit, children: _jsxs(Stack, { gap: "sm", children: [allowLinked && allowEmbedded && (_jsx(SegmentedControl, { fullWidth: true, value: source, onChange: (v) => {
70
- setSource(v);
71
- setError(null);
72
- }, data: [
73
- { value: 'linked', label: 'From URL' },
74
- { value: 'embedded', label: 'Upload' },
75
- ] })), source === 'linked' ? (_jsx(TextInput, { label: "Image URL", placeholder: "https://example.com/photo.jpg", value: url, onChange: (e) => setUrl(e.currentTarget.value), "data-autofocus": true })) : (_jsx(FileInput, { label: "Image file", placeholder: "Choose an image", accept: ACCEPTED_IMAGE_TYPES.join(','), value: file, onChange: setFile, 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) }), 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: "Insert" })] })] }) }) }));
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 { type ReactElement } from 'react';
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 edited through an input under the image — not the nested
27
- * sub-editor the architecture doc leaves open — which keeps caption edits in
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,EAAkC,KAAK,YAAY,EAAE,MAAM,OAAO,CAAC;AAG1E,OAAO,EAML,aAAa,EAGb,KAAK,eAAe,EACpB,KAAK,YAAY,EACjB,KAAK,WAAW,EAChB,KAAK,OAAO,EACZ,KAAK,qBAAqB,EAC1B,KAAK,MAAM,EACZ,MAAM,SAAS,CAAC;AAEjB;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,UAAU,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,GAAG,IAAI;IAMxC,cAAc,IAAI,MAAM;IAIxB,QAAQ,IAAI,YAAY;CAYzB;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"}
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"}
@@ -1,13 +1,11 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useCallback, useEffect, useRef } from 'react';
3
- import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
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 edited through an input under the image — not the nested
9
- * sub-editor the architecture doc leaves open — which keeps caption edits in
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
+ }
@@ -235,7 +235,8 @@
235
235
  margin: 1.5em 0;
236
236
  }
237
237
 
238
- /* Images — block-level figure with an optional caption input. */
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
- .likhari-image-caption:hover,
277
- .likhari-image-caption:focus {
278
- border-bottom-color: var(--editor-line-strong);
279
- outline: none;
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. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inshapardaz/likhari-react",
3
- "version": "0.1.5-dev.41",
3
+ "version": "0.1.6-dev.43",
4
4
  "description": "React implementation of the Likhari editor, built on @lexical/react.",
5
5
  "license": "MIT",
6
6
  "repository": {