@inshapardaz/likhari-react 0.1.4-dev.29 → 0.1.4-dev.33

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,12 +37,6 @@ 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>;
46
40
  }
47
41
  export interface EditorRef {
48
42
  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;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"}
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"}
@@ -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), onImageUpload, }, 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), }, 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, 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 })] }) }) }));
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 })] }) }) }));
88
88
  });
@@ -0,0 +1,18 @@
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
@@ -0,0 +1 @@
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"}
@@ -0,0 +1,31 @@
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
+ }
@@ -4,7 +4,6 @@ export interface ToolbarProps {
4
4
  onSave?: () => void;
5
5
  isDirty?: boolean;
6
6
  showSave?: boolean;
7
- onImageUpload?: (file: File) => Promise<string>;
8
7
  }
9
- export declare function Toolbar({ config, onSave, isDirty, showSave, onImageUpload }: ToolbarProps): import("react").JSX.Element;
8
+ export declare function Toolbar({ config, onSave, isDirty, showSave }: ToolbarProps): import("react").JSX.Element;
10
9
  //# sourceMappingURL=Toolbar.d.ts.map
@@ -1 +1 @@
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"}
1
+ {"version":3,"file":"Toolbar.d.ts","sourceRoot":"","sources":["../../src/components/Toolbar.tsx"],"names":[],"mappings":"AAwCA,OAAO,KAAK,EAAE,2BAA2B,EAAE,MAAM,2BAA2B,CAAC;AAoL7E,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,+BAqgB1E"}
@@ -2,20 +2,23 @@ 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, 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, $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';
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, $insertNodeToNearestRoot } from '@lexical/utils';
11
- import { ImageDialog } from '../image/ImageDialog.js';
12
- import { $createImageNode } from '../image/ImageNode.js';
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';
10
+ import { $findMatchingParent } from '@lexical/utils';
11
+ import { $createLinkNode, $isLinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link';
12
+ import { LinkDialog } from './LinkDialog.js';
13
+ import { normalizeLinkUrl } from '../utils/linkUrl.js';
14
+ import { IconAbc, IconAlignCenter, IconAlignJustified, IconAlignLeft, IconAlignRight, IconArrowBackUp, IconArrowForwardUp, IconBold, IconClearFormatting, IconDeviceFloppy, IconDots, IconExternalLink, IconFeather, IconIndentDecrease, IconIndentIncrease, IconItalic, IconLetterCase, IconLetterCaseLower, IconLetterCaseUpper, IconLink, IconLinkOff, IconPencil, IconPhoto, IconPilcrow, IconSparkles, IconStrikethrough, IconSubscript, IconSuperscript, IconTextSize, IconTypography, IconUnderline, IconWand, } from '@tabler/icons-react';
14
15
  const INITIAL_STATE = {
15
16
  blockType: 'paragraph',
16
17
  activeFormats: new Set(),
17
18
  elementFormat: 'start',
18
19
  listType: null,
20
+ isLink: false,
21
+ linkUrl: '',
19
22
  canUndo: false,
20
23
  canRedo: false,
21
24
  };
@@ -51,7 +54,7 @@ function OverflowItem({ icon: Icon, label, active, onClick, }) {
51
54
  // Keep the editor's selection: a mousedown here would blur the canvas.
52
55
  onMouseDown: (e) => e.preventDefault(), onClick: onClick, children: label }));
53
56
  }
54
- export function Toolbar({ config, onSave, isDirty, showSave, onImageUpload }) {
57
+ export function Toolbar({ config, onSave, isDirty, showSave }) {
55
58
  const [editor] = useLexicalComposerContext();
56
59
  const [state, setState] = useState(INITIAL_STATE);
57
60
  const updateToolbar = useCallback(() => {
@@ -70,6 +73,9 @@ export function Toolbar({ config, onSave, isDirty, showSave, onImageUpload }) {
70
73
  else if ($isQuoteNode(element)) {
71
74
  blockType = 'quote';
72
75
  }
76
+ const linkNode = $findMatchingParent(anchorNode, $isLinkNode);
77
+ const isLink = Boolean(linkNode);
78
+ const linkUrl = linkNode && $isLinkNode(linkNode) ? linkNode.getURL() : '';
73
79
  const activeFormats = new Set();
74
80
  ['bold', 'italic', 'underline', 'strikethrough', 'subscript', 'superscript'].forEach((format) => {
75
81
  if (selection.hasFormat(format))
@@ -86,9 +92,16 @@ export function Toolbar({ config, onSave, isDirty, showSave, onImageUpload }) {
86
92
  activeFormats,
87
93
  elementFormat,
88
94
  listType,
95
+ isLink,
96
+ linkUrl,
89
97
  }));
90
98
  });
91
99
  }, [editor]);
100
+ // Selection changes alone miss edits that change the toolbar's state without
101
+ // moving the caret (e.g. removing the link the caret is in).
102
+ useEffect(() => {
103
+ return editor.registerUpdateListener(() => updateToolbar());
104
+ }, [editor, updateToolbar]);
92
105
  useEffect(() => {
93
106
  return editor.registerCommand(SELECTION_CHANGE_COMMAND, () => {
94
107
  updateToolbar();
@@ -159,25 +172,63 @@ export function Toolbar({ config, onSave, isDirty, showSave, onImageUpload }) {
159
172
  action();
160
173
  editor.focus();
161
174
  };
162
- const [imageDialogOpen, setImageDialogOpen] = useState(false);
163
- const openImageDialog = () => {
175
+ const [linkDialogOpen, setLinkDialogOpen] = useState(false);
176
+ const [linkNeedsText, setLinkNeedsText] = useState(false);
177
+ const openLinkDialog = useCallback(() => {
164
178
  // Snapshot first: the dialog's focus trap makes Lexical drop the selection.
165
- snapshotSelection();
166
- setImageDialogOpen(true);
167
- };
168
- const closeImageDialog = () => {
169
- setImageDialogOpen(false);
179
+ let collapsed = false;
180
+ editor.getEditorState().read(() => {
181
+ const selection = $getSelection();
182
+ menuSelectionRef.current = selection ? selection.clone() : null;
183
+ collapsed = $isRangeSelection(selection) && selection.isCollapsed();
184
+ });
185
+ setLinkNeedsText(collapsed && !state.isLink);
186
+ setLinkDialogOpen(true);
187
+ }, [editor, state.isLink]);
188
+ const closeLinkDialog = () => {
189
+ setLinkDialogOpen(false);
170
190
  editor.focus();
171
191
  };
172
- const insertImage = (image) => {
192
+ const restoreSelection = () => {
173
193
  const saved = menuSelectionRef.current;
174
194
  if (saved)
175
195
  editor.update(() => $setSelection(saved.clone()), { discrete: true });
176
- editor.update(() => {
177
- $insertNodeToNearestRoot($createImageNode(image));
178
- });
179
- closeImageDialog();
180
196
  };
197
+ const applyLink = ({ url, text }) => {
198
+ restoreSelection();
199
+ if (linkNeedsText) {
200
+ // Nothing selected: insert a new link with its own text.
201
+ editor.update(() => {
202
+ const selection = $getSelection();
203
+ if (!$isRangeSelection(selection))
204
+ return;
205
+ const link = $createLinkNode(url);
206
+ link.append($createTextNode(text));
207
+ selection.insertNodes([link]);
208
+ });
209
+ }
210
+ else {
211
+ editor.dispatchCommand(TOGGLE_LINK_COMMAND, url);
212
+ }
213
+ closeLinkDialog();
214
+ };
215
+ const removeLink = () => {
216
+ restoreSelection();
217
+ editor.dispatchCommand(TOGGLE_LINK_COMMAND, null);
218
+ closeLinkDialog();
219
+ };
220
+ // Ctrl/Cmd+K opens the link dialog.
221
+ useEffect(() => {
222
+ if (!config.links)
223
+ return;
224
+ return editor.registerCommand(KEY_MODIFIER_COMMAND, (event) => {
225
+ if (event.key.toLowerCase() !== 'k' || !(event.ctrlKey || event.metaKey))
226
+ return false;
227
+ event.preventDefault();
228
+ openLinkDialog();
229
+ return true;
230
+ }, COMMAND_PRIORITY_NORMAL);
231
+ }, [editor, config.links, openLinkDialog]);
181
232
  const withRefocus = (fn) => (value) => {
182
233
  fn(value);
183
234
  editor.focus();
@@ -226,9 +277,11 @@ export function Toolbar({ config, onSave, isDirty, showSave, onImageUpload }) {
226
277
  const showFormattingGroup = headingLevels.length > 0 || config.lists.bullet || config.lists.numbered || config.lists.check || config.blocks.quote;
227
278
  const showInlineGroup = fmt.bold || fmt.italic || fmt.underline;
228
279
  const showAlignGroup = config.alignment.start || config.alignment.center || config.alignment.justify || config.alignment.left || config.alignment.right;
229
- const showInsertPoetryGroup = config.links || config.images.linked || config.images.embedded || config.poetry.enabled;
280
+ const showStubInsertGroup = config.images.linked || config.images.embedded || config.poetry.enabled;
230
281
  const showLanguageGroup = config.language.autocorrect || config.language.textCleanup || config.language.spellCheck;
231
282
  const showOverflowMenu = fmt.strikethrough || fmt.superscript || fmt.subscript || fmt.caseTransforms || fmt.clearFormatting || config.indent;
283
+ // Only offer the link as clickable if it's a URL the editor would have accepted.
284
+ const safeLinkUrl = normalizeLinkUrl(state.linkUrl);
232
285
  const formattingOptions = [
233
286
  { value: 'paragraph', label: 'Paragraph' },
234
287
  ...headingLevels.map((level) => ({ value: `h${level}`, label: `Heading ${level}` })),
@@ -247,5 +300,12 @@ export function Toolbar({ config, onSave, isDirty, showSave, onImageUpload }) {
247
300
  ];
248
301
  const formattingValue = state.listType ?? state.blockType;
249
302
  const AlignIcon = ALIGN_ICONS[state.elementFormat] ?? IconAlignLeft;
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 }))] }));
303
+ 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: state.isLink ? (
304
+ // Caret is in a link: the button opens a menu to see, edit or remove it.
305
+ _jsxs(Menu, { position: "bottom-start", withinPortal: true, shadow: "sm", width: 240, onOpen: snapshotSelection, children: [_jsx(Menu.Target, { children: _jsx("button", { type: "button", className: "likhari-toolbar-button", "data-active": "true", "aria-pressed": "true", "aria-haspopup": "menu", "aria-label": "Link options", title: "Link options", onMouseDown: (e) => e.preventDefault(), children: _jsx(IconLink, { size: ICON_SIZE, stroke: ICON_STROKE }) }) }), _jsxs(Menu.Dropdown, { children: [_jsx(Menu.Label, { children: "Link" }), safeLinkUrl ? (_jsx(Menu.Item, { component: "a", href: safeLinkUrl, target: "_blank", rel: "noopener noreferrer", leftSection: _jsx(IconExternalLink, { size: ICON_SIZE, stroke: ICON_STROKE }), title: state.linkUrl, className: "likhari-link-menu-url", children: state.linkUrl })) : (
306
+ // Not a URL the editor would accept (e.g. loaded from a document): show it, don't make it clickable.
307
+ _jsx(Menu.Item, { disabled: true, title: state.linkUrl, className: "likhari-link-menu-url", children: state.linkUrl || '(no URL)' })), _jsx(Menu.Item, { leftSection: _jsx(IconPencil, { size: ICON_SIZE, stroke: ICON_STROKE }), onMouseDown: (e) => e.preventDefault(), onClick: () => {
308
+ restoreSelection();
309
+ openLinkDialog();
310
+ }, children: "Edit link" }), _jsx(Menu.Item, { color: "red", leftSection: _jsx(IconLinkOff, { size: ICON_SIZE, stroke: ICON_STROKE }), onMouseDown: (e) => e.preventDefault(), onClick: removeLink, children: "Remove link" })] })] })) : (_jsx(ToolbarButton, { icon: IconLink, title: "Insert link (Ctrl+K)", 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 }))] }));
251
311
  }
package/dist/index.d.ts CHANGED
@@ -1,6 +1,4 @@
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';
6
4
  //# 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,SAAS,EAAE,gBAAgB,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAC9E,YAAY,EAAE,YAAY,EAAE,aAAa,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,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"}
package/dist/index.js CHANGED
@@ -1,3 +1,2 @@
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;AAOlD;;;;;;;;GAQG;AACH,eAAO,MAAM,YAAY,EAAE,KAAK,CAAC,WAAW,CAAC,EAQ5C,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;AAMlD;;;;;;;;GAQG;AACH,eAAO,MAAM,YAAY,EAAE,KAAK,CAAC,WAAW,CAAC,EAO5C,CAAC"}
package/dist/nodes.js CHANGED
@@ -2,7 +2,6 @@ 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';
6
5
  /**
7
6
  * Node types registered on the editor at construction time. Per
8
7
  * editor-architecture-design.md §2, node registration cannot be conditional
@@ -19,5 +18,4 @@ export const EDITOR_NODES = [
19
18
  ListItemNode,
20
19
  LinkNode,
21
20
  HorizontalRuleNode,
22
- ImageNode,
23
21
  ];
@@ -235,46 +235,13 @@
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;
238
+ /* Link menu: the URL item shows one truncated line. */
239
+ .likhari-link-menu-url {
251
240
  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
241
  }
261
242
 
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;
243
+ .likhari-link-menu-url .mantine-Menu-itemLabel {
244
+ overflow: hidden;
245
+ text-overflow: ellipsis;
246
+ white-space: nowrap;
280
247
  }
@@ -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,kBA8BzB,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,kBA6BzB,CAAC"}
@@ -32,5 +32,4 @@ export const editorTheme = {
32
32
  superscript: 'likhari-text-superscript',
33
33
  },
34
34
  hr: 'likhari-hr',
35
- image: 'likhari-image-block',
36
35
  };
@@ -0,0 +1,9 @@
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
@@ -0,0 +1 @@
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"}
@@ -0,0 +1,23 @@
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inshapardaz/likhari-react",
3
- "version": "0.1.4-dev.29",
3
+ "version": "0.1.4-dev.33",
4
4
  "description": "React implementation of the Likhari editor, built on @lexical/react.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,25 +0,0 @@
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
@@ -1 +0,0 @@
1
- {"version":3,"file":"ImageDialog.d.ts","sourceRoot":"","sources":["../../src/image/ImageDialog.tsx"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAEjD,MAAM,WAAW,iBAAiB;IAChC,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,QAAQ,EAAE,aAAa,CAAC;CACzB;AAED,MAAM,WAAW,gBAAgB;IAC/B,MAAM,EAAE,OAAO,CAAC;IAChB,+CAA+C;IAC/C,WAAW,EAAE,OAAO,CAAC;IACrB,kDAAkD;IAClD,aAAa,EAAE,OAAO,CAAC;IACvB,kEAAkE;IAClE,YAAY,EAAE,OAAO,CAAC;IACtB,yBAAyB;IACzB,SAAS,EAAE,MAAM,CAAC;IAClB,kFAAkF;IAClF,aAAa,CAAC,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;IAChD,QAAQ,EAAE,CAAC,KAAK,EAAE,iBAAiB,KAAK,IAAI,CAAC;IAC7C,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB;AAWD,iFAAiF;AACjF,wBAAgB,WAAW,CAAC,EAC1B,MAAM,EACN,WAAW,EACX,aAAa,EACb,YAAY,EACZ,SAAS,EACT,aAAa,EACb,QAAQ,EACR,OAAO,GACR,EAAE,gBAAgB,+BAmHlB"}
@@ -1,76 +0,0 @@
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
- }
@@ -1,56 +0,0 @@
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
@@ -1 +0,0 @@
1
- {"version":3,"file":"ImageNode.d.ts","sourceRoot":"","sources":["../../src/image/ImageNode.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAkC,KAAK,YAAY,EAAE,MAAM,OAAO,CAAC;AAG1E,OAAO,EAML,aAAa,EAGb,KAAK,eAAe,EACpB,KAAK,YAAY,EACjB,KAAK,WAAW,EAChB,KAAK,OAAO,EACZ,KAAK,qBAAqB,EAC1B,KAAK,MAAM,EACZ,MAAM,SAAS,CAAC;AAEjB;mFACmF;AACnF,MAAM,MAAM,aAAa,GAAG,QAAQ,GAAG,UAAU,CAAC;AAElD,MAAM,WAAW,YAAY;IAC3B,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,wDAAwD;IACxD,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,QAAQ,EAAE,aAAa,CAAC;IACxB,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,GAAG,CAAC,EAAE,OAAO,CAAC;CACf;AAED,MAAM,MAAM,mBAAmB,GAAG,MAAM,CACtC;IACE,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,QAAQ,EAAE,aAAa,CAAC;IACxB,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;CACvB,EACD,qBAAqB,CACtB,CAAC;AAEF;;;;;GAKG;AACH,qBAAa,SAAU,SAAQ,aAAa,CAAC,YAAY,CAAC;IACxD,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,UAAU,EAAE,aAAa,CAAC;IAC1B,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IAExB,MAAM,CAAC,OAAO,IAAI,MAAM;IAIxB,MAAM,CAAC,KAAK,CAAC,IAAI,EAAE,SAAS,GAAG,SAAS;IAIxC,MAAM,CAAC,UAAU,CAAC,IAAI,EAAE,mBAAmB,GAAG,SAAS;gBAYrD,GAAG,EAAE,MAAM,EACX,OAAO,SAAK,EACZ,OAAO,GAAE,MAAM,GAAG,IAAW,EAC7B,QAAQ,GAAE,aAAwB,EAClC,KAAK,GAAE,MAAM,GAAG,IAAW,EAC3B,MAAM,GAAE,MAAM,GAAG,IAAW,EAC5B,GAAG,CAAC,EAAE,OAAO;IAWf,UAAU,IAAI,mBAAmB;IAajC,oFAAoF;IACpF,SAAS,IAAI,eAAe;IAgB5B,SAAS,CAAC,MAAM,EAAE,YAAY,GAAG,WAAW;IAO5C,SAAS,IAAI,KAAK;IAIlB,QAAQ,IAAI,KAAK;IAIjB,MAAM,IAAI,MAAM;IAIhB,UAAU,IAAI,MAAM;IAIpB,UAAU,IAAI,MAAM,GAAG,IAAI;IAI3B,UAAU,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,GAAG,IAAI;IAMxC,cAAc,IAAI,MAAM;IAIxB,QAAQ,IAAI,YAAY;CAYzB;AAED,wBAAgB,gBAAgB,CAAC,EAAE,GAAG,EAAE,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,EAAE,YAAY,GAAG,SAAS,CAEjH;AAED,wBAAgB,YAAY,CAAC,IAAI,EAAE,WAAW,GAAG,IAAI,GAAG,SAAS,GAAG,IAAI,IAAI,SAAS,CAEpF"}
@@ -1,151 +0,0 @@
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
- }
@@ -1,14 +0,0 @@
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
@@ -1 +0,0 @@
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"}
@@ -1,27 +0,0 @@
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
- }