Skip to content

Add smooth animated caret with line and underline styles#13

Merged
usamaasfar merged 2 commits intomainfrom
smooth-caret
Feb 21, 2026
Merged

Add smooth animated caret with line and underline styles#13
usamaasfar merged 2 commits intomainfrom
smooth-caret

Conversation

@usamaasfar
Copy link
Owner

@usamaasfar usamaasfar commented Feb 21, 2026

  • Custom ProseMirror plugin renders a virtual caret (hides native)
  • Smooth 60ms CSS transition between positions while typing
  • Blink pauses while typing, resumes 0.5s after last keystroke
  • Line and underline styles switchable via Appearance > Cursor menu
  • Style persisted to localStorage and restored on startup

Greptile Summary

Implements a custom animated caret for the ProseMirror editor with smooth 60ms position transitions and two visual styles (line and underline). The implementation correctly addresses the previous module-level state issue by using a Map to track multiple editor instances, ensuring proper cleanup and style updates across all active carets.

Key changes:

  • Created caretPlugin that hides the native caret and renders a custom animated div
  • Animation pauses while typing and resumes after 0.5s delay using CSS keyframes
  • Line and underline cursor styles switchable via Appearance > Cursor menu
  • Style preference persisted to localStorage and restored on startup
  • Plugin properly handles lifecycle with instance tracking for multi-editor support

Confidence Score: 4/5

  • Safe to merge with minor suggestion for input validation
  • Implementation is solid and fixes the previous module-level state issue. The only minor concern is the type assertion on localStorage retrieval which could accept invalid values, though this is unlikely to cause issues in practice.
  • No files require special attention - all changes follow existing patterns and are well-implemented

Important Files Changed

Filename Overview
apps/desktop/src/editor/plugins/caret.ts New plugin implementing custom animated caret with line/underline styles - fixed previous module-level state issue by using Map to track multiple instances
apps/desktop/src/editor/Editor.tsx Added caretPlugin to ProseMirror plugins array - straightforward integration
apps/desktop/src/hooks/useAppearance.ts Added cursor change event listener and localStorage persistence - follows existing pattern
apps/desktop/src/styles/editor.css Added custom caret styles with smooth transitions and blink animation, hides native caret
apps/desktop/src-tauri/src/lib.rs Added Cursor submenu to Appearance menu with line/underline options and event handlers

Last reviewed commit: 7172585

- Custom ProseMirror plugin renders a virtual caret (hides native)
- Smooth 60ms CSS transition between positions while typing
- Blink pauses while typing, resumes 0.5s after last keystroke
- Line and underline styles switchable via Appearance > Cursor menu
- Style persisted to localStorage and restored on startup

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copy link
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

5 files reviewed, 2 comments

Edit Code Review Agent Settings | Greptile

const el = document.createElement("div");
el.className = "pm-caret";
el.setAttribute("data-style", caretStyle);
view.dom.parentElement?.appendChild(el);
Copy link
Contributor

Choose a reason for hiding this comment

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

caret element won't be created if view.dom.parentElement is null (the optional chaining causes silent failure)

Suggested change
view.dom.parentElement?.appendChild(el);
const parent = view.dom.parentElement;
if (!parent) throw new Error("Editor view has no parent element");
parent.appendChild(el);

Comment on lines +6 to +8
let caretStyle: CaretStyle = (localStorage.getItem("cursor") as CaretStyle) ?? "line";
let activeView: EditorView | null = null;
let activeEl: HTMLElement | null = null;
Copy link
Contributor

Choose a reason for hiding this comment

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

module-level state means only one editor instance works correctly - if multiple Editor components mount, they'll overwrite each other's activeView and activeEl references

@usamaasfar
Copy link
Owner Author

@copilot fix the issues Greptile flagged, only is those are legit else just skip those.

Copy link
Contributor

Copilot AI commented Feb 21, 2026

@usamaasfar I've opened a new pull request, #14, to work on those changes. Once the pull request is ready, I'll request review from you.

@usamaasfar
Copy link
Owner Author

@codex[agent] review code.

@Codex
Copy link
Contributor

Codex AI commented Feb 21, 2026

@usamaasfar I've opened a new pull request, #15, to work on those changes. Once the pull request is ready, I'll request review from you.

@usamaasfar
Copy link
Owner Author

  • Custom ProseMirror plugin renders a virtual caret (hides native)
  • Smooth 60ms CSS transition between positions while typing
  • Blink pauses while typing, resumes 0.5s after last keystroke
  • Line and underline styles switchable via Appearance > Cursor menu
  • Style persisted to localStorage and restored on startup

Greptile Summary

Implements a custom animated caret for the ProseMirror editor by hiding the native cursor and rendering a virtual one via a plugin. The caret smoothly transitions between positions with 60ms CSS animations and supports two visual styles (line and underline) selectable through a new Appearance > Cursor menu. Style preference is persisted to localStorage and restored on startup.

  • Added new caretPlugin that creates a virtual caret element and updates its position
  • Integrated plugin into ProseMirror's plugin array in Editor.tsx
  • Added Rust menu items for Line/Underline cursor styles in Tauri backend
  • Wired up event listener in useAppearance hook to handle cursor style changes
  • Set native caret-color: transparent and added CSS for custom caret with blink animation

Issues found:

  • Module-level state in caret.ts will break if multiple editor instances exist simultaneously
  • Silent failure if editor parent element is missing when plugin initializes

Confidence Score: 3/5

  • This PR has two logic bugs that could cause the caret to not render or behave incorrectly
  • The implementation is well-designed with smooth animations and proper cleanup, but has two critical issues: (1) module-level state prevents multiple editor instances from working correctly, and (2) silent failure when parent element is missing. These bugs may not surface in the current single-editor architecture but represent design flaws that should be fixed.
  • apps/desktop/src/editor/plugins/caret.ts requires attention for module-level state and error handling

Important Files Changed

Filename Overview
apps/desktop/src/editor/plugins/caret.ts New custom caret plugin with smooth animations, line/underline styles, and localStorage persistence
apps/desktop/src/editor/Editor.tsx Added caretPlugin to ProseMirror plugin array
apps/desktop/src/hooks/useAppearance.ts Added cursor style listener and cleanup for cursor_change events
Last reviewed commit: 938b0ff

@codex[agent] fix those.

@Codex
Copy link
Contributor

Codex AI commented Feb 21, 2026

@usamaasfar I've opened a new pull request, #16, to work on those changes. Once the pull request is ready, I'll request review from you.

)

* Initial plan

* Fix module-level state and silent failure in caret plugin

Co-authored-by: usamaasfar <42825498+usamaasfar@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: usamaasfar <42825498+usamaasfar@users.noreply.github.com>
@usamaasfar
Copy link
Owner Author

@greptile we good?

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Feb 21, 2026

Not quite - there are still two unresolved issues from my review:

  1. Module-level state bug - The activeView and activeEl variables at the top of caret.ts will cause problems if you ever have multiple editor instances. Each instance would overwrite these shared variables.

  2. Silent failure on init - If view.dom.parentElement is null when the plugin initializes, the caret just won't appear and there's no error thrown to indicate why.

Both comments are marked as "outdated" (likely because you pushed new commits), but they're not marked as resolved. Did you address these in a follow-up commit, or would you like me to check the current state of the code?

@usamaasfar
Copy link
Owner Author

Not quite - there are still two unresolved issues from my review:

  1. Module-level state bug - The activeView and activeEl variables at the top of caret.ts will cause problems if you ever have multiple editor instances. Each instance would overwrite these shared variables.
  2. Silent failure on init - If view.dom.parentElement is null when the plugin initializes, the caret just won't appear and there's no error thrown to indicate why.

Both comments are marked as "outdated" (likely because you pushed new commits), but they're not marked as resolved. Did you address these in a follow-up commit, or would you like me to check the current state of the code?

@codex[agent]

@Codex Codex AI mentioned this pull request Feb 21, 2026
@Codex
Copy link
Contributor

Codex AI commented Feb 21, 2026

@usamaasfar I've opened a new pull request, #17, to work on those changes. Once the pull request is ready, I'll request review from you.

@usamaasfar
Copy link
Owner Author

@greptile review again, on the issues.

Copy link
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

5 files reviewed, 1 comment

Edit Code Review Agent Settings | Greptile


export type CaretStyle = "line" | "underline";

let caretStyle: CaretStyle = (localStorage.getItem("cursor") as CaretStyle) ?? "line";
Copy link
Contributor

Choose a reason for hiding this comment

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

Type assertion could silently accept invalid values from localStorage (e.g., if manually set to "foo"). Consider adding runtime validation:

Suggested change
let caretStyle: CaretStyle = (localStorage.getItem("cursor") as CaretStyle) ?? "line";
let caretStyle: CaretStyle = (() => {
const stored = localStorage.getItem("cursor");
return stored === "line" || stored === "underline" ? stored : "line";
})();

@usamaasfar usamaasfar merged commit 417ca2f into main Feb 21, 2026
1 check passed
@usamaasfar usamaasfar deleted the smooth-caret branch February 21, 2026 18:30
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