@inshapardaz/likhari-react 0.1.3 → 0.1.4-dev.23
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 +11 -0
- package/dist/EditorRoot.d.ts.map +1 -1
- package/dist/EditorRoot.js +2 -2
- package/dist/components/LinkDialog.d.ts +18 -0
- package/dist/components/LinkDialog.d.ts.map +1 -0
- package/dist/components/LinkDialog.js +31 -0
- package/dist/components/Toolbar.d.ts.map +1 -1
- package/dist/components/Toolbar.js +122 -30
- package/dist/theme/editor.css +29 -58
- package/dist/utils/linkUrl.d.ts +9 -0
- package/dist/utils/linkUrl.d.ts.map +1 -0
- package/dist/utils/linkUrl.js +23 -0
- package/package.json +1 -1
package/dist/EditorRoot.d.ts
CHANGED
|
@@ -25,7 +25,18 @@ export interface EditorRootProps {
|
|
|
25
25
|
*/
|
|
26
26
|
height?: string | number;
|
|
27
27
|
onChange?: (state: SerializedEditorState) => void;
|
|
28
|
+
/**
|
|
29
|
+
* Handler for the toolbar's Save button. Receives the serialized content
|
|
30
|
+
* (plain text when that format is enabled, otherwise Lexical JSON).
|
|
31
|
+
*/
|
|
28
32
|
onSave?: (content: string, format: FormatId) => void;
|
|
33
|
+
/**
|
|
34
|
+
* Whether the Save button (icon-only) is shown. Defaults to `true` when
|
|
35
|
+
* `onSave` is provided and `false` otherwise, i.e. the previous behavior.
|
|
36
|
+
* Set explicitly to show the button without a handler (saving still clears
|
|
37
|
+
* the unsaved-changes state) or to hide it while keeping `onSave`.
|
|
38
|
+
*/
|
|
39
|
+
showSave?: boolean;
|
|
29
40
|
}
|
|
30
41
|
export interface EditorRef {
|
|
31
42
|
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,MAAM,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,KAAK,IAAI,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"}
|
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, }, 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:
|
|
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
|
+
}
|
|
@@ -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":"AAuCA,OAAO,KAAK,EAAE,2BAA2B,EAAE,MAAM,2BAA2B,CAAC;AAiL7E,MAAM,WAAW,YAAY;IAC3B,MAAM,EAAE,2BAA2B,CAAC;IACpC,MAAM,CAAC,EAAE,MAAM,IAAI,CAAC;IACpB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED,wBAAgB,OAAO,CAAC,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,QAAQ,EAAE,EAAE,YAAY,+BAsc1E"}
|
|
@@ -1,18 +1,23 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { Menu, Select } from '@mantine/core';
|
|
3
4
|
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
|
4
|
-
import { $getSelection, $isRangeSelection, $isElementNode, 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';
|
|
5
6
|
import { $setBlocksType } from '@lexical/selection';
|
|
6
7
|
import { $createParagraphNode } from 'lexical';
|
|
7
8
|
import { $createHeadingNode, $createQuoteNode, $isHeadingNode, $isQuoteNode } from '@lexical/rich-text';
|
|
8
9
|
import { $isListNode, INSERT_CHECK_LIST_COMMAND, INSERT_ORDERED_LIST_COMMAND, INSERT_UNORDERED_LIST_COMMAND, REMOVE_LIST_COMMAND, } from '@lexical/list';
|
|
9
10
|
import { $findMatchingParent } from '@lexical/utils';
|
|
11
|
+
import { $createLinkNode, $isLinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link';
|
|
12
|
+
import { LinkDialog } from './LinkDialog.js';
|
|
10
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';
|
|
11
14
|
const INITIAL_STATE = {
|
|
12
15
|
blockType: 'paragraph',
|
|
13
16
|
activeFormats: new Set(),
|
|
14
17
|
elementFormat: 'start',
|
|
15
18
|
listType: null,
|
|
19
|
+
isLink: false,
|
|
20
|
+
linkUrl: '',
|
|
16
21
|
canUndo: false,
|
|
17
22
|
canRedo: false,
|
|
18
23
|
};
|
|
@@ -26,8 +31,8 @@ const ALIGN_ICONS = {
|
|
|
26
31
|
right: IconAlignRight,
|
|
27
32
|
justify: IconAlignJustified,
|
|
28
33
|
};
|
|
29
|
-
function ToolbarButton({ icon: Icon, title, active, disabled, onClick, }) {
|
|
30
|
-
return (_jsx("button", { type: "button", className: "likhari-toolbar-button", "data-active": active ? 'true' : 'false', disabled: disabled, "aria-pressed": active, "aria-label": title, title: title, onMouseDown: (e) => e.preventDefault(), onClick: onClick, children: _jsx(Icon, { size: ICON_SIZE, stroke: ICON_STROKE }) }));
|
|
34
|
+
function ToolbarButton({ icon: Icon, title, active, dirty, disabled, onClick, }) {
|
|
35
|
+
return (_jsx("button", { type: "button", className: "likhari-toolbar-button", "data-active": active ? 'true' : 'false', "data-dirty": dirty === undefined ? undefined : dirty ? 'true' : 'false', disabled: disabled, "aria-pressed": active, "aria-label": title, title: title, onMouseDown: (e) => e.preventDefault(), onClick: onClick, children: _jsx(Icon, { size: ICON_SIZE, stroke: ICON_STROKE }) }));
|
|
31
36
|
}
|
|
32
37
|
/** A disabled placeholder for a feature whose config flag is on but that
|
|
33
38
|
* isn't implemented yet (link/image/poetry/font/language-tooling — see
|
|
@@ -36,16 +41,21 @@ function ToolbarButton({ icon: Icon, title, active, disabled, onClick, }) {
|
|
|
36
41
|
function StubButton({ icon, title }) {
|
|
37
42
|
return _jsx(ToolbarButton, { icon: icon, title: `${title} (coming soon)`, disabled: true, onClick: undefined });
|
|
38
43
|
}
|
|
39
|
-
/**
|
|
40
|
-
*
|
|
41
|
-
|
|
42
|
-
|
|
44
|
+
/** Mantine Select (combobox) for the toolbar's dropdown controls. The icon
|
|
45
|
+
* labels the control itself, since a dropdown can't show one per option in
|
|
46
|
+
* its closed state. The dropdown portals to <body>, so it is not clipped by
|
|
47
|
+
* the editor's `overflow: hidden` frame. */
|
|
48
|
+
function ToolbarSelect({ icon: Icon, label, value, data, width, disabled, placeholder, onChange }) {
|
|
49
|
+
return (_jsx(Select, { size: "xs", w: width, "aria-label": label, title: disabled ? `${label} (coming soon)` : label, data: data, value: value, placeholder: placeholder, disabled: disabled, allowDeselect: false, leftSection: _jsx(Icon, { size: 15, stroke: ICON_STROKE }), comboboxProps: { withinPortal: true, position: 'bottom-start', middlewares: { flip: true, shift: true } }, classNames: { input: 'likhari-toolbar-select-input', dropdown: 'likhari-toolbar-select-dropdown' }, onChange: (v) => v && onChange?.(v) }));
|
|
50
|
+
}
|
|
51
|
+
function OverflowItem({ icon: Icon, label, active, onClick, }) {
|
|
52
|
+
return (_jsx(Menu.Item, { leftSection: _jsx(Icon, { size: ICON_SIZE, stroke: ICON_STROKE }), "data-active": active ? 'true' : 'false', className: "likhari-overflow-item",
|
|
53
|
+
// Keep the editor's selection: a mousedown here would blur the canvas.
|
|
54
|
+
onMouseDown: (e) => e.preventDefault(), onClick: onClick, children: label }));
|
|
43
55
|
}
|
|
44
56
|
export function Toolbar({ config, onSave, isDirty, showSave }) {
|
|
45
57
|
const [editor] = useLexicalComposerContext();
|
|
46
58
|
const [state, setState] = useState(INITIAL_STATE);
|
|
47
|
-
const [overflowOpen, setOverflowOpen] = useState(false);
|
|
48
|
-
const overflowRef = useRef(null);
|
|
49
59
|
const updateToolbar = useCallback(() => {
|
|
50
60
|
editor.getEditorState().read(() => {
|
|
51
61
|
const selection = $getSelection();
|
|
@@ -62,6 +72,9 @@ export function Toolbar({ config, onSave, isDirty, showSave }) {
|
|
|
62
72
|
else if ($isQuoteNode(element)) {
|
|
63
73
|
blockType = 'quote';
|
|
64
74
|
}
|
|
75
|
+
const linkNode = $findMatchingParent(anchorNode, $isLinkNode);
|
|
76
|
+
const isLink = Boolean(linkNode);
|
|
77
|
+
const linkUrl = linkNode && $isLinkNode(linkNode) ? linkNode.getURL() : '';
|
|
65
78
|
const activeFormats = new Set();
|
|
66
79
|
['bold', 'italic', 'underline', 'strikethrough', 'subscript', 'superscript'].forEach((format) => {
|
|
67
80
|
if (selection.hasFormat(format))
|
|
@@ -78,6 +91,8 @@ export function Toolbar({ config, onSave, isDirty, showSave }) {
|
|
|
78
91
|
activeFormats,
|
|
79
92
|
elementFormat,
|
|
80
93
|
listType,
|
|
94
|
+
isLink,
|
|
95
|
+
linkUrl,
|
|
81
96
|
}));
|
|
82
97
|
});
|
|
83
98
|
}, [editor]);
|
|
@@ -99,25 +114,6 @@ export function Toolbar({ config, onSave, isDirty, showSave }) {
|
|
|
99
114
|
return false;
|
|
100
115
|
}, COMMAND_PRIORITY_LOW);
|
|
101
116
|
}, [editor]);
|
|
102
|
-
// Close the overflow panel on outside click or Escape.
|
|
103
|
-
useEffect(() => {
|
|
104
|
-
if (!overflowOpen)
|
|
105
|
-
return;
|
|
106
|
-
const handlePointerDown = (e) => {
|
|
107
|
-
if (overflowRef.current && !overflowRef.current.contains(e.target))
|
|
108
|
-
setOverflowOpen(false);
|
|
109
|
-
};
|
|
110
|
-
const handleKeyDown = (e) => {
|
|
111
|
-
if (e.key === 'Escape')
|
|
112
|
-
setOverflowOpen(false);
|
|
113
|
-
};
|
|
114
|
-
document.addEventListener('mousedown', handlePointerDown);
|
|
115
|
-
document.addEventListener('keydown', handleKeyDown);
|
|
116
|
-
return () => {
|
|
117
|
-
document.removeEventListener('mousedown', handlePointerDown);
|
|
118
|
-
document.removeEventListener('keydown', handleKeyDown);
|
|
119
|
-
};
|
|
120
|
-
}, [overflowOpen]);
|
|
121
117
|
const setBlockType = (type) => {
|
|
122
118
|
editor.update(() => {
|
|
123
119
|
const selection = $getSelection();
|
|
@@ -151,6 +147,86 @@ export function Toolbar({ config, onSave, isDirty, showSave }) {
|
|
|
151
147
|
}
|
|
152
148
|
setBlockType(value);
|
|
153
149
|
};
|
|
150
|
+
/** Runs a toolbar-select action, then returns focus to the canvas — the
|
|
151
|
+
* combobox otherwise keeps it, so typing after picking would go nowhere. */
|
|
152
|
+
// Opening the overflow Menu moves focus into its dropdown, which makes
|
|
153
|
+
// Lexical drop the canvas selection — so snapshot it on open and restore it
|
|
154
|
+
// before an item's action runs.
|
|
155
|
+
const menuSelectionRef = useRef(null);
|
|
156
|
+
const snapshotSelection = () => {
|
|
157
|
+
editor.getEditorState().read(() => {
|
|
158
|
+
const selection = $getSelection();
|
|
159
|
+
menuSelectionRef.current = selection ? selection.clone() : null;
|
|
160
|
+
});
|
|
161
|
+
};
|
|
162
|
+
const runOverflowAction = (action) => () => {
|
|
163
|
+
const saved = menuSelectionRef.current;
|
|
164
|
+
if (saved)
|
|
165
|
+
editor.update(() => $setSelection(saved.clone()), { discrete: true });
|
|
166
|
+
action();
|
|
167
|
+
editor.focus();
|
|
168
|
+
};
|
|
169
|
+
const [linkDialogOpen, setLinkDialogOpen] = useState(false);
|
|
170
|
+
const [linkNeedsText, setLinkNeedsText] = useState(false);
|
|
171
|
+
const openLinkDialog = useCallback(() => {
|
|
172
|
+
// Snapshot first: the dialog's focus trap makes Lexical drop the selection.
|
|
173
|
+
let collapsed = false;
|
|
174
|
+
editor.getEditorState().read(() => {
|
|
175
|
+
const selection = $getSelection();
|
|
176
|
+
menuSelectionRef.current = selection ? selection.clone() : null;
|
|
177
|
+
collapsed = $isRangeSelection(selection) && selection.isCollapsed();
|
|
178
|
+
});
|
|
179
|
+
setLinkNeedsText(collapsed && !state.isLink);
|
|
180
|
+
setLinkDialogOpen(true);
|
|
181
|
+
}, [editor, state.isLink]);
|
|
182
|
+
const closeLinkDialog = () => {
|
|
183
|
+
setLinkDialogOpen(false);
|
|
184
|
+
editor.focus();
|
|
185
|
+
};
|
|
186
|
+
const restoreSelection = () => {
|
|
187
|
+
const saved = menuSelectionRef.current;
|
|
188
|
+
if (saved)
|
|
189
|
+
editor.update(() => $setSelection(saved.clone()), { discrete: true });
|
|
190
|
+
};
|
|
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
|
+
const withRefocus = (fn) => (value) => {
|
|
227
|
+
fn(value);
|
|
228
|
+
editor.focus();
|
|
229
|
+
};
|
|
154
230
|
const formatText = (format) => editor.dispatchCommand(FORMAT_TEXT_COMMAND, format);
|
|
155
231
|
const formatElement = (format) => editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, format);
|
|
156
232
|
const applyCaseTransform = (transform) => {
|
|
@@ -195,10 +271,26 @@ export function Toolbar({ config, onSave, isDirty, showSave }) {
|
|
|
195
271
|
const showFormattingGroup = headingLevels.length > 0 || config.lists.bullet || config.lists.numbered || config.lists.check || config.blocks.quote;
|
|
196
272
|
const showInlineGroup = fmt.bold || fmt.italic || fmt.underline;
|
|
197
273
|
const showAlignGroup = config.alignment.start || config.alignment.center || config.alignment.justify || config.alignment.left || config.alignment.right;
|
|
198
|
-
const
|
|
274
|
+
const showStubInsertGroup = config.images.linked || config.images.embedded || config.poetry.enabled;
|
|
199
275
|
const showLanguageGroup = config.language.autocorrect || config.language.textCleanup || config.language.spellCheck;
|
|
200
276
|
const showOverflowMenu = fmt.strikethrough || fmt.superscript || fmt.subscript || fmt.caseTransforms || fmt.clearFormatting || config.indent;
|
|
277
|
+
const formattingOptions = [
|
|
278
|
+
{ value: 'paragraph', label: 'Paragraph' },
|
|
279
|
+
...headingLevels.map((level) => ({ value: `h${level}`, label: `Heading ${level}` })),
|
|
280
|
+
...(config.lists.numbered ? [{ value: 'number', label: 'Numbered list' }] : []),
|
|
281
|
+
...(config.lists.bullet ? [{ value: 'bullet', label: 'Bullet list' }] : []),
|
|
282
|
+
...(config.lists.check ? [{ value: 'check', label: 'Task list' }] : []),
|
|
283
|
+
...(config.blocks.quote ? [{ value: 'quote', label: 'Quote' }] : []),
|
|
284
|
+
];
|
|
285
|
+
const alignOptions = [
|
|
286
|
+
...(config.alignment.start ? [{ value: 'start', label: 'Align start' }] : []),
|
|
287
|
+
...(config.alignment.center ? [{ value: 'center', label: 'Align center' }] : []),
|
|
288
|
+
...(config.alignment.start ? [{ value: 'end', label: 'Align end' }] : []),
|
|
289
|
+
...(config.alignment.justify ? [{ value: 'justify', label: 'Justify' }] : []),
|
|
290
|
+
...(config.alignment.left ? [{ value: 'left', label: 'Align left' }] : []),
|
|
291
|
+
...(config.alignment.right ? [{ value: 'right', label: 'Align right' }] : []),
|
|
292
|
+
];
|
|
201
293
|
const formattingValue = state.listType ?? state.blockType;
|
|
202
294
|
const AlignIcon = ALIGN_ICONS[state.elementFormat] ?? IconAlignLeft;
|
|
203
|
-
return (_jsxs("div", { className: "likhari-toolbar", role: "toolbar", "aria-label": "Formatting", children: [showSave && (
|
|
295
|
+
return (_jsxs("div", { className: "likhari-toolbar", role: "toolbar", "aria-label": "Formatting", children: [showSave && (_jsx("div", { className: "likhari-toolbar-group", children: _jsx(ToolbarButton, { icon: IconDeviceFloppy, title: "Save", dirty: Boolean(isDirty), onClick: onSave }) })), config.history && (_jsxs("div", { className: "likhari-toolbar-group", children: [_jsx(ToolbarButton, { icon: IconArrowBackUp, title: "Undo", disabled: !state.canUndo, onClick: () => editor.dispatchCommand(UNDO_COMMAND, undefined) }), _jsx(ToolbarButton, { icon: IconArrowForwardUp, title: "Redo", disabled: !state.canRedo, onClick: () => editor.dispatchCommand(REDO_COMMAND, undefined) })] })), showFormattingGroup && (_jsx("div", { className: "likhari-toolbar-group", children: _jsx(ToolbarSelect, { icon: IconPilcrow, label: "Formatting", width: 148, value: formattingValue, data: formattingOptions, onChange: withRefocus((v) => applyFormatting(v)) }) })), showInlineGroup && (_jsxs("div", { className: "likhari-toolbar-group", children: [fmt.bold && (_jsx(ToolbarButton, { icon: IconBold, title: "Bold", active: state.activeFormats.has('bold'), onClick: () => formatText('bold') })), fmt.italic && (_jsx(ToolbarButton, { icon: IconItalic, title: "Italic", active: state.activeFormats.has('italic'), onClick: () => formatText('italic') })), fmt.underline && (_jsx(ToolbarButton, { icon: IconUnderline, title: "Underline", active: state.activeFormats.has('underline'), onClick: () => formatText('underline') }))] })), (config.font.family || config.font.size) && (_jsxs("div", { className: "likhari-toolbar-group likhari-toolbar-group--collapse-tablet", children: [config.font.family && (_jsx(ToolbarSelect, { icon: IconTypography, label: "Font family", width: 110, value: null, placeholder: "Font", data: [], disabled: true })), config.font.size && (_jsx(ToolbarSelect, { icon: IconTextSize, label: "Font size", width: 90, value: null, placeholder: "Size", data: [], disabled: true }))] })), showAlignGroup && (_jsx("div", { className: "likhari-toolbar-group", children: _jsx(ToolbarSelect, { icon: AlignIcon, label: "Alignment", width: 138, value: state.elementFormat || 'start', data: alignOptions, onChange: withRefocus((v) => formatElement(v)) }) })), config.links && (_jsx("div", { className: "likhari-toolbar-group", children: _jsx(ToolbarButton, { icon: IconLink, title: state.isLink ? 'Edit link (Ctrl+K)' : 'Insert link (Ctrl+K)', active: state.isLink, onClick: openLinkDialog }) })), showStubInsertGroup && (_jsxs("div", { className: "likhari-toolbar-group likhari-toolbar-group--collapse-tablet", children: [(config.images.linked || config.images.embedded) && _jsx(StubButton, { icon: IconPhoto, title: "Insert image" }), config.poetry.enabled && _jsx(StubButton, { icon: IconFeather, title: "Poetry blocks" })] })), showLanguageGroup && (_jsxs("div", { className: "likhari-toolbar-group likhari-toolbar-group--collapse-tablet", children: [config.language.autocorrect && _jsx(StubButton, { icon: IconWand, title: "Auto-correct" }), config.language.textCleanup && _jsx(StubButton, { icon: IconSparkles, title: "Text cleanup" }), config.language.spellCheck && _jsx(StubButton, { icon: IconAbc, title: "Spell-checker" })] })), showOverflowMenu && (_jsxs(Menu, { position: "bottom-end", withinPortal: true, shadow: "sm", width: 210, closeOnItemClick: true, onOpen: snapshotSelection, children: [_jsx(Menu.Target, { children: _jsx("button", { type: "button", className: "likhari-toolbar-button", "aria-label": "More formatting", title: "More formatting", onMouseDown: (e) => e.preventDefault(), children: _jsx(IconDots, { size: ICON_SIZE, stroke: ICON_STROKE }) }) }), _jsxs(Menu.Dropdown, { children: [fmt.strikethrough && (_jsx(OverflowItem, { icon: IconStrikethrough, label: "Strikethrough", active: state.activeFormats.has('strikethrough'), onClick: runOverflowAction(() => formatText('strikethrough')) })), fmt.superscript && (_jsx(OverflowItem, { icon: IconSuperscript, label: "Superscript", active: state.activeFormats.has('superscript'), onClick: runOverflowAction(() => formatText('superscript')) })), fmt.subscript && (_jsx(OverflowItem, { icon: IconSubscript, label: "Subscript", active: state.activeFormats.has('subscript'), onClick: runOverflowAction(() => formatText('subscript')) })), fmt.caseTransforms && (_jsxs(_Fragment, { children: [_jsx(OverflowItem, { icon: IconLetterCaseUpper, label: "UPPERCASE", onClick: runOverflowAction(() => applyCaseTransform('upper')) }), _jsx(OverflowItem, { icon: IconLetterCaseLower, label: "lowercase", onClick: runOverflowAction(() => applyCaseTransform('lower')) }), _jsx(OverflowItem, { icon: IconLetterCase, label: "Capitalize", onClick: runOverflowAction(() => applyCaseTransform('capitalize')) })] })), fmt.clearFormatting && _jsx(OverflowItem, { icon: IconClearFormatting, label: "Clear formatting", onClick: runOverflowAction(clearFormatting) }), config.indent && (_jsxs(_Fragment, { children: [_jsx(OverflowItem, { icon: IconIndentDecrease, label: "Outdent", onClick: runOverflowAction(() => editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined)) }), _jsx(OverflowItem, { icon: IconIndentIncrease, label: "Indent", onClick: runOverflowAction(() => editor.dispatchCommand(INDENT_CONTENT_COMMAND, undefined)) })] }))] })] })), config.links && (_jsx(LinkDialog, { opened: linkDialogOpen, initialUrl: state.linkUrl, showTextField: linkNeedsText, onSubmit: applyLink, onRemove: state.isLink ? removeLink : undefined, onClose: closeLinkDialog }))] }));
|
|
204
296
|
}
|
package/dist/theme/editor.css
CHANGED
|
@@ -23,11 +23,14 @@
|
|
|
23
23
|
align-items: center;
|
|
24
24
|
gap: 8px;
|
|
25
25
|
min-height: 46px;
|
|
26
|
-
padding:
|
|
26
|
+
padding: 4px 8px;
|
|
27
27
|
background: var(--editor-paper-dim);
|
|
28
28
|
border-bottom: 1px solid var(--editor-line);
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
/* Wrap instead of scroll: an overflow container would clip menus and grow
|
|
30
|
+
a scrollbar. Dropdowns (Select/Menu) portal out, so nothing needs to
|
|
31
|
+
escape this box. */
|
|
32
|
+
flex-wrap: wrap;
|
|
33
|
+
row-gap: 4px;
|
|
31
34
|
}
|
|
32
35
|
|
|
33
36
|
.likhari-toolbar-group {
|
|
@@ -35,11 +38,11 @@
|
|
|
35
38
|
align-items: center;
|
|
36
39
|
gap: 2px;
|
|
37
40
|
padding: 0 6px;
|
|
38
|
-
border-
|
|
41
|
+
border-inline-end: 0.5px solid var(--editor-line);
|
|
39
42
|
}
|
|
40
43
|
|
|
41
44
|
.likhari-toolbar-group:last-child {
|
|
42
|
-
border-
|
|
45
|
+
border-inline-end: none;
|
|
43
46
|
}
|
|
44
47
|
|
|
45
48
|
.likhari-toolbar-button {
|
|
@@ -79,86 +82,54 @@
|
|
|
79
82
|
cursor: not-allowed;
|
|
80
83
|
}
|
|
81
84
|
|
|
82
|
-
|
|
85
|
+
/* Mantine Select inside the toolbar — sized to the 30px control height and
|
|
86
|
+
themed from the --editor-* tokens (the closed input only; the dropdown is
|
|
87
|
+
portaled to <body>, outside the token scope, so it uses Mantine's own
|
|
88
|
+
variables, which the editor's MantineProvider already maps to the accent). */
|
|
89
|
+
.likhari-toolbar-select-input {
|
|
83
90
|
height: 30px;
|
|
84
|
-
|
|
91
|
+
min-height: 30px;
|
|
85
92
|
font-size: 12px;
|
|
86
93
|
border-radius: 6px;
|
|
87
94
|
border: 1px solid var(--editor-line-strong);
|
|
88
95
|
background: var(--editor-paper);
|
|
89
96
|
color: var(--editor-ink);
|
|
90
|
-
padding: 0 6px;
|
|
91
97
|
}
|
|
92
98
|
|
|
93
|
-
.likhari-toolbar-select:disabled {
|
|
99
|
+
.likhari-toolbar-select-input:disabled {
|
|
94
100
|
opacity: 0.4;
|
|
95
101
|
cursor: not-allowed;
|
|
96
102
|
}
|
|
97
103
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
gap: 4px;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
.likhari-toolbar-select-icon {
|
|
105
|
-
display: inline-flex;
|
|
106
|
-
align-items: center;
|
|
107
|
-
color: var(--editor-ink-soft);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/* Overflow menu ("⋯") — houses less-frequent formatting (strikethrough,
|
|
111
|
-
super/subscript, case transforms, clear formatting, indent/outdent) so
|
|
112
|
-
the primary row stays compact on small/mobile viewports. */
|
|
113
|
-
.likhari-toolbar-overflow {
|
|
114
|
-
position: relative;
|
|
115
|
-
display: flex;
|
|
116
|
-
align-items: center;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
.likhari-toolbar-overflow-panel {
|
|
120
|
-
position: absolute;
|
|
121
|
-
top: calc(100% + 4px);
|
|
122
|
-
inset-inline-end: 0;
|
|
123
|
-
z-index: 20;
|
|
124
|
-
display: flex;
|
|
125
|
-
flex-wrap: wrap;
|
|
126
|
-
gap: 2px;
|
|
127
|
-
width: 168px;
|
|
128
|
-
padding: 6px;
|
|
129
|
-
background: var(--editor-paper);
|
|
130
|
-
border: 0.5px solid var(--editor-line);
|
|
131
|
-
border-radius: 8px;
|
|
104
|
+
.likhari-overflow-item[data-active='true'] {
|
|
105
|
+
background: var(--mantine-primary-color-light);
|
|
106
|
+
color: var(--mantine-primary-color-filled);
|
|
132
107
|
}
|
|
133
108
|
|
|
134
109
|
/* Responsive collapse — editor-ui-design-spec.md §3.3. These groups hold
|
|
135
110
|
only not-yet-implemented placeholders, so hiding them below the tablet
|
|
136
111
|
breakpoint costs nothing functional while keeping the primary row usable
|
|
137
|
-
on small screens; the toolbar
|
|
138
|
-
|
|
112
|
+
on small screens; the toolbar wraps onto a second row if the
|
|
113
|
+
still-visible controls alone overflow a narrow viewport. */
|
|
139
114
|
@media (max-width: 900px) {
|
|
140
115
|
.likhari-toolbar-group--collapse-tablet {
|
|
141
116
|
display: none;
|
|
142
117
|
}
|
|
143
118
|
}
|
|
144
119
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
gap: 6px;
|
|
149
|
-
height: 30px;
|
|
150
|
-
padding: 0 14px;
|
|
151
|
-
border-radius: 6px;
|
|
152
|
-
border: 1px solid var(--editor-accent);
|
|
120
|
+
/* Save (icon-only ToolbarButton): filled accent while there are unsaved
|
|
121
|
+
changes, back to the plain outline once saved — editor-ui-design-spec.md §7. */
|
|
122
|
+
.likhari-toolbar-button[data-dirty='true'] {
|
|
153
123
|
background: var(--editor-accent);
|
|
154
124
|
color: var(--editor-paper);
|
|
155
|
-
font-size: 12px;
|
|
156
|
-
font-weight: 500;
|
|
157
|
-
cursor: pointer;
|
|
158
125
|
}
|
|
159
126
|
|
|
160
|
-
.likhari-
|
|
161
|
-
background:
|
|
127
|
+
.likhari-toolbar-button[data-dirty='true']:hover {
|
|
128
|
+
background: var(--editor-accent);
|
|
129
|
+
filter: brightness(1.1);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.likhari-toolbar-button[data-dirty='false'] {
|
|
162
133
|
color: var(--editor-accent);
|
|
163
134
|
}
|
|
164
135
|
|
|
@@ -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
|
+
}
|