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 Quill, { type DeltaOperation, type DeltaStatic } from 'quill';4import QuillCursors from 'quill-cursors';5import ColorHash from 'color-hash';6import { network } from './network';7import { displayLog, displayPeers } from './utils';8import { YorkieDoc, YorkiePresence } from './type';9import 'quill/dist/quill.snow.css';10import './style.css';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>(28 textValue: T,29): DeltaOperation {30 const { embed, ...restAttributes } = textValue.attributes ?? {};31 if (embed) {32 return { insert: embed, attributes: restAttributes };33 }3435 return {36 insert: textValue.content || '',37 attributes: textValue.attributes,38 };39}4041async function main() {42 // 01-1. create client with RPCAddr then activate it.43 const client = new yorkie.Client(import.meta.env.VITE_YORKIE_API_ADDR, {44 apiKey: import.meta.env.VITE_YORKIE_API_KEY,45 });46 await client.activate();4748 window.addEventListener('beforeunload', () => {49 client.deactivate({ keepalive: true });50 });5152 // 02-1. create a document then attach it into the client.53 const doc = new yorkie.Document<YorkieDoc, YorkiePresence>(documentKey, {54 enableDevtools: true,55 });56 doc.subscribe('connection', (event) => {57 network.statusListener(networkStatusElem)(event);58 });59 doc.subscribe('presence', (event) => {60 if (event.type !== DocEventType.PresenceChanged) {61 displayPeers(peersElem, doc.getPresences(), client.getID()!);62 }63 });6465 await client.attach(doc, {66 initialPresence: {67 username: client.getID()!.slice(-2),68 color: colorHash.hex(client.getID()!.slice(-2)),69 selection: undefined,70 },71 });7273 doc.update((root) => {74 if (!root.content) {75 root.content = new yorkie.Text();76 root.content.edit(0, 0, '\n');77 }78 }, 'create content if not exists');7980 // 02-2. subscribe document event.81 doc.subscribe((event) => {82 if (event.type === 'snapshot') {83 // The text is replaced to snapshot and must be re-synced.84 syncText();85 }86 displayLog(documentElem, documentTextElem, doc);87 });8889 doc.subscribe('$.content', (event) => {90 if (event.type === 'remote-change') {91 handleOperations(event.value.operations);92 }93 updateAllCursors();94 });95 doc.subscribe('others', (event) => {96 if (event.type === DocEventType.Unwatched) {97 cursors.removeCursor(event.value.clientID);98 } else if (event.type === DocEventType.PresenceChanged) {99 updateCursor(event.value);100 }101 });102103 function updateCursor(user: { clientID: string; presence: YorkiePresence }) {104 const { clientID, presence } = user;105 if (clientID === client.getID()) return;106 // TODO(chacha912): After resolving the presence initialization issue(#608),107 // remove the following check.108 if (!presence) return;109110 const { username, color, selection } = presence;111 if (!selection) return;112 const range = doc.getRoot().content.posRangeToIndexRange(selection);113 cursors.createCursor(clientID, username, color);114 cursors.moveCursor(clientID, {115 index: range[0],116 length: range[1] - range[0],117 });118 }119120 function updateAllCursors() {121 for (const user of doc.getPresences()) {122 updateCursor(user);123 }124 }125126 await client.sync();127128 // 03. create an instance of Quill129 Quill.register('modules/cursors', QuillCursors);130 const quill = new Quill('#editor', {131 modules: {132 toolbar: [133 ['bold', 'italic', 'underline', 'strike'],134 ['blockquote', 'code-block'],135 [{ header: 1 }, { header: 2 }],136 [{ list: 'ordered' }, { list: 'bullet' }],137 [{ script: 'sub' }, { script: 'super' }],138 [{ indent: '-1' }, { indent: '+1' }],139 [{ direction: 'rtl' }],140 [{ size: ['small', false, 'large', 'huge'] }],141 [{ header: [1, 2, 3, 4, 5, 6, false] }],142 [{ color: [] }, { background: [] }],143 [{ font: [] }],144 [{ align: [] }],145 ['image', 'video'],146 ['clean'],147 ],148 cursors: true,149 },150 theme: 'snow',151 });152 const cursors = quill.getModule('cursors');153154 // 04. bind the document with the Quill.155 // 04-1. Quill to Document.156 quill157 .on('text-change', (delta, _, source) => {158 if (source === 'api' || !delta.ops) {159 return;160 }161162 let from = 0,163 to = 0;164 console.log(`%c quill: ${JSON.stringify(delta.ops)}`, 'color: green');165 for (const op of delta.ops) {166 if (op.attributes !== undefined || op.insert !== undefined) {167 if (op.retain !== undefined) {168 to = from + op.retain;169 }170 console.log(171 `%c local: ${from}-${to}: ${op.insert} ${172 op.attributes ? JSON.stringify(op.attributes) : '{}'173 }`,174 'color: green',175 );176177 doc.update((root, presence) => {178 let range;179 if (op.attributes !== undefined && op.insert === undefined) {180 root.content.setStyle(from, to, op.attributes);181 } else if (op.insert !== undefined) {182 if (to < from) {183 to = from;184 }185186 if (typeof op.insert === 'object') {187 range = root.content.edit(from, to, ' ', {188 embed: JSON.stringify(op.insert),189 ...op.attributes,190 });191 } else {192 range = root.content.edit(from, to, op.insert, op.attributes);193 }194 from = to + op.insert.length;195 }196197 range &&198 presence.set({199 selection: root.content.indexRangeToPosRange(range),200 });201 }, `update style by ${client.getID()}`);202 } else if (op.delete !== undefined) {203 to = from + op.delete;204 console.log(`%c local: ${from}-${to}: ''`, 'color: green');205206 doc.update((root, presence) => {207 const range = root.content.edit(from, to, '');208 range &&209 presence.set({210 selection: root.content.indexRangeToPosRange(range),211 });212 }, `update content by ${client.getID()}`);213 } else if (op.retain !== undefined) {214 from = to + op.retain;215 to = from;216 }217 }218 })219 .on('selection-change', (range, _, source) => {220 if (!range) {221 return;222 }223224 // NOTE(chacha912): If the selection in the Quill editor does not match the range computed by yorkie,225 // additional updates are necessary. This condition addresses situations where Quill's selection behaves226 // differently, such as when inserting text before a range selection made by another user, causing227 // the second character onwards to be included in the selection.228 if (source === 'api') {229 const { selection } = doc.getMyPresence();230 if (selection) {231 const [from, to] = doc232 .getRoot()233 .content.posRangeToIndexRange(selection);234 const { index, length } = range;235 if (from === index && to === index + length) {236 return;237 }238 }239 }240241 doc.update((root, presence) => {242 presence.set({243 selection: root.content.indexRangeToPosRange([244 range.index,245 range.index + range.length,246 ]),247 });248 }, `update selection by ${client.getID()}`);249 });250251 // 04-2. document to Quill(remote).252 function handleOperations(ops: Array<OperationInfo>) {253 const deltaOperations = [];254 let prevTo = 0;255 for (const op of ops) {256 if (op.type === 'edit') {257 const from = op.from;258 const to = op.to;259 const retainFrom = from - prevTo;260 const retainTo = to - from;261262 const { insert, attributes } = toDeltaOperation(op.value!);263 console.log(`%c remote: ${from}-${to}: ${insert}`, 'color: skyblue');264265 if (retainFrom) {266 deltaOperations.push({ retain: retainFrom });267 }268 if (retainTo) {269 deltaOperations.push({ delete: retainTo });270 }271 if (insert) {272 const op: DeltaOperation = { insert };273 if (attributes) {274 op.attributes = attributes;275 }276 deltaOperations.push(op);277 }278 prevTo = to;279 } else if (op.type === 'style') {280 const from = op.from;281 const to = op.to;282 const retainFrom = from - prevTo;283 const retainTo = to - from;284 const { attributes } = toDeltaOperation(op.value!);285 console.log(286 `%c remote: ${from}-${to}: ${JSON.stringify(attributes)}`,287 'color: skyblue',288 );289290 if (retainFrom) {291 deltaOperations.push({ retain: retainFrom });292 }293 if (attributes) {294 const op: DeltaOperation = { attributes };295 if (retainTo) {296 op.retain = retainTo;297 }298299 deltaOperations.push(op);300 }301 prevTo = to;302 }303 }304305 if (deltaOperations.length) {306 console.log(307 `%c to quill: ${JSON.stringify(deltaOperations)}`,308 'color: green',309 );310 const delta = {311 ops: deltaOperations,312 } as DeltaStatic;313 quill.updateContents(delta, 'api');314 }315 }316317 // 05. synchronize text of document and Quill.318 function syncText() {319 const text = doc.getRoot().content;320321 const delta = {322 ops: text.values().map((val) => toDeltaOperation(val)),323 } as DeltaStatic;324 quill.setContents(delta, 'api');325 }326327 syncText();328 updateAllCursors();329 displayLog(documentElem, documentTextElem, doc);330}331332main();
- User 1
- User 2
- User 1
- User 2
Event Log