diff --git a/src/features/messages/EditableInput.tsx b/src/features/messages/EditableInput.tsx index 28ebc740ab..fd3f3e4610 100644 --- a/src/features/messages/EditableInput.tsx +++ b/src/features/messages/EditableInput.tsx @@ -81,7 +81,7 @@ import { type ComposerInputElement, } from "./composer-dom"; import { isApplePlatform } from "../shortcuts/format"; -import { macWebKit } from "./mac-webkit"; +import { macWebKit, webKit } from "./mac-webkit"; import { composerLinkUrl } from "./composer-link"; import { messageLinkParts } from "./message-link-parts"; import { updatePlainLinks, type PlainLink } from "./composer-link-edit"; @@ -110,6 +110,7 @@ export type EditorDecoration = { const CONTROL_CHARACTERS = /[\p{Cc}\uF700-\uF747]/gu; /** The control characters typed text holds: tab, newline and carriage return. */ const TYPED_CONTROLS = /[\t\n\r]/u; +const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" }); /** True when a native text insertion holds only control characters. Text that * mixes them with real characters is not control text and passes untouched. */ function controlCharacterText(text: string | null | undefined) { @@ -150,6 +151,203 @@ function keyboardKey( CARET_KEYS.get(event.code) ?? CARET_KEYS.get(event.keyCode) ?? event.key ); } +/** Moves the selection head to a document position, keeping the anchor when + * extending, and scrolls it into view. */ +function moveHead(editor: EditorView, head: number, extend: boolean) { + const { tr, selection, doc } = editor.state; + editor.dispatch( + tr + .setSelection( + TextSelection.create(doc, extend ? selection.anchor : head, head), + ) + .scrollIntoView(), + ); +} +/** Moves the head for plain or Shift Up and Down where the browser cannot. A + * line that holds nothing but a token, or part of a wrapped one, offers no + * caret position besides the token's edge, so native Up and Down stall there + * in WebKit and jump to a document edge in Chromium, entering such a line and + * leaving it alike. Such a line is entered at the token edge it holds, or + * crossed to the far edge when it holds neither, and left for the position on + * the neighbouring line nearest the caret's column. The token's client rects + * give its lines, so a line scrolled out of the composer still counts. + * A caret at a soft wrap is measured on the line it leaves: a move may go one + * line too far but never stalls. Every other line move stays native, from a + * DOM caret the browser can move. The Mac's emacs-style Ctrl+N and Ctrl+P + * line moves stay native too, and can still stall on such a line: they are + * rare in a composer and would need a platform key binding here. */ +function crossTokenLine( + editor: EditorView, + tokens: ReadonlyMap, + key: string, + event: Pick< + globalThis.KeyboardEvent, + "altKey" | "ctrlKey" | "metaKey" | "shiftKey" + >, +) { + const up = key === "ArrowUp"; + const extend = event.shiftKey; + const { selection, doc } = editor.state; + if ( + (!up && key !== "ArrowDown") || + event.altKey || + event.ctrlKey || + event.metaKey || + !(selection instanceof TextSelection) || + !tokens.size + ) + return false; + const origin = extend ? selection.head : up ? selection.from : selection.to; + // A line break, a hard break or a typed newline, directly before `pos`. + const broken = (pos: number) => + doc.textBetween(Math.max(pos - 1, 0), pos) === "\n"; + // Where a caret at `pos` is drawn. Side -1 measures the character before, + // which keeps a token's end on the token's line, but past a line break that + // is the break itself, drawn at the end of the line above. A textblock start + // has nothing before it, and Chromium draws no rect before a leading newline. + const at = (pos: number, side: -1 | 1) => + editor.coordsAtPos( + pos, + broken(pos) || !doc.resolve(pos).parentOffset ? 1 : side, + ); + const caret = at(origin, up ? -1 : 1); + const level = (line: { top: number; bottom: number }) => + (line.top + line.bottom) / 2; + const holds = (line?: { top: number; bottom: number }, point = caret) => + !!line && line.top <= level(point) && level(point) <= line.bottom; + // The position beside a token that is drawn on the neighbouring line when + // the token starts or ends its own: past a line break, into the adjacent + // textblock, or else the token edge. + const outside = (pos: number, dir: -1 | 1) => { + const $pos = doc.resolve(pos); + if (!(dir < 0 ? $pos.nodeBefore : $pos.nodeAfter)) + return Selection.findFrom(doc.resolve(pos + dir), dir, true)?.head; + const next = pos + dir; + return broken(dir < 0 ? pos : next) ? next : pos; + }; + // Stepping off a token keeps the caret's column, as a browser does between + // lines: from `edge`, the near end of the neighbouring `line`, the position + // on that line drawn nearest the caret. Landing at the near end instead adds + // only the line break to a Shift selection, which looks like no move at all. + // Positions are measured rather than hit-tested, so a line scrolled out of + // the composer still counts. Each is measured where a caret there is drawn, + // so a soft wrap belongs to the line after it. Only grapheme boundaries + // count: a position inside an emoji is drawn at its glyph, and a Shift + // selection ending there splits the character. Each inline leaf reads as a + // newline, one character that nothing after it can join, so the text's + // offsets are the textblock's positions and the edge after a token counts. + const column = ( + edge: number | undefined, + line?: { top: number; bottom: number }, + ) => { + if (edge === undefined) return edge; + const $edge = doc.resolve(edge); + const { parent } = $edge; + const text = parent.textBetween(0, parent.content.size, undefined, "\n"); + const positions = [...graphemes.segment(text), { index: text.length }] + .map(({ index }) => $edge.start() + index) + .filter((pos) => (up ? pos <= edge : pos >= edge)); + let head = edge; + let gap = Number.POSITIVE_INFINITY; + for (const pos of up ? positions.reverse() : positions) { + const point = editor.coordsAtPos(pos, 1); + if (!holds(line, point)) break; + const distance = Math.abs(point.left - caret.left); + if (distance < gap) [head, gap] = [pos, distance]; + } + return head; + }; + // A Shift press then settles the head by the selection it leaves. Toward + // the anchor, it may reach the anchor but never pass it, and it collapses + // there rather than leave a lone line break selected, which looks collapsed + // and which typing would replace, joining two lines. Away from the anchor, + // growing by a lone break onto a token's near edge looks like no move at + // all, so it takes the token. Collapsing only applies toward the anchor: from + // a collapsed caret, a lone break onto an empty line is the whole move, as + // that line holds nothing else. + const { anchor } = selection; + const lone = (start: number, end: number) => + doc.textBetween(Math.min(start, end), Math.max(start, end), "\n") === "\n"; + const shifted = (head: number) => { + if (up ? origin > anchor : origin < anchor) + return (up ? head <= anchor : head >= anchor) || lone(anchor, head) + ? anchor + : head; + const from = up ? head - 1 : head; + const token = [...tokens.values()].some((host) => host.position() === from); + return token && lone(origin, head) ? (up ? from : from + 1) : head; + }; + for (const [element, host] of tokens) { + const from = host.position(); + if (from === undefined) continue; + const to = from + 1; + // Bidi reordering can split a token into several rects on one line, as + // WebKit reports them, so a rect on its predecessor's line is folded in. + const rects = [...element.getClientRects()]; + const lines = rects.filter((rect, index) => !holds(rects[index - 1], rect)); + const last = lines.length - 1; + const before = outside(from, -1); + const after = outside(to, 1); + const above = before === undefined ? undefined : at(before, -1); + const below = after === undefined ? undefined : at(after, 1); + for (const [index, line] of lines.entries()) { + const alone = + (index > 0 || !above || level(above) < line.top) && + (index < last || !below || level(below) > line.bottom); + // WebKit's native move onto a line the token is split across can put + // the caret inside the token, so such a line is entered here too. It is + // left natively, which works from either edge. Text beside the token can + // put `previous` on this very line, so a caret already on it is not + // entering. + const split = rects.filter((rect) => holds(line, rect)).length > 1; + if (!alone && !split) continue; + const previous = up + ? (lines[index + 1] ?? below) + : (lines[index - 1] ?? above); + let head: number | undefined; + // Entering: stop at the edge this line holds, or cross a middle line. + if (holds(previous) && !holds(line)) + head = up ? (index === last ? to : from) : index === 0 ? from : to; + // Leaving from the edge: cross the rest of the token, or step off it. + else if ( + alone && + holds(line) && + ((index === 0 && origin === from) || (index === last && origin === to)) + ) + head = up + ? index > 0 + ? from + : column(before, above) + : index < last + ? to + : column(after, below); + if (head === undefined || (up ? head >= origin : head <= origin)) + continue; + moveHead(editor, extend ? shifted(head) : head, extend); + return true; + } + } + // After a token, the DOM caret sits between its element and the text that + // follows. Once WebKit, on any platform, has drawn the caret there beside a + // wrapped token, Down stalls; from the text's start, the same position, it + // moves. The step is harmless beside a token that does not wrap, so it is + // not limited to wrapped ones. Up and Chromium need no help. + const native = editor.dom.ownerDocument.getSelection(); + const next = native?.focusNode?.childNodes[native.focusOffset]; + const token = native?.focusNode?.childNodes[native.focusOffset - 1]; + if ( + !up && + webKit() && + native && + next?.nodeType === Node.TEXT_NODE && + token instanceof HTMLElement && + tokens.has(token) + ) { + if (native.isCollapsed) native.collapse(next, 0); + else native.extend(next, 0); + } + return false; +} export type EditableInputProps = Omit< HTMLAttributes, "onChange" | "onInput" @@ -1097,18 +1295,19 @@ export function EditableInput({ /** Moves the caret for a caret key the host reported as a control character. * Left unhandled, such a keydown types the character instead of moving, so * the move happens here: in document terms across an inline leaf, where - * WebKit offers extra caret positions around uneditable nodes, and otherwise - * through the browser's own caret motion with the platform's modifier - * meanings (word and line ends sideways, paragraph and document ends - * vertically). Page keys scroll without moving the caret, and Home and End - * do so too on Apple platforms unless extending the selection. */ + * WebKit offers extra caret positions around uneditable nodes, or a line + * that only a token holds, and otherwise through the browser's own caret + * motion with the platform's modifier meanings (word and line ends + * sideways, paragraph and document ends vertically). Page keys scroll + * without moving the caret, and Home and End do so too on Apple platforms + * unless extending the selection. */ const moveCaret = (key: string, event: globalThis.KeyboardEvent) => { const mac = isApplePlatform(navigator.platform); const boundary = mac ? event.metaKey : event.ctrlKey; const extend = event.shiftKey; const sideways = key === "ArrowLeft" || key === "ArrowRight"; if (sideways && !event.altKey && !boundary) { - const { $head, anchor, empty } = editor.state.selection; + const { $head, empty } = editor.state.selection; const backward = key === "ArrowLeft"; const leaf = $head.textOffset ? null @@ -1118,23 +1317,15 @@ export function EditableInput({ // Only an inline leaf is stepped over in document terms. A block beside // the head, as after select-all, is left to the browser's caret motion. if ((empty || extend) && leaf?.isInline && !leaf.isText) { - const head = backward - ? $head.pos - leaf.nodeSize - : $head.pos + leaf.nodeSize; - editor.dispatch( - editor.state.tr - .setSelection( - TextSelection.create( - editor.state.doc, - extend ? anchor : head, - head, - ), - ) - .scrollIntoView(), + moveHead( + editor, + backward ? $head.pos - leaf.nodeSize : $head.pos + leaf.nodeSize, + extend, ); return true; } } + if (crossTokenLine(editor, hosts.current, key, event)) return true; const selection = editor.dom.ownerDocument.getSelection(); if (!selection || typeof selection.modify !== "function") return true; const alter = extend ? "extend" : "move"; @@ -1926,6 +2117,13 @@ export function EditableInput({ event.preventDefault(); ref.current?.insertLineBreak(); } + // After the host, which claims Up and Down for a completion list. + if ( + !event.defaultPrevented && + view.current && + crossTokenLine(view.current, hosts.current, event.key, event) + ) + event.preventDefault(); }} onCompositionStart={(event) => { composing.current = true; diff --git a/src/features/messages/mac-webkit.ts b/src/features/messages/mac-webkit.ts index 66c436a821..26cf10252b 100644 --- a/src/features/messages/mac-webkit.ts +++ b/src/features/messages/mac-webkit.ts @@ -1,6 +1,7 @@ +/** WebKit on any platform: Safari, WKWebView and WebKitGTK. */ +export const webKit = () => navigator.vendor === "Apple Computer, Inc."; + /** Desktop Mac WebKit: Safari and Tauri's WKWebView. Desktop-mode iPad also * reports MacIntel, but keeps its touch policies. */ export const macWebKit = () => - navigator.platform === "MacIntel" && - !navigator.maxTouchPoints && - navigator.vendor === "Apple Computer, Inc."; + navigator.platform === "MacIntel" && !navigator.maxTouchPoints && webKit(); diff --git a/tests/browser/composer-links.spec.mjs b/tests/browser/composer-links.spec.mjs index 6499c0344f..20b17264d8 100644 --- a/tests/browser/composer-links.spec.mjs +++ b/tests/browser/composer-links.spec.mjs @@ -627,3 +627,527 @@ test("editable composer renders links and mentions while preserving source and n await server.close(); } }); + +test("Up and Down move one line at a time through links that wrap", async ({ + page, +}) => { + // A line that holds only part of a wrapped link has no caret position of its + // own: left to the browser, Up and Down stall there in WebKit and jump to the + // document edge in Chromium. Layout decides where a link wraps, so this needs + // a real browser at a width where both links wrap. + await page.setViewportSize({ width: 480, height: 900 }); + await page.addInitScript(() => { + localStorage.setItem("buzz-remember-mentioned-agents.v1", "off"); + }); + const server = await createServer({ + root: fileURLToPath(new URL("../../", import.meta.url)), + configFile: false, + envFile: false, + plugins: [react()], + logLevel: "error", + server: { host: "127.0.0.1", port: 0 }, + }); + await server.listen(); + try { + await page.goto( + `http://127.0.0.1:${server.httpServer.address().port}/tests/fixtures/link-messages.html`, + ); + const input = page.getByRole("textbox", { + name: "Message #design", + exact: true, + }); + await page.evaluate(() => document.fonts.ready); + const title = + "Add fully custom Auth0 login screens with staging and production rollout tooling"; + const link = (label, pull) => + `[${label}](https://github.com/block/buzz/pull/${pull})`; + // The first link starts its paragraph, so its first line holds only link + // text. The second wraps twice, so its middle line does too. + const first = link(title, 1); + const second = link(`${title} plus a long tail that wraps again`, 2); + const [lead, firstTail, secondTail] = [ + "Words before ", + ": wraps once.", + " after it.", + ]; + const text = [ + "Plain words above the links.", + `${first}${firstTail}`, + `${lead}${second}${secondTail}`, + // Enough lines that the composer scrolls the links out of view. + ...Array.from({ length: 5 }, (_, line) => `Plain line ${line} below.`), + ].join("\n"); + await input.fill(""); + await input.fill(text); + const links = input.locator('[data-link-kind="github"]'); + await expect(links).toHaveCount(2); + // How many rects each link draws, more than one where it wraps. + const rects = () => + input + .locator("[data-source]") + .evaluateAll((elements) => + elements.map((element) => element.getClientRects().length), + ); + const lines = await rects(); + expect(lines[0]).toBeGreaterThanOrEqual(2); + expect(lines[1]).toBeGreaterThanOrEqual(3); + expect( + await input.evaluate((el) => el.scrollHeight > el.clientHeight), + ).toBe(true); + + const firstFrom = text.indexOf(first); + const firstTo = firstFrom + first.length; + const secondFrom = text.indexOf(second); + const secondTo = secondFrom + second.length; + const caret = () => + input.evaluate((el) => + el.selectionDirection === "backward" + ? { head: el.selectionStart, anchor: el.selectionEnd } + : { head: el.selectionEnd, anchor: el.selectionStart }, + ); + // WebKit settles a caret beside a link when it draws it, and a press + // before then can move where a person's press would stall. + const rendered = () => + page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ), + ); + // An empty line holds nothing but its break, so a press onto or off one + // can cross the break alone. + const empty = (value, pos) => + [value[pos - 1], value[pos]].every((char) => (char ?? "\n") === "\n"); + // Presses `key` from a head at `start`, with a Shift selection's anchor + // at `anchor`, until the head reaches `stop`, returning where each press + // lands. A press that leaves the head in place fails the poll, and so does + // one that crosses nothing but a line break, unless onto or off an empty + // line: the caret jumps to the far end of a line, and a Shift selection + // grows by the break alone, which looks like no move at all. + const walk = async (key, start, stop, anchor = start) => { + const value = await input.evaluate((el) => el.value); + await input.evaluate( + (el, [anchor, head]) => + el.setSelectionRange( + Math.min(anchor, head), + Math.max(anchor, head), + head < anchor ? "backward" : "forward", + ), + [anchor, start], + ); + await rendered(); + const heads = []; + let head = start; + while (head !== stop && heads.length < 12) { + const previous = head; + await page.keyboard.press(key); + await expect + .poll(async () => (head = (await caret()).head)) + .not.toBe(previous); + expect(Math.sign(head - previous)).toBe(key.endsWith("Up") ? -1 : 1); + if (!empty(value, previous) && !empty(value, head)) + expect( + value.slice(Math.min(previous, head), Math.max(previous, head)), + ).toMatch(/[^\n]/); + expect((await caret()).anchor).toBe( + key.startsWith("Shift+") ? anchor : head, + ); + heads.push(head); + await rendered(); + } + return heads; + }; + for (const shift of ["", "Shift+"]) { + const down = await walk(`${shift}ArrowDown`, 0, secondTo); + // Onto the first link's own line at its start, then across the link. + expect(down.slice(0, 2)).toEqual([firstFrom, firstTo]); + // One line down: the words before the second link. + expect(down[2]).toBeGreaterThanOrEqual(secondFrom - lead.length); + expect(down[2]).toBeLessThanOrEqual(secondFrom); + // Across the second link's middle line. + expect(down.slice(3)).toEqual([secondTo]); + + const up = await walk(`${shift}ArrowUp`, text.length, 0); + const [afterSecond, beforeSecond, afterFirst, ...rest] = up.slice(-5); + // The line holding the end of the second link and the words after it. + expect(afterSecond).toBeGreaterThanOrEqual(secondTo); + expect(afterSecond).toBeLessThanOrEqual(secondTo + secondTail.length); + // Across the second link's middle line, then up to the first link's end + // line, onto its own first line, and off it to the line above at the + // caret's column, its start. + expect(beforeSecond).toBe(secondFrom); + expect(afterFirst).toBeGreaterThanOrEqual(firstTo); + expect(afterFirst).toBeLessThanOrEqual(firstTo + firstTail.length); + expect(rest).toEqual([firstFrom, 0]); + } + + // A typed line break after the first link leaves its end line to it too. + await input.evaluate((el, at) => el.setSelectionRange(at, at), firstTo); + await input.press("Shift+Enter"); + await expect(input).toHaveJSProperty( + "value", + `${text.slice(0, firstTo)}\n${text.slice(firstTo)}`, + ); + for (const shift of ["", "Shift+"]) + expect( + await walk( + `${shift}ArrowUp`, + firstTo + 1 + firstTail.length, + firstFrom, + ), + ).toEqual([firstTo, firstFrom]); + + // WebKit can report a caret key as a control character; that path moves + // across a link line the same way. + await input.evaluate((el, at) => el.setSelectionRange(at, at), firstFrom); + await input.evaluate((el) => + el.dispatchEvent( + new KeyboardEvent("keydown", { + key: "\u001F", + code: "ArrowDown", + bubbles: true, + cancelable: true, + }), + ), + ); + expect((await caret()).head).toBe(firstTo); + + // Stepping off a link that starts or ends its paragraph keeps the caret's + // column on the neighbouring line, even where that line starts with the + // tail of another link. + const edges = [ + "Plain words above.", + `${link(title, 3)} ends here.`, + link(title, 4), + "Plain words below, longer than the link's last line.", + ]; + const draft = edges.join("\n"); + await input.fill(""); + await input.fill(draft); + await expect(links).toHaveCount(2); + for (const count of await rects()) expect(count).toBeGreaterThanOrEqual(2); + const thirdFrom = draft.indexOf(link(title, 3)); + const thirdTo = thirdFrom + link(title, 3).length; + const fourthFrom = draft.indexOf(link(title, 4)); + const fourthTo = fourthFrom + link(title, 4).length; + for (const shift of ["", "Shift+"]) { + const down = await walk(`${shift}ArrowDown`, 0, draft.length); + expect(down.slice(0, 4)).toEqual([ + thirdFrom, + thirdTo, + fourthFrom, + fourthTo, + ]); + // Off the fourth link's last line to the line below, under its end. + const [column, ...end] = down.slice(4); + expect(column).toBeGreaterThan(fourthTo + 1); + expect(end).toEqual([draft.length]); + const { x, edge, em } = await input.evaluate((el, at) => { + el.setSelectionRange(at, at); + const fourth = [...el.querySelectorAll("[data-source]")].at(-1); + return { + x: getSelection().getRangeAt(0).getClientRects()[0].left, + edge: [...fourth.getClientRects()].at(-1).right, + em: Number.parseFloat(getComputedStyle(el).fontSize), + }; + }, column); + // The nearest position is within half a glyph, so half an em. + expect(Math.abs(x - edge)).toBeLessThanOrEqual(em / 2); + // Off the fourth link's first line to the third link's end, which is + // where the line above starts. + expect(await walk(`${shift}ArrowUp`, draft.length, 0)).toEqual([ + fourthTo, + fourthFrom, + thirdTo, + thirdFrom, + 0, + ]); + } + + // Entering a line that only a link holds stops at the edge it holds, which + // has no position at the caret's column. From just past the line break + // beside the link, a Shift selection would grow by the break alone, so it + // takes the whole link instead, and the next press goes on from there. + for (const [key, start, near, shifted] of [ + [ + "ArrowDown", + edges[0].length, + thirdFrom, + [thirdTo, fourthFrom, fourthTo], + ], + ["ArrowUp", fourthTo + 1, fourthTo, [fourthFrom, thirdTo, thirdFrom, 0]], + ]) { + await input.evaluate((el, at) => el.setSelectionRange(at, at), start); + await rendered(); + await page.keyboard.press(key); + await expect.poll(caret).toEqual({ head: near, anchor: near }); + const heads = await walk( + `Shift+${key}`, + start, + key === "ArrowUp" ? 0 : draft.length, + ); + expect(heads.slice(0, shifted.length)).toEqual(shifted); + } + + // So does a link on its own line between two lines of text. + const own = ["Please review", link(title, 5), "Thanks"]; + const ownDraft = own.join("\n"); + await input.fill(""); + await input.fill(ownDraft); + await expect(links).toHaveCount(1); + expect((await rects())[0]).toBeGreaterThanOrEqual(2); + const ownFrom = own[0].length + 1; + const ownTo = ownFrom + own[1].length; + expect(await walk("Shift+ArrowUp", ownTo + 1, 0)).toEqual([ownFrom, 0]); + const [ownDown] = await walk( + "Shift+ArrowDown", + own[0].length, + ownDraft.length, + ); + expect(ownDown).toBe(ownTo); + + // The draft's links, and the visual line of a head: the newlines before + // it, plus the lines each link before it wraps onto. A head past a link's + // start is at or past its end, on its last line. + const layout = async () => { + const value = await input.evaluate((el) => el.value); + const counts = await rects(); + const links = [...value.matchAll(/\[[^\]]*\]\([^)]*\)/g)].map( + (match, index) => ({ + from: match.index, + to: match.index + match[0].length, + count: counts[index], + }), + ); + const visual = (head) => + value.slice(0, head).split("\n").length - + 1 + + links.reduce( + (lines, { from, count }) => lines + (head > from ? count - 1 : 0), + 0, + ); + return { value, links, visual }; + }; + // Presses `key` and returns the caret once its head leaves `head`. + const press = async (key, head, message) => { + await page.keyboard.press(key); + let now; + await expect + .poll( + async () => { + now = await caret(); + return now.head; + }, + { message }, + ) + .not.toBe(head); + return now; + }; + // From either end of each line and link, a plain press moves exactly one + // visual line. A Shift press and then the opposite one keep the anchor, + // never carry the head past it, and never leave a lone line break + // selected. Most return to the start, but entering a line that only a + // link holds stops at an edge chosen by direction, not by the caret's + // column, so the second press can stop short of it. + const moves = async ({ value, links, visual }) => { + const ends = new Set([0, value.length]); + for (const { index } of value.matchAll(/\n/g)) + ends.add(index).add(index + 1); + for (const { from, to } of links) ends.add(from).add(to); + for (const at of ends) + for (const [key, back, step] of [ + ["ArrowUp", "ArrowDown", -1], + ["ArrowDown", "ArrowUp", 1], + ]) { + const target = visual(at) + step; + if (target < 0 || target > visual(value.length)) continue; + const message = `${key} from ${at}`; + await input.evaluate((el, at) => el.setSelectionRange(at, at), at); + await rendered(); + await page.keyboard.press(key); + await expect + .poll(async () => visual((await caret()).head), { message }) + .toBe(target); + await input.evaluate((el, at) => el.setSelectionRange(at, at), at); + await rendered(); + const there = await press(`Shift+${key}`, at, `Shift+${message}`); + expect(there.anchor, `Shift+${message}`).toBe(at); + if (!empty(value, at) && !empty(value, there.head)) + expect( + value.slice(Math.min(at, there.head), Math.max(at, there.head)), + `Shift+${message}`, + ).toMatch(/[^\n]/); + await rendered(); + const backMessage = `Shift+${key} then Shift+${back} from ${at}`; + const { head, anchor } = await press( + `Shift+${back}`, + there.head, + backMessage, + ); + expect(anchor, backMessage).toBe(at); + expect(Math.sign(head - at), backMessage).not.toBe(-step); + if (head !== at) + expect( + value.slice(Math.min(at, head), Math.max(at, head)), + backMessage, + ).toMatch(/[^\n]/); + } + }; + await moves(await layout()); + + // fill() makes a paragraph of each line, but a text/plain paste, like + // Shift+Enter, puts newline characters in one paragraph. A newline is + // drawn at the end of its line, apart from a caret just past it. + const pasted = link(title, 6); + const paste = async (lines) => { + const value = lines.join("\n"); + await input.fill(""); + await input.evaluate((el, value) => { + const clipboardData = new DataTransfer(); + clipboardData.setData("text/plain", value); + el.dispatchEvent( + new ClipboardEvent("paste", { + bubbles: true, + cancelable: true, + clipboardData, + }), + ); + }, value); + await expect(input).toHaveJSProperty("value", value); + await expect(input.locator(":scope > p")).toHaveCount(1); + const draft = await layout(); + // Links titled `title` wrap, and shorter ones do not. + for (const { from, to, count } of draft.links) + expect(count > 1).toBe(value.slice(from, to).includes(title)); + await moves(draft); + return draft; + }; + { + const lines = ["Please review", pasted, "X line", "Thanks"]; + const { + value, + links: [{ from, to }], + } = await paste(lines); + const thanks = value.length - lines.at(-1).length; + expect(await walk("Shift+ArrowUp", thanks, 0)).toEqual([to + 1, from, 0]); + const down = await walk("Shift+ArrowDown", 0, value.length); + expect(down.slice(0, 2)).toEqual([from, to]); + } + { + // An empty line beside the link is a line of its own. + const { + value, + links: [{ from, to }], + } = await paste(["Please review", "", pasted, "", "Thanks"]); + expect(await walk("Shift+ArrowDown", 0, value.length)).toEqual([ + from - 1, + to, + to + 1, + to + 2, + value.length, + ]); + expect(await walk("Shift+ArrowUp", value.length, 0)).toEqual([ + to + 1, + from, + from - 1, + 0, + ]); + } + { + // With an empty line below the link alone, three Shift presses down + // from the line above and three back retrace each other, collapsing at + // the start rather than undoing two presses at once. + const { + links: [{ from, to }], + } = await paste(["Please review", pasted, "", "Thanks"]); + expect(await walk("Shift+ArrowDown", from - 1, to + 2)).toEqual([ + to, + to + 1, + to + 2, + ]); + expect(await walk("Shift+ArrowUp", to + 2, from - 1, from - 1)).toEqual([ + to + 1, + to, + from - 1, + ]); + } + // A link short enough not to wrap still holds its line alone. + await paste(["Please review", link("PR 7", 7), "Thanks"]); + { + // On consecutive lines, each Shift press across two links takes a whole + // one, rather than the line break between them. + const { + value, + links: [first, second], + } = await paste(["PRs:", link(title, 8), link(title, 9), "Thanks"]); + expect( + (await walk("Shift+ArrowDown", 0, value.length)).slice(0, 3), + ).toEqual([first.from, first.to, second.to]); + expect(await walk("Shift+ArrowUp", value.length, 0)).toEqual([ + second.to, + second.from, + first.from, + 0, + ]); + } + // A draft that starts with an empty line: Chromium draws no rect before + // its leading newline. + await paste(["", pasted, ""]); + + // The nearest position on the neighbouring line is a character boundary, + // never inside an emoji or a cluster, where a caret is drawn at the glyph + // and a Shift selection would split the character. + const thumb = "👍🏽"; + const emoji = ["😀😀😀 words above", link(title, 1), thumb.repeat(20)]; + const emojiDraft = emoji.join("\n"); + await input.fill(""); + await input.fill(emojiDraft); + await expect(links).toHaveCount(1); + expect((await rects())[0]).toBeGreaterThanOrEqual(2); + const emojiFrom = emoji[0].length + 1; + const emojiTo = emojiFrom + emoji[1].length; + for (const shift of ["", "Shift+"]) { + const [below] = await walk( + `${shift}ArrowDown`, + emojiTo, + emojiDraft.length, + ); + expect((below - emojiTo - 1) % thumb.length).toBe(0); + expect(await walk(`${shift}ArrowUp`, emojiFrom, 0)).toEqual([0]); + } + // Deleting that Shift selection leaves no half of the first emoji behind. + await page.keyboard.press("Backspace"); + await expect(input).toHaveJSProperty("value", emojiDraft.slice(emojiFrom)); + + // WebKit splits this link into two rects on one line, reordering its + // Hebrew with the text after it. That is still one line, and native Down + // onto the link stalls there, so each press must still move one line. + const bidi = [ + "First line.", + `${link("PR שלום עולם", 1)} שלום עולם`, + "Last line.", + ]; + await input.fill(""); + await input.fill(bidi.join("\n")); + await expect(links).toHaveCount(1); + const starts = [0, bidi[0].length + 1, bidi[0].length + bidi[1].length + 2]; + const line = (head) => starts.findLastIndex((start) => head >= start); + for (const shift of ["", "Shift+"]) + for (const [key, start, expected] of [ + ["ArrowDown", 0, [1, 2]], + ["ArrowUp", bidi.join("\n").length, [1, 0]], + ]) { + await input.evaluate((el, at) => el.setSelectionRange(at, at), start); + await rendered(); + for (const index of expected) { + await page.keyboard.press(`${shift}${key}`); + await expect.poll(async () => line((await caret()).head)).toBe(index); + const { head, anchor } = await caret(); + expect(anchor).toBe(shift ? start : head); + await rendered(); + } + } + } finally { + await server.close(); + } +});