From d9bfeae044139acb372e7c64f2174b0dea4e2ced Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Tue, 3 Mar 2026 17:41:33 -0800 Subject: [PATCH 1/2] feat(links): convert pasted hyperlinks into real docx links --- .../src/components/context-menu/menuItems.js | 2 +- .../context-menu/tests/menuItems.test.js | 1 + packages/super-editor/src/core/InputRule.d.ts | 6 +- packages/super-editor/src/core/InputRule.js | 28 +- .../core/inputRules/docx-paste/docx-paste.js | 5 +- .../google-docs-paste/google-docs-paste.js | 5 +- .../core/inputRules/paste-link-normalizer.js | 320 +++++++++++ .../inputRules/paste-link-normalizer.test.js | 529 ++++++++++++++++++ 8 files changed, 885 insertions(+), 11 deletions(-) create mode 100644 packages/super-editor/src/core/inputRules/paste-link-normalizer.js create mode 100644 packages/super-editor/src/core/inputRules/paste-link-normalizer.test.js 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..a487af06b5 --- /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 hasAnchor = Boolean(attrs.anchor); + const rawHref = attrs.href; + + // Links with only an anchor (internal bookmark) need no href processing, + // but we still must reapply the mark to strip the pasted rId. + if (!rawHref && hasAnchor) { + tr.removeMark(from, to, linkMarkType); + tr.addMark(from, to, linkMarkType.create(attrs)); + return; + } + + // No href and no anchor → 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..8dd11a17f0 --- /dev/null +++ b/packages/super-editor/src/core/inputRules/paste-link-normalizer.test.js @@ -0,0 +1,529 @@ +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('removes link with no href and no anchor', () => { + const editor = createMockEditor({ mode: 'html' }); + const { tr, linkMarkType } = createTransactionWithLinks([ + { from: 0, to: 10, attrs: { href: null, anchor: undefined, rId: null } }, + ]); + + normalizePastedLinks(tr, editor); + + expect(tr.removeMark).toHaveBeenCalledWith(0, 10, linkMarkType); + expect(tr.addMark).not.toHaveBeenCalled(); + }); +}); From 50ad9e7a0179142ab6ce2ec6019426347c709147 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Tue, 3 Mar 2026 18:08:35 -0800 Subject: [PATCH 2/2] chore: review fix --- .../core/inputRules/paste-link-normalizer.js | 10 ++++----- .../inputRules/paste-link-normalizer.test.js | 21 +++++++++++++++++-- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/packages/super-editor/src/core/inputRules/paste-link-normalizer.js b/packages/super-editor/src/core/inputRules/paste-link-normalizer.js index a487af06b5..a0dac4f2dc 100644 --- a/packages/super-editor/src/core/inputRules/paste-link-normalizer.js +++ b/packages/super-editor/src/core/inputRules/paste-link-normalizer.js @@ -227,18 +227,18 @@ function normalizeOneLinkMark(tr, editor, linkMarkType, span, allowedProtocols) // Never trust pasted rIds — they reference a different document's rels attrs.rId = null; - const hasAnchor = Boolean(attrs.anchor); const rawHref = attrs.href; + const hasInternalRef = Boolean(attrs.anchor) || Boolean(attrs.name); - // Links with only an anchor (internal bookmark) need no href processing, - // but we still must reapply the mark to strip the pasted rId. - if (!rawHref && hasAnchor) { + // 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 and no anchor → meaningless link, remove it + // No href, no anchor, no name → meaningless link, remove it if (!rawHref) { tr.removeMark(from, to, linkMarkType); return; 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 index 8dd11a17f0..62a0699c80 100644 --- a/packages/super-editor/src/core/inputRules/paste-link-normalizer.test.js +++ b/packages/super-editor/src/core/inputRules/paste-link-normalizer.test.js @@ -515,10 +515,27 @@ describe('normalizePastedLinks', () => { expect(addedMark.attrs.anchor).toBe('bookmark1'); }); - it('removes link with no href and no anchor', () => { + 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, rId: null } }, + { from: 0, to: 10, attrs: { href: null, anchor: undefined, name: null, rId: null } }, ]); normalizePastedLinks(tr, editor);