Skip to content

Fix hidden caret by avoiding bogus-char selection#4783

Open
joelamos wants to merge 1 commit into
summernote:mainfrom
joelamos:cursor-fix-and-bogus-cleanup
Open

Fix hidden caret by avoiding bogus-char selection#4783
joelamos wants to merge 1 commit into
summernote:mainfrom
joelamos:cursor-fix-and-bogus-cleanup

Conversation

@joelamos

@joelamos joelamos commented Dec 31, 2025

Copy link
Copy Markdown

What does this PR do / why do we need it?

Stop selecting the ZERO_WIDTH_NBSP/FEFF helper character when applying font-size with a collapsed range, since selecting that invisible node can make the caret appear hidden.

Also clean up the tracked KEY_BOGUS span on selection changes: once the cursor leaves the helper span, remove it (when it only contains the bogus char) and clear the KEY_BOGUS reference. Otherwise, these bogus spans can become orphaned if you change the font-size and update the selection without content changes.

Which issue(s) this PR fixes?

Fixes #4101, #4490, #4697

Checklist

  • Added relevant tests or not required
  • Addressed and resolved all CodeRabbit review comments
  • Didn't break anything

Summary by CodeRabbit

  • Bug Fixes

    • Removed invisible placeholder characters that could appear during editing and ensured the cursor is positioned correctly.
    • Improved handling of selection changes to clear leftover placeholder spans and ensure styling applies to the intended text.
    • Reduced stray span artifacts after styling, improving editor stability and behavior when inserting styled content.
  • Tests

    • Added coverage for font-styling and selection-change scenarios to prevent regressions.

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai

coderabbitai Bot commented Dec 31, 2025

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Adds handling for a ZERO WIDTH NO-BREAK SPACE (bogus) span used as a cursor placeholder: creates/stores the bogus span, removes the placeholder and adjusts the caret on input/selection changes, treats the bogus span as the styling target for collapsed ranges, and attaches/detaches a selectionchange listener with cleanup on destroy.

Changes

Cohort / File(s) Summary
Editor core
src/js/module/Editor.js
Detects and removes a bogus ZERO WIDTH NBSP placeholder on input; stores bogus span in editable data; adds selectionchange listener on init and removes it on destroy; helper to detect caret-inside-node; uses collapse() for initial cursor placement; adjusts range collapse/select ordering and style-application to prefer bogus span when caret is inside.
Tests
test/base/module/Editor.spec.js
Adds fontStyling test: inserts editable content, applies fontSize, asserts bogus marker is stored, simulates moving selection and selectionchange, then asserts bogus marker cleared, no extra spans, and editable HTML resets.

Sequence Diagram(s)

sequenceDiagram
    autonumber
    participant User
    participant Editor
    participant BrowserSelection as SelectionAPI
    participant DOM

    rect rgb(235,245,255)
    Note over Editor,SelectionAPI: Initialization
    Editor->>SelectionAPI: attach selectionchange listener
    end

    rect rgb(245,255,235)
    Note over User,DOM: Typing / Input
    User->>DOM: input (may include ZWNBSP)
    DOM-->>Editor: input event
    Editor->>DOM: detect bogus span containing ZWNBSP
    alt caret inside bogus span
        Editor->>SelectionAPI: collapse range to position inside bogus
        Editor->>DOM: remove ZWNBSP char and clear bogus data
    else
        Editor->>DOM: remove ZWNBSP if present (no caret adjust)
    end
    end

    rect rgb(255,250,230)
    Note over User,Editor: Styling action (e.g., fontSize)
    User->>Editor: request style change
    Editor->>SelectionAPI: get current range
    alt range collapsed inside bogus span
        Editor->>DOM: treat bogus span as styling target (collapse-then-select)
    else
        Editor->>DOM: compute affected spans and apply styles
    end
    end

    rect rgb(250,235,255)
    Note over SelectionAPI,Editor: Cursor moves
    SelectionAPI->>Editor: selectionchange event
    Editor->>DOM: if caret moved outside bogus -> remove bogus span and cleanup
    end

    rect rgb(235,245,255)
    Note over Editor,SelectionAPI: Destroy
    Editor->>SelectionAPI: detach selectionchange listener
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Poem

🐰 I planted a silent span to stay,
A tiny gap to hold the way.
When selection wanders, I hop and clear,
Collapse the cursor, bring focus near.
Now edits bound — the path is dear.

Pre-merge checks and finishing touches

✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title directly addresses the main change: fixing hidden caret issues by avoiding bogus-character selection.
Linked Issues check ✅ Passed The PR implements code changes to prevent caret from disappearing when font-size is applied with collapsed range and cleans up bogus spans on selection changes, directly addressing issue #4101's core problem.
Out of Scope Changes check ✅ Passed All code changes focus on fixing bogus character handling in the editor's styling and selection management, which are directly related to the linked issue objectives.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing touches
  • 📝 Generate docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (2)
test/base/module/Editor.spec.js (1)

11-11: Verify if the dom import is necessary.

The dom module is imported but doesn't appear to be directly used in the test code. If it's not required, consider removing it to keep imports clean.

#!/bin/bash
# Verify if dom is used elsewhere in the test file
rg -n '\bdom\.' test/base/module/Editor.spec.js
src/js/module/Editor.js (1)

392-434: Review the early return logic and secondary bogus element cleanup mechanism.

The modified input handler has a design consideration regarding early returns:

  1. Early return on lines 394-396: When content is limited, the function returns early without executing the removeData(KEY_BOGUS) call at line 54. The applySnapshot restores the DOM to a previous state, but this does not guarantee that the data reference is cleared. This is mitigated by the secondary selectionchange handler (lines 62-70) which removes bogus spans when the cursor leaves them and also calls removeData(KEY_BOGUS). However, there's a potential edge case if selectionchange doesn't fire due to unexpected input flow.

  2. Single-node assumption (line 52): The break statement assumes only one text node contains the ZWNBSP character. For the bogus span's intended purpose (temporary styling), this is a reasonable assumption, but could theoretically miss edge cases if DOM content gets split into multiple text nodes through manipulation.

  3. Cursor adjustment (lines 43-49): The logic correctly adjusts cursor position when ZWNBSP is removed.

Consider whether explicitly calling removeData(KEY_BOGUS) before the early return on line 395 would improve robustness, or documenting the reliance on the selectionchange handler as the backup cleanup mechanism.

📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 57959cd and 3d80b8d.

📒 Files selected for processing (2)
  • src/js/module/Editor.js
  • test/base/module/Editor.spec.js
🧰 Additional context used
🧬 Code graph analysis (2)
src/js/module/Editor.js (1)
src/js/core/dom.js (3)
  • node (551-551)
  • node (583-583)
  • node (621-621)
test/base/module/Editor.spec.js (3)
src/js/core/dom.js (1)
  • textNode (904-904)
src/styles/lite/summernote-lite.js (1)
  • editor (10-10)
src/styles/bs4/summernote-bs4.js (1)
  • editor (7-7)
🔇 Additional comments (4)
test/base/module/Editor.spec.js (1)

460-483: LGTM! Well-structured test for bogus element cleanup.

The test effectively verifies the core behavior: when selection moves away from a styled empty span containing only the bogus character, the cleanup logic correctly removes the span and clears the stored reference. The test structure follows established patterns in the suite and appropriately uses nextTick() for async operations.

src/js/module/Editor.js (3)

436-452: LGTM! Clean selectionchange handler with proper scope.

The selectionchange handler is well-designed:

  • Uses a document-level listener (appropriate for selection events)
  • The cursorIsInside helper cleanly encapsulates the containment check
  • Properly cleans up the bogus span when the cursor moves away
  • Only removes spans that contain just the placeholder character

The logic correctly addresses the cursor visibility issue described in the PR objectives.


503-505: LGTM! Proper event listener cleanup.

The destroy method correctly removes the selectionchange listener, preventing memory leaks. The defensive check for this._onSelectionChange ensures cleanup is safe even if the listener wasn't attached.


929-943: The collapse().select() change on line 941 is correct and consistent with codebase patterns.

The code change correctly implements cursor positioning for the bogus span. The pattern collapse().select() is already used elsewhere in the codebase (line 652 in the saveRange method), confirming this is an established and appropriate approach within the project. The collapse() call positions the cursor without selecting the ZERO_WIDTH_NBSP character, then select() applies that position—exactly as intended to keep the caret visible.

Comment thread src/js/module/Editor.js Outdated
@joelamos joelamos force-pushed the cursor-fix-and-bogus-cleanup branch from 3d80b8d to 1468ad4 Compare December 31, 2025 05:07

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 0

🧹 Nitpick comments (1)
src/js/module/Editor.js (1)

928-930: Use const instead of var for consistency.

The rest of the file uses const/let (ES6 style). Using var here is inconsistent.

🔎 Proposed fix
-      var bogusSpan = this.$editable.data(KEY_BOGUS);
-      var spans = rng.isCollapsed() && bogusSpan?.contains(rng.sc) ?
+      const bogusSpan = this.$editable.data(KEY_BOGUS);
+      const spans = rng.isCollapsed() && bogusSpan?.contains(rng.sc) ?
         [bogusSpan] : this.style.styleNodes(rng);
📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3d80b8d and 1468ad4.

📒 Files selected for processing (2)
  • src/js/module/Editor.js
  • test/base/module/Editor.spec.js
🚧 Files skipped from review as they are similar to previous changes (1)
  • test/base/module/Editor.spec.js
🧰 Additional context used
🧬 Code graph analysis (1)
src/js/module/Editor.js (1)
src/js/core/dom.js (3)
  • node (551-551)
  • node (583-583)
  • node (621-621)
🔇 Additional comments (4)
src/js/module/Editor.js (4)

396-433: LGTM! Input handler correctly strips ZWNBSP once real content arrives.

The logic properly handles:

  • Early return when no bogus element exists
  • Early return when content is still only the placeholder or already clean
  • Cursor position adjustment when removing the ZWNBSP character from a mixed-content node

436-451: LGTM! Selection change handler properly cleans up orphaned bogus spans.

The handler correctly:

  • Only removes the bogus span when the cursor leaves it
  • Preserves spans that contain user-typed content (removes only placeholder-only spans)
  • Clears the bogus reference to prevent stale tracking

500-505: LGTM! Proper cleanup of the document-level listener.

The conditional guard prevents errors if destroy() is called before initialize(), and correctly removes the listener to avoid memory leaks.


936-943: LGTM! Collapse prevents selecting the invisible ZWNBSP character.

Using collapse() before select() positions the caret at the start of the styled span without selecting the invisible placeholder character, which was causing the hidden caret issue. This is the correct fix for the reported bug.

Stop selecting the ZERO_WIDTH_NBSP/FEFF helper character when applying
font-size with a collapsed range, since selecting that invisible node
can make the caret appear hidden.

Also clean up the tracked KEY_BOGUS span on selection changes: once the
cursor leaves the helper span, remove it (when it only contains the
bogus char) and clear the KEY_BOGUS reference.
@joelamos joelamos force-pushed the cursor-fix-and-bogus-cleanup branch from 1468ad4 to 1189a8e Compare December 31, 2025 19:26

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 0

🧹 Nitpick comments (1)
src/js/module/Editor.js (1)

928-930: Prefer const over var for better scoping.

The logic correctly prefers the bogus span as the styling target when the range is collapsed inside it. However, consider using const instead of var for modern JavaScript best practices.

🔎 Proposed refactor
-    var bogusSpan = this.$editable.data(KEY_BOGUS);
-    var spans = rng.isCollapsed() && bogusSpan?.contains(rng.sc) ?
-      [bogusSpan] : this.style.styleNodes(rng);
+    const bogusSpan = this.$editable.data(KEY_BOGUS);
+    const spans = rng.isCollapsed() && bogusSpan?.contains(rng.sc) ?
+      [bogusSpan] : this.style.styleNodes(rng);
📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 1468ad4 and 1189a8e.

📒 Files selected for processing (2)
  • src/js/module/Editor.js
  • test/base/module/Editor.spec.js
🚧 Files skipped from review as they are similar to previous changes (1)
  • test/base/module/Editor.spec.js
🧰 Additional context used
🧬 Code graph analysis (1)
src/js/module/Editor.js (1)
src/js/core/dom.js (3)
  • node (551-551)
  • node (583-583)
  • node (621-621)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Analyze (javascript)
🔇 Additional comments (4)
src/js/module/Editor.js (4)

396-434: LGTM! Solid implementation of ZWNBSP cleanup.

The logic correctly strips the bogus character after real text arrives, with proper cursor adjustment when the caret is positioned within the affected text node. The early returns, TreeWalker usage, and offset calculation are all appropriate.


436-451: LGTM! Clean implementation of bogus span cleanup.

The cursorIsInside helper correctly uses the Selection API to detect cursor position, and the selectionchange listener appropriately removes the bogus span only when the cursor exits and the span contains solely the placeholder character.


502-504: LGTM! Proper cleanup on destroy.

Correctly removes the document-level selectionchange listener to prevent memory leaks.


940-940: LGTM! This is the core fix for the hidden caret issue.

Changing from select() to collapse().select() correctly positions the cursor without selecting the ZWNBSP character, which would otherwise make the caret appear hidden. This change directly addresses the PR objectives.

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.

Unable to find/locate the cursor

1 participant