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({44 rpcAddr: import.meta.env.VITE_YORKIE_API_ADDR,45 apiKey: import.meta.env.VITE_YORKIE_API_KEY,46 });47 await client.activate();4849 // 02-1. create a document then attach it into the client.50 const doc = new yorkie.Document<YorkieDoc, YorkiePresence>(documentKey, {51 enableDevtools: true,52 });53 doc.subscribe('connection', (event) => {54 network.statusListener(networkStatusElem)(event);55 });56 doc.subscribe('presence', (event) => {57 if (event.type !== DocEventType.PresenceChanged) {58 displayPeers(peersElem, doc.getPresences(), client.getID()!);59 }60 });6162 await client.attach(doc, {63 initialPresence: {64 username: client.getID()!.slice(-2),65 color: colorHash.hex(client.getID()!.slice(-2)),66 selection: undefined,67 },68 });6970 doc.update((root) => {71 if (!root.content) {72 root.content = new yorkie.Text();73 root.content.edit(0, 0, '\n');74 }75 }, 'create content if not exists');7677 // 02-2. subscribe document event.78 doc.subscribe((event) => {79 if (event.type === 'snapshot') {80 // The text is replaced to snapshot and must be re-synced.81 syncText();82 }83 displayLog(documentElem, documentTextElem, doc);84 });8586 doc.subscribe('$.content', (event) => {87 if (event.type === 'remote-change') {88 handleOperations(event.value.operations);89 }90 updateAllCursors();91 });92 doc.subscribe('others', (event) => {93 if (event.type === DocEventType.Unwatched) {94 cursors.removeCursor(event.value.clientID);95 } else if (event.type === DocEventType.PresenceChanged) {96 updateCursor(event.value);97 }98 });99100 function updateCursor(user: { clientID: string; presence: YorkiePresence }) {101 const { clientID, presence } = user;102 if (clientID === client.getID()) return;103 // TODO(chacha912): After resolving the presence initialization issue(#608),104 // remove the following check.105 if (!presence) return;106107 const { username, color, selection } = presence;108 if (!selection) return;109 const range = doc.getRoot().content.posRangeToIndexRange(selection);110 cursors.createCursor(clientID, username, color);111 cursors.moveCursor(clientID, {112 index: range[0],113 length: range[1] - range[0],114 });115 }116117 function updateAllCursors() {118 for (const user of doc.getPresences()) {119 updateCursor(user);120 }121 }122123 await client.sync();124125 // 03. create an instance of Quill126 Quill.register('modules/cursors', QuillCursors);127 const quill = new Quill('#editor', {128 modules: {129 toolbar: [130 ['bold', 'italic', 'underline', 'strike'],131 ['blockquote', 'code-block'],132 [{ header: 1 }, { header: 2 }],133 [{ list: 'ordered' }, { list: 'bullet' }],134 [{ script: 'sub' }, { script: 'super' }],135 [{ indent: '-1' }, { indent: '+1' }],136 [{ direction: 'rtl' }],137 [{ size: ['small', false, 'large', 'huge'] }],138 [{ header: [1, 2, 3, 4, 5, 6, false] }],139 [{ color: [] }, { background: [] }],140 [{ font: [] }],141 [{ align: [] }],142 ['image', 'video'],143 ['clean'],144 ],145 cursors: true,146 },147 theme: 'snow',148 });149 const cursors = quill.getModule('cursors');150151 // 04. bind the document with the Quill.152 // 04-1. Quill to Document.153 quill154 .on('text-change', (delta, _, source) => {155 if (source === 'api' || !delta.ops) {156 return;157 }158159 let from = 0,160 to = 0;161 console.log(`%c quill: ${JSON.stringify(delta.ops)}`, 'color: green');162 for (const op of delta.ops) {163 if (op.attributes !== undefined || op.insert !== undefined) {164 if (op.retain !== undefined) {165 to = from + op.retain;166 }167 console.log(168 `%c local: ${from}-${to}: ${op.insert} ${169 op.attributes ? JSON.stringify(op.attributes) : '{}'170 }`,171 'color: green',172 );173174 doc.update((root, presence) => {175 let range;176 if (op.attributes !== undefined && op.insert === undefined) {177 root.content.setStyle(from, to, op.attributes);178 } else if (op.insert !== undefined) {179 if (to < from) {180 to = from;181 }182183 if (typeof op.insert === 'object') {184 range = root.content.edit(from, to, ' ', {185 embed: JSON.stringify(op.insert),186 ...op.attributes,187 });188 } else {189 range = root.content.edit(from, to, op.insert, op.attributes);190 }191 from = to + op.insert.length;192 }193194 range &&195 presence.set({196 selection: root.content.indexRangeToPosRange(range),197 });198 }, `update style by ${client.getID()}`);199 } else if (op.delete !== undefined) {200 to = from + op.delete;201 console.log(`%c local: ${from}-${to}: ''`, 'color: green');202203 doc.update((root, presence) => {204 const range = root.content.edit(from, to, '');205 range &&206 presence.set({207 selection: root.content.indexRangeToPosRange(range),208 });209 }, `update content by ${client.getID()}`);210 } else if (op.retain !== undefined) {211 from = to + op.retain;212 to = from;213 }214 }215 })216 .on('selection-change', (range, _, source) => {217 if (!range) {218 return;219 }220221 // NOTE(chacha912): If the selection in the Quill editor does not match the range computed by yorkie,222 // additional updates are necessary. This condition addresses situations where Quill's selection behaves223 // differently, such as when inserting text before a range selection made by another user, causing224 // the second character onwards to be included in the selection.225 if (source === 'api') {226 const { selection } = doc.getMyPresence();227 if (selection) {228 const [from, to] = doc229 .getRoot()230 .content.posRangeToIndexRange(selection);231 const { index, length } = range;232 if (from === index && to === index + length) {233 return;234 }235 }236 }237238 doc.update((root, presence) => {239 presence.set({240 selection: root.content.indexRangeToPosRange([241 range.index,242 range.index + range.length,243 ]),244 });245 }, `update selection by ${client.getID()}`);246 });247248 // 04-2. document to Quill(remote).249 function handleOperations(ops: Array<OperationInfo>) {250 const deltaOperations = [];251 let prevTo = 0;252 for (const op of ops) {253 if (op.type === 'edit') {254 const from = op.from;255 const to = op.to;256 const retainFrom = from - prevTo;257 const retainTo = to - from;258259 const { insert, attributes } = toDeltaOperation(op.value!);260 console.log(`%c remote: ${from}-${to}: ${insert}`, 'color: skyblue');261262 if (retainFrom) {263 deltaOperations.push({ retain: retainFrom });264 }265 if (retainTo) {266 deltaOperations.push({ delete: retainTo });267 }268 if (insert) {269 const op: DeltaOperation = { insert };270 if (attributes) {271 op.attributes = attributes;272 }273 deltaOperations.push(op);274 }275 prevTo = to;276 } else if (op.type === 'style') {277 const from = op.from;278 const to = op.to;279 const retainFrom = from - prevTo;280 const retainTo = to - from;281 const { attributes } = toDeltaOperation(op.value!);282 console.log(283 `%c remote: ${from}-${to}: ${JSON.stringify(attributes)}`,284 'color: skyblue',285 );286287 if (retainFrom) {288 deltaOperations.push({ retain: retainFrom });289 }290 if (attributes) {291 const op: DeltaOperation = { attributes };292 if (retainTo) {293 op.retain = retainTo;294 }295296 deltaOperations.push(op);297 }298 prevTo = to;299 }300 }301302 if (deltaOperations.length) {303 console.log(304 `%c to quill: ${JSON.stringify(deltaOperations)}`,305 'color: green',306 );307 const delta = {308 ops: deltaOperations,309 } as DeltaStatic;310 quill.updateContents(delta, 'api');311 }312 }313314 // 05. synchronize text of document and Quill.315 function syncText() {316 const text = doc.getRoot().content;317318 const delta = {319 ops: text.values().map((val) => toDeltaOperation(val)),320 } as DeltaStatic;321 quill.setContents(delta, 'api');322 }323324 syncText();325 updateAllCursors();326 displayLog(documentElem, documentTextElem, doc);327}328329main();
- User 1
- User 2
- User 1
- User 2
Event Log