delta-component
Version:
embeddable react component
321 lines (271 loc) • 10.8 kB
JavaScript
'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,
};