Skip to content

feat(playground): manual undo/redo for the editor - #20

Merged
ohnishant merged 3 commits into
mainfrom
feat/undo-stack
Aug 23, 2026
Merged

ohnishant merged 3 commits into
mainfrom
feat/undo-stack

Conversation

@ohnishant

Copy link
Copy Markdown
Owner

The playground normalizes its DOM after every input event, which discards the browser's native undo history, so ctrl+z did nothing in the editor. This adds a hand-rolled stack rather than pulling in an editor component; the only thing CodeMirror would buy here is highlighting, and the keywords are already emotes.

How it works:

  • normalize() schedules a commit 400ms after the last mutation. The commit stores the previous known {source, caretOffset}, so one undo step rewinds a whole typed burst instead of the final character.
  • ctrl+z / cmd+z undoes, ctrl+shift+z / ctrl+y redoes (meta covers mac). The combos are always preventDefault-ed; letting native undo run against a rebuilt DOM would corrupt it.
  • Restores go through the existing emote-image rebuild path with the saved caret offset, and close any open autocomplete popup since its anchor word may be gone.
  • Programmatic replaces (autocomplete acceptance, example loading) reset the stack, per the notes on playground: editor undo (ctrl+z) rebuilds the DOM on every keystroke #18.
  • Depth is capped at 200 snapshots.

Closes #18

Verified with node --check web/main.js, go test ./..., go vet ./..., gofmt -l .

normalize() rebuilds the DOM after every input event, so the browser's
native undo history never survives; ctrl+z did nothing. Keep our own
stack instead of pulling in an editor component:

- snapshot commits land 400ms after the last mutation and store the
  previous {source, caretOffset}, so one step rewinds a typed burst
- ctrl/cmd+z undoes, ctrl/cmd+shift+z and ctrl+y redo; the combos are
  always swallowed since native undo would corrupt the rebuilt DOM
- programmatic replaces (autocomplete accept, example load) reset the
  stack, as sketched in #18
- depth capped at 200 snapshots

Closes #18
Self-review found committed is only initialized because loadExample
runs synchronously at startup; make commitUndoPoint adopt the current
state instead of throwing if that init ever moves.
@ohnishant

Copy link
Copy Markdown
Owner Author

Self-reviewed the diff before merging.

I walked the undo/redo transitions by hand: undo twice, redo twice, typing after an undo to confirm the redo side drops correctly, and ctrl+z landing while the 400ms commit timer is still pending (the flush path). The committed-state scheme stays consistent through all of those; I found no way to corrupt the stacks or lose the caret.

Two things worth recording:

  1. committed starts as null and only gets initialized because loadExample("countdown") runs synchronously at startup, ahead of anything that could touch it. That invariant lives entirely in file ordering, so I pushed a one-line guard in commitUndoPoint that adopts the current state instead of throwing if startup order ever changes.

  2. Resetting the whole stack on autocomplete acceptance follows the sketch in playground: editor undo (ctrl+z) rebuilds the DOM on every keystroke #18, but a heavy tab-completion user loses their history often. Treating an accepted completion as a regular undo point instead of a wipe would preserve history; left as-is since it matches the issue. Happy to change it if it feels wrong in use.

Also noted: snapshots store carets, not selections, so undoing an edit that replaced a multi-character selection leaves the caret at the boundary rather than re-selecting the range. Cosmetic for a playground this size.

There is no JS test infrastructure in this repo, so verification was hand-tracing plus node --check and the Go suite (which doesn't cover this file). Filed follow-ups based on this work: string highlighting and playground JS test coverage.

* feat(playground): vendor Hack as the editor font

The CSS already asked for JetBrains Mono and Fira Code but loaded
neither, so every visitor saw a fallback. Ship Hack woff2 (regular +
bold, MIT license included) with @font-face and put it first in the
mono stacks for editor, output, and autocomplete.

* feat(playground): highlight string literals in the editor (#21)

buildEditorFragment now splits quoted spans before emote substitution
and wraps them in a .str span. The quote pairing mirrors the lexer's
readString: no escapes, newlines allowed, unterminated runs to EOF.
Emote names still render as images inside strings, as before.

editorSource/forEachPiece/setCaretOffset already recurse into element
nodes, so the span wrapper needs no caret-math changes.

* feat(playground): line-number gutter with click-to-line

The editor gets a real text-editor gutter: numbers derived from the
source (never the DOM), vertical scroll mirrored from the editor,
horizontal scrolls left alone, and clicking a number places the caret
at that line's first character. applySnapshot updates the gutter too,
so undo/redo keep the count honest.

Two layout fixes make internal scrolling possible at all: main's grid
row is capped with minmax(0,1fr) and the wide layout gives body a
definite height, so long source scrolls inside the pane instead of
stretching the page and dragging the gutter away. The editor switches
from pre-wrap to pre for the same reason - a wrapped line would span
several rows and desynchronize the numbers.

* fix(playground): address self-review findings on the editor work

- pair strings the way the lexer does: #-to-EOL comment regions are
  carved out first, so a quote in a comment can no longer open a
  phantom string across real code; comments render muted, emotes
  inside them still render as images
- ignore gutter clicks on empty space below the last number instead
  of teleporting the caret to line 1
- resync gutter scroll after undo/redo restores, for engines that
  skip the scroll event when clamping scrollTop
- drop hack-bold.woff2: no stylesheet requests bold monospace

Found via the self-review posted on #23.
@ohnishant
ohnishant merged commit 2212464 into main Aug 23, 2026
13 checks passed
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.

playground: editor undo (ctrl+z) rebuilds the DOM on every keystroke

1 participant