From 938b0ffbb879569e49b1856a6d5feaccd711e158 Mon Sep 17 00:00:00 2001 From: usamaasfar Date: Sat, 21 Feb 2026 23:31:46 +0530 Subject: [PATCH 1/2] Add smooth animated caret with line and underline styles - 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 --- apps/desktop/src-tauri/src/lib.rs | 13 +++++ apps/desktop/src/editor/Editor.tsx | 3 +- apps/desktop/src/editor/plugins/caret.ts | 70 ++++++++++++++++++++++++ apps/desktop/src/hooks/useAppearance.ts | 6 ++ apps/desktop/src/styles/editor.css | 28 +++++++++- 5 files changed, 118 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/editor/plugins/caret.ts diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 0e9fcca..dc15ccb 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -150,6 +150,17 @@ pub fn run() { &MenuItem::with_id(handle, "appearance-system", "System", true, None::<&str>)?, &MenuItem::with_id(handle, "appearance-light", "Light", true, None::<&str>)?, &MenuItem::with_id(handle, "appearance-dark", "Dark", true, None::<&str>)?, + &PredefinedMenuItem::separator(handle)?, + &Submenu::with_id_and_items( + handle, + "cursor-menu", + "Cursor", + true, + &[ + &MenuItem::with_id(handle, "cursor-line", "Line", true, None::<&str>)?, + &MenuItem::with_id(handle, "cursor-underline", "Underline", true, None::<&str>)?, + ], + )?, ], )?, &Submenu::with_id_and_items( @@ -181,6 +192,8 @@ pub fn run() { "appearance-system" => Some(("appearance_change", "system")), "appearance-light" => Some(("appearance_change", "light")), "appearance-dark" => Some(("appearance_change", "dark")), + "cursor-line" => Some(("cursor_change", "line")), + "cursor-underline" => Some(("cursor_change", "underline")), _ => None, }; if let Some((event_name, value)) = payload { diff --git a/apps/desktop/src/editor/Editor.tsx b/apps/desktop/src/editor/Editor.tsx index 2a6f98f..2a41f18 100644 --- a/apps/desktop/src/editor/Editor.tsx +++ b/apps/desktop/src/editor/Editor.tsx @@ -6,6 +6,7 @@ import { liftListItem, sinkListItem, splitListItem, wrapInList } from "prosemirr import { EditorState } from "prosemirror-state"; import { EditorView } from "prosemirror-view"; import { useEffect, useRef } from "react"; +import { caretPlugin } from "./plugins/caret"; import { parseMarkdown, schema, serializeMarkdown } from "./markdown"; function buildInputRules() { @@ -72,7 +73,7 @@ function createState(initialMarkdown?: string) { return EditorState.create({ schema, doc, - plugins: [history(), buildInputRules(), buildKeymap(), keymap(baseKeymap)], + plugins: [history(), buildInputRules(), buildKeymap(), keymap(baseKeymap), caretPlugin], }); } diff --git a/apps/desktop/src/editor/plugins/caret.ts b/apps/desktop/src/editor/plugins/caret.ts new file mode 100644 index 0000000..ca82e79 --- /dev/null +++ b/apps/desktop/src/editor/plugins/caret.ts @@ -0,0 +1,70 @@ +import { Plugin } from "prosemirror-state"; +import type { EditorView } from "prosemirror-view"; + +export type CaretStyle = "line" | "underline"; + +let caretStyle: CaretStyle = (localStorage.getItem("cursor") as CaretStyle) ?? "line"; +let activeView: EditorView | null = null; +let activeEl: HTMLElement | null = null; + +export function setCaretStyle(style: CaretStyle) { + caretStyle = style; + if (activeView && activeEl) updateCaret(activeView, activeEl); +} + +function getCursorRect(view: EditorView) { + const sel = window.getSelection(); + if (sel?.rangeCount) { + const range = sel.getRangeAt(0).cloneRange(); + range.collapse(true); + const rects = range.getClientRects(); + const rect = rects.length ? rects[rects.length - 1] : null; + if (rect?.height) return rect; + } + return view.coordsAtPos(view.state.selection.head); +} + +function updateCaret(view: EditorView, el: HTMLElement) { + const container = el.parentElement; + if (!container) return; + const containerRect = container.getBoundingClientRect(); + const cursorRect = getCursorRect(view); + if (!cursorRect) return; + + const lineHeight = cursorRect.bottom - cursorRect.top; + el.setAttribute("data-style", caretStyle); + el.style.left = `${cursorRect.left - containerRect.left}px`; + el.style.top = + caretStyle === "underline" + ? `${cursorRect.bottom - containerRect.top - 2}px` + : `${cursorRect.top - containerRect.top}px`; + el.style.height = caretStyle === "underline" ? "2px" : `${lineHeight}px`; + + // restart blink so caret stays solid while typing + el.style.animation = "none"; + void el.offsetWidth; + el.style.animation = ""; +} + +export const caretPlugin = new Plugin({ + view(view) { + const el = document.createElement("div"); + el.className = "pm-caret"; + el.setAttribute("data-style", caretStyle); + view.dom.parentElement?.appendChild(el); + activeView = view; + activeEl = el; + updateCaret(view, el); + + return { + update(v) { + updateCaret(v, el); + }, + destroy() { + el.remove(); + activeView = null; + activeEl = null; + }, + }; + }, +}); diff --git a/apps/desktop/src/hooks/useAppearance.ts b/apps/desktop/src/hooks/useAppearance.ts index b5ff00e..34496ef 100644 --- a/apps/desktop/src/hooks/useAppearance.ts +++ b/apps/desktop/src/hooks/useAppearance.ts @@ -1,5 +1,6 @@ import { listen } from "@tauri-apps/api/event"; import { useEffect } from "react"; +import { type CaretStyle, setCaretStyle } from "~/editor/plugins/caret"; const fonts: Record = { default: "Georgia, serif", @@ -52,11 +53,16 @@ export function useAppearance(): void { localStorage.setItem("size", e.payload); } }); + const unlistenCursor = listen("cursor_change", (e) => { + setCaretStyle(e.payload as CaretStyle); + localStorage.setItem("cursor", e.payload); + }); return () => { unlistenFont.then((f) => f()); unlistenAppearance.then((f) => f()); unlistenSize.then((f) => f()); + unlistenCursor.then((f) => f()); }; }, []); } diff --git a/apps/desktop/src/styles/editor.css b/apps/desktop/src/styles/editor.css index d08318b..7ac3dee 100644 --- a/apps/desktop/src/styles/editor.css +++ b/apps/desktop/src/styles/editor.css @@ -1,4 +1,5 @@ .editor-mount { + position: relative; width: 100%; height: 100%; } @@ -9,7 +10,7 @@ min-height: 100%; padding: calc(env(titlebar-area-height, 8px) + 16px) 32px 120px; outline: none; - caret-color: currentColor; + caret-color: transparent; font-family: var(--font); font-size: var(--font-size); line-height: 1.7; @@ -119,3 +120,28 @@ outline: 2px solid rgba(128, 128, 128, 0.4); border-radius: 2px; } + +/* ── Custom caret ──────────────────────────────────────────── */ +.pm-caret { + position: absolute; + background: currentColor; + pointer-events: none; + user-select: none; + transition: left 60ms ease, top 60ms ease; + animation: pm-caret-blink 1s ease 0.5s infinite; +} + +.pm-caret[data-style="line"] { + width: 2px; + border-radius: 1px; +} + +.pm-caret[data-style="underline"] { + width: 0.6em; + border-radius: 1px; +} + +@keyframes pm-caret-blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0; } +} From 71725856f923fdf0062c8eb13d436df44e96992e Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Feb 2026 23:47:43 +0530 Subject: [PATCH 2/2] [desktop] Fix module-level state and silent failure in caret plugin (#14) * 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> --- apps/desktop/src/editor/plugins/caret.ts | 25 ++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/editor/plugins/caret.ts b/apps/desktop/src/editor/plugins/caret.ts index ca82e79..25d114f 100644 --- a/apps/desktop/src/editor/plugins/caret.ts +++ b/apps/desktop/src/editor/plugins/caret.ts @@ -4,12 +4,15 @@ import type { EditorView } from "prosemirror-view"; export type CaretStyle = "line" | "underline"; let caretStyle: CaretStyle = (localStorage.getItem("cursor") as CaretStyle) ?? "line"; -let activeView: EditorView | null = null; -let activeEl: HTMLElement | null = null; + +// Track all active caret instances so setCaretStyle updates every editor. +const activeInstances = new Map(); export function setCaretStyle(style: CaretStyle) { caretStyle = style; - if (activeView && activeEl) updateCaret(activeView, activeEl); + for (const [view, el] of activeInstances) { + updateCaret(view, el); + } } function getCursorRect(view: EditorView) { @@ -48,12 +51,19 @@ function updateCaret(view: EditorView, el: HTMLElement) { export const caretPlugin = new Plugin({ view(view) { + const parent = view.dom.parentElement; + if (!parent) { + throw new Error( + "caretPlugin: editor DOM node has no parent element – ensure the editor is mounted in the DOM before initializing the caret plugin", + ); + } + const el = document.createElement("div"); el.className = "pm-caret"; el.setAttribute("data-style", caretStyle); - view.dom.parentElement?.appendChild(el); - activeView = view; - activeEl = el; + parent.appendChild(el); + + activeInstances.set(view, el); updateCaret(view, el); return { @@ -62,8 +72,7 @@ export const caretPlugin = new Plugin({ }, destroy() { el.remove(); - activeView = null; - activeEl = null; + activeInstances.delete(view); }, }; },