UNPKG

@fnlb-project/stanza

Version:

Modern XMPP in the browser, with a JSON API

345 lines (344 loc) 11.1 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.InputBuffer = exports.DisplayBuffer = void 0; exports.diff = diff; const async_1 = require("async"); const Utils_1 = require("../Utils"); /** * Calculate the erase and insert actions needed to describe the user's edit operation. * * Based on the code point buffers before and after the edit, we find the single erase * and insert actions needed to describe the full change. We are minimizing the number * of deltas, not minimizing the number of affected code points. * * @param oldText The original buffer of Unicode code points before the user's edit action. * @param newText The new buffer of Unicode code points after the user's edit action. */ function diff(oldText, newText) { const oldLen = oldText.length; const newLen = newText.length; const searchLen = Math.min(oldLen, newLen); let prefixSize = 0; for (prefixSize = 0; prefixSize < searchLen; prefixSize++) { if (oldText[prefixSize] !== newText[prefixSize]) { break; } } let suffixSize = 0; for (suffixSize = 0; suffixSize < searchLen - prefixSize; suffixSize++) { if (oldText[oldLen - suffixSize - 1] !== newText[newLen - suffixSize - 1]) { break; } } const matchedSize = prefixSize + suffixSize; const events = []; if (matchedSize < oldLen) { events.push({ length: oldLen - matchedSize, position: oldLen - suffixSize, type: 'erase' }); } if (matchedSize < newLen) { const insertedText = newText.slice(prefixSize, prefixSize + newLen - matchedSize); events.push({ position: prefixSize, text: (0, Utils_1.ucs2Encode)(insertedText), type: 'insert' }); } return events; } /** * Class for processing RTT events and providing a renderable string of the resulting text. */ class DisplayBuffer { constructor(onStateChange, ignoreWaits = false) { this.synced = false; this.cursorPosition = 0; this.ignoreWaits = false; this.timeDeficit = 0; this.sequenceNumber = 0; this.onStateChange = onStateChange || function noop() { return; }; this.ignoreWaits = ignoreWaits; this.buffer = []; this.resetActionQueue(); } /** * The encoded Unicode string to display. */ get text() { return (0, Utils_1.ucs2Encode)(this.buffer.slice()); } /** * Mark the RTT message as completed and reset state. */ commit() { this.resetActionQueue(); } /** * Accept an RTT event for processing. * * A single event can contain multiple edit actions, including * wait pauses. * * Events must be processed in order of their `seq` value in order * to stay in sync. * * @param event {RTTEvent} The RTT event to process. */ process(event) { if (event.event === 'cancel' || event.event === 'init') { this.resetActionQueue(); return; } else if (event.event === 'reset' || event.event === 'new') { this.resetActionQueue(); if (event.seq !== undefined) { this.sequenceNumber = event.seq; } } else if (event.seq !== this.sequenceNumber) { this.synced = false; } if (event.actions) { const baseTime = Date.now(); let accumulatedWait = 0; for (const action of event.actions) { action.baseTime = baseTime + accumulatedWait; if (action.type === 'wait') { accumulatedWait += action.duration; } this.actionQueue.push(action, 0); } } this.sequenceNumber = this.sequenceNumber + 1; } /** * Insert text into the Unicode code point buffer * * By default, the insertion position is the end of the buffer. * * @param text The raw text to insert * @param position The position to start the insertion */ insert(text = '', position = this.buffer.length) { text = text.normalize('NFC'); const insertedText = (0, Utils_1.ucs2Decode)(text); this.buffer.splice(position, 0, ...insertedText); this.cursorPosition = position + insertedText.length; this.emitState(); } /** * Erase text from the Unicode code point buffer * * By default, the erased text length is `1`, and the position is the end of the buffer. * * @param length The number of code points to erase from the buffer, starting at {position} and erasing to the left. * @param position The position to start erasing code points. Erasing continues to the left. */ erase(length = 1, position = this.buffer.length) { position = Math.max(Math.min(position, this.buffer.length), 0); length = Math.max(Math.min(length, this.text.length), 0); this.buffer.splice(Math.max(position - length, 0), length); this.cursorPosition = Math.max(position - length, 0); this.emitState(); } emitState(additional = {}) { this.onStateChange({ cursorPosition: this.cursorPosition, synced: this.synced, text: this.text, ...additional }); } /** * Reset the processing state and queue. * * Used when 'init', 'new', 'reset', and 'cancel' RTT events are processed. */ resetActionQueue() { if (this.actionQueue) { this.actionQueue.kill(); } this.sequenceNumber = 0; this.synced = true; this.buffer = []; this.timeDeficit = 0; this.actionQueue = (0, async_1.priorityQueue)((action, done) => { const currentTime = Date.now(); if (action.type === 'insert') { this.insert(action.text, action.position); return done(); } else if (action.type === 'erase') { this.erase(action.length, action.position); return done(); } else if (action.type === 'wait') { if (this.ignoreWaits) { return done(); } if (action.duration > 700) { action.duration = 700; } const waitTime = action.duration - (currentTime - action.baseTime) + this.timeDeficit; if (waitTime <= 0) { this.timeDeficit = waitTime; return done(); } else { this.timeDeficit = 0; setTimeout(() => done(), waitTime); } } else { return done(); } }, 1); this.emitState(); } } exports.DisplayBuffer = DisplayBuffer; /** * Class for tracking changes in a source text, and generating RTT events based on those changes. */ class InputBuffer { constructor(onStateChange, ignoreWaits = false) { this.resetInterval = 10000; this.ignoreWaits = false; this.isStarting = false; this.isReset = false; this.changedBetweenResets = false; this.onStateChange = onStateChange || function noop() { return; }; this.ignoreWaits = ignoreWaits; this.buffer = []; this.actionQueue = []; this.sequenceNumber = 0; } get text() { return (0, Utils_1.ucs2Encode)(this.buffer.slice()); } /** * Generate action deltas based on the new full state of the source text. * * The text provided here is the _entire_ source text, not a diff. * * @param text The new state of the user's text. */ update(text) { let actions = []; if (text !== undefined) { text = text.normalize('NFC'); const newBuffer = (0, Utils_1.ucs2Decode)(text); actions = diff(this.buffer, newBuffer.slice()); this.buffer = newBuffer; this.emitState(); } const now = Date.now(); if (this.changedBetweenResets && now - this.lastResetTime > this.resetInterval) { this.actionQueue = []; this.actionQueue.push({ position: 0, text: this.text, type: 'insert' }); this.isReset = true; this.lastActionTime = now; this.lastResetTime = now; this.changedBetweenResets = false; } else if (actions.length) { const wait = now - (this.lastActionTime || now); if (wait > 0 && !this.ignoreWaits) { this.actionQueue.push({ duration: wait, type: 'wait' }); } for (const action of actions) { this.actionQueue.push(action); } this.lastActionTime = now; this.changedBetweenResets = true; } else { this.lastActionTime = now; } } /** * Formally start an RTT session. * * Generates a random starting event sequence number. * * @param resetInterval {Milliseconds} Time to wait between using RTT reset events during editing. */ start(resetInterval = this.resetInterval) { this.commit(); this.isStarting = true; this.resetInterval = resetInterval; this.sequenceNumber = Math.floor(Math.random() * 10000 + 1); return { event: 'init' }; } /** * Formally stops the RTT session. */ stop() { this.commit(); return { event: 'cancel' }; } /** * Generate an RTT event based on queued edit actions. * * The edit actions included in the event are all those made since the last * time a diff was requested. */ diff() { this.update(); if (!this.actionQueue.length) { return null; } const event = { actions: this.actionQueue, seq: this.sequenceNumber++ }; if (this.isStarting) { event.event = 'new'; this.isStarting = false; this.lastResetTime = Date.now(); } else if (this.isReset) { event.event = 'reset'; this.isReset = false; } this.actionQueue = []; return event; } /** * Reset the RTT session state to prepare for a new message text. */ commit() { this.sequenceNumber = 0; this.lastActionTime = 0; this.actionQueue = []; this.buffer = []; } emitState() { this.onStateChange({ text: this.text }); } } exports.InputBuffer = InputBuffer;