CodeMirror
This is a real-time collaborative example of the CodeMirror 6 editor. It uses the Text, a custom CRDT type from Yorkie.
main.ts
1import type { EditOpInfo, OpInfo } from '@yorkie-js/sdk';2import yorkie, { DocEventType } from '@yorkie-js/sdk';3import { basicSetup, EditorView } from 'codemirror';4import { Transaction, TransactionSpec } from '@codemirror/state';5import { keymap } from '@codemirror/view';6import { network } from './network';7import { displayLog, displayPeers } from './utils';8import { YorkieDoc, YorkiePresence } from './type';9import './style.css';1011const networkStatusElem = document.getElementById('network-status')!;12const peersElem = document.getElementById('peers')!;13const editorParentElem = document.getElementById('editor')!;1415async function main() {16 // 01. create client with RPCAddr then activate it.17 const client = new yorkie.Client({18 rpcAddr: import.meta.env.VITE_YORKIE_API_ADDR,19 apiKey: import.meta.env.VITE_YORKIE_API_KEY,20 });21 await client.activate();2223 // 02-1. create a document then attach it into the client.24 const doc = new yorkie.Document<YorkieDoc, YorkiePresence>(25 `codemirror6-${new Date()26 .toISOString()27 .substring(0, 10)28 .replace(/-/g, '')}`,29 {30 enableDevtools: true,31 },32 );33 doc.subscribe('connection', (event) => {34 network.statusListener(networkStatusElem)(event);35 });36 doc.subscribe('presence', (event) => {37 if (event.type !== DocEventType.PresenceChanged) {38 // Presences are keyed by the client's actor, not by the per-session id39 // that `client.getID()` returns.40 displayPeers(peersElem, doc.getPresences(), client.getActorID()!);41 }42 });43 await client.attach(doc, {44 initialPresence: {45 username: client.getActorID()!.slice(-2),46 },47 });48 doc.update((root) => {49 if (!root.content) {50 root.content = new yorkie.Text();51 }52 }, 'create content if not exists');5354 doc.subscribe('$.content', (event) => {55 if (event.type === 'remote-change') {56 const { operations } = event.value;57 handleOperations(operations, false);58 } else if (event.source === 'undoredo') {59 const { operations } = event.value;60 handleOperations(operations, true);61 }62 });6364 await client.sync();6566 // 03-1. define function that bind the document with the codemirror(broadcast local changes to peers)67 const updateListener = EditorView.updateListener.of((viewUpdate) => {68 // One refused change invalidates the whole update, not just its own69 // transaction: the editor now holds text the document does not, so every70 // later index in this update - including the selection used for presence71 // below - is measured against a document that never moved. Nothing may be72 // translated through `root.content` again until `syncText` has run.73 let rejected = false;74 if (viewUpdate.docChanged) {75 for (const tr of viewUpdate.transactions) {76 if (rejected) {77 break;78 }79 const events = ['select', 'input', 'delete', 'move'];80 if (!events.map((event) => tr.isUserEvent(event)).some(Boolean)) {81 continue;82 }83 if (tr.annotation(Transaction.remote)) {84 continue;85 }86 let adj = 0;87 tr.changes.iterChanges((fromA, toA, _, __, inserted) => {88 // `iterChanges` cannot be stopped early, so skip the rest of the89 // transaction once one of its changes was refused.90 if (rejected) return;9192 const insertText = inserted.toJSON().join('\n');93 try {94 doc.update((root) => {95 root.content.edit(fromA + adj, toA + adj, insertText);96 }, `update content byA ${client.getID()}`);97 } catch (err) {98 // `Text.edit` refuses an index that splits a UTF-16 surrogate99 // pair, and content carrying a lone half of one, so a local100 // CodeMirror change is not guaranteed to reach the document.101 // Rebuild the editor from the document so the refused change is102 // visibly undone instead of leaving the two silently diverged.103 // The rebuild is deferred: dispatching from inside an update104 // listener re-enters CodeMirror's update cycle.105 rejected = true;106 console.error('local edit rejected, re-syncing editor:', err);107 queueMicrotask(() => syncText());108 return;109 }110 adj += insertText.length - (toA - fromA);111 });112 }113 }114115 if (rejected) {116 // The deferred `syncText` dispatches its own update, and the presence117 // selection is recomputed there against a document the editor matches.118 return;119 }120121 const hasFocus =122 viewUpdate.view.hasFocus && viewUpdate.view.dom.ownerDocument.hasFocus();123 const sel = hasFocus ? viewUpdate.state.selection.main : null;124125 doc.update((root, presence) => {126 if (sel && root.content) {127 const selection = root.content.indexRangeToPosRange([128 sel.anchor,129 sel.head,130 ]);131132 if (133 JSON.stringify(selection) !==134 JSON.stringify(presence.get('selection'))135 ) {136 presence.set({137 selection,138 });139 }140 } else if (presence.get('selection')) {141 presence.set({142 selection: undefined,143 });144 }145 });146 });147148 // 03-2. create codemirror instance149 // Height: show about 10 lines without needing actual blank text lines.150 // Adjust the px value if font-size/line-height changes.151 const fixedHeightTheme = EditorView.theme({152 '.cm-content, .cm-gutter': { minHeight: '210px' }, // ~10 lines (≈21px per line including padding)153 });154 const cmUndoRedoKeymap = keymap.of([155 {156 key: 'Mod-z',157 preventDefault: true,158 run: () => {159 if (doc.history.canUndo()) {160 doc.history.undo();161 }162 return true;163 },164 },165 {166 key: 'Mod-Shift-z',167 preventDefault: true,168 run: () => {169 if (doc.history.canRedo()) {170 doc.history.redo();171 }172 return true;173 },174 },175 ]);176 const view = new EditorView({177 doc: '',178 extensions: [179 cmUndoRedoKeymap,180 basicSetup,181 fixedHeightTheme,182 updateListener,183 ],184 parent: editorParentElem,185 });186 // 02-2. subscribe document event.187 const syncText = () => {188 const text = doc.getRoot().content;189 const selection = doc.getMyPresence().selection;190 const transactionSpec: TransactionSpec = {191 changes: { from: 0, to: view.state.doc.length, insert: text.toString() },192 annotations: [Transaction.remote.of(true)],193 };194195 if (selection) {196 // Restore the cursor position when the text is replaced.197 const cursor = text.posRangeToIndexRange(selection);198 transactionSpec['selection'] = {199 anchor: cursor[0],200 head: cursor[1],201 };202 }203 view.dispatch(transactionSpec);204 };205 doc.subscribe((event) => {206 if (event.type === 'snapshot') {207 // The text is replaced to snapshot and must be re-synced.208 syncText();209 }210 });211212 // 03-3. define event handler that apply remote changes to local213 function handleOperations(operations: Array<OpInfo>, moveCursor: boolean) {214 for (const op of operations) {215 if (op.type === 'edit') {216 handleEditOp(op, moveCursor);217 }218 }219 }220 function handleEditOp(op: EditOpInfo, moveCursor: boolean) {221 const changes = [222 {223 from: Math.max(0, op.from),224 to: Math.max(0, op.to),225 insert: op.value!.content,226 },227 ];228229 const transactionSpec: TransactionSpec = {230 changes,231 annotations: [Transaction.remote.of(true)],232 };233234 // Move cursor to the changed position for undo/redo235 if (moveCursor) {236 const newPosition = op.from + (op.value?.content?.length || 0);237 transactionSpec.selection = {238 anchor: newPosition,239 head: newPosition,240 };241 }242243 view.dispatch(transactionSpec);244 }245246 syncText();247}248249main();
- User 1
- User 2
- User 1
- User 2