Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -63,6 +69,7 @@ describe('LinkClickHandler', () => {
focus: vi.fn(),
},
dispatch: vi.fn(),
presentationEditor: mockPresentationEditor,
options: {
documentMode: 'editing',
onException: vi.fn(),
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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
// =========================================================================
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ─────────────────────────────────────────────────────

/**
Expand Down Expand Up @@ -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;
Expand Down
13 changes: 12 additions & 1 deletion packages/super-editor/src/components/toolbar/LinkInput.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
};
</script>

<template>
Expand Down Expand Up @@ -271,7 +280,9 @@ const handleRemove = () => {
</div>

<div v-else-if="isAnchor" class="input-row go-to-anchor clickable">
<a @click.stop.prevent="goToAnchor">Go to {{ rawUrl.startsWith('#_') ? rawUrl.substring(2) : rawUrl }}</a>
<a @click.stop.prevent="navigateToAnchor(rawUrl)"
>Go to {{ rawUrl.startsWith('#_') ? rawUrl.substring(2) : rawUrl }}</a
>
</div>
</div>
</template>
Expand Down
44 changes: 30 additions & 14 deletions tests/behavior/tests/toolbar/link.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});
});
Loading