diff --git a/packages/super-editor/src/components/link-click/LinkClickHandler.test.js b/packages/super-editor/src/components/link-click/LinkClickHandler.test.js index ca7af0c817..ed65e4fdc9 100644 --- a/packages/super-editor/src/components/link-click/LinkClickHandler.test.js +++ b/packages/super-editor/src/components/link-click/LinkClickHandler.test.js @@ -23,15 +23,21 @@ vi.mock('prosemirror-state', () => ({ describe('LinkClickHandler', () => { let mockEditor; + let mockPresentationEditor; let mockOpenPopover; let mockClosePopover; let mockSurfaceElement; + let windowOpenSpy; beforeEach(() => { // Reset all mocks before each test vi.clearAllMocks(); // Create mock editor with state + mockPresentationEditor = { + goToAnchor: vi.fn(), + }; + mockEditor = { state: { selection: { @@ -63,6 +69,7 @@ describe('LinkClickHandler', () => { focus: vi.fn(), }, dispatch: vi.fn(), + presentationEditor: mockPresentationEditor, options: { documentMode: 'editing', onException: vi.fn(), @@ -86,6 +93,7 @@ describe('LinkClickHandler', () => { // Setup getEditorSurfaceElement mock to return the surface element getEditorSurfaceElement.mockReturnValue(mockSurfaceElement); + windowOpenSpy = vi.spyOn(window, 'open').mockImplementation(() => null); }); afterEach(() => { @@ -614,6 +622,76 @@ describe('LinkClickHandler', () => { expect(mockEditor.dispatch).toHaveBeenCalledTimes(1); }); + it('should open external hyperlinks in viewing mode instead of showing the popover', async () => { + mockEditor.options.documentMode = 'viewing'; + + mount(LinkClickHandler, { + props: { + editor: mockEditor, + openPopover: mockOpenPopover, + closePopover: mockClosePopover, + }, + }); + + const linkElement = document.createElement('a'); + linkElement.dataset.pmStart = '10'; + + const linkClickEvent = new CustomEvent('superdoc-link-click', { + bubbles: true, + composed: true, + detail: { + href: 'https://example.com', + target: '_blank', + rel: 'noopener noreferrer', + element: linkElement, + clientX: 250, + clientY: 250, + }, + }); + + mockSurfaceElement.dispatchEvent(linkClickEvent); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(windowOpenSpy).toHaveBeenCalledWith('https://example.com', '_blank', 'noopener,noreferrer'); + expect(mockOpenPopover).not.toHaveBeenCalled(); + expect(mockEditor.dispatch).not.toHaveBeenCalled(); + expect(moveCursorToMouseEvent).not.toHaveBeenCalled(); + }); + + it('should navigate internal anchors in viewing mode via editor.goToAnchor', async () => { + mockEditor.options.documentMode = 'viewing'; + + mount(LinkClickHandler, { + props: { + editor: mockEditor, + openPopover: mockOpenPopover, + closePopover: mockClosePopover, + }, + }); + + const linkElement = document.createElement('a'); + linkElement.dataset.pmStart = '10'; + + const linkClickEvent = new CustomEvent('superdoc-link-click', { + bubbles: true, + composed: true, + detail: { + href: '#section-1', + element: linkElement, + clientX: 250, + clientY: 250, + }, + }); + + mockSurfaceElement.dispatchEvent(linkClickEvent); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(mockPresentationEditor.goToAnchor).toHaveBeenCalledWith('#section-1'); + expect(windowOpenSpy).not.toHaveBeenCalled(); + expect(mockOpenPopover).not.toHaveBeenCalled(); + expect(mockEditor.dispatch).not.toHaveBeenCalled(); + }); + // ========================================================================= // linkPopoverResolver tests // ========================================================================= diff --git a/packages/super-editor/src/components/link-click/LinkClickHandler.vue b/packages/super-editor/src/components/link-click/LinkClickHandler.vue index caf39da3c9..414cefffe7 100644 --- a/packages/super-editor/src/components/link-click/LinkClickHandler.vue +++ b/packages/super-editor/src/components/link-click/LinkClickHandler.vue @@ -297,6 +297,31 @@ const resolveAndOpenPopover = (detail, surface) => { openDefaultPopover(position); }; +/** + * Open a hyperlink when the editor is in viewing mode. + * Internal document anchors stay within the document; other URLs use browser navigation. + * + * @param {Object} detail - Event detail from superdoc-link-click + */ +const openLinkInViewingMode = (detail) => { + const href = detail.href ?? ''; + if (!href) return; + + if (href.startsWith('#') && href.length > 1) { + const presentationEditor = props.editor?.presentationEditor ?? null; + presentationEditor?.goToAnchor?.(href); + return; + } + + const target = detail.target || '_self'; + const relTokens = String(detail.rel ?? '') + .split(/\s+/) + .filter(Boolean); + const features = ['noopener', 'noreferrer'].filter((token) => relTokens.includes(token)).join(','); + + window.open(href, target, features || undefined); +}; + // ─── Link click handler ───────────────────────────────────────────────────── /** @@ -329,6 +354,11 @@ const handleLinkClick = (event) => { return; } + if (props.editor.options?.documentMode === 'viewing') { + openLinkInViewingMode(detail); + return; + } + const surface = getEditorSurfaceElement(props.editor); if (!surface) { return; diff --git a/packages/super-editor/src/components/toolbar/LinkInput.vue b/packages/super-editor/src/components/toolbar/LinkInput.vue index b375027067..4b6f415266 100644 --- a/packages/super-editor/src/components/toolbar/LinkInput.vue +++ b/packages/super-editor/src/components/toolbar/LinkInput.vue @@ -209,6 +209,15 @@ const handleRemove = () => { props.closePopover(); } }; + +const navigateToAnchor = (url) => { + const presentationEditor = props.editor?.presentationEditor ?? null; + if (presentationEditor) { + presentationEditor.goToAnchor(url); + } else if (props.goToAnchor) { + props.goToAnchor(url); + } +}; diff --git a/tests/behavior/tests/toolbar/link.spec.ts b/tests/behavior/tests/toolbar/link.spec.ts index 3b2c14c489..d4ea8c695d 100644 --- a/tests/behavior/tests/toolbar/link.spec.ts +++ b/tests/behavior/tests/toolbar/link.spec.ts @@ -132,31 +132,47 @@ test('link is not editable in viewing mode', async ({ superdoc }) => { await applyLink(superdoc, 'https://example.com'); await superdoc.snapshot('link created in editing mode'); - // Switch to viewing mode via toolbar dropdown - const modeButton = superdoc.page.locator('[data-item="btn-documentMode"]'); - await modeButton.click(); - await superdoc.waitForStable(); - - const viewingOption = superdoc.page.locator('[data-item="btn-documentMode-option"]').filter({ hasText: 'Viewing' }); - await viewingOption.click(); + // Switch to viewing mode + await superdoc.setDocumentMode('viewing'); await superdoc.waitForStable(); + await superdoc.assertDocumentMode('viewing'); await superdoc.snapshot('switched to viewing mode'); // Link toolbar button should be disabled in viewing mode const linkButton = superdoc.page.locator('[data-item="btn-link"]'); await expect(linkButton).toHaveClass(/disabled/); - // Click on the linked text in the document to trigger link details popup + // Stub window.open so we can assert navigation without depending on popup handling + await superdoc.page.evaluate(() => { + (window as any).__sdOpenedLinks = []; + const originalOpen = window.open.bind(window); + (window as any).__sdOriginalWindowOpen = originalOpen; + window.open = (...args) => { + (window as any).__sdOpenedLinks.push(args); + return null; + }; + }); + + // Clicking the rendered link should navigate, not open the read-only link popup const linkElement = superdoc.page.locator('.superdoc-link:has-text("website")'); await linkElement.click(); await superdoc.waitForStable(); - // Should show "Link details" (not "Edit link") — use .first() since there may be - // a stale toolbar dropdown element in addition to the link details popup - await expect(superdoc.page.locator('.link-title').first()).toHaveText('Link details'); - await superdoc.snapshot('link details popup in viewing mode'); - - // Remove and Apply buttons should not be visible + await expect(superdoc.page.locator('.link-title').filter({ hasText: 'Link details' })).toBeHidden(); await expect(superdoc.page.locator('[data-item="btn-link-remove"]')).toHaveCount(0); await expect(superdoc.page.locator('[data-item="btn-link-apply"]')).toHaveCount(0); + await expect + .poll(() => superdoc.page.evaluate(() => (window as any).__sdOpenedLinks)) + .toEqual([['https://example.com', '_blank', 'noopener,noreferrer']]); + await superdoc.snapshot('link navigates in viewing mode'); + + // Restore window.open for cleanliness in the browser context + await superdoc.page.evaluate(() => { + const originalOpen = (window as any).__sdOriginalWindowOpen; + if (originalOpen) { + window.open = originalOpen; + } + delete (window as any).__sdOriginalWindowOpen; + delete (window as any).__sdOpenedLinks; + }); });