libreoffice-online/loleaflet/js/global.js
Michael Meeks 136f20b386 Use jsdom to load and execute our CSS, HTML and JS.
Change-Id: Ia7414e1fd19bf7ebfa29274d1acac5cee4837341
Signed-off-by: Michael Meeks <michael.meeks@collabora.com>
2021-01-16 12:18:47 +00:00

823 lines
24 KiB
JavaScript

/* -*- js-indent-level: 8 -*- */
/* global Uint8Array */
(function (global) {
global.getParameterByName = function (name) {
name = name.replace(/[\[]/, '\\[').replace(/[\]]/, '\\]');
var regex = new RegExp('[\\?&]' + name + '=([^&#]*)');
var results = regex.exec(location.search);
return results === null ? '' : results[1].replace(/\+/g, ' ');
};
var ua = navigator.userAgent.toLowerCase(),
uv = navigator.vendor.toLowerCase(),
doc = document.documentElement,
ie = 'ActiveXObject' in window,
webkit = ua.indexOf('webkit') !== -1,
phantomjs = ua.indexOf('phantom') !== -1,
android23 = ua.search('android [23]') !== -1,
chrome = ua.indexOf('chrome') !== -1,
gecko = ua.indexOf('gecko') !== -1 && !webkit && !window.opera && !ie,
safari = !chrome && (ua.indexOf('safari') !== -1 || uv.indexOf('apple') == 0),
win = navigator.platform.indexOf('Win') === 0,
mobile = typeof orientation !== 'undefined' || ua.indexOf('mobile') !== -1,
cypressTest = ua.indexOf('cypress') !== -1,
msPointer = !window.PointerEvent && window.MSPointerEvent,
pointer = (window.PointerEvent && navigator.pointerEnabled && navigator.maxTouchPoints) || msPointer,
ie3d = ie && ('transition' in doc.style),
webkit3d = ('WebKitCSSMatrix' in window) && ('m11' in new window.WebKitCSSMatrix()) && !android23,
gecko3d = 'MozPerspective' in doc.style,
opera12 = 'OTransition' in doc.style;
var chromebook = window.ThisIsTheAndroidApp && window.LOOLMessageHandler.isChromeOS();
var touch = !window.L_NO_TOUCH && (pointer || 'ontouchstart' in window ||
(window.DocumentTouch && document instanceof window.DocumentTouch)) && !chromebook;
var isInternetExplorer = (navigator.userAgent.toLowerCase().indexOf('msie') != -1 ||
navigator.userAgent.toLowerCase().indexOf('trident') != -1);
global.L = {};
global.L.Params = {
/// Shows close button if non-zero value provided
closeButtonEnabled: global.getParameterByName('closebutton'),
/// Shows revision history file menu option
revHistoryEnabled: global.getParameterByName('revisionhistory'),
};
global.L.Browser = {
// @property ie: Boolean
// `true` for all Internet Explorer versions (not Edge).
ie: ie,
// @property ielt9: Boolean
// `true` for Internet Explorer versions less than 9.
ielt9: ie && !document.addEventListener,
// @property edge: Boolean
// `true` for the Edge web browser.
edge: 'msLaunchUri' in navigator && !('documentMode' in document),
// @property webkit: Boolean
// `true` for webkit-based browsers like Chrome and Safari (including mobile versions).
webkit: webkit,
// @property gecko: Boolean
// `true` for gecko-based browsers like Firefox.
gecko: gecko,
// @property android: Boolean
// `true` for any browser running on an Android platform.
android: ua.indexOf('android') !== -1,
// @property android23: Boolean
// `true` for browsers running on Android 2 or Android 3.
android23: android23,
// @property chrome: Boolean
// `true` for the Chrome browser.
chrome: chrome,
// @property safari: Boolean
// `true` for the Safari browser.
safari: safari,
// @property win: Boolean
// `true` when the browser is running in a Windows platform
win: win,
// @property ie3d: Boolean
// `true` for all Internet Explorer versions supporting CSS transforms.
ie3d: ie3d,
// @property isInternetExplorer: Boolean
// `true` for Internet Explorer
isInternetExplorer: isInternetExplorer,
// @property webkit3d: Boolean
// `true` for webkit-based browsers supporting CSS transforms.
webkit3d: webkit3d,
// @property gecko3d: Boolean
// `true` for gecko-based browsers supporting CSS transforms.
gecko3d: gecko3d,
// @property opera12: Boolean
// `true` for the Opera browser supporting CSS transforms (version 12 or later).
opera12: opera12,
// @property any3d: Boolean
// `true` for all browsers supporting CSS transforms.
any3d: !window.L_DISABLE_3D && (ie3d || webkit3d || gecko3d) && !opera12 && !phantomjs,
// @property mobile: Boolean
// `true` for all browsers running in a mobile device.
mobile: mobile,
// @property mobileWebkit: Boolean
// `true` for all webkit-based browsers in a mobile device.
mobileWebkit: mobile && webkit,
// @property mobileWebkit3d: Boolean
// `true` for all webkit-based browsers in a mobile device supporting CSS transforms.
mobileWebkit3d: mobile && webkit3d,
// @property mobileOpera: Boolean
// `true` for the Opera browser in a mobile device.
mobileOpera: mobile && window.opera,
// @property mobileGecko: Boolean
// `true` for gecko-based browsers running in a mobile device.
mobileGecko: mobile && gecko,
// @property cypressTest: Boolean
// `true` when the browser run by cypress
cypressTest: cypressTest,
// @property touch: Boolean
// `true` for all browsers supporting [touch events](https://developer.mozilla.org/docs/Web/API/Touch_events).
touch: !!touch,
// @property msPointer: Boolean
// `true` for browsers implementing the Microsoft touch events model (notably IE10).
msPointer: !!msPointer,
// @property pointer: Boolean
// `true` for all browsers supporting [pointer events](https://msdn.microsoft.com/en-us/library/dn433244%28v=vs.85%29.aspx).
pointer: !!pointer,
// @property retina: Boolean
// `true` for browsers on a high-resolution "retina" screen.
retina: (window.devicePixelRatio || (window.screen.deviceXDPI / window.screen.logicalXDPI)) > 1
};
global.mode = {
isChromebook: function() {
return chromebook;
},
// Here "mobile" means "mobile phone" (at least for now). Has to match small screen size
// requirement.
isMobile: function() {
if (global.mode.isChromebook())
return false;
if (global.L.Browser.mobile && L.Browser.cypressTest) {
return true;
}
return L.Browser.mobile && (screen.width < 768 || screen.height < 768);
},
// Mobile device with big screen size.
isTablet: function() {
if (global.mode.isChromebook())
return false;
return L.Browser.mobile && !window.mode.isMobile();
},
isDesktop: function() {
if (global.mode.isChromebook())
return true;
return !L.Browser.mobile;
},
getDeviceFormFactor: function() {
if (window.mode.isMobile())
return 'mobile';
else if (window.mode.isTablet())
return 'tablet';
else if (window.mode.isDesktop())
return 'desktop';
else
return null;
}
};
global.deviceFormFactor = window.mode.getDeviceFormFactor();
document.addEventListener('contextmenu', function(e) {
if (e.preventDefault) {
e.preventDefault();
} else {
e.returnValue = false;
}
}, false);
global.fakeWebSocketCounter = 0;
global.FakeWebSocket = function () {
this.binaryType = 'arraybuffer';
this.bufferedAmount = 0;
this.extensions = '';
this.protocol = '';
this.readyState = 1;
this.id = window.fakeWebSocketCounter++;
this.sendCounter = 0;
this.onclose = function() {
};
this.onerror = function() {
};
this.onmessage = function() {
};
this.onopen = function() {
};
this.close = function() {
};
};
global.FakeWebSocket.prototype.send = function(data) {
this.sendCounter++;
window.postMobileMessage(data);
};
global.proxySocketCounter = 0;
global.ProxySocket = function (uri) {
var that = this;
this.uri = uri;
this.binaryType = 'arraybuffer';
this.bufferedAmount = 0;
this.extensions = '';
this.unloading = false;
this.protocol = '';
this.connected = true;
this.readyState = 0; // connecting
this.sessionId = 'open';
this.id = window.proxySocketCounter++;
this.sendCounter = 0;
this.msgInflight = 0;
this.openInflight = 0;
this.inSerial = 0;
this.outSerial = 0;
this.minPollMs = 25; // Anything less than ~25 ms can overwhelm the HTTP server.
this.maxPollMs = 500; // We can probably go as much as 1-2 seconds without ill-effect.
this.curPollMs = this.minPollMs; // The current poll period.
this.minIdlePollsToThrottle = 3; // This many 'no data' responses and we throttle.
this.throttleFactor = 1.15; // How rapidly to throttle. 15% takes 4s to go from 25 to 500ms.
this.lastDataTimestamp = performance.now(); // The last time we got any data.
this.onclose = function() {
};
this.onerror = function() {
};
this.onmessage = function() {
};
// IE11 compatibility ...
if (!!window.MSInputMethodContext && !!document.documentMode) {
this.decoder = {};
this.decoder.decode = function(bytes) {
var decoded = '';
var code = 0;
for (var i = 0; i < bytes.length;) {
var b = bytes[i];
var seqLen = 1;
// get bits off the top.
if (b <= 0x7f)
code = b & 0xff;
else if (b <= 0xdf) {
code = b & 0x1f;
seqLen = 2;
} else if (b <= 0xdf) {
code = b & 0x0f;
seqLen = 3;
} else if (b <= 0xf4) {
code = b & 0x07;
seqLen = 4;
}
var left = bytes.length - i;
if (left >= seqLen) {
for (var j = 1; j < seqLen; ++j)
code = (code << 6) | (bytes[i + j] & 0x3f);
} else { // skip to end
seqLen = left;
code = 0xfffd;
}
decoded += String.fromCharCode(code);
i += seqLen;
}
return decoded;
};
this.doSlice = function(bytes,start,end) {
var data = new Uint8Array(end - start + 1);
for (var i = 0; i <= (end - start); ++i)
data[i] = bytes[start + i];
return data;
};
} else {
this.decoder = new TextDecoder();
this.doSlice = function(bytes,start,end) { return bytes.slice(start,end); };
}
this.decode = function(bytes,start,end) {
return this.decoder.decode(this.doSlice(bytes, start,end));
};
this.parseIncomingArray = function(arr) {
//console.debug('proxy: parse incoming array of length ' + arr.length);
for (var i = 0; i < arr.length; ++i)
{
var left = arr.length - i;
if (left < 4)
{
//console.debug('no data left');
break;
}
var type = String.fromCharCode(arr[i+0]);
if (type != 'T' && type != 'B')
{
console.debug('wrong data type: ' + type);
break;
}
i++;
// Serial
if (arr[i] !== 48 && arr[i+1] !== 120) // '0x'
{
console.debug('missing hex preamble');
break;
}
i += 2;
var numStr = '';
var start = i;
while (arr[i] != 10) // '\n'
i++;
numStr = this.decode(arr, start, i);
var serial = parseInt(numStr, 16);
i++; // skip \n
// Size:
if (arr[i] !== 48 && arr[i+1] !== 120) // '0x'
{
console.debug('missing hex preamble');
break;
}
i += 2;
start = i;
while (arr[i] != 10) // '\n'
i++;
numStr = this.decode(arr, start, i);
var size = parseInt(numStr, 16);
i++; // skip \n
var data;
if (type == 'T')
data = this.decode(arr, i, i + size);
else
data = this.doSlice(arr, i, i + size);
if (serial !== that.inSerial + 1) {
console.debug('Error: serial mismatch ' + serial + ' vs. ' + (that.inSerial + 1));
}
that.inSerial = serial;
this.onmessage({ data: data });
i += size; // skip trailing '\n' in loop-increment
}
};
this.sendQueue = '';
this._signalErrorClose = function() {
clearInterval(this.pollInterval);
clearTimeout(this.delaySession);
this.pollInterval = undefined;
this.delaySession = undefined;
if (that.readyState < 3)
{
this.onerror();
this.onclose();
}
this.sessionId = 'open';
this.inSerial = 0;
this.outSerial = 0;
this.msgInflight = 0;
this.openInflight = 0;
this.readyState = 3; // CLOSED
};
// For those who think that long-running sockets are a
// better way to wait: you're so right. However, each
// consumes a scarce server worker thread while it waits,
// so ... back in the real world:
this._setPollInterval = function(intervalMs) {
clearInterval(this.pollInterval);
if (this.readyState === 1)
this.pollInterval = setInterval(this.doSend, intervalMs);
},
this.doSend = function () {
if (that.sessionId === 'open')
{
if (that.readyState === 3)
console.debug('Error: sending on closed socket');
return;
}
if (that.msgInflight >= 4) // something went badly wrong.
{
// We shouldn't get here because we throttle sending when we
// have something in flight, but if the server hangs, we
// will do up to 3 retries before we end up here and yield.
if (that.curPollMs < that.maxPollMs)
{
that.curPollMs = Math.min(that.maxPollMs, that.curPollMs * that.throttleFactor) | 0;
console.debug('High latency connection - too much in-flight, throttling to ' + that.curPollMs + ' ms.');
that._setPollInterval(that.curPollMs);
}
else if (performance.now() - that.lastDataTimestamp > 30 * 1000)
{
console.debug('Close connection after no response for 30secs');
that._signalErrorClose();
}
else
console.debug('High latency connection - too much in-flight, pausing.');
return;
}
// Maximize the timeout, instead of stopping altogethr,
// so we don't hang when the following request takes
// too long, hangs, throws, etc. we can recover.
that._setPollInterval(that.maxPollMs);
//console.debug('send msg - ' + that.msgInflight + ' on session ' +
// that.sessionId + ' queue: "' + that.sendQueue + '"');
var req = new XMLHttpRequest();
req.open('POST', that.getEndPoint('write'));
req.responseType = 'arraybuffer';
req.addEventListener('load', function() {
if (this.status == 200)
{
var data = new Uint8Array(this.response);
if (data.length)
{
// We have some data back from WSD.
// Another user might be editing and we want
// to see their changes in real time.
that.curPollMs = that.minPollMs; // Drain fast.
that._setPollInterval(that.curPollMs);
that.lastDataTimestamp = performance.now();
that.parseIncomingArray(data);
return;
}
}
else
{
console.debug('proxy: error on incoming response ' + this.status);
that._signalErrorClose();
}
if (that.curPollMs < that.maxPollMs) // If we aren't throttled, see if we should.
{
// Has it been long enough since we got any data?
var timeSinceLastDataMs = (performance.now() - that.lastDataTimestamp) | 0;
if (timeSinceLastDataMs >= that.minIdlePollsToThrottle * that.curPollMs)
{
// Throttle.
that.curPollMs = Math.min(that.maxPollMs, that.curPollMs * that.throttleFactor) | 0;
//console.debug('No data for ' + timeSinceLastDataMs + ' ms -- throttling to ' + that.curPollMs + ' ms.');
}
}
that._setPollInterval(that.curPollMs);
});
req.addEventListener('loadend', function() {
that.msgInflight--;
});
req.send(that.sendQueue);
that.sendQueue = '';
that.msgInflight++;
};
this.getSessionId = function() {
if (this.openInflight > 0)
{
console.debug('Waiting for session open');
return;
}
if (this.delaySession)
return;
// avoid attempting to re-connect too quickly
if (global.lastCreatedProxySocket)
{
var msSince = performance.now() - global.lastCreatedProxySocket;
if (msSince < 250) {
var delay = 250 - msSince;
console.debug('Wait to re-try session creation for ' + delay + 'ms');
this.curPollMs = delay; // ms
this.delaySession = setTimeout(function() {
that.delaySession = undefined;
that.getSessionId();
}, delay);
return;
}
}
global.lastCreatedProxySocket = performance.now();
var req = new XMLHttpRequest();
req.open('POST', that.getEndPoint('open'));
req.responseType = 'text';
req.addEventListener('load', function() {
console.debug('got session: ' + this.responseText);
if (this.status !== 200 || !this.responseText ||
this.responseText.indexOf('\n') >= 0) // multi-line error
{
console.debug('Error: failed to fetch session id! error: ' + this.status);
that._signalErrorClose();
}
else // we connected - lets get going ...
{
that.sessionId = this.responseText;
that.readyState = 1;
that.onopen();
that._setPollInterval(that.curPollMs);
}
});
req.addEventListener('loadend', function() {
console.debug('Open completed state: ' + that.readyState);
that.openInflight--;
});
req.send('');
this.openInflight++;
};
this.send = function(msg) {
var hadData = this.sendQueue.length > 0;
this.sendQueue = this.sendQueue.concat(
'B0x' + this.outSerial.toString(16) + '\n' +
'0x' + msg.length.toString(16) + '\n' + msg + '\n');
this.outSerial++;
// Send ASAP, if we have throttled.
if (that.curPollMs > that.minPollMs || !hadData)
{
// Unless we are backed up.
if (that.msgInflight <= 3)
{
//console.debug('Have data to send, lowering poll interval.');
that.curPollMs = that.minPollMs;
that._setPollInterval(that.curPollMs);
}
}
};
this.sendCloseMsg = function(beacon) {
var url = that.getEndPoint('close');
if (!beacon)
{
var req = new XMLHttpRequest();
req.open('POST', url);
req.send('');
}
else
navigator.sendBeacon(url, '');
};
this.close = function() {
var oldState = this.readyState;
console.debug('proxy: close socket');
this.readyState = 3;
this.onclose();
clearInterval(this.pollInterval);
clearTimeout(this.delaySession);
this.pollInterval = undefined;
if (oldState === 1) // was open
this.sendCloseMsg(this.unloading);
this.sessionId = 'open';
};
this.setUnloading = function() {
this.unloading = true;
};
this.getEndPoint = function(command) {
var base = this.uri;
return base + '/' + this.sessionId + '/' + command + '/' + this.outSerial;
};
console.debug('proxy: new socket ' + this.id + ' ' + this.uri);
// queue fetch of session id.
this.getSessionId();
};
if (global.socketProxy)
{
// re-write relative URLs in CSS - somewhat grim.
window.addEventListener('load', function() {
var replaceUrls = function(rules, replaceBase) {
if (!rules)
return;
for (var r = 0; r < rules.length; ++r) {
// check subset of rules like @media or @import
if (rules[r] && rules[r].type != 1) {
replaceUrls(rules[r].cssRules || rules[r].rules, replaceBase);
continue;
}
if (!rules[r] || !rules[r].style)
continue;
var img = rules[r].style.backgroundImage;
if (img === '' || img === undefined)
continue;
if (img.startsWith('url("images/'))
{
rules[r].style.backgroundImage =
img.replace('url("images/', replaceBase);
}
}
};
var sheets = document.styleSheets;
for (var i = 0; i < sheets.length; ++i) {
var relBases = sheets[i].href.split('/');
relBases.pop(); // bin last - css name.
var replaceBase = 'url("' + relBases.join('/') + '/images/';
var rules;
try {
rules = sheets[i].cssRules || sheets[i].rules;
} catch (err) {
console.log('Missing CSS from ' + sheets[i].href);
continue;
}
replaceUrls(rules, replaceBase);
}
}, false);
}
global.createWebSocket = function(uri) {
if (global.socketProxy) {
window.socketProxy = true;
return new global.ProxySocket(uri);
} else {
return new WebSocket(uri);
}
};
// If not debug, don't print anything on the console
// except in tile debug mode (Ctrl-Shift-Alt-d)
console.log2 = console.log;
if (global.loleafletLogging !== 'true') {
var methods = ['warn', 'info', 'debug', 'trace', 'log', 'assert', 'time', 'timeEnd'];
for (var i = 0; i < methods.length; i++) {
console[methods[i]] = function() {};
}
} else {
window.onerror = function (msg, src, row, col, err) {
var data = {
userAgent: navigator.userAgent.toLowerCase(),
vendor: navigator.vendor.toLowerCase(),
message: msg,
source: src,
line: row,
column: col
};
var desc = err ? err.message || {}: {}, stack = err ? err.stack || {}: {};
var log = 'jserror ' + JSON.stringify(data, null, 2) + '\n' + desc + '\n' + stack + '\n';
if (window.ThisIsAMobileApp) {
window.postMobileError(log);
} else if (global.socket && (global.socket instanceof WebSocket) && global.socket.readyState === 1) {
global.socket.send(log);
} else if (global.socket && global.L && global.L.Socket &&
(global.socket instanceof global.L.Socket) && global.socket.connected()) {
global.socket.sendMessage(log);
} else {
var req = new XMLHttpRequest();
var url = global.location.protocol + '//' + global.location.host + global.location.pathname.match(/.*\//) + 'logging.html';
req.open('POST', url, true);
req.setRequestHeader('Content-type','application/json; charset=utf-8');
req.send(log);
}
return false;
};
}
global._ = function (string) {
// In the mobile app case we can't use the stuff from l10n-for-node, as that assumes HTTP.
if (window.ThisIsAMobileApp) {
// We use another approach just for iOS for now.
if (window.LOCALIZATIONS && Object.prototype.hasOwnProperty.call(window.LOCALIZATIONS, string)) {
// window.postMobileDebug('_(' + string + '): YES: ' + window.LOCALIZATIONS[string]);
var result = window.LOCALIZATIONS[string];
if (window.LANG === 'de-CH') {
result = result.replace(/ß/g, 'ss');
}
return result;
} else {
// window.postMobileDebug('_(' + string + '): NO');
return string;
}
} else {
return string.toLocaleString();
}
};
var docParams, wopiParams;
var filePath = global.getParameterByName('file_path');
var wopiSrc = global.getParameterByName('WOPISrc');
if (wopiSrc != '') {
global.docURL = decodeURIComponent(wopiSrc);
wopiSrc = '?WOPISrc=' + wopiSrc + '&compat=/ws';
if (global.accessToken !== '') {
wopiParams = { 'access_token': global.accessToken, 'access_token_ttl': global.accessTokenTTL };
}
else if (global.accessHeader !== '') {
wopiParams = { 'access_header': global.accessHeader };
}
if (global.reuseCookies !== '') {
if (wopiParams) {
wopiParams['reuse_cookies'] = global.reuseCookies;
}
else {
wopiParams = { 'reuse_cookies': global.reuseCookies };
}
}
if (wopiParams) {
docParams = Object.keys(wopiParams).map(function(key) {
return encodeURIComponent(key) + '=' + encodeURIComponent(wopiParams[key]);
}).join('&');
}
} else {
global.docURL = filePath;
}
if (window.ThisIsAMobileApp) {
global.socket = new global.FakeWebSocket();
window.TheFakeWebSocket = global.socket;
} else {
var websocketURI = global.host + global.serviceRoot + '/lool/' + encodeURIComponent(global.docURL + (docParams ? '?' + docParams : '')) + '/ws' + wopiSrc;
try {
global.socket = global.createWebSocket(websocketURI);
} catch (err) {
console.log(err);
}
}
var lang = global.getParameterByName('lang');
global.queueMsg = [];
if (window.ThisIsAMobileApp)
window.LANG = lang;
if (global.socket && global.socket.readyState !== 3) {
global.socket.onopen = function () {
if (global.socket.readyState === 1) {
var ProtocolVersionNumber = '0.1';
var timestamp = global.getParameterByName('timestamp');
var msg = 'load url=' + encodeURIComponent(global.docURL);
global.socket.send('loolclient ' + ProtocolVersionNumber);
if (window.ThisIsAMobileApp) {
msg += ' lang=' + window.LANG;
} else {
if (timestamp) {
msg += ' timestamp=' + timestamp;
}
if (lang) {
msg += ' lang=' + lang;
}
// renderingOptions?
}
if (window.deviceFormFactor) {
msg += ' deviceFormFactor=' + window.deviceFormFactor;
}
if (window.isLocalStorageAllowed) {
var spellOnline = window.localStorage.getItem('SpellOnline');
msg += ' spellOnline=' + spellOnline;
}
global.socket.send(msg);
}
};
global.socket.onerror = function (event) {
console.log(event);
};
global.socket.onclose = function (event) {
console.log(event);
};
global.socket.onmessage = function (event) {
if (typeof global.socket._onMessage === 'function') {
global.socket._emptyQueue();
global.socket._onMessage(event);
} else {
global.queueMsg.push(event.data);
}
};
global.socket.binaryType = 'arraybuffer';
if (window.ThisIsAMobileApp) {
// This corresponds to the initial GET request when creating a WebSocket
// connection and tells the app's code that it is OK to start invoking
// TheFakeWebSocket's onmessage handler. The app code that handles this
// special message knows the document to be edited anyway, and can send it
// on as necessary to the Online code.
window.postMobileMessage('HULLO');
// A FakeWebSocket is immediately open.
this.socket.onopen();
}
}
}(window));