Skip to content

fix: support hyperlinks on DrawingML images (a:hlinkClick)#2552

Open
iguit0 wants to merge 3 commits intosuperdoc-dev:mainfrom
iguit0:fix/drawingml-image-hyperlinks
Open

fix: support hyperlinks on DrawingML images (a:hlinkClick)#2552
iguit0 wants to merge 3 commits intosuperdoc-dev:mainfrom
iguit0:fix/drawingml-image-hyperlinks

Conversation

@iguit0
Copy link
Copy Markdown

@iguit0 iguit0 commented Mar 25, 2026

Summary

DOCX images with a:hlinkClick hyperlinks rendered correctly in Word but clicking them in SuperDoc did nothing. The importer already parsed the relationship and stored attrs.hyperlink on the ProseMirror image node — but the hyperlink was silently dropped further down the pipeline, so the renderer never had a URL to render.

Root cause: a three-step gap between import and rendering:

  1. ImageBlock / ImageRun contract types had no hyperlink field
  2. imageNodeToBlock and imageNodeToRun did not forward the attr
  3. DomPainter.renderImageFragment / renderImageRun never created an anchor

What Changed

  • Added hyperlink?: { url: string; tooltip?: string } to both ImageBlock and ImageRun in the shared contracts package
  • Forwarded the attr in imageNodeToBlock and imageNodeToRun, with the same validation (non-empty string URL) used for other optional fields
  • Added DomPainter.buildImageHyperlinkAnchor() — a private helper that wraps an image element in <a class="superdoc-link"> when a valid hyperlink is present. Uses sanitizeHref (same guard as text links) and applies target/rel for external URLs with accessibility attributes mirroring applyLinkAttributes
  • Called the helper in renderImageFragment (block images) and at all three return points of renderImageRun (inline images with/without clip-path wrapper)

The existing EditorInputManager click-delegation already detects a.superdoc-link on pointer-down and dispatches superdoc-link-click. LinkClickHandler then opens the URL in viewing mode — no changes needed in either component. Unlinked images are unaffected: buildImageHyperlinkAnchor is a no-op when hyperlink is undefined, and image resize/selection behavior is preserved because the fragment <div> (which carries data-image-metadata) remains the outermost element.

Test Plan

  • Added 5 unit tests to pm-adapter/src/converters/image.test.ts — verifies hyperlink is forwarded with tooltip, without tooltip, and is omitted for null/empty cases
  • Added 4 integration tests to painters/dom/src/index.test.ts — verifies linked images render as <a class="superdoc-link"> with correct href, tooltip becomes title, unlinked images have no anchor, and unsafe URLs (e.g. javascript:) are blocked
  • All existing tests pass: pm-adapter (1,669), painter-dom (824), super-editor (9,697)
  • Manual: open a DOCX with a hyperlinked image → image renders → clicking in viewing mode opens the correct URL

Fixes #2065

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7bea148896

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".


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 👍 / 👎.

Copy link
Copy Markdown
Contributor

@caio-pizzol caio-pizzol Mar 30, 2026

Choose a reason for hiding this comment

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

@iguit0 this is a good catch — clicking a linked image in edit mode opens the link instead of selecting the image. the link handler runs first and skips the image selection path entirely.

Copy link
Copy Markdown
Contributor

@caio-pizzol caio-pizzol left a comment

Choose a reason for hiding this comment

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

@iguit0 nice contribution, thanks for tackling this! the approach looks good — checked the spec and the dual placement, relationship handling, and tooltip are all done right.

one small thing on tooltip encoding (left a suggestion). one optional cleanup on shared code.

tests: block-level is well covered. the inline paths (imageNodeToRun and the 3 renderer return paths) don't have hyperlink tests yet — would be great to add those.

left a few inline comments.

@PeterHollens
Copy link
Copy Markdown

Nice work on this. I independently implemented the same fix on my fork while digging through #2065, and I converged on the same root cause.

One thing I found useful was reusing the existing shared link pipeline ( / / ) rather than introducing an image-specific hyperlink path, since it automatically inherits the current blocked-link handling, dataset propagation, tooltip accessibility, and other shared link behavior. I also added explicit inline-image coverage in both the PM adapter and DOM painter tests, since the inline paths were the main gap I noticed while reviewing.

If any of that is helpful, happy to share or compare against my branch here: https://github.com/PeterHollens/superdoc/tree/feature/drawingml-image-hyperlinks

@PeterHollens
Copy link
Copy Markdown

Nice work on this. I independently implemented the same fix on my fork while
digging through #2065, and I converged on the same root cause.

One thing I found useful was reusing the existing shared link pipeline
(FlowRunLink / buildFlowRunLink / wrapElementWithLink) rather than
introducing an image-specific hyperlink path, since it automatically inherits
the current blocked-link handling, dataset propagation, tooltip accessibility,
and other shared link behavior. I also added explicit inline-image coverage in
both the PM adapter and DOM painter tests, since the inline paths were the main
gap I noticed while reviewing.

If any of that is helpful, happy to share or compare against my branch here:
https://github.com/PeterHollens/superdoc/tree/feature/drawingml-image-hyperlinks

@iguit0
Copy link
Copy Markdown
Author

iguit0 commented Mar 30, 2026

Nice work on this. I independently implemented the same fix on my fork while digging through #2065, and I converged on the same root cause.

One thing I found useful was reusing the existing shared link pipeline (FlowRunLink / buildFlowRunLink / wrapElementWithLink) rather than introducing an image-specific hyperlink path, since it automatically inherits the current blocked-link handling, dataset propagation, tooltip accessibility, and other shared link behavior. I also added explicit inline-image coverage in both the PM adapter and DOM painter tests, since the inline paths were the main gap I noticed while reviewing.

If any of that is helpful, happy to share or compare against my branch here: https://github.com/PeterHollens/superdoc/tree/feature/drawingml-image-hyperlinks

I diffed your branch against my implementation and we converged on the same root cause, but your version is cleaner.

Reusing the shared link pipeline (FlowRunLink / buildFlowRunLink / the existing DOM wrapping) is the better call than keeping a separate image.hyperlink path around. It means image links inherit the normal blocked-link behavior, target / rel handling, dataset propagation, tooltip accessibility, and whatever else we add to link behavior later. My version currently reimplements part of that in an image-specific helper. It works, but it's the worse abstraction.

You were also right about the inline gap. That was the missing case. The fix gets a lot more solid once you add explicit PM adapter and DOM painter coverage for the inline-image path.

This is my first issue, so I'm not totally sure what's the best approach here 🤔

@caio-pizzol
Copy link
Copy Markdown
Contributor

Hey @iguit0, this PR has had no activity for 8 days.

@iguit0 iguit0 force-pushed the fix/drawingml-image-hyperlinks branch from e198d13 to eb51d4f Compare April 2, 2026 22:15
@iguit0
Copy link
Copy Markdown
Author

iguit0 commented Apr 2, 2026

Hey @iguit0, this PR has had no activity for 8 days.

Hey! Apologies for the delay. I have now resolved the merge conflicts and incorporated your suggestions. Please let me know if anything else needs adjustment.

Copy link
Copy Markdown
Contributor

@caio-pizzol caio-pizzol left a comment

Choose a reason for hiding this comment

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

@iguit0 prior feedback is all addressed, nice.

one thing: linked images work in viewing mode but not in editing mode — you can select them but can't follow the link. left an inline comment with details.

rendering looks good, tested with a Word file with different hyperlink setups and everything renders correctly.

* 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.
Copy link
Copy Markdown
Contributor

@caio-pizzol caio-pizzol Apr 3, 2026

Choose a reason for hiding this comment

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

editing mode: clicking a linked image selects it (resize handles show up) but doesn't open the link. ctrl+click doesn't work either (in Word ctrl+click or just click will open img URL straight away - no resize selection appears)
image

tooltip: shows "Image" instead of the actual tooltip URL (e.g. "https://superdoc.dev").
image

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

The editing mode issue was happening because linked images were still going through the text-link popover flow, which only knows how to resolve ProseMirror link marks. Image hyperlinks are stored on the image node attrs instead, so the click ended up selecting the image rather than following the link.

I changed that path so linked images open/navigate directly in editing mode, same as viewing mode.

The tooltip issue was happening because the wrapped image was still carrying its own title (often "Image"), which could win over the link tooltip on hover. I changed linked image rendering to prefer the hyperlink tooltip, fall back to the hyperlink URL when no tooltip is set, and clear the nested image title so the correct text is shown.

Added tests for:

  • linked images opening directly in editing mode
  • linked image anchor navigation in editing mode
  • inline linked images using hyperlink tooltip/URL instead of the image title

Also verified manually with the repro doc: click now follows the image link in editing mode, no resize selection appears on that click, and the hover tooltip is correct.

SCR-20260404-pnds

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature: Support hyperlink click on DrawingML images

3 participants