@docs.plus/extension-hyperlink 1.5.2 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,454 +1,805 @@
1
- # Hyperlink
1
+ # @docs.plus/extension-hyperlink
2
2
 
3
+ <a href="https://docs.plus"><picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/docs-plus/docs.plus/main/apps/webapp/public/badges/badge-docsplus-dark.svg"><img alt="docs.plus" height="20" src="https://raw.githubusercontent.com/docs-plus/docs.plus/main/apps/webapp/public/badges/badge-docsplus.svg"></picture></a>
3
4
  [![Version](https://img.shields.io/npm/v/@docs.plus/extension-hyperlink.svg?label=version)](https://www.npmjs.com/package/@docs.plus/extension-hyperlink)
4
5
  [![Downloads](https://img.shields.io/npm/dm/@docs.plus/extension-hyperlink.svg)](https://npmcharts.com/compare/@docs.plus/extension-hyperlink)
5
6
  [![License](https://img.shields.io/npm/l/@docs.plus/extension-hyperlink.svg)](https://www.npmjs.com/package/@docs.plus/extension-hyperlink)
7
+ [![Discord](https://img.shields.io/badge/discord-community-5865F2?logo=discord&logoColor=white)](https://discord.gg/25JPG38J59)
6
8
 
7
- The Link extension adds support for `<a>` tags to the editor. The extension is headless, there is no actual <u>**UI**</u> to add, <u>modify</u> or <u>delete</u> links. The usage example below uses the native JavaScript prompt to show you how that could work.
9
+ <p align="center">
10
+ <picture>
11
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/docs-plus/docs.plus/main/extensions/extension-hyperlink/assets/preview-dark.png">
12
+ <img alt="Preview popover on a link — copy, edit, and remove actions" width="640" src="https://raw.githubusercontent.com/docs-plus/docs.plus/main/extensions/extension-hyperlink/assets/preview-light.png">
13
+ </picture>
14
+ </p>
8
15
 
9
- In a real world application, you would probably add a more sophisticated user interface.
16
+ Tiptap hyperlink mark with optional prebuilt popovers for creating, previewing, and editing links.
10
17
 
11
- ## Installation
18
+ Beyond the mark itself, the package covers the link behavior hosts usually hand-roll. Autolink runs as you type: bare domains → `https://`, emails → `mailto:`, E.164 phones → `tel:`. The package also covers markdown round-trip, a catalog of 47 app schemes plus 16 domain matches, and a dangerous-scheme gate at every write boundary. `Mod-k` opens the prebuilt create form with no configuration, and the preview popover needs its own slot. Import `styles.css` to make the prebuilt forms look right, or bring your own UI and skip the import.
12
19
 
13
- ````sh
14
- npm install @docs.plus/extension-hyperlink
15
- ````
20
+ ## Install
16
21
 
17
- ## Settings
22
+ ```sh
23
+ bun add @docs.plus/extension-hyperlink
24
+ ```
18
25
 
19
- ### protocols
26
+ Requires **`@tiptap/core` ^3.22.3** and **`@tiptap/pm` ^3.22.3** (Tiptap 3.x).
20
27
 
21
- Additional custom protocols you would like to be recognized as links.
28
+ Installs two runtime dependencies, `@floating-ui/dom` and `linkifyjs`. The popover engine and the tooltip ship inside `dist`, so they add no third package.
22
29
 
23
- Default: `[]`
30
+ The Quickstart also imports `@tiptap/starter-kit`. Add it with `bun add @tiptap/starter-kit` when your app has none yet.
24
31
 
25
- ````js
26
- Hyperlink.configure({
27
- protocols: ['ftp', 'mailto'],
28
- })
29
- ````
32
+ Module scope touches no browser API, so a server bundle can import the package. [`createPopover`](#floating-popover-primitive), [the three openers](#openers), the three prebuilt popover factories, `attachTooltip`, `createHTMLElement`, and `copyToClipboard` read `document` or `navigator`, so call them in the browser only.
30
33
 
31
- By default, [linkify](https://linkify.js.org/docs/) adds `//` to the end of a protocol however this behavior can be changed by passing `optionalSlashes` option
34
+ Upgrading from `1.x`? Option names, command names, and CSS class names all changed — see [Migrating from 1.x](#migrating-from-1x).
32
35
 
33
- ````js
34
- Hyperlink.configure({
35
- protocols: [
36
- {
37
- scheme: 'tel',
38
- optionalSlashes: true
39
- }
36
+ ## Quickstart
37
+
38
+ The host page needs one mount point: `<div id="editor"></div>`.
39
+
40
+ ```ts
41
+ import { Editor } from '@tiptap/core'
42
+ import StarterKit from '@tiptap/starter-kit'
43
+ import {
44
+ Hyperlink,
45
+ createHyperlinkPopover,
46
+ editHyperlinkPopover,
47
+ previewHyperlinkPopover
48
+ } from '@docs.plus/extension-hyperlink'
49
+ import '@docs.plus/extension-hyperlink/styles.css'
50
+
51
+ const editor = new Editor({
52
+ element: document.querySelector('#editor'),
53
+ content: '<p>Try <a href="https://example.com">this link</a>.</p>',
54
+ extensions: [
55
+ // Disable StarterKit's bundled link mark — see Caveats.
56
+ StarterKit.configure({ link: false }),
57
+ Hyperlink.configure({
58
+ popovers: {
59
+ previewHyperlink: previewHyperlinkPopover,
60
+ editHyperlink: editHyperlinkPopover,
61
+ createHyperlink: createHyperlinkPopover
62
+ }
63
+ })
40
64
  ]
41
65
  })
42
- ````
66
+ ```
67
+
68
+ The `styles.css` import is optional. `popovers.previewHyperlink` is not: without it, a click on a link opens nothing. The snippet also styles no document link — see [Popovers](#popovers) and [Styling](#styling).
43
69
 
44
- ### autolink
70
+ ## Options
45
71
 
46
- If enabled, it adds links as you type.
72
+ Every key below goes into `Hyperlink.configure({ … })`.
47
73
 
48
- Default: `true`
74
+ | Option | Type | Default | Description |
75
+ | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
76
+ | `autolink` | `boolean` | `true` | Converts URLs to links as you type. It also sets the mark's `inclusive()`, so `false` stops a character typed at the right edge from joining the link. |
77
+ | `openOnClick` | `boolean` | `true` | Gates the whole click plugin. Primary click opens the preview popover. With no `popovers.previewHyperlink`, a read-only editor opens the link in a new tab, and an editable editor opens no popover. `false` removes every pointer handler the package binds, including the click guard and the middle-click gate. |
78
+ | `linkOnPaste` | `boolean` | `true` | Wraps a non-empty selection in a link when the clipboard holds exactly one URL. It gates that path only — a pasted bare URL still autolinks at `false`. See [Caveats](#caveats). |
79
+ | `protocols` | `Array<LinkProtocolOptions \| string>` | `[]` | Extra schemes registered with [linkifyjs](https://linkify.js.org), which is what makes paste detect them. Each scheme registers once per process. The built-in catalog is separate and registers nothing with linkifyjs. See [URL handling](#url-handling). |
80
+ | `HTMLAttributes` | `Partial<HyperlinkAttributes>` | `{ target: null, rel: 'noopener noreferrer nofollow', class: null }` | Attributes merged onto every rendered `<a>`. A `null` value renders nothing, so the default anchor carries `rel` only. A `target` set here does reach the DOM — see [Caveats](#caveats). |
81
+ | `popovers` | `{ previewHyperlink?: ((options: PreviewHyperlinkOptions) => HTMLElement \| null) \| null; editHyperlink?: ((options: EditHyperlinkOptions) => HTMLElement \| null) \| null; createHyperlink?: ((options: CreateHyperlinkOptions) => HTMLElement \| null) \| null }` | `{ previewHyperlink: null, editHyperlink: null, createHyperlink: null }` | One factory per popover slot. The three openers fall back to the prebuilt factory when a slot is `null`. The click handler does not: leave `previewHyperlink` at `null` and a click on a link opens no popover. A factory that returns `null` opts out of that popover. See [Popovers](#popovers). |
82
+ | `validate` | `(url: string) => boolean` | `undefined` | URL gate at every write boundary, after `isSafeHref`. Return `false` to reject. See [`validate` vs `isAllowedUri`](#validate-vs-isalloweduri). |
83
+ | `defaultProtocol` | `string` | `'https'` | Scheme used when promoting bare domains (`example.com` → `${defaultProtocol}://example.com`). See [URL handling](#url-handling). |
84
+ | `isAllowedUri` | `(uri: string, ctx: IsAllowedUriContext) => boolean` | `undefined` | URL gate, Tiptap-canon shape. Same write boundaries as `validate`; `ctx` carries `{ defaultValidate, protocols, defaultProtocol }`. See [`validate` vs `isAllowedUri`](#validate-vs-isalloweduri). |
85
+ | `shouldAutoLink` | `(uri: string) => boolean` | `undefined` | Per-URI autolink veto. The autolink plugin, the paste handler, and the linkify paste rule all consult it. An explicit `setHyperlink` write skips it, because that is user intent. |
86
+ | `enableClickSelection` | `boolean` | `false` | With `true`, a click inside a link in an editable editor selects the whole mark range. That expansion also needs a collapsed selection. Every other case places the caret at the clicked position, and keeps a non-empty selection only when it overlaps the link. A read-only editor is one of those cases. All paths need a mounted preview popover, so with an empty `popovers.previewHyperlink` slot the package writes no selection at all. Mirrors `@tiptap/extension-link`. |
87
+ | `exitable` | `boolean` | `false` | With `true`, ArrowRight at the end of a hyperlink mark leaves the mark, so the next typed character is plain text. |
49
88
 
50
- ````js
89
+ These option shapes need an example:
90
+
91
+ ```ts
51
92
  Hyperlink.configure({
52
- autolink: false,
93
+ protocols: ['ftp', { scheme: 'tel', optionalSlashes: true }],
94
+ isAllowedUri: (uri, ctx) => ctx.defaultValidate(uri) && !uri.includes('blocked.example'),
95
+ shouldAutoLink: (uri) => !uri.startsWith('@')
53
96
  })
54
- ````
97
+ ```
55
98
 
56
- ### openOnClick
99
+ `LinkProtocolOptions` is `{ scheme: string; optionalSlashes?: boolean }`.
57
100
 
58
- If enabled, links will be opened on click.
101
+ ### `validate` vs `isAllowedUri`
59
102
 
60
- Default: `true`
103
+ Both are URL gates. Both run at every write boundary — set, edit, paste, input rule, autolink — after the built-in `isSafeHref` gate. Only the signature differs.
61
104
 
62
- ````js
63
- Hyperlink.configure({
64
- openOnClick: false,
65
- })
66
- ````
105
+ - **`validate(url)`** — predates `isAllowedUri`. Use it for a plain URL-to-boolean check, such as "only http(s)" or "block this domain".
106
+ - **`isAllowedUri(uri, ctx)`** — Tiptap-canon shape, drop-in compatible with `@tiptap/extension-link` policies. Use it when you port an existing policy, or when you want `ctx.defaultValidate(uri)` to reuse the safety check.
67
107
 
68
- ### linkOnPaste
108
+ Pick one. Setting both works, because they compose and a URL must pass both, but that is rarely the intent.
69
109
 
70
- Adds a link to the current selection if the pasted content only contains an url.
110
+ `isSafeHref` plus your `validate` and `isAllowedUri` hooks form the composed gate. The rest of this README uses that name.
71
111
 
72
- Default: `true`
112
+ `validateURL(url, { customValidator })` is a different tool. It is the form-level shape check the prebuilt popovers run before they call a command. It runs `isSafeHref` first, then the shape check, then your `customValidator`.
73
113
 
74
- ````js
75
- Hyperlink.configure({
76
- linkOnPaste: false,
77
- })
78
- ````
114
+ ## Commands
79
115
 
80
- ### HTMLAttributes
116
+ Every command below lands on `editor.commands` and on `editor.chain()`. On `editor.commands`, each returns `boolean`, and `false` marks a no-op.
81
117
 
82
- Custom HTML attributes that should be added to the rendered HTML tag.
118
+ | Command | Description |
119
+ | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
120
+ | `setHyperlink({ href, target?, title?, image? })` | Writes the hyperlink mark over the current selection. Returns `false` when `href` is empty, when the composed gate rejects it, or when the schema cannot apply the mark there. |
121
+ | `unsetHyperlink()` | Removes the mark from the current selection, and extends an empty selection across the whole mark first. |
122
+ | `toggleHyperlink({ href, target?, title?, image? })` | Removes the mark when one is active at the selection, and writes it otherwise. Same gates as `setHyperlink`. |
123
+ | `setLink` / `unsetLink` / `toggleLink` | Aliases that share the canonical implementations, so a policy change reaches both names. They collide with StarterKit's bundled link mark — see [Caveats](#caveats). |
124
+ | `editHyperlink({ newURL?, newText?, title?, image? })` | Updates any combination of href, inner text, `title`, and `image` on the link at the current selection. |
125
+ | `editHyperlinkHref(url)` | Shorthand for an href-only edit. Same gates as `editHyperlink`. |
126
+ | `editHyperlinkText(text)` | Shorthand for a text-only edit. |
127
+ | `openCreateHyperlinkPopover(attributes?)` | Opens the create-link form anchored to the current selection. The command uses `popovers.createHyperlink` when that slot holds a factory, and the prebuilt form otherwise. Returns `false` only when a configured factory returns `null`. |
83
128
 
84
- ````js
85
- Hyperlink.configure({
86
- HTMLAttributes: {
87
- class: 'my-custom-class',
88
- },
89
- })
90
- ````
129
+ `editHyperlink` returns `false` (no-op) on four paths, checked in this order:
91
130
 
92
- ### Modals
131
+ 1. `newURL` fails the shape check, for example `https://googlecom`.
132
+ 2. The schema carries no mark under that name.
133
+ 3. No hyperlink mark covers the selection, or the mark at that position cannot be read.
134
+ 4. The composed gate rejects `newURL`.
93
135
 
94
- The Modals configuration option lets you incorporate an interactive user interface similar to Google Docs for **setting** and **previewing** hyperlinks. This provides users with a more intuitive and interactive experience;
136
+ The prebuilt edit form closes on path 3, because there is nothing left to edit. It shows an inline error on the other paths and stays open.
95
137
 
96
- - [Dive into the code](https://github.com/HMarzban/extension-hyperlink/blob/4f37ffa18237f10d76c316844b1c2ab20b751fe9/packages/nextjs/src/components/Tiptap.tsx#L21-L28)
97
- - [Demo](https://github.com/HMarzban/extension-hyperlink#test-drive-with-our-demo-)
98
-
99
- <details>
100
- <summary>The `previewHyperlinkModal` function</summary>
138
+ `openCreateHyperlinkPopover` opens UI, so `editor.can().openCreateHyperlinkPopover()` reports availability without mounting anything. With nothing selected, the prebuilt form inserts the typed URL as its own link text.
101
139
 
102
140
  ```ts
103
- function previewHyperlinkModal(options) {
104
- const href = options.link.href;
141
+ editor.chain().focus().setHyperlink({ href: 'https://example.com' }).run()
142
+ editor.getAttributes('hyperlink').href // read the current href
143
+ ```
144
+
145
+ ## Keyboard shortcuts
105
146
 
106
- const hyperlinkModal = document.createElement("div");
107
- const removeButton = document.createElement("button");
108
- const copyButton = document.createElement("button");
147
+ | Shortcut | Context | Action |
148
+ | ------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
149
+ | `Mod-k` | `document` | Opens the create-link form anchored to the current selection. |
150
+ | `ArrowRight` | `document` | With `exitable: true`, leaves the hyperlink mark at its right edge, so the next character is plain text. |
151
+ | `Escape` | `popover` | Hides the popover. Focus stays where it is. |
152
+ | `Tab` / `Shift-Tab` | `popover` | Moves focus to the next or previous control inside the popover, and wraps at both ends. |
153
+ | `Escape` | `create-link form` | Hides the popover and returns focus to the editor from the URL field. From the Apply button the shell handler hides the popover and leaves focus where it is. |
154
+ | `Escape` | `edit-link form` | Hides the popover and returns focus to the editor from any control. |
109
155
 
110
- const newBubble = document.createElement("div");
111
- newBubble.classList.add("hyperlink-preview-modal__metadata");
156
+ The shell binds `Escape` on the popover root, and `Tab` on each control inside it. Both keys therefore need focus inside the popover. The preview popover takes no focus when it opens, so dismiss it with an outside click.
112
157
 
113
- const hrefTitle = document.createElement("a");
114
- hrefTitle.setAttribute("target", "_blank");
115
- hrefTitle.setAttribute("rel", "noreferrer");
116
- hrefTitle.setAttribute("href", href);
117
- hrefTitle.innerText = href;
158
+ The mark runs at `priority: 1000`, above the Tiptap default `100`. `@tiptap/extension-link` binds no keyboard shortcut, so `Mod-k` is never contested. The two marks do collide on commands and on the `a[href]` parse rule — see [Caveats](#caveats).
118
159
 
119
- newBubble.append(hrefTitle);
160
+ ## Caveats
120
161
 
121
- hyperlinkModal.classList.add("hyperlink-preview-modal");
162
+ - **`StarterKit.configure({ link: false })` is required, not stylistic.** StarterKit v3 bundles `@tiptap/extension-link` by default. Both marks sit at `priority: 1000`, and Tiptap merges every `addCommands()` into one flat map. `setLink` / `unsetLink` / `toggleLink` then resolve to whichever mark comes later in your `extensions` array, with no warning. Both marks also claim the `a[href]` parse rule, and ProseMirror applies only the first match: one mark takes every anchor and the other never parses. If the upstream mark wins, this package's [`isSafeHref`](#security) gate never runs on parsed or pasted HTML, and its popovers never attach.
163
+ - **`linkOnPaste: false` does not stop every paste from linking.** The option gates the paste-over-selection plugin alone. The linkify paste rule stays registered, so a pasted bare URL still becomes a link. To stop that path too, set `shouldAutoLink: () => false`, which every autolink path consults.
164
+ - **The markdown `[text](url)` input rule is always on.** No option turns it off, so typing a literal `[a](b)` writes a link. Remove it afterwards with `editor.commands.unsetHyperlink()`. See [Markdown](#markdown).
165
+ - **`HTMLAttributes.target` reaches the DOM.** The mark attribute `target` carries `rendered: false`, so a stored `_blank` never renders. The option object is merged separately in `renderHTML`, so `HTMLAttributes: { target: '_blank' }` does emit `<a target="_blank">`. A browser-driven `target="_blank"` navigation skips the click gate, which is the risk the mark-level `rendered: false` exists to block. Leave `target` at `null` and let the click handler open the link.
166
+ - **The mark name is fixed at `hyperlink`.** Stored documents and the markdown wiring both key on it. `openPreviewHyperlink`, `openEditHyperlink`, and `openCreateHyperlink` throw when no extension answers to that name. Do not rename the mark.
167
+ - **[`getDefaultController()`](#ui-controller) owns one controller per bundle, not one per page.** A host that also loads `@docs.plus/extension-hypermultimedia` gets two controllers. Opening a popover in one package does not dismiss the popover of the other. Close the other popover yourself when you open one.
122
168
 
123
- removeButton.classList.add("hyperlink-preview-modal__remove-button");
124
- removeButton.innerHTML = "remove";
169
+ ## Styling
125
170
 
126
- copyButton.classList.add("hyperlink-preview-modal__copy-button");
127
- copyButton.innerHTML = "copy";
171
+ The prebuilt popovers ship a stylesheet — import it once when you use them:
128
172
 
129
- removeButton.addEventListener("click", () => {
130
- options.tippy.hide();
131
- return options.editor.chain().focus().unsetHyperlink().run();
132
- });
173
+ ```ts
174
+ import '@docs.plus/extension-hyperlink/styles.css'
175
+ ```
176
+
177
+ The package's JavaScript never imports this file. Skip the import with a fully custom UI, and no CSS from this package reaches your bundle.
178
+
179
+ The stylesheet skins the popovers only. The mark renders a plain `<a>` with no class, so style your document links yourself:
180
+
181
+ ```ts
182
+ Hyperlink.configure({ HTMLAttributes: { class: 'my-link' } })
183
+ ```
184
+
185
+ ```css
186
+ a.my-link {
187
+ color: #2563eb;
188
+ text-decoration: underline;
189
+ }
190
+ ```
133
191
 
134
- copyButton.addEventListener("click", () => {
135
- options.tippy.hide();
136
- navigator.clipboard.writeText(href);
137
- });
192
+ ### Theming
138
193
 
139
- hyperlinkModal.append(newBubble, copyButton, removeButton);
194
+ Every visual token is a `--hl-*` custom property. Colors use [`light-dark()`](https://developer.mozilla.org/docs/Web/CSS/color_value/light-dark), so the popover follows the nearest ancestor's `color-scheme`, or the OS preference when none is set.
140
195
 
141
- return hyperlinkModal;
196
+ <details>
197
+ <summary>Default values</summary>
198
+
199
+ ```css
200
+ :root {
201
+ --hl-bg: light-dark(#ffffff, #1f2937);
202
+ --hl-fg: light-dark(#111827, #f3f4f6);
203
+ --hl-muted: light-dark(#6b7280, #9ca3af);
204
+ --hl-border: light-dark(#e5e7eb, #374151);
205
+ --hl-hover: light-dark(#f3f4f6, #374151);
206
+ --hl-accent: light-dark(#2563eb, #60a5fa);
207
+ --hl-accent-fg: light-dark(#ffffff, #0b1220);
208
+ --hl-danger: light-dark(#dc2626, #f87171);
209
+ --hl-shadow:
210
+ 0 20px 25px -5px light-dark(rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0.5)),
211
+ 0 8px 10px -6px light-dark(rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0.4));
212
+ --hl-radius: 10px;
213
+ --hl-radius-sm: 8px;
214
+ --hl-font:
215
+ ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
216
+ --hl-font-size: 14px;
217
+ --hl-transition: 120ms ease-out;
142
218
  }
219
+ ```
220
+
221
+ </details>
143
222
 
223
+ JavaScript positions the popover shell; the stylesheet does not. The shell gets `position: fixed` and an inline `z-index: 9999`. The three prebuilt popovers pin that value and expose no option for it. `createPopover({ zIndex })` sets the stacking of a popover you build yourself — see [Floating-popover primitive](#floating-popover-primitive).
224
+
225
+ If your app has a light/dark toggle, set `color-scheme` on the theme root and the popover follows along:
226
+
227
+ ```css
228
+ html[data-theme='light'] {
229
+ color-scheme: light;
230
+ }
231
+ html[data-theme='dark'] {
232
+ color-scheme: dark;
233
+ }
144
234
  ```
145
235
 
236
+ > **Bundler note.** Some CSS minifiers (lightningcss, for one) down-level `light-dark()` into a `@media (prefers-color-scheme: dark)` block, which pins colors to the OS preference. Re-declare the tokens on each branch when that happens — the attribute selector wins over the media query:
237
+ >
238
+ > ```css
239
+ > html[data-theme='light'] {
240
+ > --hl-bg: #ffffff;
241
+ > --hl-fg: #111827; /* … */
242
+ > }
243
+ > html[data-theme='dark'] {
244
+ > --hl-bg: #1f2937;
245
+ > --hl-fg: #f3f4f6; /* … */
246
+ > }
247
+ > ```
248
+
249
+ ### Class names
250
+
251
+ Stable class names you can target:
252
+
253
+ | Class | Element |
254
+ | ---------------------------- | ---------------------------------------------------------------------------- |
255
+ | `.floating-popover` | Popover container (adds `.visible` after mount). |
256
+ | `.floating-popover-arrow` | Arrow pointing at the anchor (adds `-top` / `-bottom` / `-left` / `-right`). |
257
+ | `.floating-popover-content` | Content wrapper inside the popover. |
258
+ | `.floating-tooltip` | Tooltip on popover icon buttons (adds `.visible`). |
259
+ | `.hyperlink-create-popover` | Create-link form root. |
260
+ | `.hyperlink-preview-popover` | Preview toolbar root. |
261
+ | `.hyperlink-edit-popover` | Edit-link form root. |
262
+ | `.inputs-wrapper` | Input group container (adds `.error` on validation). |
263
+ | `.text-wrapper` | Edit-form text input row (adds `.error`). |
264
+ | `.href-wrapper` | Edit-form URL input row (adds `.error`). |
265
+ | `.buttons-wrapper` | Button group container. |
266
+ | `.back-button` | Edit-form back action. |
267
+ | `.apply-button` | Edit-form apply action. |
268
+ | `.copy` | Preview-toolbar copy icon button. |
269
+ | `.edit` | Preview-toolbar edit icon button. |
270
+ | `.remove` | Preview-toolbar remove icon button. |
271
+ | `.search-icon` | Leading icon inside an input group. |
272
+ | `.error-message` | Validation error text (shown with `.show`). |
273
+
274
+ The bundled `@docs.plus/floating-tooltip` ships the tooltips on the prebuilt popovers' icon buttons. It appends one bubble per bundle to the body, and shows it on hover and on keyboard focus. `styles.css` skins that bubble with a fixed literal block, and the block stays in lockstep with extension-hypermultimedia. See [Tooltip primitive](#tooltip-primitive) to attach the same labels to your own buttons.
275
+
276
+ ## Popovers
277
+
278
+ Three slots cover the whole link lifecycle: preview on click, create on `Mod-k`, and edit from preview.
279
+
280
+ ### Gallery
281
+
282
+ Prebuilt create, preview, and edit popovers (`styles.css` + `popovers` config). Each screenshot has a light and a dark version, and follows your system preference.
283
+
284
+ <details>
285
+ <summary><strong>Create</strong> — Mod+K</summary>
286
+
287
+ <p align="center">
288
+ <picture>
289
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/docs-plus/docs.plus/main/extensions/extension-hyperlink/assets/create-dark.png">
290
+ <img alt="Create-link popover with URL field and Apply button" width="640" src="https://raw.githubusercontent.com/docs-plus/docs.plus/main/extensions/extension-hyperlink/assets/create-light.png">
291
+ </picture>
292
+ </p>
293
+
146
294
  </details>
147
295
 
148
296
  <details>
149
- <summary>The `setHyperlinkModal` function</summary>
297
+ <summary><strong>Preview</strong> — click a link</summary>
150
298
 
151
- ```ts
152
- import { Editor } from "@tiptap/core";
153
- import { find } from "linkifyjs";
299
+ <p align="center">
300
+ <picture>
301
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/docs-plus/docs.plus/main/extensions/extension-hyperlink/assets/preview-dark.png">
302
+ <img alt="Preview popover on a link — copy, edit, and remove actions" width="640" src="https://raw.githubusercontent.com/docs-plus/docs.plus/main/extensions/extension-hyperlink/assets/preview-light.png">
303
+ </picture>
304
+ </p>
154
305
 
155
- let tooltip = undefined;
306
+ </details>
156
307
 
157
- function setHyperlinkModal(options) {
158
- // Create the tooltip instance
159
- if (!tooltip) tooltip = new options.Tooltip(options);
308
+ <details>
309
+ <summary><strong>Edit</strong> — preview → Edit</summary>
310
+
311
+ <p align="center">
312
+ <picture>
313
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/docs-plus/docs.plus/main/extensions/extension-hyperlink/assets/edit-dark.png">
314
+ <img alt="Edit-link popover with URL and text fields and Apply button" width="640" src="https://raw.githubusercontent.com/docs-plus/docs.plus/main/extensions/extension-hyperlink/assets/edit-light.png">
315
+ </picture>
316
+ </p>
160
317
 
161
- // Initialize the tooltip
162
- let { tippyModal } = tooltip.init();
318
+ </details>
163
319
 
164
- const hyperlinkModal = document.createElement("div");
165
- const buttonsWrapper = document.createElement("div");
166
- const inputsWrapper = document.createElement("div");
320
+ Three ways to use them:
167
321
 
168
- hyperlinkModal.classList.add("hyperlink-set-modal");
322
+ - **Use the prebuilt popovers** — slot the three factories into `Hyperlink.configure({ popovers })`, as in [Quickstart](#quickstart). The shell handles positioning, dismissal, focus, and cleanup.
323
+ - **Open them from outside the editor** — call [the openers](#openers) from a toolbar button or a React component.
324
+ - **Replace one or all of them** — pass your own factory into the matching slot. See [Bring your own popover](#bring-your-own-popover). For a popover that is not anchored to a hyperlink, or to observe popover state from outside, see [Advanced](#advanced).
169
325
 
170
- buttonsWrapper.classList.add("hyperlink-set-modal__buttons-wrapper");
171
- inputsWrapper.classList.add("hyperlink-set-modal__inputs-wrapper");
326
+ ### Popover-factory option shapes
172
327
 
173
- // create a form that contain url input and a button for submit
174
- const form = document.createElement("form");
175
- const input = document.createElement("input");
176
- const button = document.createElement("button");
328
+ Every factory takes one `options` argument. The shape depends on the slot.
177
329
 
178
- input.setAttribute("type", "text");
179
- input.setAttribute("placeholder", "https://example.com");
180
- button.setAttribute("type", "submit");
181
- button.innerText = "Submit";
330
+ **`PreviewHyperlinkOptions`** — passed to `popovers.previewHyperlink` on every link click.
182
331
 
183
- inputsWrapper.append(input);
184
- buttonsWrapper.append(button);
185
- form.append(inputsWrapper, buttonsWrapper);
332
+ | Field | Type | Description |
333
+ | --------------- | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
334
+ | `editor` | `Editor` | The Tiptap editor instance. Reach the `EditorView` through `editor.view` when you need it. |
335
+ | `link` | `HTMLAnchorElement` | The clicked `<a>` node. It anchors the popover, so the popover follows the link on scroll. |
336
+ | `nodePos` | `number` | Document position of the link mark. Pair it with `editor.state.doc.nodeAt(nodePos)` to read the mark. |
337
+ | `attrs` | `HyperlinkAttributes` | **Required.** The mark's stored attributes. Prefer `attrs.href` over `link.href` (the DOM property resolves against `document.baseURI` and would leak the host origin). |
338
+ | `validate?` | `(url: string) => boolean` | The configured `validate` option, forwarded. |
339
+ | `isAllowedUri?` | `(uri: string) => boolean` | The composed gate. **Your popover must call this before any navigation, including a rendered `<a href>` and any `window.open`.** |
186
340
 
187
- hyperlinkModal.append(form);
341
+ Return `null` to opt out for this click, for example to open a mobile bottom sheet. The package then mounts no popover.
188
342
 
189
- tippyModal.innerHTML = "";
190
- tippyModal.append(hyperlinkModal);
191
- tooltip.update(options.editor.view, {
192
- arrow: options.roundArrow,
193
- });
343
+ **`CreateHyperlinkOptions`** — passed to `popovers.createHyperlink` when `Mod-k` or `editor.commands.openCreateHyperlinkPopover()` fires.
194
344
 
195
- // make sure
196
- setTimeout(() => input.focus();, 100);
345
+ | Field | Type | Description |
346
+ | --------------- | ------------------------------ | -------------------------------------------------------------------------------------------------------------- |
347
+ | `editor` | `Editor` | The Tiptap editor instance. |
348
+ | `extensionName` | `string` | The mark name, always `'hyperlink'`. Pass it straight to commands so your factory names the mark in one place. |
349
+ | `attributes` | `Partial<HyperlinkAttributes>` | Pre-fill values forwarded from `openCreateHyperlinkPopover(attributes?)`. Empty when `Mod-k` fires. |
350
+ | `validate?` | `(url: string) => boolean` | The configured `validate` option, forwarded. |
197
351
 
198
- // event listenr for submit button
199
- form.addEventListener("submit", (event) => {
200
- event.preventDefault();
201
- const url = input.value;
352
+ Return `null` when the popover cannot open, for example when DOM construction fails. The command then returns `false`.
202
353
 
203
- if (!url) return;
354
+ **`EditHyperlinkOptions`** — passed to `popovers.editHyperlink`, and to `openEditHyperlink(opts)` when you call it from outside a preview popover.
204
355
 
205
- const sanitizeURL = find(url)
206
- .filter((link) => link.isLink)
207
- .filter((link) => {
208
- if (options.validate) {
209
- return options.validate(link.value);
210
- }
211
- return true;
212
- })
213
- .at(0);
356
+ | Field | Type | Description |
357
+ | --------------- | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
358
+ | `editor` | `Editor` | The Tiptap editor instance. |
359
+ | `link` | `HTMLAnchorElement` | The link being edited. It anchors the popover. |
360
+ | `nodePos?` | `number` | Document position of the link mark. It lets edit and Back recover the anchor when ProseMirror replaces duplicate link DOM nodes. |
361
+ | `validate?` | `(url: string) => boolean` | The configured `validate` option, forwarded — the edit form rejects the same URLs as `Mod-k`. |
362
+ | `isAllowedUri?` | `(uri: string) => boolean` | The composed gate, forwarded so Back re-opens the preview under the same navigation policy. |
363
+ | `onBack?` | `() => void` | Overrides the default Back behavior. By default, Back re-opens the preview popover through `openPreviewHyperlink`. Use it as an escape hatch. |
364
+ | `markName?` | `string` | Mark name to extend the range over, always `'hyperlink'`. It exists so your factory passes one name through to `extendMarkRange`. |
214
365
 
215
- if (!sanitizeURL?.href) return;
366
+ ### Openers
216
367
 
217
- tooltip?.hide();
368
+ The three named openers open a popover from outside the click handler. Call them from a keyboard shortcut, an outer toolbar button, a React modal, or a Tiptap command.
218
369
 
219
- return options.editor
220
- .chain()
221
- .setMark(options.extentionName, { href: sanitizeURL.href })
222
- .setMeta("preventautohyperlink", true)
223
- .run();
224
- });
370
+ | Opener | Anchor | Slot | Returns |
371
+ | ------------------------------------------ | ------------------------- | --------------------------- | -------------------------------------------------------- |
372
+ | `openPreviewHyperlink(opts)` | `opts.link` (anchor node) | `popovers.previewHyperlink` | `boolean` — `false` when the factory returns `null`. |
373
+ | `openEditHyperlink(opts)` | `opts.link` (anchor node) | `popovers.editHyperlink` | `void` — the opt-out is not observable at the call site. |
374
+ | `openCreateHyperlink(editor, attributes?)` | Current selection | `popovers.createHyperlink` | `boolean` — `false` when the factory returns `null`. |
375
+
376
+ Each opener reads its slot from `Hyperlink.configure({ popovers })`. Each opener falls back to the prebuilt factory when the slot is empty. It then builds the content and mounts through the controller. A factory that returns `null` opts out, which is the usual mobile path, and the opener then does nothing. Read the return value when your host routes to a bottom sheet.
377
+
378
+ `buildPreviewOptionsFromAnchor({ editor, link, nodePos?, validate?, isAllowedUri?, markName? })` rebuilds a full `PreviewHyperlinkOptions` from a live `<a>` node, with no hand-rolled `posAtDOM` → `mark.attrs` lookup. `openPreviewHyperlink(buildPreviewOptionsFromAnchor({ editor, link }))` is the canonical edit-to-preview handoff.
379
+
380
+ ### Bring your own popover
381
+
382
+ Three minimal factories that match the option shapes above. A factory supplies content only. The shell keeps its per-popover ARIA semantics: `role="toolbar"` for preview, and `role="dialog"` named "Add link" or "Edit link" for create and edit. Your popovers therefore stay accessible with no extra wiring. The shell also owns dismissal and focus — see [Floating-popover primitive](#floating-popover-primitive). The create and edit examples call `validateURL(url, { customValidator: validate })` for the form-level shape check — see [`validate` vs `isAllowedUri`](#validate-vs-isalloweduri).
383
+
384
+ <details>
385
+ <summary><b>Custom <code>previewHyperlink</code></b></summary>
386
+
387
+ ```ts
388
+ import {
389
+ getDefaultController,
390
+ Hyperlink,
391
+ isSafeHref,
392
+ type PreviewHyperlinkOptions
393
+ } from '@docs.plus/extension-hyperlink'
394
+
395
+ function previewHyperlink(options: PreviewHyperlinkOptions): HTMLElement {
396
+ const { editor, attrs, isAllowedUri } = options
397
+ const root = document.createElement('div')
398
+
399
+ // A rendered `<a target="_blank">` navigates on click, so it is a navigation
400
+ // sink like `window.open`. Gate it on the composed gate, and fall back to
401
+ // `isSafeHref` when the factory runs outside the click handler.
402
+ const isOpenable = isAllowedUri ?? isSafeHref
403
+ // `attrs.href` is `string | null` and can predate the current policy.
404
+ const raw = attrs.href ?? ''
405
+ const href = isOpenable(raw) ? raw : ''
406
+
407
+ const link = document.createElement('a')
408
+ link.href = href
409
+ link.textContent = attrs.href ?? ''
410
+ link.target = '_blank'
411
+ link.rel = 'noopener noreferrer'
412
+
413
+ // `title` and `image` are mark-only metadata: the package stores them
414
+ // and renders neither. Read them here to show a link card. `isSafeHref`
415
+ // runs on `image`, and it rejects `data:`, so an inline favicon is dropped.
416
+ if (attrs.title) link.textContent = attrs.title
417
+ if (isSafeHref(attrs.image)) {
418
+ const favicon = document.createElement('img')
419
+ favicon.src = attrs.image
420
+ favicon.width = 16
421
+ favicon.height = 16
422
+ root.append(favicon)
423
+ }
424
+
425
+ const remove = document.createElement('button')
426
+ remove.textContent = 'Remove'
427
+ remove.addEventListener('click', () => {
428
+ getDefaultController().close()
429
+ editor.chain().focus().unsetHyperlink().run()
430
+ })
431
+
432
+ root.append(link, remove)
433
+ return root
225
434
  }
435
+
436
+ const HyperlinkWithPreview = Hyperlink.configure({ popovers: { previewHyperlink } })
226
437
  ```
227
438
 
439
+ `configure` returns a new extension instance. Pass `HyperlinkWithPreview` into `new Editor({ extensions: [...] })`. Drop the value and a click on a link opens no popover.
440
+
228
441
  </details>
229
442
 
230
443
  <details>
231
- <summary>The Hyperlink styles.scss</summary>
232
-
233
- ```scss
234
- .tippy-box {
235
- .hyperlink-preview-modal,
236
- .hyperlink-set-modal,
237
- .hyperlink-edit-modal {
238
- background-color: #fff;
239
- border-radius: 10px;
240
- border: 1px solid #dadce0;
241
- display: flex;
242
- align-items: center;
243
- justify-content: space-between;
244
- padding: 6px 6px;
245
- box-shadow: 0 1px 3px 1px rgba(60, 64, 67, 0.15);
246
- margin-top: -6px;
247
-
248
- &__metadata {
249
- width: 200px;
250
- display: flex;
251
- align-items: center;
252
- justify-content: flex-end;
253
- flex-direction: row-reverse;
254
- a {
255
- font-size: 0.9rem;
256
- margin-right: 6px;
257
- white-space: nowrap;
258
- overflow: hidden;
259
- text-overflow: ellipsis;
260
- }
261
- img {
262
- width: 18px;
263
- height: 18px;
264
- border-radius: 50%;
265
- margin-right: 8px;
266
- }
267
- }
444
+ <summary><b>Custom <code>createHyperlink</code></b></summary>
268
445
 
269
- &__remove-button,
270
- &__edit-button,
271
- &__copy-button,
272
- &__apply-button {
273
- width: 30px;
274
- height: 30px;
275
- border-radius: 50%;
276
- margin: 0 0.25rem;
277
- display: flex;
278
- align-items: center;
279
- justify-content: center;
280
- transition: background-color 0.1s ease-in-out;
281
- &:hover {
282
- background-color: #eee;
283
- }
284
- > svg {
285
- width: 16px;
286
- height: 16px;
287
- }
288
- }
289
-
290
- form {
291
- display: flex;
292
- align-items: flex-end;
293
- width: 100%;
294
- input {
295
- border: 1px solid #dadce0;
296
- border-radius: 6px;
297
- padding: 0.4rem 0.8rem;
298
- margin-bottom: 0.2rem;
299
- width: 100%;
300
- &:last-of-type {
301
- margin-bottom: 0;
302
- }
303
- }
304
- .hyperlink-set-modal__buttons-wrapper,
305
- .hyperlink-edit-modal__buttons-wrapper {
306
- margin-left: 8px;
307
- button {
308
- border-radius: 6px;
309
- padding: 4px 14px;
310
- width: 70px;
311
- margin-bottom: 0.2rem;
312
- color: #1a73e8;
313
- &:hover {
314
- background: rgba(26, 115, 232, 0.04);
315
- color: #174ea6;
316
- }
317
- }
318
- }
319
- }
320
- }
321
-
322
- .tippy-svg-arrow {
323
- top: -6px !important;
324
- }
446
+ ```ts
447
+ import {
448
+ getDefaultController,
449
+ Hyperlink,
450
+ validateURL,
451
+ type CreateHyperlinkOptions
452
+ } from '@docs.plus/extension-hyperlink'
453
+
454
+ function createHyperlink(options: CreateHyperlinkOptions): HTMLElement {
455
+ const { editor, validate } = options
456
+ const form = document.createElement('form')
457
+
458
+ const input = document.createElement('input')
459
+ input.type = 'url'
460
+ input.placeholder = 'https://example.com'
461
+
462
+ const submit = document.createElement('button')
463
+ submit.type = 'submit'
464
+ submit.textContent = 'Apply'
465
+
466
+ form.append(input, submit)
467
+ form.addEventListener('submit', (event) => {
468
+ event.preventDefault()
469
+ const url = input.value.trim()
470
+ if (!validateURL(url, { customValidator: validate })) return
471
+
472
+ // Delegate to the canonical command — it normalizes the protocol
473
+ // and runs the composed gate. Returns `false` when the gate rejects,
474
+ // so the popover stays open and the user can re-prompt.
475
+ const chain = editor.chain().setHyperlink({ href: url })
476
+ // `setHyperlink` writes a mark and never inserts text. With an empty
477
+ // selection the mark reaches `storedMarks` only and nothing appears,
478
+ // so give the link its own text. The prebuilt form does the same.
479
+ if (editor.state.selection.empty) chain.insertContent({ type: 'text', text: url })
480
+ if (chain.run()) getDefaultController().close()
481
+ })
482
+
483
+ return form
325
484
  }
485
+
486
+ const HyperlinkWithCreateForm = Hyperlink.configure({ popovers: { createHyperlink } })
326
487
  ```
327
488
 
489
+ Pass `HyperlinkWithCreateForm` into `new Editor({ extensions: [...] })`. Drop the value and `Mod-k` opens the prebuilt form instead.
490
+
328
491
  </details>
329
492
 
330
- ````ts
493
+ <details>
494
+ <summary><b>Custom <code>editHyperlink</code></b></summary>
495
+
496
+ ```ts
331
497
  import {
498
+ buildPreviewOptionsFromAnchor,
499
+ getDefaultController,
332
500
  Hyperlink,
333
- previewHyperlinkModal,
334
- setHyperlinkModal
335
- } from "@docs.plus/extension-hyperlink";
501
+ openPreviewHyperlink,
502
+ validateURL,
503
+ type EditHyperlinkOptions
504
+ } from '@docs.plus/extension-hyperlink'
505
+
506
+ function editHyperlink(options: EditHyperlinkOptions): HTMLElement {
507
+ const { editor, link, validate, isAllowedUri, onBack, markName = 'hyperlink' } = options
508
+ const form = document.createElement('form')
509
+
510
+ // Pre-fill from the live link, reading the raw `href` attribute — the DOM
511
+ // `link.href` property resolves relative hrefs against `document.baseURI`,
512
+ // so an untouched Apply would rewrite them. The prebuilt form does the same.
513
+ const textInput = document.createElement('input')
514
+ textInput.type = 'text'
515
+ textInput.value = link.innerText
516
+ textInput.placeholder = 'Link text'
517
+
518
+ const hrefInput = document.createElement('input')
519
+ hrefInput.type = 'url'
520
+ hrefInput.value = link.getAttribute('href') ?? ''
521
+ hrefInput.placeholder = 'https://example.com'
522
+
523
+ const back = document.createElement('button')
524
+ back.type = 'button'
525
+ back.textContent = 'Back'
526
+ back.addEventListener('click', () => {
527
+ // Back UX: honor `onBack` when the caller provided one (rare, escape
528
+ // hatch); otherwise re-open the preview for the same link. Do not
529
+ // close instead — that is a silent dismissal, not Back.
530
+ if (onBack) return onBack()
531
+ openPreviewHyperlink(
532
+ buildPreviewOptionsFromAnchor({ editor, link, validate, isAllowedUri, markName })
533
+ )
534
+ })
535
+
536
+ const apply = document.createElement('button')
537
+ apply.type = 'submit'
538
+ apply.textContent = 'Apply'
539
+
540
+ form.append(textInput, hrefInput, back, apply)
541
+ form.addEventListener('submit', (event) => {
542
+ event.preventDefault()
543
+ const newText = textInput.value.trim()
544
+ const newURL = hrefInput.value.trim()
545
+ if (!newText || !validateURL(newURL, { customValidator: validate })) return
546
+
547
+ // Extend across the full mark range so the edit applies to every
548
+ // position the link covers, then delegate to the canonical command.
549
+ // It returns `false` when the composed gate rejects — keep the
550
+ // popover open in that case so the user can correct.
551
+ const ok = editor
552
+ .chain()
553
+ .focus()
554
+ .extendMarkRange(markName)
555
+ .editHyperlink({ newURL, newText })
556
+ .run()
557
+ if (!ok) return
336
558
 
337
- Hyperlink.configure({
338
- modals: {
339
- previewHyperlink: (data) => {
340
- return previewHyperlinkModal(data);
341
- },
342
- setHyperlink: (data) => {
343
- return setHyperlinkModal(data);
344
- },
345
- },
346
- })
347
- ````
559
+ getDefaultController().close()
560
+ })
348
561
 
349
- > Note: The `previewHyperlinkModal` and `setHyperlinkModal` modals are prebuilt modal boxes. You can use them or create your own modal boxes. To learn how to create your own modal boxes, please check the [source code](https://github.com/HMarzban/extension-hyperlink/tree/main/packages/extension-hyperlink/src/modals)
562
+ queueMicrotask(() => textInput.focus())
350
563
 
351
- <Su>
564
+ return form
565
+ }
352
566
 
353
- ### Removing and overriding existing html attributes
567
+ const HyperlinkWithEditForm = Hyperlink.configure({ popovers: { editHyperlink } })
568
+ ```
354
569
 
355
- You can add `rel: null` to HTMLAttributes to remove the default `rel="noopener noreferrer nofollow"`. You can also override the default by using `rel: "your-value"`.
570
+ Pass `HyperlinkWithEditForm` into `new Editor({ extensions: [...] })`. Drop the value and the preview Edit button opens the prebuilt form instead.
356
571
 
357
- This can also be used to change the `target` from the default value of `_blank`.
572
+ </details>
358
573
 
359
- ````js
360
- Hyperlink.configure({
361
- HTMLAttributes: {
362
- // Change rel to different value
363
- // Allow search engines to follow links(remove nofollow)
364
- rel: 'noopener noreferrer',
365
- // Remove target entirely so links open in current tab
366
- target: null,
367
- },
368
- })
369
- ````
574
+ ## URL handling
370
575
 
371
- ### validate
576
+ Every write boundary canonicalizes the href before storing it:
372
577
 
373
- A function that validates every autolinked link. If it exists, it will be called with the link href as argument. If it returns `false`, the link will be removed.
578
+ - **Bare domains** get `https://` in front: `google.com` → `https://google.com`.
579
+ - **Explicit schemes** pass through as typed: `http://`, `ftp://`, `whatsapp://`, `mailto:`.
580
+ - **Protocol-relative URLs** stay as they are: `//example.com`.
581
+ - **Bare E.164 phones** become `tel:`, and bare emails become `mailto:`.
374
582
 
375
- Can be used to set rules for example excluding or including certain domains, tlds, etc.
583
+ For any URL linkifyjs detects, the create form, the [markdown input rule](#markdown), autolink, and paste all produce the same `href`. Paste detection is linkifyjs-only. A catalog app scheme such as `whatsapp://…` therefore autolinks as you type, but stays plain text when pasted. Register the scheme through `protocols` and paste detects it too.
376
584
 
377
- ````js
378
- // only autolink urls with a protocol
379
- Hyperlink.configure({
380
- validate: href => /^https?:\/\//.test(href),
381
- })
382
- ````
585
+ Validation rejects a scheme-prefixed typo with no real host: `https://googlecom` fails. `http://localhost`, `https://127.0.0.1`, and any registered custom scheme pass.
383
586
 
384
- ## Commands
587
+ `normalizeHref(raw, defaultProtocol?)` returns the canonical href the editor would store. Call it in your own popover to mirror the same canonicalization.
588
+
589
+ ### Scheme classification
385
590
 
386
- ### editHyperLinkText(), editHyperLinkHref(), editHyperlink()
591
+ `getSpecialUrlInfo(href)` classifies a URL against the built-in catalog and returns `{ type, title, category } | null`. The catalog holds 47 schemes and 16 domains, matched on two different paths. A scheme matches by prefix, so `zoommtg:`, `vscode:`, and `spotify:` hit on the first characters. A domain matches by host suffix after `www.` is stripped, so `github.com` also covers `api.github.com`.
387
592
 
388
- These commands allow you to edit the text and href value of a hyperlink.
593
+ The package ships **no** icon catalog. `type` is a string-literal `SpecialUrlType` that you map to your own renderer:
389
594
 
390
- ```js
391
- this.editor.commands.editHyperLinkText('New Text');
392
- this.editor.commands.editHyperLinkHref('https://new-url.com');
393
- this.editor.commands.editHyperlink({
394
- newText: 'New Text',
395
- newURL: 'https://new-url.com'
396
- });
595
+ ```ts
596
+ import { getSpecialUrlInfo, type SpecialUrlType } from '@docs.plus/extension-hyperlink'
597
+ import * as Icons from './icons'
598
+
599
+ const TYPE_TO_ICON: Partial<Record<SpecialUrlType, () => string>> = {
600
+ email: Icons.Mail,
601
+ whatsapp: Icons.Chat
602
+ // …one entry per `type` you want a fallback icon for
603
+ }
604
+
605
+ const info = getSpecialUrlInfo(href)
606
+ if (info) renderIcon(TYPE_TO_ICON[info.type])
397
607
  ```
398
608
 
399
- ### setHyperlink()
609
+ `Partial<Record<SpecialUrlType, …>>` gives autocomplete and typo-protection without forcing exhaustiveness. Leave out domain-only types such as `meet` or web `github`, because the favicon path wins for a plain `https://` URL.
400
610
 
401
- Links the selected text.
611
+ ## Markdown
402
612
 
403
- ````js
404
- this.editor.commands.setHyperlink({
405
- href: '<https://example.com>'
406
- });
613
+ The mark ships its own markdown wiring: `markdownTokenName: 'link'` plus `parseMarkdown` and `renderMarkdown` hooks. `[label](https://example.com)` therefore round-trips when the host editor also loads a Markdown extension, such as `@tiptap/markdown`. The hooks stay inert otherwise, so they need no setup and run no code until a Markdown extension calls them.
407
614
 
408
- this.editor.commands.setHyperlink({
409
- href: '<https://example.com>',
410
- target: '_blank'
411
- });
615
+ Both directions are write boundaries. An imported href runs through `normalizeHref` and `isSafeHref`, so a markdown `javascript:` link lands with an empty `href`. On export, an unsafe href blanks the same way. `renderMarkdown` also percent-encodes `)` and whitespace, because the marked.js href grammar stops at both.
412
616
 
413
- this.editor.commands.unsetHyperlink();
414
- ````
617
+ Typing `[text](url)` in the editor fires an input rule that writes the mark directly. That rule is always registered and carries no option — see [Caveats](#caveats).
415
618
 
416
- ### unsetHyperlink()
619
+ ## Advanced
417
620
 
418
- Removes a Hyperlink.
621
+ Cross-popover primitives and lifecycle APIs live here. Use them to build a popover that is not anchored to a hyperlink, or to observe popover state from outside the editor.
419
622
 
420
- ````js
421
- this.editor.commands.unsetHyperlink();
422
- ````
623
+ ### Floating-popover primitive
423
624
 
424
- ## Keyboard shortcuts
625
+ `createPopover(options)` is the primitive every opener calls: Floating-UI placement, scroll-stickiness, outside-click dismissal, keyboard navigation, and registration with the [UI controller](#ui-controller). Call it directly for a popover that joins the same lifecycle without a hyperlink anchor.
626
+
627
+ The shell owns dismissal and focus. Escape hides the popover, and the shell binds it on the popover root, so focus must sit inside. An outside `mousedown` or `touchstart` hides it too, but the listeners arm 50 ms after `show()`, so a click inside that 50 ms window does not dismiss the popover. Tab and Shift-Tab cycle focus across the controls inside the popover.
628
+
629
+ `PopoverOptions` is a discriminated union — exactly one of `referenceElement` or `coordinates` is required, and the compiler enforces it.
425
630
 
426
- Doesn’t have a keyboard shortcut
427
- This extension doesn’t bind a specific keyboard shortcut. You would probably open your custom UI on `Mod-k` though.
631
+ | Field | Type | Default | Description |
632
+ | ----------------------- | -------------------------------------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
633
+ | `referenceElement` | `HTMLElement` | none | Element-anchor variant. Mutually exclusive with `coordinates`. |
634
+ | `coordinates` | `{ getBoundingClientRect, contextElement? }` | none | Virtual-anchor variant, for example a selection anchor. `getBoundingClientRect` MUST recompute on every call. |
635
+ | `content` | `HTMLElement` | none | Popover content node. |
636
+ | `placement?` | `Placement` | `'bottom-start'` | Floating-UI placement. The popover auto-flips to fit the viewport. |
637
+ | `offset?` | `number` | `DEFAULT_OFFSET` (`8`) | Distance in px between the anchor and the popover. |
638
+ | `showArrow?` | `boolean` | `false` | Renders an arrow pointing at the anchor. |
639
+ | `className?` | `string` | `''` | Extra class on the popover root, next to `.floating-popover`. |
640
+ | `zIndex?` | `number` | `9999` | Stacking context for the popover. |
641
+ | `role?` | `string` | `undefined` | ARIA role on the popover root. ARIA has no popover role, so pass the content's role (`toolbar`, `dialog`, …). |
642
+ | `ariaLabel?` | `string` | `undefined` | Accessible name set as `aria-label` on the popover root. Same ARIA axis as `role`, so your content inherits it (the prebuilt forms pass "Add link" / "Edit link"). |
643
+ | `ignoreOutsideClickOn?` | `HTMLElement \| HTMLElement[]` | `undefined` | Nodes that never light-dismiss the popover, such as a toggle trigger. Without a list, the `HTMLElement` `referenceElement` is ignored instead. |
644
+ | `crossAxisShift?` | `boolean` | `true` | With `false`, `shift` moves the popover on the main axis only, so an end-aligned menu stays pinned to the anchor's end edge. |
645
+ | `onShow?` | `() => void` | `undefined` | Fires synchronously at the end of `show()`, after the popover mounts and before the `.visible` entrance frame. Defer focus and measurement work to it (see the create opener). |
646
+ | `onHide?` | `() => void` | `undefined` | Fires when the popover is dismissed: outside click, programmatic `hide()`, or controller replacement. |
428
647
 
429
- ## Get the current value
648
+ Returns a `Popover`:
430
649
 
431
- Did you know that you can use `getAttributes` to find out which attributes, for example which href, is currently set? Don’t confuse it with a <u>command</u> (which changes the state), it’s just a method. Here is how that could look like:
650
+ | Member | Description |
651
+ | -------------------------------- | -------------------------------------------------------------------- |
652
+ | `element` | The popover root (`.floating-popover` div). |
653
+ | `show()` | Mounts and reveals. Idempotent, and a no-op once closed. |
654
+ | `hide()` | Dismisses. Terminal once shown — build a new popover to reopen. |
655
+ | `destroy()` | Tears down permanently: removes from the DOM and stops `autoUpdate`. |
656
+ | `isVisible()` | `true` between `show()` and `hide()` or `destroy()`. |
657
+ | `setContent(el)` | Swaps the content node in place without re-positioning. |
658
+ | `updateReference(ref?, coords?)` | Re-anchors to a different element or virtual reference. |
432
659
 
433
- ```js
434
- this.editor.getAttributes('link').href
660
+ `createPopover` adopts the new instance into the controller at the end of the call, before any `show()`. Creating a second popover therefore tears the first one down, even when you never show the second.
661
+
662
+ ### UI controller
663
+
664
+ `getDefaultController()` returns the owner of the floating-popover lifecycle. Subscribe to state changes from an outer toolbar, a devtools panel, or an E2E harness.
665
+
666
+ `ControllerState` is a discriminated union:
667
+
668
+ ```ts
669
+ type PopoverKind = 'preview' | 'edit' | 'create' | (string & {})
670
+
671
+ type ControllerState =
672
+ | { kind: 'idle' }
673
+ | {
674
+ kind: 'mounted'
675
+ popoverKind: PopoverKind
676
+ element: HTMLElement // popover root — for focus rings, observers, scroll-freezes
677
+ referenceElement: HTMLElement | null // null for virtual-coords popovers
678
+ }
435
679
  ```
436
680
 
437
- ## Sorce code and Example
681
+ `referenceElement` holds the anchor node for the preview popover only. Create and edit both anchor to virtual coordinates, so both report `null`.
682
+
683
+ `PopoverController`:
684
+
685
+ | Member | Description |
686
+ | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
687
+ | `adopt(popover, kind, metadata)` | Takes ownership of a `Popover` and destroys the previous owner. Returns an unregister function. The openers call it; consumers rarely do. |
688
+ | `close()` | Dismisses the active popover, and does nothing when idle. |
689
+ | `reposition(ref?, coords?)` | Re-anchors the active popover after the underlying mark moves, such as an external edit or a document rewrite. |
690
+ | `getState()` | One-shot read of `ControllerState`. |
691
+ | `subscribe(listener)` | Registers a state-change listener and returns an unsubscribe function. The listener does **not** fire on subscribe — call `listener(controller.getState())` yourself for the initial state. |
692
+
693
+ One controller serves one bundle. See [Caveats](#caveats) for what that means next to another docs.plus extension.
694
+
695
+ ### Tooltip primitive
696
+
697
+ `attachTooltip(target, label)` puts a hover and focus tooltip on your own button. The prebuilt popovers use the same tooltip. The bubble appears 400 ms after the pointer enters, and on keyboard focus only when the button matches `:focus-visible`.
698
+
699
+ `attachTooltip` returns a detach function, and it binds six listeners per call. Call the detach function when your popover re-renders in place, or the listeners stack on every render.
700
+
701
+ `hideTooltip()` hides the shared bubble. The bubble is per bundle, so attach and hide from the same package. Otherwise `hideTooltip()` leaves the other package's bubble on screen.
702
+
703
+ ## Security
704
+
705
+ `isSafeHref` blocks the dangerous schemes (`javascript:`, `data:`, `vbscript:`, `file:`, `blob:`) at every boundary below. The gate tests a control-stripped copy of the href. An embedded tab, newline, or control character therefore cannot hide a scheme from the gate, and the gate catches `java\tscript:` even though browsers resolve it as `javascript:`.
706
+
707
+ - **Parse** — `parseHTML` drops the mark on any `<a href>` whose scheme is blocked, on document load and on paste. The text survives; only the link goes.
708
+ - **Import** — `parseMarkdown` normalizes the href and stores an empty string when the scheme is blocked. Both import paths apply the `isSafeHref` floor alone, so a tightened `isAllowedUri` never strips marks from existing documents.
709
+ - **Write** — the input rule, paste rule, paste handler, autolink, `setHyperlink`, `toggleHyperlink`, and `editHyperlink` all route through the composed gate.
710
+ - **Serialize** — `renderHTML` re-checks `isSafeHref` and emits an empty `href` when a hostile mark reaches it through a legacy migration, a raw `addMark`, or Yjs replay. `renderMarkdown` blanks the same way.
711
+ - **Navigate** — primary click, middle-click (`auxclick`), touch, and the preview popover's href link all gate `window.open(…)` on the composed gate. They also pass `'noopener,noreferrer'`, so an opened tab cannot read `window.opener` or leak the Referer.
712
+
713
+ On the read side, the click handlers prefer the stored mark attribute over the DOM `link.href` property. A relative href injected through `setContent` therefore does not resolve against the host page's origin.
714
+
715
+ `isSafeHref(href)` and `DANGEROUS_SCHEME_RE` are exported for a custom popover that needs the same check. Prefer `isSafeHref`, because it returns a TypeScript type guard.
716
+
717
+ `SAFE_WINDOW_FEATURES` is the `'noopener,noreferrer'` string the package passes as the third argument of every `window.open` call. Pin the same constant in your own popovers and click handlers, so a future tightening propagates from one place.
718
+
719
+ ## Migrating from 1.x
720
+
721
+ `2.0` redesigns the public surface. Option names, command names, CSS class names, the popover contract, and URL validation all changed.
722
+
723
+ **Options and commands.**
724
+
725
+ | `1.x` | `2.0` |
726
+ | ------------------------------------ | ------------------------------- |
727
+ | `autoHyperlink` | `autolink` |
728
+ | `hyperlinkOnPaste` | `linkOnPaste` |
729
+ | `editHyperLinkText` | `editHyperlinkText` |
730
+ | `editHyperLinkHref` | `editHyperlinkHref` |
731
+ | `tr.setMeta('preventAutoHyperlink')` | `tr.setMeta('preventAutolink')` |
732
+
733
+ The package augments the commands under the `hyperlink:` key, not `link:`. `setHyperlink()` with no arguments no longer opens UI — call `openCreateHyperlinkPopover()` instead.
734
+
735
+ **CSS class names.** camelCase became kebab-case, and the shell prefix changed from `.floating-toolbar` to `.floating-popover`.
736
+
737
+ | `1.x` | `2.0` |
738
+ | --------------------------- | ---------------------------- |
739
+ | `.floating-toolbar` | `.floating-popover` |
740
+ | `.floating-toolbar-arrow` | `.floating-popover-arrow` |
741
+ | `.floating-toolbar-content` | `.floating-popover-content` |
742
+ | `.hyperlinkCreatePopover` | `.hyperlink-create-popover` |
743
+ | `.hyperlinkPreviewPopover` | `.hyperlink-preview-popover` |
744
+ | `.hyperlinkEditPopover` | `.hyperlink-edit-popover` |
745
+ | `.buttonsWrapper` | `.buttons-wrapper` |
746
+ | `.inputsWrapper` | `.inputs-wrapper` |
747
+ | `.textWrapper` | `.text-wrapper` |
748
+ | `.hrefWrapper` | `.href-wrapper` |
749
+ | `.backButton` | `.back-button` |
750
+ | `.btn_applyModal` | `.apply-button` |
751
+
752
+ **Popover API.** The v1 split between "popover" and "floating-toolbar" is gone.
753
+
754
+ | `1.x` | `2.0` |
755
+ | ----------------------------------------- | ---------------------------------------------------------- |
756
+ | `createFloatingToolbar(opts)` | `createPopover(opts)`, same shape minus `surface` |
757
+ | `hideCurrentToolbar()` | `getDefaultController().close()` |
758
+ | `updateCurrentToolbarPosition(ref?)` | `getDefaultController().reposition(ref?)` |
759
+ | `FloatingToolbarOptions` / `…Instance` | `PopoverOptions` / `Popover` |
760
+ | `HyperlinkUIController` | `PopoverController` |
761
+ | `SurfaceKind` | `PopoverKind` |
762
+ | `EditHyperlinkPopoverOptions` / `…Modal…` | `EditHyperlinkOptions` |
763
+ | `state.surface` | `state.popoverKind`, plus `element` and `referenceElement` |
764
+
765
+ Slot factories return `HTMLElement | null` instead of `void`, and `PreviewHyperlinkOptions.attrs` is now required. The stylesheet no longer auto-injects — add `import '@docs.plus/extension-hyperlink/styles.css'` at app bootstrap when you use the prebuilt popovers.
766
+
767
+ **Behavior differences.** Audit any fixture or seeded content that relied on the old behavior.
768
+
769
+ - The package rejects `javascript:`, `data:`, and `vbscript:` URLs at load, paste, input rule, click, and popover open, and drops stored ones.
770
+ - `validateURL` requires a plausible host for a web scheme, so `https://googlecom` no longer autolinks.
771
+ - `localhost:3000` and `mydomain.com:8080` now read as host:port, and canonicalize to `https://localhost:3000` and `https://mydomain.com:8080`.
772
+ - `SpecialUrlIcon` is gone, along with `SpecialUrlInfo.icon`. Map `SpecialUrlInfo.type` to your own renderer — see [Scheme classification](#scheme-classification).
773
+ - Two `SpecialUrlInfo.type` values were renamed: `'tv'` → `'apple-tv'`, and `'appstore'` → `'app-store'`.
774
+
775
+ The full breaking-change list, with a one-shot rename script, is in the [CHANGELOG](https://github.com/docs-plus/docs.plus/blob/main/extensions/extension-hyperlink/CHANGELOG.md#migrating-from-1x-to-20).
776
+
777
+ ## TypeScript
778
+
779
+ The package bundles the type definitions. The complete public surface, grouped by role:
780
+
781
+ - **Extension** — `Hyperlink`, also the default export. Types `HyperlinkOptions`, `HyperlinkAttributes`, `HyperlinkStorage`, `HyperlinkPublicCommands`, `SetHyperlinkAttributes`, `EditHyperlinkAttributes`, `IsAllowedUriContext`, `LinkProtocolOptions`. `HyperlinkAttributes` is generic. `HyperlinkAttributes<{ ariaLabel: string }>` extends the built-in `href` / `target` / `rel` / `class` / `title` / `image` keys with your own typed fields. The default parameter is `Record<string, unknown>`, so the unparameterized type stays open; supplying your own `Extra` replaces it and closes the type.
782
+ - **Popover factories** — `previewHyperlinkPopover`, `createHyperlinkPopover`, `editHyperlinkPopover`. Types `PreviewHyperlinkOptions`, `CreateHyperlinkOptions`, `EditHyperlinkOptions`, documented under [Popover-factory option shapes](#popover-factory-option-shapes).
783
+ - **Openers** — `openPreviewHyperlink`, `openEditHyperlink`, `openCreateHyperlink`, `buildPreviewOptionsFromAnchor`. Type `BuildPreviewOptionsFromAnchorArgs`. See [Openers](#openers).
784
+ - **Floating-popover primitive** — `createPopover`, `DEFAULT_OFFSET`. Types `Popover`, `PopoverOptions`. See [Floating-popover primitive](#floating-popover-primitive).
785
+ - **UI controller** — `getDefaultController`. Types `PopoverController`, `PopoverKind`, `ControllerState`, `AdoptMetadata`, `VirtualCoordinates`. See [UI controller](#ui-controller).
786
+ - **Tooltip primitive** — `attachTooltip`, `hideTooltip`, re-exported from the bundled `@docs.plus/floating-tooltip`. See [Tooltip primitive](#tooltip-primitive).
787
+ - **URL utilities** — `normalizeHref`, `getSpecialUrlInfo`, `validateURL`, `isSafeHref`, `DANGEROUS_SCHEME_RE`, `SAFE_WINDOW_FEATURES`. Types `SpecialUrlInfo`, `SpecialUrlType`, `LinkifyMatchLike`, `ValidateURLOptions`.
788
+ - **DOM helpers** — `copyToClipboard(text, callback?)` writes `text` to the clipboard and reports success through `callback`. `createHTMLElement(tag, props?)` builds one element and assigns `props` onto it. The SVG icon factories `Copy`, `LinkOff`, `Pencil` take `IconProps`. The prebuilt preview toolbar renders those three icons; reuse them for visual parity.
789
+ - **Linkify re-export** — `registerCustomProtocol`, passed through from [linkifyjs](https://linkify.js.org).
790
+
791
+ ## Part of docs.plus
438
792
 
439
- - Demo:
440
- [extension-hyperlink](https://github.com/HMarzban/extension-hyperlink#test-drive-with-our-demo-)
441
- - Extension:
442
- [packages/extension-hyperlink](https://github.com/HMarzban/extension-hyperlink/tree/main/packages/extension-hyperlink)
443
- - Usage: [packages/nextjs/src/components/Tiptap.tsx](https://github.com/HMarzban/extension-hyperlink/blob/59f45eba1886202f4840eb2112c34574c16fe68a/packages/nextjs/src/components/Tiptap.tsx#L19-L29)
793
+ This extension is built for and maintained by [docs.plus](https://docs.plus). docs.plus is a free, real-time collaboration tool that lets communities organize knowledge hierarchically, with a chat thread on every heading. docs.plus runs these packages from source in production, so every release is exercised there before it reaches npm.
444
794
 
445
- ## Inspiration and Acknowledgment, Let's Connect
795
+ - Website: [docs.plus](https://docs.plus)
796
+ - Project README: [docs-plus/docs.plus](https://github.com/docs-plus/docs.plus#readme)
797
+ - Sibling extensions and recommended pairings: [extensions/README.md](https://github.com/docs-plus/docs.plus/blob/main/extensions/README.md)
446
798
 
447
- Thank you for exploring our Hyperlink extension from docs.plus! We aim to make collaboration and knowledge sharing not just easy, but also enjoyable.
799
+ ## Contributing
448
800
 
449
- Our extension is inspired by Tiptap's [extension-link](https://github.com/ueberdosis/tiptap/tree/main/packages/extension-link). While we've incorporated our own enhancements, we'd like to tip our hats to Tiptap for pioneering the "headless" approach that we admire greatly.
450
- > Please note: We're not affiliated with Tiptap, but we believe in recognizing foundational work.
801
+ Bug reports and PRs welcome. Setup, test commands, and the playground harness live in [CONTRIBUTING.md](https://github.com/docs-plus/docs.plus/blob/main/extensions/extension-hyperlink/CONTRIBUTING.md).
451
802
 
452
- Your feedback and interest in docs.plus are invaluable to us. Share your thoughts, suggestions, or dive deeper into our mission at the [docs.plus](https://github.com/docs-plus/docs.plus) repository.
803
+ ## License
453
804
 
454
- Wish to converse? Connect with us [here](https://github.com/docs-plus/docs.plus#-connect-with-us).
805
+ MIT — see [LICENSE](https://github.com/docs-plus/docs.plus/blob/main/LICENSE).