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, { Text, Indexable, OperationInfo } from 'yorkie-js-sdk';3import Quill, { type DeltaOperation, type DeltaStatic } from 'quill';4import QuillCursors from 'quill-cursors';5import ShortUniqueId from 'short-unique-id';6import ColorHash from 'color-hash';7import { network } from './network';8import { displayLog, displayPeers } from './utils';9import 'quill/dist/quill.snow.css';10import './style.css';1112type YorkieDoc = {13 content: Text;14};1516type TextValueType = {17 attributes?: Indexable;18 content?: string;19};2021const peersElem = document.getElementById('peers')!;22const documentElem = document.getElementById('document')!;23const documentTextElem = document.getElementById('document-text')!;24const networkStatusElem = document.getElementById('network-status')!;25const shortUniqueID = new ShortUniqueId();26const colorHash = new ColorHash();27const documentKey = `vanilla-quill-${new Date()28 .toISOString()29 .substring(0, 10)30 .replace(/-/g, '')}`;3132function toDeltaOperation<T extends TextValueType>(33 textValue: T,34): DeltaOperation {35 const { embed, ...restAttributes } = textValue.attributes ?? {};36 if (embed) {37 return { insert: JSON.parse(embed), attributes: restAttributes };38 }3940 return {41 insert: textValue.content || '',42 attributes: textValue.attributes,43 };44}4546async function main() {47 // 01-1. create client with RPCAddr(envoy) then activate it.48 const client = new yorkie.Client(import.meta.env.VITE_YORKIE_API_ADDR, {49 apiKey: import.meta.env.VITE_YORKIE_API_KEY,50 presence: {51 username: `username-${shortUniqueID()}`,52 },53 });54 await client.activate();5556 // 01-2. subscribe client event.57 client.subscribe((event) => {58 network.statusListener(networkStatusElem)(event);59 if (event.type !== 'peers-changed') return;6061 const { type, peers } = event.value;62 if (type === 'unwatched') {63 peers[doc.getKey()].map((peer) => {64 cursors.removeCursor(peer.presence.username);65 });66 }67 displayPeers(68 peersElem,69 client.getPeersByDocKey(doc.getKey()),70 client.getID()!,71 );72 });7374 // 02-1. create a document then attach it into the client.75 const doc = new yorkie.Document<YorkieDoc>(documentKey);76 await client.attach(doc);7778 doc.update((root) => {79 if (!root.content) {80 root.content = new yorkie.Text();81 root.content.edit(0, 0, '\n');82 }83 }, 'create content if not exists');8485 // 02-2. subscribe document event.86 doc.subscribe((event) => {87 if (event.type === 'snapshot') {88 // The text is replaced to snapshot and must be re-synced.89 syncText();90 }91 displayLog(documentElem, documentTextElem, doc);92 });9394 doc.subscribe('$.content', (event) => {95 if (event.type === 'remote-change') {96 const { actor, operations } = event.value;97 handleOperations(operations, actor);98 }99 });100101 await client.sync();102103 // 03. create an instance of Quill104 Quill.register('modules/cursors', QuillCursors);105 const quill = new Quill('#editor', {106 modules: {107 toolbar: [108 ['bold', 'italic', 'underline', 'strike'],109 ['blockquote', 'code-block'],110 [{ header: 1 }, { header: 2 }],111 [{ list: 'ordered' }, { list: 'bullet' }],112 [{ script: 'sub' }, { script: 'super' }],113 [{ indent: '-1' }, { indent: '+1' }],114 [{ direction: 'rtl' }],115 [{ size: ['small', false, 'large', 'huge'] }],116 [{ header: [1, 2, 3, 4, 5, 6, false] }],117 [{ color: [] }, { background: [] }],118 [{ font: [] }],119 [{ align: [] }],120 ['image', 'video'],121 ['clean'],122 ],123 cursors: true,124 },125 theme: 'snow',126 });127 const cursors = quill.getModule('cursors');128129 // 04. bind the document with the Quill.130 // 04-1. Quill to Document.131 quill132 .on('text-change', (delta, _, source) => {133 if (source === 'api' || !delta.ops) {134 return;135 }136137 let from = 0,138 to = 0;139 console.log(`%c quill: ${JSON.stringify(delta.ops)}`, 'color: green');140 for (const op of delta.ops) {141 if (op.attributes !== undefined || op.insert !== undefined) {142 if (op.retain !== undefined) {143 to = from + op.retain;144 }145 console.log(146 `%c local: ${from}-${to}: ${op.insert} ${147 op.attributes ? JSON.stringify(op.attributes) : '{}'148 }`,149 'color: green',150 );151152 doc.update((root) => {153 if (op.attributes !== undefined && op.insert === undefined) {154 root.content.setStyle(from, to, op.attributes);155 } else if (op.insert !== undefined) {156 if (to < from) {157 to = from;158 }159160 if (typeof op.insert === 'object') {161 root.content.edit(from, to, ' ', {162 embed: JSON.stringify(op.insert),163 ...op.attributes,164 });165 } else {166 root.content.edit(from, to, op.insert, op.attributes);167 }168 from = to + op.insert.length;169 }170 }, `update style by ${client.getID()}`);171 } else if (op.delete !== undefined) {172 to = from + op.delete;173 console.log(`%c local: ${from}-${to}: ''`, 'color: green');174175 doc.update((root) => {176 root.content.edit(from, to, '');177 }, `update content by ${client.getID()}`);178 } else if (op.retain !== undefined) {179 from = to + op.retain;180 to = from;181 }182 }183 })184 .on('selection-change', (range, _, source) => {185 if (source === 'api' || !range) {186 return;187 }188189 doc.update((root) => {190 root.content.select(range.index, range.index + range.length);191 }, `update selection by ${client.getID()}`);192 });193194 // 04-2. document to Quill(remote).195 function handleOperations(196 ops: Array<OperationInfo>,197 actor: string | undefined,198 ) {199 const deltaOperations = [];200 let prevTo = 0;201 for (const op of ops) {202 const actorName = client.getPeerPresence(203 doc.getKey(),204 `${actor}`,205 )?.username;206207 if (op.type === 'edit') {208 const from = op.from;209 const to = op.to;210 const retainFrom = from - prevTo;211 const retainTo = to - from;212213 const { insert, attributes } = toDeltaOperation(op.value!);214 console.log(`%c remote: ${from}-${to}: ${insert}`, 'color: skyblue');215216 if (retainFrom) {217 deltaOperations.push({ retain: retainFrom });218 }219 if (retainTo) {220 deltaOperations.push({ delete: retainTo });221 }222 if (insert) {223 const op: DeltaOperation = { insert };224 if (attributes) {225 op.attributes = attributes;226 }227 deltaOperations.push(op);228 }229 prevTo = to;230 } else if (op.type === 'style') {231 const from = op.from;232 const to = op.to;233 const retainFrom = from - prevTo;234 const retainTo = to - from;235 const { attributes } = toDeltaOperation(op.value!);236 console.log(237 `%c remote: ${from}-${to}: ${JSON.stringify(attributes)}`,238 'color: skyblue',239 );240241 if (retainFrom) {242 deltaOperations.push({ retain: retainFrom });243 }244 if (attributes) {245 const op: DeltaOperation = { attributes };246 if (retainTo) {247 op.retain = retainTo;248 }249250 deltaOperations.push(op);251 }252 prevTo = to;253 } else if (actorName && op.type === 'select') {254 const from = op.from;255 const to = op.to;256 const retainTo = to - from;257 cursors.createCursor(actorName, actorName, colorHash.hex(actorName));258 cursors.moveCursor(actorName, {259 index: from,260 length: retainTo,261 });262263 prevTo = to;264 }265 }266267 if (deltaOperations.length) {268 console.log(269 `%c to quill: ${JSON.stringify(deltaOperations)}`,270 'color: green',271 );272 const delta = {273 ops: deltaOperations,274 } as DeltaStatic;275 quill.updateContents(delta, 'api');276 }277 }278279 // 05. synchronize text of document and Quill.280 function syncText() {281 const text = doc.getRoot().content;282283 const delta = {284 ops: text.values().map((val) => toDeltaOperation(val)),285 } as DeltaStatic;286 quill.setContents(delta, 'api');287 }288289 syncText();290 displayLog(documentElem, documentTextElem, doc);291}292293main();
- User 1
- User 2
- User 1
- User 2
Event Log