Repository navigation
fix(composer): Up/Down and Shift+Up/Down no longer stall at wrapped link chips - #1886
Conversation
… holds Links and mentions in the composer are uneditable inline tokens. When one wraps, a line holding only part of it, or only the token, has no caret position besides the token's edges. Native Up and Down with or without Shift therefore stall there in WebKit and jump to the start or end of the document in Chromium, both entering such a line and leaving it. Before a native line move, crossTokenLine reads each token's client rects to find the lines it holds alone and moves in document terms instead. Entering such a line stops at the token edge it holds. A middle line is crossed to the far edge. Leaving from an edge reaches the token's other edge or steps onto the neighbouring line, measured past a hard break, a typed newline or the paragraph boundary. Shift keeps the anchor. It runs from the React keydown handler after the host, so an open completion list still claims Up and Down, and from the control-character caret-key path. Every other line move stays native, with one adjustment. ProseMirror places a caret after a token between the token's element and the following text. Once WebKit has drawn the caret there beside a wrapped token, Down stalls. The DOM caret now moves to the start of that text first, which is the same document position. Known limitations: leaving a token-only line lands at the near end of the neighbouring line rather than the caret's column. A caret at a soft wrap is measured on the line it leaves, so a move there can go one line too far. The new Chromium and WebKit journey fails without each part of the fix. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
Bidi reordering can split a link into several client rects on one line, as WebKit reports them when the link's Hebrew runs on into the text after it. crossTokenLine took each rect for a line of its own, so the first fragment looked like a line the link held alone: Down from the line above stopped at the link's start and the next Down only stepped to its end, and Up from that line's text did the same in reverse. A rect on its predecessor's line is now folded into it. Folding alone is not enough. WebKit's native Down onto a column over the split link puts the DOM caret inside the token: the press does nothing and the next one skips a line. A line the link is split across is therefore entered at its edge like a line it holds alone, but only from the neighbouring line, and it is still left natively, which works from either edge in both engines. The DOM caret step before a native move now runs only for Down in WebKit. Without it Down stalls beside a wrapped link in macOS and Linux WebKit alike, so it checks the WebKit vendor rather than macWebKit(); Chromium and Up pass without it. It uses Selection.extend for a range. Both call sites now share crossTokenLine's own check for plain or Shift Up and Down, so Ctrl+Up on a Mac and Meta+Up elsewhere no longer reach it on the control-character path alone. The Mac's Ctrl+N and Ctrl+P stay native. moveHead now serves both crossTokenLine and the sideways inline-leaf step in moveCaret. Known gap, unchanged from before: native Up or Down onto a split link still stalls in WebKit when the link neither starts nor ends its line on the side the caret comes from, as no line break shows which line is the neighbour. The journey's new bidi case fails without the fold in macOS and Linux WebKit, with folding alone (the stall) and with the previous code (the sideways press). Chromium reports one rect and passes throughout. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
Follow-ups from review of the bidi-split link fix; behaviour is unchanged. mac-webkit.ts now exports webKit(), the vendor check for WebKit on any platform, and macWebKit() builds on it. The DOM caret step before a native Down uses it instead of repeating the vendor literal. The crossTokenLine comment on split lines now says they are left natively, which works from either edge, and why a caret already on such a line is not entering: text beside the token can put the previous line's measure on that very line. The journey's bidi loop now checks the anchor after each press, held at the start with Shift and following the head without, as walk does for the other cases. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
Stepping off a link that starts its paragraph, Up landed at the end of the line above, just before the line break. Down from a wrapped link that ends its paragraph landed just after the break. Either way a Shift selection grew by nothing but the break, so the press looked like it did nothing and the next press moved the line. In the PR's demo draft, Shift+Up from the end visited 465, 335, 334, 175, 174, 29, 28, 0. crossTokenLine now steps off to the position on the neighbouring line nearest the caret's column, as a browser does between lines of text. It walks that line's positions with coordsAtPos from the near end instead of hit-testing a point, so a line scrolled out of the composer still counts. Each position is measured where a caret there is drawn, so a soft wrap belongs to the line after it. From a link that starts its line, that is the start of the line above, or the end of a link whose tail starts that line. The demo draft now visits 465, 335, 309, 175, 153, 29, 0, one line per press, in WebKit and Chromium alike. The journey's walk now fails a press that crosses only a line break, the first Up walk climbs to the start of the draft, and a new case covers paragraphs that start or end with a wrapped link: Down lands under the link's end, and Up from a paragraph-leading link lands at the previous link's end. Without the fix the walk fails in both engines, first on the existing Up walk and, run alone, on the new case's Down walk. Unchanged: entering a line that only a link holds still stops at the edge it holds, so Shift+Down from the end of the line above a paragraph-leading link, or Shift+Up from the start of the line below a paragraph-ending one, still adds only the break. That line has no caret position at the caret's column. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
Stepping off a link with Up or Down measured every UTF-16 position on the neighbouring line for the one nearest the caret's column. A position inside a surrogate pair or a grapheme cluster is drawn at its glyph, so it could tie with or beat a real boundary. Up from a link that starts its paragraph, below `😀😀😀 words above`, put the head between the first emoji's surrogates in Chromium and WebKit, and Backspace after Shift+Up left a lone `\ud83d` in the draft. In WebKit, Down onto a line of 👍🏽, ZWJ families or decomposed é landed inside a cluster too. The walk now visits grapheme boundaries only, segmenting the textblock's text with Intl.Segmenter. Each inline leaf reads as one character, so the text's offsets are the textblock's positions. The journey gains a case with an emoji line above a paragraph-leading link and a line of 👍🏽 below it. Up and Shift+Up land at the start, Backspace then leaves the link intact, and Down lands between clusters. It fails without the fix in both engines: in Chromium on Up, which visits offset 1, and in WebKit on Down, mid-👍🏽. The journey also asserts that the third and fourth links wrap, which the expected moves depend on, and pins today's Shift+Down from the end of the line above a paragraph-leading link at the link's start. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
Entering a line that only a link holds stops at the edge it holds. From just past the line break beside the link, a Shift selection therefore grew by that break alone, which looks like no move at all. With a link pasted on its own line between 'Please review' and 'Thanks', Shift+Up from the start of 'Thanks' visited the link's end, then its start, then 0, so the first press looked dead. Shift+Down from the end of the line above a paragraph-leading link did the same. crossTokenLine now takes the whole link in that case: Shift+Up moves to the link's start and Shift+Down to its end, and the next press goes on from there. Plain Up and Down still stop at the near edge, a real caret position, and Shift from anywhere else on the neighbouring line still stops there too, since the selection then grows by more than the break. The column walk now reads each inline leaf as a newline rather than U+FFFC. A combining mark, variation selector, skin-tone modifier or ZWJ typed after a link joined U+FFFC in one grapheme, dropping the position after the link from the walk; nothing after a newline joins it. Each leaf is still one character, so offsets map 1:1 to positions. Only a bare carriage return directly before a leaf joins a newline, and typed text never holds one: inserted text normalises CR LF. A synced draft could still hold one, and the walk would then skip that one candidate. It still never lands mid-character. The journey replaces the pin on the old Shift+Down and checks plain and Shift moves from just past the break in both directions. It also adds the 'Please review' / link / 'Thanks' draft, where every Shift+Up and Shift+Down press must cross more than a line break. Before the fix, each new walk fails in Chromium and WebKit on a break-only move. The leaf change has no browser case: the two candidates sit on either side of a zero-width mark, so it is checked in Node only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
Shift+Enter and a text/plain paste put newline characters in one paragraph, where the journey's fill() made a paragraph per line. crossTokenLine measured the Up caret and the line above a link with side -1, and just past a newline that measures the newline itself, drawn at the end of the line above. A caret at the start of a line was taken to be one line higher, so in such a draft, in Chromium and WebKit alike: - Up from the start of the line below a link-only line skipped that line onto the link's end, and Up from just past the break below the link skipped its end for its start. - With an empty line above the link, Down from the line above skipped the empty line onto the link, and Down from the empty line hit the original bug: Chromium jumped to the end and WebKit stalled. - The PR's demo draft, pasted rather than filled, visited 465, 335, 175, 29, 0 on Shift+Up, skipping the words before each link. A position directly after a typed newline or a hard break is now measured with side 1, on its own line, for the caret and for the lines above and below a link. Newline drafts now move as paragraph drafts do, and the demo draft visits 465, 335, 309, 175, 153, 29, 0 either way. Undoing the whole-link Shift press left the break alone selected. After Shift+Up from just past the break below a wrapped link, Shift+Down crossed the rest of the link to its end, one break short of the anchor: the selection looked collapsed and typing joined the link to the next line. Shift+Down then Shift+Up from the line above did the same. Crossing a token toward an anchor just past the break beside it now goes to the anchor. For Up that cross happens entering the link's first line from its last, so the check follows both branches. The journey adds drafts built by a text/plain paste: 'Please review', the link, 'X line' and 'Thanks', and the same with an empty line either side of the link. From either end of every line, a plain press either way must move exactly one visual line, and Shift walks must land on exact heads. walk still fails a press that crosses only a line break, except onto or off an empty line, which holds nothing else. Undoing the whole-link press is checked on the filled own-line draft and on both pasted drafts. With every assertion soft, the journey fails 18 of them at 124c700 in both engines, covering every new check that the old code breaks. Dropping only the Up caret fix fails the 7 Up checks, only the line-above fix the 6 Down checks, and only the undo rule the 6 reverse presses. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
kazemek21
left a comment
There was a problem hiding this comment.
I reviewed the PR and went through the Nexus review-tracker notes. I didn't see any P2-or-higher issues in this change.
Posted by Codex (gpt-6.1-sol) on behalf of kylez.
wesbillman
left a comment
There was a problem hiding this comment.
🤖 Star Lord’s automated source review via Wes’s account: no actionable changes requested.
Rocket, Mantis and Groot completed independent lanes; I reconciled the keyboard, selection, grapheme and lifecycle paths at head fff8e8786aca85e8a4c47f6db25af879825b96e9, base e210c8a472cc49ee7c0b49b62bfad10fd5ef4c8e (merge-base afac5b5dc287b148e54d9e6c9bd2d2878ca73660).
Source-only: no tests or app execution; native desktop, assistive technology, documented soft-wrap/bidi limitations and test-cost changes remain unverified. The CI snapshot reports required checks successful with Windows native validation skipped; public material and seven demo frames were inspected, but full video playback/audio were not.
Two Shift-only rules, keyed on where a press started, let a press and its reverse carry the head past the anchor. 124c700 took a whole link when Shift entered its line from just past the line break beside it, and fff8e87 sent the reverse press back to the anchor. In Chromium and WebKit, with pasted and filled drafts alike: - 'Please review' / blank / link / blank / 'Thanks': Shift+Down from the link's end, then Shift+Up, selected the whole link backwards instead of collapsing. From the link's start, the mirror did the same. - A link that fits on its own line: Shift+Down from the end of the line above, then Shift+Up, selected 'review' backwards, so typing would replace it. - Two links on consecutive lines: Shift+Down from the end of the first selected only the line break between them, and Shift+Up back then selected the first link whole. - 'Please review' / link / blank / 'Thanks': three Shift+Down presses from the end of the first line, then three Shift+Up, visited 135, 13 and 0 on the way back. One press undid two, and the next overshot the start. Both rules are gone. Once the geometry has picked a head, a Shift press settles it by the selection it leaves: - Toward the anchor, the head may reach the anchor but never pass it. It collapses there rather than leave a lone line break selected: that looks collapsed, and typing would replace the break and join two lines. - Away from the anchor, growing by a lone break onto a token's near edge takes the token, since the break alone looks like no move at all. That also covers stepping off one link onto the next. The two never apply to the same press. Collapsing applies only toward the anchor: from a collapsed caret, a lone break onto an empty line is the whole move, as that line holds nothing else. Plain presses are unchanged. Separately, Chromium draws no rect at a textblock start when the block's text begins with a newline. Measured with side -1 there, the line above a link read as top 0, so Down from an empty first line jumped to the end of the document. A textblock start is now measured with side 1, like a position just past a line break. The journey now checks a Shift press and the opposite press from either end of every line and link of each pasted draft, and of the filled own-line draft in place of `undo`. The anchor must stay put, the head must never pass it, and no lone break may be left selected. New pasted drafts cover a link that fits on one line, two links on consecutive lines, an empty line below the link only (three presses down and back), and an empty first and last line. With every assertion soft, the journey fails 13 assertions at fff8e87 in Chromium and 11 in WebKit, and none now. Dropping one part at a time, in both engines unless noted: - the anchor clamp: 1 (the one-line link case); - taking the token: 17; - collapsing a lone break: 12; - the block-start side: 3, in Chromium only. Known limitation: press-and-back returns exactly to the start everywhere but on a link that fits on its own line. Shift+Down from its start then Shift+Up, or Shift+Up from its end then Shift+Down, leaves the link selected. Entering that line stops at the edge chosen by direction, not by the caret's column. The head still never passes the anchor. The PR's demo draft still gives 465, 335, 309, 175, 153, 29, 0, 0 on Shift+Up from the end, filled or pasted, in both engines. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
wesbillman
left a comment
There was a problem hiding this comment.
🤖 Star Lord’s automated source review via Wes’s account: no actionable changes requested.
Rocket, Mantis and Groot completed independent lanes; I reconciled the Shift-selection settlement, leading-newline geometry, callers and regression coverage at head 46b0cc63f25cf230c2a01ca03380522a3cf2c6a9, base e210c8a472cc49ee7c0b49b62bfad10fd5ef4c8e.
Source-only: no tests or app execution; native keyboard/assistive-technology behavior, documented selection limitations and test-cost changes remain unverified. CI was still running at the snapshot. Public material and seven saved demo frames were inspected; full playback/audio were not.
Problem
In the message composer, Up and Down (with or without Shift) stop working around a link chip that wraps. Extending a selection upwards with Shift+Up stalls as soon as the next line up holds only part of a wrapped GitHub link, and further presses do nothing. Plain Up/Down and Shift+Down break at the same places. WebKit (the macOS app) leaves the caret where it is; Chromium jumps to the start or end of the draft.
Root cause
Links and mentions in the composer are ProseMirror inline atoms rendered as
contenteditable="false"spans whose text wraps like ordinary text. Vertical caret movement was left to the browser. When the visual line above or below holds only (part of) a token — the link's first line when it starts a paragraph, or a middle line when it wraps three or more times — that line has no editable position other than the token's edges, so the browser finds nowhere to put the caret: WebKit gives up, Chromium falls through to the document boundary.Fix
crossTokenLine(EditableInput.tsx) runs before a native Up/Down, plain or with Shift. It reads each token's client rects to find the lines a token holds alone and moves in document terms instead. It stops at the token edge when entering such a line, crosses a middle line to the far edge, and steps to the neighbouring line when leaving. Shift keeps the anchor. It runs from the React keydown handler after the host, so an open completion list still claims Up and Down, and from WebKit's control-character caret-key path.Leaving such a line keeps the caret's column (
992b66df6): the caret lands at the position on the neighbouring line drawn nearest its column, as a browser does between lines of text. Previously it landed at the near end of that line, so Up from a paragraph-leading link stopped just before the line break and Down from a paragraph-ending link just after it. A Shift selection then grew by the break alone and the press looked like it did nothing. Positions are measured withcoordsAtPosrather than hit-tested, so a line scrolled out of the composer still counts.Stepping off a link lands on character boundaries only (
6b4495b5d). The nearest-column walk visits grapheme boundaries, segmenting the textblock's text withIntl.Segmenter; each link, mention or line break reads as one character, so the text's offsets are the textblock's positions. Previously it measured every UTF-16 position, and a position inside a surrogate pair or grapheme cluster is drawn at its glyph, so it could tie with or beat a real boundary. Up from a paragraph-leading link below😀😀😀 words aboveput the caret between the first emoji's surrogates in both engines, and Shift+Up then Backspace left a lone\ud83din the draft. In WebKit, Down onto a line of 👍🏽, ZWJ families or decomposed é landed inside a cluster too.Once the geometry has picked a head, a Shift+Up or Shift+Down press is settled by the selection it leaves (
46b0cc63f). This replaces two rules keyed on where a press started:124c7006ftook a whole link when Shift entered its line from just past the line break beside it, andfff8e8786sent the reverse press back to the anchor. Together they let a press and its reverse carry the head past the anchor. With a link between two empty lines, Shift+Down from its end then Shift+Up selected the whole link backwards instead of collapsing. Shift+Down then Shift+Up from the end of the line above a link that fits on its own line selectedreviewbackwards, so typing would replace it. WithPlease review/ link / empty line /Thanks, three Shift+Down presses then three Shift+Up visited 135, 13 and 0 on the way back, so one press undid two and the next overshot. Three rules now apply:Please reviewandThanks, Shift+Up from the start ofThanksselects the whole link in one press. This also covers links on consecutive lines: each Shift press across them takes a whole link, not the break between them.(a) and (c) apply only toward the anchor, and (b) only away from it, so they never apply to the same press. From a collapsed caret, a lone break onto an empty line is the whole move, since that line holds nothing else. Plain Up and Down are unchanged: they still stop at a link's near edge, which is a real caret position.
The grapheme walk reads each inline leaf as
\nrather than U+FFFC (124c7006f). A combining mark, VS16, skin-tone modifier or ZWJ typed after a link joined U+FFFC in one grapheme, which dropped the position after the link from the walk; nothing after\njoins it. Only a bare carriage return directly before a link would still join it, and typed text never holds one, since inserted text normalises CR LF.A position directly after a newline character or a hard break is measured on its own line (
fff8e8786). Shift+Enter and a text/plain paste put the lines in one paragraph, separated by newline characters, wherefill()makes a paragraph per line.crossTokenLinemeasured the Up caret and the line above a link with side -1, and just past a newline that measures the newline itself, drawn at the end of the line above, so a caret at the start of a line was taken to be one line higher. In aPlease review/ link /X line/Thanksdraft built that way, Up from the start ofThanksskippedX lineonto the link's end, and plain Up from the start ofX line, just past the break below the link, skipped the link's end for its start. With an empty line above the link, Down from the line above skipped the empty line onto the link, and Down from the empty line hit the original bug: Chromium jumped to the end and WebKit stalled. Two helpers,broken(is the character before a position a newline) andat(measure with side 1 after one), now measure the caret and the lines above and below a link, in Chromium and WebKit alike.A textblock start is measured with side 1, like a position just past a line break (
46b0cc63f). Chromium draws no rect at the start of a textblock whose text begins with a newline, so in a message that begins with a blank line, measuring there with side -1 put the line above a link at top 0. Down from that empty first line then jumped to the end of the draft.Rects that bidi reordering splits onto one line are folded into a single line. A line the link is split across is entered at the link's edge from a neighbouring line and left natively.
WebKit only, Down only: once WebKit has drawn the caret just after a wrapped token, native Down stalls. The DOM caret is moved to the start of the following text first, which is the same document position.
mac-webkit.tsexportswebKit()(vendor check on any platform);macWebKit()builds on it.Every other line move stays native. Mac Ctrl+N/Ctrl+P are unchanged.
Demo
WebKit at 480 px, synthetic draft. The caret starts at the end and Shift+↑ is pressed 8 times, 400 ms apart. Left:
mainat the branch point (afac5b5dc). Right: this branch (992b66df6). On the left the selection stops after one line, at the end of the last link. On the right each press adds one line, reaching the top of the draft on the 7th press; the 8th has nothing left to select. The video predates6b4495b5d,124c7006f,fff8e8786and46b0cc63f, and none of them changes this sequence.6b4495b5daffects only lines with multi-unit characters, which this draft has none of. The video's draft is built withfill(), a paragraph per line. At this head (46b0cc63f) the same draft gives the heads the video shows, whether built byfill()or by a text/plain paste: 465, 335, 309, 175, 153, 29, 0, 0. At124c7006fthe pasted draft gave 465, 335, 175, 29, 0, 0, 0, 0, skipping the words before each link.before-after.mp4
Tests
New browser journey in
tests/browser/composer-links.spec.mjs, "Up and Down move one line at a time through links that wrap" (Chromium + WebKit). It covers Up/Down with and without Shift through two wrapped links (a paragraph-leading link and one that wraps twice), paragraphs that start or end with a wrapped link, Shift from just past the line break beside a link-only line, a link on its own line between two lines of text, drafts built by a text/plain paste (newline characters in one paragraph, as Shift+Enter makes), including a link that fits on its own line, two links on consecutive lines and a draft that starts and ends with an empty line, a Shift press and its reverse from either end of every line and link, a Shift+Enter line break after a link, the control-character key path, a bidi-split link, and emoji next to a paragraph-leading link. It checks the anchor after every press and fails a press that crosses only a line break, except onto or off an empty line, which holds nothing else. Browser-only justification: where a link wraps, and which lines it holds alone, depend on real layout and the engine's native caret movement, which jsdom cannot reproduce. No browser cases were removed.Emoji case (
6b4495b5d):😀😀😀 words aboveon the line above a paragraph-leading link that wraps, and a line of 👍🏽 below it. Up and Shift+Up from the link's start land at 0; Backspace after that Shift+Up deletes the whole line above and leaves no lone surrogate; Down and Shift+Down land on 👍🏽 boundaries. The same commit also asserts that the third and fourth links wrap, which the expected moves depend on.Shift into a link-only line (
124c7006f): from just past the line break beside a wrapped paragraph-leading link (Down from the end of the line above) and beside a paragraph-ending link (Up from the start of the line below), plain Up/Down stop at the link's near edge, while Shift+Up/Down take the whole link and then keep moving one line per press. This replaces6b4495b5d's pin on the old Shift+Down. A newPlease review/ link /Thanksdraft puts a link on its own line: Shift+Up from the start ofThanksvisits the link's start, then 0, and Shift+Down from the end ofPlease reviewlands at the link's end, each press crossing more than a line break. The\nplaceholder has no browser case, as the two candidate positions sit on either side of a zero-width mark; it was checked in Node only, and no committed test covers it.Pasted drafts (
fff8e8786): a newpastehelper dispatches a text/plain paste and asserts that the draft is one paragraph and that the link wraps. It buildsPlease review/ link /X line/Thanks, andPlease review/ empty line / link / empty line /Thanks. From either end of every line, plain Up and plain Down must each move exactly one visual line, and Shift+Up and Shift+Down walks must land on exact heads. A newundocheck, a whole-link Shift press followed by the reverse Shift press, had to end collapsed where it started, on the filled own-line draft and on both pasted drafts;46b0cc63freplaces it withmoves()(below). The filled own-line draft also now asserts that its link wraps.Press and back (
46b0cc63f): amoves()loop runs from every line end and link edge (0, the draft's end, either side of each newline, and each link's start and end) of the filled own-line draft and of every pasted draft, in both directions. A plain press must move exactly one visual line. A Shift press must keep the anchor and select more than a line break, unless it starts or ends on an empty line. The opposite Shift press straight after must keep the anchor, never carry the head past it, and never leave a lone line break selected. New pasted drafts:Please review/ a link short enough not to wrap /Thanks: a link that fits on its own line.PRs:/ link / link /Thanks: two wrapped links on consecutive lines. Shift+Down from 0 visits the first link's start, its end, then the second link's end; Shift+Up from the end visits the second link's end, its start, the first link's start, then 0. Each press takes a whole link, never the break between them.Please review/ link / empty line /Thanks: three Shift+Down presses from the end ofPlease reviewvisit the link's end, the empty line, then the start ofThanks; three Shift+Up presses retrace them, collapsing at the start.Fail-then-pass for
46b0cc63f, with every assertion in the journey made soft and the count of failed assertions per engine:EditableInput.tsxatfff8e878646b0cc63f46b0cc63fwithout (a), the anchor clamp (fails on the link that fits on its own line)46b0cc63fwithout (b), taking the link46b0cc63fwithout (c), collapsing a lone break46b0cc63fwithout the block-start sideFail-then-pass: the journey fails on the merge base in both engines. Removing each part of the fix makes it fail: without the Down caret step WebKit fails (Chromium passes, hence the WebKit gate); the bidi case fails in macOS and Linux WebKit without the fold, and with the fold alone; without
992b66df6's column step both engines fail on the Up walk to the start of the draft and, run alone, on the new case's Down walk; without6b4495b5d's grapheme walk the emoji case fails in both engines, in Chromium on Up (it visits offset 1, mid-😀) and in WebKit on Down (mid-👍🏽); without124c7006f's Shift step each new walk fails in Chromium and WebKit on a break-only move. With every assertion made soft, the journey fails 18 of them at124c7006fin both engines, covering every newfff8e8786check that the old code breaks, and none atfff8e8786. Dropping only the Up caret measurement fails the 7 Up checks, only the line-above measurement the 6 Down checks, and only the undo rule the 6 reverse presses.At
46b0cc63f:composer-links.spec.mjs16/16 with--repeat-each 4on macOS (Chromium + WebKit); VitestEditableInput.test.tsx253/253; typecheck, lint and Biome clean. The demo draft gives 465, 335, 309, 175, 153, 29, 0, 0 on Shift+Up from the end, built byfill()or by paste, in both engines.At
fff8e8786:composer-links.spec.mjs16/16 with--repeat-each 4on macOS (Chromium + WebKit); VitestEditableInput.test.tsx253/253; typecheck, lint and Biome clean.At
124c7006f:composer-links.spec.mjs16/16 with--repeat-each 4on macOS (Chromium + WebKit); VitestEditableInput.test.tsx253/253; typecheck, lint and Biome clean.At
6b4495b5d:composer-links.spec.mjs16/16 with--repeat-each 4on macOS (Chromium + WebKit); VitestEditableInput.test.tsx253/253; typecheck, lint and Biome clean.At
992b66df6:composer-links.spec.mjs20/20 with--repeat-each 5on macOS (Chromium + WebKit), 12/12 in the Linux CI Playwright container;EditableInput.test.tsx, typecheck, Biome and the pre-commit/pre-push hooks clean.At
2bcd59e86: Vitestsrc/features/messages86 files / 2071 tests pass; lint clean.Pre-existing and unrelated:
composer-attachments.spec.mjs(3) and Vitestproject-git/claude-sh/codex-shfail the same way on the base.Follow-up: no draft in
moves()has text sharing a wrapped link's line, so (a) and (c) never run in the demo draft's shape. A pasted draft such as${pasted} is readywould cover it; worth adding later.Known gaps
… is ready for review., the heads are 29 then 153. This is the demo draft's shape, and it is no worse than onmain.Please review/ empty line / link /Thanks, three Shift+Down presses from the end ofPlease reviewvisit 14, 59 and 66, and three Shift+Up presses then visit 59, 13 (collapsed) and 0, leavingPlease reviewselected. The first press down selects only the break onto the empty line, and on the way back (c) collapses that same selection, so the press from the link's end undoes two. With the empty line below the link instead, the walk up and back does the same.Not yet done
😀😀😀 words above) directly above a link that starts its paragraph and wraps: with the caret at the link's start, Shift+Up should select the whole line above, and Backspace should delete it without leaving a broken character (no�or stray half-emoji). Next, at a narrow width, put a link on its own line between two lines of text (for examplePlease review, the link,Thanks): Shift+Up from the start of the line below should select the whole link in one press, and plain Up from the start of that line should put the caret just after the link. Finally, build the lines with Shift+Enter instead: typePlease review, press Shift+Enter, paste the link, press Shift+Enter, typeX line, press Shift+Enter, typeThanks. From the start and the end of each line, Up and Down, with or without Shift, should move exactly one line per press: no press should skipX lineor an end of the link, or look like it did nothing. The one exception is that Shift+Up from the start ofX line, or Shift+Down from the end ofPlease review, selects the whole link in one press; the reverse Shift press straight after should collapse the selection back where it started. Last, press Shift+Up a few lines and then Shift+Down the same number of times. No press should carry the selection across where you started, and no press should leave only an invisible line break selected. The walk back usually ends collapsed where you started, but in the shapes listed under Known gaps it can stop one line short, leaving a link or the text after it selected, or, beside an empty line, collapse one press early and then select the line beyond where you started.🤖 Generated with Claude Code