Rich Text Editor
This demo shows the real-time collaborative version of the Quill editor with Yorkie and Vite.
main.ts
1import yorkie, { DocEventType, Indexable, OpInfo } 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();1920// Get document key from query string or use date-based key as fallback21const params = new URLSearchParams(window.location.search);22const documentKey =23 params.get('key') ||24 `vanilla-quill-${new Date()25 .toISOString()26 .substring(0, 10)27 .replace(/-/g, '')}`;2829// Filter out null values from attributes30function filterNullAttrs(attributes?: Indexable): Indexable | undefined {31 if (!attributes) return;3233 const filtered: Indexable = {};34 let hasNonNullValue = false;3536 for (const [key, value] of Object.entries(attributes)) {37 if (value !== null) {38 filtered[key] = value;39 hasNonNullValue = true;40 }41 }4243 return hasNonNullValue ? filtered : undefined;44}4546function toDeltaOperation<T extends TextValueType>(47 textValue: T,48 filterNull: boolean = false,49): Op {50 const { embed, ...restAttributes } = textValue.attributes ?? {};51 if (embed) {52 return {53 insert: JSON.parse(embed.toString()),54 attributes: filterNull ? filterNullAttrs(restAttributes) : restAttributes,55 };56 }5758 return {59 insert: textValue.content || '',60 attributes: filterNull61 ? filterNullAttrs(textValue.attributes)62 : textValue.attributes,63 };64}6566async function main() {67 // 01-1. create client with RPCAddr then activate it.68 const client = new yorkie.Client({69 rpcAddr: import.meta.env.VITE_YORKIE_API_ADDR,70 apiKey: import.meta.env.VITE_YORKIE_API_KEY,71 });72 await client.activate();7374 // 02-1. create a document then attach it into the client.75 const doc = new yorkie.Document<YorkieDoc, YorkiePresence>(documentKey, {76 enableDevtools: true,77 });78 doc.subscribe('connection', (event) => {79 network.statusListener(networkStatusElem)(event);80 });81 doc.subscribe('presence', (event) => {82 if (event.type !== DocEventType.PresenceChanged) {83 displayPeers(peersElem, doc.getPresences(), client.getID()!);84 }85 });8687 await client.attach(doc, {88 initialPresence: {89 username: client.getID()!.slice(-2),90 color: colorHash.hex(client.getID()!.slice(-2)),91 selection: undefined,92 },93 });9495 console.log(96 `%c Document Key: ${documentKey}`,97 'color: orange; font-weight: bold;',98 );99100 doc.update((root) => {101 if (!root.content) {102 root.content = new yorkie.Text();103 root.content.edit(0, 0, '\n');104 }105 }, 'create content if not exists');106107 // 02-2. subscribe document event.108 doc.subscribe((event) => {109 if (event.type === 'snapshot') {110 // The text is replaced to snapshot and must be re-synced.111 syncText();112 }113 });114115 doc.subscribe('$.content', (event) => {116 if (event.type === 'remote-change') {117 handleOperations(event.value.operations);118 }119 updateAllCursors();120 });121 doc.subscribe('others', (event) => {122 if (event.type === DocEventType.Unwatched) {123 cursors.removeCursor(event.value.clientID);124 } else if (event.type === DocEventType.PresenceChanged) {125 updateCursor(event.value);126 }127 });128129 function updateCursor(user: { clientID: string; presence: YorkiePresence }) {130 const { clientID, presence } = user;131 if (clientID === client.getID()) return;132 // TODO(chacha912): After resolving the presence initialization issue(#608),133 // remove the following check.134 if (!presence) return;135136 const { username, color, selection } = presence;137 if (!selection) return;138 const range = doc.getRoot().content.posRangeToIndexRange(selection);139 cursors.createCursor(clientID, username, color);140 cursors.moveCursor(clientID, {141 index: range[0],142 length: range[1] - range[0],143 });144 }145146 function updateAllCursors() {147 for (const user of doc.getPresences()) {148 updateCursor(user);149 }150 }151152 await client.sync();153154 // 03. create an instance of Quill155 // Track composition state to prevent selection updates during IME input156 let isComposing = false;157158 Quill.register('modules/cursors', QuillCursors);159 const quill = new Quill('#editor', {160 modules: {161 // Simplified toolbar: keep only core formatting features.162 // Add or remove items easily by editing this array.163 toolbar: {164 container: [165 ['bold', 'italic', 'underline'],166 [{ header: 1 }, { header: 2 }],167 [{ list: 'ordered' }, { list: 'bullet' }],168 ['blockquote', 'code-block'],169 ['image', 'video'],170 ['clean'],171 ],172 handlers: {173 image: imageHandler,174 },175 },176 cursors: true,177 },178 theme: 'snow',179 });180 const cursors = quill.getModule('cursors') as QuillCursors;181182 // Custom image handler to check file size (max 1MB)183 function imageHandler() {184 const input = document.createElement('input');185 input.setAttribute('type', 'file');186 input.setAttribute('accept', 'image/*');187 input.click();188189 input.onchange = () => {190 const file = input.files?.[0];191 if (!file) return;192193 const maxSize = 1 * 1024 * 1024; // 1MB in bytes194 if (file.size > maxSize) {195 alert(196 `Image size is too large. (Max: 1MB)\nCurrent file size: ${(197 file.size /198 1024 /199 1024200 ).toFixed(2)}MB`,201 );202 return;203 }204205 const reader = new FileReader();206 reader.onload = (e) => {207 const range = quill.getSelection(true);208 const imageData = e.target?.result;209210 // Insert image in Quill editor first211 quill.insertEmbed(range.index, 'image', imageData, 'user');212 quill.setSelection(range.index + 1, 0, 'silent');213 };214 reader.readAsDataURL(file);215 };216 }217218 // 04. bind the document with the Quill.219 // Track composition events to prevent selection updates during IME input220 const editorElement = quill.root;221 editorElement.addEventListener('compositionstart', () => {222 isComposing = true;223 });224 editorElement.addEventListener('compositionend', () => {225 isComposing = false;226 });227228 // 04-1. Quill to Document.229 quill230 .on('text-change', (delta, _, source) => {231 if (source === 'api' || !delta.ops) {232 return;233 }234235 let from = 0,236 to = 0;237 console.log(`%c quill: ${JSON.stringify(delta.ops)}`, 'color: green');238 doc.update((root, presence) => {239 for (const op of delta.ops) {240 if (op.attributes !== undefined || op.insert !== undefined) {241 if (op.retain !== undefined && typeof op.retain === 'number') {242 to = from + op.retain;243 }244 console.log(245 `%c local: ${from}-${to}: ${op.insert} ${246 op.attributes ? JSON.stringify(op.attributes) : '{}'247 }`,248 'color: green',249 );250251 let range;252 if (op.attributes !== undefined && op.insert === undefined) {253 root.content.setStyle(from, to, op.attributes as Indexable);254 from = to;255 } else if (op.insert !== undefined) {256 if (to < from) {257 to = from;258 }259260 if (typeof op.insert === 'object') {261 range = root.content.edit(from, to, ' ', {262 embed: JSON.stringify(op.insert),263 ...op.attributes,264 });265 } else {266 range = root.content.edit(267 from,268 to,269 op.insert,270 op.attributes as Indexable,271 );272 }273 from =274 to + (typeof op.insert === 'string' ? op.insert.length : 1);275 to = from;276 }277278 if (range) {279 presence.set({280 selection: root.content.indexRangeToPosRange(range),281 });282 }283 } else if (op.delete !== undefined) {284 to = from + op.delete;285 console.log(`%c local: ${from}-${to}: ''`, 'color: green');286287 const range = root.content.edit(from, to, '');288 if (range) {289 presence.set({290 selection: root.content.indexRangeToPosRange(range),291 });292 }293 // After delete, 'to' should stay at 'from' since content was removed294 to = from;295 } else if (op.retain !== undefined && typeof op.retain === 'number') {296 from += op.retain;297 to = from;298 }299 }300 });301 })302 .on('selection-change', (range, _, source) => {303 if (!range) {304 return;305 }306307 // Ignore selection changes during composition (e.g., Korean IME input)308 // to prevent cursor position from being broadcast incorrectly to other users309 if (isComposing) {310 return;311 }312313 // NOTE(chacha912): If the selection in the Quill editor does not match the range computed by yorkie,314 // additional updates are necessary. This condition addresses situations where Quill's selection behaves315 // differently, such as when inserting text before a range selection made by another user, causing316 // the second character onwards to be included in the selection.317 if (source === 'api') {318 const { selection } = doc.getMyPresence();319 if (selection) {320 const [from, to] = doc321 .getRoot()322 .content.posRangeToIndexRange(selection);323 const { index, length } = range;324 if (from === index && to === index + length) {325 return;326 }327 }328 }329330 doc.update((root, presence) => {331 presence.set({332 selection: root.content.indexRangeToPosRange([333 range.index,334 range.index + range.length,335 ]),336 });337 }, `update selection by ${client.getID()}`);338 });339340 // Handle selection changes when mouse is released outside the editor341 document.addEventListener('mouseup', () => {342 // Ignore selection changes during composition343 if (isComposing) {344 return;345 }346347 const range = quill.getSelection();348 if (range) {349 doc.update((root, presence) => {350 presence.set({351 selection: root.content.indexRangeToPosRange([352 range.index,353 range.index + range.length,354 ]),355 });356 }, `update selection by ${client.getID()}`);357 }358 });359360 // 04-2. document to Quill(remote).361 function handleOperations(ops: Array<OpInfo>) {362 for (const op of ops) {363 if (op.type === 'edit') {364 const from = op.from;365 const to = op.to;366 const { insert, attributes } = toDeltaOperation(op.value!, true);367 console.log(`%c remote: ${from}-${to}: ${insert}`, 'color: skyblue');368369 const deltaOperations: Op[] = [];370371 if (from > 0) {372 deltaOperations.push({ retain: from });373 }374375 const deleteLength = to - from;376 if (deleteLength > 0) {377 deltaOperations.push({ delete: deleteLength });378 }379380 if (insert) {381 const op: Op = { insert };382 if (attributes) {383 op.attributes = attributes;384 }385 deltaOperations.push(op);386 }387388 if (deltaOperations.length > 0) {389 console.log(390 `%c to quill: ${JSON.stringify(deltaOperations)}`,391 'color: green',392 );393 const delta = new Delta(deltaOperations);394 quill.updateContents(delta, 'api');395 }396 } else if (op.type === 'style') {397 const from = op.from;398 const to = op.to;399 const { attributes } = toDeltaOperation(op.value!, false);400 console.log(401 `%c remote: ${from}-${to}: ${JSON.stringify(attributes)}`,402 'color: skyblue',403 );404405 if (attributes) {406 const deltaOperations: Op[] = [];407408 if (from > 0) {409 deltaOperations.push({ retain: from });410 }411412 const op: Op = { attributes };413 const retainLength = to - from;414 if (retainLength > 0) {415 op.retain = retainLength;416 }417 deltaOperations.push(op);418419 console.log(420 `%c to quill: ${JSON.stringify(deltaOperations)}`,421 'color: green',422 );423 const delta = new Delta(deltaOperations);424 quill.updateContents(delta, 'api');425 }426 }427 }428 }429430 // 05. synchronize text of document and Quill.431 function syncText() {432 const text = doc.getRoot().content;433 const delta = new Delta(434 text.values().map((value) => toDeltaOperation(value, true)),435 );436 quill.setContents(delta, 'api');437 }438439 syncText();440 updateAllCursors();441}442443main();
- User 1
- User 2
- User 1
- User 2