Shellia/lib/renderer.js
2022-08-21 08:20:08 +07:00

268 lines
8.8 KiB
JavaScript

/**
* This class keeps a logical mapping of lines to messages.
* It doesn't actually render or send messages, it delegates
* that task to the renderer.
*
* FIXME: do something to prevent extremely long messages to be
* sent (and rejected) when too many lines are inserted in between
* a message.
**/
var escapeHtml = require("escape-html");
var utils = require("./utils");
function Renderer(reply, state, options) {
if (!options) options = {};
this.reply = reply;
this.state = state;
this.options = options;
this.offset = 0;
this.messages = [];
this.orphanLines = [];
this.unfinishedLine = null;
this.totalLines = 0;
state.on("lineChanged", this._lineChanged.bind(this));
state.on("linesRemoving", this._linesRemoving.bind(this));
state.on("linesScrolling", this._linesScrolling.bind(this));
state.on("linesInserted", this._linesInserted.bind(this));
this.initTimers();
}
/** MESSAGE MAPPING **/
Renderer.prototype.ensureLinesCreated = function ensureLinesCreated(y) {
if (this.totalLines < y) {
this.orphanLines = this.orphanLines.concat(this.state.lines.slice(this.totalLines, y));
this.totalLines = y;
this.newLinesChanged = true;
}
};
Renderer.prototype._lineChanged = function _lineChanged(y) {
if (this.state.length - y <= this.orphanLines.length)
this.newLinesChanged = true;
};
Renderer.prototype._linesRemoving = function _linesRemoving(y, n) {
this.ensureLinesCreated(this.state.lines.length);
// Seek until we arrive at the wanted line
y += this.offset;
var idx = 0, lineIdx = 0;
while (y) {
var lines = (idx === this.messages.length) ? this.orphanLines : this.messages[idx].lines;
if (lineIdx < lines.length) { lineIdx++; y--; }
else { idx++; lineIdx = 0; }
}
// Remove following lines
this.totalLines -= n;
while (n) {
var lines = (idx === this.messages.length) ? this.orphanLines : this.messages[idx].lines;
if (lines.splice(lineIdx, 1).length) n--;
else { idx++; lineIdx = 0; }
}
if (idx >= this.messages.length) this.newLinesChanged = true;
};
Renderer.prototype._linesScrolling = function _linesScrolling(n) {
this.ensureLinesCreated(this.state.lines.length);
if (n > 0) {
// Scrolling up: increment offset, discarding message if necessary
this.offset += n;
this.totalLines -= n;
while (this.messages.length) {
var message = this.messages[0];
if (message.lines.length > this.offset) break;
if (message.rendered !== message.ref.lastText) break;
this.offset -= message.lines.length;
this.messages.shift();
}
} else {
// Scrolling down: just delete bottom lines (leaving them would complicate everything)
n = -n;
this._linesRemoving(this.state.lines.length - n, n);
}
};
Renderer.prototype._linesInserted = function _linesInserted(y, n) {
this.ensureLinesCreated(y);
var pos = y;
// Seek until we arrive at the wanted line, *just before the next one*
y += this.offset;
var idx = 0, lineIdx = 0;
while (true) {
var lines = (idx === this.messages.length) ? this.orphanLines : this.messages[idx].lines;
if (lineIdx < lines.length) {
if (!y) break;
lineIdx++; y--;
} else { idx++; lineIdx = 0; }
}
// Insert lines
this.totalLines += n;
while (n) {
var lines = (idx === this.messages.length) ? this.orphanLines : this.messages[idx].lines;
lines.splice(lineIdx, 0, this.state.lines[pos]);
n--, lineIdx++, pos++;
}
if (idx === this.messages.length) this.newLinesChanged = true;
};
Renderer.prototype.update = function update() {
this.ensureLinesCreated(this.state.lines.length);
// Rerender messages, scheduling flush if some changed
var linesChanged = false;
this.messages.forEach(function (message) {
var rendered = this.render(message);
if (rendered !== message.rendered) {
message.rendered = rendered;
linesChanged = true;
}
}.bind(this));
if (linesChanged) this.editedLineTimer.set();
if (this.newLinesChanged) this.newLineTimer.reset();
this.newLinesChanged = false;
// Make sure orphan lines are processed
this.orphanLinesUpdated();
};
Renderer.prototype.emitMessage = function emitMessage(count, silent, disablePreview) {
if (count < 0 || count > this.orphanLines.length) throw new Error("Should not happen.");
if (count > this.options.maxLinesEmitted)
count = this.options.maxLinesEmitted;
var lines = this.orphanLines.splice(0, count);
var message = { lines: lines };
this.messages.push(message);
message.rendered = this.render(message);
var reply = this.reply.silent(silent).disablePreview(disablePreview);
message.ref = new utils.EditedMessage(reply, message.rendered, "HTML");
this.orphanLinesUpdated();
};
/** HTML RENDERING **/
/* Given a line, return true if potentially monospaced */
Renderer.prototype.evaluateCode = function evaluateCode(str) {
//FIXME: line just between two code lines should be come code
if (str.indexOf(" ") !== -1 || /[-_,:;<>()/\\~|'"=^]{4}/.exec(str))
return true;
return false;
};
/* Given a message object, render to an HTML snippet */
Renderer.prototype.render = function render(message) {
var cursorString = this.state.getMode("cursorBlink") ? this.options.cursorBlinkString : this.options.cursorString;
var isWhitespace = true, x = this.state.cursor[0];
var html = message.lines.map(function (line, idx) {
var hasCursor = (this.state.getMode("cursor")) && (this.state.getLine() === line);
if (!line.code && this.evaluateCode(line.str)) line.code = true;
var content = line.str;
if (hasCursor || line.str.trim().length) isWhitespace = false;
if (idx === 0 && !content.substring(0, this.options.startFill.length).trim()) {
// The message would start with spaces, which would get trimmed by telegram
if (!(hasCursor && x < this.options.startFill.length))
content = this.options.startFill + content.substring(this.options.startFill.length);
}
if (hasCursor)
content = escapeHtml(content.substring(0, x)) + cursorString + escapeHtml(content.substring(x));
else
content = escapeHtml(content);
if (line.code) content = "<code>" + content + "</code>";
return content;
}.bind(this)).join("\n");
if (isWhitespace) return "<em>(empty)</em>";
return html;
};
/** FLUSH SCHEDULING **/
Renderer.prototype.initTimers = function initTimers() {
// Set when an existent line changes, cancelled when edited lines flushed
this.editedLineTimer = new utils.Timer(this.options.editTime).on("fire", this.flushEdited.bind(this));
// Set when a new line is added or changed, cancelled on new lines flush
this.newChunkTimer = new utils.Timer(this.options.chunkTime).on("fire", this.flushNew.bind(this));
// Reset when a new line is added or changed, cancelled on new lines flush
this.newLineTimer = new utils.Timer(this.options.lineTime).on("fire", this.flushNew.bind(this));
// Set when there is an unfinished nonempty line, cancelled when reference changes or line becomes empty
this.unfinishedLineTimer = new utils.Timer(this.options.unfinishedTime).on("fire", this.flushUnfinished.bind(this));
this.newChunkTimer.on("active", function () {
this.reply.action("typing");
}.bind(this));
//FIXME: should we emit actions on edits?
};
Renderer.prototype.orphanLinesUpdated = function orphanLinesUpdated() {
var newLines = this.orphanLines.length - 1;
if (newLines >= this.options.maxLinesWait) {
// Flush immediately
this.flushNew();
} else if (newLines > 0) {
this.newChunkTimer.set();
} else {
this.newChunkTimer.cancel();
this.newLineTimer.cancel();
}
// Update unfinished line
var unfinishedLine = this.orphanLines[this.orphanLines.length - 1];
if (unfinishedLine && this.totalLines === this.state.rows && unfinishedLine.str.length === this.state.columns)
unfinishedLine = null;
if (this.unfinishedLine !== unfinishedLine) {
this.unfinishedLine = unfinishedLine;
this.unfinishedLineTimer.cancel();
}
if (unfinishedLine && unfinishedLine.str.length) this.unfinishedLineTimer.set();
else this.unfinishedLineTimer.cancel();
};
Renderer.prototype.flushEdited = function flushEdited() {
this.messages.forEach(function (message) {
if (message.rendered !== message.ref.lastText)
message.ref.edit(message.rendered);
});
this.editedLineTimer.cancel();
};
Renderer.prototype.flushNew = function flushNew() {
this.flushEdited();
var count = this.orphanLines.length;
if (this.unfinishedLine) count--;
if (count <= 0) return;
this.emitMessage(count, !!this.options.silent, !!this.options.hidePreview);
};
Renderer.prototype.flushUnfinished = function flushUnfinished() {
do this.flushNew(); while (this.orphanLines.length > 1);
if (this.orphanLines.length < 1 || this.orphanLines[0].str.length === 0) return;
this.emitMessage(1, !!this.options.unfinishedSilent, !!this.options.unfinishedHidePreview);
};
exports.Renderer = Renderer;