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

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.
@@ -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;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"}
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;AAOzH,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,uGAsHrB,CAAC"}
@@ -14,6 +14,7 @@ import { EDITOR_NODES } from './nodes.js';
14
14
  import { editorTheme } from './theme/editorTheme.js';
15
15
  import { EditorThemeProvider } from './theme/EditorThemeProvider.js';
16
16
  import { Toolbar } from './components/Toolbar.js';
17
+ import { LinkPastePlugin } from './plugins/LinkPastePlugin.js';
17
18
  function initialEditorStateJson(initialContent) {
18
19
  if (!initialContent)
19
20
  return undefined;
@@ -84,5 +85,5 @@ export const EditorRoot = forwardRef(function EditorRoot({ documentId, initialCo
84
85
  onSave?.(content, format);
85
86
  };
86
87
  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 })] }) }) }));
88
+ 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, {}), config.links && _jsx(LinkPastePlugin, {}), _jsx(OnChangePlugin, { onChange: handleChange })] }) }) }));
88
89
  });
@@ -1 +1 @@
1
- {"version":3,"file":"Toolbar.d.ts","sourceRoot":"","sources":["../../src/components/Toolbar.tsx"],"names":[],"mappings":"AAwCA,OAAO,KAAK,EAAE,2BAA2B,EAAE,MAAM,2BAA2B,CAAC;AAoL7E,MAAM,WAAW,YAAY;IAC3B,MAAM,EAAE,2BAA2B,CAAC;IACpC,MAAM,CAAC,EAAE,MAAM,IAAI,CAAC;IACpB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED,wBAAgB,OAAO,CAAC,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,QAAQ,EAAE,EAAE,YAAY,+BAqgB1E"}
1
+ {"version":3,"file":"Toolbar.d.ts","sourceRoot":"","sources":["../../src/components/Toolbar.tsx"],"names":[],"mappings":"AAyCA,OAAO,KAAK,EAAE,2BAA2B,EAAE,MAAM,2BAA2B,CAAC;AAiO7E,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,+BAqiB1E"}
@@ -1,8 +1,8 @@
1
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
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, $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
+ import { $getSelection, $isRangeSelection, $isElementNode, $setSelection, $getNearestNodeFromDOMNode, $createTextNode, KEY_MODIFIER_COMMAND, COMMAND_PRIORITY_NORMAL, FORMAT_TEXT_COMMAND, FORMAT_ELEMENT_COMMAND, INDENT_CONTENT_COMMAND, OUTDENT_CONTENT_COMMAND, UNDO_COMMAND, REDO_COMMAND, SELECTION_CHANGE_COMMAND, CAN_UNDO_COMMAND, CAN_REDO_COMMAND, COMMAND_PRIORITY_LOW, } from 'lexical';
6
6
  import { $setBlocksType } from '@lexical/selection';
7
7
  import { $createParagraphNode } from 'lexical';
8
8
  import { $createHeadingNode, $createQuoteNode, $isHeadingNode, $isQuoteNode } from '@lexical/rich-text';
@@ -54,6 +54,15 @@ function OverflowItem({ icon: Icon, label, active, onClick, }) {
54
54
  // Keep the editor's selection: a mousedown here would blur the canvas.
55
55
  onMouseDown: (e) => e.preventDefault(), onClick: onClick, children: label }));
56
56
  }
57
+ /** The link menu's contents — shared by the toolbar button and the right-click
58
+ * menu: the URL (opens in a new tab), Edit link, Remove link. */
59
+ function LinkMenuItems({ url, onEdit, onRemove }) {
60
+ // Only offer the link as clickable if it's a URL the editor would have accepted.
61
+ const safeUrl = normalizeLinkUrl(url);
62
+ return (_jsxs(_Fragment, { children: [_jsx(Menu.Label, { children: "Link" }), safeUrl ? (_jsx(Menu.Item, { component: "a", href: safeUrl, target: "_blank", rel: "noopener noreferrer", leftSection: _jsx(IconExternalLink, { size: ICON_SIZE, stroke: ICON_STROKE }), title: url, className: "likhari-link-menu-url", children: url })) : (
63
+ // Not a URL the editor would accept (e.g. loaded from a document): show it, don't make it clickable.
64
+ _jsx(Menu.Item, { disabled: true, title: url, className: "likhari-link-menu-url", children: url || '(no URL)' })), _jsx(Menu.Item, { leftSection: _jsx(IconPencil, { size: ICON_SIZE, stroke: ICON_STROKE }), onMouseDown: (e) => e.preventDefault(), onClick: onEdit, children: "Edit link" }), _jsx(Menu.Item, { color: "red", leftSection: _jsx(IconLinkOff, { size: ICON_SIZE, stroke: ICON_STROKE }), onMouseDown: (e) => e.preventDefault(), onClick: onRemove, children: "Remove link" })] }));
65
+ }
57
66
  export function Toolbar({ config, onSave, isDirty, showSave }) {
58
67
  const [editor] = useLexicalComposerContext();
59
68
  const [state, setState] = useState(INITIAL_STATE);
@@ -165,6 +174,8 @@ export function Toolbar({ config, onSave, isDirty, showSave }) {
165
174
  menuSelectionRef.current = selection ? selection.clone() : null;
166
175
  });
167
176
  };
177
+ const snapshotSelectionRef = useRef(snapshotSelection);
178
+ snapshotSelectionRef.current = snapshotSelection;
168
179
  const runOverflowAction = (action) => () => {
169
180
  const saved = menuSelectionRef.current;
170
181
  if (saved)
@@ -217,6 +228,36 @@ export function Toolbar({ config, onSave, isDirty, showSave }) {
217
228
  editor.dispatchCommand(TOGGLE_LINK_COMMAND, null);
218
229
  closeLinkDialog();
219
230
  };
231
+ // Right-clicking a link in the text opens the same menu, at the pointer.
232
+ const [linkContext, setLinkContext] = useState(null);
233
+ useEffect(() => {
234
+ if (!config.links)
235
+ return;
236
+ const onContextMenu = (event) => {
237
+ const anchor = event.target?.closest?.('a');
238
+ if (!anchor)
239
+ return;
240
+ let url = null;
241
+ // Select the whole link first, so Edit / Remove act on it wherever the caret was.
242
+ editor.update(() => {
243
+ const node = $getNearestNodeFromDOMNode(anchor);
244
+ const link = node ? $findMatchingParent(node, $isLinkNode) : null;
245
+ if (!link || !$isLinkNode(link))
246
+ return;
247
+ url = link.getURL();
248
+ link.select(0, link.getChildrenSize());
249
+ }, { discrete: true });
250
+ if (url === null)
251
+ return;
252
+ event.preventDefault();
253
+ snapshotSelectionRef.current();
254
+ setLinkContext({ x: event.clientX, y: event.clientY, url });
255
+ };
256
+ return editor.registerRootListener((root, previous) => {
257
+ previous?.removeEventListener('contextmenu', onContextMenu);
258
+ root?.addEventListener('contextmenu', onContextMenu);
259
+ });
260
+ }, [editor, config.links]);
220
261
  // Ctrl/Cmd+K opens the link dialog.
221
262
  useEffect(() => {
222
263
  if (!config.links)
@@ -280,8 +321,6 @@ export function Toolbar({ config, onSave, isDirty, showSave }) {
280
321
  const showStubInsertGroup = config.images.linked || config.images.embedded || config.poetry.enabled;
281
322
  const showLanguageGroup = config.language.autocorrect || config.language.textCleanup || config.language.spellCheck;
282
323
  const showOverflowMenu = fmt.strikethrough || fmt.superscript || fmt.subscript || fmt.caseTransforms || fmt.clearFormatting || config.indent;
283
- // Only offer the link as clickable if it's a URL the editor would have accepted.
284
- const safeLinkUrl = normalizeLinkUrl(state.linkUrl);
285
324
  const formattingOptions = [
286
325
  { value: 'paragraph', label: 'Paragraph' },
287
326
  ...headingLevels.map((level) => ({ value: `h${level}`, label: `Heading ${level}` })),
@@ -302,10 +341,14 @@ export function Toolbar({ config, onSave, isDirty, showSave }) {
302
341
  const AlignIcon = ALIGN_ICONS[state.elementFormat] ?? IconAlignLeft;
303
342
  return (_jsxs("div", { className: "likhari-toolbar", role: "toolbar", "aria-label": "Formatting", children: [showSave && (_jsx("div", { className: "likhari-toolbar-group", children: _jsx(ToolbarButton, { icon: IconDeviceFloppy, title: "Save", dirty: Boolean(isDirty), onClick: onSave }) })), config.history && (_jsxs("div", { className: "likhari-toolbar-group", children: [_jsx(ToolbarButton, { icon: IconArrowBackUp, title: "Undo", disabled: !state.canUndo, onClick: () => editor.dispatchCommand(UNDO_COMMAND, undefined) }), _jsx(ToolbarButton, { icon: IconArrowForwardUp, title: "Redo", disabled: !state.canRedo, onClick: () => editor.dispatchCommand(REDO_COMMAND, undefined) })] })), showFormattingGroup && (_jsx("div", { className: "likhari-toolbar-group", children: _jsx(ToolbarSelect, { icon: IconPilcrow, label: "Formatting", width: 148, value: formattingValue, data: formattingOptions, onChange: withRefocus((v) => applyFormatting(v)) }) })), showInlineGroup && (_jsxs("div", { className: "likhari-toolbar-group", children: [fmt.bold && (_jsx(ToolbarButton, { icon: IconBold, title: "Bold", active: state.activeFormats.has('bold'), onClick: () => formatText('bold') })), fmt.italic && (_jsx(ToolbarButton, { icon: IconItalic, title: "Italic", active: state.activeFormats.has('italic'), onClick: () => formatText('italic') })), fmt.underline && (_jsx(ToolbarButton, { icon: IconUnderline, title: "Underline", active: state.activeFormats.has('underline'), onClick: () => formatText('underline') }))] })), (config.font.family || config.font.size) && (_jsxs("div", { className: "likhari-toolbar-group likhari-toolbar-group--collapse-tablet", children: [config.font.family && (_jsx(ToolbarSelect, { icon: IconTypography, label: "Font family", width: 110, value: null, placeholder: "Font", data: [], disabled: true })), config.font.size && (_jsx(ToolbarSelect, { icon: IconTextSize, label: "Font size", width: 90, value: null, placeholder: "Size", data: [], disabled: true }))] })), showAlignGroup && (_jsx("div", { className: "likhari-toolbar-group", children: _jsx(ToolbarSelect, { icon: AlignIcon, label: "Alignment", width: 138, value: state.elementFormat || 'start', data: alignOptions, onChange: withRefocus((v) => formatElement(v)) }) })), config.links && (_jsx("div", { className: "likhari-toolbar-group", children: state.isLink ? (
304
343
  // Caret is in a link: the button opens a menu to see, edit or remove it.
305
- _jsxs(Menu, { position: "bottom-start", withinPortal: true, shadow: "sm", width: 240, onOpen: snapshotSelection, children: [_jsx(Menu.Target, { children: _jsx("button", { type: "button", className: "likhari-toolbar-button", "data-active": "true", "aria-pressed": "true", "aria-haspopup": "menu", "aria-label": "Link options", title: "Link options", onMouseDown: (e) => e.preventDefault(), children: _jsx(IconLink, { size: ICON_SIZE, stroke: ICON_STROKE }) }) }), _jsxs(Menu.Dropdown, { children: [_jsx(Menu.Label, { children: "Link" }), safeLinkUrl ? (_jsx(Menu.Item, { component: "a", href: safeLinkUrl, target: "_blank", rel: "noopener noreferrer", leftSection: _jsx(IconExternalLink, { size: ICON_SIZE, stroke: ICON_STROKE }), title: state.linkUrl, className: "likhari-link-menu-url", children: state.linkUrl })) : (
306
- // Not a URL the editor would accept (e.g. loaded from a document): show it, don't make it clickable.
307
- _jsx(Menu.Item, { disabled: true, title: state.linkUrl, className: "likhari-link-menu-url", children: state.linkUrl || '(no URL)' })), _jsx(Menu.Item, { leftSection: _jsx(IconPencil, { size: ICON_SIZE, stroke: ICON_STROKE }), onMouseDown: (e) => e.preventDefault(), onClick: () => {
308
- restoreSelection();
309
- openLinkDialog();
310
- }, children: "Edit link" }), _jsx(Menu.Item, { color: "red", leftSection: _jsx(IconLinkOff, { size: ICON_SIZE, stroke: ICON_STROKE }), onMouseDown: (e) => e.preventDefault(), onClick: removeLink, children: "Remove link" })] })] })) : (_jsx(ToolbarButton, { icon: IconLink, title: "Insert link (Ctrl+K)", onClick: openLinkDialog })) })), showStubInsertGroup && (_jsxs("div", { className: "likhari-toolbar-group likhari-toolbar-group--collapse-tablet", children: [(config.images.linked || config.images.embedded) && _jsx(StubButton, { icon: IconPhoto, title: "Insert image" }), config.poetry.enabled && _jsx(StubButton, { icon: IconFeather, title: "Poetry blocks" })] })), showLanguageGroup && (_jsxs("div", { className: "likhari-toolbar-group likhari-toolbar-group--collapse-tablet", children: [config.language.autocorrect && _jsx(StubButton, { icon: IconWand, title: "Auto-correct" }), config.language.textCleanup && _jsx(StubButton, { icon: IconSparkles, title: "Text cleanup" }), config.language.spellCheck && _jsx(StubButton, { icon: IconAbc, title: "Spell-checker" })] })), showOverflowMenu && (_jsxs(Menu, { position: "bottom-end", withinPortal: true, shadow: "sm", width: 210, closeOnItemClick: true, onOpen: snapshotSelection, children: [_jsx(Menu.Target, { children: _jsx("button", { type: "button", className: "likhari-toolbar-button", "aria-label": "More formatting", title: "More formatting", onMouseDown: (e) => e.preventDefault(), children: _jsx(IconDots, { size: ICON_SIZE, stroke: ICON_STROKE }) }) }), _jsxs(Menu.Dropdown, { children: [fmt.strikethrough && (_jsx(OverflowItem, { icon: IconStrikethrough, label: "Strikethrough", active: state.activeFormats.has('strikethrough'), onClick: runOverflowAction(() => formatText('strikethrough')) })), fmt.superscript && (_jsx(OverflowItem, { icon: IconSuperscript, label: "Superscript", active: state.activeFormats.has('superscript'), onClick: runOverflowAction(() => formatText('superscript')) })), fmt.subscript && (_jsx(OverflowItem, { icon: IconSubscript, label: "Subscript", active: state.activeFormats.has('subscript'), onClick: runOverflowAction(() => formatText('subscript')) })), fmt.caseTransforms && (_jsxs(_Fragment, { children: [_jsx(OverflowItem, { icon: IconLetterCaseUpper, label: "UPPERCASE", onClick: runOverflowAction(() => applyCaseTransform('upper')) }), _jsx(OverflowItem, { icon: IconLetterCaseLower, label: "lowercase", onClick: runOverflowAction(() => applyCaseTransform('lower')) }), _jsx(OverflowItem, { icon: IconLetterCase, label: "Capitalize", onClick: runOverflowAction(() => applyCaseTransform('capitalize')) })] })), fmt.clearFormatting && _jsx(OverflowItem, { icon: IconClearFormatting, label: "Clear formatting", onClick: runOverflowAction(clearFormatting) }), config.indent && (_jsxs(_Fragment, { children: [_jsx(OverflowItem, { icon: IconIndentDecrease, label: "Outdent", onClick: runOverflowAction(() => editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined)) }), _jsx(OverflowItem, { icon: IconIndentIncrease, label: "Indent", onClick: runOverflowAction(() => editor.dispatchCommand(INDENT_CONTENT_COMMAND, undefined)) })] }))] })] })), config.links && (_jsx(LinkDialog, { opened: linkDialogOpen, initialUrl: state.linkUrl, showTextField: linkNeedsText, onSubmit: applyLink, onRemove: state.isLink ? removeLink : undefined, onClose: closeLinkDialog }))] }));
344
+ _jsxs(Menu, { position: "bottom-start", withinPortal: true, shadow: "sm", width: 240, onOpen: snapshotSelection, children: [_jsx(Menu.Target, { children: _jsx("button", { type: "button", className: "likhari-toolbar-button", "data-active": "true", "aria-pressed": "true", "aria-haspopup": "menu", "aria-label": "Link options", title: "Link options", onMouseDown: (e) => e.preventDefault(), children: _jsx(IconLink, { size: ICON_SIZE, stroke: ICON_STROKE }) }) }), _jsx(Menu.Dropdown, { children: _jsx(LinkMenuItems, { url: state.linkUrl, onEdit: () => {
345
+ restoreSelection();
346
+ openLinkDialog();
347
+ }, onRemove: removeLink }) })] })) : (_jsx(ToolbarButton, { icon: IconLink, title: "Insert link (Ctrl+K)", onClick: openLinkDialog })) })), config.links && (_jsxs(Menu, { opened: linkContext !== null, onChange: (opened) => {
348
+ if (!opened)
349
+ setLinkContext(null);
350
+ }, position: "bottom-start", withinPortal: true, shadow: "sm", width: 240, children: [_jsx(Menu.Target, { children: _jsx("span", { "aria-hidden": "true", style: { position: 'fixed', left: linkContext?.x ?? -9999, top: linkContext?.y ?? -9999, width: 1, height: 1, pointerEvents: 'none' } }) }), _jsx(Menu.Dropdown, { children: _jsx(LinkMenuItems, { url: linkContext?.url ?? '', onEdit: () => {
351
+ restoreSelection();
352
+ openLinkDialog();
353
+ }, onRemove: removeLink }) })] })), showStubInsertGroup && (_jsxs("div", { className: "likhari-toolbar-group likhari-toolbar-group--collapse-tablet", children: [(config.images.linked || config.images.embedded) && _jsx(StubButton, { icon: IconPhoto, title: "Insert image" }), config.poetry.enabled && _jsx(StubButton, { icon: IconFeather, title: "Poetry blocks" })] })), showLanguageGroup && (_jsxs("div", { className: "likhari-toolbar-group likhari-toolbar-group--collapse-tablet", children: [config.language.autocorrect && _jsx(StubButton, { icon: IconWand, title: "Auto-correct" }), config.language.textCleanup && _jsx(StubButton, { icon: IconSparkles, title: "Text cleanup" }), config.language.spellCheck && _jsx(StubButton, { icon: IconAbc, title: "Spell-checker" })] })), showOverflowMenu && (_jsxs(Menu, { position: "bottom-end", withinPortal: true, shadow: "sm", width: 210, closeOnItemClick: true, onOpen: snapshotSelection, children: [_jsx(Menu.Target, { children: _jsx("button", { type: "button", className: "likhari-toolbar-button", "aria-label": "More formatting", title: "More formatting", onMouseDown: (e) => e.preventDefault(), children: _jsx(IconDots, { size: ICON_SIZE, stroke: ICON_STROKE }) }) }), _jsxs(Menu.Dropdown, { children: [fmt.strikethrough && (_jsx(OverflowItem, { icon: IconStrikethrough, label: "Strikethrough", active: state.activeFormats.has('strikethrough'), onClick: runOverflowAction(() => formatText('strikethrough')) })), fmt.superscript && (_jsx(OverflowItem, { icon: IconSuperscript, label: "Superscript", active: state.activeFormats.has('superscript'), onClick: runOverflowAction(() => formatText('superscript')) })), fmt.subscript && (_jsx(OverflowItem, { icon: IconSubscript, label: "Subscript", active: state.activeFormats.has('subscript'), onClick: runOverflowAction(() => formatText('subscript')) })), fmt.caseTransforms && (_jsxs(_Fragment, { children: [_jsx(OverflowItem, { icon: IconLetterCaseUpper, label: "UPPERCASE", onClick: runOverflowAction(() => applyCaseTransform('upper')) }), _jsx(OverflowItem, { icon: IconLetterCaseLower, label: "lowercase", onClick: runOverflowAction(() => applyCaseTransform('lower')) }), _jsx(OverflowItem, { icon: IconLetterCase, label: "Capitalize", onClick: runOverflowAction(() => applyCaseTransform('capitalize')) })] })), fmt.clearFormatting && _jsx(OverflowItem, { icon: IconClearFormatting, label: "Clear formatting", onClick: runOverflowAction(clearFormatting) }), config.indent && (_jsxs(_Fragment, { children: [_jsx(OverflowItem, { icon: IconIndentDecrease, label: "Outdent", onClick: runOverflowAction(() => editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined)) }), _jsx(OverflowItem, { icon: IconIndentIncrease, label: "Indent", onClick: runOverflowAction(() => editor.dispatchCommand(INDENT_CONTENT_COMMAND, undefined)) })] }))] })] })), config.links && (_jsx(LinkDialog, { opened: linkDialogOpen, initialUrl: state.linkUrl, showTextField: linkNeedsText, onSubmit: applyLink, onRemove: state.isLink ? removeLink : undefined, onClose: closeLinkDialog }))] }));
311
354
  }
@@ -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
+ }
@@ -6,4 +6,20 @@
6
6
  * domain like "example.com/page" gets https:// prefixed.
7
7
  */
8
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[];
9
25
  //# sourceMappingURL=linkUrl.d.ts.map
@@ -1 +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"}
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"}
@@ -21,3 +21,42 @@ export function normalizeLinkUrl(input) {
21
21
  return `https://${url}`;
22
22
  return null;
23
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inshapardaz/likhari-react",
3
- "version": "0.1.4-dev.33",
3
+ "version": "0.1.4-dev.35",
4
4
  "description": "React implementation of the Likhari editor, built on @lexical/react.",
5
5
  "license": "MIT",
6
6
  "repository": {