Skip to content

Commit 358def1

Browse files
authored
fix(caret): full-width caret has no width on zero-width letters (@nadalaba) (#7708)
![2026-03-23 19-14-14](https://github.com/user-attachments/assets/b51a3f41-bf9e-460b-92cd-41ccd3df26a1) - also minimize dom query (from 3 qsa to 1)
1 parent 6a6e71d commit 358def1

File tree

1 file changed

+61
-64
lines changed

1 file changed

+61
-64
lines changed

frontend/src/ts/elements/caret.ts

Lines changed: 61 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -287,8 +287,8 @@ export class Caret {
287287
const word = wordsCache.qs(
288288
`.word[data-wordindex="${options.wordIndex}"]`,
289289
);
290-
const letters = word?.qsa("letter") ?? [];
291-
const wordText = TestWords.words.get(options.wordIndex);
290+
const wordText = TestWords.words.get(options.wordIndex) ?? "";
291+
const wordLength = Array.from(wordText).length;
292292

293293
// caret can be either on the left side of the target letter or the right
294294
// we stick to the left side unless we are on the last letter or beyond
@@ -297,43 +297,32 @@ export class Caret {
297297
// we also clamp the letterIndex to be within the range of actual letters
298298
// anything beyond just goes to the edge of the word
299299
let side: "beforeLetter" | "afterLetter" = "beforeLetter";
300-
if (options.letterIndex >= letters.length) {
301-
side = "afterLetter";
300+
if (Config.mode === "zen") {
301+
if (options.letterIndex > 0) {
302+
side = "afterLetter";
303+
options.letterIndex -= 1;
304+
}
305+
} else {
306+
if (options.letterIndex >= wordLength) {
307+
side = "afterLetter";
302308

303-
if (Config.blindMode || Config.hideExtraLetters) {
304-
options.letterIndex = (wordText?.length ?? letters.length) - 1;
305-
} else {
306-
options.letterIndex = letters.length - 1;
309+
if (Config.blindMode || Config.hideExtraLetters) {
310+
options.letterIndex = wordLength - 1;
311+
} else {
312+
options.letterIndex -= 1;
313+
}
307314
}
308315
}
309316

310317
if (options.letterIndex < 0) {
311318
options.letterIndex = 0;
312319
}
313320

314-
let letter = word?.qsa("letter")[options.letterIndex];
315-
316-
if (word === null || letter === undefined) {
317-
return;
318-
}
319-
320-
if (caretDebug) {
321-
if (this.id === "paceCaret") {
322-
for (const l of document.querySelectorAll(".word letter")) {
323-
l.classList.remove("debugCaretTarget");
324-
l.classList.remove("debugCaretTarget2");
325-
l.classList.add("debugCaret");
326-
}
327-
letter?.addClass("debugCaretTarget");
328-
this.element.addClass("debug");
329-
}
330-
} else {
331-
this.element.removeClass("debug");
332-
}
321+
if (word === null) return;
333322

334323
const { left, top, width } = this.getTargetPositionAndWidth({
335324
word,
336-
letter,
325+
letterIndex: options.letterIndex,
337326
wordText,
338327
side,
339328
isLanguageRightToLeft: options.isLanguageRightToLeft,
@@ -399,59 +388,67 @@ export class Caret {
399388

400389
private getTargetPositionAndWidth(options: {
401390
word: ElementWithUtils;
402-
letter: ElementWithUtils;
391+
letterIndex: number;
403392
wordText: string;
404393
side: "beforeLetter" | "afterLetter";
405394
isLanguageRightToLeft: boolean;
406395
isDirectionReversed: boolean;
407396
}): { left: number; top: number; width: number } {
397+
const letters = options.word?.qsa("letter");
398+
let letter;
399+
if (!letters?.length || !(letter = letters[options.letterIndex])) {
400+
// maybe we should return null here instead of throwing
401+
throw new Error(
402+
"Caret getTargetPositionAndWidth: no letters found in word",
403+
);
404+
}
405+
406+
if (caretDebug) {
407+
if (this.id === "paceCaret") {
408+
for (const l of document.querySelectorAll(".word letter")) {
409+
l.classList.remove("debugCaretTarget");
410+
l.classList.remove("debugCaretTarget2");
411+
l.classList.add("debugCaret");
412+
}
413+
letter?.addClass("debugCaretTarget");
414+
this.element.addClass("debug");
415+
}
416+
} else {
417+
this.element.removeClass("debug");
418+
}
419+
408420
// in zen, custom or polyglot mode we need to check per-letter
409421
const checkRtlByLetter =
410422
Config.mode === "zen" ||
411423
Config.mode === "custom" ||
412424
Config.funbox.includes("polyglot");
413425
const [isWordRTL, isFullMatch] = isWordRightToLeft(
414-
checkRtlByLetter
415-
? (options.letter.native.textContent ?? "")
416-
: options.wordText,
426+
checkRtlByLetter ? (letter.native.textContent ?? "") : options.wordText,
417427
options.isLanguageRightToLeft,
418428
options.isDirectionReversed,
419429
);
420430

421431
//if the letter is not visible, use the closest visible letter
422-
const isLetterVisible = options.letter.getOffsetWidth() > 0;
432+
const isLetterVisible = letter.getOffsetWidth() > 0;
423433
if (!isLetterVisible) {
424-
const letters = options.word.qsa("letter");
425-
if (letters.length === 0) {
426-
throw new Error("Caret getLeftTopWidth: no letters found in word");
427-
}
428-
429-
// ignore letters after the current letter
430-
let ignore = true;
431-
for (let i = letters.length - 1; i >= 0; i--) {
434+
for (let i = options.letterIndex - 1; i >= 0; i--) {
432435
const loopLetter = letters[i] as ElementWithUtils;
433-
if (loopLetter === options.letter) {
434-
// at the current letter, stop ignoring, continue to the next
435-
ignore = false;
436-
continue;
437-
}
438-
if (ignore) continue;
439436

440-
// found the closest visible letter before the current letter
437+
// find the closest visible letter before the current letter
441438
if (loopLetter.getOffsetWidth() > 0) {
442-
options.letter = loopLetter;
439+
letter = loopLetter;
443440
break;
444441
}
445442
}
446443
if (caretDebug) {
447-
options.letter.addClass("debugCaretTarget2");
444+
letter.addClass("debugCaretTarget2");
448445
}
449446
}
450447

451448
const spaceWidth = getTotalInlineMargin(options.word.native);
452449
let width = spaceWidth;
453450
if (this.isFullWidth() && options.side === "beforeLetter") {
454-
width = options.letter.getOffsetWidth();
451+
width = letter.getOffsetWidth();
455452
}
456453

457454
let left = 0;
@@ -468,22 +465,22 @@ export class Caret {
468465
if (this.isFullWidth()) {
469466
afterLetterCorrection += spaceWidth * -1;
470467
} else {
471-
afterLetterCorrection += options.letter.getOffsetWidth() * -1;
468+
afterLetterCorrection += letter.getOffsetWidth() * -1;
472469
}
473470
}
474471
if (Config.tapeMode === "off") {
475472
if (!this.isFullWidth()) {
476-
left += options.letter.getOffsetWidth();
473+
left += letter.getOffsetWidth();
477474
}
478-
left += options.letter.getOffsetLeft();
475+
left += letter.getOffsetLeft();
479476
left += options.word.getOffsetLeft();
480477
left += afterLetterCorrection;
481478
} else if (Config.tapeMode === "word") {
482479
if (!this.isFullWidth()) {
483-
left += options.letter.getOffsetWidth();
480+
left += letter.getOffsetWidth();
484481
}
485482
left += options.word.getOffsetWidth() * -1;
486-
left += options.letter.getOffsetLeft();
483+
left += letter.getOffsetLeft();
487484
left += afterLetterCorrection;
488485
if (this.isMainCaret && lockedMainCaretInTape) {
489486
left += wordsWrapperCache.getOffsetWidth() - tapeOffset;
@@ -498,7 +495,7 @@ export class Caret {
498495
if (this.isMainCaret && lockedMainCaretInTape) {
499496
left += wordsWrapperCache.getOffsetWidth() - tapeOffset;
500497
} else {
501-
left += options.letter.getOffsetLeft();
498+
left += letter.getOffsetLeft();
502499
left += options.word.getOffsetLeft();
503500
left += afterLetterCorrection;
504501
left += width;
@@ -507,14 +504,14 @@ export class Caret {
507504
} else {
508505
let afterLetterCorrection = 0;
509506
if (options.side === "afterLetter") {
510-
afterLetterCorrection += options.letter.getOffsetWidth();
507+
afterLetterCorrection += letter.getOffsetWidth();
511508
}
512509
if (Config.tapeMode === "off") {
513-
left += options.letter.getOffsetLeft();
510+
left += letter.getOffsetLeft();
514511
left += options.word.getOffsetLeft();
515512
left += afterLetterCorrection;
516513
} else if (Config.tapeMode === "word") {
517-
left += options.letter.getOffsetLeft();
514+
left += letter.getOffsetLeft();
518515
left += afterLetterCorrection;
519516
if (this.isMainCaret && lockedMainCaretInTape) {
520517
left += tapeOffset;
@@ -525,23 +522,23 @@ export class Caret {
525522
if (this.isMainCaret && lockedMainCaretInTape) {
526523
left += tapeOffset;
527524
} else {
528-
left += options.letter.getOffsetLeft();
525+
left += letter.getOffsetLeft();
529526
left += options.word.getOffsetLeft();
530527
left += afterLetterCorrection;
531528
}
532529
}
533530
}
534531

535532
//top position
536-
top += options.letter.getOffsetTop();
533+
top += letter.getOffsetTop();
537534
top += options.word.getOffsetTop();
538535

539536
if (this.style === "underline") {
540537
// if style is underline, add the height of the letter to the top
541-
top += options.letter.getOffsetHeight();
538+
top += letter.getOffsetHeight();
542539
} else {
543540
// else center vertically in the letter
544-
top += (options.letter.getOffsetHeight() - this.getHeight()) / 2;
541+
top += (letter.getOffsetHeight() - this.getHeight()) / 2;
545542
}
546543

547544
// also center horizontally

0 commit comments

Comments
 (0)