|
| 1 | +--- |
| 2 | +title: Links |
| 3 | +keywords: "link popover, link click, custom popover, react popover, link resolver, linkPopoverResolver" |
| 4 | +--- |
| 5 | + |
| 6 | +Control what happens when a user clicks a link in the editor. By default, SuperDoc shows a built-in popover with the link URL and edit controls. With `popoverResolver`, you can replace it with your own UI in any framework. |
| 7 | + |
| 8 | +## Quick start |
| 9 | + |
| 10 | +No configuration needed for the default behavior — click a link and the built-in popover appears. |
| 11 | + |
| 12 | +To customize, add a `popoverResolver` to the `links` module: |
| 13 | + |
| 14 | +```javascript |
| 15 | +new SuperDoc({ |
| 16 | + selector: '#editor', |
| 17 | + document: file, |
| 18 | + modules: { |
| 19 | + links: { |
| 20 | + popoverResolver: (ctx) => { |
| 21 | + // Navigate anchor links instead of showing a popover |
| 22 | + if (ctx.isAnchorLink) { |
| 23 | + window.location.hash = ctx.href; |
| 24 | + return { type: 'none' }; |
| 25 | + } |
| 26 | + // Everything else gets the default popover |
| 27 | + return { type: 'default' }; |
| 28 | + } |
| 29 | + } |
| 30 | + } |
| 31 | +}); |
| 32 | +``` |
| 33 | + |
| 34 | +## Configuration |
| 35 | + |
| 36 | +<ParamField path="modules.links.popoverResolver" type="(ctx: LinkPopoverContext) => LinkPopoverResolution | null | undefined"> |
| 37 | + Synchronous function called when a user clicks a link. Receives a [context object](#resolver-context) and returns a [resolution](#resolution-types) that determines which popover to show. Return `null` or `undefined` to use the default popover. |
| 38 | +</ParamField> |
| 39 | + |
| 40 | +<Warning> |
| 41 | +The resolver must be synchronous. Do not return a Promise. If the resolver throws, SuperDoc falls back to the default popover and calls `onException`. |
| 42 | +</Warning> |
| 43 | + |
| 44 | +## Resolver context |
| 45 | + |
| 46 | +The resolver receives a `LinkPopoverContext` object with all information about the clicked link: |
| 47 | + |
| 48 | +| Property | Type | Description | |
| 49 | +|---|---|---| |
| 50 | +| `editor` | `Editor` | The editor instance | |
| 51 | +| `href` | `string` | The `href` attribute of the clicked link | |
| 52 | +| `target` | `string \| null` | The `target` attribute | |
| 53 | +| `rel` | `string \| null` | The `rel` attribute | |
| 54 | +| `tooltip` | `string \| null` | The `title` attribute | |
| 55 | +| `element` | `HTMLAnchorElement` | The clicked anchor DOM element | |
| 56 | +| `clientX` | `number` | X coordinate of the click | |
| 57 | +| `clientY` | `number` | Y coordinate of the click | |
| 58 | +| `isAnchorLink` | `boolean` | `true` when href starts with `#` | |
| 59 | +| `documentMode` | `string` | Current mode: `'editing'`, `'viewing'`, or `'suggesting'` | |
| 60 | +| `position` | `{ left: string, top: string }` | Computed popover position relative to the editor surface | |
| 61 | +| `closePopover` | `() => void` | Close the popover programmatically | |
| 62 | + |
| 63 | +## Resolution types |
| 64 | + |
| 65 | +The resolver returns one of four resolution types. Return `null` or `undefined` to use the default popover. |
| 66 | + |
| 67 | +### `default` |
| 68 | + |
| 69 | +Show the built-in link popover with URL display and edit controls. |
| 70 | + |
| 71 | +```javascript |
| 72 | +popoverResolver: (ctx) => { |
| 73 | + return { type: 'default' }; |
| 74 | +} |
| 75 | +``` |
| 76 | + |
| 77 | +### `none` |
| 78 | + |
| 79 | +Suppress the popover entirely. Use this when the resolver handles the click itself (navigation, opening a modal, logging, etc.). |
| 80 | + |
| 81 | +```javascript |
| 82 | +popoverResolver: (ctx) => { |
| 83 | + // Open external links in a new tab, no popover |
| 84 | + if (!ctx.isAnchorLink) { |
| 85 | + window.open(ctx.href, '_blank'); |
| 86 | + return { type: 'none' }; |
| 87 | + } |
| 88 | + return { type: 'default' }; |
| 89 | +} |
| 90 | +``` |
| 91 | + |
| 92 | +### `custom` |
| 93 | + |
| 94 | +Render a Vue component inside the built-in popover shell. `editor` and `closePopover` are automatically injected as props alongside any props you provide. |
| 95 | + |
| 96 | +```javascript |
| 97 | +import MyLinkPopover from './MyLinkPopover.vue'; |
| 98 | + |
| 99 | +popoverResolver: (ctx) => { |
| 100 | + return { |
| 101 | + type: 'custom', |
| 102 | + component: MyLinkPopover, |
| 103 | + props: { href: ctx.href } |
| 104 | + }; |
| 105 | +} |
| 106 | +``` |
| 107 | + |
| 108 | +Your component receives `editor`, `closePopover`, and any additional props: |
| 109 | + |
| 110 | +```vue |
| 111 | +<script setup> |
| 112 | +defineProps({ |
| 113 | + editor: { type: Object, required: true }, // auto-injected |
| 114 | + closePopover: { type: Function, required: true }, // auto-injected |
| 115 | + href: { type: String }, // your custom prop |
| 116 | +}); |
| 117 | +</script> |
| 118 | +``` |
| 119 | + |
| 120 | +### `external` |
| 121 | + |
| 122 | +Mount framework-agnostic UI into a raw DOM container. Use this for React, Svelte, vanilla JS, or any non-Vue framework. |
| 123 | + |
| 124 | +SuperDoc creates a positioned `<div>` element and passes it to your `render` function. You mount your UI into that container. Return a `{ destroy }` callback for cleanup when the popover closes. |
| 125 | + |
| 126 | +```javascript |
| 127 | +popoverResolver: (ctx) => { |
| 128 | + return { |
| 129 | + type: 'external', |
| 130 | + render: ({ container, closePopover, editor, href }) => { |
| 131 | + // Mount your UI into the container |
| 132 | + container.innerHTML = ` |
| 133 | + <a href="${href}" target="_blank">Open link</a> |
| 134 | + <button>Close</button> |
| 135 | + `; |
| 136 | + container.querySelector('button').onclick = closePopover; |
| 137 | + |
| 138 | + return { |
| 139 | + destroy: () => { |
| 140 | + // Clean up event listeners, unmount frameworks, etc. |
| 141 | + } |
| 142 | + }; |
| 143 | + } |
| 144 | + }; |
| 145 | +} |
| 146 | +``` |
| 147 | + |
| 148 | +The `render` function receives an `ExternalPopoverRenderContext`: |
| 149 | + |
| 150 | +| Property | Type | Description | |
| 151 | +|---|---|---| |
| 152 | +| `container` | `HTMLElement` | Empty positioned DOM container to mount your UI into | |
| 153 | +| `closePopover` | `() => void` | Close the popover, call `destroy`, and return focus to the editor | |
| 154 | +| `editor` | `Editor` | The editor instance | |
| 155 | +| `href` | `string` | The href of the clicked link | |
| 156 | + |
| 157 | +<Note> |
| 158 | +The popover automatically closes on click-outside and Escape key — matching the built-in popover behavior. Your `destroy` callback is called in both cases. |
| 159 | +</Note> |
| 160 | + |
| 161 | +## Framework examples |
| 162 | + |
| 163 | +<Tabs> |
| 164 | + <Tab title="React"> |
| 165 | + Use `createRoot` to mount a React component into the external container. Return `destroy` to unmount cleanly. |
| 166 | + |
| 167 | + ```jsx |
| 168 | + import { createRoot } from 'react-dom/client'; |
| 169 | + import { LinkPreview } from './LinkPreview'; |
| 170 | + |
| 171 | + new SuperDoc({ |
| 172 | + selector: '#editor', |
| 173 | + document: file, |
| 174 | + modules: { |
| 175 | + links: { |
| 176 | + popoverResolver: (ctx) => ({ |
| 177 | + type: 'external', |
| 178 | + render: ({ container, closePopover, href }) => { |
| 179 | + const root = createRoot(container); |
| 180 | + root.render( |
| 181 | + <LinkPreview href={href} onClose={closePopover} /> |
| 182 | + ); |
| 183 | + return { destroy: () => root.unmount() }; |
| 184 | + } |
| 185 | + }) |
| 186 | + } |
| 187 | + } |
| 188 | + }); |
| 189 | + ``` |
| 190 | + |
| 191 | + With the React wrapper: |
| 192 | + |
| 193 | + ```jsx |
| 194 | + import { SuperDocEditor } from '@superdoc-dev/react'; |
| 195 | + import { createRoot } from 'react-dom/client'; |
| 196 | + import { LinkPreview } from './LinkPreview'; |
| 197 | + |
| 198 | + function App() { |
| 199 | + return ( |
| 200 | + <SuperDocEditor |
| 201 | + document={file} |
| 202 | + documentMode="editing" |
| 203 | + modules={{ |
| 204 | + links: { |
| 205 | + popoverResolver: (ctx) => ({ |
| 206 | + type: 'external', |
| 207 | + render: ({ container, closePopover, href }) => { |
| 208 | + const root = createRoot(container); |
| 209 | + root.render( |
| 210 | + <LinkPreview href={href} onClose={closePopover} /> |
| 211 | + ); |
| 212 | + return { destroy: () => root.unmount() }; |
| 213 | + } |
| 214 | + }) |
| 215 | + } |
| 216 | + }} |
| 217 | + /> |
| 218 | + ); |
| 219 | + } |
| 220 | + ``` |
| 221 | + </Tab> |
| 222 | + <Tab title="Vue"> |
| 223 | + Vue components can use the simpler `custom` type, which renders inside the built-in popover shell: |
| 224 | + |
| 225 | + ```javascript |
| 226 | + import MyLinkPopover from './MyLinkPopover.vue'; |
| 227 | + |
| 228 | + new SuperDoc({ |
| 229 | + selector: '#editor', |
| 230 | + document: file, |
| 231 | + modules: { |
| 232 | + links: { |
| 233 | + popoverResolver: (ctx) => ({ |
| 234 | + type: 'custom', |
| 235 | + component: MyLinkPopover, |
| 236 | + props: { href: ctx.href } |
| 237 | + }) |
| 238 | + } |
| 239 | + } |
| 240 | + }); |
| 241 | + ``` |
| 242 | + |
| 243 | + The `external` type also works with Vue if you prefer manual control: |
| 244 | + |
| 245 | + ```javascript |
| 246 | + import { createApp } from 'vue'; |
| 247 | + import MyLinkPopover from './MyLinkPopover.vue'; |
| 248 | + |
| 249 | + popoverResolver: (ctx) => ({ |
| 250 | + type: 'external', |
| 251 | + render: ({ container, closePopover, href }) => { |
| 252 | + const app = createApp(MyLinkPopover, { href, closePopover }); |
| 253 | + app.mount(container); |
| 254 | + return { destroy: () => app.unmount() }; |
| 255 | + } |
| 256 | + }) |
| 257 | + ``` |
| 258 | + </Tab> |
| 259 | + <Tab title="Vanilla JS"> |
| 260 | + Build your popover with plain DOM APIs: |
| 261 | + |
| 262 | + ```javascript |
| 263 | + new SuperDoc({ |
| 264 | + selector: '#editor', |
| 265 | + document: file, |
| 266 | + modules: { |
| 267 | + links: { |
| 268 | + popoverResolver: (ctx) => ({ |
| 269 | + type: 'external', |
| 270 | + render: ({ container, closePopover, href }) => { |
| 271 | + const link = document.createElement('a'); |
| 272 | + link.href = href; |
| 273 | + link.target = '_blank'; |
| 274 | + link.textContent = href; |
| 275 | + link.style.padding = '8px 12px'; |
| 276 | + link.style.display = 'block'; |
| 277 | + container.appendChild(link); |
| 278 | + |
| 279 | + // No cleanup needed for simple DOM |
| 280 | + } |
| 281 | + }) |
| 282 | + } |
| 283 | + } |
| 284 | + }); |
| 285 | + ``` |
| 286 | + </Tab> |
| 287 | +</Tabs> |
| 288 | + |
| 289 | +## Styling |
| 290 | + |
| 291 | +External popovers use CSS custom properties with sensible defaults that match the built-in popover. Override them to match your design system. |
| 292 | + |
| 293 | +### Shared popover variables |
| 294 | + |
| 295 | +These apply to both the built-in popover and external link popovers: |
| 296 | + |
| 297 | +| Variable | Default | Description | |
| 298 | +|---|---|---| |
| 299 | +| `--sd-popover-bg` | `white` | Background color | |
| 300 | +| `--sd-popover-z-index` | `1000` | Stack order | |
| 301 | +| `--sd-popover-radius` | `6px` | Border radius | |
| 302 | +| `--sd-popover-shadow` | `0 0 0 1px rgba(0,0,0,0.05), 0px 10px 20px rgba(0,0,0,0.1)` | Box shadow | |
| 303 | +| `--sd-popover-min-width` | `120px` | Minimum width | |
| 304 | +| `--sd-popover-min-height` | `40px` | Minimum height | |
| 305 | + |
| 306 | +### External link popover overrides |
| 307 | + |
| 308 | +Override just the external link popover without affecting other popovers: |
| 309 | + |
| 310 | +| Variable | Fallback | Description | |
| 311 | +|---|---|---| |
| 312 | +| `--sd-external-link-popover-bg` | `--sd-popover-bg` | Background color | |
| 313 | +| `--sd-external-link-popover-z-index` | `--sd-popover-z-index` | Stack order | |
| 314 | +| `--sd-external-link-popover-radius` | `--sd-popover-radius` | Border radius | |
| 315 | +| `--sd-external-link-popover-shadow` | `--sd-popover-shadow` | Box shadow | |
| 316 | +| `--sd-external-link-popover-min-width` | `--sd-popover-min-width` | Minimum width | |
| 317 | +| `--sd-external-link-popover-min-height` | `--sd-popover-min-height` | Minimum height | |
| 318 | + |
| 319 | +Example — dark theme for external link popovers: |
| 320 | + |
| 321 | +```css |
| 322 | +.superdoc-root { |
| 323 | + --sd-external-link-popover-bg: #1a1a2e; |
| 324 | + --sd-external-link-popover-radius: 10px; |
| 325 | + --sd-external-link-popover-shadow: 0 8px 30px rgba(0, 0, 0, 0.25); |
| 326 | +} |
| 327 | +``` |
| 328 | + |
| 329 | +The external popover container also has the class `sd-external-link-popover` for direct CSS targeting: |
| 330 | + |
| 331 | +```css |
| 332 | +.sd-external-link-popover { |
| 333 | + font-family: inherit; |
| 334 | + color: #333; |
| 335 | +} |
| 336 | +``` |
| 337 | + |
| 338 | +## Behavior |
| 339 | + |
| 340 | +- **Toggle off**: Clicking a link while its popover is already open closes the popover. |
| 341 | +- **Click outside**: Clicking anywhere outside the popover closes it. |
| 342 | +- **Escape key**: Pressing Escape closes the popover. |
| 343 | +- **Focus**: When a popover closes, focus returns to the editor. |
| 344 | +- **Error handling**: If the resolver or `render` function throws, SuperDoc falls back to the default popover and calls the `onException` callback. |
| 345 | +- **Cursor**: The editor cursor moves to the clicked link position before the resolver runs. |
| 346 | + |
| 347 | +## Conditional resolution |
| 348 | + |
| 349 | +Use resolver context to show different popovers based on link type, document mode, or any other condition: |
| 350 | + |
| 351 | +```javascript |
| 352 | +popoverResolver: (ctx) => { |
| 353 | + // Anchor links — navigate without a popover |
| 354 | + if (ctx.isAnchorLink) { |
| 355 | + document.getElementById(ctx.href.slice(1))?.scrollIntoView(); |
| 356 | + return { type: 'none' }; |
| 357 | + } |
| 358 | + |
| 359 | + // Viewing mode — open links directly |
| 360 | + if (ctx.documentMode === 'viewing') { |
| 361 | + window.open(ctx.href, '_blank'); |
| 362 | + return { type: 'none' }; |
| 363 | + } |
| 364 | + |
| 365 | + // Internal links — custom component |
| 366 | + if (ctx.href.startsWith('https://internal.app/')) { |
| 367 | + return { |
| 368 | + type: 'custom', |
| 369 | + component: InternalLinkPopover, |
| 370 | + props: { href: ctx.href } |
| 371 | + }; |
| 372 | + } |
| 373 | + |
| 374 | + // Everything else — default popover |
| 375 | + return { type: 'default' }; |
| 376 | +} |
| 377 | +``` |
0 commit comments