Skip to content
Merged
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
Binary file added e2e/pptx/fixtures/slide-jump-links.pptx
Binary file not shown.
76 changes: 76 additions & 0 deletions e2e/pptx/slide-jump-links.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/* oxlint-disable vitest/prefer-importing-vitest-globals -- Playwright */
/** Reporter IHAGI-c's reproduction for #37, shared by every binding.
* Fixture: https://raw.githubusercontent.com/IHAGI-c/ooxml/pr-assets/decks/slide-jump-links.pptx
*/
import { fileURLToPath } from 'node:url';

import { expect, test } from '@playwright/test';
import type { Page } from '@playwright/test';
import type { PowerPointViewerAPI } from 'ooxml-ui/pptx';

import { loadDeck } from './support/deck';

const fixture = fileURLToPath(new URL('./fixtures/slide-jump-links.pptx', import.meta.url));
type ViewerWindow = Window & { __pptxViewer: PowerPointViewerAPI };

// A show overlay follows the editing canvas in DOM order, even when the
// covered editing canvas is still mounted.
const stage = (page: Page) => page.locator('[aria-roledescription="slide"]:visible').last();

function linkedElement(page: Page, text: string) {
// Some bindings split a linked run into words for text layout. Click
// the link itself, rather than the centre of its wider text box.
return text.startsWith('A.')
? stage(page).getByRole('link', { name: /^A\./u }).first()
: stage(page).getByText(text, { exact: true });
}

async function setMode(page: Page, mode: 'preview' | 'edit' | 'present'): Promise<void> {
await page.waitForFunction(() => Boolean((window as ViewerWindow).__pptxViewer));
await page.evaluate((next) => (window as ViewerWindow).__pptxViewer.setMode(next), mode);
}

async function activeSlide(page: Page): Promise<number> {
return page.evaluate(() => (window as ViewerWindow).__pptxViewer.getActiveSlideIndex());
}

for (const text of ['A. Text link to slide 3', 'B. Shape link to slide 3']) {
for (const mode of ['preview', 'present'] as const) {
test(`${mode}: ${text} jumps directly to slide 3 without a new tab`, async ({
page,
context,
}) => {
await loadDeck(page, fixture);
await setMode(page, mode);
const pages = context.pages().length;
await linkedElement(page, text).click();
await expect(stage(page).getByText('Slide 3 (link target)', { exact: true })).toBeVisible();
expect(context.pages()).toHaveLength(pages);
});
}
}

test('preview hides authoring badges and lets Enter follow an internal text link', async ({
page,
}) => {
await loadDeck(page, fixture);
await setMode(page, 'preview');
await expect(stage(page).locator('.pptx-action-indicator')).toBeHidden();
const link = stage(page).getByRole('link', { name: /^A\./u }).first();
await expect(link).not.toHaveAttribute('href', /slide3\.xml/u);
await link.press('Enter');
await expect.poll(() => activeSlide(page)).toBe(2);
});

for (const text of ['A. Text link to slide 3', 'B. Shape link to slide 3']) {
test(`editing: ${text} requires Ctrl/Cmd+Click to navigate`, async ({ page }) => {
await loadDeck(page, fixture);
await setMode(page, 'edit');
const link = linkedElement(page, text);
await expect(link).not.toHaveAttribute('href', /./u);
await link.click();
expect(await activeSlide(page)).toBe(0);
await link.click({ modifiers: ['ControlOrMeta'] });
await expect.poll(() => activeSlide(page)).toBe(2);
});
}
17 changes: 12 additions & 5 deletions src/ui/src/pptx/dom/elements/text-block.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { buildTextBuildSpec, runEquationMathMl, textBuildSpanStyle } from '../../index';
import {
buildTextBuildSpec,
isRunHyperlinkClickable,
runEquationMathMl,
textBuildSpanStyle,
} from '../../index';
import type {
CssStyleMap,
ElementAnimationState,
Expand Down Expand Up @@ -40,11 +45,13 @@ function createRunBaseNode(doc: Document, run: ParagraphRun): HTMLElement {
}
return host;
}
// An internal `ppaction://` jump resolves to no href (shared refuses to make
// an action look like a URL), so it renders as plain text as it always has.
if (run.hyperlink?.href) {
if (run.hyperlink && isRunHyperlinkClickable(run.hyperlink)) {
const link = createEl(doc, 'a', 'pptxv-link', run.style);
link.setAttribute('href', run.hyperlink.href);
link.setAttribute('data-pptx-hyperlink', run.hyperlink.url);
link.setAttribute('role', 'link');
link.tabIndex = 0;
link.style.cursor = 'pointer';
if (run.hyperlink.href) link.setAttribute('href', run.hyperlink.href);
link.setAttribute('target', run.hyperlink.target ?? '_blank');
link.setAttribute('rel', run.hyperlink.rel ?? 'noopener noreferrer');
if (run.hyperlink.tooltip) {
Expand Down
4 changes: 4 additions & 0 deletions src/ui/src/pptx/render/element-action-affordance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ export const ACTION_INDICATOR_ICON_PATH = 'M13 2L3 14h9l-1 8 10-12h-9l1-8z';
* the affordance's typography a property of the affordance.
*/
export const ACTION_AFFORDANCE_CSS = `
[data-pptx-read-only] .${ACTION_INDICATOR_CLASS},
[data-pptx-read-only] .${LINK_TOOLTIP_CLASS} {
display: none;
}
.${ACTION_INDICATOR_CLASS} {
position: absolute;
top: -4px;
Expand Down
1 change: 1 addition & 0 deletions src/ui/src/pptx/render/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1766,3 +1766,4 @@ export * from './tab-row-actions';
export * from './slide-sorter-state';

export * from './viewer-root-options';
export * from './viewer-hyperlinks';
19 changes: 19 additions & 0 deletions src/ui/src/pptx/render/presentation-action.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,25 @@ describe('runPresentationAction: wave-4 verbs', () => {
});

describe('findPresentationActionTarget', () => {
it('lets an internal text link override its enclosing shape action', () => {
const slide = slideOf([shape('s1', { targetSlideIndex: 1 })]);
const node = render(
'<div data-element-id="s1"><a data-pptx-hyperlink="ppaction://hlinksldjump?slideIndex=2"><span>Jump</span></a></div>',
);
expect(findPresentationActionTarget(node.querySelector('span'), slide)).toStrictEqual({
elementId: 's1',
action: { action: 'ppaction://hlinksldjump', targetSlideIndex: 2 },
});
});

it('leaves an external text link to its own handler instead of the shape action', () => {
const slide = slideOf([shape('s1', { targetSlideIndex: 1 })]);
const node = render(
'<div data-element-id="s1"><a data-pptx-hyperlink="https://example.com"><span>Open</span></a></div>',
);
expect(findPresentationActionTarget(node.querySelector('span'), slide)).toBeUndefined();
});

it('finds the action of the element under the pointer', () => {
const slide = slideOf([
shape('s1', { action: 'ppaction://hlinksldjump', targetSlideIndex: 8 }),
Expand Down
13 changes: 12 additions & 1 deletion src/ui/src/pptx/render/presentation-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@

import type { PptxAction, PptxElement, PptxSlide } from 'ooxml-core/pptx';

import { isUrlSafe, safeOpenUrl } from './hyperlink-security';
import { isUrlSafe, parsePpactionUrl, safeOpenUrl } from './hyperlink-security';
import { isPresentationAdvanceClick } from './presentation-setup';

/**
Expand Down Expand Up @@ -287,6 +287,17 @@ export function findPresentationActionTarget(
if (typeof Element === 'undefined' || !(target instanceof Element) || !slide) {
return undefined;
}
const run = target.closest('[data-pptx-hyperlink]');
if (run) {
const internal = parsePpactionUrl(run.getAttribute('data-pptx-hyperlink') ?? '');
// A run's link takes precedence over the surrounding shape's action.
return internal
? {
elementId: run.closest('[data-element-id]')?.getAttribute('data-element-id') ?? '',
action: { action: internal.action, targetSlideIndex: internal.targetSlideIndex },
}
: undefined;
}
const byId = new Map<string, PptxElement>();
for (const element of flattenSlideElements(slide.elements)) {
byId.set(element.id, element);
Expand Down
22 changes: 22 additions & 0 deletions src/ui/src/pptx/render/text-run-meta.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,28 @@ function textEl(segments: TextSegment[], extra: Record<string, unknown> = {}): P
}

describe('resolveRunHyperlink', () => {
it('routes a slide relationship through its internal action instead of a browser href', () => {
const style = {
hyperlink: 'slide3.xml',
hyperlinkAction: 'ppaction://hlinksldjump',
hyperlinkTargetSlideIndex: 2,
};
const link = resolveRunHyperlink(style);
expect(link?.url).toBe('ppaction://hlinksldjump?slideIndex=2');
expect(link?.targetSlideIndex).toBe(2);
expect(link?.href).toBeUndefined();
expect(style.hyperlink).toBe('slide3.xml');
});

it('keeps an unresolved internal slide relationship out of the browser', () => {
const link = resolveRunHyperlink({
hyperlink: '../slides/slide99.xml',
hyperlinkAction: 'ppaction://hlinksldjump',
});
expect(link?.url).toBe('ppaction://hlinksldjump');
expect(link?.href).toBeUndefined();
});

it('returns undefined for a run with no link', () => {
expect(resolveRunHyperlink(undefined)).toBeUndefined();
expect(resolveRunHyperlink({ bold: true })).toBeUndefined();
Expand Down
12 changes: 11 additions & 1 deletion src/ui/src/pptx/render/text-run-meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@ export interface RunHyperlink {
onHover?: boolean;
}

/** A real URL or an internal action can be activated; unsafe URLs stay plain text. */
export function isRunHyperlinkClickable(link: RunHyperlink | undefined): boolean {
return Boolean(link && (link.href || isPpactionUrl(link.url)));
}

/** An inline equation run (`m:oMath`), rendered as MathML rather than text. */
export interface RunEquation {
/** The raw OMML node, for `convertOmmlToMathMl`. */
Expand All @@ -76,7 +81,12 @@ export interface RunEquation {
*/
export function resolveRunHyperlink(style: TextStyle | undefined): RunHyperlink | undefined {
const clicked = style?.hyperlink;
const raw = clicked || style?.hyperlinkMouseOver;
// Keep the package relationship on the model for saving, but navigate by
// the slide action. A relationship such as slide3.xml is not a web URL.
const clickAction = style?.hyperlinkAction;
const internalJump =
clickAction && /^ppaction:\/\/(?:hlinksldjump|hlinkshowjump)(?:\?|$)/iu.test(clickAction);
const raw = (internalJump ? clickAction : clicked) || style?.hyperlinkMouseOver;
if (!raw) {
return undefined;
}
Expand Down
165 changes: 165 additions & 0 deletions src/ui/src/pptx/render/viewer-hyperlinks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
// @vitest-environment jsdom
import type { PptxAction, PptxSlide } from 'ooxml-core/pptx';
import { afterEach, describe, expect, it, vi } from 'vitest';

import { ACTION_AFFORDANCE_CSS } from './element-action-affordance';
import { handlePresentationStageClick } from './presentation-action';
import { attachViewerHyperlinks } from './viewer-hyperlinks';
import type { ViewerHyperlinkState } from './viewer-hyperlinks';

let detach: (() => void) | undefined;
afterEach(() => {
detach?.();
document.body.replaceChildren();
vi.restoreAllMocks();
});

function mount(action: PptxAction = { action: 'ppaction://hlinksldjump', targetSlideIndex: 2 }) {
const root = document.createElement('div');
root.innerHTML = `<div aria-roledescription="slide"><div data-element-id="shape" role="button" tabindex="0"><span>Shape link</span><i class="pptx-action-indicator"></i></div><div data-element-id="text"><a role="link" tabindex="0" data-pptx-hyperlink="ppaction://hlinksldjump?slideIndex=2"><span>Text link</span></a></div></div>`;
document.body.append(root);
const slide = { elements: [{ id: 'shape', type: 'shape', actionClick: action }] } as PptxSlide;
const state: ViewerHyperlinkState = {
slide,
slideCount: 3,
currentSlideIndex: 0,
editable: false,
presenting: false,
};
const goToSlide = vi.fn();
const onHyperlinkClick = vi.fn();
const confirmExternalHyperlink = vi.fn(() => true);
const open = vi.spyOn(window, 'open').mockReturnValue(null);
detach = attachViewerHyperlinks(root, {
getState: () => state,
goToSlide,
onHyperlinkClick,
confirmExternalHyperlink,
});
const text = root.querySelector<HTMLAnchorElement>('a')!;
const shape = root.querySelector<HTMLElement>('[data-element-id="shape"]')!;
return { root, text, shape, state, goToSlide, onHyperlinkClick, confirmExternalHyperlink, open };
}

describe('viewer hyperlink navigation', () => {
it.each(['text', 'shape'] as const)(
'follows a read-only %s link without opening a tab',
(kind) => {
const view = mount();
view[kind]
.querySelector('span')!
.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
expect(view.goToSlide).toHaveBeenCalledExactlyOnceWith(2);
expect(view.open).not.toHaveBeenCalled();
expect(view.onHyperlinkClick).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ action: 'ppaction://hlinksldjump', targetSlideIndex: 2 }),
);
},
);

it.each(['text', 'shape'] as const)('lets the host cancel a %s link', (kind) => {
const view = mount();
view.onHyperlinkClick.mockReturnValue(false);
const advance = vi.fn();
view.root.addEventListener('click', advance);
view[kind].click();
expect(view.onHyperlinkClick).toHaveBeenCalledOnce();
expect(view.goToSlide).not.toHaveBeenCalled();
expect(view.open).not.toHaveBeenCalled();
expect(advance).not.toHaveBeenCalled();
});

it.each(['ctrlKey', 'metaKey'] as const)(
'keeps unmodified editing clicks for selection and follows with %s',
(modifier) => {
const view = mount();
view.state.editable = true;
view.text.click();
view.shape.click();
expect(view.goToSlide).not.toHaveBeenCalled();
expect(view.onHyperlinkClick).not.toHaveBeenCalled();
view.shape.dispatchEvent(new MouseEvent('click', { bubbles: true, [modifier]: true }));
expect(view.goToSlide).toHaveBeenCalledExactlyOnceWith(2);
},
);

it.each(['Enter', ' '])('activates a read-only link from the keyboard with %s', (key) => {
const view = mount();
view.text.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }));
expect(view.goToSlide).toHaveBeenCalledExactlyOnceWith(2);
expect(view.open).not.toHaveBeenCalled();
});

it.each(['text', 'shape'] as const)(
'uses the existing show runner exactly once for a presenting %s link',
(kind) => {
const view = mount();
view.state.presenting = true;
const showGoTo = vi.fn();
view.root.addEventListener('click', (event) => {
handlePresentationStageClick(
event.target,
view.state.slide,
{ slideCount: 3 },
{ goToSlide: showGoTo, move: vi.fn(), endShow: vi.fn() },
);
});
view[kind].dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
);
expect(showGoTo).toHaveBeenCalledExactlyOnceWith(2);
expect(view.onHyperlinkClick).toHaveBeenCalledOnce();
expect(view.goToSlide).not.toHaveBeenCalled();
expect(view.open).not.toHaveBeenCalled();
},
);

it('does not follow an unresolved internal jump as a relationship URL', () => {
const view = mount();
view.text.setAttribute('data-pptx-hyperlink', 'ppaction://hlinksldjump');
view.text.click();
expect(view.goToSlide).not.toHaveBeenCalled();
expect(view.open).not.toHaveBeenCalled();
});

it('preserves the external target frame and Trust Center gate', () => {
const view = mount();
view.text.setAttribute('data-pptx-hyperlink', 'https://example.com');
view.text.setAttribute('target', '_self');
view.confirmExternalHyperlink.mockReturnValueOnce(false);
view.text.click();
expect(view.open).not.toHaveBeenCalled();
view.text.click();
expect(view.open).toHaveBeenCalledExactlyOnceWith('https://example.com', '_self', '');
view.onHyperlinkClick.mockReturnValue(false);
view.text.click();
expect(view.open).toHaveBeenCalledOnce();
});

it('blocks unsafe external run URLs even when the host allows the click', () => {
const view = mount();
view.text.setAttribute('data-pptx-hyperlink', `${'javascript'}:alert(1)`);
view.text.click();
expect(view.open).not.toHaveBeenCalled();
});

it('ignores reflection copies and static previews', () => {
const view = mount();
view.text.parentElement!.setAttribute('aria-hidden', 'true');
view.text.click();
view.shape.closest('[aria-roledescription]')!.removeAttribute('aria-roledescription');
view.shape.click();
expect(view.onHyperlinkClick).not.toHaveBeenCalled();
expect(view.goToSlide).not.toHaveBeenCalled();
});

it('hides authoring badges in read-only mode and restores the root on cleanup', () => {
const view = mount();
const style = document.createElement('style');
style.textContent = ACTION_AFFORDANCE_CSS;
view.root.append(style);
expect(getComputedStyle(view.shape.querySelector('i')!).display).toBe('none');
detach?.();
expect(view.root.hasAttribute('data-pptx-read-only')).toBe(false);
});
});
Loading
Loading