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 // Presences are keyed by the client's actor, not by the per-session id84 // that `client.getID()` returns.85 displayPeers(peersElem, doc.getPresences(), client.getActorID()!);86 }87 });8889 await client.attach(doc, {90 initialPresence: {91 username: client.getActorID()!.slice(-2),92 color: colorHash.hex(client.getActorID()!.slice(-2)),93 selection: undefined,94 },95 });9697 console.log(98 `%c Document Key: ${documentKey}`,99 'color: orange; font-weight: bold;',100 );101102 doc.update((root) => {103 if (!root.content) {104 root.content = new yorkie.Text();105 root.content.edit(0, 0, '\n');106 }107 }, 'create content if not exists');108109 // 02-2. subscribe document event.110 doc.subscribe((event) => {111 if (event.type === 'snapshot') {112 // The text is replaced to snapshot and must be re-synced.113 syncText();114 }115 });116117 doc.subscribe('$.content', (event) => {118 if (event.type === 'remote-change') {119 handleOperations(event.value.operations);120 } else if (event.source === 'undoredo') {121 handleOperations(event.value.operations, true);122 }123 updateAllCursors();124 });125 doc.subscribe('others', (event) => {126 if (event.type === DocEventType.Unwatched) {127 cursors.removeCursor(event.value.clientID);128 } else if (event.type === DocEventType.PresenceChanged) {129 updateCursor(event.value);130 }131 });132133 function updateCursor(user: { clientID: string; presence: YorkiePresence }) {134 const { clientID, presence } = user;135 if (clientID === client.getActorID()) return;136 // TODO(chacha912): After resolving the presence initialization issue(#608),137 // remove the following check.138 if (!presence) return;139140 const { username, color, selection } = presence;141 if (!selection) return;142 const range = doc.getRoot().content.posRangeToIndexRange(selection);143 cursors.createCursor(clientID, username, color);144 cursors.moveCursor(clientID, {145 index: range[0],146 length: range[1] - range[0],147 });148 }149150 function updateAllCursors() {151 for (const user of doc.getPresences()) {152 updateCursor(user);153 }154 }155156 await client.sync();157158 // 03. create an instance of Quill159 // Track composition state to prevent selection updates during IME input160 let isComposing = false;161162 Quill.register('modules/cursors', QuillCursors);163 const quill = new Quill('#editor', {164 modules: {165 // Simplified toolbar: keep only core formatting features.166 // Add or remove items easily by editing this array.167 toolbar: {168 container: [169 ['bold', 'italic', 'underline'],170 [{ header: 1 }, { header: 2 }],171 [{ list: 'ordered' }, { list: 'bullet' }],172 ['blockquote', 'code-block'],173 ['image', 'video'],174 ['clean'],175 ],176 handlers: {177 image: imageHandler,178 },179 },180 cursors: true,181 history: {182 maxStack: 0, // Disable Quill's built-in undo/redo in favor of Yorkie's183 },184 },185 theme: 'snow',186 });187 const cursors = quill.getModule('cursors') as QuillCursors;188189 // Custom image handler to check file size (max 1MB)190 function imageHandler() {191 const input = document.createElement('input');192 input.setAttribute('type', 'file');193 input.setAttribute('accept', 'image/*');194 input.click();195196 input.onchange = () => {197 const file = input.files?.[0];198 if (!file) return;199200 const maxSize = 1 * 1024 * 1024; // 1MB in bytes201 if (file.size > maxSize) {202 alert(203 `Image size is too large. (Max: 1MB)\nCurrent file size: ${(204 file.size /205 1024 /206 1024207 ).toFixed(2)}MB`,208 );209 return;210 }211212 const reader = new FileReader();213 reader.onload = (e) => {214 const range = quill.getSelection(true);215 const imageData = e.target?.result;216217 // Insert image in Quill editor first218 quill.insertEmbed(range.index, 'image', imageData, 'user');219 quill.setSelection(range.index + 1, 0, 'silent');220 };221 reader.readAsDataURL(file);222 };223 }224225 // 04. bind the document with the Quill.226 // Track composition events to prevent selection updates during IME input227 const editorElement = quill.root;228 editorElement.addEventListener('compositionstart', () => {229 isComposing = true;230 });231 editorElement.addEventListener('compositionend', () => {232 isComposing = false;233 });234235 // 04-0. bind Yorkie undo/redo to keyboard shortcuts.236 editorElement.addEventListener('keydown', (e) => {237 if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'z') {238 if (e.shiftKey) {239 if (doc.history.canRedo()) {240 doc.history.redo();241 }242 } else {243 if (doc.history.canUndo()) {244 doc.history.undo();245 }246 }247 e.preventDefault();248 }249 });250251 // 04-1. Quill to Document.252 quill253 .on('text-change', (delta, _, source) => {254 if (source === 'api' || !delta.ops) {255 return;256 }257258 let from = 0,259 to = 0;260 console.log(`%c quill: ${JSON.stringify(delta.ops)}`, 'color: green');261 try {262 doc.update((root, presence) => {263 for (const op of delta.ops) {264 if (op.attributes !== undefined || op.insert !== undefined) {265 if (op.retain !== undefined && typeof op.retain === 'number') {266 to = from + op.retain;267 }268 console.log(269 `%c local: ${from}-${to}: ${op.insert} ${270 op.attributes ? JSON.stringify(op.attributes) : '{}'271 }`,272 'color: green',273 );274275 let range;276 if (op.attributes !== undefined && op.insert === undefined) {277 root.content.setStyle(from, to, op.attributes as Indexable);278 from = to;279 } else if (op.insert !== undefined) {280 if (to < from) {281 to = from;282 }283284 if (typeof op.insert === 'object') {285 range = root.content.edit(from, to, ' ', {286 embed: JSON.stringify(op.insert),287 ...op.attributes,288 });289 } else {290 range = root.content.edit(291 from,292 to,293 op.insert,294 op.attributes as Indexable,295 );296 }297 from =298 to + (typeof op.insert === 'string' ? op.insert.length : 1);299 to = from;300 }301302 if (range) {303 presence.set({304 selection: root.content.indexRangeToPosRange(range),305 });306 }307 } else if (op.delete !== undefined) {308 to = from + op.delete;309 console.log(`%c local: ${from}-${to}: ''`, 'color: green');310311 const range = root.content.edit(from, to, '');312 if (range) {313 presence.set({314 selection: root.content.indexRangeToPosRange(range),315 });316 }317 // After delete, 'to' should stay at 'from' since content was removed318 to = from;319 } else if (op.retain !== undefined && typeof op.retain === 'number') {320 from += op.retain;321 to = from;322 }323 }324 });325 } catch (err) {326 // `Text.edit` refuses an index that splits a UTF-16 surrogate pair,327 // and content carrying a lone half of one, so a local Quill change is328 // not guaranteed to reach the document. `doc.update` discarded the329 // whole delta, so rebuild the editor from the document instead of330 // leaving the two silently diverged.331 console.error('local edit rejected, re-syncing editor:', err);332 syncText();333 updateAllCursors();334 }335 })336 .on('selection-change', (range, _, source) => {337 if (!range) {338 return;339 }340341 // Ignore selection changes during composition (e.g., Korean IME input)342 // to prevent cursor position from being broadcast incorrectly to other users343 if (isComposing) {344 return;345 }346347 // NOTE(chacha912): If the selection in the Quill editor does not match the range computed by yorkie,348 // additional updates are necessary. This condition addresses situations where Quill's selection behaves349 // differently, such as when inserting text before a range selection made by another user, causing350 // the second character onwards to be included in the selection.351 if (source === 'api') {352 const { selection } = doc.getMyPresence();353 if (selection) {354 const [from, to] = doc355 .getRoot()356 .content.posRangeToIndexRange(selection);357 const { index, length } = range;358 if (from === index && to === index + length) {359 return;360 }361 }362 }363364 doc.update((root, presence) => {365 presence.set({366 selection: root.content.indexRangeToPosRange([367 range.index,368 range.index + range.length,369 ]),370 });371 }, `update selection by ${client.getID()}`);372 });373374 // Handle selection changes when mouse is released outside the editor375 document.addEventListener('mouseup', () => {376 // Ignore selection changes during composition377 if (isComposing) {378 return;379 }380381 const range = quill.getSelection();382 if (range) {383 doc.update((root, presence) => {384 presence.set({385 selection: root.content.indexRangeToPosRange([386 range.index,387 range.index + range.length,388 ]),389 });390 }, `update selection by ${client.getID()}`);391 }392 });393394 // 04-2. document to Quill(remote and undo/redo).395 function handleOperations(396 ops: Array<OpInfo>,397 moveCursor: boolean = false,398 ) {399 const hasFocus = quill.hasFocus();400401 // On Safari, DOM mutations in a contenteditable element can steal focus402 // from other iframes. Temporarily disable contenteditable for remote403 // updates when this editor doesn't have focus.404 if (!hasFocus) {405 quill.root.setAttribute('contenteditable', 'false');406 }407408 let cursorPosition = -1;409 for (const op of ops) {410 if (op.type === 'edit') {411 const from = op.from;412 const to = op.to;413 const { insert, attributes } = toDeltaOperation(op.value!, true);414 console.log(`%c remote: ${from}-${to}: ${insert}`, 'color: skyblue');415416 const deltaOperations: Op[] = [];417418 if (from > 0) {419 deltaOperations.push({ retain: from });420 }421422 const deleteLength = to - from;423 if (deleteLength > 0) {424 deltaOperations.push({ delete: deleteLength });425 }426427 if (insert) {428 const op: Op = { insert };429 if (attributes) {430 op.attributes = attributes;431 }432 deltaOperations.push(op);433 }434435 if (deltaOperations.length > 0) {436 console.log(437 `%c to quill: ${JSON.stringify(deltaOperations)}`,438 'color: green',439 );440 const delta = new Delta(deltaOperations);441 quill.updateContents(delta, 'api');442 }443444 if (moveCursor) {445 const insertLength =446 typeof insert === 'string' ? insert.length : insert ? 1 : 0;447 cursorPosition = from + insertLength;448 }449 } else if (op.type === 'style') {450 const from = op.from;451 const to = op.to;452 const { attributes } = toDeltaOperation(op.value!, false);453 console.log(454 `%c remote: ${from}-${to}: ${JSON.stringify(attributes)}`,455 'color: skyblue',456 );457458 if (attributes) {459 const deltaOperations: Op[] = [];460461 if (from > 0) {462 deltaOperations.push({ retain: from });463 }464465 const op: Op = { attributes };466 const retainLength = to - from;467 if (retainLength > 0) {468 op.retain = retainLength;469 }470 deltaOperations.push(op);471472 console.log(473 `%c to quill: ${JSON.stringify(deltaOperations)}`,474 'color: green',475 );476 const delta = new Delta(deltaOperations);477 quill.updateContents(delta, 'api');478 }479480 if (moveCursor) {481 cursorPosition = to;482 }483 }484 }485486 // Move cursor to the changed position for undo/redo487 if (moveCursor && cursorPosition >= 0) {488 quill.setSelection(cursorPosition, 0, 'api');489 }490491 if (!hasFocus) {492 quill.root.setAttribute('contenteditable', 'true');493 }494 }495496 // 05. synchronize text of document and Quill.497 function syncText() {498 const hasFocus = quill.hasFocus();499 if (!hasFocus) {500 quill.root.setAttribute('contenteditable', 'false');501 }502503 const text = doc.getRoot().content;504 const delta = new Delta(505 text.values().map((value) => toDeltaOperation(value, true)),506 );507 quill.setContents(delta, 'api');508509 if (!hasFocus) {510 quill.root.setAttribute('contenteditable', 'true');511 }512 }513514 syncText();515 updateAllCursors();516}517518main();
- User 1
- User 2
- User 1
- User 2