@docs.plus/extension-hyperlink 2.0.0 → 2.1.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
@@ -4,38 +4,53 @@
4
4
  [![Version](https://img.shields.io/npm/v/@docs.plus/extension-hyperlink.svg?label=version)](https://www.npmjs.com/package/@docs.plus/extension-hyperlink)
5
5
  [![Downloads](https://img.shields.io/npm/dm/@docs.plus/extension-hyperlink.svg)](https://npmcharts.com/compare/@docs.plus/extension-hyperlink)
6
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)
7
+ [![Discord](https://img.shields.io/badge/discord-community-5865F2?logo=discord&logoColor=white)](https://discord.gg/2EmAjmgZ8)
8
8
 
9
9
  <p align="center">
10
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">
11
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/docs-plus/docs.plus/main/extensions/extension-hyperlink/assets/demo-dark.gif">
12
+ <img alt="Typing "docs.plus" autolinks it, clicking the link opens a preview with copy, edit and remove, and Mod-K on a selected word opens the create-link form" width="640" src="https://raw.githubusercontent.com/docs-plus/docs.plus/main/extensions/extension-hyperlink/assets/demo-light.gif">
13
13
  </picture>
14
14
  </p>
15
15
 
16
16
  Tiptap hyperlink mark with optional prebuilt popovers for creating, previewing, and editing links.
17
17
 
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.
18
+ It replaces the `@tiptap/extension-link` mark that StarterKit v3 bundles.
19
+
20
+ [![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/docs-plus/docs.plus/tree/main/extensions/extension-hyperlink/examples/vanilla?file=main.ts)
21
+
22
+ ## Why use it
23
+
24
+ - **Autolink covers more than web URLs.** As you type, bare domains get `https://`, emails become `mailto:`, and E.164 phones become `tel:`. App schemes from a catalog of 47, such as `whatsapp://`, autolink too.
25
+ - **The link UI is included.** Prebuilt create, preview, and edit popovers ship with the package. `Mod-k` opens the create-link form with no configuration. `@tiptap/extension-link` binds no keyboard shortcut.
26
+ - **Every boundary checks the scheme.** `isSafeHref` blocks `javascript:`, `data:`, `vbscript:`, `file:`, and `blob:` on parse, import, write, serialize, and navigation. An embedded tab or newline cannot hide a scheme from the gate.
27
+ - **Your `@tiptap/extension-link` configuration ports over.** `isAllowedUri` takes the same `(uri, ctx)` shape as `@tiptap/extension-link`, so an existing policy works unchanged. `setLink`, `unsetLink`, and `toggleLink` stay as aliases. `[label](url)` round-trips with `@tiptap/markdown`.
19
28
 
20
29
  ## Install
21
30
 
22
31
  ```sh
23
- bun add @docs.plus/extension-hyperlink
32
+ npm install @docs.plus/extension-hyperlink
24
33
  ```
25
34
 
26
- Requires **`@tiptap/core` ^3.22.3** and **`@tiptap/pm` ^3.22.3** (Tiptap 3.x).
35
+ Or use `pnpm add @docs.plus/extension-hyperlink`, `yarn add @docs.plus/extension-hyperlink`, or `bun add @docs.plus/extension-hyperlink`.
27
36
 
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.
37
+ Requires **`@tiptap/core` ^3.31.3** and **`@tiptap/pm` ^3.31.3** (Tiptap 3.x).
29
38
 
30
- The Quickstart also imports `@tiptap/starter-kit`. Add it with `bun add @tiptap/starter-kit` when your app has none yet.
39
+ This package imports no React, Vue, or Next.js code. Use it from a plain page, Vite, React, Vue, Svelte, Next.js, Nuxt, or SvelteKit. Create the editor in the browser.
31
40
 
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.
41
+ React Native has no DOM. Load the editor in a web view.
33
42
 
34
- Upgrading from `1.x`? Option names, command names, and CSS class names all changed — see [Migrating from 1.x](#migrating-from-1x).
43
+ Installs two runtime dependencies, `@floating-ui/dom` and `linkifyjs`. The popover engine and the tooltip ship inside `dist`, so they add no third package.
44
+
45
+ Upgrading from `1.x`? Option names, command names, and CSS class names all changed — see [Migrating from 1.x](https://github.com/docs-plus/docs.plus/blob/main/extensions/extension-hyperlink/docs/migration.md#migrating-from-1x).
35
46
 
36
47
  ## Quickstart
37
48
 
38
- The host page needs one mount point: `<div id="editor"></div>`.
49
+ [![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/docs-plus/docs.plus/tree/main/extensions/extension-hyperlink/examples/vanilla?file=main.ts)
50
+
51
+ Run this Quickstart in your browser first, with nothing to install. The app lives in [`examples/vanilla`](https://github.com/docs-plus/docs.plus/tree/main/extensions/extension-hyperlink/examples/vanilla).
52
+
53
+ The host page needs one mount point: `<div id="editor"></div>`. The snippet also imports `@tiptap/starter-kit`. Add it with `npm install @tiptap/starter-kit` when your app has none yet.
39
54
 
40
55
  ```ts
41
56
  import { Editor } from '@tiptap/core'
@@ -65,118 +80,45 @@ const editor = new Editor({
65
80
  })
66
81
  ```
67
82
 
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).
69
-
70
- ## Options
71
-
72
- Every key below goes into `Hyperlink.configure({ … })`.
73
-
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. |
88
-
89
- These option shapes need an example:
90
-
91
- ```ts
92
- Hyperlink.configure({
93
- protocols: ['ftp', { scheme: 'tel', optionalSlashes: true }],
94
- isAllowedUri: (uri, ctx) => ctx.defaultValidate(uri) && !uri.includes('blocked.example'),
95
- shouldAutoLink: (uri) => !uri.startsWith('@')
96
- })
97
- ```
98
-
99
- `LinkProtocolOptions` is `{ scheme: string; optionalSlashes?: boolean }`.
100
-
101
- ### `validate` vs `isAllowedUri`
102
-
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.
104
-
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.
107
-
108
- Pick one. Setting both works, because they compose and a URL must pass both, but that is rarely the intent.
83
+ The `styles.css` import is optional. The snippet also styles no document link — see [Popovers](https://github.com/docs-plus/docs.plus/blob/main/extensions/extension-hyperlink/docs/popovers.md) and [Style document links](#style-document-links).
109
84
 
110
- `isSafeHref` plus your `validate` and `isAllowedUri` hooks form the composed gate. The rest of this README uses that name.
85
+ You should see a paragraph with one link. Click the link to open the preview popover, with copy, edit, and remove actions. Type `example.org` and a space, and the text becomes a link. Select a word and press `Mod-k` (Cmd on macOS, Ctrl elsewhere) to open the create-link form.
111
86
 
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`.
113
-
114
- ## Commands
115
-
116
- Every command below lands on `editor.commands` and on `editor.chain()`. On `editor.commands`, each returns `boolean`, and `false` marks a no-op.
87
+ ## Caveats
117
88
 
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`. |
89
+ - **`StarterKit.configure({ link: false })` is required, not stylistic.** StarterKit v3 bundles `@tiptap/extension-link` by default. The two marks collide on the `setLink` / `unsetLink` / `toggleLink` command names and on the `a[href]` parse rule. Your `extensions` array order then decides each contest, with no warning. If the upstream mark wins, this package's `isSafeHref` gate never runs on parsed or pasted HTML, and its popovers never attach.
90
+ - **A click on a link needs `popovers.previewHyperlink`.** 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.
91
+ - **`linkOnPaste: false` does not stop every paste from linking.** 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.
92
+ - **`HTMLAttributes.target` reaches the DOM.** `HTMLAttributes: { target: '_blank' }` does emit `<a target="_blank">`. A browser-driven `target="_blank"` navigation skips the click gate. Leave `target` at `null` and let the click handler open the link.
128
93
 
129
- `editHyperlink` returns `false` (no-op) on four paths, checked in this order:
94
+ The full list, with the reason for each, is in [Caveats](https://github.com/docs-plus/docs.plus/blob/main/extensions/extension-hyperlink/docs/api.md#caveats).
130
95
 
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`.
96
+ ## Common tasks
135
97
 
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.
98
+ ### Set a link from your own button
137
99
 
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.
100
+ Every command lands on `editor.commands` and on `editor.chain()`. On `editor.commands`, each returns `boolean`, and `false` marks a no-op.
139
101
 
140
102
  ```ts
141
103
  editor.chain().focus().setHyperlink({ href: 'https://example.com' }).run()
142
104
  editor.getAttributes('hyperlink').href // read the current href
143
105
  ```
144
106
 
145
- ## Keyboard shortcuts
146
-
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. |
107
+ `setHyperlink` returns `false` when `href` is empty, when the [composed gate](https://github.com/docs-plus/docs.plus/blob/main/extensions/extension-hyperlink/docs/api.md#validate-vs-isalloweduri) rejects it, or when the schema cannot apply the mark there. See [Commands](https://github.com/docs-plus/docs.plus/blob/main/extensions/extension-hyperlink/docs/api.md#commands) for the full list.
155
108
 
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.
109
+ ### Open the create-link form
157
110
 
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).
159
-
160
- ## Caveats
161
-
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.
168
-
169
- ## Styling
170
-
171
- The prebuilt popovers ship a stylesheet — import it once when you use them:
111
+ `Mod-k` calls this command. Call it from your own toolbar button too:
172
112
 
173
113
  ```ts
174
- import '@docs.plus/extension-hyperlink/styles.css'
114
+ editor.commands.openCreateHyperlinkPopover()
175
115
  ```
176
116
 
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.
117
+ `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.
118
+
119
+ ### Style document links
178
120
 
179
- The stylesheet skins the popovers only. The mark renders a plain `<a>` with no class, so style your document links yourself:
121
+ The `styles.css` stylesheet skins the popovers only. The mark renders a plain `<a>` with no class, so style your document links yourself:
180
122
 
181
123
  ```ts
182
124
  Hyperlink.configure({ HTMLAttributes: { class: 'my-link' } })
@@ -189,95 +131,9 @@ a.my-link {
189
131
  }
190
132
  ```
191
133
 
192
- ### Theming
193
-
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.
195
-
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;
218
- }
219
- ```
220
-
221
- </details>
134
+ For theme tokens, dark mode, and class names, see [Styling](https://github.com/docs-plus/docs.plus/blob/main/extensions/extension-hyperlink/docs/styling.md).
222
135
 
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
- }
234
- ```
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
136
+ ## Gallery
281
137
 
282
138
  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
139
 
@@ -317,476 +173,19 @@ Prebuilt create, preview, and edit popovers (`styles.css` + `popovers` config).
317
173
 
318
174
  </details>
319
175
 
320
- Three ways to use them:
321
-
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).
325
-
326
- ### Popover-factory option shapes
327
-
328
- Every factory takes one `options` argument. The shape depends on the slot.
329
-
330
- **`PreviewHyperlinkOptions`** — passed to `popovers.previewHyperlink` on every link click.
331
-
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`.** |
340
-
341
- Return `null` to opt out for this click, for example to open a mobile bottom sheet. The package then mounts no popover.
342
-
343
- **`CreateHyperlinkOptions`** — passed to `popovers.createHyperlink` when `Mod-k` or `editor.commands.openCreateHyperlinkPopover()` fires.
344
-
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. |
351
-
352
- Return `null` when the popover cannot open, for example when DOM construction fails. The command then returns `false`.
353
-
354
- **`EditHyperlinkOptions`** — passed to `popovers.editHyperlink`, and to `openEditHyperlink(opts)` when you call it from outside a preview popover.
355
-
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`. |
365
-
366
- ### Openers
367
-
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.
369
-
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
434
- }
435
-
436
- const HyperlinkWithPreview = Hyperlink.configure({ popovers: { previewHyperlink } })
437
- ```
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
-
441
- </details>
442
-
443
- <details>
444
- <summary><b>Custom <code>createHyperlink</code></b></summary>
445
-
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
484
- }
485
-
486
- const HyperlinkWithCreateForm = Hyperlink.configure({ popovers: { createHyperlink } })
487
- ```
488
-
489
- Pass `HyperlinkWithCreateForm` into `new Editor({ extensions: [...] })`. Drop the value and `Mod-k` opens the prebuilt form instead.
490
-
491
- </details>
492
-
493
- <details>
494
- <summary><b>Custom <code>editHyperlink</code></b></summary>
495
-
496
- ```ts
497
- import {
498
- buildPreviewOptionsFromAnchor,
499
- getDefaultController,
500
- 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
558
-
559
- getDefaultController().close()
560
- })
561
-
562
- queueMicrotask(() => textInput.focus())
563
-
564
- return form
565
- }
566
-
567
- const HyperlinkWithEditForm = Hyperlink.configure({ popovers: { editHyperlink } })
568
- ```
569
-
570
- Pass `HyperlinkWithEditForm` into `new Editor({ extensions: [...] })`. Drop the value and the preview Edit button opens the prebuilt form instead.
571
-
572
- </details>
573
-
574
- ## URL handling
575
-
576
- Every write boundary canonicalizes the href before storing it:
577
-
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:`.
582
-
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.
584
-
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.
586
-
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
590
-
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`.
592
-
593
- The package ships **no** icon catalog. `type` is a string-literal `SpecialUrlType` that you map to your own renderer:
594
-
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])
607
- ```
608
-
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.
610
-
611
- ## Markdown
612
-
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.
614
-
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.
616
-
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).
618
-
619
- ## Advanced
620
-
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.
622
-
623
- ### Floating-popover primitive
624
-
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.
630
-
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. |
647
-
648
- Returns a `Popover`:
649
-
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. |
659
-
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
- }
679
- ```
680
-
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).
176
+ ## Documentation
177
+
178
+ | Guide | What it covers |
179
+ | ----------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
180
+ | [API reference](https://github.com/docs-plus/docs.plus/blob/main/extensions/extension-hyperlink/docs/api.md) | Options, commands, keyboard shortcuts, the full caveats list, server bundles, and TypeScript exports |
181
+ | [Popovers](https://github.com/docs-plus/docs.plus/blob/main/extensions/extension-hyperlink/docs/popovers.md) | The three popover slots, factory option shapes, openers, and custom popovers |
182
+ | [Styling](https://github.com/docs-plus/docs.plus/blob/main/extensions/extension-hyperlink/docs/styling.md) | The stylesheet, `--hl-*` theme tokens, dark mode, and class names |
183
+ | [URL handling](https://github.com/docs-plus/docs.plus/blob/main/extensions/extension-hyperlink/docs/url-handling.md) | Href normalization, scheme classification, and Markdown round-trip |
184
+ | [Security](https://github.com/docs-plus/docs.plus/blob/main/extensions/extension-hyperlink/docs/security.md) | The dangerous-scheme gate at each boundary, and the exported safety helpers |
185
+ | [Advanced](https://github.com/docs-plus/docs.plus/blob/main/extensions/extension-hyperlink/docs/advanced.md) | The floating-popover primitive, the UI controller, and the tooltip primitive |
186
+ | [Migrating from 1.x](https://github.com/docs-plus/docs.plus/blob/main/extensions/extension-hyperlink/docs/migration.md) | Renamed options, commands, CSS classes, and popover APIs |
187
+ | [Changelog](https://github.com/docs-plus/docs.plus/blob/main/extensions/extension-hyperlink/CHANGELOG.md) | Every release and its breaking changes |
188
+ | [For AI coding agents](https://cdn.jsdelivr.net/npm/@docs.plus/extension-hyperlink/README.md) | This README as plain Markdown for your agent. The docs are also on [Context7](https://context7.com/docs-plus/docs.plus) |
790
189
 
791
190
  ## Part of docs.plus
792
191