Skip to content

Typing at the left edge of a hyperlink should stay outside the link #374

Description

@HMarzban

Related

Issue 366 is a different hyperlink bug from the same report. It covers focus in the Add link form.

No open issue covers typing at the edge of a hyperlink. No discussion covers it. Memory has no ruling on it.

What happened

This is planned for the next release of @docs.plus/extension-hyperlink. The latest published version is 2.0.0, dated 2026-08-11. The [Unreleased] section has no fix for this bug.

Edward Saperia reported this in chat on 2026-09-26. He wrote: "Also, if there is a link, and my cursor is before the first character, when I type it shouldn't extend the link".

He also wrote: "if I type here, it shouldn't be part of the link".

A link is in the text. The caret sits just before the first character of the link. A typed character should stay outside the link.

The screenshot shows question? then the caret then a blue https://foreman.. The maintainer must drag the screenshots into this issue. This command cannot attach them.

What we expect

A character typed at the left edge stays plain text. The existing link keeps its text and its href.

The right-edge rule is open. ProseMirror's own link mark sets inclusive to false, which covers both edges. The maintainer decides the right-edge product rule.

Steps to reproduce

  1. Open a pad you can edit.
  2. Type plain text, then a URL that becomes a link, such as question? https://foreman.example.
  3. Put the caret immediately before the h.
  4. Type a letter.
  5. Reported result: the letter joins the link.

Findings

A headless check of the real extension already ran. It used the pad options: autolink: true, exitable: true, linkOnPaste: false, and protocols: ['ftp', 'mailto']. The document used the hyperlink mark. dist/ was newer than src (source 2026-09-22, dist 2026-09-28) and was not rebuilt. A browser was not used. Click placement, a real arrow key, and the iOS caret fix are read from code, not measured.

The symbol is inclusive() in extensions/extension-hyperlink/src/hyperlink.ts. It returns this.options.autolink. The pad and the composer both set autolink: true, so the mark inclusive flag is true.

ProseMirror ResolvedPos.marks() keeps an inclusive mark from the node before the caret. At the start of a parent there is no node before, so it uses the node after. The current reference says the mark is active at its end, and at its start when that start is also the start of the parent.

A keystroke that no plugin consumes uses tr.insertText(text) with no positions. That uses stored marks when they are set, and otherwise the marks at the caret. insertContent('Q') at the left edge, with empty stored marks, also left Q plain.

The last 15 commits on extensions/extension-hyperlink/src do not change inclusive().

Case Result
(a) Left edge, plain text before (question? or question? with no space). Empty stored marks. marks() is none. Letter Q stays plain on text input, on insertText with positions, on insertContent, and after a DOM insert inside the <a> plus the observer flush. A typed space stays plain. The link is unchanged.
(a) Same caret, stored marks forced to the hyperlink mark. Q joins the link. The text Qhttps://foreman. is marked.
(b) Link is the first content of the paragraph. marks() is hyperlink. Letter Q joins, then autolink appendTransaction in createAutolinkInteraction removes the whole mark, because Qhttps://foreman. fails detect. Result is unmarked Qhttps://foreman.. A second letter stays unmarked. A space stays inside the link, because detection still finds a URL. With autolink: false, Q stays plain and the link remains.
(c) Right edge, with plain text after, and at the end of the paragraph. Q joins the link. A space at the end stays marked. With autolink: false, Q stays plain and the link remains.
(d) How the caret arrived. setTextSelection to the boundary, then type: Q stays plain. keyboardShortcut('ArrowLeft') returned true and did not move the caret. Click handler: read from code, not measured. It calls setTextSelection, which clears stored marks. It does not add the hyperlink mark to stored marks. iosCaretFixPlugin returns early on a link target and does not run off iOS Safari. Read from code, not measured.
(e) A space typed just before the link. With plain text before, the space stays plain. At a paragraph-start link, the space stays inside the link. See (b).
(f) List item, heading, and table cell. Same as (a). Q stays plain.
(g) exitable on versus off. The left edge is the same either way. Q stays plain. At the right end, with no ArrowRight, both settings extend the link. After handleKeyDown for ArrowRight, exitable: true leaves the next Q plain. exitable: false lets Q join the link.
(h) Composer. Same mark. autolink: true. exitable left at its default false. Left edge matches (a). Paragraph start matches (b). Right end matches (c).

Edward's pictured caret, with plain text immediately before the link and empty stored marks, did not extend the link in this headless check. The left-edge join that did happen is when the link starts its parent. A letter then strips the whole link. A space stays inside it. Forcing stored marks to the hyperlink mark reproduces the pictured report. No path in the click handler, exitMarkOnArrowRight, or iosCaretFixPlugin was found that sets those stored marks at the left edge. That last point is read from code, not measured in a browser.

Reference points

By default, marks are inclusive, meaning that they get applied to content inserted at their end (as well as at their start when they start at the start of their parent node). For link-type marks, this is usually not the expected behavior, and the `inclusive` property on the mark spec can be set to false to disable that behavior.
Whether this mark should be active when the cursor is positioned at its end (or at its start when that is also the start of the parent node). Defaults to true.
  • prosemirror-schema-basic link mark. The link mark sets inclusive: false.
  • Tiptap Link docs. The page documents autolink with default true. The page does not document inclusive.
  • Tiptap Link source at 3.31.3. inclusive() returns this.options.autolink.
  • Package page extensions/extension-hyperlink/docs/api.md. Autolink also sets the mark inclusive() flag. False stops a character typed at the right edge from joining the link.

Google Docs, Word, Notion, and Slack were not verified from a primary page. They are left out.

Acceptance criteria

  • Typing at the left edge leaves the new character outside the link when plain text is immediately before it.
  • Typing at the left edge leaves the new character outside the link when the link starts the block. This covers a paragraph, a list item, a heading, and a table cell. The existing link stays a link.
  • The maintainer decides the right-edge rule and writes it on this issue. Until that ruling, the right edge stays as it is.
  • Mod+K on a collapsed caret still works.
  • exitable still clears the link on ArrowRight at the right edge.
  • Autolink still creates a link when a URL is typed and finished with whitespace.
  • Click and the preview popover still open.
  • Markdown and HTML round trips still keep the hyperlink mark.
  • The iOS caret fix still returns early on a link target.
  • A clean-room Cypress spec in extensions/extension-hyperlink/cypress/e2e/ fails before the fix.
  • A webapp check covers the pad and the composer.
  • The next release records a Fixed entry in extensions/extension-hyperlink/CHANGELOG.md. Update docs/api.md if the inclusive rule changes. Do not publish in that change.

Blocked by

None for the left edge. The right-edge product rule needs a maintainer decision before that edge changes.

Agent brief

Type: HITL for the right-edge rule. AFK for the left-edge change and the Cypress spec after that rule is written.

Category: bug

Current behavior: See Findings. inclusive() returns autolink. The pad sets it in TipTap.tsx. The composer sets it in useTiptapEditor.ts and leaves exitable at false.

Desired behavior: A character typed at the left edge stays outside the link. The right edge follows the ruling on this issue.

Where to start: extensions/extension-hyperlink/src/hyperlink.ts (inclusive). extensions/extension-hyperlink/src/interactions/autolink.ts (createAutolinkInteraction, the wasLink && !isLink removal). Pad config in apps/webapp/src/components/TipTap/TipTap.tsx. Composer config in apps/webapp/src/components/chatroom/components/MessageComposer/hooks/useTiptapEditor.ts. Search by symbol. File names are hints as of 2026-09-29.

Rules that apply: extensions/extension-hyperlink/AGENTS.md. The mark name stays hyperlink. AGENTS.md §Test Policy allows the Cypress spec because it pins a reported bug. Release notes follow the release-extensions skill. Do not run a release from this issue.

Verify: The Cypress spec fails before the fix. Then check the pad and the composer in a browser. Confirm Mod+K, exitable, autolink, the preview popover, and a markdown round trip.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions