@inshapardaz/likhari-react 0.1.4 → 0.1.5-dev.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/EditorRoot.d.ts +15 -0
- package/dist/EditorRoot.d.ts.map +1 -1
- package/dist/EditorRoot.js +11 -3
- 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 +7 -1
- package/dist/components/Toolbar.d.ts.map +1 -1
- package/dist/components/Toolbar.js +209 -10
- package/dist/fonts/index.d.ts +22 -0
- package/dist/fonts/index.d.ts.map +1 -0
- package/dist/fonts/index.js +20 -0
- package/dist/fonts/urduWebFonts.d.ts +14 -0
- package/dist/fonts/urduWebFonts.d.ts.map +1 -0
- package/dist/fonts/urduWebFonts.js +66 -0
- 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 +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/nodes.d.ts.map +1 -1
- package/dist/nodes.js +2 -0
- package/dist/plugins/LinkPastePlugin.d.ts +12 -0
- package/dist/plugins/LinkPastePlugin.d.ts.map +1 -0
- package/dist/plugins/LinkPastePlugin.js +50 -0
- package/dist/theme/editor.css +55 -0
- package/dist/theme/editorTheme.d.ts.map +1 -1
- package/dist/theme/editorTheme.js +1 -0
- package/dist/utils/linkUrl.d.ts +25 -0
- package/dist/utils/linkUrl.d.ts.map +1 -0
- package/dist/utils/linkUrl.js +62 -0
- package/dist/utils/style.d.ts +4 -0
- package/dist/utils/style.d.ts.map +1 -0
- package/dist/utils/style.js +15 -0
- package/package.json +1 -1
|
@@ -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,8 @@
|
|
|
1
1
|
export { EditorRoot } from './EditorRoot.js';
|
|
2
2
|
export type { EditorRootProps, EditorRef, EditorInitialContent } from './EditorRoot.js';
|
|
3
3
|
export { defaultMantineTheme } from './theme/mantineTheme.js';
|
|
4
|
+
export { ImageNode, $createImageNode, $isImageNode } from './image/ImageNode.js';
|
|
5
|
+
export type { ImagePayload, ImageLinkType, SerializedImageNode } from './image/ImageNode.js';
|
|
6
|
+
export { DEFAULT_FONT_OPTIONS, URDU_WEB_FONT_OPTIONS, FONT_SIZES_PX } from './fonts/index.js';
|
|
7
|
+
export type { FontOption } from './fonts/index.js';
|
|
4
8
|
//# 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;AAC1F,OAAO,EAAE,oBAAoB,EAAE,qBAAqB,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AACrF,YAAY,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -1,2 +1,4 @@
|
|
|
1
1
|
export { EditorRoot } from './EditorRoot.js';
|
|
2
2
|
export { defaultMantineTheme } from './theme/mantineTheme.js';
|
|
3
|
+
export { ImageNode, $createImageNode, $isImageNode } from './image/ImageNode.js';
|
|
4
|
+
export { DEFAULT_FONT_OPTIONS, URDU_WEB_FONT_OPTIONS, FONT_SIZES_PX } from './fonts/index.js';
|
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
|
];
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turns pasted URLs into links instead of inserting them as bare text:
|
|
3
|
+
*
|
|
4
|
+
* - a lone URL pasted over selected text links that text (the URL isn't inserted);
|
|
5
|
+
* - a lone URL pasted at a caret becomes a link whose text is the URL;
|
|
6
|
+
* - a single line of plain text containing URLs gets a link node per URL.
|
|
7
|
+
*
|
|
8
|
+
* Everything else — multi-line text, or anything copied with HTML (Lexical's
|
|
9
|
+
* own paste already turns `<a>` into links) — falls through to the default paste.
|
|
10
|
+
*/
|
|
11
|
+
export declare function LinkPastePlugin(): null;
|
|
12
|
+
//# sourceMappingURL=LinkPastePlugin.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"LinkPastePlugin.d.ts","sourceRoot":"","sources":["../../src/plugins/LinkPastePlugin.tsx"],"names":[],"mappings":"AAaA;;;;;;;;;GASG;AACH,wBAAgB,eAAe,SAyC9B"}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { useEffect } from 'react';
|
|
2
|
+
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
|
3
|
+
import { $createLinkNode, $toggleLink } from '@lexical/link';
|
|
4
|
+
import { $createTextNode, $getSelection, $isRangeSelection, COMMAND_PRIORITY_LOW, PASTE_COMMAND, } from 'lexical';
|
|
5
|
+
import { splitPastedText } from '../utils/linkUrl.js';
|
|
6
|
+
/**
|
|
7
|
+
* Turns pasted URLs into links instead of inserting them as bare text:
|
|
8
|
+
*
|
|
9
|
+
* - a lone URL pasted over selected text links that text (the URL isn't inserted);
|
|
10
|
+
* - a lone URL pasted at a caret becomes a link whose text is the URL;
|
|
11
|
+
* - a single line of plain text containing URLs gets a link node per URL.
|
|
12
|
+
*
|
|
13
|
+
* Everything else — multi-line text, or anything copied with HTML (Lexical's
|
|
14
|
+
* own paste already turns `<a>` into links) — falls through to the default paste.
|
|
15
|
+
*/
|
|
16
|
+
export function LinkPastePlugin() {
|
|
17
|
+
const [editor] = useLexicalComposerContext();
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
return editor.registerCommand(PASTE_COMMAND, (event) => {
|
|
20
|
+
const clipboard = 'clipboardData' in event ? event.clipboardData : null;
|
|
21
|
+
const text = clipboard?.getData('text/plain');
|
|
22
|
+
if (!clipboard || !text)
|
|
23
|
+
return false;
|
|
24
|
+
const segments = splitPastedText(text.trim());
|
|
25
|
+
const hasLink = segments.some((s) => s.type === 'link');
|
|
26
|
+
const isLoneUrl = segments.length === 1 && segments[0].type === 'link';
|
|
27
|
+
const isPlainSingleLine = !clipboard.getData('text/html') && !/[\r\n]/.test(text.trim());
|
|
28
|
+
if (!hasLink || !(isLoneUrl || isPlainSingleLine))
|
|
29
|
+
return false;
|
|
30
|
+
const selection = $getSelection();
|
|
31
|
+
if (!$isRangeSelection(selection))
|
|
32
|
+
return false;
|
|
33
|
+
event.preventDefault();
|
|
34
|
+
if (isLoneUrl && !selection.isCollapsed()) {
|
|
35
|
+
$toggleLink(segments[0].url);
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
const nodes = segments.map((segment) => {
|
|
39
|
+
if (segment.type === 'text')
|
|
40
|
+
return $createTextNode(segment.text);
|
|
41
|
+
const link = $createLinkNode(segment.url);
|
|
42
|
+
link.append($createTextNode(segment.text));
|
|
43
|
+
return link;
|
|
44
|
+
});
|
|
45
|
+
selection.insertNodes(nodes);
|
|
46
|
+
return true;
|
|
47
|
+
}, COMMAND_PRIORITY_LOW);
|
|
48
|
+
}, [editor]);
|
|
49
|
+
return null;
|
|
50
|
+
}
|
package/dist/theme/editor.css
CHANGED
|
@@ -234,3 +234,58 @@
|
|
|
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
|
+
}
|
|
281
|
+
|
|
282
|
+
/* Link menu: the URL item shows one truncated line. */
|
|
283
|
+
.likhari-link-menu-url {
|
|
284
|
+
max-width: 100%;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
.likhari-link-menu-url .mantine-Menu-itemLabel {
|
|
288
|
+
overflow: hidden;
|
|
289
|
+
text-overflow: ellipsis;
|
|
290
|
+
white-space: nowrap;
|
|
291
|
+
}
|
|
@@ -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"}
|
|
@@ -0,0 +1,25 @@
|
|
|
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
|
+
export type PastedSegment = {
|
|
10
|
+
type: 'text';
|
|
11
|
+
text: string;
|
|
12
|
+
} | {
|
|
13
|
+
type: 'link';
|
|
14
|
+
text: string;
|
|
15
|
+
url: string;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Splits pasted plain text into text and link segments. Only explicit URLs
|
|
19
|
+
* count (http(s)://, mailto:, or a www. prefix) — a bare "file.txt" or
|
|
20
|
+
* "example.com" is left as text, since pasted prose is full of those. Trailing
|
|
21
|
+
* sentence punctuation stays outside the link, and anything normalizeLinkUrl
|
|
22
|
+
* rejects stays text.
|
|
23
|
+
*/
|
|
24
|
+
export declare function splitPastedText(text: string): PastedSegment[];
|
|
25
|
+
//# 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;AAED,MAAM,MAAM,aAAa,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAAC;AAKzG;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,aAAa,EAAE,CA0B7D"}
|
|
@@ -0,0 +1,62 @@
|
|
|
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
|
+
}
|
|
24
|
+
const PASTED_URL_PATTERN = /(?:https?:\/\/|mailto:|www\.)[^\s<>"']+/gi;
|
|
25
|
+
const TRAILING_PUNCTUATION = /[.,;:!?)\]}]+$/;
|
|
26
|
+
/**
|
|
27
|
+
* Splits pasted plain text into text and link segments. Only explicit URLs
|
|
28
|
+
* count (http(s)://, mailto:, or a www. prefix) — a bare "file.txt" or
|
|
29
|
+
* "example.com" is left as text, since pasted prose is full of those. Trailing
|
|
30
|
+
* sentence punctuation stays outside the link, and anything normalizeLinkUrl
|
|
31
|
+
* rejects stays text.
|
|
32
|
+
*/
|
|
33
|
+
export function splitPastedText(text) {
|
|
34
|
+
const segments = [];
|
|
35
|
+
let last = 0;
|
|
36
|
+
const pushText = (value) => {
|
|
37
|
+
if (!value)
|
|
38
|
+
return;
|
|
39
|
+
const previous = segments[segments.length - 1];
|
|
40
|
+
if (previous?.type === 'text')
|
|
41
|
+
previous.text += value;
|
|
42
|
+
else
|
|
43
|
+
segments.push({ type: 'text', text: value });
|
|
44
|
+
};
|
|
45
|
+
for (const match of text.matchAll(PASTED_URL_PATTERN)) {
|
|
46
|
+
const start = match.index ?? 0;
|
|
47
|
+
const raw = match[0];
|
|
48
|
+
const trimmed = raw.replace(TRAILING_PUNCTUATION, '');
|
|
49
|
+
const url = normalizeLinkUrl(/^www\./i.test(trimmed) ? `https://${trimmed}` : trimmed);
|
|
50
|
+
pushText(text.slice(last, start));
|
|
51
|
+
if (url && trimmed.length > 'www.'.length) {
|
|
52
|
+
segments.push({ type: 'link', text: trimmed, url });
|
|
53
|
+
pushText(raw.slice(trimmed.length));
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
pushText(raw);
|
|
57
|
+
}
|
|
58
|
+
last = start + raw.length;
|
|
59
|
+
}
|
|
60
|
+
pushText(text.slice(last));
|
|
61
|
+
return segments;
|
|
62
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"style.d.ts","sourceRoot":"","sources":["../../src/utils/style.ts"],"names":[],"mappings":"AAAA;yDACyD;AACzD,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAYrF"}
|