Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
2 tasks
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Used to track editor beta versions. Will be periodically rebased on main.