Quill
This demo shows the real-time collaborative version of the Quill editor with Yorkie and Vite.
main.ts
1import yorkie, { DocEventType, Indexable, OperationInfo } from '@yorkie-js/sdk';2import ColorHash from 'color-hash';3import Quill, { Delta, type Op } from 'quill';4import QuillCursors from 'quill-cursors';5import 'quill/dist/quill.snow.css';6import { network } from './network';7import './style.css';8import { YorkieDoc, YorkiePresence } from './type';9import { displayPeers } from './utils';1011type TextValueType = {12 attributes?: Indexable;13 content?: string;14};1516const peersElem = document.getElementById('peers')!;17const networkStatusElem = document.getElementById('network-status')!;18const colorHash = new ColorHash();19const documentKey = `vanilla-quill-${new Date()20 .toISOString()21 .substring(0, 10)22 .replace(/-/g, '')}`;2324function toDeltaOperation<T extends TextValueType>(textValue: T): Op {25 const { embed, ...restAttributes } = textValue.attributes ?? {};26 if (embed) {27 return { insert: JSON.parse(embed.toString()), attributes: restAttributes };28 }2930 return {31 insert: textValue.content || '',32 attributes: textValue.attributes,33 };34}3536async function main() {37 // 01-1. create client with RPCAddr then activate it.38 const client = new yorkie.Client({39 rpcAddr: import.meta.env.VITE_YORKIE_API_ADDR,40 apiKey: import.meta.env.VITE_YORKIE_API_KEY,41 });42 await client.activate();4344 // 02-1. create a document then attach it into the client.45 const doc = new yorkie.Document<YorkieDoc, YorkiePresence>(documentKey, {46 enableDevtools: true,47 });48 doc.subscribe('connection', (event) => {49 network.statusListener(networkStatusElem)(event);50 });51 doc.subscribe('presence', (event) => {52 if (event.type !== DocEventType.PresenceChanged) {53 displayPeers(peersElem, doc.getPresences(), client.getID()!);54 }55 });5657 await client.attach(doc, {58 initialPresence: {59 username: client.getID()!.slice(-2),60 color: colorHash.hex(client.getID()!.slice(-2)),61 selection: undefined,62 },63 });6465 doc.update((root) => {66 if (!root.content) {67 root.content = new yorkie.Text();68 root.content.edit(0, 0, '\n');69 }70 }, 'create content if not exists');7172 // 02-2. subscribe document event.73 doc.subscribe((event) => {74 if (event.type === 'snapshot') {75 // The text is replaced to snapshot and must be re-synced.76 syncText();77 }78 });7980 doc.subscribe('$.content', (event) => {81 if (event.type === 'remote-change') {82 handleOperations(event.value.operations);83 }84 updateAllCursors();85 });86 doc.subscribe('others', (event) => {87 if (event.type === DocEventType.Unwatched) {88 cursors.removeCursor(event.value.clientID);89 } else if (event.type === DocEventType.PresenceChanged) {90 updateCursor(event.value);91 }92 });9394 function updateCursor(user: { clientID: string; presence: YorkiePresence }) {95 const { clientID, presence } = user;96 if (clientID === client.getID()) return;97 // TODO(chacha912): After resolving the presence initialization issue(#608),98 // remove the following check.99 if (!presence) return;100101 const { username, color, selection } = presence;102 if (!selection) return;103 const range = doc.getRoot().content.posRangeToIndexRange(selection);104 cursors.createCursor(clientID, username, color);105 cursors.moveCursor(clientID, {106 index: range[0],107 length: range[1] - range[0],108 });109 }110111 function updateAllCursors() {112 for (const user of doc.getPresences()) {113 updateCursor(user);114 }115 }116117 await client.sync();118119 // 03. create an instance of Quill120 Quill.register('modules/cursors', QuillCursors);121 const quill = new Quill('#editor', {122 modules: {123 // Simplified toolbar: keep only core formatting features.124 // Add or remove items easily by editing this array.125 toolbar: [126 ['bold', 'italic', 'underline'],127 [{ header: 1 }, { header: 2 }],128 [{ list: 'ordered' }, { list: 'bullet' }],129 ['blockquote', 'code-block'],130 ['image', 'video'],131 ['clean'],132 ],133 cursors: true,134 },135 theme: 'snow',136 });137 const cursors = quill.getModule('cursors') as QuillCursors;138139 // 04. bind the document with the Quill.140 // 04-1. Quill to Document.141 quill142 .on('text-change', (delta, _, source) => {143 if (source === 'api' || !delta.ops) {144 return;145 }146147 let from = 0,148 to = 0;149 console.log(`%c quill: ${JSON.stringify(delta.ops)}`, 'color: green');150 for (const op of delta.ops) {151 if (op.attributes !== undefined || op.insert !== undefined) {152 if (op.retain !== undefined && typeof op.retain === 'number') {153 to = from + op.retain;154 }155 console.log(156 `%c local: ${from}-${to}: ${op.insert} ${157 op.attributes ? JSON.stringify(op.attributes) : '{}'158 }`,159 'color: green',160 );161162 doc.update((root, presence) => {163 let range;164 if (op.attributes !== undefined && op.insert === undefined) {165 root.content.setStyle(from, to, op.attributes as Indexable);166 } else if (op.insert !== undefined) {167 if (to < from) {168 to = from;169 }170171 if (typeof op.insert === 'object') {172 range = root.content.edit(from, to, ' ', {173 embed: JSON.stringify(op.insert),174 ...op.attributes,175 });176 } else {177 range = root.content.edit(178 from,179 to,180 op.insert,181 op.attributes as Indexable,182 );183 }184 from =185 to + (typeof op.insert === 'string' ? op.insert.length : 1);186 }187188 if (range) {189 presence.set({190 selection: root.content.indexRangeToPosRange(range),191 });192 }193 }, `update style by ${client.getID()}`);194 } else if (op.delete !== undefined) {195 to = from + op.delete;196 console.log(`%c local: ${from}-${to}: ''`, 'color: green');197198 doc.update((root, presence) => {199 const range = root.content.edit(from, to, '');200 if (range) {201 presence.set({202 selection: root.content.indexRangeToPosRange(range),203 });204 }205 }, `update content by ${client.getID()}`);206 } else if (op.retain !== undefined && typeof op.retain === 'number') {207 from = to + op.retain;208 to = from;209 }210 }211 })212 .on('selection-change', (range, _, source) => {213 if (!range) {214 return;215 }216217 // NOTE(chacha912): If the selection in the Quill editor does not match the range computed by yorkie,218 // additional updates are necessary. This condition addresses situations where Quill's selection behaves219 // differently, such as when inserting text before a range selection made by another user, causing220 // the second character onwards to be included in the selection.221 if (source === 'api') {222 const { selection } = doc.getMyPresence();223 if (selection) {224 const [from, to] = doc225 .getRoot()226 .content.posRangeToIndexRange(selection);227 const { index, length } = range;228 if (from === index && to === index + length) {229 return;230 }231 }232 }233234 doc.update((root, presence) => {235 presence.set({236 selection: root.content.indexRangeToPosRange([237 range.index,238 range.index + range.length,239 ]),240 });241 }, `update selection by ${client.getID()}`);242 });243244 // 04-2. document to Quill(remote).245 function handleOperations(ops: Array<OperationInfo>) {246 const deltaOperations = [];247 let prevTo = 0;248 for (const op of ops) {249 if (op.type === 'edit') {250 const from = op.from;251 const to = op.to;252 const retainFrom = from - prevTo;253 const retainTo = to - from;254255 const { insert, attributes } = toDeltaOperation(op.value!);256 console.log(`%c remote: ${from}-${to}: ${insert}`, 'color: skyblue');257258 if (retainFrom) {259 deltaOperations.push({ retain: retainFrom });260 }261 if (retainTo) {262 deltaOperations.push({ delete: retainTo });263 }264 if (insert) {265 const op: Op = { insert };266 if (attributes) {267 op.attributes = attributes;268 }269 deltaOperations.push(op);270 }271 prevTo = to;272 } else if (op.type === 'style') {273 const from = op.from;274 const to = op.to;275 const retainFrom = from - prevTo;276 const retainTo = to - from;277 const { attributes } = toDeltaOperation(op.value!);278 console.log(279 `%c remote: ${from}-${to}: ${JSON.stringify(attributes)}`,280 'color: skyblue',281 );282283 if (retainFrom) {284 deltaOperations.push({ retain: retainFrom });285 }286 if (attributes) {287 const op: Op = { attributes };288 if (retainTo) {289 op.retain = retainTo;290 }291292 deltaOperations.push(op);293 }294 prevTo = to;295 }296 }297298 if (deltaOperations.length) {299 console.log(300 `%c to quill: ${JSON.stringify(deltaOperations)}`,301 'color: green',302 );303 const delta = new Delta(deltaOperations);304 quill.updateContents(delta, 'api');305 }306 }307308 // 05. synchronize text of document and Quill.309 function syncText() {310 const text = doc.getRoot().content;311312 const delta = new Delta(313 text.values().map((value) => toDeltaOperation(value)),314 );315 quill.setContents(delta, 'api');316 }317318 syncText();319 updateAllCursors();320}321322main();
- User 1
- User 2
- User 1
- User 2