Skip to content

Make text selection more visible (bug 1879559)#20981

Open
wooorm wants to merge 16 commits intomozilla:masterfrom
wooorm:wooorm/hcm
Open

Make text selection more visible (bug 1879559)#20981
wooorm wants to merge 16 commits intomozilla:masterfrom
wooorm:wooorm/hcm

Conversation

@wooorm
Copy link
Copy Markdown
Contributor

@wooorm wooorm commented Mar 26, 2026

References https://bugzilla.mozilla.org/show_bug.cgi?id=1879559 (“In HCM, the text selection is barely visible”).

Continues work from @calixteman who had a partial patch.

This PR improves viewer text-selection highlighting by rendering selection shapes in the draw layer.

  • add selection overlay rendering in the draw layer; significant code relates to selections spanning multiple text layers/pages, and edges/end-of-content boundaries
  • clear selection on rotate/scale/scroll/spread changes

My main question is: how should it appear?
I don’t have access to the Figma file linked on bugzilla.

In the CSS (draw_layer-builder.css) there are 3 blocks:

  • default:
    screenshot-default
  • @supports for browsers supporting backdrop-filter:
    screenshot-backdrop-filter
  • forced-colors mode:
    screenshot-forced-colors

So it’s possible to design for those (or more).
Personally, the backdrop-filter: invert(1) is the most contrast, so perhaps it’s better to use something else as the default, and to use invert(1) if high contrast mode is used (maybe with a prefers-contrast media query instead)?

@codecov-commenter
Copy link
Copy Markdown

codecov-commenter commented Mar 26, 2026

Codecov Report

❌ Patch coverage is 35.97403% with 493 lines in your changes missing coverage. Please review.
✅ Project coverage is 63.86%. Comparing base (652700d) to head (b020c5e).
⚠️ Report is 33 commits behind head on master.

Files with missing lines Patch % Lines
src/display/draw_layer.js 28.14% 411 Missing ⚠️
src/display/filter_factory.js 60.89% 61 Missing ⚠️
src/display/display_utils.js 50.00% 21 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master   #20981      +/-   ##
==========================================
+ Coverage   63.32%   63.86%   +0.54%     
==========================================
  Files         177      177              
  Lines      124719   125704     +985     
==========================================
+ Hits        78980    80284    +1304     
+ Misses      45739    45420     -319     
Flag Coverage Δ
fonttest 8.05% <ø> (+0.41%) ⬆️
unittestcli 63.84% <35.97%> (+0.54%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@calixteman
Copy link
Copy Markdown
Contributor

My idea was to create a specific filter for mapping the foreground color (usually black) on a color A and the background one (usually white) on a color B.
So we could use a filter similar to the one we use for HCM:

addHCMFilter(fgColor, bgColor) {

and then we can map the two colors on their system counterparts (Highlight and HighlightText):
https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/system-color#:~:text=HighlightText

This way we've by default the same colors as the system has.
And for the future, we could use another kind of filter.

@wooorm
Copy link
Copy Markdown
Contributor Author

wooorm commented Mar 30, 2026

@calixteman I think you mean something like this patch.

This ignores the SelectedItem/SelectedItemText things from the bugzilla entry.

FF light:

screenshot-firefox-light

FF with custom colors in about:preferences:

screenshot-firefox-custom-colors

chrome:

chrome

Sadly, this does not work in Safari. It reports that the color filter is supported, but doesn’t, and no selection is thus visible

@wooorm
Copy link
Copy Markdown
Contributor Author

wooorm commented Mar 30, 2026

Alright, another small patch, now using highlight and highlighttext.

Importantly, though, is that Chrome and Safari do not use the user’s preferred theme colors for that. Probably for security, to prevent fingerprinting.

Safari also has a bug where it does not support SVG filters in backdropFilter. Maybe due to performance?

For that reason, I now gated this as a Firefox only thing. Other browsers get the normal CSS, which now uses a blue background, with some transparency.

It may be possible to gate this entire approach for FF only. Or for non-Safari. I opted not to. I think it keeps the code simpler.

@wooorm
Copy link
Copy Markdown
Contributor Author

wooorm commented Mar 30, 2026

Screenshots:

safari:

pr-safari

chrome:

pr-chrome

firefox automatic (system color)

pr-firefox-automatic-system-color

firefox custom colors:

pr-firefox-custom-colors

@calixteman
Copy link
Copy Markdown
Contributor

In HCM, the colors are wrong:
image

@wooorm
Copy link
Copy Markdown
Contributor Author

wooorm commented Mar 31, 2026

@calixteman a) how can I reproduce your example (it seems different than what I am testing wtih) and b) what would the expected output be?

@calixteman
Copy link
Copy Markdown
Contributor

@calixteman a) how can I reproduce your example (it seems different than what I am testing wtih) and b) what would the expected output be?

On Windows, switch to HCM (High Contrast Mode: it's one of the a11y settings) and switch to Night Sky theme.
It should be the opposite: purple background and dark grey foreground.
So it's probably just because you swapped some values.

@wooorm
Copy link
Copy Markdown
Contributor Author

wooorm commented Mar 31, 2026

I can do that:

Screenshot 2026-03-31 at 10 29 27 AM

I am noticing another problem.

When on macOS I set graphite as the Appearance -> “text highlight color”, which corresponds to Highlight in FF CSS, and in dark mode (meaning foreground, aka HighlightText, is white), the text then is barely visible:

Screenshot 2026-03-31 at 10 48 03 AM

Graphite in light mode (foreground is black):

Screenshot 2026-03-31 at 10 50 19 AM

Another color, orange in dark mode (foreground is white), for reference:

Screenshot 2026-03-31 at 10 49 01 AM

Orange in light mode (foreground is black):

Screenshot 2026-03-31 at 10 51 01 AM

We may want some a11y formula in the code to force a specific contrast.
Either here, or perhaps, that should be in FF itself?

@wooorm
Copy link
Copy Markdown
Contributor Author

wooorm commented Mar 31, 2026

@calixteman fascinatingly, it seems to be "Highlight" -> [ 255, 255, 255 ] and "HighlightText" -> [ 251, 251, 254 ].

That made me think, what about the alpha? Highlight -> rgba(255, 255, 255, 0.247) and HighlightText -> rgb(251, 251, 254)

@calixteman
Copy link
Copy Markdown
Contributor

IRL, do you have some alpha ? or do you wonder ?

@wooorm
Copy link
Copy Markdown
Contributor Author

wooorm commented Mar 31, 2026

Yes, I get alpha back from macOS.
Canvas/CanvasText/HighlightText probably never have that, which is why it wasn’t noticed before. But Highlight is different.
I am trying to account for that. Some things are looking better now. Still some bugs though.

@wooorm
Copy link
Copy Markdown
Contributor Author

wooorm commented Mar 31, 2026

The problem with these colors in dark mode + graphite (Highlight -> rgba(255, 255, 255, 0.247), HighlightText -> rgb(251, 251, 254)) is that they work expecting that there is some black background. But PDF.js shows a white page with black text.

In light mode, the PDF.js pages match with what the system expects.

Comment thread web/app.js Outdated
@wooorm
Copy link
Copy Markdown
Contributor Author

wooorm commented Apr 13, 2026

@calixteman re starting a selection and dragging outside of the page, that did already work on Chrome but not Firefox and Safari. I pushed a fix to make it work there too.

And, tests.

@calixteman
Copy link
Copy Markdown
Contributor

The draw layer creation depends on having a annotationEditorUIManager:

if (!annotationEditorUIManager) {

For protected documents, the UIManager isn't created.

Comment thread web/pdf_viewer.js Outdated
let hasViewerRange = false;
for (let i = 0, ii = selection.rangeCount; i < ii; i++) {
if (
this.viewer.contains(selection.getRangeAt(i).commonAncestorContainer)
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.

contains can potentially walk all the DOM. If the selection is in the text layer then selection.getRangeAt(i).commonAncestorContainer then is the text layer div so maybe we could have a fast path here. Generally, using closest should be faster.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

simplified the code!

Comment thread web/pdf_viewer.js
if (this._pagesRotation === rotation) {
return; // The rotation didn't change.
}
this.clearSelection();
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.

Can't we keep the selection ?
If not, it would be a "regression" (quoted because it's likely not a big deal).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I tried some things but it’s not easy. Though I don’t have the entire project in my head.
I think it means capturing all the selected text nodes (node index, textContent index * start, end), and then selecting all text nodes after draw and restoring.
Which sounds possible, renaming clearSelection to captureSelection / applySelection.

But I think it’s acceptable that a selection is lost when a user does something that like, changes the PDF (rotate) or goes somewhere different (another page).

Comment thread web/pdf_page_view.js
return;
}
this.drawLayer ||= new DrawLayerBuilder();
this.drawLayer ||= new DrawLayerBuilder({
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.

As said, previously it should be created whatever we've annotationEditorUIManager.

Copy link
Copy Markdown
Contributor Author

@wooorm wooorm Apr 16, 2026

Choose a reason for hiding this comment

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

Sorry i don’t quite understand, i think you mean b020c5e right?

I forced that branch and the new selection then still worked

Comment thread src/display/filter_factory.js Outdated
return [...rgb, 1];
}

const alpha = MathClamp(
Copy link
Copy Markdown
Contributor

@calixteman calixteman Apr 14, 2026

Choose a reason for hiding this comment

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

You can create a new function getRGBA similar to getRGB.
I mean add it in display_utils and avoid to call getRGB if we have "rgba(".

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Moved it!

Comment thread src/display/draw_layer.js Outdated
// Track mouse selection state to preserve selections during
// cross-boundary drags.
document.addEventListener(
"mousedown",
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.

Can't we use pointer instead of mouse ?

Copy link
Copy Markdown
Contributor Author

@wooorm wooorm Apr 16, 2026

Choose a reason for hiding this comment

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

yeah, done; I think that should work

Comment thread src/display/draw_layer.js
}
for (const textLayer of this.#textLayerSet) {
if (!selectedTextLayers.has(textLayer)) {
this.#cleanupTextLayerSelection(textLayer);
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.

On every selection change within the same text layer, we clean the selection div when we could just update the path in the svg element and it'd avoid needless DOM churn, right ? or am I missing something ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Hmm you mean, you are seeing this in the devtools right?
2 lines before your comment there’s a let i = 1. I don’t know why that was there.
I think the current layer (0) should be a selectedTextLayers, and not cleaned up. That also works. So I pushed that.

(If you mean even less cleaning up, say by setting d on path to something empty or so: possible too, but then you end up with more DOM nodes I guess)

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.

5 participants