@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/CHANGELOG.md +49 -0
- package/README.md +60 -661
- package/dist/index.cjs +2 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +61 -83
- package/dist/index.d.ts +61 -83
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/styles.css +8 -49
- package/package.json +16 -14
package/README.md
CHANGED
|
@@ -4,38 +4,53 @@
|
|
|
4
4
|
[](https://www.npmjs.com/package/@docs.plus/extension-hyperlink)
|
|
5
5
|
[](https://npmcharts.com/compare/@docs.plus/extension-hyperlink)
|
|
6
6
|
[](https://www.npmjs.com/package/@docs.plus/extension-hyperlink)
|
|
7
|
-
[](https://discord.gg/
|
|
7
|
+
[](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/
|
|
12
|
-
<img alt="
|
|
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
|
-
|
|
18
|
+
It replaces the `@tiptap/extension-link` mark that StarterKit v3 bundles.
|
|
19
|
+
|
|
20
|
+
[](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
|
-
|
|
32
|
+
npm install @docs.plus/extension-hyperlink
|
|
24
33
|
```
|
|
25
34
|
|
|
26
|
-
|
|
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
|
-
|
|
37
|
+
Requires **`@tiptap/core` ^3.31.3** and **`@tiptap/pm` ^3.31.3** (Tiptap 3.x).
|
|
29
38
|
|
|
30
|
-
|
|
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
|
-
|
|
41
|
+
React Native has no DOM. Load the editor in a web view.
|
|
33
42
|
|
|
34
|
-
|
|
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
|
-
|
|
49
|
+
[](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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
98
|
+
### Set a link from your own button
|
|
137
99
|
|
|
138
|
-
|
|
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
|
-
|
|
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
|
-
|
|
109
|
+
### Open the create-link form
|
|
157
110
|
|
|
158
|
-
|
|
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
|
-
|
|
114
|
+
editor.commands.openCreateHyperlinkPopover()
|
|
175
115
|
```
|
|
176
116
|
|
|
177
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
|
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
|
|