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 } else if (event.source === 'undoredo') {119 handleOperations(event.value.operations, true);120 }121 updateAllCursors();122 });123 doc.subscribe('others', (event) => {124 if (event.type === DocEventType.Unwatched) {125 cursors.removeCursor(event.value.clientID);126 } else if (event.type === DocEventType.PresenceChanged) {127 updateCursor(event.value);128 }129 });130131 function updateCursor(user: { clientID: string; presence: YorkiePresence }) {132 const { clientID, presence } = user;133 if (clientID === client.getID()) return;134 // TODO(chacha912): After resolving the presence initialization issue(#608),135 // remove the following check.136 if (!presence) return;137138 const { username, color, selection } = presence;139 if (!selection) return;140 const range = doc.getRoot().content.posRangeToIndexRange(selection);141 cursors.createCursor(clientID, username, color);142 cursors.moveCursor(clientID, {143 index: range[0],144 length: range[1] - range[0],145 });146 }147148 function updateAllCursors() {149 for (const user of doc.getPresences()) {150 updateCursor(user);151 }152 }153154 await client.sync();155156 // 03. create an instance of Quill157 // Track composition state to prevent selection updates during IME input158 let isComposing = false;159160 Quill.register('modules/cursors', QuillCursors);161 const quill = new Quill('#editor', {162 modules: {163 // Simplified toolbar: keep only core formatting features.164 // Add or remove items easily by editing this array.165 toolbar: {166 container: [167 ['bold', 'italic', 'underline'],168 [{ header: 1 }, { header: 2 }],169 [{ list: 'ordered' }, { list: 'bullet' }],170 ['blockquote', 'code-block'],171 ['image', 'video'],172 ['clean'],173 ],174 handlers: {175 image: imageHandler,176 },177 },178 cursors: true,179 history: {180 maxStack: 0, // Disable Quill's built-in undo/redo in favor of Yorkie's181 },182 },183 theme: 'snow',184 });185 const cursors = quill.getModule('cursors') as QuillCursors;186187 // Custom image handler to check file size (max 1MB)188 function imageHandler() {189 const input = document.createElement('input');190 input.setAttribute('type', 'file');191 input.setAttribute('accept', 'image/*');192 input.click();193194 input.onchange = () => {195 const file = input.files?.[0];196 if (!file) return;197198 const maxSize = 1 * 1024 * 1024; // 1MB in bytes199 if (file.size > maxSize) {200 alert(201 `Image size is too large. (Max: 1MB)\nCurrent file size: ${(202 file.size /203 1024 /204 1024205 ).toFixed(2)}MB`,206 );207 return;208 }209210 const reader = new FileReader();211 reader.onload = (e) => {212 const range = quill.getSelection(true);213 const imageData = e.target?.result;214215 // Insert image in Quill editor first216 quill.insertEmbed(range.index, 'image', imageData, 'user');217 quill.setSelection(range.index + 1, 0, 'silent');218 };219 reader.readAsDataURL(file);220 };221 }222223 // 04. bind the document with the Quill.224 // Track composition events to prevent selection updates during IME input225 const editorElement = quill.root;226 editorElement.addEventListener('compositionstart', () => {227 isComposing = true;228 });229 editorElement.addEventListener('compositionend', () => {230 isComposing = false;231 });232233 // 04-0. bind Yorkie undo/redo to keyboard shortcuts.234 editorElement.addEventListener('keydown', (e) => {235 if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'z') {236 if (e.shiftKey) {237 if (doc.history.canRedo()) {238 doc.history.redo();239 }240 } else {241 if (doc.history.canUndo()) {242 doc.history.undo();243 }244 }245 e.preventDefault();246 }247 });248249 // 04-1. Quill to Document.250 quill251 .on('text-change', (delta, _, source) => {252 if (source === 'api' || !delta.ops) {253 return;254 }255256 let from = 0,257 to = 0;258 console.log(`%c quill: ${JSON.stringify(delta.ops)}`, 'color: green');259 doc.update((root, presence) => {260 for (const op of delta.ops) {261 if (op.attributes !== undefined || op.insert !== undefined) {262 if (op.retain !== undefined && typeof op.retain === 'number') {263 to = from + op.retain;264 }265 console.log(266 `%c local: ${from}-${to}: ${op.insert} ${267 op.attributes ? JSON.stringify(op.attributes) : '{}'268 }`,269 'color: green',270 );271272 let range;273 if (op.attributes !== undefined && op.insert === undefined) {274 root.content.setStyle(from, to, op.attributes as Indexable);275 from = to;276 } else if (op.insert !== undefined) {277 if (to < from) {278 to = from;279 }280281 if (typeof op.insert === 'object') {282 range = root.content.edit(from, to, ' ', {283 embed: JSON.stringify(op.insert),284 ...op.attributes,285 });286 } else {287 range = root.content.edit(288 from,289 to,290 op.insert,291 op.attributes as Indexable,292 );293 }294 from =295 to + (typeof op.insert === 'string' ? op.insert.length : 1);296 to = from;297 }298299 if (range) {300 presence.set({301 selection: root.content.indexRangeToPosRange(range),302 });303 }304 } else if (op.delete !== undefined) {305 to = from + op.delete;306 console.log(`%c local: ${from}-${to}: ''`, 'color: green');307308 const range = root.content.edit(from, to, '');309 if (range) {310 presence.set({311 selection: root.content.indexRangeToPosRange(range),312 });313 }314 // After delete, 'to' should stay at 'from' since content was removed315 to = from;316 } else if (op.retain !== undefined && typeof op.retain === 'number') {317 from += op.retain;318 to = from;319 }320 }321 });322 })323 .on('selection-change', (range, _, source) => {324 if (!range) {325 return;326 }327328 // Ignore selection changes during composition (e.g., Korean IME input)329 // to prevent cursor position from being broadcast incorrectly to other users330 if (isComposing) {331 return;332 }333334 // NOTE(chacha912): If the selection in the Quill editor does not match the range computed by yorkie,335 // additional updates are necessary. This condition addresses situations where Quill's selection behaves336 // differently, such as when inserting text before a range selection made by another user, causing337 // the second character onwards to be included in the selection.338 if (source === 'api') {339 const { selection } = doc.getMyPresence();340 if (selection) {341 const [from, to] = doc342 .getRoot()343 .content.posRangeToIndexRange(selection);344 const { index, length } = range;345 if (from === index && to === index + length) {346 return;347 }348 }349 }350351 doc.update((root, presence) => {352 presence.set({353 selection: root.content.indexRangeToPosRange([354 range.index,355 range.index + range.length,356 ]),357 });358 }, `update selection by ${client.getID()}`);359 });360361 // Handle selection changes when mouse is released outside the editor362 document.addEventListener('mouseup', () => {363 // Ignore selection changes during composition364 if (isComposing) {365 return;366 }367368 const range = quill.getSelection();369 if (range) {370 doc.update((root, presence) => {371 presence.set({372 selection: root.content.indexRangeToPosRange([373 range.index,374 range.index + range.length,375 ]),376 });377 }, `update selection by ${client.getID()}`);378 }379 });380381 // 04-2. document to Quill(remote and undo/redo).382 function handleOperations(383 ops: Array<OpInfo>,384 moveCursor: boolean = false,385 ) {386 const hasFocus = quill.hasFocus();387388 // On Safari, DOM mutations in a contenteditable element can steal focus389 // from other iframes. Temporarily disable contenteditable for remote390 // updates when this editor doesn't have focus.391 if (!hasFocus) {392 quill.root.setAttribute('contenteditable', 'false');393 }394395 let cursorPosition = -1;396 for (const op of ops) {397 if (op.type === 'edit') {398 const from = op.from;399 const to = op.to;400 const { insert, attributes } = toDeltaOperation(op.value!, true);401 console.log(`%c remote: ${from}-${to}: ${insert}`, 'color: skyblue');402403 const deltaOperations: Op[] = [];404405 if (from > 0) {406 deltaOperations.push({ retain: from });407 }408409 const deleteLength = to - from;410 if (deleteLength > 0) {411 deltaOperations.push({ delete: deleteLength });412 }413414 if (insert) {415 const op: Op = { insert };416 if (attributes) {417 op.attributes = attributes;418 }419 deltaOperations.push(op);420 }421422 if (deltaOperations.length > 0) {423 console.log(424 `%c to quill: ${JSON.stringify(deltaOperations)}`,425 'color: green',426 );427 const delta = new Delta(deltaOperations);428 quill.updateContents(delta, 'api');429 }430431 if (moveCursor) {432 const insertLength =433 typeof insert === 'string' ? insert.length : insert ? 1 : 0;434 cursorPosition = from + insertLength;435 }436 } else if (op.type === 'style') {437 const from = op.from;438 const to = op.to;439 const { attributes } = toDeltaOperation(op.value!, false);440 console.log(441 `%c remote: ${from}-${to}: ${JSON.stringify(attributes)}`,442 'color: skyblue',443 );444445 if (attributes) {446 const deltaOperations: Op[] = [];447448 if (from > 0) {449 deltaOperations.push({ retain: from });450 }451452 const op: Op = { attributes };453 const retainLength = to - from;454 if (retainLength > 0) {455 op.retain = retainLength;456 }457 deltaOperations.push(op);458459 console.log(460 `%c to quill: ${JSON.stringify(deltaOperations)}`,461 'color: green',462 );463 const delta = new Delta(deltaOperations);464 quill.updateContents(delta, 'api');465 }466467 if (moveCursor) {468 cursorPosition = to;469 }470 }471 }472473 // Move cursor to the changed position for undo/redo474 if (moveCursor && cursorPosition >= 0) {475 quill.setSelection(cursorPosition, 0, 'api');476 }477478 if (!hasFocus) {479 quill.root.setAttribute('contenteditable', 'true');480 }481 }482483 // 05. synchronize text of document and Quill.484 function syncText() {485 const hasFocus = quill.hasFocus();486 if (!hasFocus) {487 quill.root.setAttribute('contenteditable', 'false');488 }489490 const text = doc.getRoot().content;491 const delta = new Delta(492 text.values().map((value) => toDeltaOperation(value, true)),493 );494 quill.setContents(delta, 'api');495496 if (!hasFocus) {497 quill.root.setAttribute('contenteditable', 'true');498 }499 }500501 syncText();502 updateAllCursors();503}504505main();
- User 1
- User 2
- User 1
- User 2