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..25d114f --- /dev/null +++ b/apps/desktop/src/editor/plugins/caret.ts @@ -0,0 +1,79 @@ +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"; + +// Track all active caret instances so setCaretStyle updates every editor. +const activeInstances = new Map(); + +export function setCaretStyle(style: CaretStyle) { + caretStyle = style; + for (const [view, el] of activeInstances) { + updateCaret(view, el); + } +} + +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 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); + parent.appendChild(el); + + activeInstances.set(view, el); + updateCaret(view, el); + + return { + update(v) { + updateCaret(v, el); + }, + destroy() { + el.remove(); + activeInstances.delete(view); + }, + }; + }, +}); 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; } +}