Quill
This demo shows the real-time collaborative version of the Quill editor with Yorkie and Vite.
main.ts
1/* eslint-disable jsdoc/require-jsdoc */2import yorkie, { DocEventType, Indexable, OperationInfo } from '@yorkie-js/sdk';3import ColorHash from 'color-hash';4import Quill, { Delta, type Op } from 'quill';5import QuillCursors from 'quill-cursors';6import 'quill/dist/quill.snow.css';7import { network } from './network';8import './style.css';9import { YorkieDoc, YorkiePresence } from './type';10import { displayLog, displayPeers } from './utils';1112type TextValueType = {13 attributes?: Indexable;14 content?: string;15};1617const peersElem = document.getElementById('peers')!;18const documentElem = document.getElementById('document')!;19const documentTextElem = document.getElementById('document-text')!;20const networkStatusElem = document.getElementById('network-status')!;21const colorHash = new ColorHash();22const documentKey = `vanilla-quill-${new Date()23 .toISOString()24 .substring(0, 10)25 .replace(/-/g, '')}`;2627function toDeltaOperation<T extends TextValueType>(textValue: T): Op {28 const { embed, ...restAttributes } = textValue.attributes ?? {};29 if (embed) {30 return { insert: JSON.parse(embed.toString()), attributes: restAttributes };31 }3233 return {34 insert: textValue.content || '',35 attributes: textValue.attributes,36 };37}3839async function main() {40 // 01-1. create client with RPCAddr then activate it.41 const client = new yorkie.Client({42 rpcAddr: import.meta.env.VITE_YORKIE_API_ADDR,43 apiKey: import.meta.env.VITE_YORKIE_API_KEY,44 });45 await client.activate();4647 // 02-1. create a document then attach it into the client.48 const doc = new yorkie.Document<YorkieDoc, YorkiePresence>(documentKey, {49 enableDevtools: true,50 });51 doc.subscribe('connection', (event) => {52 network.statusListener(networkStatusElem)(event);53 });54 doc.subscribe('presence', (event) => {55 if (event.type !== DocEventType.PresenceChanged) {56 displayPeers(peersElem, doc.getPresences(), client.getID()!);57 }58 });5960 await client.attach(doc, {61 initialPresence: {62 username: client.getID()!.slice(-2),63 color: colorHash.hex(client.getID()!.slice(-2)),64 selection: undefined,65 },66 });6768 doc.update((root) => {69 if (!root.content) {70 root.content = new yorkie.Text();71 root.content.edit(0, 0, '\n');72 }73 }, 'create content if not exists');7475 // 02-2. subscribe document event.76 doc.subscribe((event) => {77 if (event.type === 'snapshot') {78 // The text is replaced to snapshot and must be re-synced.79 syncText();80 }81 displayLog(documentElem, documentTextElem, doc);82 });8384 doc.subscribe('$.content', (event) => {85 if (event.type === 'remote-change') {86 handleOperations(event.value.operations);87 }88 updateAllCursors();89 });90 doc.subscribe('others', (event) => {91 if (event.type === DocEventType.Unwatched) {92 cursors.removeCursor(event.value.clientID);93 } else if (event.type === DocEventType.PresenceChanged) {94 updateCursor(event.value);95 }96 });9798 function updateCursor(user: { clientID: string; presence: YorkiePresence }) {99 const { clientID, presence } = user;100 if (clientID === client.getID()) return;101 // TODO(chacha912): After resolving the presence initialization issue(#608),102 // remove the following check.103 if (!presence) return;104105 const { username, color, selection } = presence;106 if (!selection) return;107 const range = doc.getRoot().content.posRangeToIndexRange(selection);108 cursors.createCursor(clientID, username, color);109 cursors.moveCursor(clientID, {110 index: range[0],111 length: range[1] - range[0],112 });113 }114115 function updateAllCursors() {116 for (const user of doc.getPresences()) {117 updateCursor(user);118 }119 }120121 await client.sync();122123 // 03. create an instance of Quill124 Quill.register('modules/cursors', QuillCursors);125 const quill = new Quill('#editor', {126 modules: {127 toolbar: [128 ['bold', 'italic', 'underline', 'strike'],129 ['blockquote', 'code-block'],130 [{ header: 1 }, { header: 2 }],131 [{ list: 'ordered' }, { list: 'bullet' }],132 [{ script: 'sub' }, { script: 'super' }],133 [{ indent: '-1' }, { indent: '+1' }],134 [{ direction: 'rtl' }],135 [{ size: ['small', false, 'large', 'huge'] }],136 [{ header: [1, 2, 3, 4, 5, 6, false] }],137 [{ color: [] }, { background: [] }],138 [{ font: [] }],139 [{ align: [] }],140 ['image', 'video'],141 ['clean'],142 ],143 cursors: true,144 },145 theme: 'snow',146 });147 const cursors = quill.getModule('cursors') as QuillCursors;148149 // 04. bind the document with the Quill.150 // 04-1. Quill to Document.151 quill152 .on('text-change', (delta, _, source) => {153 if (source === 'api' || !delta.ops) {154 return;155 }156157 let from = 0,158 to = 0;159 console.log(`%c quill: ${JSON.stringify(delta.ops)}`, 'color: green');160 for (const op of delta.ops) {161 if (op.attributes !== undefined || op.insert !== undefined) {162 if (op.retain !== undefined && typeof op.retain === 'number') {163 to = from + op.retain;164 }165 console.log(166 `%c local: ${from}-${to}: ${op.insert} ${167 op.attributes ? JSON.stringify(op.attributes) : '{}'168 }`,169 'color: green',170 );171172 doc.update((root, presence) => {173 let range;174 if (op.attributes !== undefined && op.insert === undefined) {175 root.content.setStyle(from, to, op.attributes as Indexable);176 } else if (op.insert !== undefined) {177 if (to < from) {178 to = from;179 }180181 if (typeof op.insert === 'object') {182 range = root.content.edit(from, to, ' ', {183 embed: JSON.stringify(op.insert),184 ...op.attributes,185 });186 } else {187 range = root.content.edit(188 from,189 to,190 op.insert,191 op.attributes as Indexable,192 );193 }194 from =195 to + (typeof op.insert === 'string' ? op.insert.length : 1);196 }197198 range &&199 presence.set({200 selection: root.content.indexRangeToPosRange(range),201 });202 }, `update style by ${client.getID()}`);203 } else if (op.delete !== undefined) {204 to = from + op.delete;205 console.log(`%c local: ${from}-${to}: ''`, 'color: green');206207 doc.update((root, presence) => {208 const range = root.content.edit(from, to, '');209 range &&210 presence.set({211 selection: root.content.indexRangeToPosRange(range),212 });213 }, `update content by ${client.getID()}`);214 } else if (op.retain !== undefined && typeof op.retain === 'number') {215 from = to + op.retain;216 to = from;217 }218 }219 })220 .on('selection-change', (range, _, source) => {221 if (!range) {222 return;223 }224225 // NOTE(chacha912): If the selection in the Quill editor does not match the range computed by yorkie,226 // additional updates are necessary. This condition addresses situations where Quill's selection behaves227 // differently, such as when inserting text before a range selection made by another user, causing228 // the second character onwards to be included in the selection.229 if (source === 'api') {230 const { selection } = doc.getMyPresence();231 if (selection) {232 const [from, to] = doc233 .getRoot()234 .content.posRangeToIndexRange(selection);235 const { index, length } = range;236 if (from === index && to === index + length) {237 return;238 }239 }240 }241242 doc.update((root, presence) => {243 presence.set({244 selection: root.content.indexRangeToPosRange([245 range.index,246 range.index + range.length,247 ]),248 });249 }, `update selection by ${client.getID()}`);250 });251252 // 04-2. document to Quill(remote).253 function handleOperations(ops: Array<OperationInfo>) {254 const deltaOperations = [];255 let prevTo = 0;256 for (const op of ops) {257 if (op.type === 'edit') {258 const from = op.from;259 const to = op.to;260 const retainFrom = from - prevTo;261 const retainTo = to - from;262263 const { insert, attributes } = toDeltaOperation(op.value!);264 console.log(`%c remote: ${from}-${to}: ${insert}`, 'color: skyblue');265266 if (retainFrom) {267 deltaOperations.push({ retain: retainFrom });268 }269 if (retainTo) {270 deltaOperations.push({ delete: retainTo });271 }272 if (insert) {273 const op: Op = { insert };274 if (attributes) {275 op.attributes = attributes;276 }277 deltaOperations.push(op);278 }279 prevTo = to;280 } else if (op.type === 'style') {281 const from = op.from;282 const to = op.to;283 const retainFrom = from - prevTo;284 const retainTo = to - from;285 const { attributes } = toDeltaOperation(op.value!);286 console.log(287 `%c remote: ${from}-${to}: ${JSON.stringify(attributes)}`,288 'color: skyblue',289 );290291 if (retainFrom) {292 deltaOperations.push({ retain: retainFrom });293 }294 if (attributes) {295 const op: Op = { attributes };296 if (retainTo) {297 op.retain = retainTo;298 }299300 deltaOperations.push(op);301 }302 prevTo = to;303 }304 }305306 if (deltaOperations.length) {307 console.log(308 `%c to quill: ${JSON.stringify(deltaOperations)}`,309 'color: green',310 );311 const delta = new Delta(deltaOperations);312 quill.updateContents(delta, 'api');313 }314 }315316 // 05. synchronize text of document and Quill.317 function syncText() {318 const text = doc.getRoot().content;319320 const delta = new Delta(321 text.values().map((value) => toDeltaOperation(value)),322 );323 quill.setContents(delta, 'api');324 }325326 syncText();327 updateAllCursors();328 displayLog(documentElem, documentTextElem, doc);329}330331main();
- User 1
- User 2
- User 1
- User 2
Event Log