mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
* fix(native-chat): keep a huge unbroken paste from freezing the chat
A long run with no whitespace ("xxxx…", "0000…", "abab…") wrapped with
kerning makes Chromium reshape it at every line, quadratic in run length:
128 KiB stalls 2.4 s, 1 MiB 19 s, 5 MiB never finishes. ProseMirror forces
that layout on paste and on each keystroke, so the composer freezes in
every terminal-backed chat, including paired servers and WSL. A sent
message stalls the transcript the same way: its fold clips the text but
still lays it all out (128 KiB: 2.5 s).
Runs over 256 characters now render with font-kerning: none, so prose of
any length and Shift+Enter lines keep kerning:
- composer: inline decorations, updated only for the lines a transaction
touches (a keystroke beside 8,000 decorated lines ~1 ms, not ~190 ms)
- user messages in the transcript and expanded queued cards: the whole
text when it holds such a run
Text, drafts and what is sent are unchanged. After: 1 MiB single line
84 ms, 5 MiB 312 ms (keystroke 248 ms), 1 MiB of 4000-char lines 50 ms
(was 2.3 s), transcript 128 KiB 80 ms. Worst case left kerned, 256-char
runs: 1 MiB 242 ms, 5 MiB 1.2 s, like 1 MiB of short lines (291 ms).
Fixes #32355
Co-authored-by: Manan Chataut <mananchataut@gmail.com>
* fix(native-chat): keep kerning on long CJK and Thai paragraphs
These scripts have no spaces but Chromium can break between their
characters, so a long paragraph is not one unbreakable run. Count
ideographs, kana, Hangul and Thai as break opportunities so such text
keeps kerning.
Co-authored-by: Manan Chataut <mananchataut@gmail.com>
* fix(native-chat): rescan only the edited run, and wait out IME compositions
Splitting one run inside a block of thousands replaced every decoration
in that block, and ProseMirror's removal is quadratic: 1,411 ms for one
space in a paragraph of 8,000 long runs. A change now rescans only from
the run edge before it to the run edge after it, and removes only the
decorations that overlap: ~1 ms for the same edit.
Decorations are only mapped while an IME composition is in progress, so
the composing text node is not re-rendered. The deferred range is
rescanned with the next non-composing change, or on compositionend when
none follows.
The keystroke-timing test is replaced by deterministic ones: unaffected
decorations are reused and the result equals a full scan; a composition
defers the rescan and compositionend runs it.
Co-authored-by: Manan Chataut <mananchataut@gmail.com>
* fix(native-chat): never split a CJK code point, and treat NBSP as glue
A supplementary ideograph (U+20000-U+3FFFF) broke a run at its high
surrogate only, so the decoration began on the low surrogate and split
the pair across elements. Both the run scan and the leftward edge scan
now treat the pair as one break.
NBSP joins words for Chromium's line breaker (UAX #14 class GL), so an
NBSP-joined paste is one unbreakable run and must lose kerning; drop it
from the break set. No other non-breaking characters were in it.
Drop a composer test whose coverage the decoration reuse test already
has.
Co-authored-by: Manan Chataut <mananchataut@gmail.com>
* fix(native-chat): classify surrogate pairs whole at a rescan's edge
Two lone-surrogate edge cases made the incremental rescan disagree with
a full scan:
- an edit completing a pair at the window start (a lone high surrogate,
then a pasted low surrogate and run) began the decoration on the low
surrogate; the window now widens past the whole pair
- a lone low surrogate after any break counted as a pair end, dropping
it from the run; that now requires a preceding high surrogate
A seeded fuzz also found inclusive decoration edges mapping across a
joined block's boundary, where ProseMirror cannot remove them; the
decorations are no longer inclusive.
Co-authored-by: Manan Chataut <mananchataut@gmail.com>
---------
Co-authored-by: Manan Chataut <mananchataut@gmail.com>
This commit is contained in:
@@ -215,3 +215,38 @@ it('moves focus into the composer when a paste is routed from elsewhere', async
|
||||
expect(editor.view.dom.contains(document.activeElement)).toBe(true)
|
||||
hiddenTerminal.remove()
|
||||
})
|
||||
|
||||
describe('a pasted run too long to lay out with kerning', () => {
|
||||
const unkerned = (container: HTMLElement) =>
|
||||
[...container.querySelectorAll<HTMLElement>('[style]')]
|
||||
.filter((element) => element.style.fontKerning === 'none')
|
||||
.map((element) => element.textContent)
|
||||
|
||||
it('turns kerning off on that run only and keeps the pasted text whole', () => {
|
||||
const { input, container } = setup('short line')
|
||||
const blob = 'x'.repeat(5000)
|
||||
act(() => {
|
||||
input.setSelectionRange(10, 10)
|
||||
insertNativeChatPastedText(input, `\nsee ${blob} here`)
|
||||
})
|
||||
expect(unkerned(container)).toEqual([blob])
|
||||
expect(input.value).toBe(`short line\nsee ${blob} here`)
|
||||
})
|
||||
|
||||
it('keeps kerning on prose, CJK and Shift+Enter lines, and never splits a CJK code point', () => {
|
||||
const { editor, container } = setup('')
|
||||
act(() => {
|
||||
editor.commands.insertContent('a few words '.repeat(500))
|
||||
editor.commands.insertContent('日本語の文章です。中文的句子。'.repeat(100))
|
||||
for (let line = 0; line < 60; line++) {
|
||||
editor.commands.insertContent('y'.repeat(70))
|
||||
editor.commands.setHardBreak()
|
||||
}
|
||||
editor.commands.insertContent('\u{20000}')
|
||||
editor.commands.insertContent('x'.repeat(300))
|
||||
editor.commands.setHardBreak()
|
||||
editor.commands.insertContent(`\u{20000}${'y'.repeat(300)}`)
|
||||
})
|
||||
expect(unkerned(container)).toEqual(['x'.repeat(300), 'y'.repeat(300)])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
import type { NativeChatComposerInput } from './native-chat-composer-input'
|
||||
import { NativeChatImageRowProvider } from './NativeChatImageReferencePill'
|
||||
import { mountedEditorView } from './native-chat-mounted-editor-view'
|
||||
import { NativeChatUnkernedRunDecorations } from './native-chat-unkerned-run-decorations'
|
||||
|
||||
type Props = Omit<HTMLAttributes<HTMLDivElement>, 'onChange' | 'onSelect'> & {
|
||||
scopeKey?: string
|
||||
@@ -99,6 +100,7 @@ function NativePromptEditor({
|
||||
NativeChatSkill,
|
||||
NativeChatFileReference,
|
||||
NativeChatImageReference,
|
||||
NativeChatUnkernedRunDecorations,
|
||||
// A disabled box still says why it is disabled, as the plain text box's placeholder does.
|
||||
Placeholder.configure({
|
||||
placeholder: () => placeholderRef.current,
|
||||
|
||||
@@ -41,6 +41,7 @@ import { NativeChatQueuedMessageComposer } from './NativeChatQueuedMessageCompos
|
||||
import type { QueuedEditAttachTarget } from './NativeChatQueuedMessageComposer'
|
||||
import type { QueuedMessageInlineEditor } from './use-structured-agent-session-queued-edit'
|
||||
import { nativeChatImageLabelCopy } from './native-chat-image-label'
|
||||
import { NATIVE_CHAT_UNKERNED_CSS, nativeChatTextHasUnkernedRun } from './native-chat-unkerned-runs'
|
||||
|
||||
/** The visible caption under the text; the default waiting hold needs none. */
|
||||
export function queuedMessageCardCaption(
|
||||
@@ -344,6 +345,7 @@ export function NativeChatQueuedMessageCard({
|
||||
<p
|
||||
id={textId}
|
||||
className="scrollbar-sleek mt-1 max-h-60 overflow-y-auto whitespace-pre-wrap break-words pl-5.5 text-sm"
|
||||
style={nativeChatTextHasUnkernedRun(line) ? NATIVE_CHAT_UNKERNED_CSS : undefined}
|
||||
>
|
||||
{line}
|
||||
</p>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { translate } from '@/i18n/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useNativeChatDisclosure } from './native-chat-disclosure-store'
|
||||
import { nativeChatUserMessageFolds } from '../../../../shared/native-chat/transcript/native-chat-user-message-fold'
|
||||
import { NATIVE_CHAT_UNKERNED_CSS, nativeChatTextHasUnkernedRun } from './native-chat-unkerned-runs'
|
||||
|
||||
export function NativeChatUserMessageFold({
|
||||
messageId,
|
||||
@@ -19,6 +20,8 @@ export function NativeChatUserMessageFold({
|
||||
}): React.JSX.Element {
|
||||
const disclosure = useNativeChatDisclosure(`user-message:${messageId}`, false)
|
||||
const longText = useMemo(() => nativeChatUserMessageFolds(markdown), [markdown])
|
||||
// A fold clips but still lays out the whole text, so it needs the composer's long-run rule.
|
||||
const unkerned = useMemo(() => nativeChatTextHasUnkernedRun(markdown), [markdown])
|
||||
// Measure each text revision; long-text heuristics can select a prompt that already fits the clip.
|
||||
const [measured, setMeasured] = useState({ markdown, fits: false })
|
||||
const fits = measured.markdown === markdown && measured.fits
|
||||
@@ -52,6 +55,7 @@ export function NativeChatUserMessageFold({
|
||||
id={clipId}
|
||||
data-native-chat-user-message-folded={folded ? '' : undefined}
|
||||
className={cn(folded && 'max-h-44 overflow-hidden mask-b-from-[calc(100%-1.75rem)]')}
|
||||
style={unkerned ? NATIVE_CHAT_UNKERNED_CSS : undefined}
|
||||
// Tabbing to a link under the fold scrolls the preview to it; the top returns once focus leaves.
|
||||
onBlur={(event) => {
|
||||
if (document.hasFocus() && !event.currentTarget.contains(event.relatedTarget)) {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { Editor, type JSONContent } from '@tiptap/core'
|
||||
import { EditorState, type Transaction } from '@tiptap/pm/state'
|
||||
import { DecorationSet, type Decoration } from '@tiptap/pm/view'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import { afterEach, expect, it } from 'vitest'
|
||||
import { NativeChatUnkernedRunDecorations } from './native-chat-unkerned-run-decorations'
|
||||
import { promptTextContent } from './native-chat-prompt-document'
|
||||
|
||||
let editor: Editor | null = null
|
||||
afterEach(() => {
|
||||
editor?.destroy()
|
||||
editor = null
|
||||
})
|
||||
|
||||
function decorations(state: EditorState): Decoration[] {
|
||||
return state.plugins.flatMap((plugin) => {
|
||||
const set = plugin.props.decorations?.call(plugin, state)
|
||||
return set instanceof DecorationSet ? set.find() : []
|
||||
})
|
||||
}
|
||||
|
||||
const ranges = (state: EditorState) => decorations(state).map(({ from, to }) => [from, to])
|
||||
|
||||
// One block of long runs split by spaces and Shift+Enter breaks, as a pasted log can be.
|
||||
function longRunBlock(runs: number): JSONContent {
|
||||
return {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: Array.from({ length: runs }, (_, index): JSONContent[] => [
|
||||
{ type: 'text', text: `${'x'.repeat(300)}${index % 2 ? ' ' : ''}` },
|
||||
...(index % 2 ? [] : [{ type: 'hardBreak' }])
|
||||
]).flat()
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
it('re-checks only the edited run of a long block and agrees with a full scan', () => {
|
||||
editor = new Editor({ extensions: [StarterKit, NativeChatUnkernedRunDecorations] })
|
||||
const { plugins } = editor.extensionManager
|
||||
const before = EditorState.create({
|
||||
doc: editor.schema.nodeFromJSON(longRunBlock(2000)),
|
||||
plugins
|
||||
})
|
||||
const old = decorations(before)
|
||||
expect(old).toHaveLength(2000)
|
||||
const edited = old[1000]
|
||||
const after = before.apply(before.tr.insertText(' ', edited.from + 150))
|
||||
const kept = decorations(after)
|
||||
expect(kept).toHaveLength(1999)
|
||||
const oldSpecs = new Set(old.map((decoration) => decoration.spec))
|
||||
expect(kept.every((decoration) => oldSpecs.has(decoration.spec))).toBe(true)
|
||||
expect(kept.some((decoration) => decoration.spec === edited.spec)).toBe(false)
|
||||
expect(ranges(after)).toEqual(ranges(EditorState.create({ doc: after.doc, plugins })))
|
||||
|
||||
// Edits that complete or border a surrogate pair at the window's edge.
|
||||
const x = (count: number) => 'x'.repeat(count)
|
||||
const edits: [string, (state: EditorState) => Transaction][] = [
|
||||
['\uD840', (state) => state.tr.insertText(`\uDC00${x(300)}`, 2)],
|
||||
[`\uD840 \uDC00${x(300)}`, (state) => state.tr.delete(2, 3)],
|
||||
[' \uDC00', (state) => state.tr.insertText(x(256), 3)]
|
||||
]
|
||||
for (const [text, edit] of edits) {
|
||||
const start = EditorState.create({
|
||||
doc: editor.schema.nodeFromJSON(promptTextContent(text)),
|
||||
plugins
|
||||
})
|
||||
const edited = start.apply(edit(start))
|
||||
expect(ranges(edited)).toEqual(ranges(EditorState.create({ doc: edited.doc, plugins })))
|
||||
}
|
||||
})
|
||||
|
||||
it('waits for an IME composition to end before redecorating, then rescans', async () => {
|
||||
const element = document.createElement('div')
|
||||
editor = new Editor({
|
||||
element,
|
||||
extensions: [StarterKit, NativeChatUnkernedRunDecorations],
|
||||
content: `<p>${'x'.repeat(400)}</p>`
|
||||
})
|
||||
const { view } = editor
|
||||
const unkerned = () => element.querySelectorAll('[style*="font-kerning"]').length
|
||||
expect(unkerned()).toBe(1)
|
||||
view.dispatch(view.state.tr.insertText(' ', 200).setMeta('composition', 1))
|
||||
expect(unkerned()).toBe(1)
|
||||
view.dom.dispatchEvent(new Event('compositionend'))
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
expect(unkerned()).toBe(0)
|
||||
expect(ranges(view.state)).toEqual([])
|
||||
})
|
||||
@@ -0,0 +1,162 @@
|
||||
import { Extension } from '@tiptap/core'
|
||||
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
|
||||
import { Plugin, PluginKey, type Transaction } from '@tiptap/pm/state'
|
||||
import { Decoration, DecorationSet } from '@tiptap/pm/view'
|
||||
import {
|
||||
NATIVE_CHAT_UNKERNED_STYLE,
|
||||
isHighSurrogate,
|
||||
isLowSurrogate,
|
||||
nativeChatBreaksUnkernedRun,
|
||||
nativeChatUnkernedRuns
|
||||
} from './native-chat-unkerned-runs'
|
||||
|
||||
type Range = [number, number]
|
||||
type UnkernedState = { set: DecorationSet; pending: Range | null }
|
||||
|
||||
function addRunDecorations(decorations: Decoration[], text: string, textPos: number): void {
|
||||
for (const [start, end] of nativeChatUnkernedRuns(text)) {
|
||||
// Not inclusive: an inclusive edge can map across a joined block's boundary, where it can't be removed.
|
||||
decorations.push(
|
||||
Decoration.inline(textPos + start, textPos + end, { style: NATIVE_CHAT_UNKERNED_STYLE }, {})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function allDecorations(doc: ProseMirrorNode): DecorationSet {
|
||||
const decorations: Decoration[] = []
|
||||
doc.descendants((node, pos) => {
|
||||
if (node.isText) {
|
||||
addRunDecorations(decorations, node.text ?? '', pos)
|
||||
}
|
||||
return !node.isInline
|
||||
})
|
||||
return DecorationSet.create(doc, decorations)
|
||||
}
|
||||
|
||||
function union(a: Range | null, b: Range | null): Range | null {
|
||||
return a && b ? [Math.min(a[0], b[0]), Math.max(a[1], b[1])] : (a ?? b)
|
||||
}
|
||||
|
||||
function changedHull(tr: Transaction): Range | null {
|
||||
let hull: Range | null = null
|
||||
tr.mapping.maps.forEach((map, index) => {
|
||||
const rest = tr.mapping.slice(index + 1)
|
||||
map.forEach((_oldFrom, _oldTo, from, to) => {
|
||||
hull = union(hull, [rest.map(from, -1), rest.map(to, 1)])
|
||||
})
|
||||
})
|
||||
return hull
|
||||
}
|
||||
|
||||
/** Where the run through `pos` ends in direction `dir`; the text beyond `pos` is unchanged. */
|
||||
function runEdge(doc: ProseMirrorNode, set: DecorationSet, pos: number, dir: -1 | 1): number {
|
||||
const $pos = doc.resolve(pos)
|
||||
const node = dir < 0 ? $pos.nodeBefore : $pos.nodeAfter
|
||||
if (!node?.isText || !node.text) {
|
||||
return pos
|
||||
}
|
||||
// A decoration over the unchanged side already knows where that run ends.
|
||||
const covering = set
|
||||
.find(pos - 1, pos + 1)
|
||||
.find((d) => (dir < 0 ? d.from < pos && d.to >= pos : d.to > pos && d.from <= pos))
|
||||
if (covering) {
|
||||
return dir < 0 ? covering.from : covering.to
|
||||
}
|
||||
const { text } = node
|
||||
if (dir < 0) {
|
||||
for (let index = text.length - 1; index >= 0; index--) {
|
||||
const code = text.charCodeAt(index)
|
||||
// Ending on a supplementary ideograph's low surrogate, the run starts after the pair.
|
||||
const high = text.charCodeAt(index - 1)
|
||||
const pairEnd =
|
||||
isLowSurrogate(code) && isHighSurrogate(high) && nativeChatBreaksUnkernedRun(high)
|
||||
if (pairEnd || nativeChatBreaksUnkernedRun(code)) {
|
||||
return pos - (text.length - 1 - index)
|
||||
}
|
||||
}
|
||||
return pos - text.length
|
||||
}
|
||||
for (let index = 0; index < text.length; index++) {
|
||||
if (nativeChatBreaksUnkernedRun(text.charCodeAt(index))) {
|
||||
return pos + index
|
||||
}
|
||||
}
|
||||
return pos + text.length
|
||||
}
|
||||
|
||||
// Why windowed: replacing every decoration in a block per keystroke is quadratic in its runs.
|
||||
function rescan(doc: ProseMirrorNode, set: DecorationSet, [from, to]: Range): DecorationSet {
|
||||
const size = doc.content.size
|
||||
let left = runEdge(doc, set, Math.max(0, Math.min(from, size)), -1)
|
||||
const $left = doc.resolve(left)
|
||||
const before = $left.nodeBefore?.text ?? ''
|
||||
// An edit can complete a surrogate pair at the window's edge; classify the pair whole.
|
||||
if (
|
||||
isHighSurrogate(before.charCodeAt(before.length - 1)) &&
|
||||
isLowSurrogate(($left.nodeAfter?.text ?? '').charCodeAt(0))
|
||||
) {
|
||||
left = runEdge(doc, set, left - 1, -1)
|
||||
}
|
||||
const right = runEdge(doc, set, Math.max(0, Math.min(to, size)), 1)
|
||||
const fresh: Decoration[] = []
|
||||
doc.nodesBetween(left, right, (node, pos) => {
|
||||
if (node.isText) {
|
||||
const start = Math.max(left, pos)
|
||||
const end = Math.min(right, pos + node.nodeSize)
|
||||
addRunDecorations(fresh, (node.text ?? '').slice(start - pos, end - pos), start)
|
||||
}
|
||||
return !node.isInline
|
||||
})
|
||||
const stale = set.find(left, right).filter((d) => d.from < right && d.to > left)
|
||||
const unchanged =
|
||||
stale.length === fresh.length &&
|
||||
stale.every((old, index) => old.from === fresh[index].from && old.to === fresh[index].to)
|
||||
return unchanged ? set : set.remove(stale).add(doc, fresh)
|
||||
}
|
||||
|
||||
/** Turns kerning off only on runs long enough to stall layout; the text is untouched. */
|
||||
export const NativeChatUnkernedRunDecorations = Extension.create({
|
||||
name: 'nativeChatUnkernedRunDecorations',
|
||||
addProseMirrorPlugins() {
|
||||
const key = new PluginKey<UnkernedState>('nativeChatUnkernedRunDecorations')
|
||||
return [
|
||||
new Plugin<UnkernedState>({
|
||||
key,
|
||||
state: {
|
||||
init: (_, state) => ({ set: allDecorations(state.doc), pending: null }),
|
||||
apply: (tr, previous) => {
|
||||
const rescanRequested = tr.getMeta(key) === true
|
||||
if (!tr.docChanged && !rescanRequested) {
|
||||
return previous
|
||||
}
|
||||
const set = previous.set.map(tr.mapping, tr.doc)
|
||||
const mappedPending: Range | null = previous.pending && [
|
||||
tr.mapping.map(previous.pending[0], -1),
|
||||
tr.mapping.map(previous.pending[1], 1)
|
||||
]
|
||||
const pending = union(mappedPending, changedHull(tr))
|
||||
// Why: redecorating mid-composition re-renders the IME's text node; rescan once it ends.
|
||||
if (tr.getMeta('composition') !== undefined) {
|
||||
return { set, pending }
|
||||
}
|
||||
return { set: pending ? rescan(tr.doc, set, pending) : set, pending: null }
|
||||
}
|
||||
},
|
||||
props: {
|
||||
decorations: (state) => key.getState(state)?.set,
|
||||
handleDOMEvents: {
|
||||
// A composition can end with no further doc change, so ask for its rescan.
|
||||
compositionend: (view) => {
|
||||
setTimeout(() => {
|
||||
if (!view.isDestroyed && !view.composing && key.getState(view.state)?.pending) {
|
||||
view.dispatch(view.state.tr.setMeta(key, true).setMeta('addToHistory', false))
|
||||
}
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
]
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { CSSProperties } from 'react'
|
||||
|
||||
// Why: Chromium reshapes a kerned run with no break opportunity at every wrap, quadratic in its
|
||||
// length (1 MiB of "x": 19 s); past this length kerning goes, which keeps 1 MiB of such runs near 0.2 s.
|
||||
const KERNED_RUN_MAX_CHARS = 256
|
||||
|
||||
export const NATIVE_CHAT_UNKERNED_STYLE = 'font-kerning: none'
|
||||
export const NATIVE_CHAT_UNKERNED_CSS: CSSProperties = { fontKerning: 'none' }
|
||||
|
||||
// Whitespace, plus scripts Chromium breaks between characters: Thai, Hangul, kana, CJK ideographs.
|
||||
export function nativeChatBreaksUnkernedRun(code: number): boolean {
|
||||
return (
|
||||
code === 32 ||
|
||||
(code >= 9 && code <= 13) ||
|
||||
(code >= 0x0e00 && code <= 0x0e7f) ||
|
||||
(code >= 0x1100 && code <= 0x11ff) ||
|
||||
(code >= 0x2e80 && code <= 0x9fff) ||
|
||||
(code >= 0xa960 && code <= 0xa97f) ||
|
||||
(code >= 0xac00 && code <= 0xd7af) ||
|
||||
// High surrogates of the supplementary ideograph planes (U+20000-U+3FFFF).
|
||||
(code >= 0xd840 && code <= 0xd8bf) ||
|
||||
(code >= 0xf900 && code <= 0xfaff) ||
|
||||
(code >= 0xff00 && code <= 0xffef)
|
||||
)
|
||||
}
|
||||
|
||||
export const isHighSurrogate = (code: number) => code >= 0xd800 && code <= 0xdbff
|
||||
export const isLowSurrogate = (code: number) => code >= 0xdc00 && code <= 0xdfff
|
||||
|
||||
/** `[start, end)` of every run with no break opportunity too long to lay out kerned; one linear pass. */
|
||||
export function nativeChatUnkernedRuns(text: string, limit = Infinity): [number, number][] {
|
||||
const runs: [number, number][] = []
|
||||
let start = 0
|
||||
for (let index = 0; index <= text.length && runs.length < limit; index++) {
|
||||
const code = text.charCodeAt(index)
|
||||
if (index === text.length || nativeChatBreaksUnkernedRun(code)) {
|
||||
if (index - start > KERNED_RUN_MAX_CHARS) {
|
||||
runs.push([start, index])
|
||||
}
|
||||
// A supplementary ideograph breaks as one code point, both surrogates.
|
||||
const width = isHighSurrogate(code) && isLowSurrogate(text.charCodeAt(index + 1)) ? 2 : 1
|
||||
start = index + width
|
||||
index += width - 1
|
||||
}
|
||||
}
|
||||
return runs
|
||||
}
|
||||
|
||||
export function nativeChatTextHasUnkernedRun(text: string): boolean {
|
||||
return nativeChatUnkernedRuns(text, 1).length > 0
|
||||
}
|
||||
Reference in New Issue
Block a user