Skip to content

Commit 2973aec

Browse files
committed
Fix cursor rendering for Arabic connected characters
This commit improves the block cursor behavior for Arabic text, where connected characters were being broken by the cursor overlay. ## Problems Fixed 1. **Character Breaking in Arabic**: The block cursor used an opaque background that covered characters, breaking visual continuity of connected Arabic letters. In Arabic, letters change shape based on their position in a word (isolated/initial/medial/final forms), and the cursor was disrupting these connections. 2. **Incorrect Width Calculation**: The cursor width was based on the isolated form of characters placed inside the cursor div, not the actual rendered width in connected text. This caused misalignment where narrow connected forms appeared in wide cursor boxes. 3. **Newline Cursor Issues**: - Wide cursor boxes appeared at end of lines - In normal mode, cursor could be positioned on newline characters (inconsistent with Vim behavior where $ positions on last character) ## Solutions Implemented 1. **Transparent Cursor with Outline**: Changed from opaque background to transparent background with box-shadow outline, allowing underlying text to show through naturally without breaking character connections. 2. **DOM-Based Width Measurement**: Calculate actual character width by measuring the rendered glyph using Range.getBoundingClientRect(). This captures the true width of characters after browser text shaping, including Arabic contextual forms. 3. **Smart Newline Handling**: - Use narrow cursor (15% of font size) for newline characters - In normal mode, automatically adjust cursor position to last real character when on end-of-line newline (matching Vim $ behavior) - Preserve cursor on empty lines (consecutive newlines) ## Technical Details - Added `width` property to Piece class for explicit width control - Save original DOM position before traversal for accurate measurement - Use Range API to measure individual character width from text nodes - Force transparent letter rendering to avoid covering underlying text - Distinguish between end-of-line newlines and empty line newlines ## Impact This fixes a major usability issue for Arabic language users, making the Vim mode cursor behavior work correctly with Arabic's connected writing system while properly handling complex text shaping. Fixes visual character breaking in Arabic text editing.
1 parent 71919db commit 2973aec

File tree

1 file changed

+63
-4
lines changed

1 file changed

+63
-4
lines changed

src/block-cursor.ts

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ type Measure = {cursors: Piece[]}
1616
class Piece {
1717
constructor(readonly left: number, readonly top: number,
1818
readonly height: number,
19+
readonly width: number,
1920
readonly fontFamily: string,
2021
readonly fontSize: string,
2122
readonly fontWeight: string,
@@ -35,6 +36,7 @@ class Piece {
3536
elt.style.left = this.left + "px"
3637
elt.style.top = this.top + "px"
3738
elt.style.height = this.height + "px"
39+
elt.style.width = this.width + "px"
3840
elt.style.lineHeight = this.height + "px"
3941
elt.style.fontFamily = this.fontFamily;
4042
elt.style.fontSize = this.fontSize;
@@ -47,6 +49,7 @@ class Piece {
4749

4850
eq(p: Piece) {
4951
return this.left == p.left && this.top == p.top && this.height == p.height &&
52+
this.width == p.width &&
5053
this.fontFamily == p.fontFamily && this.fontSize == p.fontSize &&
5154
this.fontWeight == p.fontWeight && this.color == p.color &&
5255
this.className == p.className &&
@@ -130,13 +133,15 @@ function configChanged(update: ViewUpdate) {
130133
},
131134
".cm-fat-cursor": {
132135
position: "absolute",
133-
background: "#ff9696",
136+
background: "transparent",
134137
border: "none",
135138
whiteSpace: "pre",
139+
boxShadow: "0 0 0 1px #ff9696",
136140
},
137141
"&:not(.cm-focused) .cm-fat-cursor": {
138-
background: "none",
139-
outline: "solid 1px #ff9696",
142+
background: "transparent",
143+
border: "none",
144+
boxShadow: "0 0 0 1px #ff9696",
140145
color: "transparent !important",
141146
},
142147
}
@@ -158,6 +163,20 @@ function measureCursor(cm: CodeMirror, view: EditorView, cursor: SelectionRange,
158163
fatCursor = true;
159164
if (vim.visualBlock && !primary)
160165
return null;
166+
167+
// In normal mode, cursor should not be on newline at end of line
168+
// (but allow it on empty lines)
169+
if (!vim.insertMode && head < view.state.doc.length) {
170+
let letter = view.state.sliceDoc(head, head + 1);
171+
if (letter == "\n" && head > 0) {
172+
let prevLetter = view.state.sliceDoc(head - 1, head);
173+
// Move back one if previous char is not also newline (i.e., not an empty line)
174+
if (prevLetter != "\n") {
175+
head--;
176+
}
177+
}
178+
}
179+
161180
if (cursor.anchor < cursor.head) {
162181
let letter = head < view.state.doc.length && view.state.sliceDoc(head, head + 1);
163182
if (letter != "\n")
@@ -178,6 +197,7 @@ function measureCursor(cm: CodeMirror, view: EditorView, cursor: SelectionRange,
178197
if (!pos) return null;
179198
let base = getBase(view);
180199
let domAtPos = view.domAtPos(head);
200+
let originalDomAtPos = domAtPos; // Save original for width measurement
181201
let node = domAtPos ? domAtPos.node : view.contentDOM;
182202
if (node instanceof Text && domAtPos.offset >= node.data.length) {
183203
if (node.parentElement?.nextSibling) {
@@ -212,11 +232,50 @@ function measureCursor(cm: CodeMirror, view: EditorView, cursor: SelectionRange,
212232
// include the second half of a surrogate pair in cursor
213233
letter += view.state.sliceDoc(head + 1, head + 2);
214234
}
235+
236+
// Calculate actual character width by measuring the rendered text
237+
let charWidth = 8; // default fallback
238+
239+
// Special handling for newlines and end-of-line
240+
let actualLetter = view.state.sliceDoc(head, head + 1);
241+
if (!actualLetter || actualLetter == "\n" || actualLetter == "\r" || head >= view.state.doc.length) {
242+
// Newline or end of document: use narrow cursor
243+
const fontSize = parseInt(style.fontSize) || 16;
244+
charWidth = fontSize * 0.15; // Very narrow for newlines
245+
} else {
246+
// Try to measure from the original DOM node before traversal
247+
if (originalDomAtPos && originalDomAtPos.node instanceof Text) {
248+
const range = document.createRange();
249+
const textNode = originalDomAtPos.node;
250+
const offset = originalDomAtPos.offset;
251+
252+
if (offset < textNode.length) {
253+
try {
254+
range.setStart(textNode, offset);
255+
range.setEnd(textNode, Math.min(offset + 1, textNode.length));
256+
const rect = range.getBoundingClientRect();
257+
if (rect.width > 0 && rect.width < 100) {
258+
charWidth = rect.width;
259+
}
260+
} catch (e) {
261+
// Range measurement failed, will use fallback
262+
}
263+
}
264+
}
265+
266+
// Fallback: use font-based estimation
267+
if (charWidth <= 0 || charWidth >= 100) {
268+
const fontSize = parseInt(style.fontSize) || 16;
269+
charWidth = fontSize * 0.6; // reasonable default for most characters
270+
}
271+
}
272+
215273
let h = (pos.bottom - pos.top);
216274
return new Piece((left - base.left)/view.scaleX, (pos.top - base.top + h * (1 - hCoeff))/view.scaleY, h * hCoeff/view.scaleY,
275+
charWidth/view.scaleX,
217276
style.fontFamily, style.fontSize, style.fontWeight, style.color,
218277
primary ? "cm-fat-cursor cm-cursor-primary" : "cm-fat-cursor cm-cursor-secondary",
219-
letter, hCoeff != 1)
278+
letter, true) // Always use transparent letter to preserve RTL character connections
220279
} else {
221280
return null;
222281
}

0 commit comments

Comments
 (0)