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
- Open a pad you can edit.
- Type plain text, then a URL that becomes a link, such as
question? https://foreman.example.
- Put the caret immediately before the
h.
- Type a letter.
- 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
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.
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 bluehttps://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
inclusiveto false, which covers both edges. The maintainer decides the right-edge product rule.Steps to reproduce
question? https://foreman.example.h.Findings
A headless check of the real extension already ran. It used the pad options:
autolink: true,exitable: true,linkOnPaste: false, andprotocols: ['ftp', 'mailto']. The document used thehyperlinkmark.dist/was newer thansrc(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()inextensions/extension-hyperlink/src/hyperlink.ts. It returnsthis.options.autolink. The pad and the composer both setautolink: true, so the markinclusiveflag 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 leftQplain.The last 15 commits on
extensions/extension-hyperlink/srcdo not changeinclusive().question?orquestion?with no space). Empty stored marks.marks()is none. LetterQstays plain on text input, oninsertTextwith positions, oninsertContent, and after a DOM insert inside the<a>plus the observer flush. A typed space stays plain. The link is unchanged.Qjoins the link. The textQhttps://foreman.is marked.marks()is hyperlink. LetterQjoins, then autolinkappendTransactionincreateAutolinkInteractionremoves the whole mark, becauseQhttps://foreman.failsdetect. Result is unmarkedQhttps://foreman.. A second letter stays unmarked. A space stays inside the link, because detection still finds a URL. Withautolink: false,Qstays plain and the link remains.Qjoins the link. A space at the end stays marked. Withautolink: false,Qstays plain and the link remains.setTextSelectionto the boundary, then type:Qstays plain.keyboardShortcut('ArrowLeft')returned true and did not move the caret. Click handler: read from code, not measured. It callssetTextSelection, which clears stored marks. It does not add the hyperlink mark to stored marks.iosCaretFixPluginreturns early on a link target and does not run off iOS Safari. Read from code, not measured.Qstays plain.exitableon versus off.Qstays plain. At the right end, with no ArrowRight, both settings extend the link. AfterhandleKeyDownfor ArrowRight,exitable: trueleaves the nextQplain.exitable: falseletsQjoin the link.autolink: true.exitableleft at its default false.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, oriosCaretFixPluginwas found that sets those stored marks at the left edge. That last point is read from code, not measured in a browser.Reference points
inclusive: false. The page says:inclusive: false.autolinkwith default true. The page does not documentinclusive.inclusive()returnsthis.options.autolink.extensions/extension-hyperlink/docs/api.md. Autolink also sets the markinclusive()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
exitablestill clears the link on ArrowRight at the right edge.extensions/extension-hyperlink/cypress/e2e/fails before the fix.extensions/extension-hyperlink/CHANGELOG.md. Updatedocs/api.mdif 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()returnsautolink. The pad sets it inTipTap.tsx. The composer sets it inuseTiptapEditor.tsand leavesexitableat 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, thewasLink && !isLinkremoval). Pad config inapps/webapp/src/components/TipTap/TipTap.tsx. Composer config inapps/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 stayshyperlink.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.