Skip to content

Commit 3aed611

Browse files
authored
draw caret on mobile for readonly text field (#44)
1 parent 72ccef3 commit 3aed611

File tree

5 files changed

+109
-9
lines changed

5 files changed

+109
-9
lines changed

page/caret.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import getCaretCoordinates from 'textarea-caret'
2+
3+
// Compared with macOS pinyin on font-size: 14px.
4+
export const UNDERLINE_OFFSET_RATIO = 1 / 6
5+
6+
export function getFontSize(element: HTMLElement) {
7+
return Number.parseFloat(getComputedStyle(element).fontSize)
8+
}
9+
10+
let timer: number | null = null
11+
let show = false
12+
13+
export function removeCaret() {
14+
if (timer) {
15+
window.clearInterval(timer)
16+
}
17+
document.querySelectorAll('.fcitx-mobile-caret').forEach(div => div.remove())
18+
}
19+
20+
export function redrawCaret(event: { target: EventTarget | null }) {
21+
const input = event.target as HTMLInputElement | HTMLTextAreaElement
22+
const color = getComputedStyle(input).caretColor
23+
const box = input.getBoundingClientRect()
24+
const { top, left } = getCaretCoordinates(input, input.selectionStart!)
25+
const caretHeight = getFontSize(input) * (1 + UNDERLINE_OFFSET_RATIO)
26+
removeCaret()
27+
const div = document.createElement('div')
28+
div.classList.add('fcitx-mobile-caret')
29+
div.style.position = 'absolute'
30+
div.style.top = `${box.top + top}px`
31+
div.style.left = `${box.left + left}px`
32+
div.style.height = `${caretHeight}px`
33+
div.style.width = '1px'
34+
div.style.backgroundColor = color
35+
document.body.appendChild(div)
36+
show = true
37+
timer = window.setInterval(() => {
38+
show = !show
39+
div.style.opacity = show ? '1' : '0'
40+
}, 500)
41+
}

page/client.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import getCaretCoordinates from 'textarea-caret'
2+
import { getFontSize, UNDERLINE_OFFSET_RATIO } from './caret'
23
import { getInputElement, setSpellCheck } from './focus'
34
import { graphemeIndices } from './unicode'
45

@@ -9,15 +10,10 @@ let preedit = ''
910
let preeditIndex = 0
1011

1112
// compared with macOS pinyin
12-
const UNDERLINE_OFFSET = 1
1313
const CANDIDATE_WINDOW_OFFSET = 6
1414

1515
const textEncoder = new TextEncoder()
1616

17-
function getFontSize(element: HTMLElement) {
18-
return Number.parseFloat(getComputedStyle(element).fontSize)
19-
}
20-
2117
export function placePanel(dx: number, dy: number, anchorTop: number, anchorLeft: number, dragging: boolean) {
2218
const input = getInputElement()
2319
if (!input) {
@@ -92,14 +88,14 @@ function drawPreeditUnderline(input: HTMLElement, start: number) {
9288
// getCaretCoordinates can't tell the position of the end of previous line,
9389
// because it's equivalent to the start of next line, which is the actual place
9490
// that new character is written. So we need to calculate width of the last character.
95-
drawUnderline(box.top + rowTop + fontSize + UNDERLINE_OFFSET, box.left + rowLeft, lastLeft - rowLeft + getTextWidth(input, preedit[i - 1]), color)
91+
drawUnderline(box.top + rowTop + fontSize * (1 + UNDERLINE_OFFSET_RATIO), box.left + rowLeft, lastLeft - rowLeft + getTextWidth(input, preedit[i - 1]), color)
9692
rowTop = top
9793
rowLeft = lastLeft
9894
}
9995
lastLeft = left
10096
}
10197
if (lastLeft !== endLeft) {
102-
drawUnderline(box.top + endTop + fontSize + UNDERLINE_OFFSET, box.left + lastLeft, endLeft - lastLeft, color)
98+
drawUnderline(box.top + endTop + fontSize * (1 + UNDERLINE_OFFSET_RATIO), box.left + lastLeft, endLeft - lastLeft, color)
10399
}
104100
}
105101

page/focus.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { redrawCaret, removeCaret } from './caret'
12
import { resetPreedit } from './client'
23
import { hasTouch, hideKeyboard, showKeyboard } from './keyboard'
34
import Module from './module'
@@ -33,9 +34,12 @@ export function focus() {
3334
input.addEventListener('mousedown', resetInput)
3435
if (hasTouch) {
3536
input.addEventListener('touchstart', resetInput)
37+
input.addEventListener('selectionchange', redrawCaret)
38+
input.addEventListener('change', redrawCaret) // Needed when deleting the only character.
3639
originalReadOnly = input.readOnly
3740
input.readOnly = true
3841
showKeyboard()
42+
redrawCaret({ target: input })
3943
}
4044
originalSpellCheck = input.spellcheck
4145
Module.ccall('focus_in', 'void', [], [])
@@ -55,8 +59,11 @@ export function blur() {
5559
input.removeEventListener('mousedown', resetInput)
5660
if (hasTouch) {
5761
input.removeEventListener('touchstart', resetInput)
62+
input.removeEventListener('selectionchange', redrawCaret)
63+
input.removeEventListener('change', redrawCaret)
5864
input.readOnly = originalReadOnly
5965
hideKeyboard()
66+
removeCaret()
6067
}
6168
input.spellcheck = originalSpellCheck
6269
input = null

page/keyboard.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@ const hiddenBottom = 'max(calc(-200vw / 3), -50vh)'
99

1010
export const hasTouch = /Android|iPhone|iPad|iPod/.test(navigator.userAgent)
1111

12-
function updateInput(input: HTMLInputElement | HTMLTextAreaElement, value: string) {
12+
function updateInput(input: HTMLInputElement | HTMLTextAreaElement, value: string, selectionStart?: number) {
1313
input.value = value
14+
input.selectionStart = input.selectionEnd = selectionStart ?? value.length
1415
input.dispatchEvent(new Event('change'))
1516
}
1617

@@ -30,7 +31,8 @@ function simulate(key: string, code: string) {
3031
case 'Backspace':
3132
if (preText) {
3233
const indices = graphemeIndices(preText)
33-
updateInput(input, preText.slice(0, indices[indices.length - 1]) + postText)
34+
const selectionStart = indices[indices.length - 1]
35+
updateInput(input, preText.slice(0, selectionStart) + postText, selectionStart)
3436
}
3537
break
3638
}

tests/test-caret.mobile.spec.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { expect, test } from '@playwright/test'
2+
import { expectKeyboardShown, getBox, init, tapKeyboard } from './util'
3+
4+
test('Focus, blink and blur', async ({ page }) => {
5+
await init(page)
6+
7+
const caret = page.locator('.fcitx-mobile-caret')
8+
await expect(caret).not.toBeInViewport()
9+
10+
const textarea = page.locator('textarea')
11+
await textarea.tap()
12+
await expect(caret).toHaveCSS('opacity', '1')
13+
await expect(caret).toHaveCSS('opacity', '0')
14+
await expect(caret).toHaveCSS('opacity', '1')
15+
16+
await page.locator('button').tap()
17+
await expect(caret).not.toBeInViewport()
18+
})
19+
20+
test('Input and Backspace', async ({ page }) => {
21+
await init(page)
22+
23+
const textarea = page.locator('textarea')
24+
await textarea.tap()
25+
await expectKeyboardShown(page)
26+
const caret = page.locator('.fcitx-mobile-caret')
27+
const { x: x0 } = await getBox(caret)
28+
29+
await tapKeyboard(page, 'a@')
30+
const { x: x1 } = await getBox(caret)
31+
expect(x1).toBeGreaterThan(x0)
32+
33+
await tapKeyboard(page, page.locator('.fcitx-keyboard-key.fcitx-keyboard-backspace'))
34+
const { x } = await getBox(caret)
35+
expect(x).toEqual(x0)
36+
})
37+
38+
test('Touch', async ({ page }) => {
39+
await init(page)
40+
const textarea = page.locator('textarea')
41+
await textarea.tap()
42+
await expectKeyboardShown(page)
43+
const caret = page.locator('.fcitx-mobile-caret')
44+
const { x: x0 } = await getBox(caret)
45+
46+
await tapKeyboard(page, 'a@')
47+
await textarea.evaluate((el: HTMLTextAreaElement) => el.selectionStart = el.selectionEnd = 0)
48+
const { x } = await getBox(caret)
49+
expect(x).toEqual(x0)
50+
51+
await textarea.tap()
52+
const { x: x1 } = await getBox(caret)
53+
expect(x1, 'Tapping on center should effectively change caret position').toBeGreaterThan(x0)
54+
})

0 commit comments

Comments
 (0)