@docs.plus/extension-hyperlink 1.5.1 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +523 -0
- package/LICENSE +21 -0
- package/README.md +676 -327
- package/dist/index.cjs +22 -712
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +414 -0
- package/dist/index.d.ts +414 -9
- package/dist/index.js +22 -707
- package/dist/index.js.map +1 -1
- package/dist/styles.css +403 -0
- package/package.json +82 -32
- package/dist/helpers/autoHyperlink.d.ts +0 -8
- package/dist/helpers/clickHandler.d.ts +0 -11
- package/dist/helpers/editHyperlink.d.ts +0 -9
- package/dist/helpers/pasteHandler.d.ts +0 -9
- package/dist/helpers/tippyHelper.d.ts +0 -29
- package/dist/hyperlink.d.ts +0 -77
- package/dist/index.umd.js +0 -713
- package/dist/index.umd.js.map +0 -1
- package/dist/modals/editeHyperlink.d.ts +0 -11
- package/dist/modals/icons.d.ts +0 -9
- package/dist/modals/previewHyperlink.d.ts +0 -11
- package/dist/modals/setHyperlink.d.ts +0 -14
- package/src/helpers/autoHyperlink.ts +0 -167
- package/src/helpers/clickHandler.ts +0 -80
- package/src/helpers/editHyperlink.ts +0 -59
- package/src/helpers/pasteHandler.ts +0 -46
- package/src/helpers/tippyHelper.ts +0 -136
- package/src/hyperlink.ts +0 -277
- package/src/index.ts +0 -17
- package/src/modals/editeHyperlink.ts +0 -99
- package/src/modals/icons.ts +0 -46
- package/src/modals/previewHyperlink.ts +0 -85
- package/src/modals/setHyperlink.ts +0 -97
package/README.md
CHANGED
|
@@ -1,456 +1,805 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @docs.plus/extension-hyperlink
|
|
2
2
|
|
|
3
|
+
<a href="https://docs.plus"><picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/docs-plus/docs.plus/main/apps/webapp/public/badges/badge-docsplus-dark.svg"><img alt="docs.plus" height="20" src="https://raw.githubusercontent.com/docs-plus/docs.plus/main/apps/webapp/public/badges/badge-docsplus.svg"></picture></a>
|
|
3
4
|
[](https://www.npmjs.com/package/@docs.plus/extension-hyperlink)
|
|
4
5
|
[](https://npmcharts.com/compare/@docs.plus/extension-hyperlink)
|
|
5
6
|
[](https://www.npmjs.com/package/@docs.plus/extension-hyperlink)
|
|
7
|
+
[](https://discord.gg/25JPG38J59)
|
|
6
8
|
|
|
7
|
-
|
|
9
|
+
<p align="center">
|
|
10
|
+
<picture>
|
|
11
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/docs-plus/docs.plus/main/extensions/extension-hyperlink/assets/preview-dark.png">
|
|
12
|
+
<img alt="Preview popover on a link — copy, edit, and remove actions" width="640" src="https://raw.githubusercontent.com/docs-plus/docs.plus/main/extensions/extension-hyperlink/assets/preview-light.png">
|
|
13
|
+
</picture>
|
|
14
|
+
</p>
|
|
8
15
|
|
|
9
|
-
|
|
16
|
+
Tiptap hyperlink mark with optional prebuilt popovers for creating, previewing, and editing links.
|
|
10
17
|
|
|
11
|
-
|
|
18
|
+
Beyond the mark itself, the package covers the link behavior hosts usually hand-roll. Autolink runs as you type: bare domains → `https://`, emails → `mailto:`, E.164 phones → `tel:`. The package also covers markdown round-trip, a catalog of 47 app schemes plus 16 domain matches, and a dangerous-scheme gate at every write boundary. `Mod-k` opens the prebuilt create form with no configuration, and the preview popover needs its own slot. Import `styles.css` to make the prebuilt forms look right, or bring your own UI and skip the import.
|
|
12
19
|
|
|
13
|
-
|
|
14
|
-
npm install @docsplus/extension-hyperlink
|
|
15
|
-
````
|
|
20
|
+
## Install
|
|
16
21
|
|
|
17
|
-
|
|
22
|
+
```sh
|
|
23
|
+
bun add @docs.plus/extension-hyperlink
|
|
24
|
+
```
|
|
18
25
|
|
|
19
|
-
|
|
26
|
+
Requires **`@tiptap/core` ^3.22.3** and **`@tiptap/pm` ^3.22.3** (Tiptap 3.x).
|
|
20
27
|
|
|
21
|
-
|
|
28
|
+
Installs two runtime dependencies, `@floating-ui/dom` and `linkifyjs`. The popover engine and the tooltip ship inside `dist`, so they add no third package.
|
|
22
29
|
|
|
23
|
-
|
|
30
|
+
The Quickstart also imports `@tiptap/starter-kit`. Add it with `bun add @tiptap/starter-kit` when your app has none yet.
|
|
24
31
|
|
|
25
|
-
|
|
26
|
-
Hyperlink.configure({
|
|
27
|
-
protocols: ['ftp', 'mailto'],
|
|
28
|
-
})
|
|
29
|
-
````
|
|
32
|
+
Module scope touches no browser API, so a server bundle can import the package. [`createPopover`](#floating-popover-primitive), [the three openers](#openers), the three prebuilt popover factories, `attachTooltip`, `createHTMLElement`, and `copyToClipboard` read `document` or `navigator`, so call them in the browser only.
|
|
30
33
|
|
|
31
|
-
|
|
34
|
+
Upgrading from `1.x`? Option names, command names, and CSS class names all changed — see [Migrating from 1.x](#migrating-from-1x).
|
|
32
35
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
36
|
+
## Quickstart
|
|
37
|
+
|
|
38
|
+
The host page needs one mount point: `<div id="editor"></div>`.
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
import { Editor } from '@tiptap/core'
|
|
42
|
+
import StarterKit from '@tiptap/starter-kit'
|
|
43
|
+
import {
|
|
44
|
+
Hyperlink,
|
|
45
|
+
createHyperlinkPopover,
|
|
46
|
+
editHyperlinkPopover,
|
|
47
|
+
previewHyperlinkPopover
|
|
48
|
+
} from '@docs.plus/extension-hyperlink'
|
|
49
|
+
import '@docs.plus/extension-hyperlink/styles.css'
|
|
50
|
+
|
|
51
|
+
const editor = new Editor({
|
|
52
|
+
element: document.querySelector('#editor'),
|
|
53
|
+
content: '<p>Try <a href="https://example.com">this link</a>.</p>',
|
|
54
|
+
extensions: [
|
|
55
|
+
// Disable StarterKit's bundled link mark — see Caveats.
|
|
56
|
+
StarterKit.configure({ link: false }),
|
|
57
|
+
Hyperlink.configure({
|
|
58
|
+
popovers: {
|
|
59
|
+
previewHyperlink: previewHyperlinkPopover,
|
|
60
|
+
editHyperlink: editHyperlinkPopover,
|
|
61
|
+
createHyperlink: createHyperlinkPopover
|
|
62
|
+
}
|
|
63
|
+
})
|
|
40
64
|
]
|
|
41
65
|
})
|
|
42
|
-
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The `styles.css` import is optional. `popovers.previewHyperlink` is not: without it, a click on a link opens nothing. The snippet also styles no document link — see [Popovers](#popovers) and [Styling](#styling).
|
|
43
69
|
|
|
44
|
-
|
|
70
|
+
## Options
|
|
45
71
|
|
|
46
|
-
|
|
72
|
+
Every key below goes into `Hyperlink.configure({ … })`.
|
|
47
73
|
|
|
48
|
-
Default
|
|
74
|
+
| Option | Type | Default | Description |
|
|
75
|
+
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
76
|
+
| `autolink` | `boolean` | `true` | Converts URLs to links as you type. It also sets the mark's `inclusive()`, so `false` stops a character typed at the right edge from joining the link. |
|
|
77
|
+
| `openOnClick` | `boolean` | `true` | Gates the whole click plugin. Primary click opens the preview popover. With no `popovers.previewHyperlink`, a read-only editor opens the link in a new tab, and an editable editor opens no popover. `false` removes every pointer handler the package binds, including the click guard and the middle-click gate. |
|
|
78
|
+
| `linkOnPaste` | `boolean` | `true` | Wraps a non-empty selection in a link when the clipboard holds exactly one URL. It gates that path only — a pasted bare URL still autolinks at `false`. See [Caveats](#caveats). |
|
|
79
|
+
| `protocols` | `Array<LinkProtocolOptions \| string>` | `[]` | Extra schemes registered with [linkifyjs](https://linkify.js.org), which is what makes paste detect them. Each scheme registers once per process. The built-in catalog is separate and registers nothing with linkifyjs. See [URL handling](#url-handling). |
|
|
80
|
+
| `HTMLAttributes` | `Partial<HyperlinkAttributes>` | `{ target: null, rel: 'noopener noreferrer nofollow', class: null }` | Attributes merged onto every rendered `<a>`. A `null` value renders nothing, so the default anchor carries `rel` only. A `target` set here does reach the DOM — see [Caveats](#caveats). |
|
|
81
|
+
| `popovers` | `{ previewHyperlink?: ((options: PreviewHyperlinkOptions) => HTMLElement \| null) \| null; editHyperlink?: ((options: EditHyperlinkOptions) => HTMLElement \| null) \| null; createHyperlink?: ((options: CreateHyperlinkOptions) => HTMLElement \| null) \| null }` | `{ previewHyperlink: null, editHyperlink: null, createHyperlink: null }` | One factory per popover slot. The three openers fall back to the prebuilt factory when a slot is `null`. The click handler does not: leave `previewHyperlink` at `null` and a click on a link opens no popover. A factory that returns `null` opts out of that popover. See [Popovers](#popovers). |
|
|
82
|
+
| `validate` | `(url: string) => boolean` | `undefined` | URL gate at every write boundary, after `isSafeHref`. Return `false` to reject. See [`validate` vs `isAllowedUri`](#validate-vs-isalloweduri). |
|
|
83
|
+
| `defaultProtocol` | `string` | `'https'` | Scheme used when promoting bare domains (`example.com` → `${defaultProtocol}://example.com`). See [URL handling](#url-handling). |
|
|
84
|
+
| `isAllowedUri` | `(uri: string, ctx: IsAllowedUriContext) => boolean` | `undefined` | URL gate, Tiptap-canon shape. Same write boundaries as `validate`; `ctx` carries `{ defaultValidate, protocols, defaultProtocol }`. See [`validate` vs `isAllowedUri`](#validate-vs-isalloweduri). |
|
|
85
|
+
| `shouldAutoLink` | `(uri: string) => boolean` | `undefined` | Per-URI autolink veto. The autolink plugin, the paste handler, and the linkify paste rule all consult it. An explicit `setHyperlink` write skips it, because that is user intent. |
|
|
86
|
+
| `enableClickSelection` | `boolean` | `false` | With `true`, a click inside a link in an editable editor selects the whole mark range. That expansion also needs a collapsed selection. Every other case places the caret at the clicked position, and keeps a non-empty selection only when it overlaps the link. A read-only editor is one of those cases. All paths need a mounted preview popover, so with an empty `popovers.previewHyperlink` slot the package writes no selection at all. Mirrors `@tiptap/extension-link`. |
|
|
87
|
+
| `exitable` | `boolean` | `false` | With `true`, ArrowRight at the end of a hyperlink mark leaves the mark, so the next typed character is plain text. |
|
|
49
88
|
|
|
50
|
-
|
|
89
|
+
These option shapes need an example:
|
|
90
|
+
|
|
91
|
+
```ts
|
|
51
92
|
Hyperlink.configure({
|
|
52
|
-
|
|
93
|
+
protocols: ['ftp', { scheme: 'tel', optionalSlashes: true }],
|
|
94
|
+
isAllowedUri: (uri, ctx) => ctx.defaultValidate(uri) && !uri.includes('blocked.example'),
|
|
95
|
+
shouldAutoLink: (uri) => !uri.startsWith('@')
|
|
53
96
|
})
|
|
54
|
-
|
|
97
|
+
```
|
|
55
98
|
|
|
56
|
-
|
|
99
|
+
`LinkProtocolOptions` is `{ scheme: string; optionalSlashes?: boolean }`.
|
|
57
100
|
|
|
58
|
-
|
|
101
|
+
### `validate` vs `isAllowedUri`
|
|
59
102
|
|
|
60
|
-
|
|
103
|
+
Both are URL gates. Both run at every write boundary — set, edit, paste, input rule, autolink — after the built-in `isSafeHref` gate. Only the signature differs.
|
|
61
104
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
openOnClick: false,
|
|
65
|
-
})
|
|
66
|
-
````
|
|
105
|
+
- **`validate(url)`** — predates `isAllowedUri`. Use it for a plain URL-to-boolean check, such as "only http(s)" or "block this domain".
|
|
106
|
+
- **`isAllowedUri(uri, ctx)`** — Tiptap-canon shape, drop-in compatible with `@tiptap/extension-link` policies. Use it when you port an existing policy, or when you want `ctx.defaultValidate(uri)` to reuse the safety check.
|
|
67
107
|
|
|
68
|
-
|
|
108
|
+
Pick one. Setting both works, because they compose and a URL must pass both, but that is rarely the intent.
|
|
69
109
|
|
|
70
|
-
|
|
110
|
+
`isSafeHref` plus your `validate` and `isAllowedUri` hooks form the composed gate. The rest of this README uses that name.
|
|
71
111
|
|
|
72
|
-
|
|
112
|
+
`validateURL(url, { customValidator })` is a different tool. It is the form-level shape check the prebuilt popovers run before they call a command. It runs `isSafeHref` first, then the shape check, then your `customValidator`.
|
|
73
113
|
|
|
74
|
-
|
|
75
|
-
Hyperlink.configure({
|
|
76
|
-
linkOnPaste: false,
|
|
77
|
-
})
|
|
78
|
-
````
|
|
114
|
+
## Commands
|
|
79
115
|
|
|
80
|
-
|
|
116
|
+
Every command below lands on `editor.commands` and on `editor.chain()`. On `editor.commands`, each returns `boolean`, and `false` marks a no-op.
|
|
81
117
|
|
|
82
|
-
|
|
118
|
+
| Command | Description |
|
|
119
|
+
| ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
120
|
+
| `setHyperlink({ href, target?, title?, image? })` | Writes the hyperlink mark over the current selection. Returns `false` when `href` is empty, when the composed gate rejects it, or when the schema cannot apply the mark there. |
|
|
121
|
+
| `unsetHyperlink()` | Removes the mark from the current selection, and extends an empty selection across the whole mark first. |
|
|
122
|
+
| `toggleHyperlink({ href, target?, title?, image? })` | Removes the mark when one is active at the selection, and writes it otherwise. Same gates as `setHyperlink`. |
|
|
123
|
+
| `setLink` / `unsetLink` / `toggleLink` | Aliases that share the canonical implementations, so a policy change reaches both names. They collide with StarterKit's bundled link mark — see [Caveats](#caveats). |
|
|
124
|
+
| `editHyperlink({ newURL?, newText?, title?, image? })` | Updates any combination of href, inner text, `title`, and `image` on the link at the current selection. |
|
|
125
|
+
| `editHyperlinkHref(url)` | Shorthand for an href-only edit. Same gates as `editHyperlink`. |
|
|
126
|
+
| `editHyperlinkText(text)` | Shorthand for a text-only edit. |
|
|
127
|
+
| `openCreateHyperlinkPopover(attributes?)` | Opens the create-link form anchored to the current selection. The command uses `popovers.createHyperlink` when that slot holds a factory, and the prebuilt form otherwise. Returns `false` only when a configured factory returns `null`. |
|
|
83
128
|
|
|
84
|
-
|
|
85
|
-
Hyperlink.configure({
|
|
86
|
-
HTMLAttributes: {
|
|
87
|
-
class: 'my-custom-class',
|
|
88
|
-
},
|
|
89
|
-
})
|
|
90
|
-
````
|
|
129
|
+
`editHyperlink` returns `false` (no-op) on four paths, checked in this order:
|
|
91
130
|
|
|
92
|
-
|
|
131
|
+
1. `newURL` fails the shape check, for example `https://googlecom`.
|
|
132
|
+
2. The schema carries no mark under that name.
|
|
133
|
+
3. No hyperlink mark covers the selection, or the mark at that position cannot be read.
|
|
134
|
+
4. The composed gate rejects `newURL`.
|
|
93
135
|
|
|
94
|
-
The
|
|
136
|
+
The prebuilt edit form closes on path 3, because there is nothing left to edit. It shows an inline error on the other paths and stays open.
|
|
95
137
|
|
|
96
|
-
|
|
97
|
-
- [Demo](https://github.com/HMarzban/extension-hyperlink#test-drive-with-our-demo-)
|
|
98
|
-
|
|
99
|
-
<details>
|
|
100
|
-
<summary>The `previewHyperlinkModal` function</summary>
|
|
138
|
+
`openCreateHyperlinkPopover` opens UI, so `editor.can().openCreateHyperlinkPopover()` reports availability without mounting anything. With nothing selected, the prebuilt form inserts the typed URL as its own link text.
|
|
101
139
|
|
|
102
140
|
```ts
|
|
103
|
-
|
|
104
|
-
|
|
141
|
+
editor.chain().focus().setHyperlink({ href: 'https://example.com' }).run()
|
|
142
|
+
editor.getAttributes('hyperlink').href // read the current href
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Keyboard shortcuts
|
|
105
146
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
147
|
+
| Shortcut | Context | Action |
|
|
148
|
+
| ------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
149
|
+
| `Mod-k` | `document` | Opens the create-link form anchored to the current selection. |
|
|
150
|
+
| `ArrowRight` | `document` | With `exitable: true`, leaves the hyperlink mark at its right edge, so the next character is plain text. |
|
|
151
|
+
| `Escape` | `popover` | Hides the popover. Focus stays where it is. |
|
|
152
|
+
| `Tab` / `Shift-Tab` | `popover` | Moves focus to the next or previous control inside the popover, and wraps at both ends. |
|
|
153
|
+
| `Escape` | `create-link form` | Hides the popover and returns focus to the editor from the URL field. From the Apply button the shell handler hides the popover and leaves focus where it is. |
|
|
154
|
+
| `Escape` | `edit-link form` | Hides the popover and returns focus to the editor from any control. |
|
|
109
155
|
|
|
110
|
-
|
|
111
|
-
newBubble.classList.add("hyperlink-preview-modal__metadata");
|
|
156
|
+
The shell binds `Escape` on the popover root, and `Tab` on each control inside it. Both keys therefore need focus inside the popover. The preview popover takes no focus when it opens, so dismiss it with an outside click.
|
|
112
157
|
|
|
113
|
-
|
|
114
|
-
hrefTitle.setAttribute("target", "_blank");
|
|
115
|
-
hrefTitle.setAttribute("rel", "noreferrer");
|
|
116
|
-
hrefTitle.setAttribute("href", href);
|
|
117
|
-
hrefTitle.innerText = href;
|
|
158
|
+
The mark runs at `priority: 1000`, above the Tiptap default `100`. `@tiptap/extension-link` binds no keyboard shortcut, so `Mod-k` is never contested. The two marks do collide on commands and on the `a[href]` parse rule — see [Caveats](#caveats).
|
|
118
159
|
|
|
119
|
-
|
|
160
|
+
## Caveats
|
|
120
161
|
|
|
121
|
-
|
|
162
|
+
- **`StarterKit.configure({ link: false })` is required, not stylistic.** StarterKit v3 bundles `@tiptap/extension-link` by default. Both marks sit at `priority: 1000`, and Tiptap merges every `addCommands()` into one flat map. `setLink` / `unsetLink` / `toggleLink` then resolve to whichever mark comes later in your `extensions` array, with no warning. Both marks also claim the `a[href]` parse rule, and ProseMirror applies only the first match: one mark takes every anchor and the other never parses. If the upstream mark wins, this package's [`isSafeHref`](#security) gate never runs on parsed or pasted HTML, and its popovers never attach.
|
|
163
|
+
- **`linkOnPaste: false` does not stop every paste from linking.** The option gates the paste-over-selection plugin alone. The linkify paste rule stays registered, so a pasted bare URL still becomes a link. To stop that path too, set `shouldAutoLink: () => false`, which every autolink path consults.
|
|
164
|
+
- **The markdown `[text](url)` input rule is always on.** No option turns it off, so typing a literal `[a](b)` writes a link. Remove it afterwards with `editor.commands.unsetHyperlink()`. See [Markdown](#markdown).
|
|
165
|
+
- **`HTMLAttributes.target` reaches the DOM.** The mark attribute `target` carries `rendered: false`, so a stored `_blank` never renders. The option object is merged separately in `renderHTML`, so `HTMLAttributes: { target: '_blank' }` does emit `<a target="_blank">`. A browser-driven `target="_blank"` navigation skips the click gate, which is the risk the mark-level `rendered: false` exists to block. Leave `target` at `null` and let the click handler open the link.
|
|
166
|
+
- **The mark name is fixed at `hyperlink`.** Stored documents and the markdown wiring both key on it. `openPreviewHyperlink`, `openEditHyperlink`, and `openCreateHyperlink` throw when no extension answers to that name. Do not rename the mark.
|
|
167
|
+
- **[`getDefaultController()`](#ui-controller) owns one controller per bundle, not one per page.** A host that also loads `@docs.plus/extension-hypermultimedia` gets two controllers. Opening a popover in one package does not dismiss the popover of the other. Close the other popover yourself when you open one.
|
|
122
168
|
|
|
123
|
-
|
|
124
|
-
removeButton.innerHTML = "remove";
|
|
169
|
+
## Styling
|
|
125
170
|
|
|
126
|
-
|
|
127
|
-
copyButton.innerHTML = "copy";
|
|
171
|
+
The prebuilt popovers ship a stylesheet — import it once when you use them:
|
|
128
172
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
173
|
+
```ts
|
|
174
|
+
import '@docs.plus/extension-hyperlink/styles.css'
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
The package's JavaScript never imports this file. Skip the import with a fully custom UI, and no CSS from this package reaches your bundle.
|
|
133
178
|
|
|
134
|
-
|
|
135
|
-
options.tippy.hide();
|
|
136
|
-
navigator.clipboard.writeText(href);
|
|
137
|
-
});
|
|
179
|
+
The stylesheet skins the popovers only. The mark renders a plain `<a>` with no class, so style your document links yourself:
|
|
138
180
|
|
|
139
|
-
|
|
181
|
+
```ts
|
|
182
|
+
Hyperlink.configure({ HTMLAttributes: { class: 'my-link' } })
|
|
183
|
+
```
|
|
140
184
|
|
|
141
|
-
|
|
185
|
+
```css
|
|
186
|
+
a.my-link {
|
|
187
|
+
color: #2563eb;
|
|
188
|
+
text-decoration: underline;
|
|
142
189
|
}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
### Theming
|
|
143
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
|
+
}
|
|
144
219
|
```
|
|
145
220
|
|
|
146
221
|
</details>
|
|
147
222
|
|
|
223
|
+
JavaScript positions the popover shell; the stylesheet does not. The shell gets `position: fixed` and an inline `z-index: 9999`. The three prebuilt popovers pin that value and expose no option for it. `createPopover({ zIndex })` sets the stacking of a popover you build yourself — see [Floating-popover primitive](#floating-popover-primitive).
|
|
224
|
+
|
|
225
|
+
If your app has a light/dark toggle, set `color-scheme` on the theme root and the popover follows along:
|
|
226
|
+
|
|
227
|
+
```css
|
|
228
|
+
html[data-theme='light'] {
|
|
229
|
+
color-scheme: light;
|
|
230
|
+
}
|
|
231
|
+
html[data-theme='dark'] {
|
|
232
|
+
color-scheme: dark;
|
|
233
|
+
}
|
|
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
|
|
281
|
+
|
|
282
|
+
Prebuilt create, preview, and edit popovers (`styles.css` + `popovers` config). Each screenshot has a light and a dark version, and follows your system preference.
|
|
283
|
+
|
|
148
284
|
<details>
|
|
149
|
-
<summary>
|
|
285
|
+
<summary><strong>Create</strong> — Mod+K</summary>
|
|
150
286
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
287
|
+
<p align="center">
|
|
288
|
+
<picture>
|
|
289
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/docs-plus/docs.plus/main/extensions/extension-hyperlink/assets/create-dark.png">
|
|
290
|
+
<img alt="Create-link popover with URL field and Apply button" width="640" src="https://raw.githubusercontent.com/docs-plus/docs.plus/main/extensions/extension-hyperlink/assets/create-light.png">
|
|
291
|
+
</picture>
|
|
292
|
+
</p>
|
|
154
293
|
|
|
155
|
-
|
|
294
|
+
</details>
|
|
156
295
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
if (!tooltip) tooltip = new options.Tooltip(options);
|
|
296
|
+
<details>
|
|
297
|
+
<summary><strong>Preview</strong> — click a link</summary>
|
|
160
298
|
|
|
161
|
-
|
|
162
|
-
|
|
299
|
+
<p align="center">
|
|
300
|
+
<picture>
|
|
301
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/docs-plus/docs.plus/main/extensions/extension-hyperlink/assets/preview-dark.png">
|
|
302
|
+
<img alt="Preview popover on a link — copy, edit, and remove actions" width="640" src="https://raw.githubusercontent.com/docs-plus/docs.plus/main/extensions/extension-hyperlink/assets/preview-light.png">
|
|
303
|
+
</picture>
|
|
304
|
+
</p>
|
|
163
305
|
|
|
164
|
-
|
|
165
|
-
const buttonsWrapper = document.createElement("div");
|
|
166
|
-
const inputsWrapper = document.createElement("div");
|
|
306
|
+
</details>
|
|
167
307
|
|
|
168
|
-
|
|
308
|
+
<details>
|
|
309
|
+
<summary><strong>Edit</strong> — preview → Edit</summary>
|
|
169
310
|
|
|
170
|
-
|
|
171
|
-
|
|
311
|
+
<p align="center">
|
|
312
|
+
<picture>
|
|
313
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/docs-plus/docs.plus/main/extensions/extension-hyperlink/assets/edit-dark.png">
|
|
314
|
+
<img alt="Edit-link popover with URL and text fields and Apply button" width="640" src="https://raw.githubusercontent.com/docs-plus/docs.plus/main/extensions/extension-hyperlink/assets/edit-light.png">
|
|
315
|
+
</picture>
|
|
316
|
+
</p>
|
|
172
317
|
|
|
173
|
-
|
|
174
|
-
const form = document.createElement("form");
|
|
175
|
-
const input = document.createElement("input");
|
|
176
|
-
const button = document.createElement("button");
|
|
318
|
+
</details>
|
|
177
319
|
|
|
178
|
-
|
|
179
|
-
input.setAttribute("placeholder", "https://example.com");
|
|
180
|
-
button.setAttribute("type", "submit");
|
|
181
|
-
button.innerText = "Submit";
|
|
320
|
+
Three ways to use them:
|
|
182
321
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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).
|
|
186
325
|
|
|
187
|
-
|
|
326
|
+
### Popover-factory option shapes
|
|
188
327
|
|
|
189
|
-
|
|
190
|
-
tippyModal.append(hyperlinkModal);
|
|
191
|
-
tooltip.update(options.editor.view, {
|
|
192
|
-
arrow: options.roundArrow,
|
|
193
|
-
});
|
|
328
|
+
Every factory takes one `options` argument. The shape depends on the slot.
|
|
194
329
|
|
|
195
|
-
|
|
196
|
-
setTimeout(() => input.focus();, 100);
|
|
330
|
+
**`PreviewHyperlinkOptions`** — passed to `popovers.previewHyperlink` on every link click.
|
|
197
331
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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`.** |
|
|
202
340
|
|
|
203
|
-
|
|
341
|
+
Return `null` to opt out for this click, for example to open a mobile bottom sheet. The package then mounts no popover.
|
|
204
342
|
|
|
205
|
-
|
|
206
|
-
.filter((link) => link.isLink)
|
|
207
|
-
.filter((link) => {
|
|
208
|
-
if (options.validate) {
|
|
209
|
-
return options.validate(link.value);
|
|
210
|
-
}
|
|
211
|
-
return true;
|
|
212
|
-
})
|
|
213
|
-
.at(0);
|
|
343
|
+
**`CreateHyperlinkOptions`** — passed to `popovers.createHyperlink` when `Mod-k` or `editor.commands.openCreateHyperlinkPopover()` fires.
|
|
214
344
|
|
|
215
|
-
|
|
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. |
|
|
216
351
|
|
|
217
|
-
|
|
352
|
+
Return `null` when the popover cannot open, for example when DOM construction fails. The command then returns `false`.
|
|
218
353
|
|
|
219
|
-
|
|
220
|
-
.chain()
|
|
221
|
-
.setMark(options.extentionName, { href: sanitizeURL.href })
|
|
222
|
-
.setMeta("preventautohyperlink", true)
|
|
223
|
-
.run();
|
|
224
|
-
});
|
|
225
|
-
}
|
|
226
|
-
```
|
|
354
|
+
**`EditHyperlinkOptions`** — passed to `popovers.editHyperlink`, and to `openEditHyperlink(opts)` when you call it from outside a preview popover.
|
|
227
355
|
|
|
228
|
-
|
|
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`. |
|
|
229
365
|
|
|
366
|
+
### Openers
|
|
230
367
|
|
|
231
|
-
|
|
232
|
-
<summary>The Hyperlink styles.scss</summary>
|
|
233
|
-
|
|
234
|
-
```scss
|
|
235
|
-
.tippy-box {
|
|
236
|
-
.hyperlink-preview-modal,
|
|
237
|
-
.hyperlink-set-modal,
|
|
238
|
-
.hyperlink-edit-modal {
|
|
239
|
-
background-color: #fff;
|
|
240
|
-
border-radius: 10px;
|
|
241
|
-
border: 1px solid #dadce0;
|
|
242
|
-
display: flex;
|
|
243
|
-
align-items: center;
|
|
244
|
-
justify-content: space-between;
|
|
245
|
-
padding: 6px 6px;
|
|
246
|
-
box-shadow: 0 1px 3px 1px rgba(60, 64, 67, 0.15);
|
|
247
|
-
margin-top: -6px;
|
|
248
|
-
|
|
249
|
-
&__metadata {
|
|
250
|
-
width: 200px;
|
|
251
|
-
display: flex;
|
|
252
|
-
align-items: center;
|
|
253
|
-
justify-content: flex-end;
|
|
254
|
-
flex-direction: row-reverse;
|
|
255
|
-
a {
|
|
256
|
-
font-size: 0.9rem;
|
|
257
|
-
margin-right: 6px;
|
|
258
|
-
white-space: nowrap;
|
|
259
|
-
overflow: hidden;
|
|
260
|
-
text-overflow: ellipsis;
|
|
261
|
-
}
|
|
262
|
-
img {
|
|
263
|
-
width: 18px;
|
|
264
|
-
height: 18px;
|
|
265
|
-
border-radius: 50%;
|
|
266
|
-
margin-right: 8px;
|
|
267
|
-
}
|
|
268
|
-
}
|
|
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.
|
|
269
369
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
height: 30px;
|
|
276
|
-
border-radius: 50%;
|
|
277
|
-
margin: 0 0.25rem;
|
|
278
|
-
display: flex;
|
|
279
|
-
align-items: center;
|
|
280
|
-
justify-content: center;
|
|
281
|
-
transition: background-color 0.1s ease-in-out;
|
|
282
|
-
&:hover {
|
|
283
|
-
background-color: #eee;
|
|
284
|
-
}
|
|
285
|
-
> svg {
|
|
286
|
-
width: 16px;
|
|
287
|
-
height: 16px;
|
|
288
|
-
}
|
|
289
|
-
}
|
|
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`. |
|
|
290
375
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
padding: 0.4rem 0.8rem;
|
|
299
|
-
margin-bottom: 0.2rem;
|
|
300
|
-
width: 100%;
|
|
301
|
-
&:last-of-type {
|
|
302
|
-
margin-bottom: 0;
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
.hyperlink-set-modal__buttons-wrapper,
|
|
306
|
-
.hyperlink-edit-modal__buttons-wrapper {
|
|
307
|
-
margin-left: 8px;
|
|
308
|
-
button {
|
|
309
|
-
border-radius: 6px;
|
|
310
|
-
padding: 4px 14px;
|
|
311
|
-
width: 70px;
|
|
312
|
-
margin-bottom: 0.2rem;
|
|
313
|
-
color: #1a73e8;
|
|
314
|
-
&:hover {
|
|
315
|
-
background: rgba(26, 115, 232, 0.04);
|
|
316
|
-
color: #174ea6;
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
}
|
|
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).
|
|
322
383
|
|
|
323
|
-
|
|
324
|
-
|
|
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)
|
|
325
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
|
|
326
434
|
}
|
|
435
|
+
|
|
436
|
+
const HyperlinkWithPreview = Hyperlink.configure({ popovers: { previewHyperlink } })
|
|
327
437
|
```
|
|
328
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
|
+
|
|
329
441
|
</details>
|
|
330
442
|
|
|
443
|
+
<details>
|
|
444
|
+
<summary><b>Custom <code>createHyperlink</code></b></summary>
|
|
331
445
|
|
|
332
|
-
|
|
446
|
+
```ts
|
|
333
447
|
import {
|
|
448
|
+
getDefaultController,
|
|
334
449
|
Hyperlink,
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
} from
|
|
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
|
+
}
|
|
338
485
|
|
|
339
|
-
Hyperlink.configure({
|
|
340
|
-
|
|
341
|
-
previewHyperlink: (data) => {
|
|
342
|
-
return previewHyperlinkModal(data);
|
|
343
|
-
},
|
|
344
|
-
setHyperlink: (data) => {
|
|
345
|
-
return setHyperlinkModal(data);
|
|
346
|
-
},
|
|
347
|
-
},
|
|
348
|
-
})
|
|
349
|
-
````
|
|
486
|
+
const HyperlinkWithCreateForm = Hyperlink.configure({ popovers: { createHyperlink } })
|
|
487
|
+
```
|
|
350
488
|
|
|
351
|
-
|
|
489
|
+
Pass `HyperlinkWithCreateForm` into `new Editor({ extensions: [...] })`. Drop the value and `Mod-k` opens the prebuilt form instead.
|
|
352
490
|
|
|
353
|
-
|
|
491
|
+
</details>
|
|
354
492
|
|
|
493
|
+
<details>
|
|
494
|
+
<summary><b>Custom <code>editHyperlink</code></b></summary>
|
|
355
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
|
|
356
558
|
|
|
357
|
-
|
|
559
|
+
getDefaultController().close()
|
|
560
|
+
})
|
|
358
561
|
|
|
359
|
-
|
|
562
|
+
queueMicrotask(() => textInput.focus())
|
|
360
563
|
|
|
361
|
-
|
|
564
|
+
return form
|
|
565
|
+
}
|
|
362
566
|
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
HTMLAttributes: {
|
|
366
|
-
// Change rel to different value
|
|
367
|
-
// Allow search engines to follow links(remove nofollow)
|
|
368
|
-
rel: 'noopener noreferrer',
|
|
369
|
-
// Remove target entirely so links open in current tab
|
|
370
|
-
target: null,
|
|
371
|
-
},
|
|
372
|
-
})
|
|
373
|
-
````
|
|
567
|
+
const HyperlinkWithEditForm = Hyperlink.configure({ popovers: { editHyperlink } })
|
|
568
|
+
```
|
|
374
569
|
|
|
375
|
-
|
|
570
|
+
Pass `HyperlinkWithEditForm` into `new Editor({ extensions: [...] })`. Drop the value and the preview Edit button opens the prebuilt form instead.
|
|
376
571
|
|
|
377
|
-
|
|
572
|
+
</details>
|
|
378
573
|
|
|
379
|
-
|
|
574
|
+
## URL handling
|
|
380
575
|
|
|
381
|
-
|
|
382
|
-
// only autolink urls with a protocol
|
|
383
|
-
Hyperlink.configure({
|
|
384
|
-
validate: href => /^https?:\/\//.test(href),
|
|
385
|
-
})
|
|
386
|
-
````
|
|
576
|
+
Every write boundary canonicalizes the href before storing it:
|
|
387
577
|
|
|
388
|
-
|
|
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.
|
|
389
588
|
|
|
390
|
-
###
|
|
589
|
+
### Scheme classification
|
|
391
590
|
|
|
392
|
-
|
|
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`.
|
|
393
592
|
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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])
|
|
401
607
|
```
|
|
402
608
|
|
|
403
|
-
|
|
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.
|
|
404
610
|
|
|
405
|
-
|
|
611
|
+
## Markdown
|
|
406
612
|
|
|
407
|
-
|
|
408
|
-
this.editor.commands.setHyperlink({
|
|
409
|
-
href: '<https://example.com>'
|
|
410
|
-
});
|
|
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.
|
|
411
614
|
|
|
412
|
-
|
|
413
|
-
href: '<https://example.com>',
|
|
414
|
-
target: '_blank'
|
|
415
|
-
});
|
|
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.
|
|
416
616
|
|
|
417
|
-
|
|
418
|
-
````
|
|
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).
|
|
419
618
|
|
|
420
|
-
|
|
619
|
+
## Advanced
|
|
421
620
|
|
|
422
|
-
|
|
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.
|
|
423
622
|
|
|
424
|
-
|
|
425
|
-
this.editor.commands.unsetHyperlink();
|
|
426
|
-
````
|
|
623
|
+
### Floating-popover primitive
|
|
427
624
|
|
|
428
|
-
|
|
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. |
|
|
429
647
|
|
|
430
|
-
|
|
431
|
-
This extension doesn’t bind a specific keyboard shortcut. You would probably open your custom UI on `Mod-k` though.
|
|
648
|
+
Returns a `Popover`:
|
|
432
649
|
|
|
433
|
-
|
|
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. |
|
|
434
659
|
|
|
435
|
-
|
|
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.
|
|
436
661
|
|
|
437
|
-
|
|
438
|
-
|
|
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
|
+
}
|
|
439
679
|
```
|
|
440
680
|
|
|
441
|
-
|
|
681
|
+
`referenceElement` holds the anchor node for the preview popover only. Create and edit both anchor to virtual coordinates, so both report `null`.
|
|
682
|
+
|
|
683
|
+
`PopoverController`:
|
|
684
|
+
|
|
685
|
+
| Member | Description |
|
|
686
|
+
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
687
|
+
| `adopt(popover, kind, metadata)` | Takes ownership of a `Popover` and destroys the previous owner. Returns an unregister function. The openers call it; consumers rarely do. |
|
|
688
|
+
| `close()` | Dismisses the active popover, and does nothing when idle. |
|
|
689
|
+
| `reposition(ref?, coords?)` | Re-anchors the active popover after the underlying mark moves, such as an external edit or a document rewrite. |
|
|
690
|
+
| `getState()` | One-shot read of `ControllerState`. |
|
|
691
|
+
| `subscribe(listener)` | Registers a state-change listener and returns an unsubscribe function. The listener does **not** fire on subscribe — call `listener(controller.getState())` yourself for the initial state. |
|
|
692
|
+
|
|
693
|
+
One controller serves one bundle. See [Caveats](#caveats) for what that means next to another docs.plus extension.
|
|
694
|
+
|
|
695
|
+
### Tooltip primitive
|
|
696
|
+
|
|
697
|
+
`attachTooltip(target, label)` puts a hover and focus tooltip on your own button. The prebuilt popovers use the same tooltip. The bubble appears 400 ms after the pointer enters, and on keyboard focus only when the button matches `:focus-visible`.
|
|
698
|
+
|
|
699
|
+
`attachTooltip` returns a detach function, and it binds six listeners per call. Call the detach function when your popover re-renders in place, or the listeners stack on every render.
|
|
700
|
+
|
|
701
|
+
`hideTooltip()` hides the shared bubble. The bubble is per bundle, so attach and hide from the same package. Otherwise `hideTooltip()` leaves the other package's bubble on screen.
|
|
702
|
+
|
|
703
|
+
## Security
|
|
704
|
+
|
|
705
|
+
`isSafeHref` blocks the dangerous schemes (`javascript:`, `data:`, `vbscript:`, `file:`, `blob:`) at every boundary below. The gate tests a control-stripped copy of the href. An embedded tab, newline, or control character therefore cannot hide a scheme from the gate, and the gate catches `java\tscript:` even though browsers resolve it as `javascript:`.
|
|
706
|
+
|
|
707
|
+
- **Parse** — `parseHTML` drops the mark on any `<a href>` whose scheme is blocked, on document load and on paste. The text survives; only the link goes.
|
|
708
|
+
- **Import** — `parseMarkdown` normalizes the href and stores an empty string when the scheme is blocked. Both import paths apply the `isSafeHref` floor alone, so a tightened `isAllowedUri` never strips marks from existing documents.
|
|
709
|
+
- **Write** — the input rule, paste rule, paste handler, autolink, `setHyperlink`, `toggleHyperlink`, and `editHyperlink` all route through the composed gate.
|
|
710
|
+
- **Serialize** — `renderHTML` re-checks `isSafeHref` and emits an empty `href` when a hostile mark reaches it through a legacy migration, a raw `addMark`, or Yjs replay. `renderMarkdown` blanks the same way.
|
|
711
|
+
- **Navigate** — primary click, middle-click (`auxclick`), touch, and the preview popover's href link all gate `window.open(…)` on the composed gate. They also pass `'noopener,noreferrer'`, so an opened tab cannot read `window.opener` or leak the Referer.
|
|
712
|
+
|
|
713
|
+
On the read side, the click handlers prefer the stored mark attribute over the DOM `link.href` property. A relative href injected through `setContent` therefore does not resolve against the host page's origin.
|
|
714
|
+
|
|
715
|
+
`isSafeHref(href)` and `DANGEROUS_SCHEME_RE` are exported for a custom popover that needs the same check. Prefer `isSafeHref`, because it returns a TypeScript type guard.
|
|
716
|
+
|
|
717
|
+
`SAFE_WINDOW_FEATURES` is the `'noopener,noreferrer'` string the package passes as the third argument of every `window.open` call. Pin the same constant in your own popovers and click handlers, so a future tightening propagates from one place.
|
|
718
|
+
|
|
719
|
+
## Migrating from 1.x
|
|
720
|
+
|
|
721
|
+
`2.0` redesigns the public surface. Option names, command names, CSS class names, the popover contract, and URL validation all changed.
|
|
722
|
+
|
|
723
|
+
**Options and commands.**
|
|
724
|
+
|
|
725
|
+
| `1.x` | `2.0` |
|
|
726
|
+
| ------------------------------------ | ------------------------------- |
|
|
727
|
+
| `autoHyperlink` | `autolink` |
|
|
728
|
+
| `hyperlinkOnPaste` | `linkOnPaste` |
|
|
729
|
+
| `editHyperLinkText` | `editHyperlinkText` |
|
|
730
|
+
| `editHyperLinkHref` | `editHyperlinkHref` |
|
|
731
|
+
| `tr.setMeta('preventAutoHyperlink')` | `tr.setMeta('preventAutolink')` |
|
|
732
|
+
|
|
733
|
+
The package augments the commands under the `hyperlink:` key, not `link:`. `setHyperlink()` with no arguments no longer opens UI — call `openCreateHyperlinkPopover()` instead.
|
|
734
|
+
|
|
735
|
+
**CSS class names.** camelCase became kebab-case, and the shell prefix changed from `.floating-toolbar` to `.floating-popover`.
|
|
736
|
+
|
|
737
|
+
| `1.x` | `2.0` |
|
|
738
|
+
| --------------------------- | ---------------------------- |
|
|
739
|
+
| `.floating-toolbar` | `.floating-popover` |
|
|
740
|
+
| `.floating-toolbar-arrow` | `.floating-popover-arrow` |
|
|
741
|
+
| `.floating-toolbar-content` | `.floating-popover-content` |
|
|
742
|
+
| `.hyperlinkCreatePopover` | `.hyperlink-create-popover` |
|
|
743
|
+
| `.hyperlinkPreviewPopover` | `.hyperlink-preview-popover` |
|
|
744
|
+
| `.hyperlinkEditPopover` | `.hyperlink-edit-popover` |
|
|
745
|
+
| `.buttonsWrapper` | `.buttons-wrapper` |
|
|
746
|
+
| `.inputsWrapper` | `.inputs-wrapper` |
|
|
747
|
+
| `.textWrapper` | `.text-wrapper` |
|
|
748
|
+
| `.hrefWrapper` | `.href-wrapper` |
|
|
749
|
+
| `.backButton` | `.back-button` |
|
|
750
|
+
| `.btn_applyModal` | `.apply-button` |
|
|
751
|
+
|
|
752
|
+
**Popover API.** The v1 split between "popover" and "floating-toolbar" is gone.
|
|
753
|
+
|
|
754
|
+
| `1.x` | `2.0` |
|
|
755
|
+
| ----------------------------------------- | ---------------------------------------------------------- |
|
|
756
|
+
| `createFloatingToolbar(opts)` | `createPopover(opts)`, same shape minus `surface` |
|
|
757
|
+
| `hideCurrentToolbar()` | `getDefaultController().close()` |
|
|
758
|
+
| `updateCurrentToolbarPosition(ref?)` | `getDefaultController().reposition(ref?)` |
|
|
759
|
+
| `FloatingToolbarOptions` / `…Instance` | `PopoverOptions` / `Popover` |
|
|
760
|
+
| `HyperlinkUIController` | `PopoverController` |
|
|
761
|
+
| `SurfaceKind` | `PopoverKind` |
|
|
762
|
+
| `EditHyperlinkPopoverOptions` / `…Modal…` | `EditHyperlinkOptions` |
|
|
763
|
+
| `state.surface` | `state.popoverKind`, plus `element` and `referenceElement` |
|
|
764
|
+
|
|
765
|
+
Slot factories return `HTMLElement | null` instead of `void`, and `PreviewHyperlinkOptions.attrs` is now required. The stylesheet no longer auto-injects — add `import '@docs.plus/extension-hyperlink/styles.css'` at app bootstrap when you use the prebuilt popovers.
|
|
766
|
+
|
|
767
|
+
**Behavior differences.** Audit any fixture or seeded content that relied on the old behavior.
|
|
768
|
+
|
|
769
|
+
- The package rejects `javascript:`, `data:`, and `vbscript:` URLs at load, paste, input rule, click, and popover open, and drops stored ones.
|
|
770
|
+
- `validateURL` requires a plausible host for a web scheme, so `https://googlecom` no longer autolinks.
|
|
771
|
+
- `localhost:3000` and `mydomain.com:8080` now read as host:port, and canonicalize to `https://localhost:3000` and `https://mydomain.com:8080`.
|
|
772
|
+
- `SpecialUrlIcon` is gone, along with `SpecialUrlInfo.icon`. Map `SpecialUrlInfo.type` to your own renderer — see [Scheme classification](#scheme-classification).
|
|
773
|
+
- Two `SpecialUrlInfo.type` values were renamed: `'tv'` → `'apple-tv'`, and `'appstore'` → `'app-store'`.
|
|
774
|
+
|
|
775
|
+
The full breaking-change list, with a one-shot rename script, is in the [CHANGELOG](https://github.com/docs-plus/docs.plus/blob/main/extensions/extension-hyperlink/CHANGELOG.md#migrating-from-1x-to-20).
|
|
776
|
+
|
|
777
|
+
## TypeScript
|
|
778
|
+
|
|
779
|
+
The package bundles the type definitions. The complete public surface, grouped by role:
|
|
780
|
+
|
|
781
|
+
- **Extension** — `Hyperlink`, also the default export. Types `HyperlinkOptions`, `HyperlinkAttributes`, `HyperlinkStorage`, `HyperlinkPublicCommands`, `SetHyperlinkAttributes`, `EditHyperlinkAttributes`, `IsAllowedUriContext`, `LinkProtocolOptions`. `HyperlinkAttributes` is generic. `HyperlinkAttributes<{ ariaLabel: string }>` extends the built-in `href` / `target` / `rel` / `class` / `title` / `image` keys with your own typed fields. The default parameter is `Record<string, unknown>`, so the unparameterized type stays open; supplying your own `Extra` replaces it and closes the type.
|
|
782
|
+
- **Popover factories** — `previewHyperlinkPopover`, `createHyperlinkPopover`, `editHyperlinkPopover`. Types `PreviewHyperlinkOptions`, `CreateHyperlinkOptions`, `EditHyperlinkOptions`, documented under [Popover-factory option shapes](#popover-factory-option-shapes).
|
|
783
|
+
- **Openers** — `openPreviewHyperlink`, `openEditHyperlink`, `openCreateHyperlink`, `buildPreviewOptionsFromAnchor`. Type `BuildPreviewOptionsFromAnchorArgs`. See [Openers](#openers).
|
|
784
|
+
- **Floating-popover primitive** — `createPopover`, `DEFAULT_OFFSET`. Types `Popover`, `PopoverOptions`. See [Floating-popover primitive](#floating-popover-primitive).
|
|
785
|
+
- **UI controller** — `getDefaultController`. Types `PopoverController`, `PopoverKind`, `ControllerState`, `AdoptMetadata`, `VirtualCoordinates`. See [UI controller](#ui-controller).
|
|
786
|
+
- **Tooltip primitive** — `attachTooltip`, `hideTooltip`, re-exported from the bundled `@docs.plus/floating-tooltip`. See [Tooltip primitive](#tooltip-primitive).
|
|
787
|
+
- **URL utilities** — `normalizeHref`, `getSpecialUrlInfo`, `validateURL`, `isSafeHref`, `DANGEROUS_SCHEME_RE`, `SAFE_WINDOW_FEATURES`. Types `SpecialUrlInfo`, `SpecialUrlType`, `LinkifyMatchLike`, `ValidateURLOptions`.
|
|
788
|
+
- **DOM helpers** — `copyToClipboard(text, callback?)` writes `text` to the clipboard and reports success through `callback`. `createHTMLElement(tag, props?)` builds one element and assigns `props` onto it. The SVG icon factories `Copy`, `LinkOff`, `Pencil` take `IconProps`. The prebuilt preview toolbar renders those three icons; reuse them for visual parity.
|
|
789
|
+
- **Linkify re-export** — `registerCustomProtocol`, passed through from [linkifyjs](https://linkify.js.org).
|
|
790
|
+
|
|
791
|
+
## Part of docs.plus
|
|
792
|
+
|
|
793
|
+
This extension is built for and maintained by [docs.plus](https://docs.plus). docs.plus is a free, real-time collaboration tool that lets communities organize knowledge hierarchically, with a chat thread on every heading. docs.plus runs these packages from source in production, so every release is exercised there before it reaches npm.
|
|
442
794
|
|
|
443
|
-
-
|
|
444
|
-
[
|
|
445
|
-
-
|
|
446
|
-
[packages/extension-hyperlink](https://github.com/HMarzban/extension-hyperlink/tree/main/packages/extension-hyperlink)
|
|
447
|
-
- Usage: [packages/nextjs/src/components/Tiptap.tsx](https://github.com/HMarzban/extension-hyperlink/blob/59f45eba1886202f4840eb2112c34574c16fe68a/packages/nextjs/src/components/Tiptap.tsx#L19-L29)
|
|
795
|
+
- Website: [docs.plus](https://docs.plus)
|
|
796
|
+
- Project README: [docs-plus/docs.plus](https://github.com/docs-plus/docs.plus#readme)
|
|
797
|
+
- Sibling extensions and recommended pairings: [extensions/README.md](https://github.com/docs-plus/docs.plus/blob/main/extensions/README.md)
|
|
448
798
|
|
|
449
|
-
##
|
|
799
|
+
## Contributing
|
|
450
800
|
|
|
451
|
-
|
|
801
|
+
Bug reports and PRs welcome. Setup, test commands, and the playground harness live in [CONTRIBUTING.md](https://github.com/docs-plus/docs.plus/blob/main/extensions/extension-hyperlink/CONTRIBUTING.md).
|
|
452
802
|
|
|
453
|
-
|
|
454
|
-
> Please note: We're not affiliated with Tiptap, but we believe in recognizing foundational work.
|
|
803
|
+
## License
|
|
455
804
|
|
|
456
|
-
|
|
805
|
+
MIT — see [LICENSE](https://github.com/docs-plus/docs.plus/blob/main/LICENSE).
|