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 // 02-1. create a document then attach it into the client.49 const doc = new yorkie.Document<YorkieDoc, YorkiePresence>(documentKey, {50 enableDevtools: true,51 });52 doc.subscribe('connection', (event) => {53 network.statusListener(networkStatusElem)(event);54 });55 doc.subscribe('presence', (event) => {56 if (event.type !== DocEventType.PresenceChanged) {57 displayPeers(peersElem, doc.getPresences(), client.getID()!);58 }59 });6061 await client.attach(doc, {62 initialPresence: {63 username: client.getID()!.slice(-2),64 color: colorHash.hex(client.getID()!.slice(-2)),65 selection: undefined,66 },67 });6869 doc.update((root) => {70 if (!root.content) {71 root.content = new yorkie.Text();72 root.content.edit(0, 0, '\n');73 }74 }, 'create content if not exists');7576 // 02-2. subscribe document event.77 doc.subscribe((event) => {78 if (event.type === 'snapshot') {79 // The text is replaced to snapshot and must be re-synced.80 syncText();81 }82 displayLog(documentElem, documentTextElem, doc);83 });8485 doc.subscribe('$.content', (event) => {86 if (event.type === 'remote-change') {87 handleOperations(event.value.operations);88 }89 updateAllCursors();90 });91 doc.subscribe('others', (event) => {92 if (event.type === DocEventType.Unwatched) {93 cursors.removeCursor(event.value.clientID);94 } else if (event.type === DocEventType.PresenceChanged) {95 updateCursor(event.value);96 }97 });9899 function updateCursor(user: { clientID: string; presence: YorkiePresence }) {100 const { clientID, presence } = user;101 if (clientID === client.getID()) return;102 // TODO(chacha912): After resolving the presence initialization issue(#608),103 // remove the following check.104 if (!presence) return;105106 const { username, color, selection } = presence;107 if (!selection) return;108 const range = doc.getRoot().content.posRangeToIndexRange(selection);109 cursors.createCursor(clientID, username, color);110 cursors.moveCursor(clientID, {111 index: range[0],112 length: range[1] - range[0],113 });114 }115116 function updateAllCursors() {117 for (const user of doc.getPresences()) {118 updateCursor(user);119 }120 }121122 await client.sync();123124 // 03. create an instance of Quill125 Quill.register('modules/cursors', QuillCursors);126 const quill = new Quill('#editor', {127 modules: {128 toolbar: [129 ['bold', 'italic', 'underline', 'strike'],130 ['blockquote', 'code-block'],131 [{ header: 1 }, { header: 2 }],132 [{ list: 'ordered' }, { list: 'bullet' }],133 [{ script: 'sub' }, { script: 'super' }],134 [{ indent: '-1' }, { indent: '+1' }],135 [{ direction: 'rtl' }],136 [{ size: ['small', false, 'large', 'huge'] }],137 [{ header: [1, 2, 3, 4, 5, 6, false] }],138 [{ color: [] }, { background: [] }],139 [{ font: [] }],140 [{ align: [] }],141 ['image', 'video'],142 ['clean'],143 ],144 cursors: true,145 },146 theme: 'snow',147 });148 const cursors = quill.getModule('cursors');149150 // 04. bind the document with the Quill.151 // 04-1. Quill to Document.152 quill153 .on('text-change', (delta, _, source) => {154 if (source === 'api' || !delta.ops) {155 return;156 }157158 let from = 0,159 to = 0;160 console.log(`%c quill: ${JSON.stringify(delta.ops)}`, 'color: green');161 for (const op of delta.ops) {162 if (op.attributes !== undefined || op.insert !== undefined) {163 if (op.retain !== undefined) {164 to = from + op.retain;165 }166 console.log(167 `%c local: ${from}-${to}: ${op.insert} ${168 op.attributes ? JSON.stringify(op.attributes) : '{}'169 }`,170 'color: green',171 );172173 doc.update((root, presence) => {174 let range;175 if (op.attributes !== undefined && op.insert === undefined) {176 root.content.setStyle(from, to, op.attributes);177 } else if (op.insert !== undefined) {178 if (to < from) {179 to = from;180 }181182 if (typeof op.insert === 'object') {183 range = root.content.edit(from, to, ' ', {184 embed: JSON.stringify(op.insert),185 ...op.attributes,186 });187 } else {188 range = root.content.edit(from, to, op.insert, op.attributes);189 }190 from = to + op.insert.length;191 }192193 range &&194 presence.set({195 selection: root.content.indexRangeToPosRange(range),196 });197 }, `update style by ${client.getID()}`);198 } else if (op.delete !== undefined) {199 to = from + op.delete;200 console.log(`%c local: ${from}-${to}: ''`, 'color: green');201202 doc.update((root, presence) => {203 const range = root.content.edit(from, to, '');204 range &&205 presence.set({206 selection: root.content.indexRangeToPosRange(range),207 });208 }, `update content by ${client.getID()}`);209 } else if (op.retain !== undefined) {210 from = to + op.retain;211 to = from;212 }213 }214 })215 .on('selection-change', (range, _, source) => {216 if (!range) {217 return;218 }219220 // NOTE(chacha912): If the selection in the Quill editor does not match the range computed by yorkie,221 // additional updates are necessary. This condition addresses situations where Quill's selection behaves222 // differently, such as when inserting text before a range selection made by another user, causing223 // the second character onwards to be included in the selection.224 if (source === 'api') {225 const { selection } = doc.getMyPresence();226 if (selection) {227 const [from, to] = doc228 .getRoot()229 .content.posRangeToIndexRange(selection);230 const { index, length } = range;231 if (from === index && to === index + length) {232 return;233 }234 }235 }236237 doc.update((root, presence) => {238 presence.set({239 selection: root.content.indexRangeToPosRange([240 range.index,241 range.index + range.length,242 ]),243 });244 }, `update selection by ${client.getID()}`);245 });246247 // 04-2. document to Quill(remote).248 function handleOperations(ops: Array<OperationInfo>) {249 const deltaOperations = [];250 let prevTo = 0;251 for (const op of ops) {252 if (op.type === 'edit') {253 const from = op.from;254 const to = op.to;255 const retainFrom = from - prevTo;256 const retainTo = to - from;257258 const { insert, attributes } = toDeltaOperation(op.value!);259 console.log(`%c remote: ${from}-${to}: ${insert}`, 'color: skyblue');260261 if (retainFrom) {262 deltaOperations.push({ retain: retainFrom });263 }264 if (retainTo) {265 deltaOperations.push({ delete: retainTo });266 }267 if (insert) {268 const op: DeltaOperation = { insert };269 if (attributes) {270 op.attributes = attributes;271 }272 deltaOperations.push(op);273 }274 prevTo = to;275 } else if (op.type === 'style') {276 const from = op.from;277 const to = op.to;278 const retainFrom = from - prevTo;279 const retainTo = to - from;280 const { attributes } = toDeltaOperation(op.value!);281 console.log(282 `%c remote: ${from}-${to}: ${JSON.stringify(attributes)}`,283 'color: skyblue',284 );285286 if (retainFrom) {287 deltaOperations.push({ retain: retainFrom });288 }289 if (attributes) {290 const op: DeltaOperation = { attributes };291 if (retainTo) {292 op.retain = retainTo;293 }294295 deltaOperations.push(op);296 }297 prevTo = to;298 }299 }300301 if (deltaOperations.length) {302 console.log(303 `%c to quill: ${JSON.stringify(deltaOperations)}`,304 'color: green',305 );306 const delta = {307 ops: deltaOperations,308 } as DeltaStatic;309 quill.updateContents(delta, 'api');310 }311 }312313 // 05. synchronize text of document and Quill.314 function syncText() {315 const text = doc.getRoot().content;316317 const delta = {318 ops: text.values().map((val) => toDeltaOperation(val)),319 } as DeltaStatic;320 quill.setContents(delta, 'api');321 }322323 syncText();324 updateAllCursors();325 displayLog(documentElem, documentTextElem, doc);326}327328main();
- User 1
- User 2
- User 1
- User 2
Event Log