From eb51d4f6493ade93e180e84d9d93712e94622cda Mon Sep 17 00:00:00 2001 From: Igor Alves Date: Wed, 25 Mar 2026 07:20:08 -0300 Subject: [PATCH 1/3] fix: support hyperlinks on DrawingML images (a:hlinkClick) --- packages/layout-engine/contracts/src/index.ts | 4 + .../painters/dom/src/index.test.ts | 129 ++++++++++++++++++ .../painters/dom/src/renderer.ts | 71 ++++++++-- .../pm-adapter/src/converters/image.test.ts | 70 ++++++++++ .../pm-adapter/src/converters/image.ts | 12 ++ .../src/converters/inline-converters/image.ts | 9 ++ 6 files changed, 287 insertions(+), 8 deletions(-) diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index 1464a2d1a5..4baa31f965 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -361,6 +361,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 = { @@ -635,6 +637,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'; diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index 40bd30b39e..88a5ebf935 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -7507,6 +7507,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, + 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 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', () => { diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 1d01335d31..ffdbf503ab 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -3668,7 +3668,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) { @@ -3677,11 +3680,63 @@ export class DomPainter { } } - private renderDrawingFragment( - fragment: DrawingFragment, - context: FragmentRenderContext, - resolvedItem?: ResolvedDrawingItem, + /** + * Optionally wrap an image element in an anchor for DrawingML hyperlinks (a:hlinkClick). + * + * When `hyperlink` is present and its URL passes sanitization, returns an + * `` 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'); + + if (sanitized.protocol === 'http' || sanitized.protocol === 'https') { + anchor.target = '_blank'; + anchor.rel = 'noopener noreferrer'; + } + if (hyperlink.tooltip) { + anchor.title = hyperlink.tooltip; + } + + // 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, resolvedItem?: ResolvedDrawingItem,): HTMLElement { try { // Use pre-extracted block from resolved item; fall back to blockLookup when resolved item // is a legacy ResolvedFragmentItem without the block field. @@ -5246,7 +5301,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) @@ -5301,10 +5356,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'); } /** diff --git a/packages/layout-engine/pm-adapter/src/converters/image.test.ts b/packages/layout-engine/pm-adapter/src/converters/image.test.ts index 5fdda71959..f11582d8cc 100644 --- a/packages/layout-engine/pm-adapter/src/converters/image.test.ts +++ b/packages/layout-engine/pm-adapter/src/converters/image.test.ts @@ -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(); + }); + }); }); }); diff --git a/packages/layout-engine/pm-adapter/src/converters/image.ts b/packages/layout-engine/pm-adapter/src/converters/image.ts index b83f99af89..50cc893732 100644 --- a/packages/layout-engine/pm-adapter/src/converters/image.ts +++ b/packages/layout-engine/pm-adapter/src/converters/image.ts @@ -313,6 +313,18 @@ export function imageNodeToBlock( ...(rotation !== undefined && { rotation }), ...(flipH !== undefined && { flipH }), ...(flipV !== undefined && { flipV }), + // Image hyperlink from OOXML a:hlinkClick + ...(() => { + 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 {}; + })(), }; } diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/image.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/image.ts index efdcddc873..eda8f108f1 100644 --- a/packages/layout-engine/pm-adapter/src/converters/inline-converters/image.ts +++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/image.ts @@ -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; } From e511244fd29cabeda94963919c66b874041b6298 Mon Sep 17 00:00:00 2001 From: Igor Alves Date: Thu, 2 Apr 2026 19:18:33 -0300 Subject: [PATCH 2/3] fix: normalize image hyperlink tool --- .../painters/dom/src/index.test.ts | 128 ++++++++++++++++-- .../painters/dom/src/renderer.ts | 11 +- .../pm-adapter/src/converters/image.ts | 22 ++- .../src/converters/inline-converters/image.ts | 12 +- .../src/converters/paragraph.test.ts | 37 +++++ .../layout-engine/pm-adapter/src/utilities.ts | 18 +++ 6 files changed, 190 insertions(+), 38 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index 88a5ebf935..117b370585 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -5996,6 +5996,58 @@ describe('DomPainter', () => { }); describe('renderImageRun (inline image runs)', () => { + const renderInlineImageRun = ( + run: Extract['runs'][number], + lineWidth = 100, + lineHeight = 100, + ) => { + const imageBlock: FlowBlock = { + kind: 'paragraph', + id: 'img-block', + runs: [run], + }; + + const imageMeasure: Measure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 0, + width: lineWidth, + ascent: lineHeight, + descent: 0, + lineHeight, + }, + ], + totalHeight: lineHeight, + }; + + const imageLayout: Layout = { + pageSize: { w: 400, h: 500 }, + pages: [ + { + number: 1, + fragments: [ + { + kind: 'para', + blockId: 'img-block', + fromLine: 0, + toLine: 1, + x: 0, + y: 0, + width: lineWidth, + }, + ], + }, + ], + }; + + const painter = createDomPainter({ blocks: [imageBlock], measures: [imageMeasure] }); + painter.paint(imageLayout, mount); + }; + it('renders img element with valid data URL', () => { const imageBlock: FlowBlock = { kind: 'paragraph', @@ -6464,6 +6516,64 @@ describe('DomPainter', () => { expect(img).toBeNull(); }); + it('wraps linked inline image in anchor without clipPath', () => { + renderInlineImageRun({ + kind: 'image', + src: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', + width: 100, + height: 100, + hyperlink: { url: 'https://example.com/inline', tooltip: ' Inline tooltip ' }, + }); + + const anchor = mount.querySelector('a.superdoc-link') as HTMLAnchorElement | null; + expect(anchor).toBeTruthy(); + expect(anchor?.href).toBe('https://example.com/inline'); + expect(anchor?.title).toBe('Inline tooltip'); + expect(anchor?.firstElementChild?.tagName).toBe('IMG'); + }); + + it('wraps linked inline image clip wrapper in anchor when clipPath uses positive dimensions', () => { + renderInlineImageRun( + { + kind: 'image', + src: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', + width: 80, + height: 60, + clipPath: 'inset(10% 20% 30% 40%)', + hyperlink: { url: 'https://example.com/clip-wrapper' }, + }, + 80, + 60, + ); + + const anchor = mount.querySelector('a.superdoc-link') as HTMLAnchorElement | null; + expect(anchor).toBeTruthy(); + expect(anchor?.querySelector('.superdoc-inline-image-clip-wrapper')).toBeTruthy(); + expect(anchor?.querySelector('.superdoc-inline-image-clip-wrapper img')).toBeTruthy(); + }); + + it('wraps linked inline image clip wrapper in anchor when clipPath falls back to wrapper return path', () => { + renderInlineImageRun( + { + kind: 'image', + src: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', + width: 0, + height: 60, + clipPath: 'inset(10% 20% 30% 40%)', + hyperlink: { url: 'https://example.com/fallback-wrapper' }, + }, + 1, + 60, + ); + + const anchor = mount.querySelector('a.superdoc-link') as HTMLAnchorElement | null; + const wrapper = anchor?.querySelector('.superdoc-inline-image-clip-wrapper') as HTMLElement | null; + expect(anchor).toBeTruthy(); + expect(wrapper).toBeTruthy(); + expect(wrapper?.style.width).toBe('0px'); + expect(wrapper?.querySelector('img')).toBeTruthy(); + }); + it('renders cropped inline image with clipPath in wrapper (overflow hidden, img with clip-path and transform)', () => { const clipPath = 'inset(10% 20% 30% 40%)'; const imageBlock: FlowBlock = { @@ -7519,18 +7629,6 @@ describe('ImageFragment (block-level images)', () => { ...(hyperlink ? { hyperlink } : {}), }; const measure: Measure = { kind: 'image', width: 100, height: 50 }; - const fragment = { - kind: 'image' as const, - blockId: 'linked-img', - x: 20, - 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] }); }; @@ -7567,14 +7665,14 @@ describe('ImageFragment (block-level images)', () => { expect(anchor?.getAttribute('role')).toBe('link'); }); - it('sets tooltip as title attribute when present', () => { + it('encodes tooltip before setting title attribute', () => { 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' }, + hyperlink: { url: 'https://example.com', tooltip: ` ${'x'.repeat(600)} ` }, }; 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 }; @@ -7586,7 +7684,7 @@ describe('ImageFragment (block-level images)', () => { painter.paint(layout, mount); const anchor = mount.querySelector('a.superdoc-link') as HTMLAnchorElement | null; - expect(anchor?.title).toBe('Go here'); + expect(anchor?.title).toBe('x'.repeat(500)); }); it('does NOT wrap unlinked image in anchor', () => { diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index ffdbf503ab..41b459b25e 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -3715,7 +3715,10 @@ export class DomPainter { anchor.rel = 'noopener noreferrer'; } if (hyperlink.tooltip) { - anchor.title = 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) @@ -3736,7 +3739,11 @@ export class DomPainter { return anchor; } - private renderDrawingFragment(fragment: DrawingFragment, context: FragmentRenderContext, resolvedItem?: ResolvedDrawingItem,): HTMLElement { + private renderDrawingFragment( + fragment: DrawingFragment, + context: FragmentRenderContext, + resolvedItem?: ResolvedDrawingItem, + ): HTMLElement { try { // Use pre-extracted block from resolved item; fall back to blockLookup when resolved item // is a legacy ResolvedFragmentItem without the block field. diff --git a/packages/layout-engine/pm-adapter/src/converters/image.ts b/packages/layout-engine/pm-adapter/src/converters/image.ts index 50cc893732..dc49f5a900 100644 --- a/packages/layout-engine/pm-adapter/src/converters/image.ts +++ b/packages/layout-engine/pm-adapter/src/converters/image.ts @@ -8,7 +8,13 @@ import type { ImageBlock, BoxSpacing, ImageAnchor } from '@superdoc/contracts'; import type { PMNode, BlockIdGenerator, PositionMap, NodeHandlerContext, TrackedChangesConfig } from '../types.js'; import { collectTrackedChangeFromMarks } from '../marks/index.js'; import { shouldHideTrackedNode, annotateBlockWithTrackedChange } from '../tracked-changes.js'; -import { isFiniteNumber, pickNumber, normalizeZIndex, resolveFloatingZIndex } from '../utilities.js'; +import { + isFiniteNumber, + pickNumber, + normalizeZIndex, + resolveFloatingZIndex, + readImageHyperlink, +} from '../utilities.js'; // ============================================================================ // Constants @@ -280,6 +286,7 @@ export function imageNodeToBlock( const rotation = typeof transformData?.rotation === 'number' ? transformData.rotation : undefined; const flipH = typeof transformData?.horizontalFlip === 'boolean' ? transformData.horizontalFlip : undefined; const flipV = typeof transformData?.verticalFlip === 'boolean' ? transformData.verticalFlip : undefined; + const hyperlink = readImageHyperlink(attrs.hyperlink); return { kind: 'image', id: nextBlockId('image'), @@ -313,18 +320,7 @@ export function imageNodeToBlock( ...(rotation !== undefined && { rotation }), ...(flipH !== undefined && { flipH }), ...(flipV !== undefined && { flipV }), - // Image hyperlink from OOXML a:hlinkClick - ...(() => { - 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 {}; - })(), + ...(hyperlink ? { hyperlink } : {}), }; } diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/image.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/image.ts index eda8f108f1..2c9a4b5346 100644 --- a/packages/layout-engine/pm-adapter/src/converters/inline-converters/image.ts +++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/image.ts @@ -1,6 +1,6 @@ import type { ImageRun } from '@superdoc/contracts'; import type { PMNode } from '../../types.js'; -import { pickNumber, isPlainObject } from '../../utilities.js'; +import { pickNumber, isPlainObject, readImageHyperlink } from '../../utilities.js'; import { type InlineConverterParams, NotInlineNodeError } from './common.js'; /** @@ -166,13 +166,9 @@ 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; - } + const hyperlink = readImageHyperlink(attrs.hyperlink); + if (hyperlink) { + run.hyperlink = hyperlink; } return run; diff --git a/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts b/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts index 05ee691b11..5b0681a31a 100644 --- a/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts +++ b/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts @@ -3655,6 +3655,43 @@ describe('paragraph converters', () => { expect(result?.pmEnd).toBe(43); }); + it('includes hyperlink metadata when present', () => { + const node: PMNode = { + type: 'image', + attrs: { + src: 'image.png', + inline: true, + hyperlink: { + url: ' https://example.com/image-link ', + tooltip: ' Image tooltip ', + }, + }, + }; + + const result = imageNodeToRun(buildImageParams(node, positions)); + expect(result?.hyperlink).toEqual({ + url: 'https://example.com/image-link', + tooltip: 'Image tooltip', + }); + }); + + it('omits hyperlink metadata when URL is empty', () => { + const node: PMNode = { + type: 'image', + attrs: { + src: 'image.png', + inline: true, + hyperlink: { + url: ' ', + tooltip: 'Image tooltip', + }, + }, + }; + + const result = imageNodeToRun(buildImageParams(node, positions)); + expect(result?.hyperlink).toBeUndefined(); + }); + it('omits PM positions when not in map', () => { const node: PMNode = { type: 'image', diff --git a/packages/layout-engine/pm-adapter/src/utilities.ts b/packages/layout-engine/pm-adapter/src/utilities.ts index d768b85671..6fd5632572 100644 --- a/packages/layout-engine/pm-adapter/src/utilities.ts +++ b/packages/layout-engine/pm-adapter/src/utilities.ts @@ -224,6 +224,24 @@ export const normalizeString = (value: unknown): string | undefined => { return trimmed || undefined; }; +/** + * Extracts image hyperlink metadata from a ProseMirror node attribute object. + * + * Accepts the raw `attrs.hyperlink` value and normalizes it to the shared + * `{ url, tooltip? }` shape used by ImageBlock and ImageRun. Empty or invalid + * URLs are dropped, and tooltip text is trimmed before inclusion. + */ +export const readImageHyperlink = (value: unknown): { url: string; tooltip?: string } | undefined => { + const hyperlink = isPlainObject(value) ? value : undefined; + const url = normalizeString(hyperlink?.url); + if (!url) { + return undefined; + } + + const tooltip = normalizeString(hyperlink?.tooltip); + return tooltip ? { url, tooltip } : { url }; +}; + /** * Coerces a value to a number if possible. * From 4646773702b04d7b2783509afaf3d70a202341a1 Mon Sep 17 00:00:00 2001 From: Igor Alves Date: Sat, 4 Apr 2026 17:47:50 -0300 Subject: [PATCH 3/3] fix: handle image hyperlinks correctly in editing mode --- apps/create/src/index.ts | 7 +- .../painters/dom/src/index.test.ts | 20 ++++ .../painters/dom/src/renderer.ts | 15 ++- .../src/converters/math-block.test.ts | 20 ++-- .../src/converters/math-constants.test.ts | 92 +++++++++++-------- .../src/converters/math-constants.ts | 10 +- .../link-click/LinkClickHandler.test.js | 69 ++++++++++++++ .../link-click/LinkClickHandler.vue | 8 ++ 8 files changed, 176 insertions(+), 65 deletions(-) diff --git a/apps/create/src/index.ts b/apps/create/src/index.ts index e6c8f6c688..32b1c5449b 100644 --- a/apps/create/src/index.ts +++ b/apps/create/src/index.ts @@ -56,12 +56,7 @@ async function main() { let agentTool = 'claude-code'; if (!nonInteractive) { - const agentIdx = await select('Which agent tool do you use?', [ - 'Claude Code', - 'Cursor', - 'Windsurf', - 'None / Skip', - ]); + const agentIdx = await select('Which agent tool do you use?', ['Claude Code', 'Cursor', 'Windsurf', 'None / Skip']); agentTool = ['claude-code', 'cursor', 'windsurf', 'none'][agentIdx]; setupMcp = agentTool !== 'none'; } diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index 117b370585..fb835f055f 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -6522,16 +6522,36 @@ describe('DomPainter', () => { src: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', width: 100, height: 100, + title: 'Image', hyperlink: { url: 'https://example.com/inline', tooltip: ' Inline tooltip ' }, }); const anchor = mount.querySelector('a.superdoc-link') as HTMLAnchorElement | null; + const img = anchor?.querySelector('img') as HTMLImageElement | null; expect(anchor).toBeTruthy(); expect(anchor?.href).toBe('https://example.com/inline'); expect(anchor?.title).toBe('Inline tooltip'); + expect(img?.getAttribute('title')).toBeNull(); expect(anchor?.firstElementChild?.tagName).toBe('IMG'); }); + it('falls back to hyperlink URL for linked inline image title', () => { + renderInlineImageRun({ + kind: 'image', + src: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', + width: 100, + height: 100, + title: 'Image', + hyperlink: { url: 'https://superdoc.dev' }, + }); + + const anchor = mount.querySelector('a.superdoc-link') as HTMLAnchorElement | null; + const img = anchor?.querySelector('img') as HTMLImageElement | null; + expect(anchor).toBeTruthy(); + expect(anchor?.title).toBe('https://superdoc.dev'); + expect(img?.getAttribute('title')).toBeNull(); + }); + it('wraps linked inline image clip wrapper in anchor when clipPath uses positive dimensions', () => { renderInlineImageRun( { diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 41b459b25e..b48470709d 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -3714,11 +3714,16 @@ export class DomPainter { anchor.target = '_blank'; anchor.rel = 'noopener noreferrer'; } - if (hyperlink.tooltip) { - const tooltipResult = encodeTooltip(hyperlink.tooltip); - if (tooltipResult?.text) { - anchor.title = tooltipResult.text; - } + + const tooltipSource = + typeof hyperlink.tooltip === 'string' && hyperlink.tooltip.trim().length > 0 ? hyperlink.tooltip : hyperlink.url; + const tooltipResult = encodeTooltip(tooltipSource); + if (tooltipResult?.text) { + anchor.title = tooltipResult.text; + } + + for (const titledElement of [imageEl, ...Array.from(imageEl.querySelectorAll('[title]'))]) { + titledElement.removeAttribute('title'); } // Accessibility: explicit role and keyboard focus (mirrors applyLinkAttributes for text links) diff --git a/packages/layout-engine/pm-adapter/src/converters/math-block.test.ts b/packages/layout-engine/pm-adapter/src/converters/math-block.test.ts index 8300c48946..f07278469b 100644 --- a/packages/layout-engine/pm-adapter/src/converters/math-block.test.ts +++ b/packages/layout-engine/pm-adapter/src/converters/math-block.test.ts @@ -78,16 +78,20 @@ describe('handleMathBlockNode', () => { const { context, blocks } = makeContext(); const fractionXml = { name: 'm:oMathPara', - elements: [{ - name: 'm:oMath', - elements: [{ - name: 'm:f', + elements: [ + { + name: 'm:oMath', elements: [ - { name: 'm:num', elements: [{ name: 'm:r' }] }, - { name: 'm:den', elements: [{ name: 'm:r' }] }, + { + name: 'm:f', + elements: [ + { name: 'm:num', elements: [{ name: 'm:r' }] }, + { name: 'm:den', elements: [{ name: 'm:r' }] }, + ], + }, ], - }], - }], + }, + ], }; handleMathBlockNode(makeNode({ textContent: 'ab', originalXml: fractionXml }) as any, context); const run = (blocks[0] as ParagraphBlock).runs[0] as MathRun; diff --git a/packages/layout-engine/pm-adapter/src/converters/math-constants.test.ts b/packages/layout-engine/pm-adapter/src/converters/math-constants.test.ts index 29c87b95d3..45a7e44a4a 100644 --- a/packages/layout-engine/pm-adapter/src/converters/math-constants.test.ts +++ b/packages/layout-engine/pm-adapter/src/converters/math-constants.test.ts @@ -19,13 +19,15 @@ describe('estimateMathDimensions', () => { it('increases height for fractions (m:f)', () => { const omml = { name: 'm:oMath', - elements: [{ - name: 'm:f', - elements: [ - { name: 'm:num', elements: [{ name: 'm:r' }] }, - { name: 'm:den', elements: [{ name: 'm:r' }] }, - ], - }], + elements: [ + { + name: 'm:f', + elements: [ + { name: 'm:num', elements: [{ name: 'm:r' }] }, + { name: 'm:den', elements: [{ name: 'm:r' }] }, + ], + }, + ], }; const { height } = estimateMathDimensions('ab', omml); expect(height).toBeGreaterThan(MATH_DEFAULT_HEIGHT); @@ -34,10 +36,12 @@ describe('estimateMathDimensions', () => { it('increases height for bar elements (m:bar)', () => { const omml = { name: 'm:oMath', - elements: [{ - name: 'm:bar', - elements: [{ name: 'm:e', elements: [{ name: 'm:r' }] }], - }], + elements: [ + { + name: 'm:bar', + elements: [{ name: 'm:e', elements: [{ name: 'm:r' }] }], + }, + ], }; const { height } = estimateMathDimensions('x', omml); expect(height).toBeGreaterThan(MATH_DEFAULT_HEIGHT); @@ -46,29 +50,37 @@ describe('estimateMathDimensions', () => { it('stacks multipliers for nested elements (bar over fraction)', () => { const omml = { name: 'm:oMath', - elements: [{ - name: 'm:bar', - elements: [{ - name: 'm:e', - elements: [{ - name: 'm:f', - elements: [ - { name: 'm:num', elements: [{ name: 'm:r' }] }, - { name: 'm:den', elements: [{ name: 'm:r' }] }, - ], - }], - }], - }], + elements: [ + { + name: 'm:bar', + elements: [ + { + name: 'm:e', + elements: [ + { + name: 'm:f', + elements: [ + { name: 'm:num', elements: [{ name: 'm:r' }] }, + { name: 'm:den', elements: [{ name: 'm:r' }] }, + ], + }, + ], + }, + ], + }, + ], }; const fractionOnly = { name: 'm:oMath', - elements: [{ - name: 'm:f', - elements: [ - { name: 'm:num', elements: [{ name: 'm:r' }] }, - { name: 'm:den', elements: [{ name: 'm:r' }] }, - ], - }], + elements: [ + { + name: 'm:f', + elements: [ + { name: 'm:num', elements: [{ name: 'm:r' }] }, + { name: 'm:den', elements: [{ name: 'm:r' }] }, + ], + }, + ], }; const barOverFraction = estimateMathDimensions('ab', omml).height; const fractionHeight = estimateMathDimensions('ab', fractionOnly).height; @@ -78,14 +90,16 @@ describe('estimateMathDimensions', () => { it('scales height with equation array row count', () => { const omml = { name: 'm:oMathPara', - elements: [{ - name: 'm:eqArr', - elements: [ - { name: 'm:e', elements: [{ name: 'm:r' }] }, - { name: 'm:e', elements: [{ name: 'm:r' }] }, - { name: 'm:e', elements: [{ name: 'm:r' }] }, - ], - }], + elements: [ + { + name: 'm:eqArr', + elements: [ + { name: 'm:e', elements: [{ name: 'm:r' }] }, + { name: 'm:e', elements: [{ name: 'm:r' }] }, + { name: 'm:e', elements: [{ name: 'm:r' }] }, + ], + }, + ], }; const { height } = estimateMathDimensions('abc', omml); // 3 rows = 2 additional rows worth of height diff --git a/packages/layout-engine/pm-adapter/src/converters/math-constants.ts b/packages/layout-engine/pm-adapter/src/converters/math-constants.ts index fe11db577d..74e5a430e0 100644 --- a/packages/layout-engine/pm-adapter/src/converters/math-constants.ts +++ b/packages/layout-engine/pm-adapter/src/converters/math-constants.ts @@ -28,9 +28,8 @@ const VERTICAL_ELEMENTS: Record = { /** Count elements in an m:eqArr (equation array) for row-based height. */ function countEqArrayRows(node: { elements?: unknown[] }): number { if (!Array.isArray(node.elements)) return 1; - return node.elements.filter( - (el: unknown) => el && typeof el === 'object' && (el as { name?: string }).name === 'm:e', - ).length; + return node.elements.filter((el: unknown) => el && typeof el === 'object' && (el as { name?: string }).name === 'm:e') + .length; } /** @@ -74,10 +73,7 @@ function estimateHeightMultiplier(node: unknown): number { * When ommlJson is provided, the height scales based on vertical stacking * (fractions, bars, limits, equation arrays). */ -export function estimateMathDimensions( - textContent: string, - ommlJson?: unknown, -): { width: number; height: number } { +export function estimateMathDimensions(textContent: string, ommlJson?: unknown): { width: number; height: number } { const multiplier = ommlJson ? estimateHeightMultiplier(ommlJson) : 0; return { width: Math.max(textContent.length * MATH_CHAR_WIDTH, MATH_MIN_WIDTH), diff --git a/packages/super-editor/src/editors/v1/components/link-click/LinkClickHandler.test.js b/packages/super-editor/src/editors/v1/components/link-click/LinkClickHandler.test.js index ed65e4fdc9..8d37faf8c1 100644 --- a/packages/super-editor/src/editors/v1/components/link-click/LinkClickHandler.test.js +++ b/packages/super-editor/src/editors/v1/components/link-click/LinkClickHandler.test.js @@ -622,6 +622,75 @@ describe('LinkClickHandler', () => { expect(mockEditor.dispatch).toHaveBeenCalledTimes(1); }); + it('should open linked images directly in editing mode instead of showing the popover', async () => { + mount(LinkClickHandler, { + props: { + editor: mockEditor, + openPopover: mockOpenPopover, + closePopover: mockClosePopover, + }, + }); + + const linkElement = document.createElement('a'); + linkElement.appendChild(document.createElement('img')); + + const linkClickEvent = new CustomEvent('superdoc-link-click', { + bubbles: true, + composed: true, + detail: { + href: 'https://example.com/image-link', + 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/image-link', '_blank', 'noopener,noreferrer'); + expect(mockOpenPopover).not.toHaveBeenCalled(); + expect(mockEditor.dispatch).not.toHaveBeenCalled(); + expect(moveCursorToMouseEvent).not.toHaveBeenCalled(); + expect(selectionHasNodeOrMark).not.toHaveBeenCalled(); + }); + + it('should navigate linked image anchors directly in editing mode', async () => { + mount(LinkClickHandler, { + props: { + editor: mockEditor, + openPopover: mockOpenPopover, + closePopover: mockClosePopover, + }, + }); + + const linkElement = document.createElement('a'); + linkElement.appendChild(document.createElement('img')); + + const linkClickEvent = new CustomEvent('superdoc-link-click', { + bubbles: true, + composed: true, + detail: { + href: '#linked-image-anchor', + element: linkElement, + clientX: 250, + clientY: 250, + }, + }); + + mockSurfaceElement.dispatchEvent(linkClickEvent); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(mockPresentationEditor.goToAnchor).toHaveBeenCalledWith('#linked-image-anchor'); + expect(windowOpenSpy).not.toHaveBeenCalled(); + expect(mockOpenPopover).not.toHaveBeenCalled(); + expect(mockEditor.dispatch).not.toHaveBeenCalled(); + expect(moveCursorToMouseEvent).not.toHaveBeenCalled(); + expect(selectionHasNodeOrMark).not.toHaveBeenCalled(); + }); + it('should open external hyperlinks in viewing mode instead of showing the popover', async () => { mockEditor.options.documentMode = 'viewing'; diff --git a/packages/super-editor/src/editors/v1/components/link-click/LinkClickHandler.vue b/packages/super-editor/src/editors/v1/components/link-click/LinkClickHandler.vue index 414cefffe7..e29ecf4dbf 100644 --- a/packages/super-editor/src/editors/v1/components/link-click/LinkClickHandler.vue +++ b/packages/super-editor/src/editors/v1/components/link-click/LinkClickHandler.vue @@ -322,6 +322,9 @@ const openLinkInViewingMode = (detail) => { window.open(href, target, features || undefined); }; +const isImageLinkClick = (detail) => + detail.element instanceof HTMLElement && detail.element.querySelector('img') !== null; + // ─── Link click handler ───────────────────────────────────────────────────── /** @@ -359,6 +362,11 @@ const handleLinkClick = (event) => { return; } + if (isImageLinkClick(detail)) { + openLinkInViewingMode(detail); + return; + } + const surface = getEditorSurfaceElement(props.editor); if (!surface) { return;