From 04fa1aad675feaf17343646bf7369e30397653a6 Mon Sep 17 00:00:00 2001 From: Matt Frank Date: Tue, 17 Feb 2026 21:39:42 -0600 Subject: [PATCH 1/6] Add URL paste autolink feature When pasting content that is just an HTTP(S) URL: - No selection: inserts URL as linked text - With selection: applies link mark to selected text using pasted URL as href Also adds isSafeHref() and parseHttpUrl() utilities for URL validation, replacing the regex in link mark's parseDOM with isSafeHref(). Respects existing links in pasted content - does not override them. Co-Authored-By: Claude Opus 4.5 --- src/editor/editor.ts | 97 +++++++++++++++++++++++++++++++++++++++++++- src/editor/schema.ts | 43 ++++++++++++++++++-- 2 files changed, 136 insertions(+), 4 deletions(-) diff --git a/src/editor/editor.ts b/src/editor/editor.ts index 359fb43..03721aa 100644 --- a/src/editor/editor.ts +++ b/src/editor/editor.ts @@ -28,7 +28,7 @@ import { createImageNodeView } from "./imageNodeView"; import { categorizeImageSrc, type ImageSrcType } from "./imageUtils"; import { createMathDisplayNodeView } from "./mathNodeView"; import { createMathPlugin } from "./mathPlugin"; -import { schema } from "./schema"; +import { parseHttpUrl, schema } from "./schema"; import { normalizeTablesInSlice } from "./tableNormalize"; // Re-export for backward compatibility @@ -370,6 +370,101 @@ export function mountEditor(host: HTMLElement): EditorView { } } + // URL autolink: If the pasted content is just a URL (without existing links), + // make it a link. Skip if slice already has link marks - don't override them. + let hasLinkMark = false; + slice.content.descendants((node) => { + if (node.marks?.some((m) => m.type === schema.marks.link)) { + hasLinkMark = true; + return false; // stop iteration + } + }); + + const sliceText = slice.content + .textBetween(0, slice.content.size, "", "") + .trim(); + const url = !hasLinkMark ? parseHttpUrl(sliceText) : null; + + if (url) { + const href = url.href; + const { state, dispatch } = view; + const { selection } = state; + + if (selection.empty) { + // Part 1: No selection - insert URL as linked text + const linkMark = schema.marks.link.create({ href }); + const textNode = schema.text(sliceText, [linkMark]); + const tr = state.tr.replaceSelectionWith(textNode, false); + // Remove stored link mark so next typed char is unlinked + tr.removeStoredMark(schema.marks.link); + dispatch(tr); + return true; + } + // Part 2: Selection - apply link mark to selected text + // Case 2b: If selection is entirely inside one existing link, + // extend range to cover the full link (links are atomic to users) + let { from, to } = selection; + + // Check if selection is entirely within one link + const $from = state.doc.resolve(from); + const $to = state.doc.resolve(to); + const linkAtFrom = $from + .marks() + .find((m) => m.type === schema.marks.link); + const linkAtTo = $to.marks().find((m) => m.type === schema.marks.link); + + if ( + linkAtFrom && + linkAtTo && + linkAtFrom.attrs.href === linkAtTo.attrs.href + ) { + // Selection is inside one link - find full extent + // Walk left from 'from' to find link start + let extendedFrom = from; + for (let pos = from - 1; pos >= 0; pos--) { + const $pos = state.doc.resolve(pos); + const linkAtPos = $pos + .marks() + .find((m) => m.type === schema.marks.link); + if (linkAtPos && linkAtPos.attrs.href === linkAtFrom.attrs.href) { + extendedFrom = pos; + } else { + break; + } + } + // Walk right from 'to' to find link end + let extendedTo = to; + const docSize = state.doc.content.size; + for (let pos = to; pos <= docSize; pos++) { + const $pos = state.doc.resolve(pos); + const linkAtPos = $pos + .marks() + .find((m) => m.type === schema.marks.link); + if (linkAtPos && linkAtPos.attrs.href === linkAtFrom.attrs.href) { + extendedTo = pos + 1; + } else { + break; + } + } + from = extendedFrom; + to = extendedTo; + } + + const tr = state.tr; + // Remove any existing link marks in the range first + tr.removeMark(from, to, schema.marks.link); + // Add the new link mark + // DESIGN NOTE: tr.addMark across a multi-block range correctly applies + // the mark only to inline content within each block. Images (atom nodes) + // within the selection are unaffected - marks only apply to text nodes. + tr.addMark(from, to, schema.marks.link.create({ href })); + // Collapse selection to end and remove stored link mark + tr.setSelection(Selection.near(tr.doc.resolve(tr.mapping.map(to)))); + tr.removeStoredMark(schema.marks.link); + dispatch(tr); + return true; + } + // Normalize tables in pasted content to enforce GFM semantics // (flatten spanning cells, ensure first row is header cells) slice = normalizeTablesInSlice(slice, schema); diff --git a/src/editor/schema.ts b/src/editor/schema.ts index 9fbceed..1422c5c 100644 --- a/src/editor/schema.ts +++ b/src/editor/schema.ts @@ -3,6 +3,44 @@ import { marks, nodes } from "prosemirror-schema-basic"; import { bulletList, listItem, orderedList } from "prosemirror-schema-list"; import { tableNodes } from "prosemirror-tables"; +/** + * Validate that an href is safe for use in links. + * Accepts http, https, and mailto URLs only. + * Used by parseDOM for tags and paste sanitization. + */ +export function isSafeHref(href: string): boolean { + try { + const url = new URL(href); + return ( + url.protocol === "http:" || + url.protocol === "https:" || + url.protocol === "mailto:" + ); + } catch { + return false; + } +} + +/** + * Parse text as an HTTP(S) URL suitable for autolink. + * Stricter than isSafeHref: only http/https with non-empty hostname. + * Returns the parsed URL if valid, null otherwise. + */ +export function parseHttpUrl(text: string): URL | null { + try { + const url = new URL(text); + if ( + (url.protocol === "http:" || url.protocol === "https:") && + url.hostname !== "" + ) { + return url; + } + return null; + } catch { + return null; + } +} + // Generate table node specs from prosemirror-tables // TODO: GFM has per-column alignment (left/center/right/none). // Add `alignments: Alignment[]` attr to table node when we implement alignment UI. @@ -192,9 +230,8 @@ export const schema = new Schema({ tag: "a[href]", getAttrs(dom) { const href = (dom as HTMLElement).getAttribute("href") || ""; - // Only allow safe URL schemes (http, https, mailto) and relative paths - if (!/^(https?|mailto):/i.test(href) && !href.startsWith("/")) { - return false; // Reject this link + if (!isSafeHref(href)) { + return false; // Strip link, keep text } return { href, From 5d9a5c968d113a478ac3089c7b043958e6b4a2f0 Mon Sep 17 00:00:00 2001 From: Matt Frank Date: Wed, 18 Feb 2026 10:07:44 -0600 Subject: [PATCH 2/6] Refactor URL paste autolink for cleaner unified flow - Handle autolinks from other apps (link where href === text) - Unified code path: detect URL, construct link slice, replaceSelection - Inline selection preserves existing marks (bold/italic) - Empty or multi-block selection uses URL as link text - Add helper functions: isInlineSelection, addMarkToFragment - Remove complex "Case 2b" link extension logic Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 23 ++++ docs/plans/01KHRS817S0EERAW6NG7FZT1K6.md | 136 +++++++++++++++++++ src/editor/editor.ts | 164 +++++++++++------------ 3 files changed, 240 insertions(+), 83 deletions(-) create mode 100644 docs/plans/01KHRS817S0EERAW6NG7FZT1K6.md diff --git a/CLAUDE.md b/CLAUDE.md index 5cfe148..5846814 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -145,3 +145,26 @@ Before designing solutions, enumerate the complete problem space: 3. **Identify the coupling.** Features that seem separate often share a communication channel (clipboard, file format, API). Design them together, not as afterthoughts to each other. Only after this enumeration should you design the implementation. + +## Plan Documents + +When creating implementation plans, save them to `docs/plans/` with a ULID filename: + +``` +docs/plans/01KHRS817S0EERAW6NG7FZT1K6.md +``` + +ULIDs are time-sortable unique identifiers. Generate one with: + +```bash +node -e " +const t = Date.now(); +const chars = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; +let ulid = ''; +for (let i = 0; i < 10; i++) { ulid = chars[t >> (i * 5) & 31] + ulid; } +for (let i = 0; i < 16; i++) { ulid += chars[Math.floor(Math.random() * 32)]; } +console.log(ulid); +" +``` + +Plans should document the design decisions and rationale, not just the implementation steps. They serve as a record of why things were built a certain way. diff --git a/docs/plans/01KHRS817S0EERAW6NG7FZT1K6.md b/docs/plans/01KHRS817S0EERAW6NG7FZT1K6.md new file mode 100644 index 0000000..e56aa6c --- /dev/null +++ b/docs/plans/01KHRS817S0EERAW6NG7FZT1K6.md @@ -0,0 +1,136 @@ +# URL Paste Autolink Refactoring Plan + +## Goal +Refactor the URL paste autolink code to be cleaner and handle the case where pasted content is an autolink (link where href = text) from another application. + +## Unified Flow + +1. **Detect if slice is a URL paste:** + - Plain text that is a valid HTTP URL, OR + - Has link mark where `href === text` (autolink from another app) + - If neither → return false, let PM handle normally + +2. **Extract `href`** from whichever case matched + +3. **Determine link text content:** + - Default: `linkText = href` + - If selection is non-empty AND inline (doesn't cross block boundaries): + `linkText = selected content (preserving marks like bold/italic)` + +4. **Construct the link slice:** + - Create slice containing `linkText` with link mark pointing to `href` + +5. **Paste using `tr.replaceSelection(slice)`:** + - PM handles all structural fixup (empty selection inserts, non-empty replaces, multi-block merges) + +6. **Cleanup:** Remove stored link mark so next typed char isn't linked + +## Helper Functions Needed + +### `isInlineSelection(state): boolean` +Check if current selection is non-empty and doesn't cross block boundaries. + +```typescript +function isInlineSelection(state: EditorState): boolean { + const { from, to, $from, $to } = state.selection; + if (from === to) return false; // empty selection + // Same parent block = inline selection + return $from.sameParent($to); +} +``` + +### `addLinkMarkToFragment(fragment, mark, schema): Fragment` +Recursively add a link mark to all text nodes in a fragment. + +```typescript +function addLinkMarkToFragment(fragment: Fragment, mark: Mark): Fragment { + const nodes: Node[] = []; + fragment.forEach((node) => { + if (node.isText) { + nodes.push(node.mark(mark.addToSet(node.marks))); + } else if (node.content.size > 0) { + nodes.push(node.copy(addLinkMarkToFragment(node.content, mark))); + } else { + nodes.push(node); + } + }); + return Fragment.from(nodes); +} +``` + +## Refactored Paste Handler Logic + +```typescript +// 1. Detect URL and extract href +const sliceText = slice.content.textBetween(0, slice.content.size, "", "").trim(); + +let href: string | null = null; + +// Check for plain text URL +const plainUrl = parseHttpUrl(sliceText); +if (plainUrl) { + href = plainUrl.href; +} else { + // Check for autolink (link mark where href === text) + let linkHref: string | null = null; + slice.content.descendants((node) => { + const linkMark = node.marks?.find((m) => m.type === schema.marks.link); + if (linkMark) { + linkHref = linkMark.attrs.href; + return false; + } + }); + if (linkHref && linkHref === sliceText) { + href = linkHref; + } +} + +if (!href) { + // Not a URL paste, fall through + // ... rest of paste handler +} + +// 2. Determine link text content +const { state, dispatch } = view; +const { selection } = state; +const linkMark = schema.marks.link.create({ href }); + +let linkSlice: Slice; + +if (selection.empty || !isInlineSelection(state)) { + // Empty or multi-block: link text = href + const linkNode = schema.text(href, [linkMark]); + linkSlice = new Slice(Fragment.from(linkNode), 0, 0); +} else { + // Inline selection: link text = selected content with link mark added + const { from, to } = selection; + const selectedSlice = state.doc.slice(from, to); + const linkedContent = addLinkMarkToFragment(selectedSlice.content, linkMark); + linkSlice = new Slice(linkedContent, selectedSlice.openStart, selectedSlice.openEnd); +} + +// 3. Paste +const tr = state.tr.replaceSelection(linkSlice); +tr.removeStoredMark(schema.marks.link); +dispatch(tr); +return true; +``` + +## What Gets Removed + +- The `hasLinkMark` check that skipped autolinks entirely +- The separate Part 1 / Part 2 code paths +- The "Case 2b" link extension logic (selecting inside existing link) + +## Behavior Changes + +| Scenario | Old Behavior | New Behavior | +|----------|--------------|--------------| +| Paste `http://x.com` (autolink) with empty selection | Pasted as-is (link preserved) | Same - pasted as link | +| Paste `http://x.com` with inline selection "click" | Fell through to default paste (replaced "click" with the link) | "click" becomes link to x.com | +| Paste `http://bar.com` (text≠href) | Fell through to default | Falls through (not an autolink) | +| Paste plain URL with selection inside existing link | Extended to full link, replaced href | Just replaces selection (simpler) | + +## Questions + +1. Is dropping the "Case 2b" link extension logic okay? (It was for when selection is inside an existing link - it extended to cover the whole link before replacing href) diff --git a/src/editor/editor.ts b/src/editor/editor.ts index 03721aa..43bb538 100644 --- a/src/editor/editor.ts +++ b/src/editor/editor.ts @@ -6,7 +6,13 @@ import { } from "prosemirror-commands"; import { history, redo, undo } from "prosemirror-history"; import { keymap } from "prosemirror-keymap"; -import { Fragment, Node, type NodeType, Slice } from "prosemirror-model"; +import { + Fragment, + type Mark, + Node, + type NodeType, + Slice, +} from "prosemirror-model"; import { liftListItem, sinkListItem, @@ -34,6 +40,33 @@ import { normalizeTablesInSlice } from "./tableNormalize"; // Re-export for backward compatibility export { categorizeImageSrc, type ImageSrcType }; +/** + * Check if current selection is non-empty and inline (doesn't cross block boundaries). + */ +function isInlineSelection(state: EditorState): boolean { + const { from, to, $from, $to } = state.selection; + if (from === to) return false; // empty selection + // Same parent block = inline selection + return $from.sameParent($to); +} + +/** + * Recursively add a mark to all text nodes in a fragment. + */ +function addMarkToFragment(fragment: Fragment, mark: Mark): Fragment { + const nodes: Node[] = []; + fragment.forEach((node) => { + if (node.isText) { + nodes.push(node.mark(mark.addToSet(node.marks))); + } else if (node.content.size > 0) { + nodes.push(node.copy(addMarkToFragment(node.content, mark))); + } else { + nodes.push(node); + } + }); + return Fragment.from(nodes); +} + interface ImageToProcess { node: Node; src: string; @@ -370,96 +403,61 @@ export function mountEditor(host: HTMLElement): EditorView { } } - // URL autolink: If the pasted content is just a URL (without existing links), - // make it a link. Skip if slice already has link marks - don't override them. - let hasLinkMark = false; - slice.content.descendants((node) => { - if (node.marks?.some((m) => m.type === schema.marks.link)) { - hasLinkMark = true; - return false; // stop iteration - } - }); - + // URL autolink: If pasted content is a URL (plain text or autolink where + // href equals text), create a link. The link text is either the URL itself + // (for empty/multi-block selection) or the selected text (for inline selection). const sliceText = slice.content .textBetween(0, slice.content.size, "", "") .trim(); - const url = !hasLinkMark ? parseHttpUrl(sliceText) : null; - if (url) { - const href = url.href; + // Detect URL: either plain text URL or autolink (link mark where href === text) + let href: string | null = null; + const plainUrl = parseHttpUrl(sliceText); + if (plainUrl) { + href = plainUrl.href; + } else { + // Check for autolink from another app (link mark where href === text) + let linkHref: string | null = null; + slice.content.descendants((node) => { + const linkMark = node.marks?.find( + (m) => m.type === schema.marks.link, + ); + if (linkMark) { + linkHref = linkMark.attrs.href as string; + return false; // stop iteration + } + }); + if (linkHref && linkHref === sliceText) { + href = linkHref; + } + } + + if (href) { const { state, dispatch } = view; const { selection } = state; - - if (selection.empty) { - // Part 1: No selection - insert URL as linked text - const linkMark = schema.marks.link.create({ href }); - const textNode = schema.text(sliceText, [linkMark]); - const tr = state.tr.replaceSelectionWith(textNode, false); - // Remove stored link mark so next typed char is unlinked - tr.removeStoredMark(schema.marks.link); - dispatch(tr); - return true; - } - // Part 2: Selection - apply link mark to selected text - // Case 2b: If selection is entirely inside one existing link, - // extend range to cover the full link (links are atomic to users) - let { from, to } = selection; - - // Check if selection is entirely within one link - const $from = state.doc.resolve(from); - const $to = state.doc.resolve(to); - const linkAtFrom = $from - .marks() - .find((m) => m.type === schema.marks.link); - const linkAtTo = $to.marks().find((m) => m.type === schema.marks.link); - - if ( - linkAtFrom && - linkAtTo && - linkAtFrom.attrs.href === linkAtTo.attrs.href - ) { - // Selection is inside one link - find full extent - // Walk left from 'from' to find link start - let extendedFrom = from; - for (let pos = from - 1; pos >= 0; pos--) { - const $pos = state.doc.resolve(pos); - const linkAtPos = $pos - .marks() - .find((m) => m.type === schema.marks.link); - if (linkAtPos && linkAtPos.attrs.href === linkAtFrom.attrs.href) { - extendedFrom = pos; - } else { - break; - } - } - // Walk right from 'to' to find link end - let extendedTo = to; - const docSize = state.doc.content.size; - for (let pos = to; pos <= docSize; pos++) { - const $pos = state.doc.resolve(pos); - const linkAtPos = $pos - .marks() - .find((m) => m.type === schema.marks.link); - if (linkAtPos && linkAtPos.attrs.href === linkAtFrom.attrs.href) { - extendedTo = pos + 1; - } else { - break; - } - } - from = extendedFrom; - to = extendedTo; + const linkMark = schema.marks.link.create({ href }); + + let linkSlice: Slice; + if (!selection.empty && isInlineSelection(state)) { + // Inline selection: link text = selected content (preserving marks) + const { from, to } = selection; + const selectedSlice = state.doc.slice(from, to); + const linkedContent = addMarkToFragment( + selectedSlice.content, + linkMark, + ); + linkSlice = new Slice( + linkedContent, + selectedSlice.openStart, + selectedSlice.openEnd, + ); + } else { + // Empty or multi-block selection: link text = href + const linkNode = schema.text(href, [linkMark]); + linkSlice = new Slice(Fragment.from(linkNode), 0, 0); } - const tr = state.tr; - // Remove any existing link marks in the range first - tr.removeMark(from, to, schema.marks.link); - // Add the new link mark - // DESIGN NOTE: tr.addMark across a multi-block range correctly applies - // the mark only to inline content within each block. Images (atom nodes) - // within the selection are unaffected - marks only apply to text nodes. - tr.addMark(from, to, schema.marks.link.create({ href })); - // Collapse selection to end and remove stored link mark - tr.setSelection(Selection.near(tr.doc.resolve(tr.mapping.map(to)))); + const tr = state.tr.replaceSelection(linkSlice); tr.removeStoredMark(schema.marks.link); dispatch(tr); return true; From 7b248e2b98d8cc6f99090511ddb3314e0c5c8e8c Mon Sep 17 00:00:00 2001 From: Matt Frank Date: Wed, 18 Feb 2026 12:58:28 -0600 Subject: [PATCH 3/6] Support link marks on images When pasting a URL onto a selection that includes images, the images now become part of the link. Changes: - addMarkToFragment: Add marks to all nodes, not just text nodes - image toDOM: Wrap in tag when image has a link mark Co-Authored-By: Claude Opus 4.5 --- src/editor/editor.ts | 11 +++-------- src/editor/schema.ts | 10 ++++++++++ 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/editor/editor.ts b/src/editor/editor.ts index 43bb538..c117be5 100644 --- a/src/editor/editor.ts +++ b/src/editor/editor.ts @@ -51,18 +51,13 @@ function isInlineSelection(state: EditorState): boolean { } /** - * Recursively add a mark to all text nodes in a fragment. + * Add a mark to all nodes in a fragment of inline content. + * Works for both text nodes and inline atoms (like images). */ function addMarkToFragment(fragment: Fragment, mark: Mark): Fragment { const nodes: Node[] = []; fragment.forEach((node) => { - if (node.isText) { - nodes.push(node.mark(mark.addToSet(node.marks))); - } else if (node.content.size > 0) { - nodes.push(node.copy(addMarkToFragment(node.content, mark))); - } else { - nodes.push(node); - } + nodes.push(node.mark(mark.addToSet(node.marks))); }); return Fragment.from(nodes); } diff --git a/src/editor/schema.ts b/src/editor/schema.ts index 1422c5c..8b3d5eb 100644 --- a/src/editor/schema.ts +++ b/src/editor/schema.ts @@ -215,6 +215,16 @@ export const schema = new Schema({ const attrs: Record = { src, class: "pm-image" }; if (alt) attrs.alt = alt; if (title) attrs.title = title; + + // If image has a link mark, wrap in + const linkMark = node.marks.find((m) => m.type.name === "link"); + if (linkMark) { + return [ + "a", + { href: linkMark.attrs.href, class: "pm-image-link" }, + ["img", attrs], + ]; + } return ["img", attrs]; }, }, From 63ad098dc2f94988ea9e7baa938d31560b0fb947 Mon Sep 17 00:00:00 2001 From: Matt Frank Date: Wed, 18 Feb 2026 13:20:01 -0600 Subject: [PATCH 4/6] Use tr.addMark for inline selection paste-to-link Instead of extracting a slice, adding marks to all nodes, and replacing, just use tr.addMark() directly on the document range. This is the idiomatic ProseMirror approach for adding marks to existing content. Removes the addMarkToFragment helper function entirely. Co-Authored-By: Claude Opus 4.5 --- src/editor/editor.ts | 42 +++++++----------------------------------- 1 file changed, 7 insertions(+), 35 deletions(-) diff --git a/src/editor/editor.ts b/src/editor/editor.ts index c117be5..68a9bc0 100644 --- a/src/editor/editor.ts +++ b/src/editor/editor.ts @@ -6,13 +6,7 @@ import { } from "prosemirror-commands"; import { history, redo, undo } from "prosemirror-history"; import { keymap } from "prosemirror-keymap"; -import { - Fragment, - type Mark, - Node, - type NodeType, - Slice, -} from "prosemirror-model"; +import { Fragment, Node, type NodeType, Slice } from "prosemirror-model"; import { liftListItem, sinkListItem, @@ -50,18 +44,6 @@ function isInlineSelection(state: EditorState): boolean { return $from.sameParent($to); } -/** - * Add a mark to all nodes in a fragment of inline content. - * Works for both text nodes and inline atoms (like images). - */ -function addMarkToFragment(fragment: Fragment, mark: Mark): Fragment { - const nodes: Node[] = []; - fragment.forEach((node) => { - nodes.push(node.mark(mark.addToSet(node.marks))); - }); - return Fragment.from(nodes); -} - interface ImageToProcess { node: Node; src: string; @@ -432,27 +414,17 @@ export function mountEditor(host: HTMLElement): EditorView { const { selection } = state; const linkMark = schema.marks.link.create({ href }); - let linkSlice: Slice; + let tr: Transaction; if (!selection.empty && isInlineSelection(state)) { - // Inline selection: link text = selected content (preserving marks) - const { from, to } = selection; - const selectedSlice = state.doc.slice(from, to); - const linkedContent = addMarkToFragment( - selectedSlice.content, - linkMark, - ); - linkSlice = new Slice( - linkedContent, - selectedSlice.openStart, - selectedSlice.openEnd, - ); + // Inline selection: add link mark to existing content + tr = state.tr.addMark(selection.from, selection.to, linkMark); } else { - // Empty or multi-block selection: link text = href + // Empty or multi-block selection: insert href as linked text const linkNode = schema.text(href, [linkMark]); - linkSlice = new Slice(Fragment.from(linkNode), 0, 0); + const linkSlice = new Slice(Fragment.from(linkNode), 0, 0); + tr = state.tr.replaceSelection(linkSlice); } - const tr = state.tr.replaceSelection(linkSlice); tr.removeStoredMark(schema.marks.link); dispatch(tr); return true; From da8ca9da9a1ef3dc8730a35da8f0dc2fe236139c Mon Sep 17 00:00:00 2001 From: Matt Frank Date: Wed, 18 Feb 2026 14:15:08 -0600 Subject: [PATCH 5/6] Tighten autolink detection to reject edge cases An autolink must be a single text node that: - Parses as a valid HTTP(S) URL - Has either no marks, or exactly one link mark where href === text This rejects structured content, partial links, text outside links, URLs with extra formatting, and encoding mismatches. Co-Authored-By: Claude Opus 4.5 --- src/editor/editor.ts | 40 ++++++++++++++++------------------------ 1 file changed, 16 insertions(+), 24 deletions(-) diff --git a/src/editor/editor.ts b/src/editor/editor.ts index 68a9bc0..83d1b4c 100644 --- a/src/editor/editor.ts +++ b/src/editor/editor.ts @@ -380,32 +380,24 @@ export function mountEditor(host: HTMLElement): EditorView { } } - // URL autolink: If pasted content is a URL (plain text or autolink where - // href equals text), create a link. The link text is either the URL itself - // (for empty/multi-block selection) or the selected text (for inline selection). - const sliceText = slice.content - .textBetween(0, slice.content.size, "", "") - .trim(); - - // Detect URL: either plain text URL or autolink (link mark where href === text) + // URL autolink: If pasted content is a single text node containing a valid + // URL, create a link. An "autolink" is either plain text that parses as a + // URL, or text with exactly one link mark where href === text (from an app + // that auto-linked the URL). No other marks allowed, no content outside. let href: string | null = null; - const plainUrl = parseHttpUrl(sliceText); - if (plainUrl) { - href = plainUrl.href; - } else { - // Check for autolink from another app (link mark where href === text) - let linkHref: string | null = null; - slice.content.descendants((node) => { - const linkMark = node.marks?.find( - (m) => m.type === schema.marks.link, - ); - if (linkMark) { - linkHref = linkMark.attrs.href as string; - return false; // stop iteration + if (slice.content.childCount === 1) { + const node = slice.content.firstChild; + if (node?.isText && node.text) { + const url = parseHttpUrl(node.text); + if ( + url && + (node.marks.length === 0 || + (node.marks.length === 1 && + node.marks[0].type === schema.marks.link && + node.marks[0].attrs.href === node.text)) + ) { + href = url.href; } - }); - if (linkHref && linkHref === sliceText) { - href = linkHref; } } From 4d3b20bc4e93dde40a5db0595e6e2850af66c587 Mon Sep 17 00:00:00 2001 From: Matt Frank Date: Wed, 18 Feb 2026 15:46:22 -0600 Subject: [PATCH 6/6] Unwrap paragraph when detecting URL autolink ProseMirror wraps pasted text in a paragraph node. The autolink detection now unwraps single-paragraph slices to find the text node inside, fixing URL detection for normal paste operations. Co-Authored-By: Claude Opus 4.5 --- src/editor/editor.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/editor/editor.ts b/src/editor/editor.ts index 83d1b4c..91cb43e 100644 --- a/src/editor/editor.ts +++ b/src/editor/editor.ts @@ -386,7 +386,11 @@ export function mountEditor(host: HTMLElement): EditorView { // that auto-linked the URL). No other marks allowed, no content outside. let href: string | null = null; if (slice.content.childCount === 1) { - const node = slice.content.firstChild; + let node = slice.content.firstChild; + // Unwrap if single paragraph containing single child + if (node?.type.name === "paragraph" && node.content.childCount === 1) { + node = node.content.firstChild; + } if (node?.isText && node.text) { const url = parseHttpUrl(node.text); if (