Skip to content

fix(shiki-editor): use DOM measurement for cursor positioning#61

Open
babarot wants to merge 1 commit into
vimeejs:mainfrom
babarot:fix/cjk-cursor-positioning
Open

fix(shiki-editor): use DOM measurement for cursor positioning#61
babarot wants to merge 1 commit into
vimeejs:mainfrom
babarot:fix/cjk-cursor-positioning

Conversation

@babarot
Copy link
Copy Markdown

@babarot babarot commented Apr 16, 2026

What

Replace ch-based CSS cursor positioning with pixel-precise DOM measurement using getBoundingClientRect(). A hidden measurement span in the code area measures the actual rendered width of text before the cursor, the character at the cursor, and the line number gutter. Cursor left and width are now set as inline pixel values instead of CSS calc(N * 1ch).

Why

The previous approach calculated cursor position as character_index * 1ch, assuming every character occupies exactly one ch unit. CJK (East Asian Wide) characters render at roughly 2ch in monospace fonts, but the exact width varies by font and never equals precisely 2 * 1ch. This caused the block cursor to drift leftward on lines containing Japanese/Chinese/Korean characters. The more CJK characters before the cursor, the larger the offset.

DOM measurement eliminates the width assumption entirely by asking the browser for actual rendered dimensions. This also lays groundwork for future word-wrap support, since DOM measurement naturally handles wrapped lines.

Replace ch-based CSS calculation with pixel-precise DOM measurement
for cursor positioning. The previous approach assumed all characters
are 1ch wide, causing cursor misalignment on lines containing CJK
(East Asian Wide) characters.

Changes:
- Vim.tsx: Add hidden measurement span to code area. Use
  useLayoutEffect + getBoundingClientRect() to measure text width
  before cursor, character width at cursor, and gutter width.
- Cursor.tsx: Accept pixel-based leftPx/widthPx props instead of
  ch-based visualCol/gutterWidth. Block cursor width now matches
  the actual rendered character width.
- styles.css: Remove ch-based left/width from cursor rules (now
  set via inline style).

This approach also lays groundwork for future word-wrap support,
since DOM measurement naturally handles wrapped lines.
@konojunya
Copy link
Copy Markdown
Member

@babarot I will also review this later.

@konojunya
Copy link
Copy Markdown
Member

@babarot

Found 2 issues:

  1. Cursor test file was not updated to match the new props API. The PR changes CursorProps to { line, col, leftPx, widthPx, mode }, but renderCursorHTML in the test still passes the removed props (position, visualCol, showLineNumbers, gutterWidth). This breaks bun run typecheck (CLAUDE.md "Before Committing" requires it) and silently makes the position-related assertions meaningless because the required leftPx/widthPx/line/col props are now undefined.

function renderCursorHTML(mode: VimMode) {
return renderToStaticMarkup(
createElement(Cursor, {
position: { line: 0, col: 0 },
visualCol: 0,
mode,
showLineNumbers: false,
gutterWidth: 0,
}),
);
}

  1. useLayoutEffect dependency array is missing effectiveShowLineNumbers. The effect measures the gutter offset by area.querySelector(\".sv-line-number\"), but effectiveShowLineNumbers (driven by engine.options.number, toggled via :set number / :set nonumber) is not in the deps. When the user toggles line numbers without moving the cursor or editing content, the .sv-line-number element appears/disappears but the effect won't re-fire, leaving the cursor offset by the stale gutter width until the next cursor movement.

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]);

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR updates @vimee/shiki-editor cursor positioning to use pixel-precise DOM measurement (via getBoundingClientRect) instead of ch-based CSS calculations, fixing misalignment on CJK (and other non-1ch-width) glyphs.

Changes:

  • Measure rendered text width before the cursor + current character width using a hidden <span> and apply left/width as inline pixel styles.
  • Refactor Cursor props to accept pixel offsets (leftPx, widthPx) and simpler line/col values.
  • Update cursor CSS to no longer rely on ch-based left/width rules.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
packages/shiki-editor/src/Vim.tsx Adds hidden measurement span + layout-effect measurement to compute pixel cursor offsets and passes them to Cursor.
packages/shiki-editor/src/components/Cursor.tsx Changes cursor API to consume pixel offsets and applies inline left/width styles.
packages/shiki-editor/src/styles.css Removes ch-based cursor positioning/width so inline pixel styles control placement/size.
Comments suppressed due to low confidence (2)

packages/shiki-editor/src/Vim.tsx:174

  • The cursor pixel measurement depends on whether line numbers are currently rendered, but the effect dependencies don’t include the line-number toggle (engine.options.number / showLineNumbers). If line numbers are turned on/off without moving the cursor or changing content, leftPx can stay stale and the cursor will be misaligned. Include the relevant dependency (or otherwise trigger re-measurement when line-number visibility changes).
    // 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]);

packages/shiki-editor/src/Vim.tsx:149

  • This introduces DOM-based cursor measurement (getBoundingClientRect + hidden span), but there are no automated tests covering the computed leftPx/widthPx behavior. Since this is a key correctness fix (CJK alignment, gutter offset), consider adding a happy-dom test that stubs getBoundingClientRect() to assert the inline left/width styles update for representative lines (ASCII vs CJK, with/without line numbers).
  // --- 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;

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 135 to 140
// --- Calculate gutter width for line numbers ---
const totalLines = tokenLines.length;
const gutterWidth = String(totalLines).length;

// --- Tab size for rendering and cursor calculation ---
const tabSize = indentWidth ?? 4;
Comment on lines 17 to +25
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;
@konojunya
Copy link
Copy Markdown
Member

Since more than 30 days have passed since the PR was created, the CI approval button has disappeared. Therefore, I will close and then reopen the PR once.

@konojunya konojunya closed this May 21, 2026
@konojunya konojunya reopened this May 21, 2026
@konojunya
Copy link
Copy Markdown
Member

The CI is failing, so please fix it.

@codspeed-hq
Copy link
Copy Markdown
Contributor

codspeed-hq Bot commented May 21, 2026

Merging this PR will improve performance by 14.47%

⚡ 2 improved benchmarks
❌ 1 regressed benchmark
✅ 68 untouched benchmarks
⏩ 12 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
dw (delete word) 472.5 µs 336.1 µs +40.59%
W (WORD) 495.7 µs 329.1 µs +50.61%
b 354.8 µs 500.9 µs -29.16%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing babarot:fix/cjk-cursor-positioning (b909883) with main (10740b9)

Open in CodSpeed

Footnotes

  1. 12 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants