@inshapardaz/likhari-react 0.1.4-dev.23 → 0.1.4-dev.27

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.
@@ -37,6 +37,12 @@ export interface EditorRootProps {
37
37
  * the unsaved-changes state) or to hide it while keeping `onSave`.
38
38
  */
39
39
  showSave?: boolean;
40
+ /**
41
+ * Stores an uploaded image and returns the URL to reference it by. Without
42
+ * it, uploaded images are embedded in the document as base64 data URIs —
43
+ * simple, but they bloat the document (see `images.maxSizeMB`).
44
+ */
45
+ onImageUpload?: (file: File) => Promise<string>;
40
46
  }
41
47
  export interface EditorRef {
42
48
  getContent(format: FormatId): string;
@@ -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;AAMzH,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;CACpB;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,uGAqHrB,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;AAMzH,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;CACjD;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,uGAsHrB,CAAC"}
@@ -20,7 +20,7 @@ function initialEditorStateJson(initialContent) {
20
20
  const state = defaultFormatRegistry.parse(initialContent.format, initialContent.value);
21
21
  return JSON.stringify(state);
22
22
  }
23
- export const EditorRoot = forwardRef(function EditorRoot({ documentId, initialContent, featureConfig, featurePreset, theme, colorScheme, locale = 'en', placeholder = 'Start writing…', height = '480px', onChange, onSave, showSave = Boolean(onSave), }, ref) {
23
+ export const EditorRoot = forwardRef(function EditorRoot({ documentId, initialContent, featureConfig, featurePreset, theme, colorScheme, locale = 'en', placeholder = 'Start writing…', height = '480px', onChange, onSave, showSave = Boolean(onSave), onImageUpload, }, ref) {
24
24
  const config = useMemo(() => resolveFeatureConfig(featureConfig, featurePreset), [featureConfig, featurePreset]);
25
25
  const editorStateRef = useRef(null);
26
26
  const lastSavedJsonRef = useRef(null);
@@ -84,5 +84,5 @@ export const EditorRoot = forwardRef(function EditorRoot({ documentId, initialCo
84
84
  onSave?.(content, format);
85
85
  };
86
86
  const dir = locale === 'en' ? 'ltr' : 'rtl';
87
- 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 }), _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, {}), _jsx(OnChangePlugin, { onChange: handleChange })] }) }) }));
87
+ 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, onImageUpload: onImageUpload }), _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, {}), _jsx(OnChangePlugin, { onChange: handleChange })] }) }) }));
88
88
  });
@@ -4,6 +4,7 @@ export interface ToolbarProps {
4
4
  onSave?: () => void;
5
5
  isDirty?: boolean;
6
6
  showSave?: boolean;
7
+ onImageUpload?: (file: File) => Promise<string>;
7
8
  }
8
- export declare function Toolbar({ config, onSave, isDirty, showSave }: ToolbarProps): import("react").JSX.Element;
9
+ export declare function Toolbar({ config, onSave, isDirty, showSave, onImageUpload }: ToolbarProps): import("react").JSX.Element;
9
10
  //# sourceMappingURL=Toolbar.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"Toolbar.d.ts","sourceRoot":"","sources":["../../src/components/Toolbar.tsx"],"names":[],"mappings":"AAuCA,OAAO,KAAK,EAAE,2BAA2B,EAAE,MAAM,2BAA2B,CAAC;AAiL7E,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;CACpB;AAED,wBAAgB,OAAO,CAAC,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,QAAQ,EAAE,EAAE,YAAY,+BAsc1E"}
1
+ {"version":3,"file":"Toolbar.d.ts","sourceRoot":"","sources":["../../src/components/Toolbar.tsx"],"names":[],"mappings":"AAoCA,OAAO,KAAK,EAAE,2BAA2B,EAAE,MAAM,2BAA2B,CAAC;AA4K7E,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,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,EAAE,YAAY,+BAgZzF"}
@@ -2,22 +2,20 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
2
2
  import { useCallback, useEffect, useRef, useState } from 'react';
3
3
  import { Menu, Select } from '@mantine/core';
4
4
  import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
5
- import { $getSelection, $isRangeSelection, $isElementNode, $setSelection, $createTextNode, KEY_MODIFIER_COMMAND, COMMAND_PRIORITY_NORMAL, FORMAT_TEXT_COMMAND, FORMAT_ELEMENT_COMMAND, INDENT_CONTENT_COMMAND, OUTDENT_CONTENT_COMMAND, UNDO_COMMAND, REDO_COMMAND, SELECTION_CHANGE_COMMAND, CAN_UNDO_COMMAND, CAN_REDO_COMMAND, COMMAND_PRIORITY_LOW, } from 'lexical';
5
+ import { $getSelection, $isRangeSelection, $isElementNode, $setSelection, FORMAT_TEXT_COMMAND, FORMAT_ELEMENT_COMMAND, INDENT_CONTENT_COMMAND, OUTDENT_CONTENT_COMMAND, UNDO_COMMAND, REDO_COMMAND, SELECTION_CHANGE_COMMAND, CAN_UNDO_COMMAND, CAN_REDO_COMMAND, COMMAND_PRIORITY_LOW, } from 'lexical';
6
6
  import { $setBlocksType } from '@lexical/selection';
7
7
  import { $createParagraphNode } from 'lexical';
8
8
  import { $createHeadingNode, $createQuoteNode, $isHeadingNode, $isQuoteNode } from '@lexical/rich-text';
9
9
  import { $isListNode, INSERT_CHECK_LIST_COMMAND, INSERT_ORDERED_LIST_COMMAND, INSERT_UNORDERED_LIST_COMMAND, REMOVE_LIST_COMMAND, } from '@lexical/list';
10
- import { $findMatchingParent } from '@lexical/utils';
11
- import { $createLinkNode, $isLinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link';
12
- import { LinkDialog } from './LinkDialog.js';
10
+ import { $findMatchingParent, $insertNodeToNearestRoot } from '@lexical/utils';
11
+ import { ImageDialog } from '../image/ImageDialog.js';
12
+ import { $createImageNode } from '../image/ImageNode.js';
13
13
  import { IconAbc, IconAlignCenter, IconAlignJustified, IconAlignLeft, IconAlignRight, IconArrowBackUp, IconArrowForwardUp, IconBold, IconClearFormatting, IconDeviceFloppy, IconDots, IconFeather, IconIndentDecrease, IconIndentIncrease, IconItalic, IconLetterCase, IconLetterCaseLower, IconLetterCaseUpper, IconLink, IconPhoto, IconPilcrow, IconSparkles, IconStrikethrough, IconSubscript, IconSuperscript, IconTextSize, IconTypography, IconUnderline, IconWand, } from '@tabler/icons-react';
14
14
  const INITIAL_STATE = {
15
15
  blockType: 'paragraph',
16
16
  activeFormats: new Set(),
17
17
  elementFormat: 'start',
18
18
  listType: null,
19
- isLink: false,
20
- linkUrl: '',
21
19
  canUndo: false,
22
20
  canRedo: false,
23
21
  };
@@ -53,7 +51,7 @@ function OverflowItem({ icon: Icon, label, active, onClick, }) {
53
51
  // Keep the editor's selection: a mousedown here would blur the canvas.
54
52
  onMouseDown: (e) => e.preventDefault(), onClick: onClick, children: label }));
55
53
  }
56
- export function Toolbar({ config, onSave, isDirty, showSave }) {
54
+ export function Toolbar({ config, onSave, isDirty, showSave, onImageUpload }) {
57
55
  const [editor] = useLexicalComposerContext();
58
56
  const [state, setState] = useState(INITIAL_STATE);
59
57
  const updateToolbar = useCallback(() => {
@@ -72,9 +70,6 @@ export function Toolbar({ config, onSave, isDirty, showSave }) {
72
70
  else if ($isQuoteNode(element)) {
73
71
  blockType = 'quote';
74
72
  }
75
- const linkNode = $findMatchingParent(anchorNode, $isLinkNode);
76
- const isLink = Boolean(linkNode);
77
- const linkUrl = linkNode && $isLinkNode(linkNode) ? linkNode.getURL() : '';
78
73
  const activeFormats = new Set();
79
74
  ['bold', 'italic', 'underline', 'strikethrough', 'subscript', 'superscript'].forEach((format) => {
80
75
  if (selection.hasFormat(format))
@@ -91,8 +86,6 @@ export function Toolbar({ config, onSave, isDirty, showSave }) {
91
86
  activeFormats,
92
87
  elementFormat,
93
88
  listType,
94
- isLink,
95
- linkUrl,
96
89
  }));
97
90
  });
98
91
  }, [editor]);
@@ -166,63 +159,25 @@ export function Toolbar({ config, onSave, isDirty, showSave }) {
166
159
  action();
167
160
  editor.focus();
168
161
  };
169
- const [linkDialogOpen, setLinkDialogOpen] = useState(false);
170
- const [linkNeedsText, setLinkNeedsText] = useState(false);
171
- const openLinkDialog = useCallback(() => {
162
+ const [imageDialogOpen, setImageDialogOpen] = useState(false);
163
+ const openImageDialog = () => {
172
164
  // Snapshot first: the dialog's focus trap makes Lexical drop the selection.
173
- let collapsed = false;
174
- editor.getEditorState().read(() => {
175
- const selection = $getSelection();
176
- menuSelectionRef.current = selection ? selection.clone() : null;
177
- collapsed = $isRangeSelection(selection) && selection.isCollapsed();
178
- });
179
- setLinkNeedsText(collapsed && !state.isLink);
180
- setLinkDialogOpen(true);
181
- }, [editor, state.isLink]);
182
- const closeLinkDialog = () => {
183
- setLinkDialogOpen(false);
165
+ snapshotSelection();
166
+ setImageDialogOpen(true);
167
+ };
168
+ const closeImageDialog = () => {
169
+ setImageDialogOpen(false);
184
170
  editor.focus();
185
171
  };
186
- const restoreSelection = () => {
172
+ const insertImage = (image) => {
187
173
  const saved = menuSelectionRef.current;
188
174
  if (saved)
189
175
  editor.update(() => $setSelection(saved.clone()), { discrete: true });
176
+ editor.update(() => {
177
+ $insertNodeToNearestRoot($createImageNode(image));
178
+ });
179
+ closeImageDialog();
190
180
  };
191
- const applyLink = ({ url, text }) => {
192
- restoreSelection();
193
- if (linkNeedsText) {
194
- // Nothing selected: insert a new link with its own text.
195
- editor.update(() => {
196
- const selection = $getSelection();
197
- if (!$isRangeSelection(selection))
198
- return;
199
- const link = $createLinkNode(url);
200
- link.append($createTextNode(text));
201
- selection.insertNodes([link]);
202
- });
203
- }
204
- else {
205
- editor.dispatchCommand(TOGGLE_LINK_COMMAND, url);
206
- }
207
- closeLinkDialog();
208
- };
209
- const removeLink = () => {
210
- restoreSelection();
211
- editor.dispatchCommand(TOGGLE_LINK_COMMAND, null);
212
- closeLinkDialog();
213
- };
214
- // Ctrl/Cmd+K opens the link dialog.
215
- useEffect(() => {
216
- if (!config.links)
217
- return;
218
- return editor.registerCommand(KEY_MODIFIER_COMMAND, (event) => {
219
- if (event.key.toLowerCase() !== 'k' || !(event.ctrlKey || event.metaKey))
220
- return false;
221
- event.preventDefault();
222
- openLinkDialog();
223
- return true;
224
- }, COMMAND_PRIORITY_NORMAL);
225
- }, [editor, config.links, openLinkDialog]);
226
181
  const withRefocus = (fn) => (value) => {
227
182
  fn(value);
228
183
  editor.focus();
@@ -271,7 +226,7 @@ export function Toolbar({ config, onSave, isDirty, showSave }) {
271
226
  const showFormattingGroup = headingLevels.length > 0 || config.lists.bullet || config.lists.numbered || config.lists.check || config.blocks.quote;
272
227
  const showInlineGroup = fmt.bold || fmt.italic || fmt.underline;
273
228
  const showAlignGroup = config.alignment.start || config.alignment.center || config.alignment.justify || config.alignment.left || config.alignment.right;
274
- const showStubInsertGroup = config.images.linked || config.images.embedded || config.poetry.enabled;
229
+ const showInsertPoetryGroup = config.links || config.images.linked || config.images.embedded || config.poetry.enabled;
275
230
  const showLanguageGroup = config.language.autocorrect || config.language.textCleanup || config.language.spellCheck;
276
231
  const showOverflowMenu = fmt.strikethrough || fmt.superscript || fmt.subscript || fmt.caseTransforms || fmt.clearFormatting || config.indent;
277
232
  const formattingOptions = [
@@ -292,5 +247,5 @@ export function Toolbar({ config, onSave, isDirty, showSave }) {
292
247
  ];
293
248
  const formattingValue = state.listType ?? state.blockType;
294
249
  const AlignIcon = ALIGN_ICONS[state.elementFormat] ?? IconAlignLeft;
295
- return (_jsxs("div", { className: "likhari-toolbar", role: "toolbar", "aria-label": "Formatting", children: [showSave && (_jsx("div", { className: "likhari-toolbar-group", children: _jsx(ToolbarButton, { icon: IconDeviceFloppy, title: "Save", dirty: Boolean(isDirty), onClick: onSave }) })), config.history && (_jsxs("div", { className: "likhari-toolbar-group", children: [_jsx(ToolbarButton, { icon: IconArrowBackUp, title: "Undo", disabled: !state.canUndo, onClick: () => editor.dispatchCommand(UNDO_COMMAND, undefined) }), _jsx(ToolbarButton, { icon: IconArrowForwardUp, title: "Redo", disabled: !state.canRedo, onClick: () => editor.dispatchCommand(REDO_COMMAND, undefined) })] })), showFormattingGroup && (_jsx("div", { className: "likhari-toolbar-group", children: _jsx(ToolbarSelect, { icon: IconPilcrow, label: "Formatting", width: 148, value: formattingValue, data: formattingOptions, onChange: withRefocus((v) => applyFormatting(v)) }) })), showInlineGroup && (_jsxs("div", { className: "likhari-toolbar-group", children: [fmt.bold && (_jsx(ToolbarButton, { icon: IconBold, title: "Bold", active: state.activeFormats.has('bold'), onClick: () => formatText('bold') })), fmt.italic && (_jsx(ToolbarButton, { icon: IconItalic, title: "Italic", active: state.activeFormats.has('italic'), onClick: () => formatText('italic') })), fmt.underline && (_jsx(ToolbarButton, { icon: IconUnderline, title: "Underline", active: state.activeFormats.has('underline'), onClick: () => formatText('underline') }))] })), (config.font.family || config.font.size) && (_jsxs("div", { className: "likhari-toolbar-group likhari-toolbar-group--collapse-tablet", children: [config.font.family && (_jsx(ToolbarSelect, { icon: IconTypography, label: "Font family", width: 110, value: null, placeholder: "Font", data: [], disabled: true })), config.font.size && (_jsx(ToolbarSelect, { icon: IconTextSize, label: "Font size", width: 90, value: null, placeholder: "Size", data: [], disabled: true }))] })), showAlignGroup && (_jsx("div", { className: "likhari-toolbar-group", children: _jsx(ToolbarSelect, { icon: AlignIcon, label: "Alignment", width: 138, value: state.elementFormat || 'start', data: alignOptions, onChange: withRefocus((v) => formatElement(v)) }) })), config.links && (_jsx("div", { className: "likhari-toolbar-group", children: _jsx(ToolbarButton, { icon: IconLink, title: state.isLink ? 'Edit link (Ctrl+K)' : 'Insert link (Ctrl+K)', active: state.isLink, onClick: openLinkDialog }) })), showStubInsertGroup && (_jsxs("div", { className: "likhari-toolbar-group likhari-toolbar-group--collapse-tablet", children: [(config.images.linked || config.images.embedded) && _jsx(StubButton, { icon: IconPhoto, title: "Insert image" }), 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.links && (_jsx(LinkDialog, { opened: linkDialogOpen, initialUrl: state.linkUrl, showTextField: linkNeedsText, onSubmit: applyLink, onRemove: state.isLink ? removeLink : undefined, onClose: closeLinkDialog }))] }));
250
+ return (_jsxs("div", { className: "likhari-toolbar", role: "toolbar", "aria-label": "Formatting", children: [showSave && (_jsx("div", { className: "likhari-toolbar-group", children: _jsx(ToolbarButton, { icon: IconDeviceFloppy, title: "Save", dirty: Boolean(isDirty), onClick: onSave }) })), config.history && (_jsxs("div", { className: "likhari-toolbar-group", children: [_jsx(ToolbarButton, { icon: IconArrowBackUp, title: "Undo", disabled: !state.canUndo, onClick: () => editor.dispatchCommand(UNDO_COMMAND, undefined) }), _jsx(ToolbarButton, { icon: IconArrowForwardUp, title: "Redo", disabled: !state.canRedo, onClick: () => editor.dispatchCommand(REDO_COMMAND, undefined) })] })), showFormattingGroup && (_jsx("div", { className: "likhari-toolbar-group", children: _jsx(ToolbarSelect, { icon: IconPilcrow, label: "Formatting", width: 148, value: formattingValue, data: formattingOptions, onChange: withRefocus((v) => applyFormatting(v)) }) })), showInlineGroup && (_jsxs("div", { className: "likhari-toolbar-group", children: [fmt.bold && (_jsx(ToolbarButton, { icon: IconBold, title: "Bold", active: state.activeFormats.has('bold'), onClick: () => formatText('bold') })), fmt.italic && (_jsx(ToolbarButton, { icon: IconItalic, title: "Italic", active: state.activeFormats.has('italic'), onClick: () => formatText('italic') })), fmt.underline && (_jsx(ToolbarButton, { icon: IconUnderline, title: "Underline", active: state.activeFormats.has('underline'), onClick: () => formatText('underline') }))] })), (config.font.family || config.font.size) && (_jsxs("div", { className: "likhari-toolbar-group likhari-toolbar-group--collapse-tablet", children: [config.font.family && (_jsx(ToolbarSelect, { icon: IconTypography, label: "Font family", width: 110, value: null, placeholder: "Font", data: [], disabled: true })), config.font.size && (_jsx(ToolbarSelect, { icon: IconTextSize, label: "Font size", width: 90, value: null, placeholder: "Size", data: [], disabled: true }))] })), showAlignGroup && (_jsx("div", { className: "likhari-toolbar-group", children: _jsx(ToolbarSelect, { icon: AlignIcon, label: "Alignment", width: 138, value: state.elementFormat || 'start', data: alignOptions, onChange: withRefocus((v) => formatElement(v)) }) })), showInsertPoetryGroup && (_jsxs("div", { className: "likhari-toolbar-group likhari-toolbar-group--collapse-tablet", children: [config.links && _jsx(StubButton, { icon: IconLink, title: "Insert link" }), (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 }))] }));
296
251
  }
@@ -0,0 +1,25 @@
1
+ import type { ImageLinkType } from './ImageNode.js';
2
+ export interface ImageDialogResult {
3
+ src: string;
4
+ altText: string;
5
+ caption: string | null;
6
+ linkType: ImageLinkType;
7
+ }
8
+ export interface ImageDialogProps {
9
+ 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;
21
+ onClose: () => void;
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;
25
+ //# sourceMappingURL=ImageDialog.d.ts.map
@@ -0,0 +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"}
@@ -0,0 +1,76 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from 'react';
3
+ import { Alert, Button, FileInput, Group, Modal, SegmentedControl, Stack, TextInput } from '@mantine/core';
4
+ import { ACCEPTED_IMAGE_TYPES, normalizeImageUrl, validateImageFile } from './imageUrl.js';
5
+ function readAsDataUrl(file) {
6
+ return new Promise((resolve, reject) => {
7
+ const reader = new FileReader();
8
+ reader.onload = () => resolve(String(reader.result));
9
+ reader.onerror = () => reject(reader.error ?? new Error('Could not read the file'));
10
+ reader.readAsDataURL(file);
11
+ });
12
+ }
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
+ const [source, setSource] = useState(defaultSource);
17
+ const [url, setUrl] = useState('');
18
+ const [file, setFile] = useState(null);
19
+ const [altText, setAltText] = useState('');
20
+ const [error, setError] = useState(null);
21
+ const [busy, setBusy] = useState(false);
22
+ useEffect(() => {
23
+ if (opened) {
24
+ setSource(defaultSource);
25
+ setUrl('');
26
+ setFile(null);
27
+ setAltText('');
28
+ setError(null);
29
+ setBusy(false);
30
+ }
31
+ }, [opened, defaultSource]);
32
+ const handleSubmit = async (e) => {
33
+ e.preventDefault();
34
+ setError(null);
35
+ 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');
41
+ return;
42
+ }
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;
53
+ }
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;
60
+ }
61
+ }
62
+ onSubmit({ src, altText: altText.trim(), caption: allowCaption ? '' : null, linkType: source });
63
+ }
64
+ catch (err) {
65
+ setError(err instanceof Error ? err.message : 'Could not add the image');
66
+ setBusy(false);
67
+ }
68
+ };
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" })] })] }) }) }));
76
+ }
@@ -0,0 +1,56 @@
1
+ import { type ReactElement } from 'react';
2
+ import { DecoratorNode, type DOMExportOutput, type EditorConfig, type LexicalNode, type NodeKey, type SerializedLexicalNode, type Spread } from 'lexical';
3
+ /** How the image's bytes are held: an external URL, or embedded in the
4
+ * document (a base64 data URI, or a URL returned by the host's upload handler). */
5
+ export type ImageLinkType = 'linked' | 'embedded';
6
+ export interface ImagePayload {
7
+ src: string;
8
+ altText?: string;
9
+ /** null = no caption; '' = caption enabled but empty */
10
+ caption?: string | null;
11
+ linkType: ImageLinkType;
12
+ width?: number | null;
13
+ height?: number | null;
14
+ key?: NodeKey;
15
+ }
16
+ export type SerializedImageNode = Spread<{
17
+ src: string;
18
+ altText: string;
19
+ caption: string | null;
20
+ linkType: ImageLinkType;
21
+ width: number | null;
22
+ height: number | null;
23
+ }, SerializedLexicalNode>;
24
+ /**
25
+ * Block-level image (editor-architecture-design.md §3.5). The caption is a
26
+ * plain string 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.
29
+ */
30
+ export declare class ImageNode extends DecoratorNode<ReactElement> {
31
+ __src: string;
32
+ __altText: string;
33
+ __caption: string | null;
34
+ __linkType: ImageLinkType;
35
+ __width: number | null;
36
+ __height: number | null;
37
+ static getType(): string;
38
+ static clone(node: ImageNode): ImageNode;
39
+ static importJSON(json: SerializedImageNode): ImageNode;
40
+ constructor(src: string, altText?: string, caption?: string | null, linkType?: ImageLinkType, width?: number | null, height?: number | null, key?: NodeKey);
41
+ exportJSON(): SerializedImageNode;
42
+ /** `<figure><img><figcaption>` — the figure/figcaption semantics from spec §4.7. */
43
+ exportDOM(): DOMExportOutput;
44
+ createDOM(config: EditorConfig): HTMLElement;
45
+ updateDOM(): false;
46
+ isInline(): false;
47
+ getSrc(): string;
48
+ getAltText(): string;
49
+ getCaption(): string | null;
50
+ setCaption(caption: string | null): this;
51
+ getTextContent(): string;
52
+ decorate(): ReactElement;
53
+ }
54
+ export declare function $createImageNode({ src, altText, caption, linkType, width, height, key }: ImagePayload): ImageNode;
55
+ export declare function $isImageNode(node: LexicalNode | null | undefined): node is ImageNode;
56
+ //# sourceMappingURL=ImageNode.d.ts.map
@@ -0,0 +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"}
@@ -0,0 +1,151 @@
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';
6
+ /**
7
+ * 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.
11
+ */
12
+ export class ImageNode extends DecoratorNode {
13
+ static getType() {
14
+ return 'image';
15
+ }
16
+ static clone(node) {
17
+ return new ImageNode(node.__src, node.__altText, node.__caption, node.__linkType, node.__width, node.__height, node.__key);
18
+ }
19
+ static importJSON(json) {
20
+ return $createImageNode({
21
+ src: json.src,
22
+ altText: json.altText,
23
+ caption: json.caption,
24
+ linkType: json.linkType,
25
+ width: json.width,
26
+ height: json.height,
27
+ });
28
+ }
29
+ constructor(src, altText = '', caption = null, linkType = 'linked', width = null, height = null, key) {
30
+ super(key);
31
+ this.__src = src;
32
+ this.__altText = altText;
33
+ this.__caption = caption;
34
+ this.__linkType = linkType;
35
+ this.__width = width;
36
+ this.__height = height;
37
+ }
38
+ exportJSON() {
39
+ return {
40
+ type: 'image',
41
+ version: 1,
42
+ src: this.__src,
43
+ altText: this.__altText,
44
+ caption: this.__caption,
45
+ linkType: this.__linkType,
46
+ width: this.__width,
47
+ height: this.__height,
48
+ };
49
+ }
50
+ /** `<figure><img><figcaption>` — the figure/figcaption semantics from spec §4.7. */
51
+ exportDOM() {
52
+ const figure = document.createElement('figure');
53
+ const img = document.createElement('img');
54
+ img.setAttribute('src', this.__src);
55
+ img.setAttribute('alt', this.__altText);
56
+ if (this.__width)
57
+ img.setAttribute('width', String(this.__width));
58
+ if (this.__height)
59
+ img.setAttribute('height', String(this.__height));
60
+ figure.appendChild(img);
61
+ if (this.__caption) {
62
+ const caption = document.createElement('figcaption');
63
+ caption.textContent = this.__caption;
64
+ figure.appendChild(caption);
65
+ }
66
+ return { element: figure };
67
+ }
68
+ createDOM(config) {
69
+ const div = document.createElement('div');
70
+ const className = config.theme.image;
71
+ if (typeof className === 'string')
72
+ div.className = className;
73
+ return div;
74
+ }
75
+ updateDOM() {
76
+ return false;
77
+ }
78
+ isInline() {
79
+ return false;
80
+ }
81
+ getSrc() {
82
+ return this.__src;
83
+ }
84
+ getAltText() {
85
+ return this.__altText;
86
+ }
87
+ getCaption() {
88
+ return this.__caption;
89
+ }
90
+ setCaption(caption) {
91
+ const writable = this.getWritable();
92
+ writable.__caption = caption;
93
+ return writable;
94
+ }
95
+ getTextContent() {
96
+ return this.__altText || this.__caption || '';
97
+ }
98
+ decorate() {
99
+ return (_jsx(ImageComponent, { nodeKey: this.getKey(), src: this.__src, altText: this.__altText, caption: this.__caption, width: this.__width, height: this.__height }));
100
+ }
101
+ }
102
+ export function $createImageNode({ src, altText, caption, linkType, width, height, key }) {
103
+ return new ImageNode(src, altText, caption ?? null, linkType, width ?? null, height ?? null, key);
104
+ }
105
+ export function $isImageNode(node) {
106
+ return node instanceof ImageNode;
107
+ }
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,14 @@
1
+ /**
2
+ * Validates the source of a linked image. Accepts http(s) URLs, relative
3
+ * paths, and base64 raster/SVG data URIs (what an embedded image stores);
4
+ * anything else (javascript:, other data: types, file:, ...) is rejected.
5
+ * Returns the trimmed URL, or null when it should be refused.
6
+ */
7
+ export declare function normalizeImageUrl(input: string): string | null;
8
+ export declare const ACCEPTED_IMAGE_TYPES: string[];
9
+ /** Returns an error message if the file can't be inserted, otherwise null. */
10
+ export declare function validateImageFile(file: {
11
+ type: string;
12
+ size: number;
13
+ }, maxSizeMB: number): string | null;
14
+ //# sourceMappingURL=imageUrl.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"imageUrl.d.ts","sourceRoot":"","sources":["../../src/image/imageUrl.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAO9D;AAED,eAAO,MAAM,oBAAoB,UAA0E,CAAC;AAE5G,8EAA8E;AAC9E,wBAAgB,iBAAiB,CAAC,IAAI,EAAE;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAIxG"}
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Validates the source of a linked image. Accepts http(s) URLs, relative
3
+ * paths, and base64 raster/SVG data URIs (what an embedded image stores);
4
+ * anything else (javascript:, other data: types, file:, ...) is rejected.
5
+ * Returns the trimmed URL, or null when it should be refused.
6
+ */
7
+ export function normalizeImageUrl(input) {
8
+ const url = input.trim();
9
+ if (!url)
10
+ return null;
11
+ if (/^https?:\/\/\S+$/i.test(url))
12
+ return url;
13
+ if (/^(\/|\.\.?\/)\S*$/.test(url))
14
+ return url;
15
+ if (/^data:image\/(png|jpe?g|gif|webp|svg\+xml);base64,[a-z0-9+/=]+$/i.test(url))
16
+ return url;
17
+ return null;
18
+ }
19
+ export const ACCEPTED_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml'];
20
+ /** Returns an error message if the file can't be inserted, otherwise null. */
21
+ export function validateImageFile(file, maxSizeMB) {
22
+ if (!ACCEPTED_IMAGE_TYPES.includes(file.type))
23
+ return 'Choose a PNG, JPEG, GIF, WebP or SVG image';
24
+ if (file.size > maxSizeMB * 1024 * 1024)
25
+ return `Image is larger than ${maxSizeMB} MB`;
26
+ return null;
27
+ }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  export { EditorRoot } from './EditorRoot.js';
2
2
  export type { EditorRootProps, EditorRef, EditorInitialContent } from './EditorRoot.js';
3
3
  export { defaultMantineTheme } from './theme/mantineTheme.js';
4
+ export { ImageNode, $createImageNode, $isImageNode } from './image/ImageNode.js';
5
+ export type { ImagePayload, ImageLinkType, SerializedImageNode } from './image/ImageNode.js';
4
6
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAC1C,YAAY,EAAE,eAAe,EAAE,SAAS,EAAE,oBAAoB,EAAE,MAAM,cAAc,CAAC;AACrF,OAAO,EAAE,mBAAmB,EAAE,MAAM,sBAAsB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAC1C,YAAY,EAAE,eAAe,EAAE,SAAS,EAAE,oBAAoB,EAAE,MAAM,cAAc,CAAC;AACrF,OAAO,EAAE,mBAAmB,EAAE,MAAM,sBAAsB,CAAC;AAC3D,OAAO,EAAE,SAAS,EAAE,gBAAgB,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAC9E,YAAY,EAAE,YAAY,EAAE,aAAa,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC"}
package/dist/index.js CHANGED
@@ -1,2 +1,3 @@
1
1
  export { EditorRoot } from './EditorRoot.js';
2
2
  export { defaultMantineTheme } from './theme/mantineTheme.js';
3
+ export { ImageNode, $createImageNode, $isImageNode } from './image/ImageNode.js';
@@ -1 +1 @@
1
- {"version":3,"file":"nodes.d.ts","sourceRoot":"","sources":["../src/nodes.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAMlD;;;;;;;;GAQG;AACH,eAAO,MAAM,YAAY,EAAE,KAAK,CAAC,WAAW,CAAC,EAO5C,CAAC"}
1
+ {"version":3,"file":"nodes.d.ts","sourceRoot":"","sources":["../src/nodes.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAOlD;;;;;;;;GAQG;AACH,eAAO,MAAM,YAAY,EAAE,KAAK,CAAC,WAAW,CAAC,EAQ5C,CAAC"}
package/dist/nodes.js CHANGED
@@ -2,6 +2,7 @@ import { HeadingNode, QuoteNode } from '@lexical/rich-text';
2
2
  import { ListItemNode, ListNode } from '@lexical/list';
3
3
  import { LinkNode } from '@lexical/link';
4
4
  import { HorizontalRuleNode } from '@lexical/react/LexicalHorizontalRuleNode';
5
+ import { ImageNode } from './image/ImageNode.js';
5
6
  /**
6
7
  * Node types registered on the editor at construction time. Per
7
8
  * editor-architecture-design.md §2, node registration cannot be conditional
@@ -18,4 +19,5 @@ export const EDITOR_NODES = [
18
19
  ListItemNode,
19
20
  LinkNode,
20
21
  HorizontalRuleNode,
22
+ ImageNode,
21
23
  ];
@@ -234,3 +234,47 @@
234
234
  border-top: 1px solid var(--editor-line);
235
235
  margin: 1.5em 0;
236
236
  }
237
+
238
+ /* Images — block-level figure with an optional caption input. */
239
+ .likhari-image-block {
240
+ margin: 0 0 1em 0;
241
+ }
242
+
243
+ .likhari-image-figure {
244
+ display: block;
245
+ margin: 0;
246
+ text-align: center;
247
+ }
248
+
249
+ .likhari-image {
250
+ display: inline-block;
251
+ max-width: 100%;
252
+ height: auto;
253
+ border-radius: 4px;
254
+ cursor: default;
255
+ }
256
+
257
+ .likhari-image-figure[data-selected='true'] .likhari-image {
258
+ outline: 2px solid var(--editor-accent);
259
+ outline-offset: 2px;
260
+ }
261
+
262
+ .likhari-image-caption {
263
+ display: block;
264
+ width: 100%;
265
+ margin-top: 6px;
266
+ padding: 2px 4px;
267
+ border: none;
268
+ border-bottom: 1px dashed transparent;
269
+ background: transparent;
270
+ color: var(--editor-ink-soft);
271
+ font: inherit;
272
+ font-size: 0.85em;
273
+ text-align: center;
274
+ }
275
+
276
+ .likhari-image-caption:hover,
277
+ .likhari-image-caption:focus {
278
+ border-bottom-color: var(--editor-line-strong);
279
+ outline: none;
280
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"editorTheme.d.ts","sourceRoot":"","sources":["../../src/theme/editorTheme.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,SAAS,CAAC;AAElD;;;;GAIG;AACH,eAAO,MAAM,WAAW,EAAE,kBA6BzB,CAAC"}
1
+ {"version":3,"file":"editorTheme.d.ts","sourceRoot":"","sources":["../../src/theme/editorTheme.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,SAAS,CAAC;AAElD;;;;GAIG;AACH,eAAO,MAAM,WAAW,EAAE,kBA8BzB,CAAC"}
@@ -32,4 +32,5 @@ export const editorTheme = {
32
32
  superscript: 'likhari-text-superscript',
33
33
  },
34
34
  hr: 'likhari-hr',
35
+ image: 'likhari-image-block',
35
36
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inshapardaz/likhari-react",
3
- "version": "0.1.4-dev.23",
3
+ "version": "0.1.4-dev.27",
4
4
  "description": "React implementation of the Likhari editor, built on @lexical/react.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,18 +0,0 @@
1
- export interface LinkDialogProps {
2
- opened: boolean;
3
- /** Existing link's URL when editing; empty when inserting. */
4
- initialUrl: string;
5
- /** Show a "Text" field — used when nothing is selected, so there is no text to link. */
6
- showTextField: boolean;
7
- onSubmit: (link: {
8
- url: string;
9
- text: string;
10
- }) => void;
11
- /** Present only when editing an existing link. */
12
- onRemove?: () => void;
13
- onClose: () => void;
14
- }
15
- /** Insert/edit-link dialog (UI spec §6). Mantine Modal supplies focus
16
- * trapping, Escape-to-close and the overlay. */
17
- export declare function LinkDialog({ opened, initialUrl, showTextField, onSubmit, onRemove, onClose }: LinkDialogProps): import("react").JSX.Element;
18
- //# sourceMappingURL=LinkDialog.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"LinkDialog.d.ts","sourceRoot":"","sources":["../../src/components/LinkDialog.tsx"],"names":[],"mappings":"AAIA,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,OAAO,CAAC;IAChB,8DAA8D;IAC9D,UAAU,EAAE,MAAM,CAAC;IACnB,wFAAwF;IACxF,aAAa,EAAE,OAAO,CAAC;IACvB,QAAQ,EAAE,CAAC,IAAI,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC;IACxD,kDAAkD;IAClD,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAC;IACtB,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB;AAED;gDACgD;AAChD,wBAAgB,UAAU,CAAC,EAAE,MAAM,EAAE,UAAU,EAAE,aAAa,EAAE,QAAQ,EAAE,QAAQ,EAAE,OAAO,EAAE,EAAE,eAAe,+BAgE7G"}
@@ -1,31 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useEffect, useState } from 'react';
3
- import { Button, Group, Modal, TextInput } from '@mantine/core';
4
- import { normalizeLinkUrl } from '../utils/linkUrl.js';
5
- /** Insert/edit-link dialog (UI spec §6). Mantine Modal supplies focus
6
- * trapping, Escape-to-close and the overlay. */
7
- export function LinkDialog({ opened, initialUrl, showTextField, onSubmit, onRemove, onClose }) {
8
- const [url, setUrl] = useState(initialUrl);
9
- const [text, setText] = useState('');
10
- const [error, setError] = useState(null);
11
- useEffect(() => {
12
- if (opened) {
13
- setUrl(initialUrl);
14
- setText('');
15
- setError(null);
16
- }
17
- }, [opened, initialUrl]);
18
- const handleSubmit = (e) => {
19
- e.preventDefault();
20
- const normalized = normalizeLinkUrl(url);
21
- if (!normalized) {
22
- setError('Enter a valid http(s), mailto, tel or relative URL');
23
- return;
24
- }
25
- onSubmit({ url: normalized, text: text.trim() || normalized });
26
- };
27
- return (_jsx(Modal, { opened: opened, onClose: onClose, title: onRemove ? 'Edit link' : 'Insert link', centered: true, size: "sm", children: _jsxs("form", { onSubmit: handleSubmit, children: [_jsx(TextInput, { label: "URL", placeholder: "https://example.com", value: url, onChange: (e) => {
28
- setUrl(e.currentTarget.value);
29
- setError(null);
30
- }, error: error, "data-autofocus": true }), showTextField && (_jsx(TextInput, { mt: "sm", label: "Text", placeholder: "Link text (defaults to the URL)", value: text, onChange: (e) => setText(e.currentTarget.value) })), _jsxs(Group, { justify: "space-between", mt: "md", children: [onRemove ? (_jsx(Button, { variant: "subtle", color: "red", onClick: onRemove, children: "Remove link" })) : (_jsx("span", {})), _jsxs(Group, { gap: "xs", children: [_jsx(Button, { variant: "default", onClick: onClose, children: "Cancel" }), _jsx(Button, { type: "submit", children: "Apply" })] })] })] }) }));
31
- }
@@ -1,9 +0,0 @@
1
- /**
2
- * Normalizes a URL typed into the link dialog, or returns null if it should
3
- * be rejected. Only http(s), mailto, tel, in-page (#), and relative URLs are
4
- * accepted — any other scheme (javascript:, data:, vbscript:, ...) is
5
- * refused so a pasted link can't carry script into exported HTML. A bare
6
- * domain like "example.com/page" gets https:// prefixed.
7
- */
8
- export declare function normalizeLinkUrl(input: string): string | null;
9
- //# sourceMappingURL=linkUrl.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"linkUrl.d.ts","sourceRoot":"","sources":["../../src/utils/linkUrl.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAc7D"}
@@ -1,23 +0,0 @@
1
- /**
2
- * Normalizes a URL typed into the link dialog, or returns null if it should
3
- * be rejected. Only http(s), mailto, tel, in-page (#), and relative URLs are
4
- * accepted — any other scheme (javascript:, data:, vbscript:, ...) is
5
- * refused so a pasted link can't carry script into exported HTML. A bare
6
- * domain like "example.com/page" gets https:// prefixed.
7
- */
8
- export function normalizeLinkUrl(input) {
9
- const url = input.trim();
10
- if (!url)
11
- return null;
12
- if (/^(https?:\/\/|mailto:|tel:)\S+$/i.test(url))
13
- return url;
14
- if (/^(#|\/|\.\.?\/)\S*$/.test(url))
15
- return url;
16
- // Any other explicit scheme is rejected.
17
- if (/^[a-z][a-z0-9+.-]*:/i.test(url))
18
- return null;
19
- // Looks like a bare domain (no spaces, has a dot in its host part).
20
- if (/^[^\s/?#]+\.[^\s/?#]+(?:[/?#]\S*)?$/.test(url))
21
- return `https://${url}`;
22
- return null;
23
- }