mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
fix(web): align rich composer DOM and Unicode boundaries (#1325)
* fix(web): align rich composer DOM offsets * fix(web): truncate session titles safely * fix(web): preserve session title size bounds * fix(web): normalize empty wrapper offsets
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { serializeComposerSegments } from '@/lib/composerSegments'
|
||||
import { mirrorComposerSegments, serializeComposerSegments } from '@/lib/composerSegments'
|
||||
import {
|
||||
insertLineBreakAtCaret,
|
||||
mirrorOffsetFromPoint,
|
||||
mirrorSelectionFromPoints,
|
||||
segmentsFromEditor,
|
||||
} from './RichComposerInput'
|
||||
|
||||
@@ -152,10 +153,141 @@ describe('mirrorOffsetFromPoint', () => {
|
||||
it('matches segmentsFromEditor length for br-separated lines', () => {
|
||||
const root = document.createElement('div')
|
||||
root.innerHTML = 'a<br>b'
|
||||
const mirrorLen = serializeComposerSegments(segmentsFromEditor(root)).length
|
||||
const mirrorLen = mirrorComposerSegments(segmentsFromEditor(root)).length
|
||||
// caret after 'b' → end of second text node
|
||||
const b = root.childNodes[2] as Text
|
||||
expect(b.nodeType).toBe(Node.TEXT_NODE)
|
||||
expect(mirrorOffsetFromPoint(root, b, b.textContent!.length)).toBe(mirrorLen)
|
||||
})
|
||||
|
||||
it('keeps a direct br point at its explicit newline boundary', () => {
|
||||
const root = document.createElement('div')
|
||||
root.innerHTML = 'a<br>b'
|
||||
const br = root.childNodes[1] as HTMLElement
|
||||
|
||||
expect(mirrorOffsetFromPoint(root, br, 0)).toBe(1)
|
||||
expect(mirrorOffsetFromPoint(root, root, 2)).toBe(2)
|
||||
})
|
||||
|
||||
it('includes implicit block newlines for text and root-anchored points', () => {
|
||||
const root = document.createElement('div')
|
||||
root.innerHTML = '<div>one</div><div>two</div>'
|
||||
const two = root.lastChild!.firstChild as Text
|
||||
|
||||
expect(mirrorComposerSegments(segmentsFromEditor(root))).toBe('one\ntwo')
|
||||
// The boundary before the second block is the visual start of "two",
|
||||
// after the implicit newline rather than after "one".
|
||||
expect(mirrorOffsetFromPoint(root, root, 1)).toBe(4)
|
||||
expect(mirrorOffsetFromPoint(root, two, two.length)).toBe(7)
|
||||
expect(mirrorOffsetFromPoint(root, root, root.childNodes.length)).toBe(7)
|
||||
})
|
||||
|
||||
it('normalizes a text descendant of a session chip to atom boundaries', () => {
|
||||
const root = document.createElement('div')
|
||||
root.innerHTML =
|
||||
'before <span contenteditable="false" data-composer-mention="session" data-session-id="aaa" data-session-title="Peer A"><strong>@Peer A</strong></span> after'
|
||||
const chip = root.children[0] as HTMLElement
|
||||
const visibleChipText = chip.firstChild!.firstChild as Text
|
||||
const mirror = mirrorComposerSegments(segmentsFromEditor(root))
|
||||
|
||||
expect(mirror).toBe('before \uFFFC after')
|
||||
expect(mirrorOffsetFromPoint(root, visibleChipText, 0)).toBe(7)
|
||||
expect(mirrorOffsetFromPoint(root, visibleChipText, visibleChipText.length)).toBe(8)
|
||||
expect(mirrorOffsetFromPoint(root, visibleChipText, visibleChipText.length)).not.toBe(mirror.length)
|
||||
})
|
||||
|
||||
it('keeps forward and reverse cross-chip selections in one mirror coordinate space', () => {
|
||||
const root = document.createElement('div')
|
||||
root.innerHTML =
|
||||
'one <span contenteditable="false" data-composer-mention="session" data-session-id="aaa" data-session-title="Peer A">@Peer A</span> two <span contenteditable="false" data-composer-mention="session" data-session-id="bbb" data-session-title="Peer B"><em>@Peer B</em></span> three'
|
||||
const firstChipText = root.children[0]!.firstChild as Text
|
||||
const secondChipText = root.children[1]!.firstChild!.firstChild as Text
|
||||
|
||||
const forward = mirrorSelectionFromPoints(
|
||||
root,
|
||||
firstChipText,
|
||||
0,
|
||||
secondChipText,
|
||||
secondChipText.length
|
||||
)
|
||||
const reverse = mirrorSelectionFromPoints(
|
||||
root,
|
||||
secondChipText,
|
||||
secondChipText.length,
|
||||
firstChipText,
|
||||
0
|
||||
)
|
||||
|
||||
expect(forward).toEqual({ start: 4, end: 11 })
|
||||
expect(reverse).toEqual(forward)
|
||||
})
|
||||
|
||||
it('uses the same offsets across nested non-empty block wrappers', () => {
|
||||
const root = document.createElement('div')
|
||||
root.innerHTML = '<div>one<div><p>two</p><p>three</p></div></div><div>four</div>'
|
||||
const one = root.firstChild!.firstChild as Text
|
||||
const two = (root.firstChild!.childNodes[1] as HTMLElement).firstChild!.firstChild as Text
|
||||
const four = root.lastChild!.firstChild as Text
|
||||
const mirror = mirrorComposerSegments(segmentsFromEditor(root))
|
||||
|
||||
expect(mirror).toBe('one\ntwo\nthree\nfour')
|
||||
expect(mirrorOffsetFromPoint(root, two, 0)).toBe(4)
|
||||
expect(mirrorOffsetFromPoint(root, root, 1)).toBe(14)
|
||||
expect(mirrorSelectionFromPoints(root, one, 1, four, four.length)).toEqual({
|
||||
start: 1,
|
||||
end: mirror.length,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps bare empty block wrappers out of the wire mirror', () => {
|
||||
const root = document.createElement('div')
|
||||
root.innerHTML = '<div>one</div><div></div><div>two</div>'
|
||||
|
||||
expect(mirrorComposerSegments(segmentsFromEditor(root))).toBe('one\ntwo')
|
||||
// Both boundaries around a bare empty wrapper normalize to the next
|
||||
// visible block start; the wrapper itself creates no new mirror slot.
|
||||
expect(mirrorOffsetFromPoint(root, root, 1)).toBe(4)
|
||||
expect(mirrorOffsetFromPoint(root, root, 2)).toBe(4)
|
||||
const empty = root.children[1] as HTMLElement
|
||||
expect(mirrorOffsetFromPoint(root, empty, 0)).toBe(4)
|
||||
|
||||
const trailingEmpty = document.createElement('div')
|
||||
trailingEmpty.innerHTML = '<div>one</div><div></div>'
|
||||
expect(mirrorComposerSegments(segmentsFromEditor(trailingEmpty))).toBe('one')
|
||||
expect(mirrorOffsetFromPoint(trailingEmpty, trailingEmpty.children[1]!, 0)).toBe(3)
|
||||
|
||||
const leadingEmpty = document.createElement('div')
|
||||
leadingEmpty.innerHTML = '<div></div><div>two</div>'
|
||||
expect(mirrorOffsetFromPoint(leadingEmpty, leadingEmpty.children[0]!, 0)).toBe(0)
|
||||
|
||||
const nestedEmpty = document.createElement('div')
|
||||
nestedEmpty.innerHTML = '<div>one<div></div><div>two</div></div>'
|
||||
const nested = nestedEmpty.firstChild as HTMLElement
|
||||
expect(mirrorOffsetFromPoint(nestedEmpty, nested.children[0]!, 0)).toBe(4)
|
||||
|
||||
const sectionWrapped = document.createElement('div')
|
||||
sectionWrapped.innerHTML = '<div>one</div><section><div></div></section><div>two</div>'
|
||||
const section = sectionWrapped.children[1] as HTMLElement
|
||||
const sectionInner = section.firstElementChild!
|
||||
expect(mirrorOffsetFromPoint(sectionWrapped, sectionWrapped, 1)).toBe(4)
|
||||
expect(mirrorOffsetFromPoint(sectionWrapped, sectionWrapped, 2)).toBe(4)
|
||||
expect(mirrorOffsetFromPoint(sectionWrapped, section, 0)).toBe(4)
|
||||
expect(mirrorOffsetFromPoint(sectionWrapped, sectionInner, 0)).toBe(4)
|
||||
|
||||
const listWrapped = document.createElement('div')
|
||||
listWrapped.innerHTML = '<div>one</div><ul><li></li></ul><div>two</div>'
|
||||
const list = listWrapped.children[1] as HTMLElement
|
||||
const listItem = list.firstElementChild!
|
||||
expect(mirrorOffsetFromPoint(listWrapped, listWrapped, 1)).toBe(4)
|
||||
expect(mirrorOffsetFromPoint(listWrapped, listWrapped, 2)).toBe(4)
|
||||
expect(mirrorOffsetFromPoint(listWrapped, list, 0)).toBe(4)
|
||||
expect(mirrorOffsetFromPoint(listWrapped, listItem, 0)).toBe(4)
|
||||
|
||||
const inline = document.createElement('div')
|
||||
inline.innerHTML = '<span></span>one<span></span>two'
|
||||
expect(mirrorComposerSegments(segmentsFromEditor(inline))).toBe('onetwo')
|
||||
expect(mirrorOffsetFromPoint(inline, inline.children[0]!, 0)).toBe(0)
|
||||
expect(mirrorOffsetFromPoint(inline, inline.children[1]!, 0)).toBe(3)
|
||||
expect(mirrorOffsetFromPoint(inline, inline, 2)).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -111,15 +111,37 @@ function stripCaretPad(text: string): string {
|
||||
return text.replaceAll(CARET_PAD, '')
|
||||
}
|
||||
|
||||
/** Exported for unit tests — maps contenteditable DOM → composer segments. */
|
||||
export function segmentsFromEditor(root: HTMLElement): ComposerSegment[] {
|
||||
type ComposerDomSpan = {
|
||||
/** Mirror offset at the point inside this node where its visible content begins. */
|
||||
start: number
|
||||
/** Mirror offset after this node's visible content. */
|
||||
end: number
|
||||
/** The node caused some serialized mirror output, including a pending block break. */
|
||||
producesOutput: boolean
|
||||
}
|
||||
|
||||
type ComposerDomMapping = {
|
||||
segments: ComposerSegment[]
|
||||
mirrorLength: number
|
||||
spans: Map<Node, ComposerDomSpan>
|
||||
}
|
||||
|
||||
/**
|
||||
* One DOM traversal is the source of truth for both serialized segments and
|
||||
* DOM-point offsets. In particular, a block break is emitted immediately
|
||||
* before the next visible node, so its offset belongs to that node's start.
|
||||
*/
|
||||
function mapComposerEditorDom(root: HTMLElement): ComposerDomMapping {
|
||||
const segments: ComposerSegment[] = []
|
||||
const spans = new Map<Node, ComposerDomSpan>()
|
||||
let mirrorLength = 0
|
||||
let pendingBlockBreak = false
|
||||
|
||||
const pushText = (text: string) => {
|
||||
const cleaned = stripCaretPad(text)
|
||||
if (!cleaned) return
|
||||
segments.push({ type: 'text', text: cleaned })
|
||||
mirrorLength += cleaned.length
|
||||
}
|
||||
|
||||
const pushNewlineIfNeeded = () => {
|
||||
@@ -133,9 +155,18 @@ export function segmentsFromEditor(root: HTMLElement): ComposerSegment[] {
|
||||
}
|
||||
|
||||
const walk = (node: Node) => {
|
||||
const before = mirrorLength
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
pushNewlineIfNeeded()
|
||||
const start = mirrorLength
|
||||
pushText(node.textContent ?? '')
|
||||
spans.set(node, {
|
||||
start,
|
||||
end: mirrorLength,
|
||||
// An empty text node can still materialize a pending block
|
||||
// newline, matching the existing serializer behavior.
|
||||
producesOutput: mirrorLength > before,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return
|
||||
@@ -144,6 +175,7 @@ export function segmentsFromEditor(root: HTMLElement): ComposerSegment[] {
|
||||
// that would strip the id from the agent prompt on send.
|
||||
if (el.dataset.composerMention === 'session') {
|
||||
pushNewlineIfNeeded()
|
||||
const start = mirrorLength
|
||||
const id = el.dataset.sessionId?.trim()
|
||||
if (id) {
|
||||
segments.push({
|
||||
@@ -151,13 +183,25 @@ export function segmentsFromEditor(root: HTMLElement): ComposerSegment[] {
|
||||
id,
|
||||
title: el.dataset.sessionTitle || id.slice(0, 8),
|
||||
})
|
||||
mirrorLength += 1
|
||||
}
|
||||
// Orphan chip (missing id): drop it rather than emit title-only.
|
||||
spans.set(node, {
|
||||
start,
|
||||
end: mirrorLength,
|
||||
producesOutput: mirrorLength > before,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (el.tagName === 'BR') {
|
||||
pushNewlineIfNeeded()
|
||||
const start = mirrorLength
|
||||
pushText('\n')
|
||||
spans.set(node, {
|
||||
start,
|
||||
end: mirrorLength,
|
||||
producesOutput: mirrorLength > before,
|
||||
})
|
||||
return
|
||||
}
|
||||
const isBlock = BLOCK_TAGS.has(el.tagName)
|
||||
@@ -172,11 +216,36 @@ export function segmentsFromEditor(root: HTMLElement): ComposerSegment[] {
|
||||
if (isBlock) {
|
||||
pendingBlockBreak = true
|
||||
}
|
||||
const firstOutputChild = Array.from(el.childNodes)
|
||||
.map((child) => spans.get(child))
|
||||
.find((span) => span?.producesOutput)
|
||||
spans.set(node, {
|
||||
// A nested block's start is after its implicit preceding newline.
|
||||
// This makes parent-anchored points at that block's start agree
|
||||
// with the text point at the beginning of the block.
|
||||
start: firstOutputChild?.start ?? before,
|
||||
end: mirrorLength,
|
||||
producesOutput: mirrorLength > before,
|
||||
})
|
||||
}
|
||||
for (const child of Array.from(root.childNodes)) {
|
||||
walk(child)
|
||||
}
|
||||
return coalesceComposerSegments(segments)
|
||||
spans.set(root, {
|
||||
start: 0,
|
||||
end: mirrorLength,
|
||||
producesOutput: mirrorLength > 0,
|
||||
})
|
||||
return {
|
||||
segments: coalesceComposerSegments(segments),
|
||||
mirrorLength,
|
||||
spans,
|
||||
}
|
||||
}
|
||||
|
||||
/** Exported for unit tests — maps contenteditable DOM → composer segments. */
|
||||
export function segmentsFromEditor(root: HTMLElement): ComposerSegment[] {
|
||||
return mapComposerEditorDom(root).segments
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -249,78 +318,147 @@ function renderSegmentsToEditor(
|
||||
}
|
||||
}
|
||||
|
||||
function containingSessionMention(root: HTMLElement, node: Node): HTMLElement | null {
|
||||
let current: Node | null = node
|
||||
while (current && current !== root) {
|
||||
if (
|
||||
current.nodeType === Node.ELEMENT_NODE
|
||||
&& (current as HTMLElement).dataset.composerMention === 'session'
|
||||
) {
|
||||
return current as HTMLElement
|
||||
}
|
||||
current = current.parentNode
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** True only for a DOM point at the structural start of a session atom. */
|
||||
function isPointAtSessionMentionStart(
|
||||
mention: HTMLElement,
|
||||
container: Node,
|
||||
offset: number
|
||||
): boolean {
|
||||
if (offset !== 0) return false
|
||||
if (container === mention) return true
|
||||
|
||||
let current: Node | null = container
|
||||
while (current && current !== mention) {
|
||||
const parent: ParentNode | null = current.parentNode
|
||||
if (!parent || parent.firstChild !== current) return false
|
||||
current = parent
|
||||
}
|
||||
return current === mention
|
||||
}
|
||||
|
||||
function mirrorOffsetWithinElement(
|
||||
container: HTMLElement,
|
||||
offset: number,
|
||||
mapping: ComposerDomMapping
|
||||
): number {
|
||||
const span = mapping.spans.get(container)
|
||||
if (!span) return mapping.mirrorLength
|
||||
|
||||
// Any zero-output element has no mirror position of its own. Normalize it
|
||||
// to the boundary immediately after that element in its parent; this
|
||||
// deliberately climbs only to strict parents, including empty non-block
|
||||
// wrappers such as <section> or <ul> around an empty block.
|
||||
if (!span.producesOutput) {
|
||||
const parent = container.parentElement
|
||||
if (parent) {
|
||||
const index = Array.from(parent.childNodes).indexOf(container)
|
||||
if (index >= 0) return mirrorOffsetWithinElement(parent, index + 1, mapping)
|
||||
}
|
||||
}
|
||||
|
||||
const children = Array.from(container.childNodes)
|
||||
const childOffset = Math.max(0, Math.min(offset, children.length))
|
||||
// A parent boundary before a later block belongs at that block's visible
|
||||
// start, including its implicit newline. This is what root-anchored
|
||||
// selection points need for <div>one</div><div>two</div>.
|
||||
for (let i = childOffset; i < children.length; i++) {
|
||||
const childSpan = mapping.spans.get(children[i]!)
|
||||
if (childSpan?.producesOutput) return childSpan.start
|
||||
}
|
||||
return span.end
|
||||
}
|
||||
|
||||
function mirrorOffsetFromMappedPoint(
|
||||
root: HTMLElement,
|
||||
endContainer: Node,
|
||||
endOffset: number,
|
||||
mapping: ComposerDomMapping
|
||||
): number {
|
||||
const mention = containingSessionMention(root, endContainer)
|
||||
if (mention) {
|
||||
const span = mapping.spans.get(mention)
|
||||
if (!span) return mapping.mirrorLength
|
||||
return isPointAtSessionMentionStart(mention, endContainer, endOffset)
|
||||
? span.start
|
||||
: span.end
|
||||
}
|
||||
|
||||
if (endContainer.nodeType === Node.TEXT_NODE) {
|
||||
const span = mapping.spans.get(endContainer)
|
||||
if (!span) return mapping.mirrorLength
|
||||
const raw = endContainer.textContent ?? ''
|
||||
const rawOffset = Math.max(0, Math.min(endOffset, raw.length))
|
||||
return Math.min(span.end, span.start + stripCaretPad(raw.slice(0, rawOffset)).length)
|
||||
}
|
||||
|
||||
if (endContainer.nodeType === Node.ELEMENT_NODE) {
|
||||
const element = endContainer as HTMLElement
|
||||
// A Range point directly on <br> has no child boundary. Treat it as
|
||||
// the position before the explicit newline, as browsers do for a
|
||||
// parent boundary immediately before that node.
|
||||
if (element.tagName === 'BR') {
|
||||
return mapping.spans.get(element)?.start ?? mapping.mirrorLength
|
||||
}
|
||||
return mirrorOffsetWithinElement(element, endOffset, mapping)
|
||||
}
|
||||
|
||||
return mapping.mirrorLength
|
||||
}
|
||||
|
||||
/** Exported for unit tests — maps a DOM caret point into mirror-string offset. */
|
||||
export function mirrorOffsetFromPoint(root: HTMLElement, endContainer: Node, endOffset: number): number {
|
||||
let count = 0
|
||||
const mapping = mapComposerEditorDom(root)
|
||||
return mirrorOffsetFromMappedPoint(root, endContainer, endOffset, mapping)
|
||||
}
|
||||
|
||||
const visit = (n: Node): boolean => {
|
||||
if (n === endContainer && n.nodeType === Node.TEXT_NODE) {
|
||||
const raw = n.textContent ?? ''
|
||||
count += stripCaretPad(raw.slice(0, endOffset)).length
|
||||
return true
|
||||
}
|
||||
if (n.nodeType === Node.TEXT_NODE) {
|
||||
count += stripCaretPad(n.textContent ?? '').length
|
||||
return false
|
||||
}
|
||||
if (n.nodeType !== Node.ELEMENT_NODE) return false
|
||||
const el = n as HTMLElement
|
||||
if (el.dataset.composerMention === 'session') {
|
||||
if (n === endContainer) {
|
||||
count += endOffset > 0 ? 1 : 0
|
||||
return true
|
||||
}
|
||||
count += 1
|
||||
return false
|
||||
}
|
||||
if (el.tagName === 'BR') {
|
||||
if (n === endContainer) return true
|
||||
count += 1
|
||||
return false
|
||||
}
|
||||
if (n === endContainer) {
|
||||
const children = Array.from(n.childNodes)
|
||||
for (let i = 0; i < endOffset && i < children.length; i++) {
|
||||
if (visit(children[i]!)) return true
|
||||
}
|
||||
return true
|
||||
}
|
||||
for (const child of Array.from(n.childNodes)) {
|
||||
if (visit(child)) return true
|
||||
}
|
||||
return false
|
||||
/**
|
||||
* Maps either direction of a DOM selection into the ordered mirror range.
|
||||
* Exported for unit tests; production selection reads use the same mapping.
|
||||
*/
|
||||
export function mirrorSelectionFromPoints(
|
||||
root: HTMLElement,
|
||||
startContainer: Node,
|
||||
startOffset: number,
|
||||
endContainer: Node,
|
||||
endOffset: number
|
||||
): ComposerSelection {
|
||||
const mapping = mapComposerEditorDom(root)
|
||||
if (!root.contains(startContainer) || !root.contains(endContainer)) {
|
||||
return { start: mapping.mirrorLength, end: mapping.mirrorLength }
|
||||
}
|
||||
|
||||
// Root-anchored ranges (caret before a leading chip, select-all) report
|
||||
// endContainer === root; visit only children of that offset.
|
||||
if (endContainer === root) {
|
||||
const children = Array.from(root.childNodes)
|
||||
for (let i = 0; i < endOffset && i < children.length; i++) {
|
||||
visit(children[i]!)
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
for (const child of Array.from(root.childNodes)) {
|
||||
if (visit(child)) break
|
||||
}
|
||||
return count
|
||||
const start = mirrorOffsetFromMappedPoint(root, startContainer, startOffset, mapping)
|
||||
const end = mirrorOffsetFromMappedPoint(root, endContainer, endOffset, mapping)
|
||||
return { start: Math.min(start, end), end: Math.max(start, end) }
|
||||
}
|
||||
|
||||
function getMirrorSelection(root: HTMLElement): ComposerSelection {
|
||||
const sel = window.getSelection()
|
||||
if (!sel || sel.rangeCount === 0) {
|
||||
const len = mirrorComposerSegments(segmentsFromEditor(root)).length
|
||||
const len = mapComposerEditorDom(root).mirrorLength
|
||||
return { start: len, end: len }
|
||||
}
|
||||
const range = sel.getRangeAt(0)
|
||||
if (!root.contains(range.startContainer) || !root.contains(range.endContainer)) {
|
||||
const len = mirrorComposerSegments(segmentsFromEditor(root)).length
|
||||
return { start: len, end: len }
|
||||
}
|
||||
const start = mirrorOffsetFromPoint(root, range.startContainer, range.startOffset)
|
||||
const end = mirrorOffsetFromPoint(root, range.endContainer, range.endOffset)
|
||||
return { start: Math.min(start, end), end: Math.max(start, end) }
|
||||
return mirrorSelectionFromPoints(
|
||||
root,
|
||||
range.startContainer,
|
||||
range.startOffset,
|
||||
range.endContainer,
|
||||
range.endOffset
|
||||
)
|
||||
}
|
||||
|
||||
function setMirrorSelection(root: HTMLElement, selection: ComposerSelection) {
|
||||
|
||||
@@ -59,6 +59,19 @@ describe('serializeComposerSegments', () => {
|
||||
{ type: 'text', text: ' after' },
|
||||
])).toBe('before after')
|
||||
})
|
||||
|
||||
it('keeps the 120 UTF-16-unit mention title limit without splitting an emoji', () => {
|
||||
const title = `${'a'.repeat(119)}😀x`
|
||||
const expectedTitle = 'a'.repeat(119)
|
||||
expect(serializeComposerSegments([
|
||||
{ type: 'session', id: 'emoji-session', title },
|
||||
])).toBe(`[${expectedTitle}](/sessions/emoji-session)`)
|
||||
|
||||
const fittingTitle = `${'a'.repeat(118)}😀x`
|
||||
expect(serializeComposerSegments([
|
||||
{ type: 'session', id: 'emoji-session', title: fittingTitle },
|
||||
])).toBe(`[${'a'.repeat(118)}😀](/sessions/emoji-session)`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseComposerSegments', () => {
|
||||
@@ -226,6 +239,7 @@ describe('serializeComposerSelection', () => {
|
||||
const segments: ComposerSegment[] = [{ type: 'text', text: 'abc' }]
|
||||
expect(serializeComposerSelection(segments, { start: 1, end: 1 })).toBeNull()
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
describe('insertSegmentsInComposerSegments', () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { buildSessionReferencePath, parseSessionPathHref } from '@/lib/sessionReference'
|
||||
import { truncateGraphemes } from '@/lib/graphemes'
|
||||
import { findActiveWord } from '@/utils/findActiveWord'
|
||||
|
||||
/** Object Replacement Character — one mirror slot per session atom. */
|
||||
@@ -23,7 +24,7 @@ export type ComposerSelection = {
|
||||
}
|
||||
|
||||
function sanitizeMentionTitle(title: string): string {
|
||||
return title.replace(/\s+/g, ' ').trim().slice(0, 120)
|
||||
return truncateGraphemes(title.replace(/\s+/g, ' ').trim(), 120)
|
||||
}
|
||||
|
||||
function escapeMarkdownLinkLabel(title: string): string {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { truncateGraphemes } from './graphemes'
|
||||
|
||||
const BOUNDARY_SAMPLES = [
|
||||
'😀',
|
||||
'e\u0301',
|
||||
'👨\u200D👩\u200D👧\u200D👦',
|
||||
]
|
||||
|
||||
const CODE_POINT_FALLBACK_SAMPLES = [
|
||||
...BOUNDARY_SAMPLES,
|
||||
'한',
|
||||
'क्\u200Dष',
|
||||
'a\u200Db',
|
||||
]
|
||||
|
||||
function expectGraphemeHardLimit(): void {
|
||||
for (const grapheme of BOUNDARY_SAMPLES) {
|
||||
const fittingPrefix = 'a'.repeat(120 - grapheme.length)
|
||||
const overflowingPrefix = 'a'.repeat(121 - grapheme.length)
|
||||
expect(truncateGraphemes(`${fittingPrefix}${grapheme}x`, 120)).toBe(
|
||||
`${fittingPrefix}${grapheme}`
|
||||
)
|
||||
expect(truncateGraphemes(`${overflowingPrefix}${grapheme}x`, 120)).toBe(
|
||||
overflowingPrefix
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function codePointBounded(value: string, maxLength: number): string {
|
||||
let result = ''
|
||||
for (const codePoint of Array.from(value)) {
|
||||
if (result.length + codePoint.length > maxLength) break
|
||||
result += codePoint
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
describe('truncateGraphemes', () => {
|
||||
it('keeps only whole 120-UTF-16-unit graphemes', () => {
|
||||
expectGraphemeHardLimit()
|
||||
expect(truncateGraphemes(`${'a'.repeat(119)}😀`, 120)).toBe('a'.repeat(119))
|
||||
expect(truncateGraphemes(`${'a'.repeat(118)}😀`, 120)).toBe(`${'a'.repeat(118)}😀`)
|
||||
expect(truncateGraphemes('👨\u200D👩\u200D👧\u200D👦', 2)).toBe('')
|
||||
expect(truncateGraphemes('abc', 0)).toBe('')
|
||||
})
|
||||
|
||||
it('uses a bounded code-point fallback when Intl.Segmenter is unavailable', () => {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(Intl, 'Segmenter')
|
||||
Object.defineProperty(Intl, 'Segmenter', { configurable: true, value: undefined })
|
||||
try {
|
||||
for (const grapheme of CODE_POINT_FALLBACK_SAMPLES) {
|
||||
const source = `${'a'.repeat(119)}${grapheme}x`
|
||||
const result = truncateGraphemes(source, 120)
|
||||
expect(result).toBe(codePointBounded(source, 120))
|
||||
expect(result.length).toBeLessThanOrEqual(120)
|
||||
expect(result).not.toMatch(/[\uD800-\uDBFF]$/)
|
||||
}
|
||||
expect(truncateGraphemes('abc', 0)).toBe('')
|
||||
} finally {
|
||||
if (descriptor) {
|
||||
Object.defineProperty(Intl, 'Segmenter', descriptor)
|
||||
} else {
|
||||
const mutableIntl = Intl as { Segmenter?: unknown }
|
||||
delete mutableIntl.Segmenter
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Truncate by user-perceived grapheme clusters when the platform provides the
|
||||
* Unicode segmenter. The fallback is code-point-safe rather than a complete
|
||||
* UAX grapheme implementation, so it never creates a lone surrogate. Both
|
||||
* paths retain the original 120 UTF-16-unit title limit.
|
||||
*/
|
||||
export function truncateGraphemes(value: string, maxLength: number): string {
|
||||
if (maxLength <= 0 || !value) return ''
|
||||
|
||||
if (typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function') {
|
||||
const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
|
||||
let result = ''
|
||||
for (const entry of segmenter.segment(value)) {
|
||||
if (result.length + entry.segment.length > maxLength) break
|
||||
result += entry.segment
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
let result = ''
|
||||
for (const codePoint of Array.from(value)) {
|
||||
if (result.length + codePoint.length > maxLength) break
|
||||
result += codePoint
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -58,6 +58,27 @@ describe('buildSessionReferenceText', () => {
|
||||
'See HAPI session /sessions/abc-def for context'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps combining and ZWJ title graphemes only when they fit the UTF-16 limit', () => {
|
||||
const prefix = 'a'.repeat(119)
|
||||
const combining = `${prefix}e\u0301x`
|
||||
const family = `${prefix}👨\u200D👩\u200D👧\u200D👦x`
|
||||
|
||||
expect(buildSessionReferenceText(combining, 'combining')).toBe(
|
||||
`See session ${JSON.stringify(prefix)} (/sessions/combining) for context`
|
||||
)
|
||||
expect(buildSessionReferenceText(family, 'family')).toBe(
|
||||
`See session ${JSON.stringify(prefix)} (/sessions/family) for context`
|
||||
)
|
||||
|
||||
const fittingFamilyPrefix = 'a'.repeat(120 - '👨\u200D👩\u200D👧\u200D👦'.length)
|
||||
expect(buildSessionReferenceText(
|
||||
`${fittingFamilyPrefix}👨\u200D👩\u200D👧\u200D👦x`,
|
||||
'fitting-family'
|
||||
)).toBe(
|
||||
`See session ${JSON.stringify(`${fittingFamilyPrefix}👨\u200D👩\u200D👧\u200D👦`)} (/sessions/fitting-family) for context`
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('matchSessionsForMention', () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { SessionSummary } from '@/types/api'
|
||||
import { normalizeSearch, sessionMatchesQuery } from '@/components/SessionList'
|
||||
import { truncateGraphemes } from '@/lib/graphemes'
|
||||
import { getSessionTitle } from '@/lib/sessionTitle'
|
||||
|
||||
export function buildSessionReferencePath(sessionId: string): string {
|
||||
@@ -9,7 +10,7 @@ export function buildSessionReferencePath(sessionId: string): string {
|
||||
}
|
||||
|
||||
function sanitizeSessionReferenceTitle(sessionTitle: string): string {
|
||||
return sessionTitle.replace(/\s+/g, ' ').trim().slice(0, 120)
|
||||
return truncateGraphemes(sessionTitle.replace(/\s+/g, ' ').trim(), 120)
|
||||
}
|
||||
|
||||
/** Clipboard text for citing this session in another HAPI chat (not a public share link). */
|
||||
|
||||
Reference in New Issue
Block a user