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
4 changes: 4 additions & 0 deletions packages/layout-engine/contracts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,8 @@ export type ImageRun = {
// OOXML image effects
grayscale?: boolean; // Apply grayscale filter to image
lum?: ImageLuminanceAdjustment; // DrawingML luminance adjustment from a:lum
/** Image hyperlink from OOXML a:hlinkClick. When set, clicking the image opens the URL. */
hyperlink?: { url: string; tooltip?: string };
};

export type BreakRun = {
Expand Down Expand Up @@ -588,6 +590,8 @@ export type ImageBlock = {
rotation?: number; // Rotation angle in degrees
flipH?: boolean; // Horizontal flip
flipV?: boolean; // Vertical flip
/** Image hyperlink from OOXML a:hlinkClick. When set, clicking the image opens the URL. */
hyperlink?: { url: string; tooltip?: string };
};

export type DrawingKind = 'image' | 'vectorShape' | 'shapeGroup' | 'chart';
Expand Down
129 changes: 129 additions & 0 deletions packages/layout-engine/painters/dom/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6442,6 +6442,135 @@ describe('ImageFragment (block-level images)', () => {
expect(metadataAttr).toBeTruthy();
});
});

describe('hyperlink (DrawingML a:hlinkClick)', () => {
const makePainter = (hyperlink?: { url: string; tooltip?: string }) => {
const block: FlowBlock = {
kind: 'image',
id: 'linked-img',
src: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
width: 100,
height: 50,
...(hyperlink ? { hyperlink } : {}),
};
const measure: Measure = { kind: 'image', width: 100, height: 50 };
const fragment = {
kind: 'image' as const,
blockId: 'linked-img',
x: 20,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

layout is created here but never used — the tests build their own. can be removed.

y: 20,
width: 100,
height: 50,
};
const layout: Layout = {
pageSize: { w: 400, h: 300 },
pages: [{ number: 1, fragments: [fragment] }],
};
return createDomPainter({ blocks: [block], measures: [measure] });
};

it('wraps linked image in <a class="superdoc-link"> with correct href', () => {
const painter = makePainter({ url: 'https://example.com' });
const layout: Layout = {
pageSize: { w: 400, h: 300 },
pages: [
{
number: 1,
fragments: [
{
kind: 'image' as const,
blockId: 'linked-img',
x: 20,
y: 20,
width: 100,
height: 50,
},
],
},
],
};
painter.paint(layout, mount);

const fragmentEl = mount.querySelector('.superdoc-image-fragment');
expect(fragmentEl).toBeTruthy();

const anchor = fragmentEl?.querySelector('a.superdoc-link') as HTMLAnchorElement | null;
expect(anchor).toBeTruthy();
expect(anchor?.href).toBe('https://example.com/');
expect(anchor?.target).toBe('_blank');
expect(anchor?.rel).toContain('noopener');
expect(anchor?.getAttribute('role')).toBe('link');
});

it('sets tooltip as title attribute when present', () => {
const block: FlowBlock = {
kind: 'image',
id: 'tip-img',
src: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
width: 100,
height: 50,
hyperlink: { url: 'https://example.com', tooltip: 'Go here' },
};
const measure: Measure = { kind: 'image', width: 100, height: 50 };
const fragment = { kind: 'image' as const, blockId: 'tip-img', x: 0, y: 0, width: 100, height: 50 };
const layout: Layout = {
pageSize: { w: 400, h: 300 },
pages: [{ number: 1, fragments: [fragment] }],
};
const painter = createDomPainter({ blocks: [block], measures: [measure] });
painter.paint(layout, mount);

const anchor = mount.querySelector('a.superdoc-link') as HTMLAnchorElement | null;
expect(anchor?.title).toBe('Go here');
});

it('does NOT wrap unlinked image in anchor', () => {
const block: FlowBlock = {
kind: 'image',
id: 'plain-img',
src: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
width: 100,
height: 50,
};
const measure: Measure = { kind: 'image', width: 100, height: 50 };
const fragment = { kind: 'image' as const, blockId: 'plain-img', x: 0, y: 0, width: 100, height: 50 };
const layout: Layout = {
pageSize: { w: 400, h: 300 },
pages: [{ number: 1, fragments: [fragment] }],
};
const painter = createDomPainter({ blocks: [block], measures: [measure] });
painter.paint(layout, mount);

const anchor = mount.querySelector('a.superdoc-link');
expect(anchor).toBeNull();

// Image element should still be present
const img = mount.querySelector('.superdoc-image-fragment img');
expect(img).toBeTruthy();
});

it('does NOT wrap image when hyperlink URL fails sanitization', () => {
const block: FlowBlock = {
kind: 'image',
id: 'unsafe-img',
src: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
width: 100,
height: 50,
hyperlink: { url: 'javascript:alert(1)' },
};
const measure: Measure = { kind: 'image', width: 100, height: 50 };
const fragment = { kind: 'image' as const, blockId: 'unsafe-img', x: 0, y: 0, width: 100, height: 50 };
const layout: Layout = {
pageSize: { w: 400, h: 300 },
pages: [{ number: 1, fragments: [fragment] }],
};
const painter = createDomPainter({ blocks: [block], measures: [measure] });
painter.paint(layout, mount);

const anchor = mount.querySelector('a.superdoc-link');
expect(anchor).toBeNull();
});
});
});

describe('URL sanitization security', () => {
Expand Down
67 changes: 63 additions & 4 deletions packages/layout-engine/painters/dom/src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3440,7 +3440,10 @@ export class DomPainter {
if (filters.length > 0) {
img.style.filter = filters.join(' ');
}
fragmentEl.appendChild(img);

// Wrap in anchor when block has a DrawingML hyperlink (a:hlinkClick)
const imageChild = this.buildImageHyperlinkAnchor(img, block.hyperlink, 'block');
fragmentEl.appendChild(imageChild);

return fragmentEl;
} catch (error) {
Expand All @@ -3449,6 +3452,62 @@ export class DomPainter {
}
}

/**
* Optionally wrap an image element in an anchor for DrawingML hyperlinks (a:hlinkClick).
*
* When `hyperlink` is present and its URL passes sanitization, returns an
* `<a class="superdoc-link">` wrapping `imageEl`. The existing EditorInputManager
* click-delegation on `a.superdoc-link` handles both viewing-mode navigation and
* editing-mode event dispatch automatically, with no extra wiring needed here.
*
* When `hyperlink` is absent or the URL fails sanitization the original element
* is returned unchanged.
*
* @param imageEl - The image element (img or span wrapper) to potentially wrap.
* @param hyperlink - Hyperlink metadata from the ImageBlock/ImageRun, or undefined.
* @param display - CSS display value for the anchor: 'block' for fragment images,
* 'inline-block' for inline runs.
*/
private buildImageHyperlinkAnchor(
imageEl: HTMLElement,
hyperlink: { url: string; tooltip?: string } | undefined,
display: 'block' | 'inline-block',
): HTMLElement {
if (!hyperlink?.url || !this.doc) return imageEl;

const sanitized = sanitizeHref(hyperlink.url);
if (!sanitized?.href) return imageEl;

const anchor = this.doc.createElement('a');
anchor.href = sanitized.href;
anchor.classList.add('superdoc-link');
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve image selection for linked images in edit mode

Wrapping image elements with a.superdoc-link causes EditorInputManager.#handlePointerDown to take the link fast-path (closest('a.superdoc-link')) and return before the inline/block image selection paths run (#handleInlineImageClick / #handleFragmentClick). As a result, in editable documents, clicking a hyperlinked image no longer produces image node selection or resize-overlay activation, so linked images become effectively non-selectable by pointer.

Useful? React with 👍 / 👎.


if (sanitized.protocol === 'http' || sanitized.protocol === 'https') {
anchor.target = '_blank';
anchor.rel = 'noopener noreferrer';
}
if (hyperlink.tooltip) {
anchor.title = hyperlink.tooltip;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

text links run the tooltip through encodeTooltip() which trims whitespace and caps it at 500 chars. this skips that — a crafted docx with a huge tooltip would render it in full. worth matching the text link behavior?

Suggested change
}
if (hyperlink.tooltip) {
const tooltipResult = encodeTooltip(hyperlink.tooltip);
if (tooltipResult?.text) {
anchor.title = tooltipResult.text;
}
}


// Accessibility: explicit role and keyboard focus (mirrors applyLinkAttributes for text links)
anchor.setAttribute('role', 'link');
anchor.setAttribute('tabindex', '0');

if (display === 'block') {
anchor.style.cssText = 'display: block; width: 100%; height: 100%; cursor: pointer;';
} else {
// inline-block preserves the image's layout box inside a paragraph line
anchor.style.display = 'inline-block';
anchor.style.lineHeight = '0';
anchor.style.cursor = 'pointer';
anchor.style.verticalAlign = imageEl.style.verticalAlign || 'bottom';
}

anchor.appendChild(imageEl);
return anchor;
}

private renderDrawingFragment(fragment: DrawingFragment, context: FragmentRenderContext): HTMLElement {
try {
const lookup = this.blockLookup.get(fragment.blockId);
Expand Down Expand Up @@ -4941,7 +5000,7 @@ export class DomPainter {
this.applySdtDataset(wrapper, run.sdt);
if (run.dataAttrs) applyRunDataAttributes(wrapper, run.dataAttrs);
wrapper.appendChild(img);
return wrapper;
return this.buildImageHyperlinkAnchor(wrapper, run.hyperlink, 'inline-block');
}

// Apply PM position tracking for cursor placement (only on img when not wrapped)
Expand Down Expand Up @@ -4996,10 +5055,10 @@ export class DomPainter {
this.applySdtDataset(wrapper, run.sdt);

wrapper.appendChild(img);
return wrapper;
return this.buildImageHyperlinkAnchor(wrapper, run.hyperlink, 'inline-block');
}

return img;
return this.buildImageHyperlinkAnchor(img, run.hyperlink, 'inline-block');
}

/**
Expand Down
70 changes: 70 additions & 0 deletions packages/layout-engine/pm-adapter/src/converters/image.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -753,5 +753,75 @@ describe('image converter', () => {
expect(result.flipH).toBeUndefined();
expect(result.flipV).toBeUndefined();
});

describe('hyperlink (DrawingML a:hlinkClick)', () => {
it('passes hyperlink url and tooltip from node attrs to ImageBlock', () => {
const node: PMNode = {
type: 'image',
attrs: {
src: 'image.png',
hyperlink: { url: 'https://example.com', tooltip: 'Visit us' },
},
};

const result = imageNodeToBlock(node, mockBlockIdGenerator, mockPositionMap) as ImageBlock;

expect(result.hyperlink).toEqual({ url: 'https://example.com', tooltip: 'Visit us' });
});

it('passes hyperlink url without tooltip', () => {
const node: PMNode = {
type: 'image',
attrs: {
src: 'image.png',
hyperlink: { url: 'https://example.com' },
},
};

const result = imageNodeToBlock(node, mockBlockIdGenerator, mockPositionMap) as ImageBlock;

expect(result.hyperlink).toEqual({ url: 'https://example.com' });
expect(result.hyperlink?.tooltip).toBeUndefined();
});

it('omits hyperlink when node attrs has no hyperlink', () => {
const node: PMNode = {
type: 'image',
attrs: { src: 'image.png' },
};

const result = imageNodeToBlock(node, mockBlockIdGenerator, mockPositionMap) as ImageBlock;

expect(result.hyperlink).toBeUndefined();
});

it('omits hyperlink when url is empty string', () => {
const node: PMNode = {
type: 'image',
attrs: {
src: 'image.png',
hyperlink: { url: '' },
},
};

const result = imageNodeToBlock(node, mockBlockIdGenerator, mockPositionMap) as ImageBlock;

expect(result.hyperlink).toBeUndefined();
});

it('omits hyperlink when hyperlink attr is null', () => {
const node: PMNode = {
type: 'image',
attrs: {
src: 'image.png',
hyperlink: null,
},
};

const result = imageNodeToBlock(node, mockBlockIdGenerator, mockPositionMap) as ImageBlock;

expect(result.hyperlink).toBeUndefined();
});
});
});
});
12 changes: 12 additions & 0 deletions packages/layout-engine/pm-adapter/src/converters/image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,18 @@ export function imageNodeToBlock(
...(rotation !== undefined && { rotation }),
...(flipH !== undefined && { flipH }),
...(flipV !== undefined && { flipV }),
// Image hyperlink from OOXML a:hlinkClick
...(() => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same parsing logic exists in inline-converters/image.ts:170. a small shared helper in utilities.ts would keep them in sync and also get rid of the IIFE here.

const hlAttr = isPlainObject(attrs.hyperlink) ? attrs.hyperlink : undefined;
if (hlAttr && typeof hlAttr.url === 'string' && hlAttr.url.trim()) {
const hyperlink: { url: string; tooltip?: string } = { url: hlAttr.url as string };
if (typeof hlAttr.tooltip === 'string' && (hlAttr.tooltip as string).trim()) {
hyperlink.tooltip = hlAttr.tooltip as string;
}
return { hyperlink };
}
return {};
})(),
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,15 @@ export function imageNodeToRun({ node, positions, sdtMetadata }: InlineConverter
};
}

// Image hyperlink from OOXML a:hlinkClick
const hlAttr = isPlainObject(attrs.hyperlink) ? attrs.hyperlink : undefined;
if (hlAttr && typeof hlAttr.url === 'string' && hlAttr.url.trim()) {
run.hyperlink = { url: hlAttr.url as string };
if (typeof hlAttr.tooltip === 'string' && (hlAttr.tooltip as string).trim()) {
run.hyperlink.tooltip = hlAttr.tooltip as string;
}
}

return run;
}

Expand Down
Loading