From 938b0ffbb879569e49b1856a6d5feaccd711e158 Mon Sep 17 00:00:00 2001 From: usamaasfar Date: Sat, 21 Feb 2026 23:31:46 +0530 Subject: [PATCH 1/4] 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/4] [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); }, }; }, From 25ab6f3689d0a5bb255e865d81b50755063ffd16 Mon Sep 17 00:00:00 2001 From: "openai-code-agent[bot]" <242516109+Codex@users.noreply.github.com> Date: Sat, 21 Feb 2026 18:21:04 +0000 Subject: [PATCH 3/4] Initial plan From 09f0a01f334dc085397da309c960af58d72472bc Mon Sep 17 00:00:00 2001 From: "openai-code-agent[bot]" <242516109+Codex@users.noreply.github.com> Date: Sat, 21 Feb 2026 18:27:59 +0000 Subject: [PATCH 4/4] fix: align biome config and lint issues --- apps/desktop/src-tauri/tauri.conf.json | 12 ++-------- apps/desktop/src/App.tsx | 28 +++++++++++------------- apps/desktop/src/command/CommandBar.tsx | 12 +++++++++- apps/desktop/src/editor/Editor.tsx | 2 +- apps/desktop/src/editor/markdown.ts | 5 +++-- apps/desktop/src/editor/plugins/caret.ts | 5 +---- apps/desktop/src/hooks/useAutoUpdate.ts | 6 ++++- apps/desktop/src/hooks/useFileSystem.ts | 2 +- apps/desktop/src/styles/editor.css | 9 ++++++-- apps/desktop/src/styles/globals.css | 4 +--- apps/desktop/tsconfig.json | 4 ++-- apps/desktop/vite.config.ts | 4 ++-- biome.json | 9 ++------ package.json | 4 +--- 14 files changed, 52 insertions(+), 54 deletions(-) diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index edf6341..94a2841 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -26,13 +26,7 @@ "active": true, "targets": "all", "createUpdaterArtifacts": true, - "icon": [ - "icons/32x32.png", - "icons/128x128.png", - "icons/128x128@2x.png", - "icons/icon.icns", - "icons/icon.ico" - ], + "icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"], "macOS": { "signingIdentity": null } @@ -40,9 +34,7 @@ "plugins": { "updater": { "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDg4RTRDMDREMjdGNjYxODcKUldTSFlmWW5UY0RraUlaTjZwYmV6MjRnWGs3QWs3U3o2YmpGbEJ5akV0MER2b2I1M3V4LzNoem4K", - "endpoints": [ - "https://github.com/usamaasfar/superscript/releases/latest/download/latest.json" - ] + "endpoints": ["https://github.com/usamaasfar/superscript/releases/latest/download/latest.json"] } }, "version": "../../../package.json" diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index d9807e5..65e0373 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -5,8 +5,8 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { CommandBar } from "~/command/CommandBar"; import { Editor } from "~/editor/Editor"; import { useAppearance } from "~/hooks/useAppearance"; -import { useAutoUpdate } from "~/hooks/useAutoUpdate"; import { useAutoSave } from "~/hooks/useAutoSave"; +import { useAutoUpdate } from "~/hooks/useAutoUpdate"; import { useFileSystem } from "~/hooks/useFileSystem"; import { useRename } from "~/hooks/useRename"; import { getFileStem } from "~/utils/file"; @@ -44,21 +44,19 @@ function App() { setActivePath, }); - const { isRenaming, renameValue, setRenameValue, renameInputRef, startRename, submitRename, resetRename } = useRename( - { - activePath, - files, - flushSave, - loadDir, - setActivePath, - setActiveContent, - setEditorKey: (updater) => { - const next = updater(editorKeyRef.current); - editorKeyRef.current = next; - setEditorKey(next); - }, + const { isRenaming, renameValue, setRenameValue, renameInputRef, startRename, submitRename, resetRename } = useRename({ + activePath, + files, + flushSave, + loadDir, + setActivePath, + setActiveContent, + setEditorKey: (updater) => { + const next = updater(editorKeyRef.current); + editorKeyRef.current = next; + setEditorKey(next); }, - ); + }); const openFile = useCallback( async (path: string) => { diff --git a/apps/desktop/src/command/CommandBar.tsx b/apps/desktop/src/command/CommandBar.tsx index 17f22c2..8fed9b5 100644 --- a/apps/desktop/src/command/CommandBar.tsx +++ b/apps/desktop/src/command/CommandBar.tsx @@ -8,7 +8,17 @@ interface Props { export function CommandBar({ files, onSelect, onClose }: Props) { return ( -
+
{ + if (e.key === "Escape" || e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onClose(); + } + }} + tabIndex={-1} + > e.stopPropagation()}> diff --git a/apps/desktop/src/editor/Editor.tsx b/apps/desktop/src/editor/Editor.tsx index 2a41f18..9064acc 100644 --- a/apps/desktop/src/editor/Editor.tsx +++ b/apps/desktop/src/editor/Editor.tsx @@ -6,8 +6,8 @@ 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"; +import { caretPlugin } from "./plugins/caret"; function buildInputRules() { return inputRules({ diff --git a/apps/desktop/src/editor/markdown.ts b/apps/desktop/src/editor/markdown.ts index ece55c8..74bf044 100644 --- a/apps/desktop/src/editor/markdown.ts +++ b/apps/desktop/src/editor/markdown.ts @@ -1,6 +1,6 @@ import MarkdownIt from "markdown-it"; import type Token from "markdown-it/lib/token.mjs"; -import { defaultMarkdownSerializer, MarkdownParser, MarkdownSerializer } from "prosemirror-markdown"; +import { MarkdownParser, MarkdownSerializer, defaultMarkdownSerializer } from "prosemirror-markdown"; import { Schema } from "prosemirror-model"; // schema: CommonMark nodes + strikethrough mark @@ -168,7 +168,8 @@ export const schema = new Schema({ }); function listIsTight(tokens: Token[], i: number) { - while (++i < tokens.length) if (tokens[i].type !== "list_item_open") return tokens[i].hidden; + let index = i; + while (++index < tokens.length) if (tokens[index].type !== "list_item_open") return tokens[index].hidden; return false; } diff --git a/apps/desktop/src/editor/plugins/caret.ts b/apps/desktop/src/editor/plugins/caret.ts index 25d114f..db1cdf4 100644 --- a/apps/desktop/src/editor/plugins/caret.ts +++ b/apps/desktop/src/editor/plugins/caret.ts @@ -37,10 +37,7 @@ function updateCaret(view: EditorView, el: HTMLElement) { 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.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 diff --git a/apps/desktop/src/hooks/useAutoUpdate.ts b/apps/desktop/src/hooks/useAutoUpdate.ts index aefb013..fcca0ed 100644 --- a/apps/desktop/src/hooks/useAutoUpdate.ts +++ b/apps/desktop/src/hooks/useAutoUpdate.ts @@ -6,7 +6,11 @@ export function useAutoUpdate(): void { useEffect(() => { check() .then((update) => { - if (update) update.downloadAndInstall().then(() => relaunch()).catch(console.error); + if (update) + update + .downloadAndInstall() + .then(() => relaunch()) + .catch(console.error); }) .catch(console.error); }, []); diff --git a/apps/desktop/src/hooks/useFileSystem.ts b/apps/desktop/src/hooks/useFileSystem.ts index a0ce3ce..9c7906a 100644 --- a/apps/desktop/src/hooks/useFileSystem.ts +++ b/apps/desktop/src/hooks/useFileSystem.ts @@ -1,6 +1,6 @@ +import { homeDir } from "@tauri-apps/api/path"; import { open } from "@tauri-apps/plugin-dialog"; import { mkdir, readDir, stat } from "@tauri-apps/plugin-fs"; -import { homeDir } from "@tauri-apps/api/path"; import { useCallback, useEffect, useState } from "react"; interface UseFileSystemOptions { diff --git a/apps/desktop/src/styles/editor.css b/apps/desktop/src/styles/editor.css index 7ac3dee..b0c17cc 100644 --- a/apps/desktop/src/styles/editor.css +++ b/apps/desktop/src/styles/editor.css @@ -142,6 +142,11 @@ } @keyframes pm-caret-blink { - 0%, 100% { opacity: 1; } - 50% { opacity: 0; } + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0; + } } diff --git a/apps/desktop/src/styles/globals.css b/apps/desktop/src/styles/globals.css index 9dcb86a..5e54449 100644 --- a/apps/desktop/src/styles/globals.css +++ b/apps/desktop/src/styles/globals.css @@ -84,9 +84,7 @@ body { border-radius: 0; background: transparent; color: var(--fg); - font-family: - -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", - sans-serif; + font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", sans-serif; font-size: 0.78rem; font-weight: 500; line-height: 1.1; diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json index d2ca536..ed874c1 100644 --- a/apps/desktop/tsconfig.json +++ b/apps/desktop/tsconfig.json @@ -20,8 +20,8 @@ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true, + "noFallthroughCasesInSwitch": true }, "include": ["src"], - "references": [{ "path": "./tsconfig.node.json" }], + "references": [{ "path": "./tsconfig.node.json" }] } diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index daf85d2..55c0ad4 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -1,7 +1,7 @@ +import { resolve } from "node:path"; import tailwindcss from "@tailwindcss/vite"; -import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; -import { resolve } from "path"; +import { defineConfig } from "vite"; // @ts-expect-error process is a nodejs global const host = process.env.TAURI_DEV_HOST; diff --git a/biome.json b/biome.json index 069488e..41a8ded 100644 --- a/biome.json +++ b/biome.json @@ -24,12 +24,7 @@ "quoteStyle": "double" } }, - "assist": { - "enabled": true, - "actions": { - "source": { - "organizeImports": "on" - } - } + "assists": { + "enabled": true } } diff --git a/package.json b/package.json index 44c6b0c..02e8c2b 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,5 @@ "node": ">=18" }, "packageManager": "bun@1.3.9", - "workspaces": [ - "apps/*" - ] + "workspaces": ["apps/*"] }