codemirror-record@1
CodeMirror instance
Migration runbook / schema 1
Change the editor.
Keep the recording.
Move from CodeMirror 5 and codemirror-record@1 to
CodeMirror 6 and codemirror-record@2. The editor
architecture changes; the recorder/player calls and recording JSON
seam stay in place.
Written as a deterministic runbook for application developers and
coding agents. Read the canonical prose
docs/MIGRATING.md
or load the canonical structured
migration-contract.json.
codemirror-record@2
EditorView
One deliberate break at the editor boundary
Upgrade the editor and recorder package together. Pass an
EditorView where v1 received a CM5 instance. Keep the
recorder/player surface and serialized recording seam.
Construction, values, edits, positions, events, options, CSS, and history.
Named exports, constructor roles, methods, options, and player events.
Opaque v1 wire payload, tested in both producer/player directions.
Compatibility is a release contract enforced by bidirectional packaged-artifact tests. It does not mean that CM5 and CM6 editor APIs are interchangeable. The canonical structured fields live in the machine-readable migration contract.
Select the package major by editor generation
npm install codemirror-record@^1 codemirror@^5
Open the v1 README
npm install codemirror-record@^2 @codemirror/state@^6 @codemirror/view@^6
Open the default v2 README
Add only the language and feature packages the editor uses. The
umbrella codemirror@^6 package provides
basicSetup; language support remains separate. Keep one
resolved copy of each @codemirror/* package.
Replace the editor object, not the workflow
import CodeMirror from 'codemirror';
import {CodePlay, CodeRecord}
from 'codemirror-record';
const recordEditor = CodeMirror(
recordMount,
{
value: initialDocument,
mode: 'javascript',
lineNumbers: true,
},
);
const playEditor = CodeMirror(
playMount,
{
value: initialDocument,
readOnly: 'nocursor',
},
);
const recorder = new CodeRecord(recordEditor);
recorder.listen();
const player = new CodePlay(playEditor);
import {EditorState}
from '@codemirror/state';
import {EditorView, lineNumbers}
from '@codemirror/view';
import {javascript}
from '@codemirror/lang-javascript';
import {CodePlay, CodeRecord}
from 'codemirror-record';
const recordEditor = new EditorView({
parent: recordMount,
doc: initialDocument,
extensions: [lineNumbers(), javascript()],
});
const playEditor = new EditorView({
parent: playMount,
doc: initialDocument,
extensions: [
EditorState.readOnly.of(true),
EditorView.editable.of(false),
],
});
const recorder = new CodeRecord(recordEditor);
recorder.listen();
const player = new CodePlay(playEditor);
CodeRecord.listen() installs its transaction listener.
Do not add a second DOM/input listener for recording. CM6 has no
direct CodeMirror.fromTextArea equivalent; create a view
and copy view.state.doc.toString() back on form submit.
Keep recorder and player call sites
| Surface | Retained in v2 | Migration action |
|---|---|---|
| Recorder | CodeRecord(editor), listen() |
Pass an EditorView |
| External activity | recordExtraActivity(value) |
Keep values JSON-serializable |
| Serialization | getRecords() |
Store the returned string unchanged |
| Player | CodePlay(editor, options) |
Pass an EditorView; keep options |
| Load + transport | addOperations(), play(), pause(), seek(), clear() |
No call-site change |
| Timeline reads | getStatus(), getCurrentTime(), getDuration() |
No call-site change |
| Player events | on(), off(), once() |
Keep play, pause, seek, end, and clear |
Player options stay named the same
maxDelayMaximum gap between operations; zero disables the cap.autoplayStart when operations are added.autofocusFocus the editor during playback.speedPlayback speed multiplier.extraActivityHandlerApply surrounding application activity.extraActivityReverterRevert application activity during backward seek.
Keep the matching setters: setMaxDelay,
setAutoplay, setAutofocus,
setSpeed, setExtraActivityHandler, and
setExtraActivityReverter.
Corrected edge behavior. Maintained v1.1.8 and v2.0.0 deliberately align these runtime details:
seek(0)restores the configured playback speed and a normalPAUSEstate.- Backward seek restores the document, every directed selection, and its primary range.
- Terminal playback enters
PAUSEand emitspausebeforeend; do not preserve the v1.1.6PLAY-inside-endbug. - Paste capture never mutates or duplicates a non-cursor predecessor.
- A replacement seek issued while an earlier seek is still
running preserves the configured playback speed and the
original
PLAYorPAUSEstate. - A seek to duration during active playback emits exactly
one
endevent. - An equal-time compressed group with scalar
texpands every logical operation at that timestamp. Published v0.3.1 through v1.1.7 readers indexed the scalar as an interval and produced invalid timing; v1.1.8 and v2 accept the unchanged bytes and writet: [time, time]for new compressed equal-time groups. - An ungrouped record with interval
t: [start, end]and nolis one operation at the interval end. Published v0.3.1 through v1.1.7 players exposed a non-numeric duration when the record was terminal; v1.1.8 and v2 normalize it and emit a scalar for new ungrouped records.
Translate application-owned editor calls
CM6 state is immutable. Apply changes with transactions and use numeric UTF-16 offsets in application code.
| CodeMirror 5 | CodeMirror 6 |
|---|---|
cm.getValue() | view.state.doc.toString() |
cm.getRange(a, b) | view.state.sliceDoc(a, b) |
cm.getLine(n) | view.state.doc.line(n + 1).text |
cm.lineCount() | view.state.doc.lines |
cm.replaceRange(text, from, to) | view.dispatch({changes: {from, to, insert: text}}) |
cm.replaceSelection(text) | view.dispatch(view.state.replaceSelection(text)) |
cm.setValue(text) | Dispatch a replacement over 0..doc.length |
cm.operation(() => edits) | Dispatch one transaction with a change set |
cm.focus() | view.focus() |
cm.getWrapperElement() | view.dom |
| Remove wrapper DOM | view.destroy() |
{line, ch} becomes a numeric offset
CM6 Text.line(n) is one-based. A CM5 line maps with
the clipping cm5PositionToOffset helper below. It
clips lines before/after the document and clips ch
to the target line, matching CM5. Use that conversion only for
application-owned coordinates, never serialized recordings.
Preserve anchor, head, and primary range
| CodeMirror 5 | CodeMirror 6 |
|---|---|
cm.getCursor() | view.state.selection.main.head |
cm.listSelections() | view.state.selection.ranges |
cm.getSelection() | Slice selection.main.from..to |
cm.getSelections() | Slice every range from state |
cm.somethingSelected() | Test whether any range is non-empty |
cm.setCursor(pos) | Convert pos to an offset, then dispatch the numeric anchor |
cm.setSelection(anchor, head) | Convert both positions to offsets, then dispatch numeric anchor/head values |
cm.setSelections(ranges, primary) | Convert each anchor/head to offsets with EditorSelection.range, then call EditorSelection.create(selectionRanges, primaryIndex) |
Use anchor and head when direction matters;
from and to are normalized bounds.
Convert each anchor/head to offsets with
EditorSelection.range, then call
EditorSelection.create(selectionRanges, primaryIndex).
import {EditorSelection, EditorState} from '@codemirror/state';
import {EditorView} from '@codemirror/view';
const editorState = EditorState.create({
doc: initialValue,
extensions: [
EditorState.allowMultipleSelections.of(true),
// ...the rest of the application's extensions
],
});
const view = new EditorView({state: editorState, parent: editorMount});
const previousPrimaryIndex = view.state.selection.mainIndex;
function cm5PositionToOffset(doc, {line, ch}) {
if (line < 0) return 0;
if (line >= doc.lines) return doc.length;
const targetLine = doc.line(line + 1);
const clippedCh = ch == null ? targetLine.length :
Math.max(0, Math.min(targetLine.length, ch));
return targetLine.from + clippedCh;
}
const selectionRanges = cm5Ranges.map(({anchor, head = anchor}) =>
EditorSelection.range(
cm5PositionToOffset(view.state.doc, anchor),
cm5PositionToOffset(view.state.doc, head),
));
if (selectionRanges.length > 0) {
const primaryIndex = cm5PrimaryIndex ?? Math.min(
selectionRanges.length - 1,
previousPrimaryIndex,
);
view.dispatch({
selection: EditorSelection.create(selectionRanges, primaryIndex),
});
}
Enable EditorState.allowMultipleSelections.of(true)
when creating the state. This recipe accepts an already-normalized
cm.listSelections() snapshot. CM5's default
selectionsMayTouch: false can merge touching or
overlapping raw setSelections inputs differently
from CM6, including direction; normalize them while CM5 is
available or define and test the application's merge rule first.
Pass through CM5's explicit
primary argument. When omitted, CM5 preserves its
previous primary index and clamps it to the new last range, so
use Math.min(selectionRanges.length - 1,
previousPrimaryIndex); do not simply choose the last
range. For an existing CM5 snapshot, match
getCursor('anchor'/'head') against
listSelections() and pass that index. CM6 exposes
the choice as selection.mainIndex. Empty ranges are
a no-op, and raw CM5 {anchor, head} objects are not
CM6 SelectionRange instances.
Observe transactions, not DOM text
| CM5 integration | CM6 integration |
|---|---|
change / changes | EditorView.updateListener, view plugin, or state field |
cursorActivity | Compare starting and resulting selections |
beforeChange | EditorState.changeFilter or transaction filter |
beforeSelectionChange | Transaction filter |
change.origin | Transaction.userEvent and isUserEvent() |
const appObserver = EditorView.updateListener.of((update) => {
if (update.docChanged) {
onDocumentValue(update.state.doc.toString());
}
if (!update.startState.selection.eq(update.state.selection)) {
onSelection(update.state.selection);
}
});
A view update may contain several transactions, and one
transaction may contain several simultaneous changes. Iterate
update.transactions for transaction-sensitive logic.
The recorder already does this and maps user-event annotations to
the established recording origin vocabulary.
Playback uses the reserved Transaction.userEvent
value codemirror-record.playback. A string marker
remains visible across mixed CommonJS/ESM entry paths, where an
identity-based custom Annotation from another
@codemirror/state instance would not. Application
observers may ignore this value, but must not attach it to
ordinary edits.
Move configuration into extensions
| CodeMirror 5 | CodeMirror 6 |
|---|---|
value | doc in state/view configuration |
mode: 'javascript' | javascript() language extension |
lineNumbers: true | lineNumbers() extension |
extraKeys / keyMap | keymap.of([...bindings]) |
readOnly | EditorState.readOnly and optionally EditorView.editable |
cm.setOption(...) | Reconfigure an extension in a Compartment |
const tabSize = new Compartment();
const view = new EditorView({
parent: mount,
extensions: [tabSize.of(EditorState.tabSize.of(2))],
});
view.dispatch({
effects: tabSize.reconfigure(EditorState.tabSize.of(4)),
});
A Compartment reconfiguration preserves the recorder
listener. After view.setState(newState) or a full
top-level StateEffect.reconfigure.of(...), call the
idempotent recorder.listen() again. Because
setState is not a transaction, dispatch any document
or selection replacement that belongs in the recording before
resetting; otherwise start a new recorder and baseline. Keep a
recordable change separate from top-level reconfiguration.
Rebuild custom CSS for the CM6 DOM. Common changes include
.CodeMirror to .cm-editor,
.CodeMirror-line to .cm-line, and
.CodeMirror-scroll to .cm-scroller.
Use decorations for presentation; do not mutate
view.contentDOM directly.
Separate state policy from DOM editability
EditorState.readOnly.of(true)
Keeps the editor focusable and selectable.
EditorView.editable.of(false)
Add this for a non-interactive playback surface.
EditorView.contentAttributes.of({tabindex: '0'})
Add when an uneditable surface still needs keyboard focus.
editable.of(false) alone does not block programmatic
dispatch. Playback uses programmatic transactions, so a read-only
view can still replay recorded changes.
Install undo history explicitly
import {history, historyKeymap, redo, undo}
from '@codemirror/commands';
import {EditorView, keymap} from '@codemirror/view';
const view = new EditorView({
parent: mount,
extensions: [history(), keymap.of(historyKeymap)],
});
undo(view);
redo(view);
A bare CM6 view has no history. Use the extensions above or
basicSetup. CM6 has no direct
clearHistory(); create a fresh state and call
view.setState(newState) for a full document reset,
then immediately call recorder.listen() when that view
is being recorded. setState itself is invisible to the
recorder: first dispatch an equivalent replacement when it must
remain in the same recording, or finish the old recording and
use the fresh state as a new baseline. The player marks replay
transactions to stay out of the user's undo stack.
Pass the same bytes in both directions
const records = recorder.getRecords();
// Store or transfer `records` without parsing or rewriting it.
player.addOperations(records);
player.play();
Old stored recordings must load directly in the v2 player.
New recordings must remain readable by the released v1 player.
This bidirectional release contract covers supported text,
cursor/selection, timing, origin, and external-activity operations.
Serialized positions remain zero-based legacy line/character
pairs. The gate requires trace equality at every logical boundary
for the same payload in both real players, except for operation
time, duration, and affected seek timing when a published
v0.3.1-v1.1.7 reader consumes either an ungrouped
t: [start, end] record with no l or a
compressed record with scalar t plus
l > 1.
Document, selection, origin, and extra-activity traces must still
match; v1.1.8 and v2 use the interval end for the first shape and
the scalar time for every operation in the second. Never rewrite
either payload to hide these reader defects. Exclude operation
time only for operations expanded from an affected record,
duration only when that record is terminal, and any seek
comparison when a payload contains either shape.
Keep representative production recording strings as application
fixtures in addition to the package compatibility suite.
Execute the migration in this order
- 01Freeze a baseline.
Pin v1/CM5 and save representative raw
getRecords()strings. - 02Inventory CM5 seams.
Find constructors, methods, events, options, CSS, selections, read-only, history, and cleanup.
- 03Install v2 and CM6.
Add state/view plus only the language and feature extensions in use.
- 04Replace editor construction.
Create
EditorViewobjects and pass them to existing recorder/player constructors. - 05Translate application integrations.
Use transactions, offsets, selection ranges, listeners, extensions, facets, and explicit history.
- 06Prove both wire directions.
Load unchanged v1 strings in v2 and unchanged v2 strings in a released v1 player.
Checklist for developers and coding agents
Treat every unchecked item as an application migration blocker.
- Dependencies
One compatible resolved copy of each required
@codemirror/*package. - Construction
Every recorder and player receives an
EditorView. - Recorder lifecycle
Dispatch a recordable document/selection replacement before
view.setState(newState), or start a new baseline; then call the idempotentrecorder.listen()after every full reset or top-level reconfiguration. - No transform
Recording strings reach
addOperations()without parsing, conversion, or reserialization. - Old → new
Representative v1 recordings finish with the expected CM6 document, selections, timing, and application state.
- New → old
Representative v2 recordings finish with the expected released-v1 CM5 state.
- Selections
Forward/backward selections, multi-cursor state, and primary range replay correctly.
- Input origins
Typing, multiline edits, delete, paste, drop, IME composition, undo, and redo are covered where used.
- Playback
Play, pause/resume, seek zero/forward/backward, speed, max delay, end, and clear work.
- External activity
Handlers and reverters receive original JSON-serializable values in both directions.
- Read-only + focus
The player matches intended focus, selection, and editability behavior.
- History + cleanup
History exists where needed, replay adds no user undo entries, and replaced views call
destroy().