Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 47 additions & 16 deletions packages/shiki-editor/src/Vim.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
* ```
*/

import { useRef, useCallback, useMemo, useEffect } from "react";
import { useRef, useCallback, useMemo, useEffect, useLayoutEffect, useState } from "react";
import type { HighlighterGeneric } from "shiki/types";
import type { VimMode, VimAction, CursorPosition } from "@vimee/core";
import { useVim } from "@vimee/react";
Expand Down Expand Up @@ -134,25 +134,44 @@

// --- Calculate gutter width for line numbers ---
const totalLines = tokenLines.length;
const gutterWidth = String(totalLines).length;

Check warning on line 137 in packages/shiki-editor/src/Vim.tsx

View workflow job for this annotation

GitHub Actions / ci

eslint(no-unused-vars)

Variable 'gutterWidth' is declared but never used. Unused variables should start with a '_'.

// --- Tab size for rendering and cursor calculation ---
const tabSize = indentWidth ?? 4;

// --- Calculate visual column (accounting for tab width) ---
const visualCol = useMemo(() => {
// --- Measure cursor position in pixels via DOM (handles CJK, tabs, etc.) ---
const measureRef = useRef<HTMLSpanElement>(null);
const [cursorPx, setCursorPx] = useState({ left: 0, width: 0 });

useLayoutEffect(() => {
const measure = measureRef.current;
const area = codeAreaRef.current;
if (!measure || !area) return;

const lines = engine.content.split("\n");
const line = lines[engine.cursor.line] ?? "";
let col = 0;
for (let i = 0; i < engine.cursor.col && i < line.length; i++) {
if (line[i] === "\t") {
col += tabSize - (col % tabSize);
} else {
col++;
}

// Measure text width before cursor
measure.textContent = line.slice(0, engine.cursor.col) || "\u200b";
const textWidth = line.slice(0, engine.cursor.col)
? measure.getBoundingClientRect().width
: 0;

// Measure character at cursor (for block cursor width)
const charAtCursor = line[engine.cursor.col];
if (charAtCursor) {
measure.textContent = charAtCursor;
} else {
measure.textContent = " ";
}
return col;
}, [engine.content, engine.cursor.line, engine.cursor.col, tabSize]);
const charWidth = measure.getBoundingClientRect().width;

// Measure gutter offset from first line-number element
const gutterEl = area.querySelector(".sv-line-number");
const gutterWidth = gutterEl ? gutterEl.getBoundingClientRect().width : 0;

setCursorPx({ left: gutterWidth + textWidth, width: charWidth });
}, [engine.content, engine.cursor.line, engine.cursor.col]);

// --- Calculate search match positions per line ---
const searchMatchesByLine = useMemo(() => {
Expand Down Expand Up @@ -271,13 +290,25 @@
>
{/* Code area */}
<div ref={codeAreaRef} className="sv-code-area">
{/* Hidden span for measuring character widths (inherits font from code area) */}
<span
ref={measureRef}
aria-hidden="true"
style={{
position: "absolute",
visibility: "hidden",
whiteSpace: "pre",
font: "inherit",
pointerEvents: "none",
}}
/>
{/* Cursor (overlay) */}
<Cursor
position={engine.cursor}
visualCol={visualCol}
line={engine.cursor.line}
col={engine.cursor.col}
leftPx={cursorPx.left}
widthPx={cursorPx.width}
mode={engine.mode}
showLineNumbers={effectiveShowLineNumbers}
gutterWidth={gutterWidth}
/>

{/* Render each line */}
Expand Down
44 changes: 17 additions & 27 deletions packages/shiki-editor/src/components/Cursor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,36 +7,31 @@
* - Insert mode: line cursor (vertical bar)
* - Visual mode: block cursor
*
* Cursor position is controlled via CSS variables,
* calculated using `ch` / `lh` units of a monospace font.
* Cursor position is controlled via pixel values measured from the DOM,
* ensuring correct alignment with CJK and other variable-width characters.
*/

import { useState, useEffect, useRef } from "react";
import type { CursorPosition, VimMode } from "@vimee/core";
import type { VimMode } from "@vimee/core";

export interface CursorProps {
/** Cursor position (0-based) */
position: CursorPosition;
/** Visual column (accounting for tab width) */
visualCol: number;
/** Cursor line (0-based) */
line: number;
/** Cursor column (0-based, for blink restart detection) */
col: number;
/** Left offset in pixels (measured from DOM) */
leftPx: number;
/** Character width in pixels (measured from DOM) */
widthPx: number;
Comment on lines 17 to +25
/** Current Vim mode */
mode: VimMode;
/** Whether line numbers are displayed */
showLineNumbers: boolean;
/** Gutter width for line numbers (in ch units) */
gutterWidth: number;
}

/**
* Renders the editor cursor.
*
* Displayed as an overlay using absolute positioning.
* left / top are calculated accounting for the line number gutter offset.
*/
const BLINK_RESTART_DELAY = 500;

export function Cursor({ position, visualCol, mode, showLineNumbers, gutterWidth }: CursorProps) {
export function Cursor({ line, col, leftPx, widthPx, mode }: CursorProps) {
const cursorClass = getCursorClass(mode);
const isBlock = mode !== "insert";

// Pause blink while cursor is moving, resume after idle
const [blinking, setBlinking] = useState(true);
Expand All @@ -47,17 +42,15 @@ export function Cursor({ position, visualCol, mode, showLineNumbers, gutterWidth
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setBlinking(true), BLINK_RESTART_DELAY);
return () => clearTimeout(timerRef.current);
}, [position.line, position.col]);

// Gutter offset (only when line numbers are displayed)
const gutterOffset = showLineNumbers ? gutterWidth + 1 : 0;
}, [line, col]);

return (
<div
className={`sv-cursor ${cursorClass}`}
style={{
["--cursor-col" as string]: visualCol + gutterOffset,
["--cursor-line" as string]: position.line,
["--cursor-line" as string]: line,
left: `${leftPx}px`,
width: isBlock ? `${widthPx}px` : undefined,
animation: blinking && mode !== "command-line" ? undefined : "none",
opacity: mode === "command-line" ? 0 : blinking ? undefined : 1,
}}
Expand All @@ -66,9 +59,6 @@ export function Cursor({ position, visualCol, mode, showLineNumbers, gutterWidth
);
}

/**
* Returns the CSS class for the cursor based on the mode.
*/
function getCursorClass(mode: VimMode): string {
switch (mode) {
case "insert":
Expand Down
3 changes: 1 addition & 2 deletions packages/shiki-editor/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -103,14 +103,13 @@
.sv-cursor {
position: absolute;
top: calc(var(--cursor-line) * 1lh + 8px);
left: calc(var(--cursor-col) * 1ch);
/* left and width are set via inline style (pixel-based DOM measurement) */
pointer-events: none;
z-index: 10;
}

/* Block cursor (normal mode) */
.sv-cursor-block {
width: 1ch;
height: 1lh;
background-color: var(--sv-cursor-color, rgba(255, 255, 255, 0.6));
animation: sv-blink 1s step-start infinite;
Expand Down
Loading