@inshapardaz/likhari-react 0.1.4-dev.23 → 0.1.4-dev.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/EditorRoot.d.ts +6 -0
- package/dist/EditorRoot.d.ts.map +1 -1
- package/dist/EditorRoot.js +2 -2
- package/dist/components/Toolbar.d.ts +2 -1
- package/dist/components/Toolbar.d.ts.map +1 -1
- package/dist/components/Toolbar.js +19 -64
- package/dist/image/ImageDialog.d.ts +25 -0
- package/dist/image/ImageDialog.d.ts.map +1 -0
- package/dist/image/ImageDialog.js +76 -0
- package/dist/image/ImageNode.d.ts +56 -0
- package/dist/image/ImageNode.d.ts.map +1 -0
- package/dist/image/ImageNode.js +151 -0
- package/dist/image/imageUrl.d.ts +14 -0
- package/dist/image/imageUrl.d.ts.map +1 -0
- package/dist/image/imageUrl.js +27 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/nodes.d.ts.map +1 -1
- package/dist/nodes.js +2 -0
- package/dist/theme/editor.css +44 -0
- package/dist/theme/editorTheme.d.ts.map +1 -1
- package/dist/theme/editorTheme.js +1 -0
- package/package.json +1 -1
- package/dist/components/LinkDialog.d.ts +0 -18
- package/dist/components/LinkDialog.d.ts.map +0 -1
- package/dist/components/LinkDialog.js +0 -31
- package/dist/utils/linkUrl.d.ts +0 -9
- package/dist/utils/linkUrl.d.ts.map +0 -1
- package/dist/utils/linkUrl.js +0 -23
package/dist/EditorRoot.d.ts
CHANGED
|
@@ -37,6 +37,12 @@ export interface EditorRootProps {
|
|
|
37
37
|
* the unsaved-changes state) or to hide it while keeping `onSave`.
|
|
38
38
|
*/
|
|
39
39
|
showSave?: boolean;
|
|
40
|
+
/**
|
|
41
|
+
* Stores an uploaded image and returns the URL to reference it by. Without
|
|
42
|
+
* it, uploaded images are embedded in the document as base64 data URIs —
|
|
43
|
+
* simple, but they bloat the document (see `images.maxSizeMB`).
|
|
44
|
+
*/
|
|
45
|
+
onImageUpload?: (file: File) => Promise<string>;
|
|
40
46
|
}
|
|
41
47
|
export interface EditorRef {
|
|
42
48
|
getContent(format: FormatId): string;
|
package/dist/EditorRoot.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"EditorRoot.d.ts","sourceRoot":"","sources":["../src/EditorRoot.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,eAAe,CAAC;AAS1D,OAAO,KAAK,EAAe,qBAAqB,EAAE,MAAM,SAAS,CAAC;AAClE,OAAO,EAAyB,KAAK,QAAQ,EAAE,MAAM,iCAAiC,CAAC;AACvF,OAAO,EAAwB,KAAK,mBAAmB,EAAE,KAAK,uBAAuB,EAAE,MAAM,2BAA2B,CAAC;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;
|
|
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"}
|
package/dist/EditorRoot.js
CHANGED
|
@@ -20,7 +20,7 @@ function initialEditorStateJson(initialContent) {
|
|
|
20
20
|
const state = defaultFormatRegistry.parse(initialContent.format, initialContent.value);
|
|
21
21
|
return JSON.stringify(state);
|
|
22
22
|
}
|
|
23
|
-
export const EditorRoot = forwardRef(function EditorRoot({ documentId, initialContent, featureConfig, featurePreset, theme, colorScheme, locale = 'en', placeholder = 'Start writing…', height = '480px', onChange, onSave, showSave = Boolean(onSave), }, ref) {
|
|
23
|
+
export const EditorRoot = forwardRef(function EditorRoot({ documentId, initialContent, featureConfig, featurePreset, theme, colorScheme, locale = 'en', placeholder = 'Start writing…', height = '480px', onChange, onSave, showSave = Boolean(onSave), onImageUpload, }, ref) {
|
|
24
24
|
const config = useMemo(() => resolveFeatureConfig(featureConfig, featurePreset), [featureConfig, featurePreset]);
|
|
25
25
|
const editorStateRef = useRef(null);
|
|
26
26
|
const lastSavedJsonRef = useRef(null);
|
|
@@ -84,5 +84,5 @@ export const EditorRoot = forwardRef(function EditorRoot({ documentId, initialCo
|
|
|
84
84
|
onSave?.(content, format);
|
|
85
85
|
};
|
|
86
86
|
const dir = locale === 'en' ? 'ltr' : 'rtl';
|
|
87
|
-
return (_jsx(EditorThemeProvider, { theme: theme, colorScheme: colorScheme, children: _jsx("div", { className: "likhari-root", dir: dir, ref: rootElementRef, "data-document-id": documentId, style: { height: typeof height === 'number' ? `${height}px` : height }, children: _jsxs(LexicalComposer, { initialConfig: initialConfig, children: [_jsx(Toolbar, { config: config, onSave: handleSave, isDirty: isDirty, showSave: showSave }), _jsx("div", { className: "likhari-canvas", children: _jsx(RichTextPlugin, { contentEditable: _jsx(ContentEditable, { className: "likhari-content-editable", dir: dir, "aria-label": "Editor content" }), placeholder: _jsx("div", { className: "likhari-placeholder", children: placeholder }), ErrorBoundary: LexicalErrorBoundary }) }), config.history && _jsx(HistoryPlugin, {}), (config.lists.bullet || config.lists.numbered || config.lists.check) && _jsx(ListPlugin, {}), config.links && _jsx(LinkPlugin, {}), _jsx(OnChangePlugin, { onChange: handleChange })] }) }) }));
|
|
87
|
+
return (_jsx(EditorThemeProvider, { theme: theme, colorScheme: colorScheme, children: _jsx("div", { className: "likhari-root", dir: dir, ref: rootElementRef, "data-document-id": documentId, style: { height: typeof height === 'number' ? `${height}px` : height }, children: _jsxs(LexicalComposer, { initialConfig: initialConfig, children: [_jsx(Toolbar, { config: config, onSave: handleSave, isDirty: isDirty, showSave: showSave, onImageUpload: onImageUpload }), _jsx("div", { className: "likhari-canvas", children: _jsx(RichTextPlugin, { contentEditable: _jsx(ContentEditable, { className: "likhari-content-editable", dir: dir, "aria-label": "Editor content" }), placeholder: _jsx("div", { className: "likhari-placeholder", children: placeholder }), ErrorBoundary: LexicalErrorBoundary }) }), config.history && _jsx(HistoryPlugin, {}), (config.lists.bullet || config.lists.numbered || config.lists.check) && _jsx(ListPlugin, {}), config.links && _jsx(LinkPlugin, {}), _jsx(OnChangePlugin, { onChange: handleChange })] }) }) }));
|
|
88
88
|
});
|
|
@@ -4,6 +4,7 @@ export interface ToolbarProps {
|
|
|
4
4
|
onSave?: () => void;
|
|
5
5
|
isDirty?: boolean;
|
|
6
6
|
showSave?: boolean;
|
|
7
|
+
onImageUpload?: (file: File) => Promise<string>;
|
|
7
8
|
}
|
|
8
|
-
export declare function Toolbar({ config, onSave, isDirty, showSave }: ToolbarProps): import("react").JSX.Element;
|
|
9
|
+
export declare function Toolbar({ config, onSave, isDirty, showSave, onImageUpload }: ToolbarProps): import("react").JSX.Element;
|
|
9
10
|
//# sourceMappingURL=Toolbar.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Toolbar.d.ts","sourceRoot":"","sources":["../../src/components/Toolbar.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"Toolbar.d.ts","sourceRoot":"","sources":["../../src/components/Toolbar.tsx"],"names":[],"mappings":"AAoCA,OAAO,KAAK,EAAE,2BAA2B,EAAE,MAAM,2BAA2B,CAAC;AA4K7E,MAAM,WAAW,YAAY;IAC3B,MAAM,EAAE,2BAA2B,CAAC;IACpC,MAAM,CAAC,EAAE,MAAM,IAAI,CAAC;IACpB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,aAAa,CAAC,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;CACjD;AAED,wBAAgB,OAAO,CAAC,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,QAAQ,EAAE,aAAa,EAAE,EAAE,YAAY,+BAgZzF"}
|
|
@@ -2,22 +2,20 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
2
2
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
3
3
|
import { Menu, Select } from '@mantine/core';
|
|
4
4
|
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
|
5
|
-
import { $getSelection, $isRangeSelection, $isElementNode, $setSelection,
|
|
5
|
+
import { $getSelection, $isRangeSelection, $isElementNode, $setSelection, FORMAT_TEXT_COMMAND, FORMAT_ELEMENT_COMMAND, INDENT_CONTENT_COMMAND, OUTDENT_CONTENT_COMMAND, UNDO_COMMAND, REDO_COMMAND, SELECTION_CHANGE_COMMAND, CAN_UNDO_COMMAND, CAN_REDO_COMMAND, COMMAND_PRIORITY_LOW, } from 'lexical';
|
|
6
6
|
import { $setBlocksType } from '@lexical/selection';
|
|
7
7
|
import { $createParagraphNode } from 'lexical';
|
|
8
8
|
import { $createHeadingNode, $createQuoteNode, $isHeadingNode, $isQuoteNode } from '@lexical/rich-text';
|
|
9
9
|
import { $isListNode, INSERT_CHECK_LIST_COMMAND, INSERT_ORDERED_LIST_COMMAND, INSERT_UNORDERED_LIST_COMMAND, REMOVE_LIST_COMMAND, } from '@lexical/list';
|
|
10
|
-
import { $findMatchingParent } from '@lexical/utils';
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
10
|
+
import { $findMatchingParent, $insertNodeToNearestRoot } from '@lexical/utils';
|
|
11
|
+
import { ImageDialog } from '../image/ImageDialog.js';
|
|
12
|
+
import { $createImageNode } from '../image/ImageNode.js';
|
|
13
13
|
import { IconAbc, IconAlignCenter, IconAlignJustified, IconAlignLeft, IconAlignRight, IconArrowBackUp, IconArrowForwardUp, IconBold, IconClearFormatting, IconDeviceFloppy, IconDots, IconFeather, IconIndentDecrease, IconIndentIncrease, IconItalic, IconLetterCase, IconLetterCaseLower, IconLetterCaseUpper, IconLink, IconPhoto, IconPilcrow, IconSparkles, IconStrikethrough, IconSubscript, IconSuperscript, IconTextSize, IconTypography, IconUnderline, IconWand, } from '@tabler/icons-react';
|
|
14
14
|
const INITIAL_STATE = {
|
|
15
15
|
blockType: 'paragraph',
|
|
16
16
|
activeFormats: new Set(),
|
|
17
17
|
elementFormat: 'start',
|
|
18
18
|
listType: null,
|
|
19
|
-
isLink: false,
|
|
20
|
-
linkUrl: '',
|
|
21
19
|
canUndo: false,
|
|
22
20
|
canRedo: false,
|
|
23
21
|
};
|
|
@@ -53,7 +51,7 @@ function OverflowItem({ icon: Icon, label, active, onClick, }) {
|
|
|
53
51
|
// Keep the editor's selection: a mousedown here would blur the canvas.
|
|
54
52
|
onMouseDown: (e) => e.preventDefault(), onClick: onClick, children: label }));
|
|
55
53
|
}
|
|
56
|
-
export function Toolbar({ config, onSave, isDirty, showSave }) {
|
|
54
|
+
export function Toolbar({ config, onSave, isDirty, showSave, onImageUpload }) {
|
|
57
55
|
const [editor] = useLexicalComposerContext();
|
|
58
56
|
const [state, setState] = useState(INITIAL_STATE);
|
|
59
57
|
const updateToolbar = useCallback(() => {
|
|
@@ -72,9 +70,6 @@ export function Toolbar({ config, onSave, isDirty, showSave }) {
|
|
|
72
70
|
else if ($isQuoteNode(element)) {
|
|
73
71
|
blockType = 'quote';
|
|
74
72
|
}
|
|
75
|
-
const linkNode = $findMatchingParent(anchorNode, $isLinkNode);
|
|
76
|
-
const isLink = Boolean(linkNode);
|
|
77
|
-
const linkUrl = linkNode && $isLinkNode(linkNode) ? linkNode.getURL() : '';
|
|
78
73
|
const activeFormats = new Set();
|
|
79
74
|
['bold', 'italic', 'underline', 'strikethrough', 'subscript', 'superscript'].forEach((format) => {
|
|
80
75
|
if (selection.hasFormat(format))
|
|
@@ -91,8 +86,6 @@ export function Toolbar({ config, onSave, isDirty, showSave }) {
|
|
|
91
86
|
activeFormats,
|
|
92
87
|
elementFormat,
|
|
93
88
|
listType,
|
|
94
|
-
isLink,
|
|
95
|
-
linkUrl,
|
|
96
89
|
}));
|
|
97
90
|
});
|
|
98
91
|
}, [editor]);
|
|
@@ -166,63 +159,25 @@ export function Toolbar({ config, onSave, isDirty, showSave }) {
|
|
|
166
159
|
action();
|
|
167
160
|
editor.focus();
|
|
168
161
|
};
|
|
169
|
-
const [
|
|
170
|
-
const
|
|
171
|
-
const openLinkDialog = useCallback(() => {
|
|
162
|
+
const [imageDialogOpen, setImageDialogOpen] = useState(false);
|
|
163
|
+
const openImageDialog = () => {
|
|
172
164
|
// Snapshot first: the dialog's focus trap makes Lexical drop the selection.
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
});
|
|
179
|
-
setLinkNeedsText(collapsed && !state.isLink);
|
|
180
|
-
setLinkDialogOpen(true);
|
|
181
|
-
}, [editor, state.isLink]);
|
|
182
|
-
const closeLinkDialog = () => {
|
|
183
|
-
setLinkDialogOpen(false);
|
|
165
|
+
snapshotSelection();
|
|
166
|
+
setImageDialogOpen(true);
|
|
167
|
+
};
|
|
168
|
+
const closeImageDialog = () => {
|
|
169
|
+
setImageDialogOpen(false);
|
|
184
170
|
editor.focus();
|
|
185
171
|
};
|
|
186
|
-
const
|
|
172
|
+
const insertImage = (image) => {
|
|
187
173
|
const saved = menuSelectionRef.current;
|
|
188
174
|
if (saved)
|
|
189
175
|
editor.update(() => $setSelection(saved.clone()), { discrete: true });
|
|
176
|
+
editor.update(() => {
|
|
177
|
+
$insertNodeToNearestRoot($createImageNode(image));
|
|
178
|
+
});
|
|
179
|
+
closeImageDialog();
|
|
190
180
|
};
|
|
191
|
-
const applyLink = ({ url, text }) => {
|
|
192
|
-
restoreSelection();
|
|
193
|
-
if (linkNeedsText) {
|
|
194
|
-
// Nothing selected: insert a new link with its own text.
|
|
195
|
-
editor.update(() => {
|
|
196
|
-
const selection = $getSelection();
|
|
197
|
-
if (!$isRangeSelection(selection))
|
|
198
|
-
return;
|
|
199
|
-
const link = $createLinkNode(url);
|
|
200
|
-
link.append($createTextNode(text));
|
|
201
|
-
selection.insertNodes([link]);
|
|
202
|
-
});
|
|
203
|
-
}
|
|
204
|
-
else {
|
|
205
|
-
editor.dispatchCommand(TOGGLE_LINK_COMMAND, url);
|
|
206
|
-
}
|
|
207
|
-
closeLinkDialog();
|
|
208
|
-
};
|
|
209
|
-
const removeLink = () => {
|
|
210
|
-
restoreSelection();
|
|
211
|
-
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null);
|
|
212
|
-
closeLinkDialog();
|
|
213
|
-
};
|
|
214
|
-
// Ctrl/Cmd+K opens the link dialog.
|
|
215
|
-
useEffect(() => {
|
|
216
|
-
if (!config.links)
|
|
217
|
-
return;
|
|
218
|
-
return editor.registerCommand(KEY_MODIFIER_COMMAND, (event) => {
|
|
219
|
-
if (event.key.toLowerCase() !== 'k' || !(event.ctrlKey || event.metaKey))
|
|
220
|
-
return false;
|
|
221
|
-
event.preventDefault();
|
|
222
|
-
openLinkDialog();
|
|
223
|
-
return true;
|
|
224
|
-
}, COMMAND_PRIORITY_NORMAL);
|
|
225
|
-
}, [editor, config.links, openLinkDialog]);
|
|
226
181
|
const withRefocus = (fn) => (value) => {
|
|
227
182
|
fn(value);
|
|
228
183
|
editor.focus();
|
|
@@ -271,7 +226,7 @@ export function Toolbar({ config, onSave, isDirty, showSave }) {
|
|
|
271
226
|
const showFormattingGroup = headingLevels.length > 0 || config.lists.bullet || config.lists.numbered || config.lists.check || config.blocks.quote;
|
|
272
227
|
const showInlineGroup = fmt.bold || fmt.italic || fmt.underline;
|
|
273
228
|
const showAlignGroup = config.alignment.start || config.alignment.center || config.alignment.justify || config.alignment.left || config.alignment.right;
|
|
274
|
-
const
|
|
229
|
+
const showInsertPoetryGroup = config.links || config.images.linked || config.images.embedded || config.poetry.enabled;
|
|
275
230
|
const showLanguageGroup = config.language.autocorrect || config.language.textCleanup || config.language.spellCheck;
|
|
276
231
|
const showOverflowMenu = fmt.strikethrough || fmt.superscript || fmt.subscript || fmt.caseTransforms || fmt.clearFormatting || config.indent;
|
|
277
232
|
const formattingOptions = [
|
|
@@ -292,5 +247,5 @@ export function Toolbar({ config, onSave, isDirty, showSave }) {
|
|
|
292
247
|
];
|
|
293
248
|
const formattingValue = state.listType ?? state.blockType;
|
|
294
249
|
const AlignIcon = ALIGN_ICONS[state.elementFormat] ?? IconAlignLeft;
|
|
295
|
-
return (_jsxs("div", { className: "likhari-toolbar", role: "toolbar", "aria-label": "Formatting", children: [showSave && (_jsx("div", { className: "likhari-toolbar-group", children: _jsx(ToolbarButton, { icon: IconDeviceFloppy, title: "Save", dirty: Boolean(isDirty), onClick: onSave }) })), config.history && (_jsxs("div", { className: "likhari-toolbar-group", children: [_jsx(ToolbarButton, { icon: IconArrowBackUp, title: "Undo", disabled: !state.canUndo, onClick: () => editor.dispatchCommand(UNDO_COMMAND, undefined) }), _jsx(ToolbarButton, { icon: IconArrowForwardUp, title: "Redo", disabled: !state.canRedo, onClick: () => editor.dispatchCommand(REDO_COMMAND, undefined) })] })), showFormattingGroup && (_jsx("div", { className: "likhari-toolbar-group", children: _jsx(ToolbarSelect, { icon: IconPilcrow, label: "Formatting", width: 148, value: formattingValue, data: formattingOptions, onChange: withRefocus((v) => applyFormatting(v)) }) })), showInlineGroup && (_jsxs("div", { className: "likhari-toolbar-group", children: [fmt.bold && (_jsx(ToolbarButton, { icon: IconBold, title: "Bold", active: state.activeFormats.has('bold'), onClick: () => formatText('bold') })), fmt.italic && (_jsx(ToolbarButton, { icon: IconItalic, title: "Italic", active: state.activeFormats.has('italic'), onClick: () => formatText('italic') })), fmt.underline && (_jsx(ToolbarButton, { icon: IconUnderline, title: "Underline", active: state.activeFormats.has('underline'), onClick: () => formatText('underline') }))] })), (config.font.family || config.font.size) && (_jsxs("div", { className: "likhari-toolbar-group likhari-toolbar-group--collapse-tablet", children: [config.font.family && (_jsx(ToolbarSelect, { icon: IconTypography, label: "Font family", width: 110, value: null, placeholder: "Font", data: [], disabled: true })), config.font.size && (_jsx(ToolbarSelect, { icon: IconTextSize, label: "Font size", width: 90, value: null, placeholder: "Size", data: [], disabled: true }))] })), showAlignGroup && (_jsx("div", { className: "likhari-toolbar-group", children: _jsx(ToolbarSelect, { icon: AlignIcon, label: "Alignment", width: 138, value: state.elementFormat || 'start', data: alignOptions, onChange: withRefocus((v) => formatElement(v)) }) })),
|
|
250
|
+
return (_jsxs("div", { className: "likhari-toolbar", role: "toolbar", "aria-label": "Formatting", children: [showSave && (_jsx("div", { className: "likhari-toolbar-group", children: _jsx(ToolbarButton, { icon: IconDeviceFloppy, title: "Save", dirty: Boolean(isDirty), onClick: onSave }) })), config.history && (_jsxs("div", { className: "likhari-toolbar-group", children: [_jsx(ToolbarButton, { icon: IconArrowBackUp, title: "Undo", disabled: !state.canUndo, onClick: () => editor.dispatchCommand(UNDO_COMMAND, undefined) }), _jsx(ToolbarButton, { icon: IconArrowForwardUp, title: "Redo", disabled: !state.canRedo, onClick: () => editor.dispatchCommand(REDO_COMMAND, undefined) })] })), showFormattingGroup && (_jsx("div", { className: "likhari-toolbar-group", children: _jsx(ToolbarSelect, { icon: IconPilcrow, label: "Formatting", width: 148, value: formattingValue, data: formattingOptions, onChange: withRefocus((v) => applyFormatting(v)) }) })), showInlineGroup && (_jsxs("div", { className: "likhari-toolbar-group", children: [fmt.bold && (_jsx(ToolbarButton, { icon: IconBold, title: "Bold", active: state.activeFormats.has('bold'), onClick: () => formatText('bold') })), fmt.italic && (_jsx(ToolbarButton, { icon: IconItalic, title: "Italic", active: state.activeFormats.has('italic'), onClick: () => formatText('italic') })), fmt.underline && (_jsx(ToolbarButton, { icon: IconUnderline, title: "Underline", active: state.activeFormats.has('underline'), onClick: () => formatText('underline') }))] })), (config.font.family || config.font.size) && (_jsxs("div", { className: "likhari-toolbar-group likhari-toolbar-group--collapse-tablet", children: [config.font.family && (_jsx(ToolbarSelect, { icon: IconTypography, label: "Font family", width: 110, value: null, placeholder: "Font", data: [], disabled: true })), config.font.size && (_jsx(ToolbarSelect, { icon: IconTextSize, label: "Font size", width: 90, value: null, placeholder: "Size", data: [], disabled: true }))] })), showAlignGroup && (_jsx("div", { className: "likhari-toolbar-group", children: _jsx(ToolbarSelect, { icon: AlignIcon, label: "Alignment", width: 138, value: state.elementFormat || 'start', data: alignOptions, onChange: withRefocus((v) => formatElement(v)) }) })), showInsertPoetryGroup && (_jsxs("div", { className: "likhari-toolbar-group likhari-toolbar-group--collapse-tablet", children: [config.links && _jsx(StubButton, { icon: IconLink, title: "Insert link" }), (config.images.linked || config.images.embedded) && (_jsx(ToolbarButton, { icon: IconPhoto, title: "Insert image", onClick: openImageDialog })), config.poetry.enabled && _jsx(StubButton, { icon: IconFeather, title: "Poetry blocks" })] })), showLanguageGroup && (_jsxs("div", { className: "likhari-toolbar-group likhari-toolbar-group--collapse-tablet", children: [config.language.autocorrect && _jsx(StubButton, { icon: IconWand, title: "Auto-correct" }), config.language.textCleanup && _jsx(StubButton, { icon: IconSparkles, title: "Text cleanup" }), config.language.spellCheck && _jsx(StubButton, { icon: IconAbc, title: "Spell-checker" })] })), showOverflowMenu && (_jsxs(Menu, { position: "bottom-end", withinPortal: true, shadow: "sm", width: 210, closeOnItemClick: true, onOpen: snapshotSelection, children: [_jsx(Menu.Target, { children: _jsx("button", { type: "button", className: "likhari-toolbar-button", "aria-label": "More formatting", title: "More formatting", onMouseDown: (e) => e.preventDefault(), children: _jsx(IconDots, { size: ICON_SIZE, stroke: ICON_STROKE }) }) }), _jsxs(Menu.Dropdown, { children: [fmt.strikethrough && (_jsx(OverflowItem, { icon: IconStrikethrough, label: "Strikethrough", active: state.activeFormats.has('strikethrough'), onClick: runOverflowAction(() => formatText('strikethrough')) })), fmt.superscript && (_jsx(OverflowItem, { icon: IconSuperscript, label: "Superscript", active: state.activeFormats.has('superscript'), onClick: runOverflowAction(() => formatText('superscript')) })), fmt.subscript && (_jsx(OverflowItem, { icon: IconSubscript, label: "Subscript", active: state.activeFormats.has('subscript'), onClick: runOverflowAction(() => formatText('subscript')) })), fmt.caseTransforms && (_jsxs(_Fragment, { children: [_jsx(OverflowItem, { icon: IconLetterCaseUpper, label: "UPPERCASE", onClick: runOverflowAction(() => applyCaseTransform('upper')) }), _jsx(OverflowItem, { icon: IconLetterCaseLower, label: "lowercase", onClick: runOverflowAction(() => applyCaseTransform('lower')) }), _jsx(OverflowItem, { icon: IconLetterCase, label: "Capitalize", onClick: runOverflowAction(() => applyCaseTransform('capitalize')) })] })), fmt.clearFormatting && _jsx(OverflowItem, { icon: IconClearFormatting, label: "Clear formatting", onClick: runOverflowAction(clearFormatting) }), config.indent && (_jsxs(_Fragment, { children: [_jsx(OverflowItem, { icon: IconIndentDecrease, label: "Outdent", onClick: runOverflowAction(() => editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined)) }), _jsx(OverflowItem, { icon: IconIndentIncrease, label: "Indent", onClick: runOverflowAction(() => editor.dispatchCommand(INDENT_CONTENT_COMMAND, undefined)) })] }))] })] })), (config.images.linked || config.images.embedded) && (_jsx(ImageDialog, { opened: imageDialogOpen, allowLinked: config.images.linked, allowEmbedded: config.images.embedded, allowCaption: config.images.caption, maxSizeMB: config.images.maxSizeMB, onImageUpload: onImageUpload, onSubmit: insertImage, onClose: closeImageDialog }))] }));
|
|
296
251
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ImageLinkType } from './ImageNode.js';
|
|
2
|
+
export interface ImageDialogResult {
|
|
3
|
+
src: string;
|
|
4
|
+
altText: string;
|
|
5
|
+
caption: string | null;
|
|
6
|
+
linkType: ImageLinkType;
|
|
7
|
+
}
|
|
8
|
+
export interface ImageDialogProps {
|
|
9
|
+
opened: boolean;
|
|
10
|
+
/** `images.linked` — allow an external URL. */
|
|
11
|
+
allowLinked: boolean;
|
|
12
|
+
/** `images.embedded` — allow uploading a file. */
|
|
13
|
+
allowEmbedded: boolean;
|
|
14
|
+
/** `images.caption` — image gets an (initially empty) caption. */
|
|
15
|
+
allowCaption: boolean;
|
|
16
|
+
/** `images.maxSizeMB` */
|
|
17
|
+
maxSizeMB: number;
|
|
18
|
+
/** Host upload handler; when absent, uploads are embedded as base64 data URIs. */
|
|
19
|
+
onImageUpload?: (file: File) => Promise<string>;
|
|
20
|
+
onSubmit: (image: ImageDialogResult) => void;
|
|
21
|
+
onClose: () => void;
|
|
22
|
+
}
|
|
23
|
+
/** Insert-image dialog (UI spec §6): an external URL and/or an uploaded file. */
|
|
24
|
+
export declare function ImageDialog({ opened, allowLinked, allowEmbedded, allowCaption, maxSizeMB, onImageUpload, onSubmit, onClose, }: ImageDialogProps): import("react").JSX.Element;
|
|
25
|
+
//# sourceMappingURL=ImageDialog.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ImageDialog.d.ts","sourceRoot":"","sources":["../../src/image/ImageDialog.tsx"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAEjD,MAAM,WAAW,iBAAiB;IAChC,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,QAAQ,EAAE,aAAa,CAAC;CACzB;AAED,MAAM,WAAW,gBAAgB;IAC/B,MAAM,EAAE,OAAO,CAAC;IAChB,+CAA+C;IAC/C,WAAW,EAAE,OAAO,CAAC;IACrB,kDAAkD;IAClD,aAAa,EAAE,OAAO,CAAC;IACvB,kEAAkE;IAClE,YAAY,EAAE,OAAO,CAAC;IACtB,yBAAyB;IACzB,SAAS,EAAE,MAAM,CAAC;IAClB,kFAAkF;IAClF,aAAa,CAAC,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;IAChD,QAAQ,EAAE,CAAC,KAAK,EAAE,iBAAiB,KAAK,IAAI,CAAC;IAC7C,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB;AAWD,iFAAiF;AACjF,wBAAgB,WAAW,CAAC,EAC1B,MAAM,EACN,WAAW,EACX,aAAa,EACb,YAAY,EACZ,SAAS,EACT,aAAa,EACb,QAAQ,EACR,OAAO,GACR,EAAE,gBAAgB,+BAmHlB"}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useState } from 'react';
|
|
3
|
+
import { Alert, Button, FileInput, Group, Modal, SegmentedControl, Stack, TextInput } from '@mantine/core';
|
|
4
|
+
import { ACCEPTED_IMAGE_TYPES, normalizeImageUrl, validateImageFile } from './imageUrl.js';
|
|
5
|
+
function readAsDataUrl(file) {
|
|
6
|
+
return new Promise((resolve, reject) => {
|
|
7
|
+
const reader = new FileReader();
|
|
8
|
+
reader.onload = () => resolve(String(reader.result));
|
|
9
|
+
reader.onerror = () => reject(reader.error ?? new Error('Could not read the file'));
|
|
10
|
+
reader.readAsDataURL(file);
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
/** Insert-image dialog (UI spec §6): an external URL and/or an uploaded file. */
|
|
14
|
+
export function ImageDialog({ opened, allowLinked, allowEmbedded, allowCaption, maxSizeMB, onImageUpload, onSubmit, onClose, }) {
|
|
15
|
+
const defaultSource = allowLinked ? 'linked' : 'embedded';
|
|
16
|
+
const [source, setSource] = useState(defaultSource);
|
|
17
|
+
const [url, setUrl] = useState('');
|
|
18
|
+
const [file, setFile] = useState(null);
|
|
19
|
+
const [altText, setAltText] = useState('');
|
|
20
|
+
const [error, setError] = useState(null);
|
|
21
|
+
const [busy, setBusy] = useState(false);
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
if (opened) {
|
|
24
|
+
setSource(defaultSource);
|
|
25
|
+
setUrl('');
|
|
26
|
+
setFile(null);
|
|
27
|
+
setAltText('');
|
|
28
|
+
setError(null);
|
|
29
|
+
setBusy(false);
|
|
30
|
+
}
|
|
31
|
+
}, [opened, defaultSource]);
|
|
32
|
+
const handleSubmit = async (e) => {
|
|
33
|
+
e.preventDefault();
|
|
34
|
+
setError(null);
|
|
35
|
+
try {
|
|
36
|
+
let src;
|
|
37
|
+
if (source === 'linked') {
|
|
38
|
+
src = normalizeImageUrl(url);
|
|
39
|
+
if (!src) {
|
|
40
|
+
setError('Enter an http(s) or relative image URL');
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
if (!file) {
|
|
46
|
+
setError('Choose an image file');
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const problem = validateImageFile(file, maxSizeMB);
|
|
50
|
+
if (problem) {
|
|
51
|
+
setError(problem);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
setBusy(true);
|
|
55
|
+
src = onImageUpload ? await onImageUpload(file) : await readAsDataUrl(file);
|
|
56
|
+
if (!normalizeImageUrl(src)) {
|
|
57
|
+
setError('The upload handler returned an unusable image URL');
|
|
58
|
+
setBusy(false);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
onSubmit({ src, altText: altText.trim(), caption: allowCaption ? '' : null, linkType: source });
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
setError(err instanceof Error ? err.message : 'Could not add the image');
|
|
66
|
+
setBusy(false);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
return (_jsx(Modal, { opened: opened, onClose: onClose, title: "Insert image", centered: true, size: "sm", children: _jsx("form", { onSubmit: handleSubmit, children: _jsxs(Stack, { gap: "sm", children: [allowLinked && allowEmbedded && (_jsx(SegmentedControl, { fullWidth: true, value: source, onChange: (v) => {
|
|
70
|
+
setSource(v);
|
|
71
|
+
setError(null);
|
|
72
|
+
}, data: [
|
|
73
|
+
{ value: 'linked', label: 'From URL' },
|
|
74
|
+
{ value: 'embedded', label: 'Upload' },
|
|
75
|
+
] })), source === 'linked' ? (_jsx(TextInput, { label: "Image URL", placeholder: "https://example.com/photo.jpg", value: url, onChange: (e) => setUrl(e.currentTarget.value), "data-autofocus": true })) : (_jsx(FileInput, { label: "Image file", placeholder: "Choose an image", accept: ACCEPTED_IMAGE_TYPES.join(','), value: file, onChange: setFile, description: `Up to ${maxSizeMB} MB${onImageUpload ? '' : '; stored inside the document'}` })), _jsx(TextInput, { label: "Alt text", description: "Describes the image for screen readers", value: altText, onChange: (e) => setAltText(e.currentTarget.value) }), error && (_jsx(Alert, { color: "red", variant: "light", p: "xs", children: error })), _jsxs(Group, { justify: "flex-end", gap: "xs", children: [_jsx(Button, { variant: "default", onClick: onClose, children: "Cancel" }), _jsx(Button, { type: "submit", loading: busy, children: "Insert" })] })] }) }) }));
|
|
76
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { type ReactElement } from 'react';
|
|
2
|
+
import { DecoratorNode, type DOMExportOutput, type EditorConfig, type LexicalNode, type NodeKey, type SerializedLexicalNode, type Spread } from 'lexical';
|
|
3
|
+
/** How the image's bytes are held: an external URL, or embedded in the
|
|
4
|
+
* document (a base64 data URI, or a URL returned by the host's upload handler). */
|
|
5
|
+
export type ImageLinkType = 'linked' | 'embedded';
|
|
6
|
+
export interface ImagePayload {
|
|
7
|
+
src: string;
|
|
8
|
+
altText?: string;
|
|
9
|
+
/** null = no caption; '' = caption enabled but empty */
|
|
10
|
+
caption?: string | null;
|
|
11
|
+
linkType: ImageLinkType;
|
|
12
|
+
width?: number | null;
|
|
13
|
+
height?: number | null;
|
|
14
|
+
key?: NodeKey;
|
|
15
|
+
}
|
|
16
|
+
export type SerializedImageNode = Spread<{
|
|
17
|
+
src: string;
|
|
18
|
+
altText: string;
|
|
19
|
+
caption: string | null;
|
|
20
|
+
linkType: ImageLinkType;
|
|
21
|
+
width: number | null;
|
|
22
|
+
height: number | null;
|
|
23
|
+
}, SerializedLexicalNode>;
|
|
24
|
+
/**
|
|
25
|
+
* Block-level image (editor-architecture-design.md §3.5). The caption is a
|
|
26
|
+
* plain string edited through an input under the image — not the nested
|
|
27
|
+
* sub-editor the architecture doc leaves open — which keeps caption edits in
|
|
28
|
+
* the parent editor's undo history.
|
|
29
|
+
*/
|
|
30
|
+
export declare class ImageNode extends DecoratorNode<ReactElement> {
|
|
31
|
+
__src: string;
|
|
32
|
+
__altText: string;
|
|
33
|
+
__caption: string | null;
|
|
34
|
+
__linkType: ImageLinkType;
|
|
35
|
+
__width: number | null;
|
|
36
|
+
__height: number | null;
|
|
37
|
+
static getType(): string;
|
|
38
|
+
static clone(node: ImageNode): ImageNode;
|
|
39
|
+
static importJSON(json: SerializedImageNode): ImageNode;
|
|
40
|
+
constructor(src: string, altText?: string, caption?: string | null, linkType?: ImageLinkType, width?: number | null, height?: number | null, key?: NodeKey);
|
|
41
|
+
exportJSON(): SerializedImageNode;
|
|
42
|
+
/** `<figure><img><figcaption>` — the figure/figcaption semantics from spec §4.7. */
|
|
43
|
+
exportDOM(): DOMExportOutput;
|
|
44
|
+
createDOM(config: EditorConfig): HTMLElement;
|
|
45
|
+
updateDOM(): false;
|
|
46
|
+
isInline(): false;
|
|
47
|
+
getSrc(): string;
|
|
48
|
+
getAltText(): string;
|
|
49
|
+
getCaption(): string | null;
|
|
50
|
+
setCaption(caption: string | null): this;
|
|
51
|
+
getTextContent(): string;
|
|
52
|
+
decorate(): ReactElement;
|
|
53
|
+
}
|
|
54
|
+
export declare function $createImageNode({ src, altText, caption, linkType, width, height, key }: ImagePayload): ImageNode;
|
|
55
|
+
export declare function $isImageNode(node: LexicalNode | null | undefined): node is ImageNode;
|
|
56
|
+
//# sourceMappingURL=ImageNode.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ImageNode.d.ts","sourceRoot":"","sources":["../../src/image/ImageNode.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAkC,KAAK,YAAY,EAAE,MAAM,OAAO,CAAC;AAG1E,OAAO,EAML,aAAa,EAGb,KAAK,eAAe,EACpB,KAAK,YAAY,EACjB,KAAK,WAAW,EAChB,KAAK,OAAO,EACZ,KAAK,qBAAqB,EAC1B,KAAK,MAAM,EACZ,MAAM,SAAS,CAAC;AAEjB;mFACmF;AACnF,MAAM,MAAM,aAAa,GAAG,QAAQ,GAAG,UAAU,CAAC;AAElD,MAAM,WAAW,YAAY;IAC3B,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,wDAAwD;IACxD,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,QAAQ,EAAE,aAAa,CAAC;IACxB,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,GAAG,CAAC,EAAE,OAAO,CAAC;CACf;AAED,MAAM,MAAM,mBAAmB,GAAG,MAAM,CACtC;IACE,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,QAAQ,EAAE,aAAa,CAAC;IACxB,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;CACvB,EACD,qBAAqB,CACtB,CAAC;AAEF;;;;;GAKG;AACH,qBAAa,SAAU,SAAQ,aAAa,CAAC,YAAY,CAAC;IACxD,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,UAAU,EAAE,aAAa,CAAC;IAC1B,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IAExB,MAAM,CAAC,OAAO,IAAI,MAAM;IAIxB,MAAM,CAAC,KAAK,CAAC,IAAI,EAAE,SAAS,GAAG,SAAS;IAIxC,MAAM,CAAC,UAAU,CAAC,IAAI,EAAE,mBAAmB,GAAG,SAAS;gBAYrD,GAAG,EAAE,MAAM,EACX,OAAO,SAAK,EACZ,OAAO,GAAE,MAAM,GAAG,IAAW,EAC7B,QAAQ,GAAE,aAAwB,EAClC,KAAK,GAAE,MAAM,GAAG,IAAW,EAC3B,MAAM,GAAE,MAAM,GAAG,IAAW,EAC5B,GAAG,CAAC,EAAE,OAAO;IAWf,UAAU,IAAI,mBAAmB;IAajC,oFAAoF;IACpF,SAAS,IAAI,eAAe;IAgB5B,SAAS,CAAC,MAAM,EAAE,YAAY,GAAG,WAAW;IAO5C,SAAS,IAAI,KAAK;IAIlB,QAAQ,IAAI,KAAK;IAIjB,MAAM,IAAI,MAAM;IAIhB,UAAU,IAAI,MAAM;IAIpB,UAAU,IAAI,MAAM,GAAG,IAAI;IAI3B,UAAU,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,GAAG,IAAI;IAMxC,cAAc,IAAI,MAAM;IAIxB,QAAQ,IAAI,YAAY;CAYzB;AAED,wBAAgB,gBAAgB,CAAC,EAAE,GAAG,EAAE,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,EAAE,YAAY,GAAG,SAAS,CAEjH;AAED,wBAAgB,YAAY,CAAC,IAAI,EAAE,WAAW,GAAG,IAAI,GAAG,SAAS,GAAG,IAAI,IAAI,SAAS,CAEpF"}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useCallback, useEffect, useRef } from 'react';
|
|
3
|
+
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
|
4
|
+
import { useLexicalNodeSelection } from '@lexical/react/useLexicalNodeSelection';
|
|
5
|
+
import { $getNodeByKey, $getSelection, $isNodeSelection, CLICK_COMMAND, COMMAND_PRIORITY_LOW, DecoratorNode, KEY_BACKSPACE_COMMAND, KEY_DELETE_COMMAND, } from 'lexical';
|
|
6
|
+
/**
|
|
7
|
+
* Block-level image (editor-architecture-design.md §3.5). The caption is a
|
|
8
|
+
* plain string edited through an input under the image — not the nested
|
|
9
|
+
* sub-editor the architecture doc leaves open — which keeps caption edits in
|
|
10
|
+
* the parent editor's undo history.
|
|
11
|
+
*/
|
|
12
|
+
export class ImageNode extends DecoratorNode {
|
|
13
|
+
static getType() {
|
|
14
|
+
return 'image';
|
|
15
|
+
}
|
|
16
|
+
static clone(node) {
|
|
17
|
+
return new ImageNode(node.__src, node.__altText, node.__caption, node.__linkType, node.__width, node.__height, node.__key);
|
|
18
|
+
}
|
|
19
|
+
static importJSON(json) {
|
|
20
|
+
return $createImageNode({
|
|
21
|
+
src: json.src,
|
|
22
|
+
altText: json.altText,
|
|
23
|
+
caption: json.caption,
|
|
24
|
+
linkType: json.linkType,
|
|
25
|
+
width: json.width,
|
|
26
|
+
height: json.height,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
constructor(src, altText = '', caption = null, linkType = 'linked', width = null, height = null, key) {
|
|
30
|
+
super(key);
|
|
31
|
+
this.__src = src;
|
|
32
|
+
this.__altText = altText;
|
|
33
|
+
this.__caption = caption;
|
|
34
|
+
this.__linkType = linkType;
|
|
35
|
+
this.__width = width;
|
|
36
|
+
this.__height = height;
|
|
37
|
+
}
|
|
38
|
+
exportJSON() {
|
|
39
|
+
return {
|
|
40
|
+
type: 'image',
|
|
41
|
+
version: 1,
|
|
42
|
+
src: this.__src,
|
|
43
|
+
altText: this.__altText,
|
|
44
|
+
caption: this.__caption,
|
|
45
|
+
linkType: this.__linkType,
|
|
46
|
+
width: this.__width,
|
|
47
|
+
height: this.__height,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/** `<figure><img><figcaption>` — the figure/figcaption semantics from spec §4.7. */
|
|
51
|
+
exportDOM() {
|
|
52
|
+
const figure = document.createElement('figure');
|
|
53
|
+
const img = document.createElement('img');
|
|
54
|
+
img.setAttribute('src', this.__src);
|
|
55
|
+
img.setAttribute('alt', this.__altText);
|
|
56
|
+
if (this.__width)
|
|
57
|
+
img.setAttribute('width', String(this.__width));
|
|
58
|
+
if (this.__height)
|
|
59
|
+
img.setAttribute('height', String(this.__height));
|
|
60
|
+
figure.appendChild(img);
|
|
61
|
+
if (this.__caption) {
|
|
62
|
+
const caption = document.createElement('figcaption');
|
|
63
|
+
caption.textContent = this.__caption;
|
|
64
|
+
figure.appendChild(caption);
|
|
65
|
+
}
|
|
66
|
+
return { element: figure };
|
|
67
|
+
}
|
|
68
|
+
createDOM(config) {
|
|
69
|
+
const div = document.createElement('div');
|
|
70
|
+
const className = config.theme.image;
|
|
71
|
+
if (typeof className === 'string')
|
|
72
|
+
div.className = className;
|
|
73
|
+
return div;
|
|
74
|
+
}
|
|
75
|
+
updateDOM() {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
isInline() {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
getSrc() {
|
|
82
|
+
return this.__src;
|
|
83
|
+
}
|
|
84
|
+
getAltText() {
|
|
85
|
+
return this.__altText;
|
|
86
|
+
}
|
|
87
|
+
getCaption() {
|
|
88
|
+
return this.__caption;
|
|
89
|
+
}
|
|
90
|
+
setCaption(caption) {
|
|
91
|
+
const writable = this.getWritable();
|
|
92
|
+
writable.__caption = caption;
|
|
93
|
+
return writable;
|
|
94
|
+
}
|
|
95
|
+
getTextContent() {
|
|
96
|
+
return this.__altText || this.__caption || '';
|
|
97
|
+
}
|
|
98
|
+
decorate() {
|
|
99
|
+
return (_jsx(ImageComponent, { nodeKey: this.getKey(), src: this.__src, altText: this.__altText, caption: this.__caption, width: this.__width, height: this.__height }));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
export function $createImageNode({ src, altText, caption, linkType, width, height, key }) {
|
|
103
|
+
return new ImageNode(src, altText, caption ?? null, linkType, width ?? null, height ?? null, key);
|
|
104
|
+
}
|
|
105
|
+
export function $isImageNode(node) {
|
|
106
|
+
return node instanceof ImageNode;
|
|
107
|
+
}
|
|
108
|
+
function ImageComponent({ nodeKey, src, altText, caption, width, height }) {
|
|
109
|
+
const [editor] = useLexicalComposerContext();
|
|
110
|
+
const [isSelected, setSelected, clearSelection] = useLexicalNodeSelection(nodeKey);
|
|
111
|
+
const imageRef = useRef(null);
|
|
112
|
+
// Selecting must go through CLICK_COMMAND (returning true) rather than a
|
|
113
|
+
// React onClick: otherwise Lexical's own click handling turns the DOM
|
|
114
|
+
// selection into a range selection right after, undoing the node selection.
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
return editor.registerCommand(CLICK_COMMAND, (event) => {
|
|
117
|
+
if (event.target !== imageRef.current)
|
|
118
|
+
return false;
|
|
119
|
+
clearSelection();
|
|
120
|
+
setSelected(true);
|
|
121
|
+
return true;
|
|
122
|
+
}, COMMAND_PRIORITY_LOW);
|
|
123
|
+
}, [editor, clearSelection, setSelected]);
|
|
124
|
+
// Delete/Backspace removes the selected image — but not while typing in the
|
|
125
|
+
// caption input, whose keystrokes also reach the editor's root listener.
|
|
126
|
+
const onDelete = useCallback((event) => {
|
|
127
|
+
if (!isSelected || event.target instanceof HTMLInputElement)
|
|
128
|
+
return false;
|
|
129
|
+
if (!$isNodeSelection($getSelection()))
|
|
130
|
+
return false;
|
|
131
|
+
event.preventDefault();
|
|
132
|
+
$getNodeByKey(nodeKey)?.remove();
|
|
133
|
+
return true;
|
|
134
|
+
}, [isSelected, nodeKey]);
|
|
135
|
+
useEffect(() => {
|
|
136
|
+
const unregisterDelete = editor.registerCommand(KEY_DELETE_COMMAND, onDelete, COMMAND_PRIORITY_LOW);
|
|
137
|
+
const unregisterBackspace = editor.registerCommand(KEY_BACKSPACE_COMMAND, onDelete, COMMAND_PRIORITY_LOW);
|
|
138
|
+
return () => {
|
|
139
|
+
unregisterDelete();
|
|
140
|
+
unregisterBackspace();
|
|
141
|
+
};
|
|
142
|
+
}, [editor, onDelete]);
|
|
143
|
+
const setCaption = (value) => {
|
|
144
|
+
editor.update(() => {
|
|
145
|
+
const node = $getNodeByKey(nodeKey);
|
|
146
|
+
if ($isImageNode(node))
|
|
147
|
+
node.setCaption(value);
|
|
148
|
+
});
|
|
149
|
+
};
|
|
150
|
+
return (_jsxs("figure", { className: "likhari-image-figure", "data-selected": isSelected ? 'true' : 'false', contentEditable: false, children: [_jsx("img", { ref: imageRef, className: "likhari-image", src: src, alt: altText, width: width ?? undefined, height: height ?? undefined, draggable: false }), caption !== null && (_jsx("input", { className: "likhari-image-caption", type: "text", "aria-label": "Image caption", placeholder: "Add a caption\u2026", value: caption, onChange: (e) => setCaption(e.currentTarget.value) }))] }));
|
|
151
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validates the source of a linked image. Accepts http(s) URLs, relative
|
|
3
|
+
* paths, and base64 raster/SVG data URIs (what an embedded image stores);
|
|
4
|
+
* anything else (javascript:, other data: types, file:, ...) is rejected.
|
|
5
|
+
* Returns the trimmed URL, or null when it should be refused.
|
|
6
|
+
*/
|
|
7
|
+
export declare function normalizeImageUrl(input: string): string | null;
|
|
8
|
+
export declare const ACCEPTED_IMAGE_TYPES: string[];
|
|
9
|
+
/** Returns an error message if the file can't be inserted, otherwise null. */
|
|
10
|
+
export declare function validateImageFile(file: {
|
|
11
|
+
type: string;
|
|
12
|
+
size: number;
|
|
13
|
+
}, maxSizeMB: number): string | null;
|
|
14
|
+
//# sourceMappingURL=imageUrl.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"imageUrl.d.ts","sourceRoot":"","sources":["../../src/image/imageUrl.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAO9D;AAED,eAAO,MAAM,oBAAoB,UAA0E,CAAC;AAE5G,8EAA8E;AAC9E,wBAAgB,iBAAiB,CAAC,IAAI,EAAE;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAIxG"}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validates the source of a linked image. Accepts http(s) URLs, relative
|
|
3
|
+
* paths, and base64 raster/SVG data URIs (what an embedded image stores);
|
|
4
|
+
* anything else (javascript:, other data: types, file:, ...) is rejected.
|
|
5
|
+
* Returns the trimmed URL, or null when it should be refused.
|
|
6
|
+
*/
|
|
7
|
+
export function normalizeImageUrl(input) {
|
|
8
|
+
const url = input.trim();
|
|
9
|
+
if (!url)
|
|
10
|
+
return null;
|
|
11
|
+
if (/^https?:\/\/\S+$/i.test(url))
|
|
12
|
+
return url;
|
|
13
|
+
if (/^(\/|\.\.?\/)\S*$/.test(url))
|
|
14
|
+
return url;
|
|
15
|
+
if (/^data:image\/(png|jpe?g|gif|webp|svg\+xml);base64,[a-z0-9+/=]+$/i.test(url))
|
|
16
|
+
return url;
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
export const ACCEPTED_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml'];
|
|
20
|
+
/** Returns an error message if the file can't be inserted, otherwise null. */
|
|
21
|
+
export function validateImageFile(file, maxSizeMB) {
|
|
22
|
+
if (!ACCEPTED_IMAGE_TYPES.includes(file.type))
|
|
23
|
+
return 'Choose a PNG, JPEG, GIF, WebP or SVG image';
|
|
24
|
+
if (file.size > maxSizeMB * 1024 * 1024)
|
|
25
|
+
return `Image is larger than ${maxSizeMB} MB`;
|
|
26
|
+
return null;
|
|
27
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export { EditorRoot } from './EditorRoot.js';
|
|
2
2
|
export type { EditorRootProps, EditorRef, EditorInitialContent } from './EditorRoot.js';
|
|
3
3
|
export { defaultMantineTheme } from './theme/mantineTheme.js';
|
|
4
|
+
export { ImageNode, $createImageNode, $isImageNode } from './image/ImageNode.js';
|
|
5
|
+
export type { ImagePayload, ImageLinkType, SerializedImageNode } from './image/ImageNode.js';
|
|
4
6
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAC1C,YAAY,EAAE,eAAe,EAAE,SAAS,EAAE,oBAAoB,EAAE,MAAM,cAAc,CAAC;AACrF,OAAO,EAAE,mBAAmB,EAAE,MAAM,sBAAsB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAC1C,YAAY,EAAE,eAAe,EAAE,SAAS,EAAE,oBAAoB,EAAE,MAAM,cAAc,CAAC;AACrF,OAAO,EAAE,mBAAmB,EAAE,MAAM,sBAAsB,CAAC;AAC3D,OAAO,EAAE,SAAS,EAAE,gBAAgB,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAC9E,YAAY,EAAE,YAAY,EAAE,aAAa,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC"}
|
package/dist/index.js
CHANGED
package/dist/nodes.d.ts.map
CHANGED
|
@@ -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;
|
|
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
|
];
|
package/dist/theme/editor.css
CHANGED
|
@@ -234,3 +234,47 @@
|
|
|
234
234
|
border-top: 1px solid var(--editor-line);
|
|
235
235
|
margin: 1.5em 0;
|
|
236
236
|
}
|
|
237
|
+
|
|
238
|
+
/* Images — block-level figure with an optional caption input. */
|
|
239
|
+
.likhari-image-block {
|
|
240
|
+
margin: 0 0 1em 0;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
.likhari-image-figure {
|
|
244
|
+
display: block;
|
|
245
|
+
margin: 0;
|
|
246
|
+
text-align: center;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
.likhari-image {
|
|
250
|
+
display: inline-block;
|
|
251
|
+
max-width: 100%;
|
|
252
|
+
height: auto;
|
|
253
|
+
border-radius: 4px;
|
|
254
|
+
cursor: default;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
.likhari-image-figure[data-selected='true'] .likhari-image {
|
|
258
|
+
outline: 2px solid var(--editor-accent);
|
|
259
|
+
outline-offset: 2px;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
.likhari-image-caption {
|
|
263
|
+
display: block;
|
|
264
|
+
width: 100%;
|
|
265
|
+
margin-top: 6px;
|
|
266
|
+
padding: 2px 4px;
|
|
267
|
+
border: none;
|
|
268
|
+
border-bottom: 1px dashed transparent;
|
|
269
|
+
background: transparent;
|
|
270
|
+
color: var(--editor-ink-soft);
|
|
271
|
+
font: inherit;
|
|
272
|
+
font-size: 0.85em;
|
|
273
|
+
text-align: center;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
.likhari-image-caption:hover,
|
|
277
|
+
.likhari-image-caption:focus {
|
|
278
|
+
border-bottom-color: var(--editor-line-strong);
|
|
279
|
+
outline: none;
|
|
280
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"editorTheme.d.ts","sourceRoot":"","sources":["../../src/theme/editorTheme.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,SAAS,CAAC;AAElD;;;;GAIG;AACH,eAAO,MAAM,WAAW,EAAE,
|
|
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"}
|
package/package.json
CHANGED
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
export interface LinkDialogProps {
|
|
2
|
-
opened: boolean;
|
|
3
|
-
/** Existing link's URL when editing; empty when inserting. */
|
|
4
|
-
initialUrl: string;
|
|
5
|
-
/** Show a "Text" field — used when nothing is selected, so there is no text to link. */
|
|
6
|
-
showTextField: boolean;
|
|
7
|
-
onSubmit: (link: {
|
|
8
|
-
url: string;
|
|
9
|
-
text: string;
|
|
10
|
-
}) => void;
|
|
11
|
-
/** Present only when editing an existing link. */
|
|
12
|
-
onRemove?: () => void;
|
|
13
|
-
onClose: () => void;
|
|
14
|
-
}
|
|
15
|
-
/** Insert/edit-link dialog (UI spec §6). Mantine Modal supplies focus
|
|
16
|
-
* trapping, Escape-to-close and the overlay. */
|
|
17
|
-
export declare function LinkDialog({ opened, initialUrl, showTextField, onSubmit, onRemove, onClose }: LinkDialogProps): import("react").JSX.Element;
|
|
18
|
-
//# sourceMappingURL=LinkDialog.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"LinkDialog.d.ts","sourceRoot":"","sources":["../../src/components/LinkDialog.tsx"],"names":[],"mappings":"AAIA,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,OAAO,CAAC;IAChB,8DAA8D;IAC9D,UAAU,EAAE,MAAM,CAAC;IACnB,wFAAwF;IACxF,aAAa,EAAE,OAAO,CAAC;IACvB,QAAQ,EAAE,CAAC,IAAI,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC;IACxD,kDAAkD;IAClD,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAC;IACtB,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB;AAED;gDACgD;AAChD,wBAAgB,UAAU,CAAC,EAAE,MAAM,EAAE,UAAU,EAAE,aAAa,EAAE,QAAQ,EAAE,QAAQ,EAAE,OAAO,EAAE,EAAE,eAAe,+BAgE7G"}
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useEffect, useState } from 'react';
|
|
3
|
-
import { Button, Group, Modal, TextInput } from '@mantine/core';
|
|
4
|
-
import { normalizeLinkUrl } from '../utils/linkUrl.js';
|
|
5
|
-
/** Insert/edit-link dialog (UI spec §6). Mantine Modal supplies focus
|
|
6
|
-
* trapping, Escape-to-close and the overlay. */
|
|
7
|
-
export function LinkDialog({ opened, initialUrl, showTextField, onSubmit, onRemove, onClose }) {
|
|
8
|
-
const [url, setUrl] = useState(initialUrl);
|
|
9
|
-
const [text, setText] = useState('');
|
|
10
|
-
const [error, setError] = useState(null);
|
|
11
|
-
useEffect(() => {
|
|
12
|
-
if (opened) {
|
|
13
|
-
setUrl(initialUrl);
|
|
14
|
-
setText('');
|
|
15
|
-
setError(null);
|
|
16
|
-
}
|
|
17
|
-
}, [opened, initialUrl]);
|
|
18
|
-
const handleSubmit = (e) => {
|
|
19
|
-
e.preventDefault();
|
|
20
|
-
const normalized = normalizeLinkUrl(url);
|
|
21
|
-
if (!normalized) {
|
|
22
|
-
setError('Enter a valid http(s), mailto, tel or relative URL');
|
|
23
|
-
return;
|
|
24
|
-
}
|
|
25
|
-
onSubmit({ url: normalized, text: text.trim() || normalized });
|
|
26
|
-
};
|
|
27
|
-
return (_jsx(Modal, { opened: opened, onClose: onClose, title: onRemove ? 'Edit link' : 'Insert link', centered: true, size: "sm", children: _jsxs("form", { onSubmit: handleSubmit, children: [_jsx(TextInput, { label: "URL", placeholder: "https://example.com", value: url, onChange: (e) => {
|
|
28
|
-
setUrl(e.currentTarget.value);
|
|
29
|
-
setError(null);
|
|
30
|
-
}, error: error, "data-autofocus": true }), showTextField && (_jsx(TextInput, { mt: "sm", label: "Text", placeholder: "Link text (defaults to the URL)", value: text, onChange: (e) => setText(e.currentTarget.value) })), _jsxs(Group, { justify: "space-between", mt: "md", children: [onRemove ? (_jsx(Button, { variant: "subtle", color: "red", onClick: onRemove, children: "Remove link" })) : (_jsx("span", {})), _jsxs(Group, { gap: "xs", children: [_jsx(Button, { variant: "default", onClick: onClose, children: "Cancel" }), _jsx(Button, { type: "submit", children: "Apply" })] })] })] }) }));
|
|
31
|
-
}
|
package/dist/utils/linkUrl.d.ts
DELETED
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Normalizes a URL typed into the link dialog, or returns null if it should
|
|
3
|
-
* be rejected. Only http(s), mailto, tel, in-page (#), and relative URLs are
|
|
4
|
-
* accepted — any other scheme (javascript:, data:, vbscript:, ...) is
|
|
5
|
-
* refused so a pasted link can't carry script into exported HTML. A bare
|
|
6
|
-
* domain like "example.com/page" gets https:// prefixed.
|
|
7
|
-
*/
|
|
8
|
-
export declare function normalizeLinkUrl(input: string): string | null;
|
|
9
|
-
//# sourceMappingURL=linkUrl.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"linkUrl.d.ts","sourceRoot":"","sources":["../../src/utils/linkUrl.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAc7D"}
|
package/dist/utils/linkUrl.js
DELETED
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Normalizes a URL typed into the link dialog, or returns null if it should
|
|
3
|
-
* be rejected. Only http(s), mailto, tel, in-page (#), and relative URLs are
|
|
4
|
-
* accepted — any other scheme (javascript:, data:, vbscript:, ...) is
|
|
5
|
-
* refused so a pasted link can't carry script into exported HTML. A bare
|
|
6
|
-
* domain like "example.com/page" gets https:// prefixed.
|
|
7
|
-
*/
|
|
8
|
-
export function normalizeLinkUrl(input) {
|
|
9
|
-
const url = input.trim();
|
|
10
|
-
if (!url)
|
|
11
|
-
return null;
|
|
12
|
-
if (/^(https?:\/\/|mailto:|tel:)\S+$/i.test(url))
|
|
13
|
-
return url;
|
|
14
|
-
if (/^(#|\/|\.\.?\/)\S*$/.test(url))
|
|
15
|
-
return url;
|
|
16
|
-
// Any other explicit scheme is rejected.
|
|
17
|
-
if (/^[a-z][a-z0-9+.-]*:/i.test(url))
|
|
18
|
-
return null;
|
|
19
|
-
// Looks like a bare domain (no spaces, has a dot in its host part).
|
|
20
|
-
if (/^[^\s/?#]+\.[^\s/?#]+(?:[/?#]\S*)?$/.test(url))
|
|
21
|
-
return `https://${url}`;
|
|
22
|
-
return null;
|
|
23
|
-
}
|