Skip to content

Commit 070190f

Browse files
authored
feat(link-popover): custom link popovers (#2222)
1 parent 767e010 commit 070190f

10 files changed

Lines changed: 1434 additions & 71 deletions

File tree

apps/docs/docs.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@
129129
},
130130
"modules/comments",
131131
"modules/toolbar",
132+
"modules/links",
132133
"modules/context-menu",
133134
"modules/pdf",
134135
"modules/whiteboard"

apps/docs/modules/links.mdx

Lines changed: 377 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,377 @@
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

Comments
 (0)