UNPKG

delta-component

Version:

embeddable react component

321 lines (271 loc) 10.8 kB
'use strict'; const TreedocClient = require('./../../../common/crdt/client').Client; const LocalOpsConsumer = require('./../consumers/clientOps').LocalOpsConsumer; const RemoteOpsConsumer = require('./../consumers/remoteOps').RemoteOpsConsumer; const log = require('./../../../common/logger').log; const stats = require('./statistics'); const cursors = require('./cursors'); const BaseDocument = require('./../../../common/logic/baseDocument').BaseDocument; const TextState = require('./textState').TextState; const Selection = require('./selection').Selection; function unlockAwaiter(document) { let timeoutId = setTimeout(() => { if(document.isLocked()) { document.permanentlyNotOperable('lock timeout'); } }, document.allowedToBeLockedTotally); function cancelAwaiter(ready) { clearTimeout(timeoutId); ready(true); } document.on('unlock', cancelAwaiter); return timeoutId; } class BaseClientDocument extends BaseDocument { constructor(documentString, treedoc, transport, currentPerson) { super(documentString); this.treedoc = treedoc; this.transport = transport; this.log = log.child({ context: { document: { id: documentString } }}); this.generateTreedocClient(treedoc, currentPerson); this._textState = new TextState(treedoc.asString()); this.somewhenLoadCurrentParticipants(currentPerson); this._localSelection = null; this._permanentlyNotOperable = false; this.usageStats = new stats.Statistics(this.log); this.allowedToBeLockedTotally = 10000; // msec } getText() { /** * Дорогой вызов! Не вызывать часто! */ this._textState.clear(this.treedocClient.treedoc.asString()); return this._textState.getClearText(); } _delete() { const selectionWidth = this.getSelection().getWidth(); this.getSelection().toNullWidth(); this._textState.makeDirty(); if(selectionWidth > 0) { // курсор всегда справа for(let i = 0; i < selectionWidth; i++) this.treedocClient.backspace(); } else { this.treedocClient.delete(); } } /** методы, порождающие события в интерфейсе **/ textChanged(newText) { /** * Окно ввода должно перерендерить текст, т.к. он изменился. * * Прилетели изменения по сети. */ this._textState.clear(newText); this.emit('textChanged', newText); } participantsChanged() { /** * Окно ввода должно перерисовать чужие курсоры и список редактирующих. */ this.emit('participantsChanged', this.viewersMap.toList()); } cursorsChanged(cursorsMap) { /** * Все у кого поменялся/появился курсор - обновить курсор * Все у кого курсор пропал - убрать курсор. */ if(!cursorsMap) cursorsMap = {}; this.viewersMap.cursorsChanged(cursorsMap); this.participantsChanged(); } moveSelection(fromIndex, toIndex) { this.throwWhenLocked(); this.getSelection().set(fromIndex, toIndex); this.treedocClient.moveCursor(toIndex); } localCursorChanged(position) { /** * Окно ввода должно перерисовать локальный курсор. */ this.getSelection().move(position); this.emit('localSelectionChanged', this.getSelection()); } prepareForNewEpoch() { /** * Окну ввода запретить любой ввод. * * Подписчики должны вызвать функцию, * чтобы сигнализировать что они готовы к переходу * (т.е. что интерфейс ввода заблокирован). * Иначе сервер разорвет соединение. UI должен попросить перезагрузить страницу. * * Если UI не успевает это сделать за 100мсек сервер разрывает сетевое соединение. * Потому что считатет что UI слишком медленный. * UI должен попросить перезагрузить страницу. */ let log = this.log.child({ context: { currentMessage: 'prepareForNewEpoch' }}); let value = 0; let sequence = function() { return function () { value += 1; } }(); this.lock(sequence); let total = this.listeners('locked').length; if (value === total) { return true; } else { log.error(`not ready ${total - value} of ${total}, still locked`); this.permanentlyNotOperable('did not '); return false; } } startNewEpoch() { /** * Окну ввода разблокировать интерфейс. * * Подписчики должны вызвать функцию, * чтобы сигнализировать что они готовы к переходу * (т.е. что интерфейс ввода заблокирован). Если это не будет сделано * сервер разорвет соединение. UI должен попросить перезагрузить страницу. * * * Если UI не успевает это сделать за 100мсек сервер разрывает сетевое соединение. * Потому что считатет что UI слишком медленный. UI должен попросить перезагрузить страницу. */ let log = this.log.child({context: { currentMessage: 'startNewEpoch' }}); let value = 0; let sequence = function() { return function () { value += 1; } }(); this.unlock(sequence); let total = this.listeners('unlocked').length; if (value === total) { return true; } else { // сервер должен отключить, поэтому эта строка избыточна, // но я решил ее оставить this._setLock(); log.error(`not ready ${total - value} of ${total}, still locked`, this.listeners('unlocked')); return false; } } joined(viewer) { this.viewersMap.joined(viewer); this.participantsChanged(); } left(siteId) { const participant = this.viewersMap.left(siteId); this.log.info(`${participant} left`); this.treedocClient.siteClosedDoc(siteId); this.participantsChanged(); } /** непубличные методы **/ applyRemoteOps(ops){ this.treedocClient.applyRemoteOperations(ops); } getCachedText() { return this._textState.getClearText(); } somewhenLoadCurrentParticipants(currentUser){ let log = this.log; let viewers = new cursors.Participants(currentUser); this.viewersMap = viewers; let that = this; this.transport.getParticipants(this.documentString).then( participants => { viewers.replaceEveryone(participants); log.info(`Loaded ${viewers}`); that.participantsChanged(); }, error => log.error(`could not load participants for ${that.documentString}: ${error.error_type}`) ) } sendOpsToBackend(ops) { this.transport.sendOperations(this.documentString, ops); } _getLocalOpsConsumer() { return new LocalOpsConsumer(this); } _getRemoteOpsConsumer() { return new RemoteOpsConsumer(this); } generateTreedocClient(treedoc, currentPerson) { /** * Заменить текущий клиент на новый * @type {Client} */ if(this.treedocClient) { // TODO: убедиться у Олега что это корректно если текст успел поменяться на сервере this.treedocClient.applyNewTreedoc(treedoc); this.treedocClient.siteId = currentPerson.siteId; } else { this.treedocClient = new TreedocClient( treedoc, currentPerson.siteId, 0, this._getLocalOpsConsumer(), this._getRemoteOpsConsumer() ); } } emit(...args) { if(this._permanentlyNotOperable) { return; } super.emit(...args); } permanentlyNotOperable(reason) { /** * После вызова никаких событий от документа не будет поступать. * * @param reason: * */ if(this._permanentlyNotOperable) { this.log.info(`${this} is already not operable`); } if(!this.isLocked()) this.lock(); this.log.warn(`${this} is not operable anymore: "${reason}"`); this.emit('permanentlyNotOperable', reason); this._permanentlyNotOperable = true; } awaitUnlock() { return unlockAwaiter(this); } lock(callback=null) { super.lock(callback); this.awaitUnlock(); } _removeLock() { if(this._permanentlyNotOperable) return; super._removeLock(); } toString() { return `Document "${this.documentString}"`; } getSelection() { /** * * это не совсем корректное место * Если UI курсор никогда не выставлял, то этот код * принудительно решает за UI, что курсор надо поставить в 0 * И никак ему об этом не сообщает. */ if(this._localSelection === null) { this._localSelection = new Selection(0, 0); } return this._localSelection; } } module.exports = { BaseClientDocument, };