Skip to content

Conversation

rsimon
Copy link
Member

@rsimon rsimon commented Sep 4, 2025

In this PR

This PR makes changes to the behavior for handling clicks outside the annotatable area (#133).

  • Any area outside of the container is now automatically considered not-annotatable - no need to set the CSS class.
  • By default, a single click outside the container will not discard the annotation selection.
  • However, there is now a new init arg dismissOnClick - setting this arg to true will change the behavior and discard selections if the user clicks outside the annotatable area

Note: this PR also includes a small performance improvement: redraw() calls from the mutation observer are now debounced. (Mostly because React seems to fire lots of small incremental mutations.)

@oleksandr-danylchenko and @SovanramyVar4D: I think this should do the trick and (hopefully...) not introduce any unwanted side effects. I'd appreciate it if you could take a quick look and perhaps test in your environments!

Comment on lines +190 to +192
if (isNotAnnotatable(container, evt.target as Node)) {
if (options.dismissOnClickOutside)
selection.clear();
Copy link
Contributor

@oleksandr-danylchenko oleksandr-danylchenko Sep 4, 2025

Choose a reason for hiding this comment

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

I'm not sure whether the dismissOnClickOutside name is strictly applicable here. With that option, any pointerup event over a non-annotatable element will cause the selection to be dismissed even when it's not "outside" the container.

Copy link
Contributor

Choose a reason for hiding this comment

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

It's worth mentioning that the isLeftClick isn't strictly correct either 🤔
It only captures whether a user's interaction was started with the "LMB", but doesn't check whether it's a "click":

CleanShot.2025-09-04.at.12.11.03.mp4

Copy link
Member Author

Choose a reason for hiding this comment

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

I guess that depends on your definition of "outside" :-) I'd argue that this init option should simply cover any area that's outside the annotatable regions – including anything outside the container, but also anything inside the container that's marked as not-annotatable (which I'd consider "outside the annotatable area").

I wouldn't want to over-complicate at this point, as long as the behavior makes sense. With this PR, you'd either:

  • discard the annotation by clicking anywhere, or
  • only discard when clicking an annotatable region (incl. when selecting a different annotation, or creating a new one)

Copy link
Contributor

Choose a reason for hiding this comment

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

including anything outside the container, but also anything inside the container that's marked as not-annotatable (which I'd consider "outside the annotatable area").

That's an interesting point that's surprisingly sensible. As the "inner" not-annotatable can be treated as "gaps"/"exclusions". I'd like to add it to the JSDoc for the isNotAnnotatable method if we end up with such a definition.

Copy link
Contributor

@oleksandr-danylchenko oleksandr-danylchenko left a comment

Choose a reason for hiding this comment

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

Thanks, @rsimon 🙌🏻

I think I grasped your approach with using only the pointerup with the isNotAnnotatable check. However, extending the "not-annotatable" category to anything beyond the container will introduce breaking changes in my case.


In my previous PR #135 I relied on the fact that the lastPointerDown will be captured even outside the container. In clickSelect, the selection will be cleared because the click doesn't land inside the container - #135 (comment).

const hovered =
evt.target instanceof Node &&
container.contains(evt.target) &&
store.getAt(evt.clientX - x, evt.clientY - y, selectionMode === 'all', currentFilter);
if (hovered) {

} else {
selection.clear();

Although anything that lands on the not-annotatable element will be ignored:

if (isNotAnnotatable(evt.target as Node) || !isLeftClick) return;

Such an approach allowed me to create the SideNotes component beyond the container. Any clicks within it shouldn't clear the selection. Exactly like in the case of the "toolbar" @rsimon mentioned - #133 (comment).

However, any interactions beyond the container or the SideNotes/"toolbar" should cause the selection to clear.

Using just the dismissOnClick won't be sufficient. That won't allow users to interact with the "toolbar" without unexpectedly dismissing the selection. But not using the dismissOnClick won't allow them to dismiss the selection when an irrelevant piece of UI is clicked.

@rsimon
Copy link
Member Author

rsimon commented Sep 4, 2025

I don't understand. Can you summarize?

  • You have a sidebar. It's not annotatable itself (and outside the container). Clicks on it should not dismiss the selection? (Which would now be the case as the default behavior).
  • Same for the inline popup: interacting with it should not discard the annotation, which would now also be the default behavior.
  • Click on other areas (application-specific) should discard, correct?

I guess there are two ways to think about this:

  • let the annotator listen to the whole screen and explicitly mark non-annotatable regions
  • let the annotator listen only to the container and handle the parts outside yourself

There's definitely pros and cons for both. But I wonder if there's really a clear winner in terms of what's more effort (both inside of Recogito and the host app.)

@oleksandr-danylchenko
Copy link
Contributor

oleksandr-danylchenko commented Sep 4, 2025

Using just the dismissOnClick won't be sufficient. That won't allow users to interact with the "toolbar" without unexpectedly dismissing the selection. But not using the dismissOnClick won't allow them to dismiss the selection when an irrelevant piece of UI is clicked.

Maybe it would be more sensible to pass the dismissOnNotAnnotatable prop as an expression that will forward the container and the pointerup event's target. Then, the consumer can fine-tune where the selection should actually be dismissed, and which areas should be considered as the "toolbars".

@rsimon
Copy link
Member Author

rsimon commented Sep 4, 2025

Yes, allowing a function could be an option! (At the same time, you could pretty much attach that function to your document as a listener, and then clear the selection programmatically accordingly?)

@oleksandr-danylchenko
Copy link
Contributor

oleksandr-danylchenko commented Sep 4, 2025

I don't understand. Can you summarize?

  • You have a sidebar. It's not annotatable itself (and outside the container). Clicks on it should not dismiss the selection? (Which would now be the case as the default behavior).
  • Same for the inline popup: interacting with it should not discard the annotation, which would now also be the default behavior.
  • Click on other areas (application-specific) should discard, correct?

Yep, all those points are correct ✅

If the dismissOnClick is false, the 1 and 2 points are fulfilled. But the 3rd isn't, and won't allow users to dismiss the selection in other areas beyond the container. When the dismissOnClick is true, the situation will be inverted.

@oleksandr-danylchenko
Copy link
Contributor

oleksandr-danylchenko commented Sep 4, 2025

(At the same time, you could pretty much attach that function to your document as a listener, and then clear the selection programmatically accordingly?)

Yeah, I believe it's a possible option as well. But it makes the consumer come up with custom code wrappers that most likely will conflict with the selection flow/timing. Especially if we decide to modify it in the future. So I'd suggest centralizing the selection dismissal handling on the package's side with the dismissOnNotAnnotatable expression.

@rsimon
Copy link
Member Author

rsimon commented Sep 4, 2025

If the dismissOnClick is false, the 1 and 2 points are fulfilled. But the 3rd isn't, and won't allow users to dismiss in other areas beyond the container. When the dismissOnClick is true, the situation will be inverted.

Exactly. Therefore, if the application needs mixed behavior (some areas outside should discard and some should not) it will always have to handle one case or the other itself.

If you start with the default behavior (no dismissal) and then do a programmatic dismissal on those areas that you want to dismiss, there should be no interference anywhere.

I can see, though, how you'd sometime want to mark one or two specific "non-dismiss" areas, rather than the other way round. That might start to get messy with CSS classes though, since we'd now have not-annotatable, dismiss and/or don't dismiss etc. (And CSS classes aren't a great way to begin with.)

@rsimon
Copy link
Member Author

rsimon commented Sep 4, 2025

How about the following: dismissOnClick could have multiple values (all working titles):

  • ALWAYS - what it says
  • ANNOTATABLE_AREAS - will dismiss selection only if you click on annotatable areas (inside the container, not marked as not_annotatable)
  • Function with the click event and (perhaps) the container element for convenience

I guess apps that want to use a "positive" way to mark areas could append something like a data-discard attribute to DOM elements, and then return true if found (on the element or any parent). Vice versa, if you want to take the reverse route, you could append data-dont-discard and do the reverse.

@oleksandr-danylchenko
Copy link
Contributor

I like that idea! It both provides a sensible set of defaults and allows fine-tuning for the consumers using the function. I'd go with it ✅.
Although I'm still not sure about the "click" part. As the expression will be executed on each pointerup event, even when a user finishes the selection process.

@rsimon
Copy link
Member Author

rsimon commented Sep 4, 2025

Ok, I'll put that on the todo list. But I guess this PR is safe to merge, since it doesn't actually change the default behavior, right? (Auto-discarding was and will only be happening when clicking inside the annotatable areas.)

@rsimon
Copy link
Member Author

rsimon commented Sep 4, 2025

Although I'm still not sure about the "click" part. As the expression will be executed on each pointerup event, even when a user finishes the selection process.

Well, we can call it "discardOnPointerUp" :-)

@oleksandr-danylchenko
Copy link
Contributor

oleksandr-danylchenko commented Sep 4, 2025

Ok, I'll put that on the todo list. But I guess this PR is safe to merge, since it doesn't actually change the default behavior, right?

Yeah, I think so ✔️
Also, I can skip merging it into my fork for now to prevent breaking changes. Once the discardOnPointerup is added, I'll sync them.

@rsimon rsimon merged commit 2652e0d into main Sep 4, 2025
@rsimon rsimon deleted the click-outside-fixes branch September 4, 2025 10:22
@SovanramyVar4D
Copy link

Sorry, I'm only catching up with the conversation. I think it looks good, the default behaviour and the customisable one ! I'm going to test this very soon (next week probably), thanks for the work !

@oleksandr-danylchenko
Copy link
Contributor

Ok, I'll put that on the todo list.

@rsimon, I addressed the property extension in the #215

@SovanramyVar4D
Copy link

This feature seems to be now working fine on my app.
I just had to make a few changes, like adding a 'not-annotatable' class on one of my controls injected inside the container, because the events changed a bit. But it now works as expected.
Thanks for the time and the work both of you!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants