a31e6597cc
This relies on core change I5e6c93c64af0d201a8ec045fea5546e189baca74 (https://gerrit.libreoffice.org/c/core/+/160313) as the logic to keep the cell focused is from there. Here we set the new option when there is an onscreen keyboard hint given by ui_defaults or a postmessage. As ui_defaults are set at page load, we need to wait to send our message until we're initializing everything else to set our options. Additionally, we need to use the socket directly rather than sending this through the map so that this bypasses things like readonly mode Signed-off-by: Skyler Grey <skyler.grey@collabora.com> Change-Id: Ie6d80c0e1ebca607b9d705b673ab8008fae39260 Signed-off-by: Skyler Grey <skyler.grey@collabora.com>
1182 lines
38 KiB
JavaScript
1182 lines
38 KiB
JavaScript
/* -*- js-indent-level: 8 -*- */
|
|
/* global Uint8Array _ */
|
|
|
|
/*
|
|
For extending window.app object, please see "docstate.js" file.
|
|
Below definition is only for the properties that this (global.js) file needs at initialization.
|
|
*/
|
|
window.app = {
|
|
socket: null,
|
|
console: {}
|
|
};
|
|
|
|
(function (global) {
|
|
|
|
global.logServer = function (log) {
|
|
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.app.definitions.Socket &&
|
|
(global.socket instanceof global.app.definitions.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);
|
|
}
|
|
};
|
|
|
|
// enable later toggling
|
|
global.setLogging = function(doLogging)
|
|
{
|
|
var loggingMethods = ['error', 'warn', 'info', 'debug', 'trace', 'log', 'assert', 'time', 'timeEnd', 'group', 'groupEnd'];
|
|
if (!doLogging) {
|
|
var noop = function() {};
|
|
|
|
for (var i = 0; i < loggingMethods.length; i++) {
|
|
window.app.console[loggingMethods[i]] = noop;
|
|
}
|
|
} else {
|
|
for (var i = 0; i < loggingMethods.length; i++) {
|
|
if (!Object.prototype.hasOwnProperty.call(window.console, loggingMethods[i])) {
|
|
continue;
|
|
}
|
|
(function(method) {
|
|
window.app.console[method] = function logWithCool() {
|
|
var args = Array.prototype.slice.call(arguments);
|
|
|
|
return window.console[method].apply(console, args);
|
|
};
|
|
}(loggingMethods[i]));
|
|
}
|
|
|
|
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 || '(no message)': '(no err)', stack = err ? err.stack || '(no stack)': '(no err)';
|
|
var log = 'jserror ' + JSON.stringify(data, null, 2) + '\n' + desc + '\n' + stack + '\n';
|
|
global.logServer(log);
|
|
return false;
|
|
};
|
|
}
|
|
};
|
|
|
|
global.setLogging(global.coolLogging == 'true');
|
|
|
|
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,
|
|
|
|
cypressTest = ua.indexOf('cypress') !== -1,
|
|
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 || (cypressTest && 'MozUserFocus' in doc.style))
|
|
&& !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,
|
|
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.COOLMessageHandler.isChromeOS();
|
|
|
|
var isInternetExplorer = (navigator.userAgent.toLowerCase().indexOf('msie') != -1 ||
|
|
navigator.userAgent.toLowerCase().indexOf('trident') != -1);
|
|
|
|
var navigatorLang = navigator.languages && navigator.languages.length ? navigator.languages[0] :
|
|
(navigator.language || navigator.userLanguage || navigator.browserLanguage || navigator.systemLanguage);
|
|
|
|
function getFirefoxVersion() {
|
|
var version = '';
|
|
|
|
var userAgent = navigator.userAgent.toLowerCase();
|
|
if (userAgent.indexOf('firefox') !== -1) {
|
|
var matches = userAgent.match(/firefox\/([0-9]+\.*[0-9]*)/);
|
|
if (matches) {
|
|
version = matches[1];
|
|
}
|
|
}
|
|
return version;
|
|
}
|
|
|
|
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 geckoVersion: String
|
|
// Firefox version: abc.d.
|
|
geckoVersion: getFirefoxVersion(),
|
|
|
|
// @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 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,
|
|
|
|
// @property lang: String
|
|
// browser language locale
|
|
lang: navigatorLang
|
|
};
|
|
|
|
global.keyboard = {
|
|
onscreenKeyboardHint: window.uiDefaults['onscreenKeyboardHint'],
|
|
// If there's an onscreen keyboard, we don't want to trigger it with innocuous actions like panning around a spreadsheet
|
|
// on the other hand, if there is a hardware keyboard we want to do things like focusing contenteditables so that typing is
|
|
// recognized without tapping again. This is an impossible problem, because browsers do not give us enough information
|
|
// Instead, let's just guess
|
|
guessOnscreenKeyboard: function() {
|
|
if (global.keyboard.onscreenKeyboardHint != undefined) return global.keyboard.onscreenKeyboardHint;
|
|
return (window.ThisIsAMobileApp && !window.ThisIsTheEmscriptenApp) || global.mode.isMobile() || global.mode.isTablet();
|
|
// It's better to guess that more devices will have an onscreen keyboard than reality,
|
|
// because calc becomes borderline unusable if you miss a device that pops up an onscreen keyboard which covers
|
|
// a sizeable portion of the screen
|
|
},
|
|
// alternatively, maybe someone else (e.g. an integrator) knows more about the situation than we do. In this case, let's
|
|
// let them override our default
|
|
hintOnscreenKeyboard: function(hint) {
|
|
if (global.app
|
|
&& global.L.Map
|
|
&& global.L.Map.THIS._docLayer.isCalc()
|
|
&& hint !== undefined) {
|
|
var command = {
|
|
Enable: {
|
|
type: 'boolean',
|
|
value: hint
|
|
}
|
|
};
|
|
global.L.Map.THIS.sendUnoCommand('.uno:MoveKeepInsertMode', command);
|
|
}
|
|
global.keyboard.onscreenKeyboardHint = hint;
|
|
},
|
|
};
|
|
|
|
global.memo = {
|
|
_lastId: 0,
|
|
|
|
/// This does pretty much the same as L.stamp. We can't use L.stamp because it's not yet in-scope by the first time we want to call global.memo.decorator
|
|
/// If you are able to use L.stamp instead, you probably should
|
|
_getId: function(obj) {
|
|
if (obj === null || obj === undefined) {
|
|
return '' + obj;
|
|
}
|
|
if (!('_coolMemoId' in obj)) {
|
|
obj['_coolMemoId'] = ++global.memo._lastId;
|
|
}
|
|
return obj._coolMemoId;
|
|
},
|
|
|
|
_decoratorMemo: {},
|
|
|
|
/// A decorator factory, which takes a decorator and prevents it from creating new instances when wrapping the same function
|
|
/// This is particularly useful for functions that take events, say, as .on and .off won't work properly if you don't provide the same function instance
|
|
decorator: function(decorator, context) {
|
|
var decoratorId = global.memo._getId(decorator);
|
|
var contextId = global.memo._getId(context);
|
|
|
|
return function(f) {
|
|
var functionId = global.memo._getId(f);
|
|
|
|
if (global.memo._decoratorMemo[decoratorId + ' ' + contextId + ' ' + functionId] === undefined) {
|
|
global.memo._decoratorMemo[decoratorId + ' ' + contextId + ' ' + functionId] = decorator.apply(this, arguments);
|
|
|
|
if (context !== null && context !== undefined) {
|
|
global.memo._decoratorMemo[decoratorId + ' ' + contextId + ' ' + functionId] = global.memo._decoratorMemo[decoratorId + ' ' + contextId + ' ' + functionId].bind(context);
|
|
}
|
|
}
|
|
|
|
return global.memo._decoratorMemo[decoratorId + ' ' + contextId + ' ' + functionId];
|
|
};
|
|
},
|
|
|
|
_bindMemo: {},
|
|
|
|
/// A decorator, which takes a function and binds it to an object
|
|
/// Similar to L.bind, but when given the same function and context we will return the previously bound function
|
|
bind: function(f, context) {
|
|
var functionId = global.memo._getId(f);
|
|
var contextId = global.memo._getId(context);
|
|
if (global.memo._bindMemo[functionId + ' ' + contextId] === undefined) {
|
|
global.memo._bindMemo[functionId + ' ' + contextId] = f.bind(context);
|
|
}
|
|
return global.memo._bindMemo[functionId + ' ' + contextId];
|
|
}
|
|
};
|
|
|
|
global.touch = {
|
|
/// a touchscreen event handler, supports both DOM and hammer.js events
|
|
isTouchEvent: function(e) {
|
|
if (e.originalEvent) {
|
|
e = e.originalEvent;
|
|
}
|
|
|
|
if (L.Browser.cypressTest && global.L.Browser.mobile) {
|
|
return true; // As cypress tests on mobile tend to use "click" events instead of touches... we cheat to get them recognized as touch events
|
|
}
|
|
|
|
if (e.pointerType) {
|
|
return e.pointerType === 'touch' || e.pointerType === 'kinect';
|
|
}
|
|
|
|
if (e.isMouseEvent !== undefined) {
|
|
return !e.isMouseEvent;
|
|
}
|
|
|
|
return !(e instanceof MouseEvent);
|
|
},
|
|
|
|
/// a decorator that only runs the function if the event is a touch event
|
|
touchOnly: global.memo.decorator(function(f) {
|
|
return function(e) {
|
|
if (!global.touch.isTouchEvent(e)) return;
|
|
return f.apply(this, arguments);
|
|
};
|
|
}),
|
|
|
|
/// a decorator that only runs the function if the event is not a touch event
|
|
mouseOnly: global.memo.decorator(function(f) {
|
|
return function(e) {
|
|
if (global.touch.isTouchEvent(e)) return;
|
|
return f.apply(this, arguments);
|
|
};
|
|
}),
|
|
|
|
/// detect if the primary pointing device is of limited accuracy (generally a touchscreen)
|
|
/// you shouldn't use this for determining the behavior of an event (use isTouchEvent instead), but this may
|
|
/// be useful for determining what UI to show (e.g. the draggable teardrops under the cursor)
|
|
hasPrimaryTouchscreen: function() {
|
|
return window.matchMedia('(pointer: coarse)').matches;
|
|
},
|
|
/// detect any pointing device is of limited accuracy (generally a touchscreen)
|
|
/// you shouldn't use this for determining the behavior of an event (use isTouchEvent instead), but this may
|
|
/// be useful for determining what UI to show (e.g. the draggable teardrops under the cursor)
|
|
hasAnyTouchscreen: function() {
|
|
return window.matchMedia('(any-pointer: coarse)').matches;
|
|
},
|
|
|
|
};
|
|
|
|
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.onclose = function() {
|
|
};
|
|
this.onerror = function() {
|
|
};
|
|
this.onmessage = function() {
|
|
};
|
|
this.onopen = function() {
|
|
};
|
|
this.close = function() {
|
|
};
|
|
};
|
|
global.FakeWebSocket.prototype.send = function(data) {
|
|
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.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() {
|
|
};
|
|
|
|
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) {
|
|
//window.app.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)
|
|
{
|
|
//window.app.console.debug('no data left');
|
|
break;
|
|
}
|
|
var type = String.fromCharCode(arr[i+0]);
|
|
if (type != 'T' && type != 'B')
|
|
{
|
|
window.app.console.debug('wrong data type: ' + type);
|
|
break;
|
|
}
|
|
i++;
|
|
|
|
// Serial
|
|
if (arr[i] !== 48 && arr[i+1] !== 120) // '0x'
|
|
{
|
|
window.app.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'
|
|
{
|
|
window.app.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) {
|
|
window.app.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)
|
|
window.app.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;
|
|
window.app.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)
|
|
{
|
|
window.app.console.debug('Close connection after no response for 30secs');
|
|
that._signalErrorClose();
|
|
}
|
|
else
|
|
window.app.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);
|
|
|
|
//window.app.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
|
|
{
|
|
window.app.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;
|
|
//window.app.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)
|
|
{
|
|
window.app.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;
|
|
window.app.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() {
|
|
window.app.console.debug('got session: ' + this.responseText);
|
|
if (this.status !== 200 || !this.responseText ||
|
|
this.responseText.indexOf('\n') >= 0) // multi-line error
|
|
{
|
|
window.app.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() {
|
|
window.app.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' + (new TextEncoder().encode(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)
|
|
{
|
|
//window.app.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;
|
|
window.app.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;
|
|
};
|
|
window.app.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 + '/images/');
|
|
}
|
|
if (img.startsWith('url("remote/'))
|
|
{
|
|
rules[r].style.backgroundImage =
|
|
img.replace('url("remote/', replaceBase + '/remote/');
|
|
}
|
|
}
|
|
};
|
|
var sheets = document.styleSheets;
|
|
for (var i = 0; i < sheets.length; ++i) {
|
|
var relBases;
|
|
try {
|
|
relBases = sheets[i].href.split('/');
|
|
} catch (err) {
|
|
window.app.console.log('Missing href from CSS number ' + i);
|
|
continue;
|
|
}
|
|
relBases.pop(); // bin last - css name.
|
|
var replaceBase = 'url("' + relBases.join('/');
|
|
|
|
var rules;
|
|
try {
|
|
rules = sheets[i].cssRules || sheets[i].rules;
|
|
} catch (err) {
|
|
window.app.console.log('Missing CSS from ' + sheets[i].href);
|
|
continue;
|
|
}
|
|
replaceUrls(rules, replaceBase);
|
|
}
|
|
}, false);
|
|
}
|
|
|
|
// indirect socket to wrap the asyncness around fetching the routetoken from indirection url endpoint
|
|
global.IndirectSocket = function(uri) {
|
|
var that = this;
|
|
this.uri = uri;
|
|
this.binaryType = '';
|
|
this.unloading = false;
|
|
this.readyState = 0; // connecting
|
|
this.innerSocket = undefined;
|
|
|
|
this.onclose = function() {};
|
|
this.onerror = function () {};
|
|
this.onmessage = function () {};
|
|
this.onopen = function () {};
|
|
|
|
this.close = function() {
|
|
this.innerSocket.close();
|
|
};
|
|
|
|
this.send = function(msg) {
|
|
this.innerSocket.send(msg);
|
|
};
|
|
|
|
this.setUnloading = function() {
|
|
this.unloading = true;
|
|
};
|
|
|
|
this.sendPostMsg = function(errorCode) {
|
|
var errorMsg;
|
|
if (errorCode === 0) {
|
|
errorMsg = _('Cluster is scaling, retrying...');
|
|
} else if (errorCode === 1) {
|
|
errorMsg = _('Document is migrating to new server, retrying...');
|
|
} else {
|
|
errorMsg = _('Failed to get RouteToken from controller');
|
|
}
|
|
var msg = {
|
|
'MessageId': 'Action_Load_Resp',
|
|
'SendTime': Date.now(),
|
|
'Values': {
|
|
success: false,
|
|
errorMsg: errorMsg,
|
|
errorType: 'clusterscaling'
|
|
}
|
|
};
|
|
window.parent.postMessage(JSON.stringify(msg), '*');
|
|
};
|
|
|
|
var http = new XMLHttpRequest();
|
|
http.open('GET', global.indirectionUrl + '?Uri=' + encodeURIComponent(that.uri), true);
|
|
http.responseType = 'json';
|
|
http.addEventListener('load', function() {
|
|
if (this.status === 200) {
|
|
var uriWithRouteToken = http.response.uri;
|
|
global.expectedServerId = http.response.serverId;
|
|
var params = (new URL(uriWithRouteToken)).searchParams;
|
|
global.routeToken = params.get('RouteToken');
|
|
window.app.console.log('updated routeToken: ' + global.routeToken);
|
|
that.innerSocket = new WebSocket(uriWithRouteToken);
|
|
that.innerSocket.binaryType = that.binaryType;
|
|
that.innerSocket.onerror = function() {
|
|
that.readyState = that.innerSocket.readyState;
|
|
that.onerror();
|
|
};
|
|
that.innerSocket.onclose = function() {
|
|
that.readyState = 3;
|
|
that.onclose();
|
|
that.innerSocket.onerror = function () {};
|
|
that.innerSocket.onclose = function () {};
|
|
that.innerSocket.onmessage = function () {};
|
|
};
|
|
that.innerSocket.onopen = function() {
|
|
that.readyState = 1;
|
|
that.onopen();
|
|
};
|
|
that.innerSocket.onmessage = function(e) {
|
|
that.readyState = that.innerSocket.readyState;
|
|
that.onmessage(e);
|
|
};
|
|
} else if (this.status === 202) {
|
|
that.sendPostMsg(http.response.errorCode);
|
|
var timeoutFn = function (indirectionUrl, uri) {
|
|
console.warn('Requesting again for routeToken');
|
|
this.open('GET', indirectionUrl + '?Uri=' + encodeURIComponent(uri), true);
|
|
this.send();
|
|
}.bind(this);
|
|
setTimeout(timeoutFn, 3000, global.indirectionUrl, that.uri);
|
|
} else {
|
|
window.app.console.error('Indirection url: error on incoming response ' + this.status);
|
|
that.sendPostMsg(-1);
|
|
}
|
|
});
|
|
http.send();
|
|
};
|
|
|
|
global.createWebSocket = function(uri) {
|
|
if ('processCoolUrl' in window) {
|
|
uri = window.processCoolUrl({ url: uri, type: 'ws' });
|
|
}
|
|
|
|
if (global.socketProxy) {
|
|
window.socketProxy = true;
|
|
return new global.ProxySocket(uri);
|
|
} else if (global.indirectionUrl != '' && !global.migrating) {
|
|
window.indirectSocket = true;
|
|
return new global.IndirectSocket(uri);
|
|
} else {
|
|
return new WebSocket(uri);
|
|
}
|
|
};
|
|
|
|
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();
|
|
}
|
|
};
|
|
|
|
// Some global variables are defined in cool.html, among them:
|
|
// global.host: the host URL, with ws(s):// protocol
|
|
// global.serviceRoot: an optional root path on the server, typically blank.
|
|
|
|
// Setup global.webserver: the host URL, with http(s):// protocol (used to fetch files).
|
|
if (global.webserver === undefined) {
|
|
var protocol = window.location.protocol === 'file:' ? 'https:' : window.location.protocol;
|
|
global.webserver = global.host.replace(/^(ws|wss):/i, protocol);
|
|
global.webserver = global.webserver.replace(/\/*$/, ''); // Remove trailing slash.
|
|
}
|
|
|
|
var docParams, wopiParams;
|
|
var filePath = global.getParameterByName('file_path');
|
|
global.wopiSrc = global.getParameterByName('WOPISrc');
|
|
if (global.wopiSrc != '') {
|
|
global.docURL = decodeURIComponent(global.wopiSrc);
|
|
if (global.accessToken !== '') {
|
|
wopiParams = { 'access_token': global.accessToken, 'access_token_ttl': global.accessTokenTTL };
|
|
}
|
|
else if (global.accessHeader !== '') {
|
|
wopiParams = { 'access_header': global.accessHeader };
|
|
}
|
|
|
|
if (wopiParams) {
|
|
docParams = Object.keys(wopiParams).map(function(key) {
|
|
return encodeURIComponent(key) + '=' + encodeURIComponent(wopiParams[key]);
|
|
}).join('&');
|
|
}
|
|
} else if (window.ThisIsTheEmscriptenApp) {
|
|
// This is of course just a horrible temporary hack
|
|
global.docURL = 'file:///sample.docx';
|
|
} else {
|
|
global.docURL = filePath;
|
|
}
|
|
|
|
// Form a valid WS URL to the host with the given path.
|
|
global.makeWsUrl = function (path) {
|
|
window.app.console.assert(global.host.startsWith('ws'), 'host is not ws: ' + global.host);
|
|
return global.host + global.serviceRoot + path;
|
|
};
|
|
|
|
// Form a URI from the docUrl and wopiSrc and encodes.
|
|
// The docUrlParams, suffix, and wopiSrc are optionally hexified.
|
|
global.routeToken = '';
|
|
global.makeDocAndWopiSrcUrl = function (root, docUrlParams, suffix, wopiSrcParam) {
|
|
var wopiSrc = '';
|
|
if (global.wopiSrc != '') {
|
|
wopiSrc = '?WOPISrc=' + global.wopiSrc;
|
|
if (global.routeToken != '')
|
|
wopiSrc += '&RouteToken=' + global.routeToken;
|
|
wopiSrc += '&compat=';
|
|
if (wopiSrcParam && wopiSrcParam.length > 0)
|
|
wopiSrc += '&' + wopiSrcParam;
|
|
}
|
|
else if (wopiSrcParam && wopiSrcParam.length > 0) {
|
|
wopiSrc = '?' + wopiSrcParam;
|
|
}
|
|
|
|
suffix = suffix || '/ws';
|
|
var encodedDocUrl = encodeURIComponent(docUrlParams) + suffix + wopiSrc;
|
|
if (global.hexifyUrl)
|
|
encodedDocUrl = global.hexEncode(encodedDocUrl);
|
|
return root + encodedDocUrl + '/ws';
|
|
};
|
|
|
|
// Form a valid WS URL to the host with the given path and
|
|
// encode the document URL and params.
|
|
global.makeWsUrlWopiSrc = function (path, docUrlParams, suffix, wopiSrcParam) {
|
|
var websocketURI = global.makeWsUrl(path);
|
|
return global.makeDocAndWopiSrcUrl(websocketURI, docUrlParams, suffix, wopiSrcParam);
|
|
};
|
|
|
|
// Form a valid HTTP URL to the host with the given path.
|
|
global.makeHttpUrl = function (path) {
|
|
window.app.console.assert(global.webserver.startsWith('http'), 'webserver is not http: ' + global.webserver);
|
|
return global.webserver + global.serviceRoot + path;
|
|
};
|
|
|
|
// Form a valid HTTP URL to the host with the given path and
|
|
// encode the document URL and params.
|
|
global.makeHttpUrlWopiSrc = function (path, docUrlParams, suffix, wopiSrcParam) {
|
|
var httpURI = window.makeHttpUrl(path);
|
|
return global.makeDocAndWopiSrcUrl(httpURI, docUrlParams, suffix, wopiSrcParam);
|
|
};
|
|
|
|
// Encode a string to hex.
|
|
global.hexEncode = function (string) {
|
|
var bytes = new TextEncoder().encode(string);
|
|
var hex = '0x';
|
|
for (var i = 0; i < bytes.length; ++i) {
|
|
hex += bytes[i].toString(16);
|
|
}
|
|
return hex;
|
|
};
|
|
|
|
// Decode hexified string back to plain text.
|
|
global.hexDecode = function (hex) {
|
|
if (hex.startsWith('0x'))
|
|
hex = hex.substr(2);
|
|
var bytes = new Uint8Array(hex.length / 2);
|
|
for (var i = 0; i < bytes.length; i++) {
|
|
bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
|
|
}
|
|
return new TextDecoder().decode(bytes);
|
|
};
|
|
|
|
if (window.ThisIsAMobileApp) {
|
|
global.socket = new global.FakeWebSocket();
|
|
window.TheFakeWebSocket = global.socket;
|
|
} else {
|
|
// The URL may already contain a query (e.g., 'http://server.tld/foo/wopi/files/bar?desktop=baz') - then just append more params
|
|
var docParamsPart = docParams ? (global.docURL.includes('?') ? '&' : '?') + docParams : '';
|
|
var websocketURI = global.makeWsUrlWopiSrc('/cool/', global.docURL + docParamsPart);
|
|
try {
|
|
global.socket = global.createWebSocket(websocketURI);
|
|
} catch (err) {
|
|
window.app.console.log(err);
|
|
}
|
|
}
|
|
|
|
var lang = encodeURIComponent(global.getParameterByName('lang'));
|
|
if (lang)
|
|
window.langParam = lang;
|
|
else
|
|
window.langParam = 'en-US';
|
|
window.langParamLocale = new Intl.Locale(window.langParam);
|
|
global.queueMsg = [];
|
|
if (window.ThisIsTheEmscriptenApp)
|
|
// Temporary hack
|
|
window.LANG = 'en-US';
|
|
else 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 = encodeURIComponent(global.getParameterByName('timestamp'));
|
|
var msg = 'load url=' + encodeURIComponent(global.docURL);
|
|
|
|
var now0 = Date.now();
|
|
var now1 = performance.now();
|
|
var now2 = Date.now();
|
|
global.socket.send('coolclient ' + ProtocolVersionNumber + ' ' + ((now0 + now2) / 2) + ' ' + now1);
|
|
|
|
var isCalcTest =
|
|
global.docURL.includes('data/desktop/calc/') ||
|
|
global.docURL.includes('data/mobile/calc/') ||
|
|
global.docURL.includes('data/multiuser/calc/');
|
|
|
|
if (L.Browser.cypressTest && isCalcTest)
|
|
window.enableAccessibility = false;
|
|
|
|
var accessibilityState = window.localStorage.getItem('accessibilityState') === 'true';
|
|
accessibilityState = accessibilityState || (L.Browser.cypressTest && !isCalcTest);
|
|
msg += ' accessibilityState=' + accessibilityState;
|
|
|
|
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');
|
|
if (spellOnline) {
|
|
msg += ' spellOnline=' + spellOnline;
|
|
}
|
|
|
|
}
|
|
|
|
msg += ' timezone=' + Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
|
|
global.socket.send(msg);
|
|
}
|
|
};
|
|
|
|
global.socket.onerror = function (event) {
|
|
window.app.console.log(event);
|
|
};
|
|
|
|
global.socket.onclose = function (event) {
|
|
window.app.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 && !window.ThisIsTheEmscriptenApp) {
|
|
// 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));
|