diff --git a/apps/docs/docs.json b/apps/docs/docs.json index 59871f175e..3d53a1f80c 100644 --- a/apps/docs/docs.json +++ b/apps/docs/docs.json @@ -129,6 +129,7 @@ }, "modules/comments", "modules/toolbar", + "modules/links", "modules/context-menu", "modules/pdf", "modules/whiteboard" diff --git a/apps/docs/modules/links.mdx b/apps/docs/modules/links.mdx new file mode 100644 index 0000000000..eaa7ac2769 --- /dev/null +++ b/apps/docs/modules/links.mdx @@ -0,0 +1,377 @@ +--- +title: Links +keywords: "link popover, link click, custom popover, react popover, link resolver, linkPopoverResolver" +--- + +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. + +## Quick start + +No configuration needed for the default behavior — click a link and the built-in popover appears. + +To customize, add a `popoverResolver` to the `links` module: + +```javascript +new SuperDoc({ + selector: '#editor', + document: file, + modules: { + links: { + popoverResolver: (ctx) => { + // Navigate anchor links instead of showing a popover + if (ctx.isAnchorLink) { + window.location.hash = ctx.href; + return { type: 'none' }; + } + // Everything else gets the default popover + return { type: 'default' }; + } + } + } +}); +``` + +## Configuration + + + 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. + + + +The resolver must be synchronous. Do not return a Promise. If the resolver throws, SuperDoc falls back to the default popover and calls `onException`. + + +## Resolver context + +The resolver receives a `LinkPopoverContext` object with all information about the clicked link: + +| Property | Type | Description | +|---|---|---| +| `editor` | `Editor` | The editor instance | +| `href` | `string` | The `href` attribute of the clicked link | +| `target` | `string \| null` | The `target` attribute | +| `rel` | `string \| null` | The `rel` attribute | +| `tooltip` | `string \| null` | The `title` attribute | +| `element` | `HTMLAnchorElement` | The clicked anchor DOM element | +| `clientX` | `number` | X coordinate of the click | +| `clientY` | `number` | Y coordinate of the click | +| `isAnchorLink` | `boolean` | `true` when href starts with `#` | +| `documentMode` | `string` | Current mode: `'editing'`, `'viewing'`, or `'suggesting'` | +| `position` | `{ left: string, top: string }` | Computed popover position relative to the editor surface | +| `closePopover` | `() => void` | Close the popover programmatically | + +## Resolution types + +The resolver returns one of four resolution types. Return `null` or `undefined` to use the default popover. + +### `default` + +Show the built-in link popover with URL display and edit controls. + +```javascript +popoverResolver: (ctx) => { + return { type: 'default' }; +} +``` + +### `none` + +Suppress the popover entirely. Use this when the resolver handles the click itself (navigation, opening a modal, logging, etc.). + +```javascript +popoverResolver: (ctx) => { + // Open external links in a new tab, no popover + if (!ctx.isAnchorLink) { + window.open(ctx.href, '_blank'); + return { type: 'none' }; + } + return { type: 'default' }; +} +``` + +### `custom` + +Render a Vue component inside the built-in popover shell. `editor` and `closePopover` are automatically injected as props alongside any props you provide. + +```javascript +import MyLinkPopover from './MyLinkPopover.vue'; + +popoverResolver: (ctx) => { + return { + type: 'custom', + component: MyLinkPopover, + props: { href: ctx.href } + }; +} +``` + +Your component receives `editor`, `closePopover`, and any additional props: + +```vue + +``` + +### `external` + +Mount framework-agnostic UI into a raw DOM container. Use this for React, Svelte, vanilla JS, or any non-Vue framework. + +SuperDoc creates a positioned `
` 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. + +```javascript +popoverResolver: (ctx) => { + return { + type: 'external', + render: ({ container, closePopover, editor, href }) => { + // Mount your UI into the container + container.innerHTML = ` + Open link + + `; + container.querySelector('button').onclick = closePopover; + + return { + destroy: () => { + // Clean up event listeners, unmount frameworks, etc. + } + }; + } + }; +} +``` + +The `render` function receives an `ExternalPopoverRenderContext`: + +| Property | Type | Description | +|---|---|---| +| `container` | `HTMLElement` | Empty positioned DOM container to mount your UI into | +| `closePopover` | `() => void` | Close the popover, call `destroy`, and return focus to the editor | +| `editor` | `Editor` | The editor instance | +| `href` | `string` | The href of the clicked link | + + +The popover automatically closes on click-outside and Escape key — matching the built-in popover behavior. Your `destroy` callback is called in both cases. + + +## Framework examples + + + + Use `createRoot` to mount a React component into the external container. Return `destroy` to unmount cleanly. + + ```jsx + import { createRoot } from 'react-dom/client'; + import { LinkPreview } from './LinkPreview'; + + new SuperDoc({ + selector: '#editor', + document: file, + modules: { + links: { + popoverResolver: (ctx) => ({ + type: 'external', + render: ({ container, closePopover, href }) => { + const root = createRoot(container); + root.render( + + ); + return { destroy: () => root.unmount() }; + } + }) + } + } + }); + ``` + + With the React wrapper: + + ```jsx + import { SuperDocEditor } from '@superdoc-dev/react'; + import { createRoot } from 'react-dom/client'; + import { LinkPreview } from './LinkPreview'; + + function App() { + return ( + ({ + type: 'external', + render: ({ container, closePopover, href }) => { + const root = createRoot(container); + root.render( + + ); + return { destroy: () => root.unmount() }; + } + }) + } + }} + /> + ); + } + ``` + + + Vue components can use the simpler `custom` type, which renders inside the built-in popover shell: + + ```javascript + import MyLinkPopover from './MyLinkPopover.vue'; + + new SuperDoc({ + selector: '#editor', + document: file, + modules: { + links: { + popoverResolver: (ctx) => ({ + type: 'custom', + component: MyLinkPopover, + props: { href: ctx.href } + }) + } + } + }); + ``` + + The `external` type also works with Vue if you prefer manual control: + + ```javascript + import { createApp } from 'vue'; + import MyLinkPopover from './MyLinkPopover.vue'; + + popoverResolver: (ctx) => ({ + type: 'external', + render: ({ container, closePopover, href }) => { + const app = createApp(MyLinkPopover, { href, closePopover }); + app.mount(container); + return { destroy: () => app.unmount() }; + } + }) + ``` + + + Build your popover with plain DOM APIs: + + ```javascript + new SuperDoc({ + selector: '#editor', + document: file, + modules: { + links: { + popoverResolver: (ctx) => ({ + type: 'external', + render: ({ container, closePopover, href }) => { + const link = document.createElement('a'); + link.href = href; + link.target = '_blank'; + link.textContent = href; + link.style.padding = '8px 12px'; + link.style.display = 'block'; + container.appendChild(link); + + // No cleanup needed for simple DOM + } + }) + } + } + }); + ``` + + + +## Styling + +External popovers use CSS custom properties with sensible defaults that match the built-in popover. Override them to match your design system. + +### Shared popover variables + +These apply to both the built-in popover and external link popovers: + +| Variable | Default | Description | +|---|---|---| +| `--sd-popover-bg` | `white` | Background color | +| `--sd-popover-z-index` | `1000` | Stack order | +| `--sd-popover-radius` | `6px` | Border radius | +| `--sd-popover-shadow` | `0 0 0 1px rgba(0,0,0,0.05), 0px 10px 20px rgba(0,0,0,0.1)` | Box shadow | +| `--sd-popover-min-width` | `120px` | Minimum width | +| `--sd-popover-min-height` | `40px` | Minimum height | + +### External link popover overrides + +Override just the external link popover without affecting other popovers: + +| Variable | Fallback | Description | +|---|---|---| +| `--sd-external-link-popover-bg` | `--sd-popover-bg` | Background color | +| `--sd-external-link-popover-z-index` | `--sd-popover-z-index` | Stack order | +| `--sd-external-link-popover-radius` | `--sd-popover-radius` | Border radius | +| `--sd-external-link-popover-shadow` | `--sd-popover-shadow` | Box shadow | +| `--sd-external-link-popover-min-width` | `--sd-popover-min-width` | Minimum width | +| `--sd-external-link-popover-min-height` | `--sd-popover-min-height` | Minimum height | + +Example — dark theme for external link popovers: + +```css +.superdoc-root { + --sd-external-link-popover-bg: #1a1a2e; + --sd-external-link-popover-radius: 10px; + --sd-external-link-popover-shadow: 0 8px 30px rgba(0, 0, 0, 0.25); +} +``` + +The external popover container also has the class `sd-external-link-popover` for direct CSS targeting: + +```css +.sd-external-link-popover { + font-family: inherit; + color: #333; +} +``` + +## Behavior + +- **Toggle off**: Clicking a link while its popover is already open closes the popover. +- **Click outside**: Clicking anywhere outside the popover closes it. +- **Escape key**: Pressing Escape closes the popover. +- **Focus**: When a popover closes, focus returns to the editor. +- **Error handling**: If the resolver or `render` function throws, SuperDoc falls back to the default popover and calls the `onException` callback. +- **Cursor**: The editor cursor moves to the clicked link position before the resolver runs. + +## Conditional resolution + +Use resolver context to show different popovers based on link type, document mode, or any other condition: + +```javascript +popoverResolver: (ctx) => { + // Anchor links — navigate without a popover + if (ctx.isAnchorLink) { + document.getElementById(ctx.href.slice(1))?.scrollIntoView(); + return { type: 'none' }; + } + + // Viewing mode — open links directly + if (ctx.documentMode === 'viewing') { + window.open(ctx.href, '_blank'); + return { type: 'none' }; + } + + // Internal links — custom component + if (ctx.href.startsWith('https://internal.app/')) { + return { + type: 'custom', + component: InternalLinkPopover, + props: { href: ctx.href } + }; + } + + // Everything else — default popover + return { type: 'default' }; +} +``` diff --git a/apps/docs/modules/overview.mdx b/apps/docs/modules/overview.mdx index edfba443a7..c0d6e37df5 100644 --- a/apps/docs/modules/overview.mdx +++ b/apps/docs/modules/overview.mdx @@ -14,7 +14,8 @@ const superdoc = new SuperDoc({ toolbar: { selector: '#toolbar' }, comments: { allowResolve: true }, collaboration: { ydoc, provider }, - contextMenu: { includeDefaultItems: true } + contextMenu: { includeDefaultItems: true }, + links: { popoverResolver: (ctx) => ({ type: 'default' }) } } }); ``` @@ -31,6 +32,9 @@ const superdoc = new SuperDoc({ Customizable formatting controls + + Customize the link click popover or bring your own UI + Right-click actions and custom commands diff --git a/packages/super-editor/src/components/SuperEditor.vue b/packages/super-editor/src/components/SuperEditor.vue index 5f4f3d040b..d577756e42 100644 --- a/packages/super-editor/src/components/SuperEditor.vue +++ b/packages/super-editor/src/components/SuperEditor.vue @@ -1104,6 +1104,7 @@ onBeforeUnmount(() => { :openPopover="openPopover" :closePopover="closePopover" :popoverVisible="popoverControls.visible" + :linkPopoverResolver="props.options.linkPopoverResolver" /> { }, view: { dom: document.createElement('div'), + focus: vi.fn(), }, dispatch: vi.fn(), + options: { + documentMode: 'editing', + onException: vi.fn(), + }, }; // Create mock functions @@ -372,6 +377,7 @@ describe('LinkClickHandler', () => { const editorWithoutState = { view: { dom: document.createElement('div'), + focus: vi.fn(), }, }; @@ -607,4 +613,640 @@ describe('LinkClickHandler', () => { // Should only dispatch once (second event was debounced) expect(mockEditor.dispatch).toHaveBeenCalledTimes(1); }); + + // ========================================================================= + // linkPopoverResolver tests + // ========================================================================= + + describe('linkPopoverResolver', () => { + /** + * Helper to dispatch a link click event and wait for the async handler. + */ + const dispatchLinkClick = async (surface, detail = {}) => { + const linkElement = detail.element || document.createElement('a'); + if (!linkElement.dataset.pmStart) { + linkElement.dataset.pmStart = '10'; + } + + const event = new CustomEvent('superdoc-link-click', { + bubbles: true, + composed: true, + detail: { + href: 'https://example.com', + target: '_blank', + rel: 'noopener', + tooltip: 'Example', + element: linkElement, + clientX: 250, + clientY: 250, + ...detail, + }, + }); + + surface.dispatchEvent(event); + await new Promise((resolve) => setTimeout(resolve, 20)); + }; + + beforeEach(() => { + selectionHasNodeOrMark.mockReturnValue(true); + TextSelection.create.mockReturnValue({ from: 10, to: 10 }); + }); + + it('should open default LinkInput when resolver is absent', async () => { + mount(LinkClickHandler, { + props: { + editor: mockEditor, + openPopover: mockOpenPopover, + closePopover: mockClosePopover, + }, + }); + + await dispatchLinkClick(mockSurfaceElement); + + expect(mockOpenPopover).toHaveBeenCalledWith( + expect.anything(), // LinkInput (markRaw) + { + showInput: true, + editor: mockEditor, + closePopover: mockClosePopover, + }, + expect.objectContaining({ left: '150px', top: '165px' }), + ); + }); + + it('should open default LinkInput when resolver returns null', async () => { + const resolver = vi.fn().mockReturnValue(null); + + mount(LinkClickHandler, { + props: { + editor: mockEditor, + openPopover: mockOpenPopover, + closePopover: mockClosePopover, + linkPopoverResolver: resolver, + }, + }); + + await dispatchLinkClick(mockSurfaceElement); + + expect(resolver).toHaveBeenCalled(); + expect(mockOpenPopover).toHaveBeenCalledWith( + expect.anything(), + { showInput: true, editor: mockEditor, closePopover: mockClosePopover }, + expect.any(Object), + ); + }); + + it('should open default LinkInput when resolver returns undefined', async () => { + const resolver = vi.fn().mockReturnValue(undefined); + + mount(LinkClickHandler, { + props: { + editor: mockEditor, + openPopover: mockOpenPopover, + closePopover: mockClosePopover, + linkPopoverResolver: resolver, + }, + }); + + await dispatchLinkClick(mockSurfaceElement); + + expect(resolver).toHaveBeenCalled(); + expect(mockOpenPopover).toHaveBeenCalledWith( + expect.anything(), + { showInput: true, editor: mockEditor, closePopover: mockClosePopover }, + expect.any(Object), + ); + }); + + it('should open default LinkInput when resolver returns { type: "default" }', async () => { + const resolver = vi.fn().mockReturnValue({ type: 'default' }); + + mount(LinkClickHandler, { + props: { + editor: mockEditor, + openPopover: mockOpenPopover, + closePopover: mockClosePopover, + linkPopoverResolver: resolver, + }, + }); + + await dispatchLinkClick(mockSurfaceElement); + + expect(resolver).toHaveBeenCalled(); + expect(mockOpenPopover).toHaveBeenCalledWith( + expect.anything(), + { showInput: true, editor: mockEditor, closePopover: mockClosePopover }, + expect.any(Object), + ); + }); + + it('should suppress popover when resolver returns { type: "none" }', async () => { + const resolver = vi.fn().mockReturnValue({ type: 'none' }); + + mount(LinkClickHandler, { + props: { + editor: mockEditor, + openPopover: mockOpenPopover, + closePopover: mockClosePopover, + linkPopoverResolver: resolver, + }, + }); + + await dispatchLinkClick(mockSurfaceElement); + + expect(resolver).toHaveBeenCalled(); + expect(mockOpenPopover).not.toHaveBeenCalled(); + }); + + it('should open custom component when resolver returns { type: "custom" }', async () => { + const MockComponent = { template: '
Custom
' }; + const resolver = vi.fn().mockReturnValue({ + type: 'custom', + component: MockComponent, + props: { foo: 'bar' }, + }); + + mount(LinkClickHandler, { + props: { + editor: mockEditor, + openPopover: mockOpenPopover, + closePopover: mockClosePopover, + linkPopoverResolver: resolver, + }, + }); + + await dispatchLinkClick(mockSurfaceElement); + + expect(resolver).toHaveBeenCalled(); + expect(mockOpenPopover).toHaveBeenCalledWith( + expect.anything(), // MockComponent (markRaw) + expect.objectContaining({ + editor: mockEditor, + closePopover: mockClosePopover, + foo: 'bar', + }), + expect.objectContaining({ left: '150px', top: '165px' }), + ); + }); + + it('should fallback to default when resolver returns { type: "custom" } without component', async () => { + const resolver = vi.fn().mockReturnValue({ + type: 'custom', + component: null, + props: { foo: 'bar' }, + }); + + mount(LinkClickHandler, { + props: { + editor: mockEditor, + openPopover: mockOpenPopover, + closePopover: mockClosePopover, + linkPopoverResolver: resolver, + }, + }); + + await dispatchLinkClick(mockSurfaceElement); + + expect(resolver).toHaveBeenCalled(); + // Should fallback to default LinkInput + expect(mockOpenPopover).toHaveBeenCalledWith( + expect.anything(), + { showInput: true, editor: mockEditor, closePopover: mockClosePopover }, + expect.any(Object), + ); + }); + + it('should call onException and fallback to default when resolver throws', async () => { + const error = new Error('Resolver exploded'); + const resolver = vi.fn().mockImplementation(() => { + throw error; + }); + + mount(LinkClickHandler, { + props: { + editor: mockEditor, + openPopover: mockOpenPopover, + closePopover: mockClosePopover, + linkPopoverResolver: resolver, + }, + }); + + await dispatchLinkClick(mockSurfaceElement); + + // onException should have been called with the error + expect(mockEditor.options.onException).toHaveBeenCalledWith({ + error, + editor: mockEditor, + }); + + // Should fallback to default popover + expect(mockOpenPopover).toHaveBeenCalledWith( + expect.anything(), + { showInput: true, editor: mockEditor, closePopover: mockClosePopover }, + expect.any(Object), + ); + }); + + it('should pass correct context to resolver', async () => { + const resolver = vi.fn().mockReturnValue({ type: 'default' }); + + mount(LinkClickHandler, { + props: { + editor: mockEditor, + openPopover: mockOpenPopover, + closePopover: mockClosePopover, + linkPopoverResolver: resolver, + }, + }); + + const linkElement = document.createElement('a'); + linkElement.dataset.pmStart = '10'; + + await dispatchLinkClick(mockSurfaceElement, { + href: 'https://example.com', + target: '_blank', + rel: 'noopener', + tooltip: 'Example', + element: linkElement, + clientX: 250, + clientY: 250, + }); + + expect(resolver).toHaveBeenCalledWith( + expect.objectContaining({ + editor: mockEditor, + href: 'https://example.com', + target: '_blank', + rel: 'noopener', + tooltip: 'Example', + element: linkElement, + clientX: 250, + clientY: 250, + isAnchorLink: false, + documentMode: 'editing', + position: { left: '150px', top: '165px' }, + closePopover: mockClosePopover, + }), + ); + }); + + it('should correctly detect anchor links in context', async () => { + const resolver = vi.fn().mockReturnValue({ type: 'default' }); + + mount(LinkClickHandler, { + props: { + editor: mockEditor, + openPopover: mockOpenPopover, + closePopover: mockClosePopover, + linkPopoverResolver: resolver, + }, + }); + + await dispatchLinkClick(mockSurfaceElement, { href: '#section-1' }); + + expect(resolver).toHaveBeenCalledWith(expect.objectContaining({ isAnchorLink: true })); + }); + + it('should allow conditional resolution based on href', async () => { + const MockCustom = { template: '
Custom
' }; + const resolver = vi.fn().mockImplementation(({ href }) => { + if (href.includes('customer://')) { + return { type: 'custom', component: MockCustom, props: { href } }; + } + return { type: 'default' }; + }); + + mount(LinkClickHandler, { + props: { + editor: mockEditor, + openPopover: mockOpenPopover, + closePopover: mockClosePopover, + linkPopoverResolver: resolver, + }, + }); + + // First: regular link → default popover + await dispatchLinkClick(mockSurfaceElement, { href: 'https://example.com' }); + expect(mockOpenPopover).toHaveBeenCalledWith( + expect.anything(), + { showInput: true, editor: mockEditor, closePopover: mockClosePopover }, + expect.any(Object), + ); + + // Wait for debounce + await new Promise((resolve) => setTimeout(resolve, 350)); + mockOpenPopover.mockClear(); + + // Second: custom link → custom popover + await dispatchLinkClick(mockSurfaceElement, { href: 'customer://abc-123' }); + expect(mockOpenPopover).toHaveBeenCalledWith( + expect.anything(), // MockCustom (markRaw) + expect.objectContaining({ + editor: mockEditor, + closePopover: mockClosePopover, + href: 'customer://abc-123', + }), + expect.any(Object), + ); + }); + + it('should close popover without invoking resolver when popoverVisible is true', async () => { + const resolver = vi.fn().mockReturnValue({ type: 'none' }); + + mount(LinkClickHandler, { + props: { + editor: mockEditor, + openPopover: mockOpenPopover, + closePopover: mockClosePopover, + popoverVisible: true, + linkPopoverResolver: resolver, + }, + }); + + await dispatchLinkClick(mockSurfaceElement); + + // Popover should be closed + expect(mockClosePopover).toHaveBeenCalled(); + + // Resolver should NOT have been invoked (early return before resolver) + expect(resolver).not.toHaveBeenCalled(); + + // openPopover should NOT have been called + expect(mockOpenPopover).not.toHaveBeenCalled(); + + // editor.dispatch should NOT have been called (early return before cursor movement) + expect(mockEditor.dispatch).not.toHaveBeenCalled(); + }); + + // ─── External type (framework-agnostic) ─────────────────────────────── + + describe('external type', () => { + let editorWrapper; + + beforeEach(() => { + // External popovers need a parent container to mount into. + // Mirrors the real DOM: .super-editor > surface + editorWrapper = document.createElement('div'); + editorWrapper.classList.add('super-editor'); + editorWrapper.appendChild(mockSurfaceElement); + document.body.appendChild(editorWrapper); + }); + + afterEach(() => { + editorWrapper.remove(); + }); + + it('should call render with container, closePopover, editor, and href', async () => { + const renderFn = vi.fn(); + const resolver = vi.fn().mockReturnValue({ type: 'external', render: renderFn }); + + mount(LinkClickHandler, { + props: { + editor: mockEditor, + openPopover: mockOpenPopover, + closePopover: mockClosePopover, + linkPopoverResolver: resolver, + }, + }); + + await dispatchLinkClick(mockSurfaceElement, { href: 'https://example.com' }); + + expect(renderFn).toHaveBeenCalledTimes(1); + const ctx = renderFn.mock.calls[0][0]; + expect(ctx.container).toBeInstanceOf(HTMLElement); + expect(typeof ctx.closePopover).toBe('function'); + expect(ctx.editor.state).toStrictEqual(mockEditor.state); + expect(ctx.href).toBe('https://example.com'); + }); + + it('should append a positioned container to the editor wrapper', async () => { + const renderFn = vi.fn(); + const resolver = vi.fn().mockReturnValue({ type: 'external', render: renderFn }); + + mount(LinkClickHandler, { + props: { + editor: mockEditor, + openPopover: mockOpenPopover, + closePopover: mockClosePopover, + linkPopoverResolver: resolver, + }, + }); + + await dispatchLinkClick(mockSurfaceElement); + + const container = renderFn.mock.calls[0][0].container; + expect(container.parentElement).toBe(editorWrapper); + expect(container.classList.contains('sd-external-link-popover')).toBe(true); + expect(container.style.position).toBe('absolute'); + expect(container.style.left).toBe('150px'); + expect(container.style.top).toBe('165px'); + }); + + it('should call destroy and remove container when closePopover is called', async () => { + const destroyFn = vi.fn(); + const renderFn = vi.fn().mockReturnValue({ destroy: destroyFn }); + const resolver = vi.fn().mockReturnValue({ type: 'external', render: renderFn }); + + mount(LinkClickHandler, { + props: { + editor: mockEditor, + openPopover: mockOpenPopover, + closePopover: mockClosePopover, + linkPopoverResolver: resolver, + }, + }); + + await dispatchLinkClick(mockSurfaceElement); + + const ctx = renderFn.mock.calls[0][0]; + const container = ctx.container; + expect(container.parentElement).toBe(editorWrapper); + + // Close the external popover + ctx.closePopover(); + + expect(destroyFn).toHaveBeenCalledTimes(1); + expect(container.parentElement).toBeNull(); + }); + + it('should clean up container even when render returns void (no destroy)', async () => { + const renderFn = vi.fn(); // returns undefined + const resolver = vi.fn().mockReturnValue({ type: 'external', render: renderFn }); + + mount(LinkClickHandler, { + props: { + editor: mockEditor, + openPopover: mockOpenPopover, + closePopover: mockClosePopover, + linkPopoverResolver: resolver, + }, + }); + + await dispatchLinkClick(mockSurfaceElement); + + const container = renderFn.mock.calls[0][0].container; + expect(container.parentElement).toBe(editorWrapper); + + renderFn.mock.calls[0][0].closePopover(); + expect(container.parentElement).toBeNull(); + }); + + it('should close on Escape key', async () => { + const destroyFn = vi.fn(); + const renderFn = vi.fn().mockReturnValue({ destroy: destroyFn }); + const resolver = vi.fn().mockReturnValue({ type: 'external', render: renderFn }); + + mount(LinkClickHandler, { + props: { + editor: mockEditor, + openPopover: mockOpenPopover, + closePopover: mockClosePopover, + linkPopoverResolver: resolver, + }, + }); + + await dispatchLinkClick(mockSurfaceElement); + + const container = renderFn.mock.calls[0][0].container; + expect(container.parentElement).toBe(editorWrapper); + + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + + expect(destroyFn).toHaveBeenCalledTimes(1); + expect(container.parentElement).toBeNull(); + }); + + it('should close on click outside', async () => { + const destroyFn = vi.fn(); + const renderFn = vi.fn().mockReturnValue({ destroy: destroyFn }); + const resolver = vi.fn().mockReturnValue({ type: 'external', render: renderFn }); + + mount(LinkClickHandler, { + props: { + editor: mockEditor, + openPopover: mockOpenPopover, + closePopover: mockClosePopover, + linkPopoverResolver: resolver, + }, + }); + + await dispatchLinkClick(mockSurfaceElement); + + const container = renderFn.mock.calls[0][0].container; + expect(container.parentElement).toBe(editorWrapper); + + // Click outside the container + document.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })); + + expect(destroyFn).toHaveBeenCalledTimes(1); + expect(container.parentElement).toBeNull(); + }); + + it('should toggle off external popover when clicking another link', async () => { + const destroyFn = vi.fn(); + const renderFn = vi.fn().mockReturnValue({ destroy: destroyFn }); + const resolver = vi.fn().mockReturnValue({ type: 'external', render: renderFn }); + + mount(LinkClickHandler, { + props: { + editor: mockEditor, + openPopover: mockOpenPopover, + closePopover: mockClosePopover, + linkPopoverResolver: resolver, + }, + }); + + // First click opens external popover + await dispatchLinkClick(mockSurfaceElement); + expect(renderFn).toHaveBeenCalledTimes(1); + + // Wait for debounce + await new Promise((resolve) => setTimeout(resolve, 350)); + + // Second click should close the external popover (toggle-off) + await dispatchLinkClick(mockSurfaceElement); + + expect(destroyFn).toHaveBeenCalledTimes(1); + // Resolver is NOT called again — early return from toggle-off guard + expect(resolver).toHaveBeenCalledTimes(1); + }); + + it('should fallback to default when render is not a function', async () => { + const resolver = vi.fn().mockReturnValue({ type: 'external', render: 'not-a-function' }); + + mount(LinkClickHandler, { + props: { + editor: mockEditor, + openPopover: mockOpenPopover, + closePopover: mockClosePopover, + linkPopoverResolver: resolver, + }, + }); + + await dispatchLinkClick(mockSurfaceElement); + + expect(mockOpenPopover).toHaveBeenCalledWith( + expect.anything(), + { showInput: true, editor: mockEditor, closePopover: mockClosePopover }, + expect.any(Object), + ); + }); + + it('should call onException and fallback to default when render throws', async () => { + const error = new Error('Render exploded'); + const renderFn = vi.fn().mockImplementation(() => { + throw error; + }); + const resolver = vi.fn().mockReturnValue({ type: 'external', render: renderFn }); + + mount(LinkClickHandler, { + props: { + editor: mockEditor, + openPopover: mockOpenPopover, + closePopover: mockClosePopover, + linkPopoverResolver: resolver, + }, + }); + + await dispatchLinkClick(mockSurfaceElement); + + expect(mockEditor.options.onException).toHaveBeenCalledWith({ + error, + editor: mockEditor, + }); + + // Container should have been removed (not left in DOM) + const orphanedContainers = editorWrapper.querySelectorAll('[style*="position: absolute"]'); + expect(orphanedContainers.length).toBe(0); + + // Should fallback to default popover + expect(mockOpenPopover).toHaveBeenCalledWith( + expect.anything(), + { showInput: true, editor: mockEditor, closePopover: mockClosePopover }, + expect.any(Object), + ); + }); + + it('should not bypass GenericPopover — openPopover is not called for external', async () => { + const renderFn = vi.fn(); + const resolver = vi.fn().mockReturnValue({ type: 'external', render: renderFn }); + + mount(LinkClickHandler, { + props: { + editor: mockEditor, + openPopover: mockOpenPopover, + closePopover: mockClosePopover, + linkPopoverResolver: resolver, + }, + }); + + await dispatchLinkClick(mockSurfaceElement); + + // openPopover (which routes through GenericPopover) should NOT be called + expect(mockOpenPopover).not.toHaveBeenCalled(); + // The render function should have been called instead + expect(renderFn).toHaveBeenCalledTimes(1); + }); + }); + }); }); diff --git a/packages/super-editor/src/components/link-click/LinkClickHandler.vue b/packages/super-editor/src/components/link-click/LinkClickHandler.vue index b53a2254b2..a02d217ac5 100644 --- a/packages/super-editor/src/components/link-click/LinkClickHandler.vue +++ b/packages/super-editor/src/components/link-click/LinkClickHandler.vue @@ -22,34 +22,287 @@ const props = defineProps({ type: Boolean, default: false, }, + linkPopoverResolver: { + type: Function, + default: undefined, + }, }); -/** - * Debounce tracking to prevent double-handling of link clicks. - * The pointerdown handler in PresentationEditor dispatches superdoc-link-click, - * and then the click handler in the renderer also dispatches it. - * We only want to handle the first one. - */ +// ─── Constants ────────────────────────────────────────────────────────────── + let lastLinkClickTime = 0; + +/** Prevents double-handling when both pointerdown and click dispatch the event */ const LINK_CLICK_DEBOUNCE_MS = 300; +/** Delay for editor state to settle after cursor movement */ +const CURSOR_UPDATE_TIMEOUT_MS = 10; + +/** Offset below the click point where the popover appears */ +const POPOVER_VERTICAL_OFFSET_PX = 15; + +/** Matches GenericPopover's visual treatment so external popovers look native */ +const EXTERNAL_POPOVER_STYLES = { + position: 'absolute', + zIndex: 'var(--sd-external-link-popover-z-index, var(--sd-popover-z-index, 1000))', + borderRadius: 'var(--sd-external-link-popover-radius, var(--sd-popover-radius, 6px))', + boxShadow: + 'var(--sd-external-link-popover-shadow, var(--sd-popover-shadow, 0 0 0 1px rgba(0, 0, 0, 0.05), 0px 10px 20px rgba(0, 0, 0, 0.1)))', + minWidth: 'var(--sd-external-link-popover-min-width, var(--sd-popover-min-width, 120px))', + minHeight: 'var(--sd-external-link-popover-min-height, var(--sd-popover-min-height, 40px))', + backgroundColor: 'var(--sd-external-link-popover-bg, var(--sd-popover-bg, white))', +}; + +// ─── External popover lifecycle ───────────────────────────────────────────── + /** - * Timeout delay for cursor update before checking link mark presence. - * Allows the editor state to update after cursor movement. + * Tracks the currently active external (framework-agnostic) popover. + * Null when no external popover is open. + * + * @type {{ container: HTMLElement, destroyFn: Function|null, onPointerDown: Function, onKeyDown: Function } | null} */ -const CURSOR_UPDATE_TIMEOUT_MS = 10; +let activeExternalPopover = null; + +/** + * Tear down the active external popover: call the customer's destroy(), + * remove the container from the DOM, detach global listeners, and + * return focus to the editor. + */ +const cleanupExternalPopover = () => { + if (!activeExternalPopover) return; + + const { container, destroyFn, onPointerDown, onKeyDown } = activeExternalPopover; + activeExternalPopover = null; + + try { + destroyFn?.(); + } catch { + // Swallow cleanup errors — the customer's destroy() should not break the editor + } + + container.remove(); + document.removeEventListener('pointerdown', onPointerDown); + document.removeEventListener('keydown', onKeyDown); + props.editor?.view?.focus(); +}; + +// ─── Position computation ─────────────────────────────────────────────────── + +/** + * Compute popover coordinates relative to the editor surface. + * + * @param {{ clientX: number, clientY: number }} detail - Click coordinates + * @param {HTMLElement} surface - Editor surface element + * @returns {{ left: string, top: string } | null} + */ +const computePopoverPosition = (detail, surface) => { + const rect = surface.getBoundingClientRect(); + if (!rect) return null; + + return { + left: `${detail.clientX - rect.left}px`, + top: `${detail.clientY - rect.top + POPOVER_VERTICAL_OFFSET_PX}px`, + }; +}; + +// ─── Popover openers ──────────────────────────────────────────────────────── + +/** + * Open the built-in LinkInput popover (default path). + * + * @param {{ left: string, top: string }} position + */ +const openDefaultPopover = (position) => { + props.openPopover( + markRaw(LinkInput), + { + showInput: true, + editor: props.editor, + closePopover: props.closePopover, + }, + position, + ); +}; + +/** + * Open a customer-supplied Vue component inside GenericPopover. + * + * @param {{ component: unknown, props?: Record }} resolution + * @param {{ left: string, top: string }} position + */ +const openCustomPopover = (resolution, position) => { + props.openPopover( + markRaw(resolution.component), + { + editor: props.editor, + closePopover: props.closePopover, + ...(resolution.props || {}), + }, + position, + ); +}; + +/** + * Mount a framework-agnostic popover by creating a positioned DOM container + * and handing it to the customer's render() function. + * + * Lifecycle mirrors GenericPopover: click-outside (pointerdown) and Escape close + * the popover. The customer's optional destroy() callback is invoked on close. + * + * @param {{ render: Function }} resolution + * @param {{ left: string, top: string }} position + * @param {Object} detail - Original event detail (href, etc.) + * @param {HTMLElement} surface - Editor surface element + */ +const openExternalPopover = (resolution, position, detail, surface) => { + cleanupExternalPopover(); + + // Create container with the same visual treatment as GenericPopover + const container = document.createElement('div'); + container.classList.add('sd-external-link-popover'); + Object.assign(container.style, EXTERNAL_POPOVER_STYLES, { + left: position.left, + top: position.top, + }); + + // Stop events inside the popover from triggering click-outside + container.addEventListener('pointerdown', (e) => e.stopPropagation()); + container.addEventListener('click', (e) => e.stopPropagation()); + + // Mount into the same coordinate-space parent that GenericPopover uses + const mountTarget = surface.closest('.super-editor') ?? surface.parentElement; + if (!mountTarget) return; + mountTarget.appendChild(container); + + // Hand the container to the customer + let renderResult; + try { + renderResult = resolution.render({ + container, + closePopover: cleanupExternalPopover, + editor: props.editor, + href: detail.href ?? '', + }); + } catch (error) { + container.remove(); + props.editor.options?.onException?.({ error, editor: props.editor }); + openDefaultPopover(position); + return; + } + + // Click-outside and Escape handlers (same pattern as GenericPopover) + const onPointerDown = (event) => { + if (!container.contains(event.target)) cleanupExternalPopover(); + }; + const onKeyDown = (event) => { + if (event.key === 'Escape') cleanupExternalPopover(); + }; + + document.addEventListener('pointerdown', onPointerDown); + document.addEventListener('keydown', onKeyDown); + + activeExternalPopover = { + container, + destroyFn: typeof renderResult?.destroy === 'function' ? renderResult.destroy : null, + onPointerDown, + onKeyDown, + }; +}; + +// ─── Resolver dispatch ────────────────────────────────────────────────────── + +/** + * Determine which popover to open based on the linkPopoverResolver config. + * Falls back to the default LinkInput popover for: + * - No resolver configured + * - null / undefined / { type: 'default' } + * - Resolver throws (also calls onException) + * - { type: 'custom' } with missing component + * - { type: 'external' } with missing render function + * - Unknown resolution type + * + * @param {Object} detail - Event detail from superdoc-link-click + * @param {HTMLElement} surface - Editor surface element + */ +const resolveAndOpenPopover = (detail, surface) => { + const position = computePopoverPosition(detail, surface); + if (!position) return; + + // No resolver → open default + if (typeof props.linkPopoverResolver !== 'function') { + openDefaultPopover(position); + return; + } + + // Build resolver context + const href = detail.href ?? ''; + + /** @type {import('../../core/types/EditorConfig.js').LinkPopoverContext} */ + const ctx = { + editor: props.editor, + href, + target: detail.target ?? null, + rel: detail.rel ?? null, + tooltip: detail.tooltip ?? null, + element: detail.element, + clientX: detail.clientX, + clientY: detail.clientY, + isAnchorLink: href.startsWith('#') && href.length > 1, + documentMode: props.editor.options?.documentMode ?? 'editing', + position, + closePopover: props.closePopover, + }; + + // Call resolver with error boundary + let resolution; + try { + resolution = props.linkPopoverResolver(ctx); + } catch (error) { + props.editor.options?.onException?.({ error, editor: props.editor }); + openDefaultPopover(position); + return; + } + + // Dispatch on resolution type + if (!resolution || resolution.type === 'default') { + openDefaultPopover(position); + return; + } + + if (resolution.type === 'none') { + return; + } + + if (resolution.type === 'custom') { + if (!resolution.component) { + openDefaultPopover(position); + return; + } + openCustomPopover(resolution, position); + return; + } + + if (resolution.type === 'external') { + if (typeof resolution.render !== 'function') { + openDefaultPopover(position); + return; + } + openExternalPopover(resolution, position, detail, surface); + return; + } + + // Unknown resolution type + openDefaultPopover(position); +}; + +// ─── Link click handler ───────────────────────────────────────────────────── /** * Handle link click events from layout-engine rendered links. - * This handler listens for the custom 'superdoc-link-click' event - * dispatched by link elements in the DOM painter. + * Listens for the custom 'superdoc-link-click' event dispatched by + * link elements in the DOM painter. * * @param {CustomEvent} event - Custom event with link metadata in event.detail - * @param {Object} event.detail - Event detail containing link information - * @param {HTMLElement} [event.detail.element] - The link element that was clicked - * @param {string} [event.detail.href] - The href attribute of the link - * @param {number} event.detail.clientX - X coordinate of the click - * @param {number} event.detail.clientY - Y coordinate of the click */ const handleLinkClick = (event) => { const detail = event?.detail ?? {}; @@ -62,10 +315,11 @@ const handleLinkClick = (event) => { } lastLinkClickTime = now; - // If popover is already visible, close it and don't reopen - // This allows clicking a link to toggle the popover off - if (props.popoverVisible) { - props.closePopover(); + // If any popover is already visible, close it and don't reopen. + // This preserves the toggle-off behavior and runs BEFORE the resolver. + if (props.popoverVisible || activeExternalPopover) { + if (props.popoverVisible) props.closePopover(); + cleanupExternalPopover(); return; } @@ -78,86 +332,55 @@ const handleLinkClick = (event) => { return; } - // Get PM position from the link element's data attributes (set by layout-engine renderer) - // This is more reliable than using posAtCoords which doesn't work well with layout-engine DOM + // Move cursor to the clicked link position const pmStart = linkElement?.dataset?.pmStart; if (pmStart != null) { - // Move cursor to the link position using the PM position from the element const pos = parseInt(pmStart, 10); const state = props.editor.state; const doc = state.doc; - // Validate position is a valid number and within document bounds if (!isNaN(pos) && pos >= 0 && pos <= doc.content.size) { const tr = state.tr.setSelection(TextSelection.create(doc, pos)); props.editor.dispatch(tr); } else { - // Invalid or out-of-bounds position - fallback to coordinate-based positioning console.warn(`Invalid PM position from data-pm-start: ${pmStart}, falling back to coordinate-based positioning`); moveCursorToMouseEvent(detail, props.editor); } } else { - // Fallback to coordinate-based positioning (may not work well with layout-engine) moveCursorToMouseEvent(detail, props.editor); } - // Check if the cursor is now on a link mark after moving - // Use a small timeout to ensure the selection has been updated + // Wait for editor state to settle, then open popover if cursor landed on a link setTimeout(() => { - // IMPORTANT: Use CURRENT state after cursor movement, not stale captured state const currentState = props.editor.state; const $from = currentState.selection.$from; const linkMarkType = currentState.schema.marks.link; - // Check marks at cursor position and on adjacent nodes const nodeAfter = $from.nodeAfter; const nodeBefore = $from.nodeBefore; const marksOnNodeAfter = nodeAfter?.marks || []; const marksOnNodeBefore = nodeBefore?.marks || []; - // Check if cursor is adjacent to a link (nodeAfter or nodeBefore has link mark) - // This handles the case where cursor is at the boundary of a link mark const linkOnNodeAfter = linkMarkType && marksOnNodeAfter.some((m) => m.type === linkMarkType); const linkOnNodeBefore = linkMarkType && marksOnNodeBefore.some((m) => m.type === linkMarkType); const hasLinkAdjacent = linkOnNodeAfter || linkOnNodeBefore; - const hasLink = selectionHasNodeOrMark(currentState, 'link', { requireEnds: true }); - // Use hasLinkAdjacent as fallback for when cursor is at mark boundary if (hasLink || hasLinkAdjacent) { - const surfaceRect = surface.getBoundingClientRect(); - if (!surfaceRect) return; - - // Calculate popover position relative to the surface - props.openPopover( - markRaw(LinkInput), - { - showInput: true, - editor: props.editor, - closePopover: props.closePopover, - }, - { - left: `${detail.clientX - surfaceRect.left}px`, - top: `${detail.clientY - surfaceRect.top + 15}px`, - }, - ); + resolveAndOpenPopover(detail, surface); } }, CURSOR_UPDATE_TIMEOUT_MS); }; -/** - * Reference to the editor surface element where link click events are attached. - * Cached on mount to enable proper cleanup on unmount. - * - * @type {HTMLElement | null} - */ +// ─── Lifecycle ────────────────────────────────────────────────────────────── + +/** @type {HTMLElement | null} */ let surfaceElement = null; onMounted(() => { if (!props.editor) return; - // Attach link click listener to the editor surface surfaceElement = getEditorSurfaceElement(props.editor); if (surfaceElement) { surfaceElement.addEventListener('superdoc-link-click', handleLinkClick); @@ -165,10 +388,10 @@ onMounted(() => { }); onBeforeUnmount(() => { - // Clean up event listener if (surfaceElement) { surfaceElement.removeEventListener('superdoc-link-click', handleLinkClick); } + cleanupExternalPopover(); }); diff --git a/packages/super-editor/src/components/popovers/GenericPopover.vue b/packages/super-editor/src/components/popovers/GenericPopover.vue index ac88b15744..2059898b0c 100644 --- a/packages/super-editor/src/components/popovers/GenericPopover.vue +++ b/packages/super-editor/src/components/popovers/GenericPopover.vue @@ -69,15 +69,12 @@ const derivedStyles = computed(() => ({ diff --git a/packages/super-editor/src/core/types/EditorConfig.ts b/packages/super-editor/src/core/types/EditorConfig.ts index 70c4ed22ab..910dba57d7 100644 --- a/packages/super-editor/src/core/types/EditorConfig.ts +++ b/packages/super-editor/src/core/types/EditorConfig.ts @@ -14,6 +14,76 @@ import type { } from './EditorEvents.js'; import type { ProseMirrorJSON } from './EditorTypes.js'; +/** + * Context provided to a link popover resolver when a link is clicked. + * Contains all information needed to decide which popover to show. + */ +export interface LinkPopoverContext { + /** The editor instance */ + editor: Editor; + /** The href attribute of the clicked link */ + href: string; + /** The target attribute of the clicked link */ + target: string | null; + /** The rel attribute of the clicked link */ + rel: string | null; + /** The title/tooltip attribute of the clicked link */ + tooltip: string | null; + /** The clicked anchor DOM element */ + element: HTMLAnchorElement; + /** X coordinate of the click */ + clientX: number; + /** Y coordinate of the click */ + clientY: number; + /** Whether this is an anchor link (href starts with #) */ + isAnchorLink: boolean; + /** Current document mode ('editing', 'viewing', 'suggesting') */ + documentMode: string; + /** Computed popover position relative to editor surface */ + position: { left: string; top: string }; + /** Close the popover programmatically */ + closePopover: () => void; +} + +/** + * Context passed to an external (framework-agnostic) popover renderer. + * The `render` function receives a raw DOM container and is responsible for + * mounting its own UI (React, Svelte, vanilla JS, etc.). + */ +export interface ExternalPopoverRenderContext { + /** Empty DOM container positioned where the popover should appear */ + container: HTMLElement; + /** Call to close the popover and clean up */ + closePopover: () => void; + /** The editor instance */ + editor: Editor; + /** The href of the clicked link */ + href: string; +} + +/** + * Resolution returned by a link popover resolver. + * - `{ type: 'default' }` — use the built-in LinkInput popover. + * - `{ type: 'none' }` — suppress the popover entirely (resolver can perform side effects like navigation). + * - `{ type: 'custom', component, props }` — render a Vue component in the popover. + * `editor` and `closePopover` are auto-injected into props. + * - `{ type: 'external', render }` — mount framework-agnostic UI into a raw DOM container. + * Return `{ destroy }` for cleanup when the popover closes. + */ +export type LinkPopoverResolution = + | { type: 'default' } + | { type: 'none' } + | { type: 'custom'; component: unknown; props?: Record } + | { type: 'external'; render: (ctx: ExternalPopoverRenderContext) => { destroy?: () => void } | void }; + +/** + * Resolver function for customizing the link click popover. + * Must be synchronous — do not return a Promise. + * + * @returns A resolution object, or `null`/`undefined` to use the default popover. + */ +export type LinkPopoverResolver = (ctx: LinkPopoverContext) => LinkPopoverResolution | null | undefined; + /** * User information for collaboration */ @@ -416,6 +486,13 @@ export interface EditorOptions { /** Host-provided permission hook */ permissionResolver?: ((params: PermissionParams) => boolean | undefined) | null; + /** + * Custom resolver for the link click popover. + * Called when a user clicks a link to determine which popover to show. + * Must be synchronous. + */ + linkPopoverResolver?: LinkPopoverResolver; + /** * When true, defers document initialization until open() is called. * This enables the new document lifecycle API where: diff --git a/packages/superdoc/src/SuperDoc.vue b/packages/superdoc/src/SuperDoc.vue index 2ae153f7ff..d8cf4634c1 100644 --- a/packages/superdoc/src/SuperDoc.vue +++ b/packages/superdoc/src/SuperDoc.vue @@ -564,6 +564,7 @@ const editorOptions = (doc) => { disableContextMenu: proxy.$superdoc.config.disableContextMenu, jsonOverride: proxy.$superdoc.config.jsonOverride, viewOptions: proxy.$superdoc.config.viewOptions, + linkPopoverResolver: proxy.$superdoc.config.modules?.links?.popoverResolver, layoutEngineOptions: useLayoutEngine ? { ...(proxy.$superdoc.config.layoutEngineOptions || {}), diff --git a/packages/superdoc/src/core/types/index.js b/packages/superdoc/src/core/types/index.js index f0adbeec4e..26b27d2bec 100644 --- a/packages/superdoc/src/core/types/index.js +++ b/packages/superdoc/src/core/types/index.js @@ -39,6 +39,47 @@ * @property {Object} [params] Additional params for internal provider (deprecated) */ +/** @typedef {import('@superdoc/super-editor').Editor} Editor */ +/** @typedef {import('../SuperDoc.js').SuperDoc} SuperDoc */ + +/** + * Context passed to a link popover resolver when a link is clicked. + * @typedef {Object} LinkPopoverContext + * @property {Editor} editor The editor instance + * @property {string} href The href attribute of the clicked link + * @property {string | null} target The target attribute of the clicked link + * @property {string | null} rel The rel attribute of the clicked link + * @property {string | null} tooltip The title/tooltip attribute of the clicked link + * @property {HTMLAnchorElement} element The clicked anchor DOM element + * @property {number} clientX X coordinate of the click + * @property {number} clientY Y coordinate of the click + * @property {boolean} isAnchorLink Whether this is an anchor link (href starts with #) + * @property {string} documentMode Current document mode ('editing', 'viewing', 'suggesting') + * @property {{ left: string, top: string }} position Computed popover position relative to editor surface + * @property {() => void} closePopover Close the popover programmatically + */ + +/** + * Context passed to an external (framework-agnostic) popover renderer. + * @typedef {Object} ExternalPopoverRenderContext + * @property {HTMLElement} container Empty DOM container positioned where the popover should appear + * @property {() => void} closePopover Call to close the popover and clean up + * @property {Editor} editor The editor instance + * @property {string} href The href of the clicked link + */ + +/** + * Resolution returned by a link popover resolver. + * @typedef {{ type: 'default' } | { type: 'none' } | { type: 'custom', component: unknown, props?: Record } | { type: 'external', render: (ctx: ExternalPopoverRenderContext) => ({ destroy?: () => void } | void) }} LinkPopoverResolution + */ + +/** + * Resolver function for customizing the link click popover. + * Must be synchronous — do not return a Promise. + * Return null/undefined to use the default popover. + * @typedef {(ctx: LinkPopoverContext) => LinkPopoverResolution | null | undefined} LinkPopoverResolver + */ + /** * @typedef {Object} Modules * @property {Object | false} [comments] Comments module configuration (false to disable) @@ -83,6 +124,8 @@ * @property {number} [pdf.outputScale] Canvas render scale (quality) * @property {CollaborationConfig} [collaboration] Collaboration module configuration * @property {Object} [toolbar] Toolbar module configuration + * @property {Object} [links] Link click popover configuration + * @property {LinkPopoverResolver} [links.popoverResolver] Custom resolver for the link click popover. * @property {Object} [contextMenu] Context menu module configuration * @property {Array} [contextMenu.customItems] Array of custom menu sections with items * @property {Function} [contextMenu.menuProvider] Function to customize menu items @@ -90,9 +133,6 @@ * @property {Object} [slashMenu] @deprecated Use contextMenu instead */ -/** @typedef {import('@superdoc/super-editor').Editor} Editor */ -/** @typedef {import('../SuperDoc.js').SuperDoc} SuperDoc */ - /** * @typedef {'editing' | 'viewing' | 'suggesting'} DocumentMode */