@inshapardaz/likhari-react 0.1.4-dev.38 → 0.1.5-dev.41

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.
@@ -38,6 +38,12 @@ export interface EditorRootProps {
38
38
  * the unsaved-changes state) or to hide it while keeping `onSave`.
39
39
  */
40
40
  showSave?: boolean;
41
+ /**
42
+ * Stores an uploaded image and returns the URL to reference it by. Without
43
+ * it, uploaded images are embedded in the document as base64 data URIs —
44
+ * simple, but they bloat the document (see `images.maxSizeMB`).
45
+ */
46
+ onImageUpload?: (file: File) => Promise<string>;
41
47
  /**
42
48
  * Entries for the toolbar's font-family dropdown. Defaults to
43
49
  * `DEFAULT_FONT_OPTIONS` (generic Latin faces plus the Urdu/Arabic-script
@@ -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;;;;;;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,uGA6HrB,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;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"}
@@ -22,7 +22,7 @@ function initialEditorStateJson(initialContent) {
22
22
  const state = defaultFormatRegistry.parse(initialContent.format, initialContent.value);
23
23
  return JSON.stringify(state);
24
24
  }
25
- export const EditorRoot = forwardRef(function EditorRoot({ documentId, initialContent, featureConfig, featurePreset, theme, colorScheme, locale = 'en', placeholder = 'Start writing…', height = '480px', onChange, onSave, showSave = Boolean(onSave), fontOptions, }, ref) {
25
+ 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, fontOptions, }, ref) {
26
26
  const config = useMemo(() => resolveFeatureConfig(featureConfig, featurePreset), [featureConfig, featurePreset]);
27
27
  // The urdu-web-fonts stylesheets are needed for the font dropdown and for
28
28
  // RTL content, whose canvas font (editor.css) is one of those families.
@@ -92,5 +92,5 @@ export const EditorRoot = forwardRef(function EditorRoot({ documentId, initialCo
92
92
  onSave?.(content, format);
93
93
  };
94
94
  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, 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 })] }) }) }));
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 })] }) }) }));
96
96
  });
@@ -9,6 +9,7 @@ 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>;
12
13
  }
13
- export declare function Toolbar({ config, onSave, isDirty, showSave, fontOptions, direction }: ToolbarProps): import("react").JSX.Element;
14
+ export declare function Toolbar({ config, onSave, isDirty, showSave, onImageUpload, fontOptions, direction }: ToolbarProps): import("react").JSX.Element;
14
15
  //# sourceMappingURL=Toolbar.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"Toolbar.d.ts","sourceRoot":"","sources":["../../src/components/Toolbar.tsx"],"names":[],"mappings":"AA0CA,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,+BAqmBjI"}
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"}
@@ -7,7 +7,9 @@ import { $getSelectionStyleValueForProperty, $patchStyleText, $setBlocksType } f
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';
10
+ import { $findMatchingParent, $insertNodeToNearestRoot } from '@lexical/utils';
11
+ import { ImageDialog } from '../image/ImageDialog.js';
12
+ import { $createImageNode } from '../image/ImageNode.js';
11
13
  import { $createLinkNode, $isLinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link';
12
14
  import { LinkDialog } from './LinkDialog.js';
13
15
  import { normalizeLinkUrl } from '../utils/linkUrl.js';
@@ -71,7 +73,7 @@ function LinkMenuItems({ url, onEdit, onRemove }) {
71
73
  // Not a URL the editor would accept (e.g. loaded from a document): show it, don't make it clickable.
72
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" })] }));
73
75
  }
74
- export function Toolbar({ config, onSave, isDirty, showSave, fontOptions = DEFAULT_FONT_OPTIONS, direction = 'ltr' }) {
76
+ export function Toolbar({ config, onSave, isDirty, showSave, onImageUpload, fontOptions = DEFAULT_FONT_OPTIONS, direction = 'ltr' }) {
75
77
  const [editor] = useLexicalComposerContext();
76
78
  const [state, setState] = useState(INITIAL_STATE);
77
79
  const updateToolbar = useCallback(() => {
@@ -195,6 +197,25 @@ export function Toolbar({ config, onSave, isDirty, showSave, fontOptions = DEFAU
195
197
  action();
196
198
  editor.focus();
197
199
  };
200
+ const [imageDialogOpen, setImageDialogOpen] = useState(false);
201
+ const openImageDialog = () => {
202
+ // Snapshot first: the dialog's focus trap makes Lexical drop the selection.
203
+ snapshotSelection();
204
+ setImageDialogOpen(true);
205
+ };
206
+ const closeImageDialog = () => {
207
+ setImageDialogOpen(false);
208
+ editor.focus();
209
+ };
210
+ const insertImage = (image) => {
211
+ const saved = menuSelectionRef.current;
212
+ if (saved)
213
+ editor.update(() => $setSelection(saved.clone()), { discrete: true });
214
+ editor.update(() => {
215
+ $insertNodeToNearestRoot($createImageNode(image));
216
+ });
217
+ closeImageDialog();
218
+ };
198
219
  /** Applies a font property per `config.font.scope`: to the selection, to
199
220
  * every text node in the document, or (for 'both') to the selection when
200
221
  * there is a range and to the whole document when there isn't. */
@@ -404,5 +425,5 @@ export function Toolbar({ config, onSave, isDirty, showSave, fontOptions = DEFAU
404
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: () => {
405
426
  restoreSelection();
406
427
  openLinkDialog();
407
- }, onRemove: removeLink }) })] })), 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 }))] }));
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 }))] }));
408
429
  }
@@ -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,6 +1,8 @@
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
  export { DEFAULT_FONT_OPTIONS, URDU_WEB_FONT_OPTIONS, FONT_SIZES_PX } from './fonts/index.js';
5
7
  export type { FontOption } from './fonts/index.js';
6
8
  //# 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;AAC3D,OAAO,EAAE,oBAAoB,EAAE,qBAAqB,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AACrF,YAAY,EAAE,UAAU,EAAE,MAAM,SAAS,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;AAC1F,OAAO,EAAE,oBAAoB,EAAE,qBAAqB,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AACrF,YAAY,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC"}
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
1
  export { EditorRoot } from './EditorRoot.js';
2
2
  export { defaultMantineTheme } from './theme/mantineTheme.js';
3
+ export { ImageNode, $createImageNode, $isImageNode } from './image/ImageNode.js';
3
4
  export { DEFAULT_FONT_OPTIONS, URDU_WEB_FONT_OPTIONS, FONT_SIZES_PX } from './fonts/index.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
  ];
@@ -235,6 +235,50 @@
235
235
  margin: 1.5em 0;
236
236
  }
237
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
+ }
281
+
238
282
  /* Link menu: the URL item shows one truncated line. */
239
283
  .likhari-link-menu-url {
240
284
  max-width: 100%;
@@ -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.38",
3
+ "version": "0.1.5-dev.41",
4
4
  "description": "React implementation of the Likhari editor, built on @lexical/react.",
5
5
  "license": "MIT",
6
6
  "repository": {