Skip to content

[editor] Beta 1.3#749

Draft
amadeus wants to merge 59 commits into
mainfrom
beta-1.3
Draft

[editor] Beta 1.3#749
amadeus wants to merge 59 commits into
mainfrom
beta-1.3

Conversation

@amadeus

@amadeus amadeus commented May 29, 2026

Copy link
Copy Markdown
Member

Used to track editor beta versions. Will be periodically rebased on main.

@vercel

vercel Bot commented May 29, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
pierre-docs-diffs Ready Ready Preview Jun 22, 2026 6:47pm
pierre-docs-diffshub Ready Ready Preview Jun 22, 2026 6:47pm
pierre-docs-trees Ready Ready Preview Jun 22, 2026 6:47pm
pierrejs-diff-demo Ready Ready Preview Jun 22, 2026 6:47pm
pierrejs-docs Ready Ready Preview Jun 22, 2026 6:47pm

Request Review

@amadeus amadeus mentioned this pull request May 29, 2026
15 tasks
necolas and others added 30 commits June 22, 2026 11:45
Editing a diff was re-diffing the whole file twice for every keystroke
that adds or removes a line (Enter, Backspace that merges lines, typing
over a multi-line selection).

For such edits the editor calls updateRenderCache and then
applyDocumentChange in the same pass, and both rebuild hunk metadata
with a full recompute. The recompute in updateRenderCache runs against
a half-updated line array and is immediately discarded by
applyDocumentChange, which recomputes from the authoritative document
text — so it is pure wasted work.

It cannot be made incremental: updateDiffHunks falls back to a full
recompute whenever the addition and deletion line counts differ, which
is exactly a line-count change. Since applyDocumentChange already does
the authoritative recompute, the editor now tells updateRenderCache to
skip the hunk recompute on these edits. The per-line token and text
updates still run, so the rendered output is unchanged.
editor.applyEdits applied edits to the buffer but called #applyChange
with selections=undefined, so the selection-update path was skipped
and #selections was never remapped against the edit. After a
programmatic edit before the caret, getState().selections, the native
window selection, and the on-screen caret all pointed at stale
offsets, and the next keystroke was computed from the wrong position.

Capture the selection edges and edit ranges as pre-edit offsets, remap
each selection edge through the applied edits (an edge inside a
replaced range collapses to the end of the replacement; direction is
preserved), and pass the remapped selections to #applyChange. When
updateHistory is set, record them as the entry's after-selections so
redo restores the same caret. This matches every other edit path,
which already remaps selections.

Behavior change: a programmatic applyEdits now repositions the caret
and, like undo/redo and other edits, focuses the editor and scrolls
the caret into view. Hosts that previously relied on applyEdits
leaving focus untouched will now see the editor take focus.

Add test/editorApplyEdits.test.ts, which drives the public API through
the jsdom harness: caret shifts down for an insert above it, both
edges of a range shift while direction is preserved, the caret holds
for an edit after it, and redo restores the remapped caret under
updateHistory. Verified with moonx diffs:test (552 pass),
diffs:typecheck, and root:format root:lint.
remapOffsetThroughEdits treated an offset equal to an edit's start as
sitting before the edit, so inserting text at a collapsed caret left
the caret on the left of the new text and the next keystroke landed in
front of it. Use right gravity at the edit-start boundary so an offset
at or after an edit's start follows the replacement, matching how
typing advances the caret past inserted characters.

Add a regression test covering an insert at the collapsed caret.
Verified with moonx diffs:test (553 pass), diffs:typecheck, and
root:format root:lint.
The editor caret animated with `blinking 1.2s infinite` regardless of
the OS reduced-motion preference. Add a `prefers-reduced-motion: reduce`
media query that drops the animation so the caret renders as a solid
bar instead of pulsing.
Route cut events through a dedicated cut path so caret-only cuts copy
and remove the full logical line instead of behaving like copy. With
multiple selections, every collapsed caret cuts its own line while
ranged selections cut their text, and overlapping cuts are merged so
shared text is removed and copied only once.

Unify copy and cut clipboard text behind a shared getSelectionText
helper so the two stay in sync: a collapsed selection now contributes
its whole logical line including the trailing line break (the final line
has none), and separators synthesized between non-contiguous regions use
the document's detected line ending. Note this also changes a plain copy
of a caret to include the line's trailing newline.

Add editor clipboard regression tests (single, multi-cursor, mixed,
same-line, and range-overlapping-caret cuts, plus copy) and expose the
jsdom globals needed to mount the editor in those tests.
Shift-select goal column. Put the caret well into a long line (say
column 20), then hold Shift and press Down across a shorter line onto
another long line. The selection should keep extending at column 20,
but the focus snapped to the end of the short line and stayed there on
later moves. mapSelectionShift round-tripped the focus through a
document offset, which clamps the column to the short line's length; it
now carries the focus position directly so the preferred column
survives. Auto-surrounding or running a selection action over such an
overshooting selection also pulled in the short line's trailing
newline, so getText now clamps positions to the visible line before
slicing.

Multi-caret Tab indent. With several cursors on one line (say columns 0
and 5 of "abcdefgh"), press Tab. The indent was re-applied once per
cursor, over-indenting the line, and the second cursor landed before
its own indent (column 7 instead of 9), so the next keystroke went to
the wrong place. Tab now batches the per-caret edits into one change
and shifts each later same-line caret past the indents inserted ahead
of it.

Linux Ctrl+A / Ctrl+F. On Linux these triggered Emacs cursor moves
(line start / forward char) instead of select-all and find; those
bindings are now limited to macOS.

Supporting fixes: truncate cached file rows when an edit removes lines
(stale rows lingered after deletions); keep the default tab size when
the computed tab-size is non-numeric; and reset cached platform
detection on each test DOM install so platform-specific tests stay
isolated.
The editor is commonly embedded next to other inputs — for example an
AI chat box that writes generated code into the editor while the user
keeps typing in the chat box. A host inserts that generated code with
editor.applyEdits(). Before this change, the programmatic edit pulled
focus into the editor and scrolled to the caret, interrupting whatever
the user was typing elsewhere on the page.

Repro:
1. Render a File with the editor plus another text input on the page.
2. Click into the editor to place a caret, then click into the other
   input and start typing.
3. While focus is in the other input, run editor.applyEdits([...]) (an
   AI action, a codemod, or a call from the devtools console).

Before:
Focus jumps to the editor and the view scrolls to the caret, cutting off
the user's typing. After: focus stays in the other input and the editor
updates quietly, with its caret left in the correct, remapped spot for
when the user returns.

Cause and fix:
applyEdits routes through `#applyChange`, which always called focus()
and scrolled the caret into view. It now tracks the contenteditable's
focus via focus/blur listeners (`#contentHasFocus`) and passes skipFocus
to `#applyChange` when the editor is not focused at edit time. The
selection is still re-anchored either way, so the caret is correct once
the user returns; only the focus and scroll side effects are suppressed.
Already-focused editors (typing, undo/redo, in-editor edits) are
unchanged.

One timing case:
`#focus()` defers the real focus() call to a requestAnimationFrame, so
in a same-tick setSelections()/focus() then applyEdits() flow the focus
event has not fired yet. That edit would wrongly be treated as unfocused
and skip repositioning, while the queued focus landed afterward on a
stale native selection. `#focus()` now sets `#contentHasFocus` eagerly
so a same-tick edit still repositions.

Tests:
applyEdits leaves an unfocused editor's focus untouched, an
already-focused editor still repositions, and a same-tick
setSelections/focus + applyEdits flow repositions.
A programmatic applyEdits on an unfocused editor re-anchors #selections
but, with skipFocus, never syncs the native Selection. The global
selectionchange handler did not check focus, so a DOM-driven or refocus
selectionchange whose range still belonged to the editor could overwrite
the remapped caret with a stale position before the user returned.

Bail out of the handler when the contenteditable is unfocused. Pointer
and programmatic focus set the focus flag first, so those paths are
unaffected.
After a skipFocus applyEdits, #selections is remapped but the native DOM
Selection is left at its old range. On a keyboard or direct programmatic
refocus the focus listener sets #contentHasFocus before selectionchange
fires, so the unfocused guard no longer suppresses the stale range; it
overwrites the remapped caret — putting the next keystroke at the
pre-edit position.

Re-assert the primary selection onto the native Selection when the
editor regains focus without a pointer gesture. A pointer focus is left
to the click, and #focus() already syncs during an editor-driven focus.
Line-by-line search compiled a new RegExp from the pattern's source
and flags on every line, so a search over an N-line document built N
identical regexes for no benefit. Reuse the single compiled pattern
and reset its lastIndex before each line instead.

Add a regression test for matches within and across lines, which had
no happy-path coverage before.
Type a character, paste some text, then press undo. Both the paste
and the typed character disappear together, but undo should remove
only the paste.

Paste and cut go through the same path as typing, and the undo stack
only looks at the shape of an edit. A single-line paste looks just
like more typing, so it merged into the previous keystroke's undo
entry. (Multi-line pastes were kept separate only because they change
the line count.)

Mark paste and cut as undo boundaries so each stays its own step and
never merges with the edit before or after it. Typing still merges as
before.

Also stop the undo check from merging a newline insert into earlier
typing, so the shared helper is safe for any caller.
Steps to reproduce:
1. Open the editor on a page that uses a custom monospace web font.
2. Load it with a cold cache so the editor renders before the font
   file finishes downloading.
3. Click in the code to place a caret.

The caret and selection sit left of the real text and the gutter is
too narrow. The editor measures the '0' character width on first
render and uses it to size the gutter and place every caret. Before
the web font loads, that width comes from the fallback font.
getComputedStyle returns the same font-family string before and after
the font arrives, so init() never re-measures and the wrong width
sticks.

Re-measure the '0' width once document.fonts.ready resolves. If it
changed, drop the cached widths and offsets and repaint the carets,
selections, and markers so they match the loaded glyphs. Do nothing
when the width is unchanged or the font was already loaded.

The re-measure is scheduled once per mount; cleanUp() clears the guard
so a reused editor instance schedules it again for a font that may not
be loaded yet.
`#computeChangedLineRange` counted only `\n` when sizing the inserted
line span, but the piece table (and `lineCount`/`lineDelta`) count
`\n`, `\r`, and `\r\n` via `computeLineOffsets`. Inserting lone-`\r`
(classic Mac) text therefore under-scoped `changedLineRanges` and
`endLine`, leaving the tokenizer to skip re-highlighting new lines.

Add a `countLineBreaks` helper that applies the same single-pass,
CR/CRLF-aware scan as `computeLineOffsets` without allocating an
offsets array, and use it for the inserted line span. Cover the fix
with lone-`\r` change-range tests plus a unit test asserting
`countLineBreaks` stays in lockstep with `computeLineOffsets`.
Put a tab after other text on a line, like `foo<tab>bar`, then place
the caret right after the tab. The caret and selection box landed a
column past the real glyph. With line wrapping on, selecting tabbed
text that starts partway into a visual row was mis-sized too.

The editor renders tabs with CSS tab-size, so a tab advances to the
next tab stop (the next multiple of the tab size). The measurement
code instead added a full tab size for every tab, which is too wide
for any tab that follows other characters. Leading indentation still
lined up because each tab there already starts on a tab stop.

Advance each tab to the next tab stop from its running column, both
in the ASCII column count and in the tab-to-space expansion used for
canvas and DOM measurement. For wrapped selections, measure the width
as the gap between two offsets taken from the segment start (which
sits on a tab stop) instead of measuring the sliced selection on its
own, matching how the non-wrapped path already works. Add regression
tests for mid-line tabs and the slice-versus-offset difference.
Steps to reproduce:
1. In the diffs editor, edit a line with an emoji, e.g. "a😀".
2. Put the caret after "a" and press Right. The caret lands
   inside the emoji instead of moving past it; a later insert
   or delete then splits it into a lone surrogate.
3. Put the caret at the end of "a😀" and press Ctrl-T. Instead
   of swapping to "😀a", the emoji is split into mojibake.

Cause: mapCursorMove stepped left/right by one UTF-16 code unit
and transpose swapped single code units. The document is UTF-16,
so an emoji takes two units; both operations landed on or split a
surrogate pair, even though the module already segments graphemes
for word operations.

Fix: move horizontally by whole grapheme clusters and transpose
whole graphemes, via a shared getLineGraphemeStarts helper built
on Intl.Segmenter (also reused by deleteWordBackward). ASCII
behavior is unchanged. Add regression tests for left/right move,
shift-select, and the mid-line, end-of-line, and cross-line
transpose branches.
The editor could only undo and redo through keyboard shortcuts. Apps
that drive the editor (toolbar buttons, AI edits, custom shortcuts)
had no way to trigger or check undo state.

Expose `undo()`, `redo()`, `canUndo`, and `canRedo` on the Editor
class. The methods reuse the existing command path, so programmatic
and keyboard undo behave the same and consumers still get the
`onChange` callback after each one.
* fix(diffs): memoize and round DOM text measurements

Steps to reproduce:
1. Open the diffs editor on a line containing an emoji, such as
   const greeting = "😀 hello".
2. Move the caret along that line or extend a selection over it.
3. Watch the Performance panel: every caret or selection update
   forces a synchronous layout. For each non-ASCII run the editor
   inserts a hidden span, reads getBoundingClientRect(), and
   removes it, with no caching, so the same runs are re-measured on
   every render. That path also returns the raw sub-pixel width
   while the canvas and ASCII paths round, so offsets are quantized
   inconsistently across runs on the same line.

Fix:
- Memoize domMeasureTextWidth() by measured text, capped at 4096
  entries with FIFO eviction, so repeats skip the reflow.
- Clear the cache in init() when the font changes, since cached
  widths are font-specific.
- Round the DOM-measured width to match canvasMeasureTextWidth and
  the ch metric.

Add regression tests for the rounding, one measurement per distinct
run, and cache invalidation on font change.

* fix(diffs): clear text width cache on layout reflow

The DOM text-width cache was only cleared on a font change in init(),
which runs only for a new content element. When a web font finished
loading or font-related styles changed on the same content element,
cached emoji/non-ASCII widths could stay stuck at the previous font.

Clear the cache from the editor's resize handler, where the sibling
DOM-measurement caches (wrap offsets, line Y, last caret X) are
already invalidated on a width change, so the widths re-measure after
such a reflow.

* Fix  text wdith cache key

---------

Co-authored-by: Je Xia <i@jex.me>
* some tweaks

* add editing to playground

* MAYBE: fix theme detection

* MAYBE: fix wrapping breaking markers and cursor when in split mode

* Update editor theme css

* Fix split diff with wrap

* Fix selection flash

* fix menus on playground for mobile

* update liveediting demo for better mobile

---------

Co-authored-by: Je Xia <i@jex.me>
Repro:
1. Place a cursor on a line; the whole line is highlighted.
2. Select across multiple lines.
3. The line the cursor is on is no longer highlighted.

#updateSelections only applied the active-line highlight for a
collapsed cursor. A ranged selection set a data-active attribute
on the gutter line number instead, but that attribute has no CSS
rule and is read nowhere, so the line lost its highlight as soon
as the selection gained any width.

Highlight the line holding the caret (the selection head) for
both collapsed and ranged selections, and remove the dead
data-active path. getCaretPosition resolves the selection head,
so backward selections highlight the start line. This also
removes a per-update querySelectorAll, and the gutter number now
highlights through data-selected-line.

Cover the collapsed and multi-line cases with a regression test.
The editor renders its active-line highlight by calling the
component setSelectedLines, which commits a line-selection range
and fires the host onLineSelected callback. A caret or text
selection in the editor is not a gutter line selection, so an
editable File/FileDiff with a line-selection handler received a
bogus notification on every selection update.

Pass notify: false from the editor so the highlight renders
without committing a line selection, and widen the component
setSelectedLines type to accept the option.

Add a regression test asserting onLineSelected stays silent for a
multi-line editor selection.
Steps to reproduce:
1. Open edit mode with the selection action enabled.
2. Drag to select several characters inside a word.
3. Click the gutter lightning-bolt icon, then the wrap button.
4. Only the first (or last) character is wrapped, not the
   whole selection.

The gutter icon is a cached DOM element reused across renders for
the same line, but its click handler closed over the selection
captured when the icon was first created. During a drag that is
the first single-character selection (the first letter for a
forward drag, the last for a backward drag), so the action ran
against a stale range instead of the user's final selection.

Read the current primary selection at click time so the action
always operates on what the user has selected. Add a regression
test covering the forward and backward drag cases.
Repro (diffs /playground, edit mode):
1. Select all text in the editor.
2. Press delete/backspace.

Before this fix the editor breaks: split mode collapses to a
single uneditable view and undo does nothing; unified mode keeps
the view but you can no longer type.

The editor's text document always keeps one (empty) line, but
splitFileContents('') returns [], so emptying the editable side
recomputed the diff with zero addition lines. The additions
column then rendered no line elements, leaving the attached
editor with nothing to host its caret.

Now an emptied document is represented as one empty editable
line: diff the unchanged deletions against a single empty line
and store the addition as [''] so it still joins back to the
editor's empty document. Covered by model- and DOM-level
regression tests in both split and unified modes.
When the editor is emptied, the recompute diffs the deletions
against a single empty line to place one editable row. If the
old side was itself a single blank line, that diff was a no-op
(zero hunks), so iterateOverDiff emitted nothing and the row was
still missing.

Pick a sentinel that always differs from the deletion side so a
hunk is produced; its text is discarded by the [''] override.
Covered by the empty-document regression tests.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants