fix(web): repair Safari textarea soft-wrap shrink

This commit is contained in:
lsdsjy
2026-08-13 15:54:13 +08:00
parent 887c4977db
commit 4edbdd443e
13 changed files with 410 additions and 7 deletions

View File

@@ -824,6 +824,116 @@ describe('running and lock semantics', () => {
expect(backdrop.textContent).toBe('line\n'.repeat(40))
})
it('repairs Safari native overflow after the mirror shrinks the draft', () => {
const vendor = vi.spyOn(window.navigator, 'vendor', 'get').mockReturnValue('Apple Computer, Inc.')
const userAgent = vi.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue(
'Mozilla/5.0 (Macintosh) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Safari/605.1.15',
)
onTestFinished(() => {
vendor.mockRestore()
userAgent.mockRestore()
})
const { textarea } = bench({ draft: 'two wrapped lines' })
const scrollport = textarea.closest<HTMLElement>('[data-input-scroll]')!
let inputRepaired = false
let scrollportRepaired = false
const inputLayouts: string[] = []
const scrollportLayouts: string[] = []
Object.defineProperty(textarea, 'clientHeight', {
configurable: true,
get: () => textarea.style.height === '29px' ? 29 : 28,
})
Object.defineProperty(textarea, 'scrollHeight', {
configurable: true,
get: () => inputRepaired ? 28 : 52,
})
Object.defineProperty(textarea, 'offsetHeight', {
configurable: true,
get: () => {
inputLayouts.push(textarea.style.height)
if (textarea.style.height === '') inputRepaired = true
return textarea.clientHeight
},
})
Object.defineProperty(scrollport, 'clientHeight', {
configurable: true,
get: () => {
if (scrollport.style.height === '53px') return 53
if (inputRepaired && !scrollportRepaired) return 52
return 28
},
})
Object.defineProperty(scrollport, 'offsetHeight', {
configurable: true,
get: () => {
scrollportLayouts.push(scrollport.style.height)
if (scrollport.style.height === '') scrollportRepaired = true
return scrollport.clientHeight
},
})
textarea.setSelectionRange(5, 5)
fireEvent.change(textarea, { target: { value: 'one line' } })
expect(inputLayouts).toEqual(['29px', ''])
expect(scrollportLayouts).toEqual(['53px', ''])
expect(textarea.style.height).toBe('')
expect(scrollport.style.height).toBe('')
expect(textarea.scrollHeight).toBe(textarea.clientHeight)
expect(scrollport.clientHeight).toBe(28)
})
it('does not force the Safari recovery for another iOS browser', () => {
const vendor = vi.spyOn(window.navigator, 'vendor', 'get').mockReturnValue('Apple Computer, Inc.')
const userAgent = vi.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue(
'Mozilla/5.0 (iPhone) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/140.0.0.0 Mobile/15E148 Safari/604.1',
)
onTestFinished(() => {
vendor.mockRestore()
userAgent.mockRestore()
})
const { textarea } = bench({ draft: 'two wrapped lines' })
const scrollport = textarea.closest<HTMLElement>('[data-input-scroll]')!
Object.defineProperty(textarea, 'clientHeight', { configurable: true, value: 28 })
Object.defineProperty(textarea, 'scrollHeight', { configurable: true, value: 52 })
Object.defineProperty(textarea, 'offsetHeight', {
configurable: true,
get: () => { throw new Error('non-Safari browser must not force textarea layout') },
})
Object.defineProperty(scrollport, 'offsetHeight', {
configurable: true,
get: () => { throw new Error('non-Safari browser must not force scrollport layout') },
})
fireEvent.change(textarea, { target: { value: 'one line' } })
expect(scrollport.style.height).toBe('')
})
it('does not read Safari layout while a native edit grows the draft', () => {
const vendor = vi.spyOn(window.navigator, 'vendor', 'get').mockReturnValue('Apple Computer, Inc.')
const userAgent = vi.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue(
'Mozilla/5.0 (Macintosh) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Safari/605.1.15',
)
onTestFinished(() => {
vendor.mockRestore()
userAgent.mockRestore()
})
const { textarea, shell } = bench({ draft: 'one line' })
Object.defineProperty(textarea, 'clientHeight', {
configurable: true,
get: () => { throw new Error('growing Safari input must not read layout') },
})
Object.defineProperty(textarea, 'scrollHeight', {
configurable: true,
get: () => { throw new Error('growing Safari input must not read layout') },
})
fireEvent.change(textarea, { target: { value: 'one line grows' } })
expect(shell.snapshot.draft).toBe('one line grows')
})
it('an edit the composer performs itself scrolls the caret back into view', async () => {
// Paste and cut suppress the native edit, so no engine reveals the caret
// for them. jsdom has no layout: the rects are stubbed,

View File

@@ -0,0 +1,131 @@
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest'
import { isSafariBrowser, repairSafariTextareaLayout } from '../src/client/skeleton/safari.ts'
describe('Safari browser detection', () => {
it.each([
{
name: 'desktop Safari',
vendor: 'Apple Computer, Inc.',
userAgent: 'Mozilla/5.0 (Macintosh) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Safari/605.1.15',
expected: true,
},
{
name: 'mobile Safari',
vendor: 'Apple Computer, Inc.',
userAgent: 'Mozilla/5.0 (iPhone) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Mobile/15E148 Safari/604.1',
expected: true,
},
{
name: 'desktop Chromium',
vendor: 'Google Inc.',
userAgent: 'Mozilla/5.0 (Macintosh) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36',
expected: false,
},
{
name: 'Chrome on iOS',
vendor: 'Apple Computer, Inc.',
userAgent: 'Mozilla/5.0 (iPhone) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/140.0.0.0 Mobile/15E148 Safari/604.1',
expected: false,
},
{
name: 'Edge on iOS with Safari tokens',
vendor: 'Apple Computer, Inc.',
userAgent: 'Mozilla/5.0 (iPhone) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 EdgiOS/140.0 Mobile/15E148 Safari/604.1',
expected: false,
},
{
name: 'Opera on iOS with Safari tokens',
vendor: 'Apple Computer, Inc.',
userAgent: 'Mozilla/5.0 (iPhone) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 OPiOS/6.0 Mobile/15E148 Safari/604.1',
expected: false,
},
{
name: 'Apple web view',
vendor: 'Apple Computer, Inc.',
userAgent: 'Mozilla/5.0 (iPhone) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148',
expected: false,
},
])('identifies $name', ({ vendor, userAgent, expected }) => {
expect(isSafariBrowser({ vendor, userAgent })).toBe(expected)
})
})
describe('Safari textarea layout recovery', () => {
it('does nothing while the textarea owns no scrollable overflow', () => {
const input = document.createElement('textarea')
Object.defineProperty(input, 'clientHeight', { value: 28 })
Object.defineProperty(input, 'scrollHeight', { value: 28 })
repairSafariTextareaLayout(input)
expect(input.style.height).toBe('')
})
it('invalidates a stale native layout and restores the owned height', () => {
const input = document.createElement('textarea')
const scrollport = document.createElement('div')
scrollport.setAttribute('data-input-scroll', '')
scrollport.appendChild(input)
input.value = 'abcdef'
input.setSelectionRange(3, 3)
input.style.height = '100%'
scrollport.style.height = '100%'
let inputRepaired = false
let scrollportRepaired = false
const inputLayouts: string[] = []
const scrollportLayouts: string[] = []
Object.defineProperty(input, 'clientHeight', {
get: () => input.style.height === '29px' ? 29 : 28,
})
Object.defineProperty(input, 'scrollHeight', {
get: () => inputRepaired ? 28 : 52,
})
Object.defineProperty(input, 'offsetHeight', {
get: () => {
inputLayouts.push(input.style.height)
if (input.style.height === '100%') inputRepaired = true
return input.clientHeight
},
})
Object.defineProperty(scrollport, 'clientHeight', {
get: () => {
if (scrollport.style.height === '53px') return 53
if (inputRepaired && !scrollportRepaired) return 52
return 28
},
})
Object.defineProperty(scrollport, 'offsetHeight', {
get: () => {
scrollportLayouts.push(scrollport.style.height)
if (scrollport.style.height === '100%') scrollportRepaired = true
return scrollport.clientHeight
},
})
repairSafariTextareaLayout(input)
expect(inputLayouts).toEqual(['29px', '100%'])
expect(scrollportLayouts).toEqual(['53px', '100%'])
expect(input.style.height).toBe('100%')
expect(scrollport.style.height).toBe('100%')
expect(input.scrollHeight).toBe(input.clientHeight)
expect(scrollport.clientHeight).toBe(28)
expect([input.selectionStart, input.selectionEnd]).toEqual([3, 3])
})
it('does nothing outside the composer scrollport', () => {
const input = document.createElement('textarea')
Object.defineProperty(input, 'clientHeight', { value: 28 })
Object.defineProperty(input, 'scrollHeight', { value: 52 })
repairSafariTextareaLayout(input)
expect(input.style.height).toBe('')
})
it('accepts an absent textarea during teardown', () => {
expect(() => { repairSafariTextareaLayout(null) }).not.toThrow()
})
})