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 displayPeers(peersElem, doc.getPresences(), client.getID()!);39 }40 });41 await client.attach(doc, {42 initialPresence: {43 username: client.getID()!.slice(-2),44 },45 });46 doc.update((root) => {47 if (!root.content) {48 root.content = new yorkie.Text();49 }50 }, 'create content if not exists');5152 doc.subscribe('$.content', (event) => {53 if (event.type === 'remote-change') {54 const { operations } = event.value;55 handleOperations(operations, false);56 } else if (event.source === 'undoredo') {57 const { operations } = event.value;58 handleOperations(operations, true);59 }60 });6162 await client.sync();6364 // 03-1. define function that bind the document with the codemirror(broadcast local changes to peers)65 const updateListener = EditorView.updateListener.of((viewUpdate) => {66 if (viewUpdate.docChanged) {67 for (const tr of viewUpdate.transactions) {68 const events = ['select', 'input', 'delete', 'move'];69 if (!events.map((event) => tr.isUserEvent(event)).some(Boolean)) {70 continue;71 }72 if (tr.annotation(Transaction.remote)) {73 continue;74 }75 let adj = 0;76 tr.changes.iterChanges((fromA, toA, _, __, inserted) => {77 const insertText = inserted.toJSON().join('\n');78 doc.update((root) => {79 root.content.edit(fromA + adj, toA + adj, insertText);80 }, `update content byA ${client.getID()}`);81 adj += insertText.length - (toA - fromA);82 });83 }84 }8586 const hasFocus =87 viewUpdate.view.hasFocus && viewUpdate.view.dom.ownerDocument.hasFocus();88 const sel = hasFocus ? viewUpdate.state.selection.main : null;8990 doc.update((root, presence) => {91 if (sel && root.content) {92 const selection = root.content.indexRangeToPosRange([93 sel.anchor,94 sel.head,95 ]);9697 if (98 JSON.stringify(selection) !==99 JSON.stringify(presence.get('selection'))100 ) {101 presence.set({102 selection,103 });104 }105 } else if (presence.get('selection')) {106 presence.set({107 selection: undefined,108 });109 }110 });111 });112113 // 03-2. create codemirror instance114 // Height: show about 10 lines without needing actual blank text lines.115 // Adjust the px value if font-size/line-height changes.116 const fixedHeightTheme = EditorView.theme({117 '.cm-content, .cm-gutter': { minHeight: '210px' }, // ~10 lines (≈21px per line including padding)118 });119 const cmUndoRedoKeymap = keymap.of([120 {121 key: 'Mod-z',122 preventDefault: true,123 run: () => {124 if (doc.history.canUndo()) {125 doc.history.undo();126 }127 return true;128 },129 },130 {131 key: 'Mod-Shift-z',132 preventDefault: true,133 run: () => {134 if (doc.history.canRedo()) {135 doc.history.redo();136 }137 return true;138 },139 },140 ]);141 const view = new EditorView({142 doc: '',143 extensions: [144 cmUndoRedoKeymap,145 basicSetup,146 fixedHeightTheme,147 updateListener,148 ],149 parent: editorParentElem,150 });151 // 02-2. subscribe document event.152 const syncText = () => {153 const text = doc.getRoot().content;154 const selection = doc.getMyPresence().selection;155 const transactionSpec: TransactionSpec = {156 changes: { from: 0, to: view.state.doc.length, insert: text.toString() },157 annotations: [Transaction.remote.of(true)],158 };159160 if (selection) {161 // Restore the cursor position when the text is replaced.162 const cursor = text.posRangeToIndexRange(selection);163 transactionSpec['selection'] = {164 anchor: cursor[0],165 head: cursor[1],166 };167 }168 view.dispatch(transactionSpec);169 };170 doc.subscribe((event) => {171 if (event.type === 'snapshot') {172 // The text is replaced to snapshot and must be re-synced.173 syncText();174 }175 });176177 // 03-3. define event handler that apply remote changes to local178 function handleOperations(operations: Array<OpInfo>, moveCursor: boolean) {179 for (const op of operations) {180 if (op.type === 'edit') {181 handleEditOp(op, moveCursor);182 }183 }184 }185 function handleEditOp(op: EditOpInfo, moveCursor: boolean) {186 const changes = [187 {188 from: Math.max(0, op.from),189 to: Math.max(0, op.to),190 insert: op.value!.content,191 },192 ];193194 const transactionSpec: TransactionSpec = {195 changes,196 annotations: [Transaction.remote.of(true)],197 };198199 // Move cursor to the changed position for undo/redo200 if (moveCursor) {201 const newPosition = op.from + (op.value?.content?.length || 0);202 transactionSpec.selection = {203 anchor: newPosition,204 head: newPosition,205 };206 }207208 view.dispatch(transactionSpec);209 }210211 syncText();212}213214main();
- User 1
- User 2
- User 1
- User 2