Skip to content

Commit 0d544cc

Browse files
authored
render preedit underline (#37)
1 parent 426dbb6 commit 0d544cc

File tree

3 files changed

+125
-6
lines changed

3 files changed

+125
-6
lines changed

page/client.ts

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,30 @@ let y = 0
77
let preedit = ''
88
let preeditIndex = 0
99

10+
// compared with macOS pinyin
11+
const UNDERLINE_OFFSET = 1
12+
const CANDIDATE_WINDOW_OFFSET = 6
13+
1014
const textEncoder = new TextEncoder()
1115

16+
function getFontSize(element: HTMLElement) {
17+
return Number.parseFloat(getComputedStyle(element).fontSize)
18+
}
19+
1220
export function placePanel(dx: number, dy: number, anchorTop: number, anchorLeft: number, dragging: boolean) {
1321
const input = getInputElement()
1422
if (!input) {
1523
return
1624
}
1725
const rect = input.getBoundingClientRect()
1826
const { top, left, height } = getCaretCoordinates(input, input.selectionStart! - (window.fcitx.followCursor ? 0 : preeditIndex))
19-
const h = height /* NaN if no line-height is set */ || Number(getComputedStyle(input).fontSize.slice(0, -'px'.length))
27+
const h = height /* NaN if no line-height is set */ || getFontSize(input) + CANDIDATE_WINDOW_OFFSET
2028
const panel = <HTMLElement>document.querySelector('#fcitx-theme')
2129
const frame = panel.getBoundingClientRect()
2230
panel.style.opacity = '1'
2331
panel.style.position = 'absolute'
2432
panel.style.height = '0' // let mouse event pass through
33+
panel.style.zIndex = '2147483647' // absolutely above preedit underline
2534
if (dragging) {
2635
x += dx
2736
y += dy
@@ -40,6 +49,59 @@ export function hidePanel() {
4049
panel.style.opacity = '0'
4150
}
4251

52+
function clearPreeditUnderline() {
53+
document.querySelectorAll('.fcitx-preedit-underline').forEach(div => div.remove())
54+
}
55+
56+
function drawUnderline(top: number, left: number, width: number, color: string) {
57+
const div = document.createElement('div')
58+
div.className = 'fcitx-preedit-underline'
59+
div.style.position = 'absolute'
60+
div.style.top = `${top}px`
61+
div.style.left = `${left}px`
62+
div.style.height = '1px'
63+
div.style.width = `${width}px`
64+
div.style.backgroundColor = color
65+
document.body.appendChild(div)
66+
}
67+
68+
function getTextWidth(input: HTMLElement, text: string) {
69+
const div = document.createElement('div')
70+
const style = getComputedStyle(input)
71+
div.style.position = 'absolute'
72+
div.style.opacity = '0'
73+
div.style.font = style.font
74+
div.textContent = text
75+
document.body.append(div)
76+
const { width } = div.getBoundingClientRect()
77+
div.remove()
78+
return width
79+
}
80+
81+
function drawPreeditUnderline(input: HTMLElement, start: number) {
82+
const box = input.getBoundingClientRect()
83+
const color = getComputedStyle(input).color
84+
const fontSize = getFontSize(input)
85+
const { top: startTop, left: startLeft } = getCaretCoordinates(input, start)
86+
const { top: endTop, left: endLeft } = getCaretCoordinates(input, start + preedit.length)
87+
let lastLeft = startLeft
88+
for (let i = 1, rowLeft = startLeft, rowTop = startTop; i <= preedit.length && rowTop < endTop; ++i) {
89+
const { top, left } = getCaretCoordinates(input, start + i)
90+
if (top !== rowTop) {
91+
// getCaretCoordinates can't tell the position of the end of previous line,
92+
// because it's equivalent to the start of next line, which is the actual place
93+
// that new character is written. So we need to calculate width of the last character.
94+
drawUnderline(box.top + rowTop + fontSize + UNDERLINE_OFFSET, box.left + rowLeft, lastLeft - rowLeft + getTextWidth(input, preedit[i - 1]), color)
95+
rowTop = top
96+
rowLeft = lastLeft
97+
}
98+
lastLeft = left
99+
}
100+
if (lastLeft !== endLeft) {
101+
drawUnderline(box.top + endTop + fontSize + UNDERLINE_OFFSET, box.left + lastLeft, endLeft - lastLeft, color)
102+
}
103+
}
104+
43105
function changeInput(commitText: string, preeditText: string, index: number) {
44106
/*
45107
____ pre|edit ____
@@ -64,15 +126,20 @@ ____ commit pre|edit ____
64126

65127
const start = input.selectionStart! - preeditIndex
66128
const end = preedit ? start + preedit.length : input.selectionEnd!
129+
const newStart = start + commitText.length
67130
input.value = input.value.slice(0, start) + commitText + preeditText + input.value.slice(end)
68131
// This may be triggered by user clicking panel. Focus to ensure setting selectionEnd works.
69132
input.focus()
70-
input.selectionStart = input.selectionEnd = start + commitText.length + i
133+
input.selectionStart = input.selectionEnd = newStart + i
71134
// For vue-based input, this is needed to synchronize state.
72135
input.dispatchEvent(new Event('change'))
73136
preedit = preeditText
74137
preeditIndex = i
75138
setSpellCheck(!preedit)
139+
clearPreeditUnderline()
140+
if (preedit) {
141+
drawPreeditUnderline(input, newStart)
142+
}
76143
}
77144

78145
export function setPreedit(text: string, index: number) {
@@ -86,4 +153,5 @@ export function commit(text: string) {
86153
export function resetPreedit() {
87154
preedit = ''
88155
preeditIndex = 0
156+
clearPreeditUnderline()
89157
}

tests/test-preedit.spec.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { Locator } from '@playwright/test'
22
import { expect, test } from '@playwright/test'
3-
import { init } from './util'
3+
import { getBox, init } from './util'
44

55
function getSpellCheck(locator: Locator) {
66
return locator.evaluate((el: HTMLElement) => el.spellcheck)
@@ -32,6 +32,7 @@ test('Respect original spellcheck value (manually set false)', async ({ page })
3232
el.spellcheck = false
3333
})
3434

35+
await textarea.click()
3536
await page.evaluate(() => {
3637
window.fcitx.setPreedit('pin xie', 7)
3738
})
@@ -42,3 +43,51 @@ test('Respect original spellcheck value (manually set false)', async ({ page })
4243
})
4344
expect(await getSpellCheck(textarea), 'Original spellcheck value is restored').toBe(false)
4445
})
46+
47+
test('Underline', async ({ page }) => {
48+
await init(page)
49+
50+
const textarea = page.locator('textarea')
51+
await textarea.evaluate((el: HTMLElement) => {
52+
el.style.width = '20px'
53+
el.style.fontSize = '16px'
54+
})
55+
await textarea.focus()
56+
await page.evaluate(() => {
57+
window.fcitx.setPreedit('aa', 0)
58+
})
59+
const underline = page.locator('.fcitx-preedit-underline')
60+
await expect(underline).toHaveCount(1)
61+
const box = await getBox(underline)
62+
expect(box.height).toBe(1)
63+
64+
await page.evaluate(() => {
65+
window.fcitx.setPreedit('', 0)
66+
})
67+
await expect(underline, 'Clearing preedit should clear underline').not.toBeAttached()
68+
69+
await page.evaluate(() => {
70+
window.fcitx.setPreedit('aaa', 0)
71+
})
72+
await expect(underline).toHaveCount(2)
73+
const firstBox = await getBox(underline.nth(0))
74+
const secondBox = await getBox(underline.nth(1))
75+
expect(firstBox).toEqual(box)
76+
expect(secondBox.height).toBe(1)
77+
expect(secondBox.x).toEqual(box.x)
78+
expect(secondBox.y).toBeGreaterThan(box.y)
79+
expect(secondBox.width, 'a should be thinner than aa').toBeLessThan(box.width)
80+
81+
await page.evaluate(() => {
82+
window.fcitx.setPreedit('啊', 0)
83+
})
84+
await expect(underline).toHaveCount(1)
85+
const aBox = await getBox(underline)
86+
expect(aBox.height).toBe(1)
87+
expect(aBox.x).toEqual(box.x)
88+
expect(aBox.y, '啊 could be taller than a').toBeGreaterThanOrEqual(box.y)
89+
expect(aBox.width, '啊 should be wider than a').toBeGreaterThan(secondBox.width)
90+
91+
await page.locator('input').click()
92+
await expect(underline, 'Focusing out should clear underline').not.toBeAttached()
93+
})

tests/util.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1-
import type {
2-
Page,
3-
} from '@playwright/test'
1+
import type { Locator, Page } from '@playwright/test'
42

53
export async function init(page: Page) {
64
await page.goto('http://localhost:9000')
75
return page.evaluate(() => {
86
return window.fcitxReady
97
})
108
}
9+
10+
export async function getBox(locator: Locator) {
11+
return (await locator.boundingBox())!
12+
}

0 commit comments

Comments
 (0)