diff --git a/packages/super-editor/src/components/context-menu/menuItems.js b/packages/super-editor/src/components/context-menu/menuItems.js
index e935e9c0cf..99aabd89dd 100644
--- a/packages/super-editor/src/components/context-menu/menuItems.js
+++ b/packages/super-editor/src/components/context-menu/menuItems.js
@@ -328,7 +328,7 @@ export function getItems(context, customItems = [], includeDefaultItems = true)
view.dispatch(tr.setSelection(SelectionType.create(doc, safeFrom, safeTo)));
}
}
- const handled = html ? handleClipboardPaste({ editor, view }, html) : false;
+ const handled = handleClipboardPaste({ editor, view }, html, text);
if (!handled) {
const pasteEvent = createPasteEventShim({ html, text });
diff --git a/packages/super-editor/src/components/context-menu/tests/menuItems.test.js b/packages/super-editor/src/components/context-menu/tests/menuItems.test.js
index b7dd341ad6..b27cd5ed41 100644
--- a/packages/super-editor/src/components/context-menu/tests/menuItems.test.js
+++ b/packages/super-editor/src/components/context-menu/tests/menuItems.test.js
@@ -514,6 +514,7 @@ describe('menuItems.js', () => {
expect(clipboardMocks.handleClipboardPaste).toHaveBeenCalledWith(
{ editor: mockEditor, view: mockEditor.view },
'
word html
',
+ 'word html',
);
expect(mockEditor.view.pasteHTML).toHaveBeenCalledWith('word html
', expect.any(Object));
expect(insertContent).not.toHaveBeenCalled();
diff --git a/packages/super-editor/src/core/InputRule.d.ts b/packages/super-editor/src/core/InputRule.d.ts
index 9685da878b..0264a727df 100644
--- a/packages/super-editor/src/core/InputRule.d.ts
+++ b/packages/super-editor/src/core/InputRule.d.ts
@@ -91,4 +91,8 @@ export function sanitizeHtml(html: string, forbiddenTags?: string[], domDocument
/**
* Handle clipboard paste events
*/
-export function handleClipboardPaste(params: { editor: Editor; view: EditorView }, html: string): boolean;
+export function handleClipboardPaste(
+ params: { editor: Editor; view: EditorView },
+ html: string,
+ plainText?: string,
+): boolean;
diff --git a/packages/super-editor/src/core/InputRule.js b/packages/super-editor/src/core/InputRule.js
index 258f1f393d..aef6598393 100644
--- a/packages/super-editor/src/core/InputRule.js
+++ b/packages/super-editor/src/core/InputRule.js
@@ -9,6 +9,12 @@ import { isRegExp } from './utilities/isRegExp.js';
import { handleDocxPaste, wrapTextsInRuns } from './inputRules/docx-paste/docx-paste.js';
import { flattenListsInHtml } from './inputRules/html/html-helpers.js';
import { handleGoogleDocsHtml } from './inputRules/google-docs-paste/google-docs-paste.js';
+import {
+ detectPasteUrl,
+ handlePlainTextUrlPaste,
+ normalizePastedLinks,
+ resolveLinkProtocols,
+} from './inputRules/paste-link-normalizer.js';
export class InputRule {
match;
@@ -221,6 +227,7 @@ export const inputRulesPlugin = ({ editor, rules }) => {
handlePaste(view, event, slice) {
const clipboard = event.clipboardData;
const html = clipboard.getData('text/html');
+ const plainText = clipboard.getData('text/plain');
// Allow specialised plugins (e.g., field-annotation) first shot.
const fieldAnnotationContent = slice.content.content.filter((item) => item.type.name === 'fieldAnnotation');
@@ -228,7 +235,7 @@ export const inputRulesPlugin = ({ editor, rules }) => {
return false;
}
- const result = handleClipboardPaste({ editor, view }, html);
+ const result = handleClipboardPaste({ editor, view }, html, plainText);
return result;
},
},
@@ -367,6 +374,7 @@ export function handleHtmlPaste(html, editor, source) {
// Extract the contents of the paragraph and paste only those
const paragraphContent = doc.firstChild.content;
const tr = state.tr.replaceSelectionWith(paragraphContent, false);
+ normalizePastedLinks(tr, editor);
dispatch(tr);
} else if (isInParagraph) {
// For multi-paragraph paste, use replaceSelection with a proper Slice
@@ -375,10 +383,13 @@ export function handleHtmlPaste(html, editor, source) {
const slice = new Slice(doc.content, 0, 0);
const tr = state.tr.replaceSelection(slice);
+ normalizePastedLinks(tr, editor);
dispatch(tr);
} else {
// Use the original behavior for other cases
- dispatch(state.tr.replaceSelectionWith(doc, true));
+ const tr = state.tr.replaceSelectionWith(doc, true);
+ normalizePastedLinks(tr, editor);
+ dispatch(tr);
}
return true;
@@ -483,9 +494,10 @@ export function sanitizeHtml(html, forbiddenTags = ['meta', 'svg', 'script', 'st
* @param {Editor} params.editor The SuperEditor instance.
* @param {View} params.view The ProseMirror view associated with the editor.
* @param {String} html HTML clipboard content (may be empty).
+ * @param {String} [plainText] Plain-text clipboard content (may be empty).
* @returns {Boolean} Whether the paste was handled.
*/
-export function handleClipboardPaste({ editor, view }, html) {
+export function handleClipboardPaste({ editor, view }, html, plainText) {
let source;
if (!html) {
@@ -499,10 +511,12 @@ export function handleClipboardPaste({ editor, view }, html) {
}
switch (source) {
- case 'plain-text':
- // Let native/plain text paste fall through so ProseMirror handles it.
- // Will hit the Fallback when boolean is returned false
- return false;
+ case 'plain-text': {
+ const protocols = resolveLinkProtocols(editor);
+ const detected = detectPasteUrl(plainText, protocols);
+ if (!detected) return false;
+ return handlePlainTextUrlPaste(editor, view, plainText, detected);
+ }
case 'word-html':
if (editor.options.mode === 'docx') {
return handleDocxPaste(html, editor, view);
diff --git a/packages/super-editor/src/core/inputRules/docx-paste/docx-paste.js b/packages/super-editor/src/core/inputRules/docx-paste/docx-paste.js
index c73ab4f933..8ad595ad5c 100644
--- a/packages/super-editor/src/core/inputRules/docx-paste/docx-paste.js
+++ b/packages/super-editor/src/core/inputRules/docx-paste/docx-paste.js
@@ -1,5 +1,6 @@
import { DOMParser, Fragment } from 'prosemirror-model';
import { cleanHtmlUnnecessaryTags, convertEmToPt, handleHtmlPaste } from '../../InputRule.js';
+import { normalizePastedLinks } from '../paste-link-normalizer.js';
import { ListHelpers } from '@helpers/list-numbering-helpers.js';
import {
extractListLevelStyles,
@@ -188,7 +189,9 @@ export const handleDocxPaste = (html, editor, view) => {
const { dispatch } = editor.view;
if (!dispatch) return false;
- dispatch(view.state.tr.replaceSelectionWith(doc, true));
+ const tr = view.state.tr.replaceSelectionWith(doc, true);
+ normalizePastedLinks(tr, editor);
+ dispatch(tr);
return true;
};
diff --git a/packages/super-editor/src/core/inputRules/google-docs-paste/google-docs-paste.js b/packages/super-editor/src/core/inputRules/google-docs-paste/google-docs-paste.js
index c05b5cbac3..17756c49b7 100644
--- a/packages/super-editor/src/core/inputRules/google-docs-paste/google-docs-paste.js
+++ b/packages/super-editor/src/core/inputRules/google-docs-paste/google-docs-paste.js
@@ -1,5 +1,6 @@
import { DOMParser } from 'prosemirror-model';
import { convertEmToPt, sanitizeHtml } from '../../InputRule.js';
+import { normalizePastedLinks } from '../paste-link-normalizer.js';
import { ListHelpers } from '../../helpers/list-numbering-helpers.js';
import { createSingleItemList } from '../html/html-helpers.js';
import { getLvlTextForGoogleList, googleNumDefMap } from '../../helpers/pasteListHelpers.js';
@@ -32,7 +33,9 @@ export const handleGoogleDocsHtml = (html, editor, view) => {
const { dispatch } = editor.view;
if (!dispatch) return false;
- dispatch(view.state.tr.replaceSelectionWith(doc, true));
+ const tr = view.state.tr.replaceSelectionWith(doc, true);
+ normalizePastedLinks(tr, editor);
+ dispatch(tr);
return true;
};
diff --git a/packages/super-editor/src/core/inputRules/paste-link-normalizer.js b/packages/super-editor/src/core/inputRules/paste-link-normalizer.js
new file mode 100644
index 0000000000..a0dac4f2dc
--- /dev/null
+++ b/packages/super-editor/src/core/inputRules/paste-link-normalizer.js
@@ -0,0 +1,320 @@
+import { TextSelection } from 'prosemirror-state';
+import { sanitizeHref, UrlValidationConstants } from '@superdoc/url-validation';
+import {
+ findRelationshipIdFromTarget,
+ insertNewRelationship,
+} from '@core/super-converter/docx-helpers/document-rels.js';
+import { mergeRanges } from '../../utils/rangeUtils.js';
+
+/**
+ * Prepend `https://` to bare `www.` URLs so they pass protocol validation.
+ *
+ * This is user-intent normalization — `sanitizeHref` correctly rejects bare
+ * `www.` as a security boundary, so we add the protocol before calling it.
+ *
+ * @param {string} text
+ * @returns {string}
+ */
+export function maybeAddProtocol(text) {
+ return /^www\./i.test(text) ? `https://${text}` : text;
+}
+
+/**
+ * Detect whether a pasted plain-text string is a single URL.
+ *
+ * Rejects strings with internal whitespace (not a bare URL).
+ * Handles `www.` inputs by prepending `https://`.
+ *
+ * @param {string} text Raw clipboard text
+ * @param {string[]} protocols Extra protocols from link extension config
+ * @returns {{ href: string } | null}
+ */
+export function detectPasteUrl(text, protocols = []) {
+ const trimmed = text?.trim();
+ if (!trimmed) return null;
+
+ // A bare URL has no internal whitespace
+ if (/\s/.test(trimmed)) return null;
+
+ const withProtocol = maybeAddProtocol(trimmed);
+ const allowedProtocols = buildAllowedProtocols(protocols);
+ const result = sanitizeHref(withProtocol, { allowedProtocols });
+
+ return result ? { href: result.href } : null;
+}
+
+/**
+ * Whether the editor context allows writing to `word/_rels/document.xml.rels`.
+ *
+ * Child editors and header/footer editors need part-local rels that the export
+ * step creates — writing to the main rels from those contexts is wrong.
+ *
+ * @param {object} editor
+ * @returns {boolean}
+ */
+export function canAllocateRels(editor) {
+ return editor.options.mode === 'docx' && !editor.options.isChildEditor && !editor.options.isHeaderOrFooter;
+}
+
+/**
+ * Handle a plain-text paste that was detected as a URL.
+ *
+ * - Collapsed selection: inserts URL as text and applies link + underline marks.
+ * - Non-collapsed text selection: keeps selected text and applies link mark with URL as href.
+ * - Non-text selections (NodeSelection, CellSelection): not handled — return false.
+ *
+ * @param {object} editor SuperEditor instance
+ * @param {object} view ProseMirror EditorView
+ * @param {string} plainText The pasted text
+ * @param {{ href: string }} detected Result from `detectPasteUrl`
+ * @returns {boolean} Whether the paste was handled
+ */
+export function handlePlainTextUrlPaste(editor, view, plainText, detected) {
+ const { state } = view;
+ const { selection } = state;
+
+ // Only apply link-on-selection for text selections. NodeSelection,
+ // CellSelection, etc. should fall through to default paste handling.
+ if (!(selection instanceof TextSelection)) return false;
+
+ const linkMarkType = editor.schema.marks.link;
+ const underlineMarkType = editor.schema.marks.underline;
+
+ if (!linkMarkType) return false;
+
+ const rId = allocateRelationshipId(editor, detected.href);
+
+ let tr = state.tr;
+ let from = selection.from;
+ let to = selection.to;
+
+ const trimmedText = plainText.trim();
+
+ if (selection.empty) {
+ // Insert the URL as visible text
+ tr = tr.insertText(trimmedText, from);
+ to = from + trimmedText.length;
+ }
+ // Non-collapsed text selection: keep existing selected text, just apply marks below
+
+ tr = tr.addMark(from, to, linkMarkType.create({ href: detected.href, rId }));
+
+ if (underlineMarkType) {
+ tr = tr.addMark(from, to, underlineMarkType.create());
+ }
+
+ view.dispatch(tr.scrollIntoView());
+ return true;
+}
+
+/**
+ * Extract changed ranges from a single transaction's step maps.
+ *
+ * Unlike `collectChangedRanges` (which takes `Transaction[]` for
+ * `appendedTransaction` use), this works on a single in-progress transaction.
+ *
+ * @param {import('prosemirror-state').Transaction} tr
+ * @returns {{ from: number, to: number }[]}
+ */
+function getChangedRangesFromTransaction(tr) {
+ const maps = tr.mapping?.maps;
+ if (!maps?.length) return [];
+
+ const ranges = [];
+
+ maps.forEach((map) => {
+ map.forEach((oldStart, oldEnd, newStart, newEnd) => {
+ ranges.push({ from: newStart, to: newEnd });
+ });
+ });
+
+ return mergeRanges(ranges, tr.doc.content.size);
+}
+
+/**
+ * Normalize every link mark within the changed range of a paste transaction.
+ *
+ * - Strips untrusted pasted rIds
+ * - Validates and sanitizes hrefs (including `www.` → `https://www.`)
+ * - Removes link marks with no valid href and no anchor
+ * - Allocates fresh rIds when appropriate (main docx editor only)
+ *
+ * Resolves extra protocols from the link extension config automatically
+ * when `protocols` is not provided.
+ *
+ * Mutates `tr` in place. Call before dispatching.
+ *
+ * @param {import('prosemirror-state').Transaction} tr
+ * @param {object} editor SuperEditor instance
+ * @param {Array} [protocols] Extra allowed protocols (auto-resolved from editor if omitted)
+ */
+export function normalizePastedLinks(tr, editor, protocols) {
+ const changedRanges = getChangedRangesFromTransaction(tr);
+ if (!changedRanges.length) return;
+
+ const linkMarkType = editor.schema.marks.link;
+ if (!linkMarkType) return;
+
+ const resolvedProtocols = protocols ?? resolveLinkProtocols(editor);
+ const allowedProtocols = buildAllowedProtocols(resolvedProtocols);
+
+ for (const { from, to } of changedRanges) {
+ normalizeLinkMarksInRange(tr, editor, linkMarkType, from, to, allowedProtocols);
+ }
+}
+
+/**
+ * Resolve extra protocols from the link extension configuration.
+ *
+ * @param {object} editor
+ * @returns {Array}
+ */
+export function resolveLinkProtocols(editor) {
+ const linkExt = editor.extensionService?.extensions?.find((e) => e.name === 'link');
+ return linkExt?.options?.protocols ?? [];
+}
+
+// ---------------------------------------------------------------------------
+// Internal helpers
+// ---------------------------------------------------------------------------
+
+/**
+ * Walk a range and normalize each link mark found.
+ *
+ * @param {import('prosemirror-state').Transaction} tr
+ * @param {object} editor
+ * @param {object} linkMarkType
+ * @param {number} from
+ * @param {number} to
+ * @param {string[]} allowedProtocols
+ */
+function normalizeLinkMarksInRange(tr, editor, linkMarkType, from, to, allowedProtocols) {
+ /** @type {{ from: number, to: number, mark: object }[]} */
+ const linkSpans = [];
+
+ tr.doc.nodesBetween(from, to, (node, pos) => {
+ if (!node.isInline) return;
+
+ const linkMark = node.marks.find((m) => m.type === linkMarkType);
+ if (!linkMark) return;
+
+ linkSpans.push({
+ from: pos,
+ to: pos + node.nodeSize,
+ mark: linkMark,
+ });
+ });
+
+ // Process in reverse so position shifts don't invalidate earlier spans
+ for (let i = linkSpans.length - 1; i >= 0; i--) {
+ normalizeOneLinkMark(tr, editor, linkMarkType, linkSpans[i], allowedProtocols);
+ }
+}
+
+/**
+ * Normalize a single link mark span.
+ *
+ * @param {import('prosemirror-state').Transaction} tr
+ * @param {object} editor
+ * @param {object} linkMarkType
+ * @param {{ from: number, to: number, mark: object }} span
+ * @param {string[]} allowedProtocols
+ */
+function normalizeOneLinkMark(tr, editor, linkMarkType, span, allowedProtocols) {
+ const { from, to, mark } = span;
+ const attrs = { ...mark.attrs };
+
+ // Never trust pasted rIds — they reference a different document's rels
+ attrs.rId = null;
+
+ const rawHref = attrs.href;
+ const hasInternalRef = Boolean(attrs.anchor) || Boolean(attrs.name);
+
+ // Links with an internal reference (anchor or name) but no href need no
+ // href processing — just reapply the mark to strip the pasted rId.
+ if (!rawHref && hasInternalRef) {
+ tr.removeMark(from, to, linkMarkType);
+ tr.addMark(from, to, linkMarkType.create(attrs));
+ return;
+ }
+
+ // No href, no anchor, no name → meaningless link, remove it
+ if (!rawHref) {
+ tr.removeMark(from, to, linkMarkType);
+ return;
+ }
+
+ const withProtocol = maybeAddProtocol(rawHref);
+ const sanitized = sanitizeHref(withProtocol, { allowedProtocols });
+
+ if (!sanitized) {
+ // Invalid href → remove the link mark, text is preserved
+ tr.removeMark(from, to, linkMarkType);
+ return;
+ }
+
+ attrs.href = sanitized.href;
+
+ if (canAllocateRels(editor)) {
+ attrs.rId = allocateRelationshipId(editor, sanitized.href);
+ }
+
+ // Replace the old mark with the cleaned one
+ tr.removeMark(from, to, linkMarkType);
+ tr.addMark(from, to, linkMarkType.create(attrs));
+}
+
+/**
+ * Try to reuse an existing relationship or create a new one.
+ *
+ * @param {object} editor
+ * @param {string} href
+ * @returns {string | null}
+ */
+function allocateRelationshipId(editor, href) {
+ if (!canAllocateRels(editor)) return null;
+
+ try {
+ const existing = findRelationshipIdFromTarget(href, editor);
+ if (existing) return existing;
+ return insertNewRelationship(href, 'hyperlink', editor);
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Merge default allowed protocols with extras from the link extension config.
+ *
+ * Normalizes entries the same way the link extension does: accepts strings
+ * (any case) and `{ scheme: string }` objects, lowercases everything.
+ *
+ * @param {Array} extras
+ * @returns {string[]}
+ */
+function buildAllowedProtocols(extras = []) {
+ const normalized = normalizeProtocols(extras);
+ return Array.from(new Set([...UrlValidationConstants.DEFAULT_ALLOWED_PROTOCOLS, ...normalized]));
+}
+
+/**
+ * Convert protocol config entries into a flat array of lowercase strings.
+ *
+ * Mirrors the normalization in `link.js` so paste handles the same protocol
+ * formats that the link extension accepts.
+ *
+ * @param {Array} protocols
+ * @returns {string[]}
+ */
+function normalizeProtocols(protocols = []) {
+ const result = [];
+ for (const entry of protocols) {
+ if (!entry) continue;
+ if (typeof entry === 'string' && entry.trim()) {
+ result.push(entry.trim().toLowerCase());
+ } else if (typeof entry === 'object' && typeof entry.scheme === 'string' && entry.scheme.trim()) {
+ result.push(entry.scheme.trim().toLowerCase());
+ }
+ }
+ return result;
+}
diff --git a/packages/super-editor/src/core/inputRules/paste-link-normalizer.test.js b/packages/super-editor/src/core/inputRules/paste-link-normalizer.test.js
new file mode 100644
index 0000000000..62a0699c80
--- /dev/null
+++ b/packages/super-editor/src/core/inputRules/paste-link-normalizer.test.js
@@ -0,0 +1,546 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+/** Lightweight stand-in so `selection instanceof TextSelection` works in tests. */
+const { MockTextSelection } = vi.hoisted(() => {
+ class MockTextSelection {
+ constructor({ from = 0, to = 0 } = {}) {
+ this.from = from;
+ this.to = to;
+ this.empty = from === to;
+ }
+ }
+ return { MockTextSelection };
+});
+
+vi.mock('prosemirror-state', () => ({
+ TextSelection: MockTextSelection,
+}));
+
+vi.mock('@superdoc/url-validation', () => {
+ const DEFAULT_ALLOWED_PROTOCOLS = ['http', 'https', 'mailto', 'tel', 'sms'];
+
+ return {
+ UrlValidationConstants: { DEFAULT_ALLOWED_PROTOCOLS },
+ sanitizeHref: vi.fn((raw, config) => {
+ if (!raw || typeof raw !== 'string') return null;
+
+ const trimmed = raw.trim();
+ if (!trimmed) return null;
+
+ // Reject blocked protocols
+ if (/^javascript:/i.test(trimmed)) return null;
+
+ // Must have a known protocol
+ const match = trimmed.match(/^([a-z]+):/i);
+ if (!match) return null;
+
+ const protocol = match[1].toLowerCase();
+ const allowed = config?.allowedProtocols ?? DEFAULT_ALLOWED_PROTOCOLS;
+ if (!allowed.includes(protocol)) return null;
+
+ return { href: trimmed, protocol, isExternal: true };
+ }),
+ };
+});
+
+vi.mock('@core/super-converter/docx-helpers/document-rels.js', () => ({
+ findRelationshipIdFromTarget: vi.fn(),
+ insertNewRelationship: vi.fn(),
+}));
+
+vi.mock('../../utils/rangeUtils.js', () => ({
+ mergeRanges: vi.fn((ranges, _docSize) => {
+ if (!ranges.length) return [];
+ const sorted = [...ranges].sort((a, b) => a.from - b.from);
+ const merged = [];
+ for (const range of sorted) {
+ const last = merged[merged.length - 1];
+ if (last && range.from <= last.to) {
+ last.to = Math.max(last.to, range.to);
+ } else {
+ merged.push({ ...range });
+ }
+ }
+ return merged;
+ }),
+}));
+
+import { sanitizeHref } from '@superdoc/url-validation';
+import {
+ findRelationshipIdFromTarget,
+ insertNewRelationship,
+} from '@core/super-converter/docx-helpers/document-rels.js';
+import {
+ maybeAddProtocol,
+ detectPasteUrl,
+ canAllocateRels,
+ handlePlainTextUrlPaste,
+ normalizePastedLinks,
+} from './paste-link-normalizer.js';
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+/** Shared link mark type so editor and transaction mocks use the same reference */
+const LINK_MARK_TYPE = {
+ name: 'link',
+ create: (attrs) => ({ type: LINK_MARK_TYPE, attrs }),
+};
+
+function createMockEditor(overrides = {}) {
+ return {
+ options: {
+ mode: 'docx',
+ isChildEditor: false,
+ isHeaderOrFooter: false,
+ ...overrides,
+ },
+ schema: {
+ marks: {
+ link: LINK_MARK_TYPE,
+ underline: {
+ create: () => ({ type: { name: 'underline' } }),
+ },
+ },
+ },
+ converter: { convertedXml: {} },
+ };
+}
+
+function createMockView({ selectionFrom = 0, selectionTo = 0, isTextSelection = true } = {}) {
+ const dispatched = [];
+ const tr = createMockTransaction();
+
+ const selection = isTextSelection
+ ? new MockTextSelection({ from: selectionFrom, to: selectionTo })
+ : { from: selectionFrom, to: selectionTo, empty: selectionFrom === selectionTo };
+
+ return {
+ state: { selection, tr },
+ dispatch: (transaction) => dispatched.push(transaction),
+ _dispatched: dispatched,
+ };
+}
+
+function createMockTransaction() {
+ const tr = {
+ doc: { content: { size: 100 } },
+ mapping: {
+ maps: [],
+ },
+ insertText: vi.fn(function () {
+ return this;
+ }),
+ addMark: vi.fn(function () {
+ return this;
+ }),
+ removeMark: vi.fn(function () {
+ return this;
+ }),
+ scrollIntoView: vi.fn(function () {
+ return this;
+ }),
+ };
+
+ return tr;
+}
+
+// ---------------------------------------------------------------------------
+// maybeAddProtocol
+// ---------------------------------------------------------------------------
+
+describe('maybeAddProtocol', () => {
+ it('prepends https:// to www. URLs', () => {
+ expect(maybeAddProtocol('www.example.com')).toBe('https://www.example.com');
+ });
+
+ it('handles case-insensitive www. prefix', () => {
+ expect(maybeAddProtocol('WWW.Example.COM')).toBe('https://WWW.Example.COM');
+ });
+
+ it('does not modify URLs that already have a protocol', () => {
+ expect(maybeAddProtocol('https://example.com')).toBe('https://example.com');
+ });
+
+ it('does not modify non-URL text', () => {
+ expect(maybeAddProtocol('not a url')).toBe('not a url');
+ });
+
+ it('returns empty string unchanged', () => {
+ expect(maybeAddProtocol('')).toBe('');
+ });
+});
+
+// ---------------------------------------------------------------------------
+// detectPasteUrl
+// ---------------------------------------------------------------------------
+
+describe('detectPasteUrl', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('detects https URL', () => {
+ const result = detectPasteUrl('https://example.com');
+ expect(result).toEqual({ href: 'https://example.com' });
+ });
+
+ it('detects http URL with path and query', () => {
+ const result = detectPasteUrl('http://example.com/path?q=1');
+ expect(result).toEqual({ href: 'http://example.com/path?q=1' });
+ });
+
+ it('detects www. URL by prepending https://', () => {
+ const result = detectPasteUrl('www.example.com');
+ expect(result).toEqual({ href: 'https://www.example.com' });
+ });
+
+ it('detects mailto links', () => {
+ const result = detectPasteUrl('mailto:user@example.com');
+ expect(result).toEqual({ href: 'mailto:user@example.com' });
+ });
+
+ it('returns null for plain text', () => {
+ expect(detectPasteUrl('just some text')).toBeNull();
+ });
+
+ it('returns null for URL with trailing text', () => {
+ expect(detectPasteUrl('https://example.com extra text')).toBeNull();
+ });
+
+ it('rejects javascript: protocol', () => {
+ expect(detectPasteUrl('javascript:alert(1)')).toBeNull();
+ });
+
+ it('trims whitespace before detecting', () => {
+ const result = detectPasteUrl(' https://example.com ');
+ expect(result).toEqual({ href: 'https://example.com' });
+ });
+
+ it('returns null for empty string', () => {
+ expect(detectPasteUrl('')).toBeNull();
+ });
+
+ it('returns null for null/undefined', () => {
+ expect(detectPasteUrl(null)).toBeNull();
+ expect(detectPasteUrl(undefined)).toBeNull();
+ });
+
+ it('normalizes protocol config entries (case and {scheme} objects)', () => {
+ // Mock sanitizeHref to accept FTP protocol when configured
+ sanitizeHref.mockImplementationOnce((raw, config) => {
+ if (raw === 'ftp://files.example.com' && config?.allowedProtocols?.includes('ftp')) {
+ return { href: 'ftp://files.example.com', protocol: 'ftp', isExternal: true };
+ }
+ return null;
+ });
+
+ const result = detectPasteUrl('ftp://files.example.com', [{ scheme: 'FTP' }]);
+ expect(result).toEqual({ href: 'ftp://files.example.com' });
+ });
+});
+
+// ---------------------------------------------------------------------------
+// canAllocateRels
+// ---------------------------------------------------------------------------
+
+describe('canAllocateRels', () => {
+ it('returns true for main docx editor', () => {
+ const editor = createMockEditor();
+ expect(canAllocateRels(editor)).toBe(true);
+ });
+
+ it('returns false for child editors', () => {
+ const editor = createMockEditor({ isChildEditor: true });
+ expect(canAllocateRels(editor)).toBe(false);
+ });
+
+ it('returns false for header/footer editors', () => {
+ const editor = createMockEditor({ isHeaderOrFooter: true });
+ expect(canAllocateRels(editor)).toBe(false);
+ });
+
+ it('returns false for non-docx mode', () => {
+ const editor = createMockEditor({ mode: 'html' });
+ expect(canAllocateRels(editor)).toBe(false);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// handlePlainTextUrlPaste
+// ---------------------------------------------------------------------------
+
+describe('handlePlainTextUrlPaste', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('inserts URL text and applies link + underline marks on collapsed selection', () => {
+ const editor = createMockEditor({ mode: 'html' });
+ const view = createMockView({ selectionFrom: 5, selectionTo: 5 });
+ const detected = { href: 'https://example.com' };
+
+ const handled = handlePlainTextUrlPaste(editor, view, 'https://example.com', detected);
+
+ expect(handled).toBe(true);
+ expect(view._dispatched).toHaveLength(1);
+
+ const tr = view._dispatched[0];
+ expect(tr.insertText).toHaveBeenCalledWith('https://example.com', 5);
+ expect(tr.addMark).toHaveBeenCalledTimes(2); // link + underline
+ });
+
+ it('applies link mark to non-collapsed text selection without inserting text', () => {
+ const editor = createMockEditor({ mode: 'html' });
+ const view = createMockView({ selectionFrom: 5, selectionTo: 15 });
+ const detected = { href: 'https://example.com' };
+
+ const handled = handlePlainTextUrlPaste(editor, view, 'https://example.com', detected);
+
+ expect(handled).toBe(true);
+
+ const tr = view._dispatched[0];
+ expect(tr.insertText).not.toHaveBeenCalled();
+ expect(tr.addMark).toHaveBeenCalled();
+ });
+
+ it('returns false for non-text selections (NodeSelection, CellSelection)', () => {
+ const editor = createMockEditor({ mode: 'html' });
+ const view = createMockView({ selectionFrom: 5, selectionTo: 15, isTextSelection: false });
+ const detected = { href: 'https://example.com' };
+
+ const handled = handlePlainTextUrlPaste(editor, view, 'https://example.com', detected);
+ expect(handled).toBe(false);
+ expect(view._dispatched).toHaveLength(0);
+ });
+
+ it('allocates rId for main docx editor', () => {
+ const editor = createMockEditor({ mode: 'docx' });
+ const view = createMockView({ selectionFrom: 0, selectionTo: 0 });
+
+ findRelationshipIdFromTarget.mockReturnValue(null);
+ insertNewRelationship.mockReturnValue('rId5');
+
+ handlePlainTextUrlPaste(editor, view, 'https://example.com', { href: 'https://example.com' });
+
+ expect(insertNewRelationship).toHaveBeenCalledWith('https://example.com', 'hyperlink', editor);
+ });
+
+ it('does not allocate rId for child editor', () => {
+ const editor = createMockEditor({ mode: 'docx', isChildEditor: true });
+ const view = createMockView({ selectionFrom: 0, selectionTo: 0 });
+
+ handlePlainTextUrlPaste(editor, view, 'https://example.com', { href: 'https://example.com' });
+
+ expect(insertNewRelationship).not.toHaveBeenCalled();
+ });
+
+ it('returns false when link mark type is not in schema', () => {
+ const editor = createMockEditor();
+ editor.schema.marks.link = undefined;
+ const view = createMockView();
+
+ const handled = handlePlainTextUrlPaste(editor, view, 'https://example.com', { href: 'https://example.com' });
+ expect(handled).toBe(false);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// normalizePastedLinks
+// ---------------------------------------------------------------------------
+
+describe('normalizePastedLinks', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ function createTransactionWithLinks(linkSpans = []) {
+ const tr = {
+ doc: {
+ content: { size: 100 },
+ nodesBetween: vi.fn((from, to, callback) => {
+ for (const span of linkSpans) {
+ if (span.from >= from && span.from < to) {
+ const linkMark = { type: LINK_MARK_TYPE, attrs: { ...span.attrs } };
+ const node = {
+ isInline: true,
+ nodeSize: span.to - span.from,
+ marks: [linkMark],
+ };
+ callback(node, span.from);
+ }
+ }
+ }),
+ },
+ mapping: {
+ maps: [
+ {
+ forEach: (cb) => {
+ // Simulate one changed range covering the whole area
+ if (linkSpans.length) {
+ const minFrom = Math.min(...linkSpans.map((s) => s.from));
+ const maxTo = Math.max(...linkSpans.map((s) => s.to));
+ cb(minFrom, maxTo, minFrom, maxTo);
+ }
+ },
+ },
+ ],
+ },
+ removeMark: vi.fn(),
+ addMark: vi.fn(),
+ };
+
+ return { tr, linkMarkType: LINK_MARK_TYPE };
+ }
+
+ it('sanitizes valid href and strips pasted rId (non-docx)', () => {
+ const editor = createMockEditor({ mode: 'html' });
+ const { tr, linkMarkType } = createTransactionWithLinks([
+ { from: 0, to: 10, attrs: { href: 'https://example.com', rId: 'rId99' } },
+ ]);
+
+ normalizePastedLinks(tr, editor);
+
+ expect(tr.removeMark).toHaveBeenCalledWith(0, 10, linkMarkType);
+ expect(tr.addMark).toHaveBeenCalled();
+
+ const addedMark = tr.addMark.mock.calls[0][2];
+ expect(addedMark.attrs.href).toBe('https://example.com');
+ expect(addedMark.attrs.rId).toBeNull();
+ });
+
+ it('allocates fresh rId for main docx editor', () => {
+ const editor = createMockEditor({ mode: 'docx' });
+ findRelationshipIdFromTarget.mockReturnValue(null);
+ insertNewRelationship.mockReturnValue('rId10');
+
+ const { tr } = createTransactionWithLinks([
+ { from: 0, to: 10, attrs: { href: 'https://example.com', rId: 'rId1' } },
+ ]);
+
+ normalizePastedLinks(tr, editor);
+
+ expect(insertNewRelationship).toHaveBeenCalledWith('https://example.com', 'hyperlink', editor);
+
+ const addedMark = tr.addMark.mock.calls[0][2];
+ expect(addedMark.attrs.rId).toBe('rId10');
+ });
+
+ it('strips rId but skips allocation for child/header editor', () => {
+ const editor = createMockEditor({ mode: 'docx', isHeaderOrFooter: true });
+ const { tr } = createTransactionWithLinks([
+ { from: 0, to: 10, attrs: { href: 'https://example.com', rId: 'rId5' } },
+ ]);
+
+ normalizePastedLinks(tr, editor);
+
+ expect(insertNewRelationship).not.toHaveBeenCalled();
+
+ const addedMark = tr.addMark.mock.calls[0][2];
+ expect(addedMark.attrs.rId).toBeNull();
+ });
+
+ it('removes link mark when href is invalid', () => {
+ const editor = createMockEditor({ mode: 'html' });
+ const { tr, linkMarkType } = createTransactionWithLinks([
+ { from: 0, to: 10, attrs: { href: 'javascript:alert(1)', rId: null } },
+ ]);
+
+ normalizePastedLinks(tr, editor);
+
+ expect(tr.removeMark).toHaveBeenCalledWith(0, 10, linkMarkType);
+ expect(tr.addMark).not.toHaveBeenCalled();
+ });
+
+ it('prepends https:// to www. hrefs in pasted HTML', () => {
+ const editor = createMockEditor({ mode: 'html' });
+ const { tr } = createTransactionWithLinks([{ from: 0, to: 10, attrs: { href: 'www.example.com', rId: null } }]);
+
+ // Override sanitizeHref to accept the prepended URL
+ sanitizeHref.mockImplementationOnce((raw) => {
+ if (raw === 'https://www.example.com') {
+ return { href: 'https://www.example.com', protocol: 'https', isExternal: true };
+ }
+ return null;
+ });
+
+ normalizePastedLinks(tr, editor);
+
+ expect(sanitizeHref).toHaveBeenCalledWith('https://www.example.com', expect.any(Object));
+ const addedMark = tr.addMark.mock.calls[0][2];
+ expect(addedMark.attrs.href).toBe('https://www.example.com');
+ });
+
+ it('is a no-op when there are no link marks in range', () => {
+ const editor = createMockEditor();
+ const { tr } = createTransactionWithLinks([]);
+
+ // No step maps = no changed ranges
+ tr.mapping.maps = [];
+ normalizePastedLinks(tr, editor);
+
+ expect(tr.removeMark).not.toHaveBeenCalled();
+ expect(tr.addMark).not.toHaveBeenCalled();
+ });
+
+ it('normalizes multiple links independently', () => {
+ const editor = createMockEditor({ mode: 'html' });
+ const { tr } = createTransactionWithLinks([
+ { from: 0, to: 5, attrs: { href: 'https://one.com', rId: 'rId1' } },
+ { from: 10, to: 15, attrs: { href: 'https://two.com', rId: 'rId2' } },
+ ]);
+
+ normalizePastedLinks(tr, editor);
+
+ // Both links should be removed then re-added
+ expect(tr.removeMark).toHaveBeenCalledTimes(2);
+ expect(tr.addMark).toHaveBeenCalledTimes(2);
+ });
+
+ it('reapplies anchor-only link with rId stripped', () => {
+ const editor = createMockEditor({ mode: 'docx' });
+ const { tr, linkMarkType } = createTransactionWithLinks([
+ { from: 0, to: 10, attrs: { href: null, anchor: 'bookmark1', rId: 'rId3' } },
+ ]);
+
+ normalizePastedLinks(tr, editor);
+
+ // Must reapply the mark to strip the pasted rId
+ expect(tr.removeMark).toHaveBeenCalledWith(0, 10, linkMarkType);
+ expect(tr.addMark).toHaveBeenCalledTimes(1);
+
+ const addedMark = tr.addMark.mock.calls[0][2];
+ expect(addedMark.attrs.rId).toBeNull();
+ expect(addedMark.attrs.anchor).toBe('bookmark1');
+ });
+
+ it('preserves name-only anchor target with rId stripped', () => {
+ const editor = createMockEditor({ mode: 'docx' });
+ const { tr, linkMarkType } = createTransactionWithLinks([
+ { from: 0, to: 10, attrs: { href: null, name: 'toc_1', rId: 'rId7' } },
+ ]);
+
+ normalizePastedLinks(tr, editor);
+
+ // Name-only links are valid bookmark targets — must be preserved
+ expect(tr.removeMark).toHaveBeenCalledWith(0, 10, linkMarkType);
+ expect(tr.addMark).toHaveBeenCalledTimes(1);
+
+ const addedMark = tr.addMark.mock.calls[0][2];
+ expect(addedMark.attrs.rId).toBeNull();
+ expect(addedMark.attrs.name).toBe('toc_1');
+ });
+
+ it('removes link with no href, no anchor, and no name', () => {
+ const editor = createMockEditor({ mode: 'html' });
+ const { tr, linkMarkType } = createTransactionWithLinks([
+ { from: 0, to: 10, attrs: { href: null, anchor: undefined, name: null, rId: null } },
+ ]);
+
+ normalizePastedLinks(tr, editor);
+
+ expect(tr.removeMark).toHaveBeenCalledWith(0, 10, linkMarkType);
+ expect(tr.addMark).not.toHaveBeenCalled();
+ });
+});