(core) move client code to core

Summary:
This moves all client code to core, and makes minimal fix-ups to
get grist and grist-core to compile correctly.  The client works
in core, but I'm leaving clean-up around the build and bundles to
follow-up.

Test Plan: existing tests pass; server-dev bundle looks sane

Reviewers: dsagal

Reviewed By: dsagal

Differential Revision: https://phab.getgrist.com/D2627
This commit is contained in:
Paul Fitzpatrick
2020-10-02 11:10:00 -04:00
parent 5d60d51763
commit 1654a2681f
395 changed files with 52651 additions and 47 deletions

250
app/client/lib/ACIndex.ts Normal file
View File

@@ -0,0 +1,250 @@
/**
* A search index for auto-complete suggestions.
*
* This implementation indexes words, and suggests items based on a best-match score, including
* amount of overlap and position of words. It searches case-insensitively and only at the start
* of words. E.g. searching for "Blue" would match "Blu" in "Lavender Blush", but searching for
* "lush" would only match the "L" in "Lavender".
*/
import {nativeCompare, sortedIndex} from 'app/common/gutil';
import {DomContents} from 'grainjs';
export interface ACItem {
// This should be a trimmed lowercase version of the item's text. It may be an accessor.
// Note that items with empty cleanText are never suggested.
cleanText: string;
}
// Regexp used to split text into words; includes nearly all punctuation. This means that
// "foo-bar" may be searched by "bar", but it's impossible to search for punctuation itself (e.g.
// "a-b" and "a+b" are not distinguished). (It's easy to exclude unicode punctuation too if the
// need arises, see https://stackoverflow.com/a/25575009/328565).
const wordSepRegexp = /[\s!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~]+/;
/**
* An auto-complete index, which simply allows searching for a string.
*/
export interface ACIndex<Item extends ACItem> {
search(searchText: string): ACResults<Item>;
}
// Splits text into an array of pieces, with odd-indexed pieces being the ones to highlight.
export type HighlightFunc = (text: string) => string[];
export const highlightNone: HighlightFunc = (text) => [text];
/**
* AutoComplete results include the suggested items, which one to highlight, and a function for
* highlighting the matched portion of each item.
*/
export interface ACResults<Item extends ACItem> {
// Matching items in order from best match to worst.
items: Item[];
// May be used to highlight matches using buildHighlightedDom().
highlightFunc: HighlightFunc;
// index of a good match (normally 0), or -1 if no great match
selectIndex: number;
}
interface Word {
word: string; // The indexed word
index: number; // Index into _allItems for the item containing this word.
pos: number; // Position of the word within the item where it occurred.
}
/**
* Implements a search index. It doesn't currently support updates; when any values change, the
* index needs to be rebuilt from scratch.
*/
export class ACIndexImpl<Item extends ACItem> implements ACIndex<Item> {
private _allItems: Item[];
// All words from _allItems, sorted.
private _words: Word[];
// Creates an index for the given list of items.
// The max number of items to suggest may be set using _maxResults (default is 50).
constructor(items: Item[], private _maxResults: number = 50) {
this._allItems = items.slice(0);
// Collects [word, occurrence, position] tuples for all words in _allItems.
const allWords: Word[] = [];
for (let index = 0; index < this._allItems.length; index++) {
const item = this._allItems[index];
const words = item.cleanText.split(wordSepRegexp).filter(w => w);
for (let pos = 0; pos < words.length; pos++) {
allWords.push({word: words[pos], index, pos});
}
}
allWords.sort((a, b) => nativeCompare(a.word, b.word));
this._words = allWords;
}
// The main search function. SearchText will be cleaned (trimmed and lowercased) at the start.
// Empty search text returns the first N items in the search universe.
public search(searchText: string): ACResults<Item> {
const cleanedSearchText = searchText.trim().toLowerCase();
const searchWords = cleanedSearchText.split(wordSepRegexp).filter(w => w);
// Maps item index in _allItems to its score.
const myMatches = new Map<number, number>();
if (searchWords.length > 0) {
// For each of searchWords, go through items with an overlap, and update their scores.
for (let k = 0; k < searchWords.length; k++) {
const searchWord = searchWords[k];
for (const [itemIndex, score] of this._findOverlaps(searchWord, k)) {
myMatches.set(itemIndex, (myMatches.get(itemIndex) || 0) + score);
}
}
// Give an extra point to items that start with the searchText.
for (const [itemIndex, score] of myMatches) {
if (this._allItems[itemIndex].cleanText.startsWith(cleanedSearchText)) {
myMatches.set(itemIndex, score + 1);
}
}
}
// Array of pairs [itemIndex, score], sorted by score (desc) and itemIndex.
const sortedMatches = Array.from(myMatches)
.sort((a, b) => nativeCompare(b[1], a[1]) || nativeCompare(a[0], b[0]))
.slice(0, this._maxResults);
const items: Item[] = sortedMatches.map(([index, score]) => this._allItems[index]);
// Append enough non-matching items to reach maxResults.
for (let i = 0; i < this._allItems.length && items.length < this._maxResults; i++) {
if (this._allItems[i].cleanText && !myMatches.has(i)) {
items.push(this._allItems[i]);
}
}
if (!cleanedSearchText) {
// In this case we are just returning the first few items.
return {items, highlightFunc: highlightNone, selectIndex: -1};
}
const highlightFunc = highlightMatches.bind(null, searchWords);
// The best match is the first item. If it actually starts with the search text, AND has a
// strictly better score than other items, highlight it as a default selection. Otherwise, no
// item will be auto-selected.
let selectIndex = -1;
if (items.length > 0 && items[0].cleanText.startsWith(cleanedSearchText) &&
(sortedMatches.length <= 1 || sortedMatches[1][1] < sortedMatches[0][1])) {
selectIndex = 0;
}
return {items, highlightFunc, selectIndex};
}
/**
* Given one of the search words, looks it up in the indexed list of words and searches up and
* down the list for all words that share a prefix with it. Each such word contributes something
* to the score of the index entry it is a part of.
*
* Returns a Map from the index entry (index into _allItems) to the score which this searchWord
* contributes to it.
*
* The searchWordPos argument is the position of searchWord in the overall search text (e.g. 0
* if it's the first word). It is used for the position bonus, to give higher scores to entries
* whose words occur in the same order as in the search text.
*/
private _findOverlaps(searchWord: string, searchWordPos: number): Map<number, number> {
const insertIndex = sortedIndex<{word: string}>(this._words, {word: searchWord},
(a, b) => nativeCompare(a.word, b.word));
// Maps index of item to its score.
const scored = new Map<number, number>();
// Search up and down the list, accepting smaller and smaller overlap.
for (const step of [1, -1]) {
let prefix = searchWord;
let index = insertIndex + (step > 0 ? 0 : -1);
while (prefix && index >= 0 && index < this._words.length) {
for ( ; index >= 0 && index < this._words.length; index += step) {
const wordEntry = this._words[index];
// Once we reach a word that doesn't start with our prefix, break this loop, so we can
// reduce the length of the prefix and keep scanning.
if (!wordEntry.word.startsWith(prefix)) { break; }
// The contribution of this word's to the score consists primarily of the length of
// overlap (i.e. length for the current prefix).
const baseScore = prefix.length;
// To this we add 1 if the word matches exactly.
const fullWordBonus = (wordEntry.word === searchWord ? 1 : 0);
// To prefer matches where words occur in the same order as searched (e.g. searching for
// "Foo B" should prefer "Foo Bar" over "Bar Foo"), we give a bonus based on the
// position of the word in the search text and the entry text. (If positions match as
// 0:0 and 1:1, the total position bonus is 2^0+2^(-2)=1.25; while the bonus from 0:1
// and 1:0 would be 2^(-1) + 2^(-1)=1.0.)
const positionBonus = Math.pow(2, -(searchWordPos + wordEntry.pos));
const itemScore = baseScore + fullWordBonus + positionBonus;
// Each search word contributes only one score (e.g. a search for "Foo" will partially
// match both words in "forty five", but only the higher of the matches will count).
if (itemScore >= (scored.get(wordEntry.index) || 0)) {
scored.set(wordEntry.index, itemScore);
}
}
prefix = prefix.slice(0, -1);
}
}
return scored;
}
}
export type BuildHighlightFunc = (match: string) => DomContents;
/**
* Converts text to DOM with matching bits of text rendered using highlight(match) function.
*/
export function buildHighlightedDom(
text: string, highlightFunc: HighlightFunc, highlight: BuildHighlightFunc
): DomContents {
if (!text) { return text; }
const parts = highlightFunc(text);
return parts.map((part, k) => k % 2 ? highlight(part) : part);
}
// Same as wordSepRegexp, but with capturing parentheses.
const wordSepRegexpParen = new RegExp(`(${wordSepRegexp.source})`);
/**
* Splits text into pieces, with odd-numbered pieces the ones matching a prefix of some
* searchWord, i.e. the ones to highlight.
*/
function highlightMatches(searchWords: string[], text: string): string[] {
const textParts = text.split(wordSepRegexpParen);
const outputs = [''];
for (let i = 0; i < textParts.length; i += 2) {
const word = textParts[i];
const separator = textParts[i + 1] || '';
const prefixLen = findLongestPrefixLen(word.toLowerCase(), searchWords);
if (prefixLen === 0) {
outputs[outputs.length - 1] += word + separator;
} else {
outputs.push(word.slice(0, prefixLen), word.slice(prefixLen) + separator);
}
}
return outputs;
}
function findLongestPrefixLen(text: string, choices: string[]): number {
return choices.reduce((max, choice) => Math.max(max, findCommonPrefixLength(text, choice)), 0);
}
function findCommonPrefixLength(text1: string, text2: string): number {
let i = 0;
while (i < text1.length && text1[i] === text2[i]) { ++i; }
return i;
}

View File

@@ -0,0 +1,47 @@
import { SafeBrowser, ViewProcess } from 'app/client/lib/SafeBrowser';
import { PluginInstance } from 'app/common/PluginInstance';
export { ViewProcess } from 'app/client/lib/SafeBrowser';
/**
* A PluginCustomSection identifies one custom section in a plugin.
*/
export interface PluginCustomSection {
pluginId: string;
sectionId: string;
}
export class CustomSectionElement {
/**
* Get the list of all available custom sections in all plugins' contributions.
*/
public static getSections(plugins: PluginInstance[]): PluginCustomSection[] {
return plugins.reduce<PluginCustomSection[]>((acc, plugin) => {
const customSections = plugin.definition.manifest.contributions.customSections;
const pluginId = plugin.definition.id;
if (customSections) {
// collect identifiers
const sectionIds = customSections.map(section => ({sectionId: section.name, pluginId}));
// concat to the accumulator
return acc.concat(sectionIds);
}
return acc;
}, []);
}
/**
* Find a section matching sectionName in the plugin instances' constributions and returns
* it. Returns `undefined` if not found.
*/
public static find(plugin: PluginInstance, sectionName: string): ViewProcess|undefined {
const customSections = plugin.definition.manifest.contributions.customSections;
if (customSections) {
const section = customSections.find(({ name }) => name === sectionName);
if (section) {
const safeBrowser = plugin.safeBrowser as SafeBrowser;
return safeBrowser.createViewProcess(section.path);
}
}
}
}

91
app/client/lib/Delay.ts Normal file
View File

@@ -0,0 +1,91 @@
/**
* A little class to make it easier to work with setTimeout/clearTimeout when it may need to get
* cancelled or rescheduled.
*/
import {Disposable} from 'app/client/lib/dispose';
export class Delay extends Disposable {
/**
* Returns a function which will schedule a call to cb(), forwarding the arguments.
* This is a static method that may be used without a Delay object.
* E.g. wrapWithDelay(10, cb)(1,2,3) will call cb(1,2,3) in 10ms.
*/
public static wrapWithDelay(ms: number, cb: (this: void, ...args: any[]) => any,
optContext?: any): (...args: any[]) => void;
public static wrapWithDelay<T>(ms: number, cb: (this: T, ...args: any[]) => any,
optContext: T): (...args: any[]) => void {
return function(this: any, ...args: any[]) {
const ctx = optContext || this;
setTimeout(() => cb.apply(ctx, args), ms);
};
}
/**
* Returns a wrapped callback whose execution is delayed until the next animation frame. The
* returned callback may be disposed to cancel the delayed execution.
*/
public static untilAnimationFrame(cb: (this: void, ...args: any[]) => void,
optContext?: any): DisposableCB;
public static untilAnimationFrame<T>(cb: (this: T, ...args: any[]) => void,
optContext: T): DisposableCB {
let reqId: number|null = null;
const f = function(...args: any[]) {
if (reqId === null) {
reqId = window.requestAnimationFrame(() => {
reqId = null;
cb.apply(optContext, args);
});
}
};
f.dispose = function() {
if (reqId !== null) {
window.cancelAnimationFrame(reqId);
}
};
return f;
}
private _timeoutId: ReturnType<typeof setTimeout> | null = null;
public create() {
this.autoDisposeCallback(this.cancel);
}
/**
* If there is a scheduled callback, clear it.
*/
public cancel() {
if (this._timeoutId !== null) {
clearTimeout(this._timeoutId);
this._timeoutId = null;
}
}
/**
* Returns whether there is a scheduled callback.
*/
public isPending() {
return this._timeoutId !== null;
}
/**
* Schedule a new callback, to be called in ms milliseconds, optionally bound to the passed-in
* arguments. If another callback was scheduled, it is cleared first.
*/
public schedule(ms: number, cb: (this: void, ...args: any[]) => any, optContext?: any, ...optArgs: any[]): void;
public schedule<T>(ms: number, cb: (this: T, ...args: any[]) => any, optContext: T, ...optArgs: any[]): void {
this.cancel();
this._timeoutId = setTimeout(() => {
this._timeoutId = null;
cb.apply(optContext, optArgs);
}, ms);
}
}
export interface DisposableCB {
(...args: any[]): void;
dispose(): void;
}

View File

@@ -0,0 +1,66 @@
import {ClientScope} from 'app/client/components/ClientScope';
import {SafeBrowser} from 'app/client/lib/SafeBrowser';
import {ActiveDocAPI} from 'app/common/ActiveDocAPI';
import {LocalPlugin} from 'app/common/plugin';
import {createRpcLogger, PluginInstance} from 'app/common/PluginInstance';
import {Rpc} from 'grain-rpc';
/**
* DocPluginManager's Client side implementation.
*/
export class DocPluginManager {
public pluginsList: PluginInstance[];
constructor(localPlugins: LocalPlugin[], private _untrustedContentOrigin: string, private _docComm: ActiveDocAPI,
private _clientScope: ClientScope) {
this.pluginsList = [];
for (const plugin of localPlugins) {
try {
const pluginInstance = new PluginInstance(plugin, createRpcLogger(console, `PLUGIN ${plugin.id}:`));
const components = plugin.manifest.components || {};
const safeBrowser = pluginInstance.safeBrowser = new SafeBrowser(pluginInstance,
this._clientScope, this._untrustedContentOrigin, components.safeBrowser);
if (components.safeBrowser) {
pluginInstance.rpc.registerForwarder(components.safeBrowser, safeBrowser);
}
// Forward calls to the server, if no matching forwarder.
pluginInstance.rpc.registerForwarder('*', {
forwardCall: (call) => this._docComm.forwardPluginRpc(plugin.id, call),
forwardMessage: (msg) => this._docComm.forwardPluginRpc(plugin.id, msg),
});
this.pluginsList.push(pluginInstance);
} catch (err) {
console.error( // tslint:disable-line:no-console
`DocPluginManager: failed to instantiate ${plugin.id}: ${err.message}`);
}
}
}
/**
* `receiveAction` handles an action received from the server by forwarding it to all safe browser component.
*/
public receiveAction(action: any[]) {
for (const plugin of this.pluginsList) {
const safeBrowser = plugin.safeBrowser as SafeBrowser;
if (safeBrowser) {
safeBrowser.receiveAction(action);
}
}
}
/**
* Make an Rpc object to call server methods from a url-flavored custom view.
*/
public makeAnonForwarder() {
const rpc = new Rpc({});
rpc.queueOutgoingUntilReadyMessage();
rpc.registerForwarder('*', {
forwardCall: (call) => this._docComm.forwardPluginRpc("builtIn/core", call),
forwardMessage: (msg) => this._docComm.forwardPluginRpc("builtIn/core", msg),
});
return rpc;
}
}

View File

@@ -0,0 +1,35 @@
import {PluginInstance} from 'app/common/PluginInstance';
import {InternalImportSourceAPI} from 'app/plugin/InternalImportSourceAPI';
import {ImportSource} from 'app/plugin/PluginManifest';
import {checkers} from 'app/plugin/TypeCheckers';
/**
* Encapsulate together an import source contribution with its plugin instance and a callable stub
* for the ImportSourceAPI. Exposes as well a `fromArray` static method to get all the import
* sources from an array of plugins instances.
*/
export class ImportSourceElement {
/**
* Get all import sources from an array of plugin instances.
*/
public static fromArray(pluginInstances: PluginInstance[]): ImportSourceElement[] {
const importSources: ImportSourceElement[] = [];
for (const plugin of pluginInstances) {
const definitions = plugin.definition.manifest.contributions.importSources;
if (definitions) {
for (const importSource of definitions) {
importSources.push(new ImportSourceElement(plugin, importSource));
}
}
}
return importSources;
}
public importSourceStub: InternalImportSourceAPI;
private constructor(public plugin: PluginInstance, public importSource: ImportSource) {
this.importSourceStub = plugin.getStub<InternalImportSourceAPI>(importSource.importSource,
checkers.InternalImportSourceAPI);
}
}

View File

@@ -0,0 +1,67 @@
/**
* This file adds some includes tweaks to the behavior of Mousetrap.js, the keyboard bindings
* library. It exports the mousetrap library itself, so you may use it in mousetrap's place.
*/
/* global document */
if (typeof window === 'undefined') {
// We can't require('mousetrap') in a browserless environment (specifically for unittests)
// because it uses global variables right on require, which are not available with jsdom.
// So to use mousetrap in unittests, we need to stub it out.
module.exports = {
bind: function() {},
unbind: function() {},
};
} else {
var Mousetrap = require('mousetrap');
var ko = require('knockout');
// Minus is different on Gecko:
// see https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/keyCode
// and https://github.com/ccampbell/mousetrap/pull/215
Mousetrap.addKeycodes({173: '-'});
var MousetrapProtype = Mousetrap.prototype;
var origStopCallback = MousetrapProtype.stopCallback;
/**
* Enhances Mousetrap's stopCallback filter. Normally, mousetrap ignores key events in input
* fields and textareas. This replacement allows individual CommandGroups to be activated in such
* elements. See also 'attach' method of commands.CommandGroup.
*/
MousetrapProtype.stopCallback = function(e, element, combo, sequence) {
if (mousetrapBindingsPaused) {
return true;
}
var cmdGroup = ko.utils.domData.get(element, 'mousetrapCommandGroup');
if (cmdGroup) {
return !cmdGroup.knownKeys.hasOwnProperty(combo);
}
try {
return origStopCallback.call(this, e, element, combo, sequence);
} catch (err) {
if (!document.body.contains(element)) {
// Mousetrap throws a pointless error in this case, which we ignore. It happens when
// element gets removed by a non-mousetrap keyboard handler.
return;
}
throw err;
}
};
var mousetrapBindingsPaused = false;
/**
* Globally pause or unpause mousetrap bindings. This is useful e.g. while a context menu is being
* shown, which has its own keyboard handling.
*/
Mousetrap.setPaused = function(yesNo) {
mousetrapBindingsPaused = yesNo;
};
module.exports = Mousetrap;
}

View File

@@ -0,0 +1,155 @@
var ko = require('knockout');
var dispose = require('./dispose');
/**
* ObservableMap provides a structure to keep track of values that need to recalculate in
* response to a key change or a mapping function change.
*
* @example
* let factor = ko.observable(2);
* let myFunc = ko.computed(() => {
* let f = factor();
* return (keyId) => key * f;
* });
*
* let myMap = ObservableMap.create(myFunc);
* let inObs1 = ko.observable(2);
* let inObs2 = ko.observable(3);
*
* let outObs1 = myMap.add(inObs1);
* let outObs2 = myMap.add(inObs2);
* outObs1(); // 4
* outObs2(); // 6
*
* inObs1(5);
* outObs1(); // 10
*
* factor(3);
* outObs1(); // 15
* outObs2(); // 9
*
*
* @param {Function} mapFunc - Computed that returns a mapping function that takes in a key and
* returns a value. Whenever `mapFunc` is updated, all the current values in the map will be
* recalculated using the new function.
*/
function ObservableMap(mapFunc) {
this.store = new Map();
this.mapFunc = mapFunc;
// Recalculate all values on changes to mapFunc
let mapFuncSub = mapFunc.subscribe(() => {
this.updateAll();
});
// Disposes all stored observable and clears the map.
this.autoDisposeCallback(() => {
// Unsbuscribe from mapping function
mapFuncSub.dispose();
// Clear the store
this.store.forEach((val, key) => val.forEach(obj => obj.dispose()));
this.store.clear();
});
}
dispose.makeDisposable(ObservableMap);
/**
* Takes an observable for the key value and returns an observable for the output.
* Subscribes to the given observable so that whenever it changes the output observable is
* updated to the value returned by `mapFunc` when provided the new key as input.
* If user disposes of the returned observable, it will be removed from the map.
*
* @param {ko.observable} obsKey
* @return {ko.observble} Observable value equal to `mapFunc(obsKey())` that will be updated on
* updates to `obsKey` and `mapFunc`.
*/
ObservableMap.prototype.add = function (obsKey) {
let currKey = obsKey();
let ret = ko.observable(this.mapFunc()(currKey));
// Add to map
this._addKeyValue(currKey, ret);
// Subscribe to changes to key
let subs = obsKey.subscribe(newKey => {
ret(this.mapFunc()(newKey));
if (currKey !== newKey) {
// If the key changed, add it to the new bucket and delete from the old one
this._addKeyValue(newKey, ret);
this._delete(currKey, ret);
// And update the key
currKey = newKey;
}
});
ret.dispose = () => {
// On dispose, delete from map unless the whole map is being disposed
if (!this.isDisposed()) {
this._delete(currKey, ret);
}
subs.dispose();
};
return ret;
};
/**
* Returns the Set of observable values for the given key.
*/
ObservableMap.prototype.get = function (key) {
return this.store.get(key);
};
ObservableMap.prototype._addKeyValue = function (key, value) {
if (!this.store.has(key)) {
this.store.set(key, new Set([value]));
} else {
this.store.get(key).add(value);
}
};
/**
* Triggers an update for all keys.
*/
ObservableMap.prototype.updateAll = function () {
this.store.forEach((val, key) => this.updateKey(key));
};
/**
* Triggers an update for all observables for given keys in the map.
* @param {Array} keys
*/
ObservableMap.prototype.updateKeys = function (keys) {
keys.forEach(key => this.updateKey(key));
};
/**
* Triggers an update for all observables for the given key in the map.
* @param {Any} key
*/
ObservableMap.prototype.updateKey = function (key) {
if (this.store.has(key) && this.store.get(key).size > 0) {
this.store.get(key).forEach(obj => {
obj(this.mapFunc()(key));
});
}
};
/**
* Given a key and an observable, deletes the observable from that key's bucket.
*
* @param {Any} key - Current value of the key.
* @param {Any} obsValue - An observable previously returned by `add`.
*/
ObservableMap.prototype._delete = function (key, obsValue) {
if (this.store.has(key) && this.store.get(key).size > 0) {
this.store.get(key).delete(obsValue);
// Clean up empty buckets
if (this.store.get(key).size === 0) {
this.store.delete(key);
}
}
};
module.exports = ObservableMap;

View File

@@ -0,0 +1,74 @@
var _ = require('underscore');
var ko = require('knockout');
var dispose = require('./dispose');
/**
* An ObservableSet keeps track of a set of values whose membership is controlled by a boolean
* observable.
* @property {ko.observable<Number>} count: Count of items that are currently included.
*/
function ObservableSet() {
this._items = {};
this.count = ko.observable(0);
}
dispose.makeDisposable(ObservableSet);
/**
* Adds an item to keep track of. The value is added to the set whenever isIncluded observable is
* true. To stop keeping track of this item, call dispose() on the returned object.
*
* @param {ko.observable<Boolean>} isIncluded: observable for whether to include the value.
* @param {Object} value: Arbitrary value. May be omitted if you only care about the count.
* @return {Object} Object with dispose() method, which can be called to unsubscribe from
* isIncluded, and remove the value from the set.
*/
ObservableSet.prototype.add = function(isIncluded, value) {
var uniqueKey = _.uniqueId();
var sub = this.autoDispose(isIncluded.subscribe(function(include) {
if (include) {
this._add(uniqueKey, value);
} else {
this._remove(uniqueKey);
}
}, this));
if (isIncluded.peek()) {
this._add(uniqueKey, value);
}
return {
dispose: function() {
this._remove(uniqueKey);
this.disposeDiscard(sub);
}.bind(this)
};
};
/**
* Returns an array of all the values that are currently included in the set.
*/
ObservableSet.prototype.all = function() {
return _.values(this._items);
};
/**
* Internal helper to add a value to the set.
*/
ObservableSet.prototype._add = function(key, value) {
if (!this._items.hasOwnProperty(key)) {
this._items[key] = value;
this.count(this.count() + 1);
}
};
/**
* Internal helper to remove a value from the set.
*/
ObservableSet.prototype._remove = function(key) {
if (this._items.hasOwnProperty(key)) {
delete this._items[key];
this.count(this.count() - 1);
}
};
module.exports = ObservableSet;

View File

@@ -0,0 +1,331 @@
/**
* The SafeBrowser component implementation is responsible for executing the safeBrowser component
* of a plugin.
*
* A plugin's safeBrowser component is made of one main entry point (the javascript files declares
* in the manifest), html files and any ressources included by the html files (css, scripts, images
* ...). The main script is the main entry point which uses the Grist API to render the views,
* communicate with them en dispose them.
*
* The main script is executed within a WebWorker, and the html files are rendered within webviews
* if run within electron, or iframe in case of the browser.
*
* Communication between the main process and the views are handle with rpc.
*
* If the plugins includes as well an unsafeNode component or a safePython component and if one of
* them registers a function using the Grist Api, this function can then be called from within the
* safeBrowser main script using the Grist API, as described in `app/plugin/Grist.ts`.
*
* The grist API available to safeBrowser components is implemented in `app/plugin/PluginImpl.ts`.
*
* All the safeBrowser's component ressources, including the main script, the html files and any
* other ressources needed by the views, should be placed within one plugins' subfolder, and Grist
* should serve only this folder. However, this is not yet implemented and is left as a TODO, as of
* now the whole plugin's folder is served.
*
*/
// Todo: plugin ressources should not be made available on the server by default, but only after
// activation.
// tslint:disable:max-classes-per-file
import { ClientScope } from 'app/client/components/ClientScope';
import { get as getBrowserGlobals } from 'app/client/lib/browserGlobals';
import * as dom from 'app/client/lib/dom';
import * as Mousetrap from 'app/client/lib/Mousetrap';
import { ActionRouter } from 'app/common/ActionRouter';
import { BaseComponent, BaseLogger, createRpcLogger, PluginInstance, warnIfNotReady } from 'app/common/PluginInstance';
import { tbind } from 'app/common/tbind';
import { getOriginUrl } from 'app/common/urlUtils';
import { GristAPI, RPC_GRISTAPI_INTERFACE } from 'app/plugin/GristAPI';
import { RenderOptions, RenderTarget } from 'app/plugin/RenderOptions';
import { checkers } from 'app/plugin/TypeCheckers';
import { IpcMessageEvent, WebviewTag } from 'electron';
import { IMsgCustom, IMsgRpcCall, Rpc } from 'grain-rpc';
import { Disposable } from './dispose';
const G = getBrowserGlobals('document', 'window');
/**
* The SafeBrowser component implementation. Responsible for running the script, rendering the
* views, settings up communication channel.
*/
// todo: it is unfortunate that SafeBrowser had to expose both `renderImpl` and `disposeImpl` which
// really have no business outside of this module. What could be done, is to have an internal class
// ProcessManager which will be created by SafeBrowser as a private field. It will manage the
// client processes and among other thing will expose both renderImpl and
// disposeImpl. ClientProcess will hold a reference to ProcessManager instead of SafeBrowser.
export class SafeBrowser extends BaseComponent {
/**
* Create a webview ClientProcess to render safe browser process in electron.
*/
public static createWorker(safeBrowser: SafeBrowser, rpc: Rpc, src: string): WorkerProcess {
return new WorkerProcess(safeBrowser, rpc, src);
}
/**
* Create either an iframe or a webview ClientProcess depending on wether running electron or not.
*/
public static createView(safeBrowser: SafeBrowser, rpc: Rpc, src: string): ViewProcess {
return G.window.isRunningUnderElectron ?
new WebviewProcess(safeBrowser, rpc, src) :
new IframeProcess(safeBrowser, rpc, src);
}
// All view processes. This is not used anymore to dispose all processes on deactivation (this is
// now achieved using `this._mainProcess.autoDispose(...)`) but rather to be able to dispatch
// events to all processes (such as doc actions which will need soon).
private _viewProcesses: Map<number, ClientProcess> = new Map();
private _pluginId: string;
private _pluginRpc: Rpc;
private _mainProcess: WorkerProcess|undefined;
private _viewCount: number = 0;
constructor(
private _plugin: PluginInstance,
private _clientScope: ClientScope,
private _untrustedContentOrigin: string,
private _mainPath: string = "",
private _baseLogger: BaseLogger = console,
rpcLogger = createRpcLogger(_baseLogger, `PLUGIN ${_plugin.definition.id} SafeBrowser:`),
) {
super(_plugin.definition.manifest, rpcLogger);
this._pluginId = _plugin.definition.id;
this._pluginRpc = _plugin.rpc;
}
/**
* Render the file at path in an iframe or webview and returns its ViewProcess.
*/
public createViewProcess(path: string): ViewProcess {
return this._createViewProcess(path)[0];
}
/**
* `receiveAction` handles an action received from the server by forwarding it to the view processes.
*/
public receiveAction(action: any[]) {
for (const view of this._viewProcesses.values()) {
view.receiveAction(action);
}
}
/**
* Renders the file at path and returns its proc id. This is the SafeBrowser implementation for
* the GristAPI's render(...) method, more details can be found at app/plugin/GristAPI.ts.
*/
public async renderImpl(path: string, target: RenderTarget, options: RenderOptions): Promise<number> {
const [proc, viewId] = this._createViewProcess(path);
const renderFunc = this._plugin.getRenderTarget(target, options);
renderFunc(proc.element);
if (this._mainProcess) {
// Disposing the web worker should dispose all view processes that created using the
// gristAPI. There is a flaw here: please read [1].
this._mainProcess.autoDispose(proc);
}
return viewId;
// [1]: When a process, which is not owned by the mainProcess (ie: a process which was created
// using `public createViewProcess(...)'), creates a view process using the gristAPI, the
// rendered view will be owned by the main process. This is not correct and could cause views to
// suddently disappear from the screen. This is pretty nasty. But for following reason I think
// it's ok to leave it for now: (1) fixing this would require (yet) another refactoring of
// SafeBrowser and (2) at this point it is not sure wether we want to keep `render()` in the
// future (we could as well directly register contribution using files directly in the
// manifest), and (3) plugins are only developped by us, we only have to remember that using
// `render()` is only supported from within the main process (which cover all our use cases so
// far).
}
/**
* Dispose the process using it's proc id. This is the SafeBrowser implementation for the
* GristAPI's dispose(...) method, more details can be found at app/plugin/GristAPI.ts.
*/
public async disposeImpl(procId: number): Promise<void> {
const proc = this._viewProcesses.get(procId);
if (proc) {
this._viewProcesses.delete(procId);
proc.dispose();
}
}
protected doForwardCall(c: IMsgRpcCall): Promise<any> {
if (this._mainProcess) {
return this._mainProcess.rpc.forwardCall(c);
}
// should not happen.
throw new Error("Using SafeBrowser as an IForwarder requires a main script");
}
protected doForwardMessage(c: IMsgCustom): Promise<any> {
if (this._mainProcess) {
return this._mainProcess.rpc.forwardMessage(c);
}
// should not happen.
throw new Error("Using SafeBrowser as an IForwarder requires a main script");
}
protected async activateImplementation(): Promise<void> {
if (this._mainPath) {
const rpc = this._createRpc(this._mainPath);
const src = `plugins/${this._pluginId}/${this._mainPath}`;
// This SafeBrowser object is registered with _pluginRpc as _mainPath forwarder, and
// forwards calls to _mainProcess in doForward* methods (called from BaseComponent.forward*
// methods). Note that those calls are what triggers component activation.
this._mainProcess = SafeBrowser.createWorker(this, rpc, src);
}
}
protected async deactivateImplementation(): Promise<void> {
if (this._mainProcess) {
this._mainProcess.dispose();
}
}
/**
* Creates an iframe or a webview embedding the file at path. And adds it to `this._viewProcesses`
* using `viewId` as key, and registers it as forwarder to the `pluginRpc` using name
* `path`. Unregister both on disposal.
*/
private _createViewProcess(path: string): [ViewProcess, number] {
const rpc = this._createRpc(path);
const url = `${this._untrustedContentOrigin}/plugins/${this._plugin.definition.id}/${path}`
+ `?host=${G.window.location.origin}`;
const viewId = this._viewCount++;
const process = SafeBrowser.createView(this, rpc, url);
this._viewProcesses.set(viewId, process);
this._pluginRpc.registerForwarder(path, rpc);
process.autoDisposeCallback(() => {
this._pluginRpc.unregisterForwarder(path);
this._viewProcesses.delete(viewId);
});
return [process, viewId];
}
/**
* Create an rpc instance and set it up for communicating with a ClientProcess:
* - won't send any message before receiving a ready message
* - has the '*' forwarder set to the plugin's instance rpc
* - has registered an implementation of the gristAPI.
* Returns the rpc instance.
*/
private _createRpc(path: string): Rpc {
const rpc = new Rpc({logger: createRpcLogger(this._baseLogger, `PLUGIN ${this._pluginId}/${path} SafeBrowser:`) });
rpc.queueOutgoingUntilReadyMessage();
warnIfNotReady(rpc, 3000, "Plugin isn't ready; be sure to call grist.ready() from plugin");
rpc.registerForwarder('*', this._pluginRpc);
// TODO: we should be able to stop serving plugins, it looks like there are some resources
// required that should be disposed on component deactivation.
this._clientScope.servePlugin(this._pluginId, rpc);
return rpc;
}
}
/**
* Base class for any client process. `onDispose` allows to register a callback that will be
* triggered when dispose() is called. This is for internally use.
*/
export class ClientProcess extends Disposable {
public rpc: Rpc;
private _safeBrowser: SafeBrowser;
private _src: string;
private _actionRouter: ActionRouter;
public create(...args: any[]): void;
public create(safeBrowser: SafeBrowser, rpc: Rpc, src: string) {
this.rpc = rpc;
this._safeBrowser = safeBrowser;
this._src = src;
this._actionRouter = new ActionRouter(this.rpc);
const gristAPI: GristAPI = {
subscribe: tbind(this._actionRouter.subscribeTable, this._actionRouter),
unsubscribe: tbind(this._actionRouter.unsubscribeTable, this._actionRouter),
render: tbind(this._safeBrowser.renderImpl, this._safeBrowser),
dispose: tbind(this._safeBrowser.disposeImpl, this._safeBrowser),
};
rpc.registerImpl<GristAPI>(RPC_GRISTAPI_INTERFACE, gristAPI, checkers.GristAPI);
this.autoDisposeCallback(() => {
this.rpc.unregisterImpl(RPC_GRISTAPI_INTERFACE);
});
}
public receiveAction(action: any[]) {
this._actionRouter.process(action)
// tslint:disable:no-console
.catch((err: any) => console.warn("ClientProcess[%s] receiveAction: failed with %s", this._src, err));
}
}
/**
* The web worker client process, used to execute safe browser main script.
*/
class WorkerProcess extends ClientProcess {
public create(safeBrowser: SafeBrowser, rpc: Rpc, src: string) {
super.create(safeBrowser, rpc, src);
// Serve web worker script from same host as current page
const worker = new Worker(getOriginUrl(`/${src}`));
worker.addEventListener("message", (e: MessageEvent) => this.rpc.receiveMessage(e.data));
this.rpc.setSendMessage(worker.postMessage.bind(worker));
this.autoDisposeCallback(() => worker.terminate());
}
}
export class ViewProcess extends ClientProcess {
public element: HTMLElement;
}
/**
* The Iframe ClientProcess used to render safe browser content in the browser.
*/
class IframeProcess extends ViewProcess {
public create(safeBrowser: SafeBrowser, rpc: Rpc, src: string) {
super.create(safeBrowser, rpc, src);
const iframe = this.element = this.autoDispose(dom(`iframe.safe_browser_process.clipboard_focus`,
{ src }));
const listener = (event: MessageEvent) => {
if (event.source === iframe.contentWindow) {
this.rpc.receiveMessage(event.data);
}
};
G.window.addEventListener('message', listener);
this.autoDisposeCallback(() => {
G.window.removeEventListener('message', listener);
});
this.rpc.setSendMessage(msg => iframe.contentWindow!.postMessage(msg, '*'));
}
}
/**
* The webview ClientProcess to render safe browser process in electron.
*/
class WebviewProcess extends ViewProcess {
public create(safeBrowser: SafeBrowser, rpc: Rpc, src: string) {
super.create(safeBrowser, rpc, src);
const webview: WebviewTag = this.element = this.autoDispose(dom('webview.safe_browser_process.clipboard_focus', {
src,
allowpopups: '',
// Requests with this partition get an extra header (see main.js) to get access to plugin content.
partition: 'plugins',
}));
// Temporaily disable "mousetrap" keyboard stealing for the duration of this webview.
// This is acceptable since webviews are currently full-screen modals.
// TODO: find a way for keyboard events to play nice when webviews are non-modal.
Mousetrap.setPaused(true);
this.autoDisposeCallback(() => Mousetrap.setPaused(false));
webview.addEventListener('ipc-message', (event: IpcMessageEvent) => {
// The event object passed to the listener is missing proper documentation. In the examples
// listed in https://electronjs.org/docs/api/ipc-main the arguments should be passed to the
// listener after the event object, but this is not happening here. Only we know it is a
// DOMEvent with some extra porperties including a `channel` property of type `string` and an
// `args` property of type `any[]`.
if (event.channel === 'grist') {
rpc.receiveMessage(event.args[0]);
}
});
this.rpc.setSendMessage(msg => webview.send('grist', msg));
}
}

View File

@@ -0,0 +1,12 @@
.plugin_instance_fullscreen {
position: absolute;
width: 100%;
height: 100%;
left: 0;
top: 0;
z-index:9999;
}
.safe_browser_process{
border: none;
}

View File

@@ -0,0 +1,239 @@
/**
* Implements an autocomplete dropdown.
*/
import {createPopper, Instance as Popper, Modifier, Options as PopperOptions} from '@popperjs/core';
import {ACItem, ACResults, HighlightFunc} from 'app/client/lib/ACIndex';
import {reportError} from 'app/client/models/errors';
import {Disposable, dom, EventCB, IDisposable} from 'grainjs';
import {obsArray, onKeyElem, styled} from 'grainjs';
import merge = require('lodash/merge');
import maxSize from 'popper-max-size-modifier';
import {cssMenu} from 'popweasel';
export interface IAutocompleteOptions<Item extends ACItem> {
// If provided, applies the css class to the menu container. Could be multiple, space-separated.
menuCssClass?: string;
// A single class name to add for the selected item, or 'selected' by default.
selectedCssClass?: string;
// Popper options for positioning the popup.
popperOptions?: Partial<PopperOptions>;
// Given a search term, return the list of Items to render.
search(searchText: string): Promise<ACResults<Item>>;
// Function to render a single item.
renderItem(item: Item, highlightFunc: HighlightFunc): HTMLElement;
// Get text for the text input for a selected item, i.e. the text to present to the user.
getItemText(item: Item): string;
// A callback triggered when user clicks one of the choices.
onClick?(): void;
}
/**
* An instance of an open Autocomplete dropdown.
*/
export class Autocomplete<Item extends ACItem> extends Disposable {
// The UL element containing the actual menu items.
protected _menuContent: HTMLElement;
// Index into _items as well as into _menuContent, -1 if nothing selected.
protected _selectedIndex: number = -1;
// Currently selected element.
protected _selected: HTMLElement|null = null;
private _popper: Popper;
private _mouseOver: {reset(): void};
private _lastAsTyped: string;
private _items = this.autoDispose(obsArray<Item>([]));
private _highlightFunc: HighlightFunc;
constructor(
private _triggerElem: HTMLInputElement | HTMLTextAreaElement,
private readonly options: IAutocompleteOptions<Item>,
) {
super();
const content = cssMenuWrap(
this._menuContent = cssMenu({class: options.menuCssClass || ''},
dom.forEach(this._items, (item) => options.renderItem(item, this._highlightFunc)),
dom.style('min-width', _triggerElem.getBoundingClientRect().width + 'px'),
dom.on('mouseleave', (ev) => this._setSelected(-1, true)),
dom.on('click', (ev) => {
this._setSelected(this._findTargetItem(ev.target), true);
if (options.onClick) { options.onClick(); }
})
),
// Prevent trigger element from being blurred on click.
dom.on('mousedown', (ev) => ev.preventDefault()),
);
this._mouseOver = attachMouseOverOnMove(this._menuContent,
(ev) => this._setSelected(this._findTargetItem(ev.target), true));
// Add key handlers to the trigger element as well as the menu if it is an input.
this.autoDispose(onKeyElem(_triggerElem, 'keydown', {
ArrowDown: () => this._setSelected(this._getNext(1), true),
ArrowUp: () => this._setSelected(this._getNext(-1), true),
}));
// Keeps track of the last value as typed by the user.
this.search();
this.autoDispose(dom.onElem(_triggerElem, 'input', () => this.search()));
// Attach the content to the page.
document.body.appendChild(content);
this.onDispose(() => { dom.domDispose(content); content.remove(); });
// Prepare and create the Popper instance, which places the content according to the options.
const popperOptions = merge({}, defaultPopperOptions, options.popperOptions);
this._popper = createPopper(_triggerElem, content, popperOptions);
this.onDispose(() => this._popper.destroy());
}
public getSelectedItem(): Item|undefined {
return this._items.get()[this._selectedIndex];
}
public search(findMatch?: (items: Item[]) => number) {
this._updateChoices(this._triggerElem.value, findMatch).catch(reportError);
}
// When the selected element changes, update the classes of the formerly and newly-selected
// elements and optionally update the text input.
private _setSelected(index: number, updateValue: boolean) {
const elem = (this._menuContent.children[index] as HTMLElement) || null;
const prev = this._selected;
if (elem !== prev) {
const clsName = this.options.selectedCssClass || 'selected';
if (prev) { prev.classList.remove(clsName); }
if (elem) {
elem.classList.add(clsName);
elem.scrollIntoView({block: 'nearest'});
}
}
this._selected = elem;
this._selectedIndex = elem ? index : -1;
if (updateValue) {
// Update trigger's value with the selected choice, or else with the last typed value.
if (elem) {
this._triggerElem.value = this.options.getItemText(this.getSelectedItem()!);
} else {
this._triggerElem.value = this._lastAsTyped;
}
}
}
private _findTargetItem(target: EventTarget|null): number {
// Find immediate child of this._menuContent which is an ancestor of ev.target.
const elem = findAncestorChild(this._menuContent, target as Element|null);
return Array.prototype.indexOf.call(this._menuContent.children, elem);
}
private _getNext(step: 1 | -1): number {
// Pretend there is an extra element at the end to mean "nothing selected".
const xsize = this._items.get().length + 1;
const next = (this._selectedIndex + step + xsize) % xsize;
return (next === xsize - 1) ? -1 : next;
}
private async _updateChoices(inputVal: string, findMatch?: (items: Item[]) => number): Promise<void> {
this._lastAsTyped = inputVal;
// TODO We should perhaps debounce the search() call in some clever way, to avoid unnecessary
// searches while typing. Today, search() is synchronous in practice, so it doesn't matter.
const acResults = await this.options.search(inputVal);
this._highlightFunc = acResults.highlightFunc;
this._items.set(acResults.items);
// Plain update() (which is deferred) may be better, but if _setSelected() causes scrolling
// before the positions are updated, it causes the entire page to scroll horizontally.
this._popper.forceUpdate();
this._mouseOver.reset();
let index: number;
if (findMatch) {
index = findMatch(this._items.get());
} else {
index = inputVal ? acResults.selectIndex : -1;
}
this._setSelected(index, false);
}
}
// The maxSize modifiers follow recommendations at https://www.npmjs.com/package/popper-max-size-modifier
const calcMaxSize = {
...maxSize,
options: {padding: 4},
};
const applyMaxSize: Modifier<any, any> = {
name: 'applyMaxSize',
enabled: true,
phase: 'beforeWrite',
requires: ['maxSize'],
fn({state}: any) {
// The `maxSize` modifier provides this data
const {height} = state.modifiersData.maxSize;
Object.assign(state.styles.popper, {
maxHeight: `${Math.max(160, height)}px`
});
}
};
export const defaultPopperOptions: Partial<PopperOptions> = {
placement: 'bottom-start',
modifiers: [
calcMaxSize,
applyMaxSize,
{name: "computeStyles", options: {gpuAcceleration: false}},
],
};
/**
* Helper function which returns the direct child of ancestor which is an ancestor of elem, or
* null if elem is not a descendant of ancestor.
*/
function findAncestorChild(ancestor: Element, elem: Element|null): Element|null {
while (elem && elem.parentElement !== ancestor) {
elem = elem.parentElement;
}
return elem;
}
/**
* A version of dom.onElem('mouseover') that doesn't start firing until there is first a 'mousemove'.
* This way if an element is created under the mouse cursor (triggered by the keyboard, for
* instance) it's not immediately highlighted, but only when a user moves the mouse.
* Returns an object with a reset() method, which restarts the wait for mousemove.
*/
function attachMouseOverOnMove<T extends EventTarget>(elem: T, callback: EventCB<MouseEvent, T>) {
let lis: IDisposable|undefined;
function setListener(eventType: 'mouseover'|'mousemove', cb: EventCB<MouseEvent, T>) {
if (lis) { lis.dispose(); }
lis = dom.onElem(elem, eventType, cb);
}
function reset() {
setListener('mousemove', (ev, _elem) => {
setListener('mouseover', callback);
callback(ev, _elem);
});
}
reset();
return {reset};
}
const cssMenuWrap = styled('div', `
position: absolute;
display: flex;
flex-direction: column;
outline: none;
`);

View File

@@ -0,0 +1,54 @@
/**
* Module that allows client-side code to use browser globals (such as `document` or `Node`) in a
* way that allows those globals to be replaced by mocks in browser-less tests.
*
* E.g. test/client/clientUtil.js can replace globals with those provided by jsdom.
*/
var allGlobals = [];
/* global window */
var globalVars = (typeof window !== 'undefined' ? window : {});
/**
* Usage: to get access to global variables `foo` and `bar`, call:
* var G = require('browserGlobals').get('foo', 'bar');
* and use G.foo and G.bar.
*
* This modules stores a reference to G, so that setGlobals() call can replace the values to which
* G.foo and G.bar refer.
*/
function get(varArgNames) {
var obj = {
neededNames: Array.prototype.slice.call(arguments),
globals: {}
};
updateGlobals(obj);
allGlobals.push(obj);
return obj.globals;
}
exports.get = get;
/**
* Internal helper which updates properties of all globals objects created with get().
*/
function updateGlobals(obj) {
obj.neededNames.forEach(function(key) {
obj.globals[key] = globalVars[key];
});
}
/**
* Replace globals with those from the given object. The previous mapping of global values is
* returned, so that it can be restored later.
*/
function setGlobals(globals) {
var oldVars = globalVars;
globalVars = globals;
allGlobals.forEach(function(obj) {
updateGlobals(obj);
});
return oldVars;
}
exports.setGlobals = setGlobals;

View File

@@ -0,0 +1,13 @@
import * as Bowser from "bowser"; // TypeScript
let parser: Bowser.Parser.Parser|undefined;
function getParser() {
return parser || (parser = Bowser.getParser(window.navigator.userAgent));
}
// Returns whether the browser we are in is a desktop browser.
export function isDesktop() {
const platformType = getParser().getPlatformType();
return (!platformType || platformType === 'desktop');
}

View File

@@ -0,0 +1,20 @@
import {typedCompare} from 'app/common/SortFunc';
import {Datum} from 'plotly.js';
/**
* Sort all values in a list of series according to the values in the first one.
*/
export function sortByXValues(series: Array<{values: Datum[]}>): void {
// The order of points matters for graph types that connect points with lines: the lines are
// drawn in order in which the points appear in the data. For the chart types we support, it
// only makes sense to keep the points sorted. (The only downside is that Grist line charts can
// no longer produce arbitrary line drawings.)
if (!series[0]) { return; }
const xValues = series[0].values;
const indices = xValues.map((val, i) => i);
indices.sort((a, b) => typedCompare(xValues[a], xValues[b]));
for (const s of series) {
const values = s.values;
s.values = indices.map((i) => values[i]);
}
}

View File

@@ -0,0 +1,38 @@
import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals';
const G = getBrowserGlobals('document', 'window');
/**
* Copy some text to the clipboard, by hook or by crook.
*/
export async function copyToClipboard(txt: string) {
// If present and we have permission to use it, the navigator.clipboard interface
// is convenient. This method works in non-headless tests, and regular chrome
// and firefox.
if (G.window.navigator && G.window.navigator.clipboard && G.window.navigator.clipboard.writeText) {
try {
await G.window.navigator.clipboard.writeText(txt);
return;
} catch (e) {
// no joy, try another way.
}
}
// Otherwise fall back on document.execCommand('copy'), which requires text in
// the dom to be selected. Implementation here based on:
// https://hackernoon.com/copying-text-to-clipboard-with-javascript-df4d4988697f
// This fallback takes effect at least in headless tests, and in Safari.
const stash = G.document.createElement('textarea');
stash.value = txt;
stash.setAttribute('readonly', '');
stash.style.position = 'absolute';
stash.style.left = '-10000px';
G.document.body.appendChild(stash);
const selection = G.document.getSelection().rangeCount > 0 && G.document.getSelection().getRangeAt(0);
stash.select();
G.document.execCommand('copy');
G.document.body.removeChild(stash);
if (selection) {
G.document.getSelection().removeAllRanges();
G.document.getSelection().addRange(selection);
}
}

16
app/client/lib/dispose.d.ts vendored Normal file
View File

@@ -0,0 +1,16 @@
// TODO: add remaining Disposable methode
export abstract class Disposable {
public static create<T extends new (...args: any[]) => any>(
this: T, ...args: ConstructorParameters<T>): InstanceType<T>;
constructor(...args: any[]);
public dispose(): void;
public isDisposed(): boolean;
public autoDispose<T>(obj: T): T;
public autoDisposeCallback(callback: () => void): void;
public disposeRelease<T>(obj: T): T;
public disposeDiscard(obj: any): void;
public makeDisposable(obj: any): void;
}
export function emptyNode(node: Node): void;

369
app/client/lib/dispose.js Normal file
View File

@@ -0,0 +1,369 @@
/**
* dispose.js provides tools to components that needs to dispose of resources, such as
* destroy DOM, and unsubscribe from events. The motivation with examples is presented here:
*
* https://phab.getgrist.com/w/disposal/
*/
var ko = require('knockout');
var util = require('util');
var _ = require("underscore");
// Use the browser globals in a way that allows replacing them with mocks in tests.
var G = require('./browserGlobals').get('DocumentFragment', 'Node');
/**
* Disposable is a base class for components that need cleanup (e.g. maintain DOM, listen to
* events, subscribe to anything). It provides a .dispose() method that should be called to
* destroy the component, and .autoDispose() method that the component should use to take
* responsibility for other pieces that require cleanup.
*
* To define a disposable prototype:
* function Foo() { ... }
* dispose.makeDisposable(Foo);
*
* To define a disposable ES6 class:
* class Foo extends dispose.Disposable { create() {...} }
*
* NB: Foo should not have its construction logic in a constructor but in a `create` method
* instead. If Foo defines a constructor (for taking advantage of type checking) the constructor
* should only call super `super(arg1, arg2 ...)`. Any way calling `new Foo(...args)` safely
* construct the component.
*
* In Foo's constructor or methods, take ownership of other objects:
* this.bar = this.autoDispose(Bar.create(...));
* The argument will get disposed when `this` is disposed. If it's a DOM node, it will get removed
* using ko.removeNode(). If it has a `dispose` method, it will be called.
*
* For more customized disposal:
* this.baz = this.autoDisposeWith('destroy', new Baz());
* this.elem = this.autoDisposeWith(ko.cleanNode, document.createElement(...));
* When `this` is disposed, will call this.baz.destroy(), and ko.cleanNode(this.elem).
*
* To call another method on disposal (e.g. to add custom disposal logic):
* this.autoDisposeCallback(this.myUnsubscribeAllMethod);
* The method will be called with `this` as context, and no arguments.
*
* To create Foo:
* var foo = Foo.create(args...);
* `Foo.create` ensures that if the constructor throws an exception, any calls to .autoDispose()
* that happened before that are honored.
*
* To dispose of Foo:
* foo.dispose();
* Owned objects will be disposed in reverse order from which `autoDispose` were called. Note that
* `foo` is no longer usable afterwards, and all its properties are wiped.
* If Foo has a `stopListening` method (e.g. inherits from Backbone.Events), `dispose` will call
* it automatically, as if it were added with `this.autoDisposeCallback(this.stopListening)`.
*
* To release an owned object:
* this.disposeRelease(this.bar);
*
* To dispose of an owned object early:
* this.disposeDiscard(this.bar);
*
* To determine if a reference refers to object that has already been disposed:
* foo.isDisposed()
*/
class Disposable {
/**
* A safe constructor which calls dispose() in case the creation throws an exception.
*/
constructor(...args) {
safelyConstruct(this.create, this, args);
}
/**
* Static method to allow rewriting old classes into ES6 without modifying their
* instantiation to use `new Foo()` (i.e. you can continue to use `Foo.create()`).
*/
static create(...args) {
return new this(...args);
}
}
Object.assign(Disposable.prototype, {
/**
* Take ownership of `obj`, and dispose it when `this.dispose` is called.
* @param {Object} obj: Object to take ownership of. It can be a DOM node or an object with a
* `dispose` method.
* @returns {Object} obj
*/
autoDispose: function(obj) {
return this.autoDisposeWith(defaultDisposer, obj);
},
/**
* As for autoDispose, but we receive a promise of an object. We wait for it to
* resolve and then take ownership of it. We return a promise that resolves to
* the object, or to null if the owner is disposed in the meantime.
*/
autoDisposePromise: function(objPromise) {
return objPromise.then(obj => {
if (this.isDisposed()) {
defaultDisposer(obj);
return null;
}
this.autoDispose(obj);
return obj;
});
},
/**
* Take ownership of `obj`, and dispose it when `this.dispose` is called by calling the
* specified function.
* @param {Function|String} disposer: If a function, disposer(obj) will be called to dispose the
* object, with `this` as the context. If a string, then obj[disposer]() will be called. E.g.
* this.autoDisposeWith('destroy', a); // will call a.destroy()
* this.autoDisposeWith(ko.cleanNode, b); // will call ko.cleanNode(b)
* @param {Object} obj: Object to take ownership of, on which `disposer` will be called.
* @returns {Object} obj
*/
autoDisposeWith: function(disposer, obj) {
var list = this._disposalList || (this._disposalList = []);
list.push({ obj: obj,
disposer: typeof disposer === 'string' ? methodDisposer(disposer) : disposer });
return obj;
},
/**
* Adds the given callback to be called when `this.dispose` is called.
* @param {Function} callback: Called on disposal with `this` as the context and no arguments.
* @returns nothing
*/
autoDisposeCallback: function(callback) {
this.autoDisposeWith(callFuncHelper, callback);
},
/**
* Remove `obj` from the list of owned objects; it will not be disposed on `this.dispose`.
* @param {Object} obj: Object to release.
* @returns {Object} obj
*/
disposeRelease: function(obj) {
removeObjectToDispose(this._disposalList, obj);
return obj;
},
/**
* Dispose of an owned object `obj` now, and remove it from the list of owned objects.
* @param {Object} obj: Object to release.
* @returns nothing
*/
disposeDiscard: function(obj) {
var entry = removeObjectToDispose(this._disposalList, obj);
if (entry) {
entry.disposer.call(this, obj);
}
},
/**
* Returns whether this object has already been disposed.
*/
isDisposed: function() {
return this._disposalList === WIPED_VALUE;
},
/**
* Clean up `this` by disposing of all owned objects, and calling `stopListening()` if defined.
*/
dispose: function() {
if (this.isDisposed()) {
return;
}
var disposalList = this._disposalList;
this._disposalList = WIPED_VALUE; // This makes isDisposed() true.
if (disposalList) {
// Go backwards through the disposal list, and dispose of everything.
for (var i = disposalList.length - 1; i >= 0; i--) {
var entry = disposalList[i];
disposeHelper(this, entry.disposer, entry.obj);
}
}
// Call stopListening if it exists. This is a convenience when using Backbone.Events. It's
// equivalent to calling this.autoDisposeCallback(this.stopListening) in constructor.
if (typeof this.stopListening === 'function') {
// Wrap in disposeHelper so that errors get caught.
disposeHelper(this, callFuncHelper, this.stopListening);
}
// Finish by wiping out the object, since nothing should use it after dispose().
// See https://phab.getgrist.com/w/disposal/ for more motivation.
wipeOutObject(this);
}
});
exports.Disposable = Disposable;
/**
* The recommended way to make an object disposable. It simply adds the methods of `Disposable` to
* its prototype, and also adds a `Class.create()` function, for a safer way to construct objects
* (see `safeCreate` for explanation). For instance,
* function Foo(args...) {...}
* dispose.makeDisposable(Foo);
* Now you can create Foo objects with:
* var foo = Foo.create(args...);
* And dispose of them with:
* foo.dispose();
*/
function makeDisposable(Constructor) {
Object.assign(Constructor.prototype, Disposable.prototype);
Constructor.create = safeConstructor;
}
exports.makeDisposable = makeDisposable;
/**
* Helper to create and construct an object safely: `safeCreate(Foo, ...)` is similar to `new
* Foo(...)`. The difference is that in case of an exception in the constructor, the dispose()
* method will be called on the partially constructed object.
* If you call makeDisposable(Foo), then Foo.create(...) is equivalent and more convenient.
* @returns {Object} the newly constructed object.
*/
function safeCreate(Constructor, varArgs) {
return safeConstructor.apply(Constructor, Array.prototype.slice.call(arguments, 1));
}
exports.safeCreate = safeCreate;
/**
* Helper used by makeDisposable() for the `create` property of a disposable class. E.g. when
* assigned to Foo.create, the call `Foo.create(args)` becomes similar to `new Foo(args)`, but
* calls dispose() in case the constructor throws an exception.
*/
var safeConstructor = function(varArgs) {
var Constructor = this;
var obj = Object.create(Constructor.prototype);
return safelyConstruct(Constructor, obj, arguments);
};
var safelyConstruct = function(Constructor, obj, args) {
try {
Constructor.apply(obj, args);
return obj;
} catch (e) {
// Be a bit more helpful and concise in reporting errors: print error as an object (that
// includes its stacktrace in FF and Chrome), and avoid printing it multiple times as it
// bubbles up through the stack of safeConstructor calls.
if (!e.printed) {
let name = obj.constructor.name || Constructor.name;
console.error("Error constructing %s:", name, e);
// assigning printed to a string throws: TypeError: Cannot create property 'printed' on [...]
if (_.isObject(e)) {
e.printed = true;
}
}
obj.dispose();
throw e;
}
};
// It doesn't matter what the value is, but some values cause more helpful errors than others.
// E.g. if x = "disposed", then x.foo() throws "undefined is not a function", while when x = null,
// x.foo() throws "Cannot read property 'foo' of null", which seems more helpful.
var WIPED_VALUE = null;
/**
* Wipe out the given object by setting each property to a dummy value. This is helpful for
* objects that are disposed and should be ready to be garbage-collected. The goals are:
* - If anything still refers to the object and uses it, we'll get an early error, rather than
* silently keep going, potentially doing useless work (or worse) and wasting resources.
* - If anything still refers to the object but doesn't use it, the fields of the object can
* still be garbage-collected.
* - If there are circular references between the object and its properties, they get broken,
* making the job easier for the garbage collector.
*/
function wipeOutObject(obj) {
for (var k in obj) {
if (obj.hasOwnProperty(k)) {
obj[k] = WIPED_VALUE;
}
}
}
/**
* Internal helper used by disposeDiscard() and disposeRelease(). It finds, removes, and returns
* an entry from the given disposalList.
*/
function removeObjectToDispose(disposalList, obj) {
if (disposalList) {
for (var i = 0; i < disposalList.length; i++) {
if (disposalList[i].obj === obj) {
var entry = disposalList[i];
disposalList.splice(i, 1);
return entry;
}
}
}
return null;
}
/**
* Internal helper to allow adding cleanup callbacks to the disposalList. It acts as the
* "disposer" for callback, by simply calling them with the same context that it is called with.
*/
var callFuncHelper = function(callback) {
callback.call(this);
};
/**
* Internal helper to dispose objects that need a differently-named method to be called on them.
* It's used by `autoDisposeWith` when the disposer is a string method name.
*/
function methodDisposer(methodName) {
return function(obj) {
obj[methodName]();
};
}
/**
* Internal helper to call a disposer on an object. It swallows errors (but reports them) to make
* sure that when we dispose of an object, an error in disposing of one owned part doesn't stop
* the disposal of the other parts.
*/
function disposeHelper(owner, disposer, obj) {
try {
disposer.call(owner, obj);
} catch (e) {
console.error("While disposing %s, error disposing %s: %s",
describe(owner), describe(obj), e);
}
}
/**
* Helper for reporting errors during disposal. Try to report the type of the object.
*/
function describe(obj) {
return (obj && obj.constructor && obj.constructor.name ? obj.constructor.name :
util.inspect(obj, {depth: 1}));
}
/**
* Internal helper that implements the default disposal for an object. It just supports removing
* DOM nodes with ko.removeNode, and calling dispose() on any part that has a `dispose` method.
*/
function defaultDisposer(obj) {
if (obj instanceof G.Node) {
// This does both knockout- and jquery-related cleaning, and removes the node from the DOM.
ko.removeNode(obj);
} else if (typeof obj.dispose === 'function') {
obj.dispose();
} else {
throw new Error("Object has no 'dispose' method");
}
}
/**
* Removes all children of the given node, and all knockout bindings. You can use it as
* this.autoDisposeWith(dispose.emptyNode, node);
*/
function emptyNode(node) {
ko.virtualElements.emptyNode(node);
ko.cleanNode(node);
}
exports.emptyNode = emptyNode;

453
app/client/lib/dom.js Normal file
View File

@@ -0,0 +1,453 @@
// Builds a DOM tree or document fragment, easily.
//
// Usage:
// dom('a#link.c1.c2', {href:url}, 'Hello ', dom('span', 'world'));
// creates Node <a id="link" class="c1 c2" href={{url}}>Hello <span>world</span></a>.
// dom.frag(dom('span', 'Hello'), ['blah', dom('div', 'world')])
// creates document fragment with <span>Hello</span>blah<div>world</div>.
//
// Arrays among child arguments get flattened. Objects are turned into attributes.
//
// If an argument is a function it will be called with elem as the argument,
// which may be a convenient way to modify elements, set styles, or attach events.
var ko = require('knockout');
/**
* Use the browser globals in a way that allows replacing them with mocks in tests.
*/
var G = require('./browserGlobals').get('document', 'Node', '$', 'window');
/**
* dom('tag#id.class1.class2' | Node, other args)
* The first argument is typically a string consisting of a tag name, with optional #foo suffix
* to add the ID 'foo', and zero or more .bar suffixes to add a css class 'bar'. If the first
* argument is a Node, that node is used for subsequent changes without creating a new one.
*
* The rest of the arguments are optional and may be:
*
* Nodes - which become children of the created element;
* strings - which become text node children;
* objects - of the form {attr: val} to set additional attributes on the element;
* Arrays of Nodes - which are flattened out and become children of the created element;
* functions - which are called with elem as the argument, for a chance to modify the
* element as it's being created. When functions return values (other than undefined),
* these return values get applied to the containing element recursively.
*/
function dom(firstArg, ...args) {
let elem;
if (firstArg instanceof G.Node) {
elem = firstArg;
} else {
elem = createElemFromString(firstArg, createDOMElement);
}
return handleChildren(elem, arguments, 1);
}
/**
* dom.svg('tag#id.class1.class2', other args) behaves much like `dom`, but does not accept Node
* as a first argument--only a tag string. Because SVG elements are created in a different
* namespace, `dom.svg` should be used for creating SVG elements such as `polygon`.
*/
dom.svg = function (firstArg, ...args) {
let elem = createElemFromString(firstArg, createSVGElement);
return handleChildren(elem, arguments, 1);
};
/**
* Given a tag string of the form 'tag#id.class1.class2' and an element creator function, returns
* a new tag element with the id and classes properly set.
*/
function createElemFromString(tagString, elemCreator) {
// We do careful hand-written parsing rather than use a regexp for speed. Using a regexp is
// significantly more expensive.
let tag, id, classes;
let dotPos = tagString.indexOf(".");
let hashPos = tagString.indexOf('#');
if (dotPos === -1) {
dotPos = tagString.length;
} else {
classes = tagString.substring(dotPos + 1).replace(/\./g, ' ');
}
if (hashPos === -1) {
tag = tagString.substring(0, dotPos);
} else if (hashPos > dotPos) {
throw new Error('ID must come before classes in dom("' + tagString + '")');
} else {
tag = tagString.substring(0, hashPos);
id = tagString.substring(hashPos + 1, dotPos);
}
let elem = elemCreator(tag);
if (id) { elem.setAttribute('id', id); }
if (classes) { elem.setAttribute('class', classes); }
return elem;
}
function createDOMElement(tagName) {
return G.document.createElement(tagName);
}
function createSVGElement(tagName) {
return G.document.createElementNS('http://www.w3.org/2000/svg', tagName);
}
// Append the rest of the arguments as children, flattening arrays
function handleChildren(elem, children, index) {
for (var i = index, len = children.length; i < len; i++) {
var child = children[i];
if (Array.isArray(child)) {
child = handleChildren(elem, child, 0);
} else if (typeof child == 'function') {
child = child(elem);
if (typeof child !== 'undefined') {
handleChildren(elem, [child], 0);
}
} else if (child === null || child === void 0) {
// nothing
} else if (child instanceof G.Node) {
elem.appendChild(child);
} else if (typeof child === 'object') {
for (var key in child) {
elem.setAttribute(key, child[key]);
}
} else {
elem.appendChild(G.document.createTextNode(child));
}
}
return elem;
}
/**
* Creates a DocumentFragment consisting of all arguments, flattening any arguments that are
* arrays. If any arguments or array elements are strings, those are turned into text nodes.
* All argument types supported by the dom() function are supported by dom.frag() as well.
*/
dom.frag = function(varArgNodes) {
var elem = G.document.createDocumentFragment();
return handleChildren(elem, arguments, 0);
};
/**
* Forward all or some arguments to the dom() call. E.g.
*
* dom(a, b, c, dom.fwdArgs(arguments, 2));
*
* is equivalent to:
*
* dom(a, b, c, arguments[2], arguments[3], arguments[4], ...)
*
* It is very convenient to use in other functions which want to accept arbitrary arguments for
* dom() and forward them. See koForm.js for many examples.
*
* @param {Array|Arguments} args: Array or Arguments object containing arguments to forward.
* @param {Number} startIndex: The index of the first element to forward.
*/
dom.fwdArgs = function(args, startIndex) {
return function(elem) {
handleChildren(elem, args, startIndex);
};
};
/**
* Wraps the given function to make it easy to use as an argument to dom(). The passed-in function
* must take a DOM Node as the first argument, and the returned wrapped function may be called
* without this argument when used as an argument to dom(), in which case the original function
* will be called with the element being constructed.
*
* For example, if we define:
* foo.method = dom.inlinable(function(elem, a, b) { ... });
* then the call
* dom('div', foo.method(1, 2))
* translates to
* dom('div', function(elem) { foo.method(elem, 1, 2); })
* which causes foo.method(elem, 1, 2) to be called with elem set to the DIV being constructed.
*
* When the first argument is a DOM Node, calls to the wrapped function proceed as usual. In both
* cases, `this` context is passed along to the wrapped function as expected.
*/
dom.inlinable = dom.inlineable = function inlinable(func) {
return function(optElem) {
if (optElem instanceof G.Node) {
return func.apply(this, arguments);
} else {
return wrapInlinable(func, this, arguments);
}
};
};
function wrapInlinable(func, context, args) {
// The switch is an optimization which speeds things up substantially.
switch (args.length) {
case 0: return function(elem) { return func.call(context, elem); };
case 1: return function(elem) { return func.call(context, elem, args[0]); };
case 2: return function(elem) { return func.call(context, elem, args[0], args[1]); };
case 3: return function(elem) { return func.call(context, elem, args[0], args[1], args[2]); };
}
return function(elem) {
Array.prototype.unshift.call(args, elem);
return func.apply(context, args);
};
}
/**
* Shortcut for document.getElementById.
*/
dom.id = function(id) {
return G.document.getElementById(id);
};
/**
* Hides the given element. Can be passed into dom(), e.g. dom('div', dom.hide, ...).
*/
dom.hide = function(elem) {
elem.style.display = 'none';
};
/**
* Shows the given element, assuming that it's not hidden by a class.
*/
dom.show = function(elem) {
elem.style.display = '';
};
/**
* Toggles the given element, assuming that it's not hidden by a class. The second argument is
* optional, and if provided will make toggle() behave as either show() or hide().
* @returns {Boolean} Whether the element is visible after toggle.
*/
dom.toggle = function(elem, optYesNo) {
if (optYesNo === undefined)
optYesNo = (elem.style.display === 'none');
elem.style.display = optYesNo ? '' : 'none';
return elem.style.display !== 'none';
};
/**
* Set the given className on the element while it is being dragged over.
* Can be used inlined as in `dom(..., dom.dragOverClass('foo'))`.
* @param {String} className: Class name to set while a drag-over is in progress.
*/
dom.dragOverClass = dom.inlinable(function(elem, className) {
// Note: This is hard to get correct on both FF and Chrome because of dragenter/dragleave events that
// occur for contained elements. See
// http://stackoverflow.com/questions/7110353/html5-dragleave-fired-when-hovering-a-child-element.
// Here we use a reference count, and filter out duplicate dragenter events on the same target.
let counter = 0;
let lastTarget = null;
dom.on(elem, 'dragenter', ev => {
if (Array.from(ev.originalEvent.dataTransfer.types).includes('text/html')) {
// This would not be present when dragging in an actual file. We return undefined, to avoid
// suppressing normal behavior (which is suppressed below when we return false).
return;
}
if (ev.target !== lastTarget) {
lastTarget = ev.target;
ev.originalEvent.dataTransfer.dropEffect = 'copy';
if (!counter) { elem.classList.add(className); }
counter++;
}
return false;
});
dom.on(elem, 'dragleave', () => {
lastTarget = null;
counter = Math.max(0, counter - 1);
if (!counter) { elem.classList.remove(className); }
});
dom.on(elem, 'drop', () => {
lastTarget = null;
counter = 0;
elem.classList.remove(className);
});
});
/**
* Change a Node's childNodes similarly to Array splice. This allows removing and adding nodes.
* It translates to calls to replaceChild, insertBefore, removeChild, and appendChild, as
* appropriate.
* @param {Number} index Index at which to start changing the array.
* @param {Number} howMany Number of old array elements to remove or replace.
* @param {Node} optNewChildren This is an optional parameter specifying a new node to insert,
* and may be repeated to insert multiple nodes. Null values are ignored.
* @returns {Array[Node]} array of removed nodes.
* TODO: this desperately needs a unittest.
*/
dom.splice = function(node, index, howMany, optNewChildren) {
var end = Math.min(index + howMany, node.childNodes.length);
for (var i = 3; i < arguments.length; i++) {
if (arguments[i] !== null) {
if (index < end) {
node.replaceChild(arguments[i], node.childNodes[index]);
index++;
} else if (index < node.childNodes.length) {
node.insertBefore(arguments[i], node.childNodes[index]);
} else {
node.appendChild(arguments[i]);
}
}
}
var ret = Array.prototype.slice.call(node.childNodes, index, end);
while (end > index) {
node.removeChild(node.childNodes[--end]);
}
return ret;
};
/**
* Returns the index of the given node among its parent's children (i.e. its siblings).
*/
dom.childIndex = function(node) {
return Array.prototype.indexOf.call(node.parentNode.childNodes, node);
};
function makeFilterFunc(selectorOrFunc) {
if (typeof selectorOrFunc === 'string') {
return function(elem) { return elem.matches && elem.matches(selectorOrFunc); };
}
return selectorOrFunc;
}
/**
* Iterates backwards through the children of `parent`, returning the first one matching the given
* selector or filter function. Returns null if no matching node is found.
*/
dom.findLastChild = function(parent, selectorOrFunc) {
var filterFunc = makeFilterFunc(selectorOrFunc);
for (var c = parent.lastChild; c; c = c.previousSibling) {
if (filterFunc(c)) {
return c;
}
}
return null;
};
/**
* Iterates up the DOM tree from `child` to `container`, returning the first Node matching the
* given selector or filter function. Returns null for no match.
* If `container` is given, the returned node will be non-null only if contained in it.
* If `container` is omitted, the search will go all the way up.
*/
dom.findAncestor = function(child, optContainer, selectorOrFunc) {
if (arguments.length === 2) {
selectorOrFunc = optContainer;
optContainer = null;
}
var filterFunc = makeFilterFunc(selectorOrFunc);
var match = null;
while (child) {
if (!match && filterFunc(child)) {
match = child;
if (!optContainer) {
return match;
}
}
if (child === optContainer) {
return match;
}
child = child.parentNode;
}
return null;
};
/**
* Detaches a Node from its parent, and returns the Node passed in.
*/
dom.detachNode = function(node) {
if (node.parentNode) {
node.parentNode.removeChild(node);
}
return node;
};
/**
* Use JQuery to attach an event handler to the given element. For documentation, see
* http://api.jquery.com/on/. You may use this inline while building dom, as:
* dom(..., dom.on(events, args...))
* E.g.
* dom('div',
* dom.on('click', function(ev) {
* console.log(ev);
* })
* );
*/
dom.on = dom.inlinable(function(elem, events, optSelector, optData, handler) {
G.$(elem).on(events, optSelector, optData, handler);
});
dom.once = dom.inlinable(function(elem, events, optSelector, optData, handler) {
G.$(elem).one(events, optSelector, optData, handler);
});
/**
* Helper to do some processing on a DOM element after the current call stack has cleared. E.g.
* dom('input',
* dom.defer(function(elem) {
* elem.focus();
* })
* );
* will cause elem.focus() to be called for the INPUT element after a setTimeout of 0.
*
* This is often useful for dealing with focusing and selection.
*/
dom.defer = function(func, optContext) {
return function(elem) {
setTimeout(func.bind(optContext, elem), 0);
};
};
/**
* Call the given function with the given context when the element is cleaned up using
* ko.removeNode or ko.cleanNode. This may be used inline as an argument to dom(), without the
* first argument, to apply to the element being constructed. The function called will receive the
* element as the sole argument.
* @param {Node} elem Element whose destruction should trigger a call to func. It
* should be omitted when used as an argument to dom().
* @param {Function} func Function to call, with elem as an argument, when elem is cleaned up.
* @param {Object} optContext Optionally `this` context to call the function with.
*/
dom.onDispose = dom.inlinable(function(elem, func, optContext) {
ko.utils.domNodeDisposal.addDisposeCallback(elem, func.bind(optContext));
});
/**
* Tie the disposal of the given value to the given element, so that value.dispose() gets called
* when the element is cleaned up using ko.removeNode or ko.cleanNode. This may be used inline as
* an argument to dom(), without the first argument, to apply to the element being constructed.
* @param {Node} elem Element whose destruction should trigger the disposal of the value. It
* should be omitted when used as an argument to dom().
* @param {Object} disposableValue A value with a dispose() method, such as a computed observable.
*/
dom.autoDispose = dom.inlinable(function(elem, disposableValue) {
ko.utils.domNodeDisposal.addDisposeCallback(elem, function() {
disposableValue.dispose();
});
});
/**
* Set an identifier for the given element for identifying the element in automated browser tests.
* @param {String} ident: Arbitrary string; convention is to name it as "ModuleName.nameInModule".
*/
dom.testId = dom.inlinable(function(elem, ident) {
elem.setAttribute('data-test-id', ident);
});
module.exports = dom;

View File

@@ -0,0 +1,25 @@
import {reportError} from 'app/client/models/errors';
import {DomContents, onDisposeElem, replaceContent} from 'grainjs';
// grainjs annoyingly doesn't export browserGlobals tools, useful for testing in a simulated environment.
import {G} from 'grainjs/dist/cjs/lib/browserGlobals';
/**
* Insert DOM contents produced by a Promise. Until the Promise is fulfilled, nothing shows up.
* TODO: This would be a handy place to support options to show a loading spinner (perhaps
* showing up if the promise takes more than a bit to show).
*/
export function domAsync(promiseForDomContents: Promise<DomContents>, onError = reportError): DomContents {
const markerPre = G.document.createComment('a');
const markerPost = G.document.createComment('b');
// Function is added after the markers, to run once they have been attached to elem (the parent).
return [markerPre, markerPost, (elem: Node) => {
let disposed = false;
promiseForDomContents
.then((contents) => disposed || replaceContent(markerPre, markerPost, contents))
.catch(onError);
// If markerPost is disposed before the promise resolves, set insertContent to noop.
onDisposeElem(markerPost, () => { disposed = true; });
}];
}

View File

@@ -0,0 +1,31 @@
const G = require('../lib/browserGlobals').get('document');
const dom = require('../lib/dom');
/**
* Note about testing
* It is difficult to test file downloads as Selenuim and javascript do not provide
* an easy way to control native dialogs.
* One approach would be to configure the test browser to automatically start the download and
* save the file in a specific place. Then check that the file exists at that location.
* Firefox documentation: http://kb.mozillazine.org/File_types_and_download_actions
* Approach detailed here in java: https://www.seleniumeasy.com/selenium-tutorials/verify-file-after-downloading-using-webdriver-java
*/
let _download = null;
/**
* Trigger a download on the file at the given url.
* @param {String} href: The url of the download.
*/
function download(href) {
if (!_download) {
_download = dom('a', {
style: 'position: absolute; top: 0; display: none',
download: ''
});
G.document.body.appendChild(_download);
}
_download.setAttribute('href', href);
_download.click();
}
module.exports = download;

View File

@@ -0,0 +1,32 @@
/**
* Replicates some of grainjs's fromKo, except that the returned observables have a set() method
* which calls koObs.saveOnly(val) rather than koObs(val).
*/
import {IKnockoutObservable, KoWrapObs, Observable} from 'grainjs';
const wrappers: WeakMap<IKnockoutObservable<any>, Observable<any>> = new WeakMap();
/**
* Returns a Grain.js observable which mirrors a Knockout observable.
*
* Do not dispose this wrapper, as it is shared by all code using koObs, and its lifetime is tied
* to the lifetime of koObs. If unused, it consumes minimal resources, and should get garbage
* collected along with koObs.
*/
export function fromKoSave<T>(koObs: IKnockoutObservable<T>): Observable<T> {
return wrappers.get(koObs) || wrappers.set(koObs, new KoSaveWrapObs(koObs)).get(koObs)!;
}
export class KoSaveWrapObs<T> extends KoWrapObs<T> {
constructor(_koObs: IKnockoutObservable<T>) {
if (!('saveOnly' in _koObs)) {
throw new Error('fromKoSave needs a saveable observable');
}
super(_koObs);
}
public set(value: T): void {
// Hacky cast to get a private member. TODO: should make it protected instead.
(this as any)._koObs.saveOnly(value);
}
}

View File

@@ -0,0 +1,11 @@
import {loadMomentTimezone} from 'app/client/lib/imports';
/**
* Returns the browser timezone, using moment.tz.guess(), allowing overriding it via a "timezone"
* URL parameter, for the sake of tests.
*/
export async function guessTimezone() {
const moment = await loadMomentTimezone();
const searchParams = new URLSearchParams(window.location.search);
return searchParams.get('timezone') || moment.tz.guess();
}

209
app/client/lib/helpScout.ts Normal file
View File

@@ -0,0 +1,209 @@
/**
* This module contains tools and helpers to open HelpScout "Beacon" -- a popup which may contain
* an email form, chat, and help docs -- and to include info relevant to support requests.
*
* Usage:
* import {Beacon} from 'app/client/lib/helpScout';
* Beacon('open')
* Beacon('prefill', {...})
* It takes care of initialization automatically.
*
* This is essentially a prettified typescript version of the snippet for the HelpScout Beacon
* available under Beacon settings in HelpScout. It offers the API documented at
* https://developer.helpscout.com/beacon-2/web/javascript-api/
*/
// tslint:disable:unified-signatures
import {AppModel, reportError} from 'app/client/models/AppModel';
import {IAppError} from 'app/client/models/NotifyModel';
import {GristLoadConfig} from 'app/common/gristUrls';
import {timeFormat} from 'app/common/timeFormat';
import {ActiveSessionInfo} from 'app/common/UserAPI';
import * as version from 'app/common/version';
import {dom} from 'grainjs';
import identity = require('lodash/identity');
import pickBy = require('lodash/pickBy');
export type BeaconCmd = 'init' | 'destroy' | 'open' | 'close' | 'toggle' | 'search' | 'suggest' |
'article' | 'navigate' | 'identify' | 'prefill' | 'reset' | 'logout' | 'config' | 'on' | 'off' |
'once' | 'event' | 'session-data';
export interface IUserObj {
name?: string;
email?: string;
company?: string;
jobTitle?: string;
avatar?: string;
signature?: string;
[customKey: string]: string|number|boolean|null|undefined;
}
interface IFormObj {
name?: string;
email?: string;
subject?: string;
text?: string;
fields?: Array<{id: number, value: string|number|boolean}>;
}
interface ISessionData {
[key: string]: string;
}
/**
* This provides the HelpScout Beacon API, taking care of initializing Beacon on first use.
*/
export function Beacon(method: 'init', beaconId: string): void;
export function Beacon(method: 'search', query: string): void;
export function Beacon(method: 'suggest', articles?: string[]): void;
export function Beacon(method: 'article', articleId: string, options?: unknown): void;
export function Beacon(method: 'navigate', route: string): void;
export function Beacon(method: 'identify', userObj: IUserObj): void;
export function Beacon(method: 'prefill', formObj: IFormObj): void;
export function Beacon(method: 'config', configObj: object): void;
export function Beacon(method: 'on'|'off'|'once', event: 'open'|'close', callback: () => void): void;
export function Beacon(method: 'session-data', data: ISessionData): void;
export function Beacon(method: BeaconCmd): void;
export function Beacon(method: BeaconCmd, options?: unknown, data?: unknown) {
initBeacon();
(window as any).Beacon(method, options, data);
}
function _beacon(method: BeaconCmd, options?: unknown, data?: unknown) {
_beacon.readyQueue.push({method, options, data});
}
_beacon.readyQueue = [] as unknown[];
function initBeacon(): void {
if (!(window as any).Beacon) {
const gristConfig: GristLoadConfig|undefined = (window as any).gristConfig;
const beaconId = gristConfig && gristConfig.helpScoutBeaconId;
if (beaconId) {
(window as any).Beacon = _beacon;
document.head.appendChild(dom('script', {
type: 'text/javascript',
src: 'https://beacon-v2.helpscout.net',
async: true,
}));
_beacon('init', beaconId);
_beacon('config', {display: {style: "manual"}});
} else {
(window as any).Beacon = () => null;
reportError(new Error("Support form is not configured"));
}
}
}
let lastOpenType: 'error' | 'message' = 'message';
/**
* Helper to open a beacon, taking care of setting focus appropriately. Calls optional onOpen
* callback when the beacon has opened.
* If errors is given, prepares a form for submitting an error report, and includes stack traces
* into the session-data.
*/
function _beaconOpen(userObj: IUserObj|null, options: {onOpen?: () => void, errors?: IAppError[]}) {
const {onOpen, errors} = options;
// The beacon remembers its content, so reset it when switching between reporting errors and
// sending a message.
const openType = errors ? 'error' : 'message';
if (openType !== lastOpenType) {
Beacon('reset');
lastOpenType = openType;
}
Beacon('once', 'open', () => {
const iframe = document.querySelector('#beacon-container iframe') as HTMLIFrameElement;
if (iframe) { iframe.focus(); }
if (onOpen) { onOpen(); }
});
Beacon('once', 'close', () => {
const iframe = document.querySelector('#beacon-container iframe') as HTMLIFrameElement;
if (iframe) { iframe.blur(); }
});
if (userObj) {
Beacon('identify', userObj);
}
const attrs: ISessionData = {};
if (errors) {
// If sending errors, prefill part of the message (the user sees this and can add to it), and
// include more detailed errors with stack traces into session-data.
const messages = errors.map(({error, timestamp}) =>
(timeFormat('T', new Date(timestamp)) + ' ' + error.message));
const lastMessage = errors.length > 0 ? errors[errors.length - 1].error.message : '';
const prefill: IFormObj = {
subject: `Application Error: ${lastMessage}`.slice(0, 250), // subject has max-length of 250
text: `\n-- Include your description above --\nErrors encountered:\n${messages.join('\n')}\n`,
};
Beacon('prefill', prefill);
Beacon('config', {messaging: {contactForm: {showSubject: false}}});
errors.forEach(({error, timestamp}, i) => {
attrs[`error-${i}`] = timeFormat('D T', new Date(timestamp)) + ' ' + error.message;
if (error.stack) {
attrs[`error-${i}-stack`] = JSON.stringify(error.stack.trim().split('\n'));
}
});
} else {
Beacon('config', {messaging: {contactForm: {showSubject: true}}});
}
Beacon('session-data', {
'Grist Version': `${version.version} (${version.gitcommit})`,
...attrs,
});
Beacon('open');
Beacon('navigate', '/ask/message/');
}
export interface IBeaconOpenOptions {
appModel: AppModel|null;
includeAppErrors?: boolean;
onOpen?: () => void;
errors?: IAppError[];
}
/**
* Open the helpScout beacon to send us a message. Calls optional onOpen callback when the beacon
* has opened. The topAppModel is used to get the current user.
*
* If includeAppErrors or errors is set, the beacon will open to submit an error report. With
* includeAppErrors, it will include stack traces of errors in the notifier into the session-data.
* If errors is set, it will include the specified errors.
*/
export function beaconOpenMessage(options: IBeaconOpenOptions) {
const app = options.appModel;
const errors = options.errors || [];
if (options.includeAppErrors && app) {
errors.push(...app.notifier.getFullAppErrors());
}
_beaconOpen(getBeaconUserObj(app), options);
}
function getBeaconUserObj(appModel: AppModel|null): IUserObj|null {
if (!appModel) { return null; }
// ActiveSessionInfo["user"] includes optional helpScoutSignature too.
const user = appModel.currentValidUser as ActiveSessionInfo["user"]|null;
// For anon user, don't attempt to identify anything. Even the "company" field (when anon on a
// team doc) isn't useful, because the user may be external to the company.
if (!user) { return null; }
// Use the company name only when it's not a personal org. Otherwise, it adds no information and
// overrides more useful company name gleaned by HelpScout from the web.
const org = appModel.currentOrg;
const company = org && !org.owner ? appModel.currentOrgName : undefined;
return pickBy({
name: user.name,
email: user.email,
company,
avatar: user.picture,
signature: user.helpScoutSignature,
}, identity);
}

20
app/client/lib/imports.d.ts vendored Normal file
View File

@@ -0,0 +1,20 @@
import * as BillingPageModule from 'app/client/ui/BillingPage';
import * as GristDocModule from 'app/client/components/GristDoc';
import * as SearchBarModule from 'app/client/components/SearchBar';
import * as ViewPane from 'app/client/components/ViewPane';
import * as UserManagerModule from 'app/client/ui/UserManager';
import * as searchModule from 'app/client/ui2018/search';
import * as momentTimezone from 'moment-timezone';
import * as plotly from 'plotly.js';
export type PlotlyType = typeof plotly;
export type MomentTimezone = typeof momentTimezone;
export function loadBillingPage(): Promise<typeof BillingPageModule>;
export function loadGristDoc(): Promise<typeof GristDocModule>;
export function loadMomentTimezone(): Promise<MomentTimezone>;
export function loadPlotly(): Promise<PlotlyType>;
export function loadSearch(): Promise<typeof searchModule>;
export function loadSearchBar(): Promise<typeof SearchBarModule>;
export function loadUserManager(): Promise<typeof UserManagerModule>;
export function loadViewPane(): Promise<typeof ViewPane>;

16
app/client/lib/imports.js Normal file
View File

@@ -0,0 +1,16 @@
/**
*
* Dynamic imports from js work fine with webpack; from typescript we need to upgrade
* our "module" setting, which has a lot of knock-on effects. To work around that for
* the moment, importing can be done from this js file.
*
*/
exports.loadBillingPage = () => import('app/client/ui/BillingPage' /* webpackChunkName: "BillingModule" */);
exports.loadGristDoc = () => import('app/client/components/GristDoc' /* webpackChunkName: "GristDoc" */);
exports.loadMomentTimezone = () => import('moment-timezone');
exports.loadPlotly = () => import('plotly.js-basic-dist' /* webpackChunkName: "plotly" */);
exports.loadSearchBar = () => import('app/client/components/SearchBar' /* webpackChunkName: "searchbar" */);
exports.loadSearch = () => import('app/client/ui2018/search' /* webpackChunkName: "search" */);
exports.loadUserManager = () => import('app/client/ui/UserManager' /* webpackChunkName: "usermanager" */);
exports.loadViewPane = () => import('app/client/components/ViewPane' /* webpackChunkName: "viewpane" */);

31
app/client/lib/koArray.d.ts vendored Normal file
View File

@@ -0,0 +1,31 @@
import * as ko from 'knockout';
declare class KoArray<T> {
public static syncedKoArray(...args: any[]): any;
public peekLength: number;
public subscribe: ko.Observable["subscribe"];
public dispose(): void;
public at(index: number): T|null;
public all(): T[];
public map<T2>(op: (x: T) => T2): KoArray<T2>;
public peek(): T[];
public getObservable(): ko.Observable<T[]>;
public push(...items: T[]): void;
public unshift(...items: T[]): void;
public assign(newValues: T[]): void;
public splice(start: number, optDeleteCount?: number, ...values: T[]): T[];
public subscribeForEach(options: {
add?: (item: T, index: number, arr: KoArray<T>) => void;
remove?: (item: T, arr: KoArray<T>) => void;
addDelay?: number;
}): ko.Subscription;
public clampIndex(index: number): number|null;
public makeLiveIndex(index?: number): ko.Observable<number> & {setLive(live: boolean): void};
public setAutoDisposeValues(): this;
}
declare function syncedKoArray(...args: any[]): any;
export default function koArray<T>(initialValue?: T[]): KoArray<T>;

457
app/client/lib/koArray.js Normal file
View File

@@ -0,0 +1,457 @@
/**
* Our version of knockout's ko.observableArray(), similar but more efficient. It
* supports fewer methods (mainly because we don't need other methods at the moment). Instead of
* emitting 'arrayChange' events, it emits 'spliceChange' events.
*/
var ko = require('knockout');
var Promise = require('bluebird');
var dispose = require('./dispose');
var gutil = require('app/common/gutil');
require('./koUtil'); // adds subscribeInit method to observables.
/**
* Event indicating that a koArray has been modified. This reflects changes to which objects are
* in the array, not the state of those objects. A `spliceChange` event is emitted after the array
* has been modified.
* @event spliceChange
* @property {Array} data - The underlying array, already modified.
* @property {Number} start - The start index at which items were inserted or deleted.
* @property {Number} added - The number of items inserted.
* @property {Array} deleted - The array of items that got deleted.
*/
/**
* Creates and returns a new koArray, either empty or with the given initial values.
* Unlike a ko.observableArray(), you access the values using array.all(), and set values using
* array.assign() (or better, by using push() and splice()).
*/
function koArray(optInitialValues) {
return KoArray.create(optInitialValues);
}
// The koArray function is the main export.
module.exports = exports = koArray;
exports.default = koArray;
/**
* Checks if an object is an instance of koArray.
*/
koArray.isKoArray = function(obj) {
return (obj && typeof obj.subscribe === 'function' && typeof obj.all === 'function');
};
exports.isKoArray = koArray.isKoArray;
/**
* Given an observable which evaluates to different arrays or koArrays, returns a single koArray
* observable which mirrors whichever array is the current value of the observable. If a callback
* is given, all elements are mapped through it. See also map().
* @param {ko.observable} koArrayObservable: observable whose value is a koArray or plain array.
* @param {Function} optCallback: If given, maps elements from original arrays.
* @param {Object} optCallbackTarget: If callback is given, this becomes the `this` value for it.
* @returns {koArray} a single koArray that mirrors the current value of koArrayObservable,
* optionally mapping them through optCallback.
*/
koArray.syncedKoArray = function(koArrayObservable, optCallback, optCallbackTarget) {
var ret = koArray();
optCallback = optCallback || identity;
ret.autoDispose(koArrayObservable.subscribeInit(function(currentArray) {
if (koArray.isKoArray(currentArray)) {
ret.syncMap(currentArray, optCallback, optCallbackTarget);
} else if (currentArray) {
ret.syncMapDisable();
ret.assign(currentArray.map(function(item, i) {
return optCallback.call(optCallbackTarget, item, i);
}));
}
}));
return ret;
};
exports.syncedKoArray = koArray.syncedKoArray;
function SyncedState(constructFunc, key) {
constructFunc(this, key);
}
dispose.makeDisposable(SyncedState);
/**
* Create and return a new Map that's kept in sync with koArrayObj. The keys are the array items
* themselves. The values are constructed using constructFunc(state, item), where state is a new
* Disposable object, allowing to associate other disposable state with the item. The returned Map
* should itself be disposed when no longer needed.
* @param {KoArray} koArrayObj: A KoArray object to watch.
* @param {Function} constructFunc(state, item): called for each item in the array, with a new
* disposable state object, on which all Disposable methods are available. The state object
* will be disposed when an item is removed or the returned map itself disposed.
* @param [Number] options.addDelay: (optional) If numeric, delay calls to add items
* by this many milliseconds (except initialization, which is always immediate).
* @return {Map} map object mapping array items to state objects, and with a dispose() method.
*/
koArray.syncedMap = function(koArrayObj, constructFunc, options) {
var map = new Map();
var sub = koArrayObj.subscribeForEach({
add: item => map.set(item, SyncedState.create(constructFunc, item)),
remove: item => gutil.popFromMap(map, item).dispose(),
addDelay: options && options.addDelay
});
map.dispose = () => {
sub.dispose();
map.forEach((stateObj, item) => stateObj.dispose());
};
return map;
};
/**
* The actual constructor for koArray. To create a new instance, simply use koArray() (without
* `new`). The constructor might be needed, however, to inherit from this class.
*/
function KoArray(initialValues) {
this._array = ko.observable(initialValues || []);
this._preparedSpliceEvent = null;
this._syncSubscription = null;
this._disposeElements = noop;
this.autoDispose(this._array.subscribe(this._emitPreparedEvent, this, 'spectate'));
this.autoDisposeCallback(function() {
this._disposeElements(this.peek());
});
}
exports.KoArray = KoArray;
dispose.makeDisposable(KoArray);
/**
* If called on a koArray, it will dispose of its contained items as they are removed or when the
* array is itself disposed.
* @returns {koArray} itself.
*/
KoArray.prototype.setAutoDisposeValues = function() {
this._disposeElements = this._doDisposeElements;
return this;
};
/**
* Returns the underlying array, creating a dependency when used from a computed observable.
* Note that you must not modify the returned array directly; you should use koArray methods.
*/
KoArray.prototype.all = function() {
return this._array();
};
/**
* Returns the underlying array without creating a dependency on it.
* Note that you must not modify the returned array directly; you should use koArray methods.
*/
KoArray.prototype.peek = function() {
return this._array.peek();
};
/**
* Returns the underlying observable whose value is a plain array.
*/
KoArray.prototype.getObservable = function() {
return this._array;
};
/**
* The `peekLength` property evaluates to the length of the underlying array. Using it does NOT
* create a dependency on the array. Use array.all().length to create a dependency.
*/
Object.defineProperty(KoArray.prototype, 'peekLength', {
configurable: false,
enumerable: false,
get: function() { return this._array.peek().length; },
});
/**
* A shorthand for the itemModel at a given index. Returns null if the index is invalid or out of
* range. Create a dependency on the array itself.
*/
KoArray.prototype.at = function(index) {
var arr = this._array();
return index >= 0 && index < arr.length ? arr[index] : null;
};
/**
* Assigns a new underlying array. This is analogous to observableArray(newValues).
*/
KoArray.prototype.assign = function(newValues) {
var oldArray = this.peek();
this._prepareSpliceEvent(0, newValues.length, oldArray);
this._array(newValues.slice());
this._disposeElements(oldArray);
};
/**
* Subscribe to events for this koArray. To be notified of splice details, subscribe to
* 'spliceChange', which will always follow the plain 'change' events.
*/
KoArray.prototype.subscribe = function(callback, callbackTarget, event) {
return this._array.subscribe(callback, callbackTarget, event);
};
/**
* @private
* Internal method to prepare a 'spliceChange' event.
*/
KoArray.prototype._prepareSpliceEvent = function(start, numAdded, deleted) {
this._preparedSpliceEvent = {
array: null,
start: start,
added: numAdded,
deleted: deleted
};
};
/**
* @private
* Internal method to emit and reset a prepared 'spliceChange' event, if there is one.
*/
KoArray.prototype._emitPreparedEvent = function() {
var event = this._preparedSpliceEvent;
if (event) {
event.array = this.peek();
this._preparedSpliceEvent = null;
this._array.notifySubscribers(event, 'spliceChange');
}
};
/**
* @private
* Internal method called before the underlying array is modified. This copies how knockout emits
* its default events internally.
*/
KoArray.prototype._preChange = function() {
this._array.valueWillMutate();
};
/**
* @private
* Internal method called before the underlying array is modified. This copies how knockout emits
* its default events internally.
*/
KoArray.prototype._postChange = function() {
this._array.valueHasMutated();
};
/**
* @private
* Internal method to call dispose() for each item in the passed-in array. It's only used when
* autoDisposeValues option is given to koArray.
*/
KoArray.prototype._doDisposeElements = function(elements) {
for (var i = 0; i < elements.length; i++) {
elements[i].dispose();
}
};
/**
* The standard array `push` method, which emits all expected events.
*/
KoArray.prototype.push = function() {
var array = this.peek();
var start = array.length;
this._preChange();
var ret = array.push.apply(array, arguments);
this._prepareSpliceEvent(start, arguments.length, []);
this._postChange();
return ret;
};
/**
* The standard array `unshift` method, which emits all expected events.
*/
KoArray.prototype.unshift = function() {
var array = this.peek();
this._preChange();
var ret = array.unshift.apply(array, arguments);
this._prepareSpliceEvent(0, arguments.length, []);
this._postChange();
return ret;
};
/**
* The standard array `splice` method, which emits all expected events.
*/
KoArray.prototype.splice = function(start, optDeleteCount) {
return this.arraySplice(start, optDeleteCount, Array.prototype.slice.call(arguments, 2));
};
KoArray.prototype.arraySplice = function(start, optDeleteCount, arrToInsert) {
var array = this.peek();
var len = array.length;
var startIndex = Math.min(len, Math.max(0, start < 0 ? len + start : start));
this._preChange();
var ret = (optDeleteCount === void 0 ? array.splice(start) :
array.splice(start, optDeleteCount));
gutil.arraySplice(array, startIndex, arrToInsert);
this._prepareSpliceEvent(startIndex, arrToInsert.length, ret);
this._postChange();
this._disposeElements(ret);
return ret;
};
/**
* The standard array `slice` method. Creates a dependency when used from a computed observable.
*/
KoArray.prototype.slice = function() {
var array = this.all();
return array.slice.apply(array, arguments);
};
/**
* Returns a new KoArray instance, subscribed to the current one to stay parallel to it. The new
* element are set to the result of calling `callback(orig, i)` on each original element. Note
* that the index argument is only correct as of the time the callback got called.
*/
KoArray.prototype.map = function(callback, optThis) {
var newArray = new KoArray();
newArray.syncMap(this, callback, optThis);
return newArray;
};
function noop() {}
function identity(x) { return x; }
/**
* Keep this array in sync with another koArray, optionally mapping all elements through the given
* callback. If callback is omitted, the current array will just mirror otherKoArray.
* See also map().
*
* The subscription is disposed when the koArray is disposed.
*/
KoArray.prototype.syncMap = function(otherKoArray, optCallback, optCallbackTarget) {
this.syncMapDisable();
optCallback = optCallback || identity;
this.assign(otherKoArray.peek().map(function(item, i) {
return optCallback.call(optCallbackTarget, item, i);
}));
this._syncSubscription = this.autoDispose(otherKoArray.subscribe(function(splice) {
var arr = splice.array;
var newValues = [];
for (var i = splice.start, n = 0; n < splice.added; i++, n++) {
newValues.push(optCallback.call(optCallbackTarget, arr[i], i));
}
this.arraySplice(splice.start, splice.deleted.length, newValues);
}, this, 'spliceChange'));
};
/**
* Disable previously created syncMap subscription, if any.
*/
KoArray.prototype.syncMapDisable = function() {
if (this._syncSubscription) {
this.disposeDiscard(this._syncSubscription);
this._syncSubscription = null;
}
};
/**
* Analog to forEach for regular arrays, but that stays in sync with array changes.
* @param {Function} options.add: func(item, index, koarray) is called for each item present,
* and whenever an item is added.
* @param {Function} options.remove: func(item, koarray) is called whenever an item is removed.
* @param [Object] options.context: (optional) `this` value to use in add/remove callbacks.
* @param [Number] options.addDelay: (optional) If numeric, delay calls to the add
* callback by this many milliseconds (except initialization calls which are always immediate).
*/
KoArray.prototype.subscribeForEach = function(options) {
var context = options.context;
var onAdd = options.add || noop;
var onRemove = options.remove || noop;
var shouldDelay = (typeof options.addDelay === 'number');
var subscription = this.subscribe(function(splice) {
var i, arr = splice.array;
for (i = 0; i < splice.deleted.length; i++) {
onRemove.call(context, splice.deleted[i], this);
}
var callAdd = () => {
var end = splice.start + splice.added;
for (i = splice.start; i < end; i++) {
onAdd.call(context, arr[i], i, this);
}
};
if (!shouldDelay) {
callAdd();
} else if (options.addDelay > 0) {
setTimeout(callAdd, options.addDelay);
} else {
// Promise library invokes the callback much sooner than setTimeout does, i.e. it's much
// closer to "nextTick", which is what we want here.
Promise.resolve(null).then(callAdd);
}
}, this, 'spliceChange');
this.peek().forEach(function(item, i) {
onAdd.call(context, item, i, this);
}, this);
return subscription;
};
/**
* Given a numeric index, returns an index that's valid for this array, clamping it if needed.
* If the array is empty, returns null. If the index given is null, treats it as 0.
*/
KoArray.prototype.clampIndex = function(index) {
var len = this.peekLength;
return len === 0 ? null : gutil.clamp(index || 0, 0, len - 1);
};
/**
* Returns a new observable representing an index into this array. It can be read and written, and
* its value is clamped to be a valid index. The index is only null if the array is empty.
*
* As the array changes, the index is adjusted to continue pointing to the same element. If the
* pointed element is deleted, the index is adjusted to after the deletion point.
*
* The returned observable has an additional .setLive(bool) method. While set to false, the
* observale will not be adjusted as the array changes, except to keep it valid.
*/
KoArray.prototype.makeLiveIndex = function(optInitialIndex) {
// The underlying observable index. Not exposed directly.
var index = ko.observable(this.clampIndex(optInitialIndex));
var isLive = true;
// Adjust the index when data is spliced before it.
this.subscribe(function(splice) {
var idx = index.peek();
if (!isLive) {
index(this.clampIndex(idx));
} else if (idx === null) {
index(this.clampIndex(0));
} else if (idx >= splice.start + splice.deleted.length) {
// Adjust the index if it was beyond the deleted region.
index(this.clampIndex(idx + splice.added - splice.deleted.length));
} else if (idx >= splice.start + splice.added) {
// Adjust the index if it was inside the deleted region (and not replaced).
index(this.clampIndex(splice.start + splice.added));
}
}, this, 'spliceChange');
// The returned value, which is a writable computable, constraining the value to the valid range
// (or null if the range is empty).
var ret = ko.pureComputed({
read: index,
write: function(val) { index(this.clampIndex(val)); },
owner: this
});
ret.setLive = (val => { isLive = val; });
return ret;
};

View File

@@ -0,0 +1,42 @@
import {KoArray} from 'app/client/lib/koArray';
import {IDisposableOwnerT, MutableObsArray, ObsArray, setDisposeOwner} from 'grainjs';
/**
* Returns a grainjs ObsArray that reflects the given koArray, mapping small changes using
* similarly efficient events.
*
* (Note that for both ObsArray and koArray, the main purpose in life is to be more efficient than
* an array-valued observable by handling small changes more efficiently.)
*/
export function createObsArray<T>(
owner: IDisposableOwnerT<ObsArray<T>> | null,
koArray: KoArray<T>,
): ObsArray<T> {
return setDisposeOwner(owner, new KoWrapObsArray(koArray));
}
/**
* An Observable that wraps a Knockout observable, created via fromKo(). It keeps minimal overhead
* when unused by only subscribing to the wrapped observable while it itself has subscriptions.
*
* This way, when unused, the only reference is from the wrapper to the wrapped object. KoWrapObs
* should not be disposed; its lifetime is tied to that of the wrapped object.
*/
class KoWrapObsArray<T> extends MutableObsArray<T> {
private _koSub: any = null;
constructor(_koArray: KoArray<T>) {
super(Array.from(_koArray.peek()));
this._koSub = _koArray.subscribe((splice: any) => {
const newValues = splice.array.slice(splice.start, splice.start + splice.added);
this.splice(splice.start, splice.deleted.length, ...newValues);
}, null, 'spliceChange');
}
public dispose(): void {
this._koSub.dispose();
super.dispose();
}
}

426
app/client/lib/koDom.js Normal file
View File

@@ -0,0 +1,426 @@
/**
* koDom.js is an analog to Knockout.js bindings that works with our dom.js library.
* koDom provides a suite of bindings between the DOM and knockout observables.
*
* For example, here's how we can create som DOM with some bindings, given a view-model object vm:
* dom(
* 'div',
* kd.toggleClass('active', vm.isActive),
* kd.text(function() {
* return vm.data()[vm.selectedRow()][part.value.index()];
* })
* );
*/
/**
* Use the browser globals in a way that allows replacing them with mocks in tests.
*/
var G = require('./browserGlobals').get('document', 'Node');
var ko = require('knockout');
var dom = require('./dom');
var koArray = require('./koArray');
/**
* Creates a binding between a DOM element and an observable value, making sure that
* updaterFunc(elem, value) is called whenever the observable changes. It also registers disposal
* callbacks on the element so that the binding is cleared when the element is disposed with
* ko.cleanNode() or ko.removeNode().
*
* @param {Node} elem: DOM element.
* @param {Object} valueOrFunc: Either an observable, a function (to create ko.computed() with),
* or a constant value.
* @param {Function} updaterFunc: Called both initially and whenever the value changes as
* updaterFunc(elem, value). The value is already unwrapped (so is not an observable).
*/
function setBinding(elem, valueOrFunc, updaterFunc) {
var subscription;
if (ko.isObservable(valueOrFunc)) {
subscription = valueOrFunc.subscribe(function(v) { updaterFunc(elem, v); });
ko.utils.domNodeDisposal.addDisposeCallback(elem, function() {
subscription.dispose();
});
updaterFunc(elem, valueOrFunc.peek());
} else if (typeof valueOrFunc === 'function') {
valueOrFunc = ko.computed(valueOrFunc);
subscription = valueOrFunc.subscribe(function(v) { updaterFunc(elem, v); });
ko.utils.domNodeDisposal.addDisposeCallback(elem, function() {
subscription.dispose();
valueOrFunc.dispose();
});
updaterFunc(elem, valueOrFunc.peek());
} else {
updaterFunc(elem, valueOrFunc);
}
}
exports.setBinding = setBinding;
/**
* Internal helper to create a binding. Used by most simple bindings.
* @param {Object} valueOrFunc: Either an observable, a function (to create ko.computed() with),
* or a constant value.
* @param {Function} updaterFunc: Called both initially and whenever the value changes as
* updaterFunc(elem, value). The value is already unwrapped (so is not an observable).
* @returns {Function} Function suitable to pass as an argument to dom(); i.e. one that takes an
* DOM element, and adds the bindings to it. It also registers disposal callbacks on the
* element, so that bindings are cleaned up when the element is disposed with ko.cleanNode()
* or ko.removeNode().
*/
function makeBinding(valueOrFunc, updaterFunc) {
return function(elem) {
setBinding(elem, valueOrFunc, updaterFunc);
};
}
exports.makeBinding = makeBinding;
/**
* Keeps the text content of a DOM element in sync with an observable value.
* Just like knockout's `text` binding.
* @param {Object} valueOrFunc An observable, a constant, or a function for a computed observable.
*/
function text(valueOrFunc) {
return function(elem) {
// Since setting textContent property of an element removes all its other children, we insert
// a new text node, and change the content of that. However, we tie the binding to the parent
// elem, i.e. make it disposed along with elem, because text nodes don't get cleaned by
// ko.removeNode / ko.cleanNode.
var textNode = G.document.createTextNode("");
setBinding(elem, valueOrFunc, function(elem, value) {
textNode.nodeValue = value;
});
elem.appendChild(textNode);
};
}
exports.text = text;
// Used for replacing the static token span created by bootstrap tokenfield with the the same token
// but with its text content tied to an observable.
// To use bootstrapToken:
// 1) Get the token to make a clone of and the observable desired.
// 2) Create the new token by calling this function.
// 3) Replace the original token with this newly created token in the DOM by doing
// Ex: var newToken = bootstrapToken(originalToken, observable);
// parentElement.replaceChild(originalToken, newToken);
// TODO: Make templateToken optional. If not given, bind the observable to a manually created token.
function bootstrapToken(templateToken, valueOrFunc) {
var clone = templateToken.cloneNode();
setBinding(clone, valueOrFunc, function(e, value) {
clone.textContent = value;
});
return clone;
}
exports.bootstrapToken = bootstrapToken;
/**
* Keeps the attribute `attrName` of a DOM element in sync with an observable value.
* Just like knockout's `attr` binding. Removes the attribute when the value is null or undefined.
* @param {String} attrName The name of the attribute to bind, e.g. 'href'.
* @param {Object} valueOrFunc An observable, a constant, or a function for a computed observable.
*/
function attr(attrName, valueOrFunc) {
return makeBinding(valueOrFunc, function(elem, value) {
if (value === null || value === undefined) {
elem.removeAttribute(attrName);
} else {
elem.setAttribute(attrName, value);
}
});
}
exports.attr = attr;
/**
* Keeps the style property `property` of a DOM element in sync with an observable value.
* Just like knockout's `style` binding.
* @param {String} property The name of the style property to bind, e.g. 'fontWeight'.
* @param {Object} valueOrFunc An observable, a constant, or a function for a computed observable.
*/
function style(property, valueOrFunc) {
return makeBinding(valueOrFunc, function(elem, value) {
elem.style[property] = value;
});
}
exports.style = style;
/**
* Shows or hides the element depending on a boolean value. Note that the element must be visible
* initially (i.e. unsetting style.display should show it).
* @param {Object} boolValueOrFunc An observable, a constant, or a function for a computed
* observable. The value is treated as a boolean.
*/
function show(boolValueOrFunc) {
return makeBinding(boolValueOrFunc, function(elem, value) {
elem.style.display = value ? '' : 'none';
});
}
exports.show = show;
/**
* The opposite of show, equivalent to show(function() { return !value(); }).
* @param {Object} boolValueOrFunc An observable, a constant, or a function for a computed
* observable. The value is treated as a boolean.
*/
function hide(boolValueOrFunc) {
return makeBinding(boolValueOrFunc, function(elem, value) {
elem.style.display = value ? 'none' : '';
});
}
exports.hide = hide;
/**
* Associates some data with the DOM element, using ko.utils.domData.
*/
function domData(key, valueOrFunc) {
return makeBinding(valueOrFunc, function(elem, value) {
ko.utils.domData.set(elem, key, value);
});
}
exports.domData = domData;
/**
* Keeps the value of the given DOM form element in sync with an observable value.
* Just like knockout's `value` binding, except that it is one-directional (for now).
*/
function value(valueOrFunc) {
return makeBinding(valueOrFunc, function(elem, value) {
// This conditional shouldn't be necessary, but on Electron 1.7,
// setting unchanged value cause cursor to jump
if (elem.value !== value) { elem.value = value; }
});
}
exports.value = value;
/**
* Toggles a css class `className` according to the truthiness of an observable value.
* Similar to knockout's `css` binding with a static class.
* @param {String} className The name of the class to toggle.
* @param {Object} boolValueOrFunc An observable, a constant, or a function for a computed
* observable. The value is treated as a boolean.
*/
function toggleClass(className, boolValueOrFunc) {
return makeBinding(boolValueOrFunc, function(elem, value) {
elem.classList.toggle(className, !!value);
});
}
exports.toggleClass = toggleClass;
/**
* Toggles the `disabled` attribute on when boolValueOrFunc evaluates true. When
* it evaluates false, the attribute is removed.
* @param {[type]} boolValueOrFunc boolValueOrFunc An observable, a constant, or a function for a computed
* observable. The value is treated as a boolean.
*/
function toggleDisabled(boolValueOrFunc) {
return makeBinding(boolValueOrFunc, function(elem, disabled) {
if (disabled) {
elem.setAttribute('disabled', 'disabled');
} else {
elem.removeAttribute('disabled');
}
});
}
exports.toggleDisabled = toggleDisabled;
/**
* Adds a css class named by an observable value. If the value changes, the previous class will be
* removed and the new one added. The value may be empty to avoid adding any class.
* Similar to knockout's `css` binding with a dynamic class.
* @param {Object} valueOrFunc An observable, a constant, or a function for a computed observable.
*/
function cssClass(valueOrFunc) {
var prevClass;
return makeBinding(valueOrFunc, function(elem, value) {
if (prevClass) {
elem.classList.remove(prevClass);
}
prevClass = value;
if (value) {
elem.classList.add(value);
}
});
}
exports.cssClass = cssClass;
/**
* Scrolls a child element into view. The value should be the index of the child element to
* consider. This function supports scrolly, and is mainly useful for scrollable container
* elements, with a `foreach` or a `scrolly` inside.
* @param {Object} valueOrFunc An observable, a constant, or a function for a computed observable
* whose value is the index of the child element to keep scrolled into view.
*/
function scrollChildIntoView(valueOrFunc) {
return makeBinding(valueOrFunc, function(elem, index) {
if (index === null) {
return;
}
var scrolly = ko.utils.domData.get(elem, "scrolly");
if (scrolly) {
// Delay this in case it's triggered while other changes are processed (e.g. splices).
setTimeout(() => scrolly.isDisposed() || scrolly.scrollRowIntoView(index), 0);
} else {
var child = elem.children[index];
if (!child) {
return;
}
if (index === 0) {
// Scroll the container all the way if showing the first child.
elem.scrollTop = 0;
}
var childRect = child.getBoundingClientRect();
var parentRect = elem.getBoundingClientRect();
if (childRect.top < parentRect.top) {
child.scrollIntoView(true); // Align with top if scrolling up..
} else if (childRect.bottom > parentRect.bottom) {
child.scrollIntoView(false); // ..bottom if scrolling down.
}
}
});
}
exports.scrollChildIntoView = scrollChildIntoView;
/**
* Adds to a DOM element the content returned by `contentFunc` called with the value of the given
* observable. The content may be a Node, an array of Nodes, text, null or undefined.
* Similar to knockout's `with` binding.
* @param {Object} valueOrFunc An observable, a constant, or a function for a computed observable.
* @param {Function} contentFunc Called with the value of `valueOrFunc` whenever that value
* changes. The returned content will replace previous content among the children of the bound
* DOM element in the place where the scope() call was present among arguments to dom().
*/
function scope(valueOrFunc, contentFunc) {
var marker, contentNodes = [];
return makeBinding(valueOrFunc, function(elem, value) {
// We keep a comment marker, so that we know where to insert the content, and numChildren, so
// that we know how many children are part of that content.
if (!marker) {
marker = elem.appendChild(G.document.createComment(""));
}
// Create the new content before destroying the old, so that it is OK for the new content to
// include the old (by reattaching inside the new content). If we did it after, the old
// content would get destroyed before it gets moved. (Note that "destroyed" here means
// clearing associated bindings and event handlers, so it's not easily visible.)
var content = dom.frag(contentFunc(value));
// Remove any children added last time, cleaning associated data.
for (var i = 0; i < contentNodes.length; i++) {
if (contentNodes[i].parentNode === elem) {
ko.removeNode(contentNodes[i]);
}
}
contentNodes.length = 0;
var next = marker.nextSibling;
elem.insertBefore(content, next);
// Any number of children may have gotten added if content was a DocumentFragment.
for (var n = marker.nextSibling; n !== next; n = n.nextSibling) {
contentNodes.push(n);
}
});
}
exports.scope = scope;
/**
* Conditionally adds to a DOM element the content returned by `contentFunc()` depending on the
* boolean value of the given observable. The content may be a Node, an array of Nodes, text, null
* or undefined.
* Similar to knockout's `if` binding.
* @param {Object} boolValueOrFunc An observable, a constant, or a function for a computed
* observable. The value is checked as a boolean, and passed to the content function.
* @param {Function} contentFunc A function called with the value of `boolValueOrFunc` whenever
* the observable changes from false to true. The returned content is added to the bound DOM
* element in the place where the maybe() call was present among arguments to dom().
*/
function maybe(boolValueOrFunc, contentFunc) {
return scope(boolValueOrFunc, function(yesNo) {
return yesNo ? contentFunc(yesNo) : null;
});
}
exports.maybe = maybe;
/**
* Observes an observable array (koArray), and creates and adds as many children to the bound DOM
* element as there are items in it. As the array is changed, children are added or removed. Also
* works for a plain data array, creating a static list of children.
*
* Elements are typically added and removed by splicing data into or out of the data koArray. When
* an item is removed, the corresponding node is removed using ko.removeNode (which also runs any
* disposal tied to the node). If the caller retains a reference to a Node item, and removes it
* from its parent, foreach will cope with it fine, but will not call ko.removeNode on that node
* when the item from which it came is spliced out.
*
* @param {koArray} data An koArray instance.
* @param {Function} itemCreateFunc A function called as `itemCreateFunc(item)` for each
* array element. Note: `index` is not passed to itemCreateFunc as it is only correct at the
* time the item is created, and does not reflect further changes to the array.
*/
function foreach(data, itemCreateFunc) {
var marker;
var children = [];
return function(elem) {
if (!marker) {
marker = elem.appendChild(G.document.createComment(""));
}
var spliceFunc = function(splice) {
var i, start = splice.start;
// Remove the elements that are gone.
var deletedElems = children.splice(start, splice.deleted.length);
for (i = 0; i < deletedElems.length; i++) {
// Some nodes may be null, or may have been removed elsewhere in the program. The latter
// are no longer our responsibility, and we should not clean them up.
if (deletedElems[i] && deletedElems[i].parentNode === elem) {
ko.removeNode(deletedElems[i]);
}
}
if (splice.added > 0) {
// Create and insert new elements.
var frag = G.document.createDocumentFragment();
var spliceArgs = [start, 0];
for (i = 0; i < splice.added; i++) {
var itemModel = splice.array[start + i];
var insertEl = itemCreateFunc(itemModel);
if (insertEl) {
ko.utils.domData.set(insertEl, "itemModel", itemModel);
frag.appendChild(insertEl);
}
spliceArgs.push(insertEl);
}
// Add new elements to the children array we maintain.
Array.prototype.splice.apply(children, spliceArgs);
// Find a valid child immediately preceding the start of the splice, for DOM insertion.
var baseElem = marker;
for (i = start - 1; i >= 0; i--) {
if (children[i] && children[i].parentNode === elem) {
baseElem = children[i];
break;
}
}
elem.insertBefore(frag, baseElem.nextSibling);
}
};
var array = data;
if (koArray.isKoArray(data)) {
var subscription = data.subscribe(spliceFunc, null, 'spliceChange');
ko.utils.domNodeDisposal.addDisposeCallback(elem, function() {
subscription.dispose();
});
array = data.all();
} else if (!Array.isArray(data)) {
throw new Error("koDom.foreach applied to non-array: " + data);
}
spliceFunc({ array: array, start: 0, added: array.length, deleted: [] });
};
}
exports.foreach = foreach;

View File

@@ -0,0 +1,3 @@
.scrolly_outer {
position: relative; /* Forces absolutely-positiong scrolly-div to be within scrolly outer*/
}

View File

@@ -0,0 +1,649 @@
/**
* Scrolly is a class that allows scrolling a very long list of rows by rendering only those
* that are visible. Note that the elements rendered by scrolly should have box-sizing set to
* border-box.
*/
var _ = require('underscore');
var ko = require('knockout');
var assert = require('assert');
var gutil = require('app/common/gutil');
var BinaryIndexedTree = require('app/common/BinaryIndexedTree');
var {Delay} = require('./Delay');
var dispose = require('./dispose');
var kd = require('./koDom');
var dom = require('./dom');
/**
* Use the browser globals in a way that allows replacing them with mocks in tests.
*/
var G = require('./browserGlobals').get('window', '$');
/**
* Scrolly may contain multiple panes scrolling in parallel (e.g. for row numbers). The UI for
* each pane consists of two nested pieces: a scrollDiv and a blockDiv. The scrollDiv is very tall
* and mostly empty; the blockDiv contains the actual rendered rows, and is absolutely positioned
* inside its scrollDiv.
*/
function ScrollyPane(scrolly, paneIndex, container, options, itemCreateFunc) {
this.scrolly = scrolly;
this.paneIndex = paneIndex;
this.container = container;
this.itemCreateFunc = itemCreateFunc;
this.preparedRows = [];
_.extend(this.scrolly.options, options);
this.container.appendChild(
this.scrollDiv = dom(
'div.scrolly_outer',
kd.style('height', this.scrolly.totalHeightPx),
this.blockDiv = dom(
'div',
kd.style('position', 'absolute'),
kd.style('top', this.scrolly.blockTopPx),
kd.style('width', options.fitToWidth ? '100%' : ''),
kd.style('padding-right', options.paddingRight + 'px')
)
)
);
ko.utils.domNodeDisposal.addDisposeCallback(container, () => {
this.scrolly.destroyPane(this);
// Delete all members, to break cycles.
for (var k in this) {
delete this[k];
}
});
G.$(this.container).on('scroll', () => this.scrolly.onScroll(this) );
}
/**
* Prepares the DOM for rows in scrolly's [begin, end) range, reusing currently active rows as
* much as possible. New rows are saved in this.preparedRows, and also added to the end of
* blockDiv so that they may be measured.
*/
ScrollyPane.prototype.prepareNewRows = function() {
var i, item, row,
begin = this.scrolly.begin,
count = this.scrolly.end - begin,
array = this.scrolly.data.peek(),
prevItemModels = this.scrolly.activeItemModels,
prevRows = this.preparedRows;
if (prevRows.length > 0) {
// Skip this check if there are no rows, maybe we just added this pane.
assert.equal(prevRows.length, prevItemModels.length,
"Rows and models not in sync: " + prevRows.length + "!=" + prevItemModels.length);
}
this.preparedRows = [];
// Reuse any reusable old rows. They must be tied to an active model.
for (i = 0; i < prevRows.length; i++) {
row = prevRows[i];
item = prevItemModels[i];
if (item._index() === null) {
ko.removeNode(row);
} else {
var relIndex = item._index() - begin;
assert(relIndex >= 0 && relIndex < count, "prepareNewRows saw out-of-range model");
this.preparedRows[relIndex] = row;
}
}
// Create any missing rows.
for (i = 0; i < count; i++) {
if (!this.preparedRows[i]) {
item = array[begin + i];
assert(item, "ScrollyPane item missing at index " + (begin + i));
item._rowHeightPx(""); // Mark this row as in need of measuring.
row = this.itemCreateFunc(item);
kd.style('height', item._rowHeightPx)(row);
ko.utils.domData.set(row, "itemModel", item);
this.preparedRows[i] = row;
// The row may not end up at the end of blockDiv, but we need to add it to the document in
// order to measure it. We'll move it to the right place in arrangePreparedRows().
this.blockDiv.appendChild(row);
}
}
};
/**
* Returns the measured height of the given prepared row.
*/
ScrollyPane.prototype.measurePreparedRow = function(rowIndex) {
var row = this.preparedRows[rowIndex];
var rect = row.getBoundingClientRect();
return rect.bottom - rect.top;
};
/**
* Update the DOM with the prepared rows in the correct order.
*/
ScrollyPane.prototype.arrangePreparedRows = function() {
// Note that everything that was in blockDiv previously is now either gone or is in
// preparedRows. So placing all preparedRows into blockDiv automatically removes them from their
// old positions.
//
// For a slight speedup in rendering, we try to avoid removing and reinserting rows
// unnecessarily, as that slows down subsequent rendering. We could try harder, by finding the
// longest common subsequence, but that's quite a bit harder.
for (var i = 0; i < this.preparedRows.length; i++) {
var row = this.preparedRows[i];
var current = this.blockDiv.childNodes[i];
if (row !== current) {
this.blockDiv.insertBefore(row, current);
}
}
};
//----------------------------------------------------------------------
/**
* The Scrolly class is used internally to manage the state of the scrolly. It keeps track of the
* data items being rendered, of the heights of all rows (including cumulative heights, in a
* BinaryIndexedTree), and various other counts and positions.
*
* The actual DOM elements are managed by ScrollyPane class. There may be more than one instance,
* if there are multiple panes scrolling together (e.g. for row numbers).
*/
function Scrolly(dataModel) {
// In the constructor we only initialize the parts shared by all ScrollyPanes.
this.data = dataModel;
this.numRows = 0;
this.options = {
paddingBottom: 0
};
this.panes = [];
// The items currently rendered. Same as this.data._itemModels, but we manage it manually
// to maintain the invariant that rendered DOM elements match this.activeItemModels.
this.activeItemModels = [];
// Data structure to store row heights and cumulative offsets of all rows.
this.rowHeights = [];
this.rowOffsetTree = new BinaryIndexedTree();
// TODO: Reconsider row height for rendering layouts / other tall elements in a scrolly.
this.minRowHeight = 23; // In pixels. Rows will be forced to be at least this tall.
this.numBuffered = 1; // How many rows to render outside the visible area.
this.numRendered = 1; // Total rows to render.
this.begin = 0; // Index of the first rendered row
this.end = 0; // Index of the row after the last rendered one
this.scrollTop = 0; // The scrollTop position of all panes.
this.shownHeight = 0; // The clientHeight of all panes.
this.blockBottom = 0; // Bottom of the rendered block, i.e. rowOffsetTree.getSumTo(this.end)
// Top in px of the rendered block; rowOffsetTree.getSumTo(this.begin)
this.blockTop = ko.observable(0);
this.blockTopPx = ko.computed(function() { return this.blockTop() + 'px'; }, this);
// The height of the scrolly_outer div
this.totalHeight = ko.observable(0);
this.totalHeightPx = ko.computed(function() { return this.totalHeight() + 'px'; }, this);
// Subscribe to data changes, and initialize with the current data.
this.subscription = this.autoDispose(
this.data.subscribe(this.onDataSplice, this, 'spliceChange'));
// The delayedUpdateSize helper is used by scheduleUpdateSize.
this.delayedUpdateSize = this.autoDispose(Delay.create());
// Initialize with the current data.
var array = this.data.all();
this.onDataSplice({ array: array, start: 0, added: array.length, deleted: [] });
//T198: Scrolly should have its own handler to remove, so that when removing handlers it does not
//remove other's handler.
let onResize = () => {
this.scheduleUpdateSize();
};
G.$(G.window).on('resize.scrolly', onResize);
this.autoDisposeCallback(() => G.$(G.window).off('resize.scrolly', onResize));
}
exports.Scrolly = Scrolly;
dispose.makeDisposable(Scrolly);
Scrolly.prototype.debug = function() {
console.log("Scrolly: numRows " + this.numRows + "; panes " + this.panes.length +
"; numRendered " + this.numRendered + " [" + this.begin + ", " + this.end + ")" +
"; block at " + this.blockTop() + " of " + this.totalHeight() +
"; scrolled to " + this.scrollTop + "; shownHeight " + this.shownHeight);
console.assert(this.numRows, this.data.peekLength,
"Wrong numRows; data is " + this.data.peekLength);
console.assert(this.numRows, this.rowHeights.length,
"Wrong rowHeights size " + this.rowHeights.length);
console.assert(this.numRows, this.rowOffsetTree.size(),
"Wrong rowOffsetTree size " + this.rowOffsetTree.size());
var count = Math.min(this.numRendered, this.numRows);
console.assert(this.end - this.begin, count,
"Wrong range size " + (this.end - this.begin));
console.assert(this.activeItemModels.length, count,
"Wrong activeItemModels.size " + this.activeItemModels.length);
var expectedHeight = this.blockBottom - this.blockTop();
if (count > 0) {
for (var p = 0; p < this.panes.length; p++) {
var topRow = this.panes[p].preparedRows[0].getBoundingClientRect();
var bottomRow = _.last(this.panes[p].preparedRows).getBoundingClientRect();
var blockHeight = bottomRow.bottom - topRow.top;
if (blockHeight !== expectedHeight) {
console.warn("Scrolly render pane #%d %dpx bigger from expected (%dpx per row). Ensure items have no margins",
p, blockHeight - expectedHeight, (blockHeight - expectedHeight) / count);
}
}
}
};
/**
* Helper that returns the Scrolly object currently associate with the given LazyArrayModel. It
* feels a bit wrong that the model knows about its user, but a LazyArrayModel generally only
* supports a single user (e.g. a single Scrolly), so it makes sense.
*/
function getInstance(dataModel) {
if (!dataModel._scrollyObj) {
dataModel._scrollyObj = Scrolly.create(dataModel);
dataModel._scrollyObj.autoDisposeCallback(() => delete dataModel._scrollyObj);
}
return dataModel._scrollyObj;
}
exports.getInstance = getInstance;
/**
* Adds a new pane that scrolls as part of this Scrolly object. This call itself does no
* rendering of the pane.
*/
Scrolly.prototype.addPane = function(containerElem, options, itemCreateFunc) {
var pane = new ScrollyPane(this, this.panes.length, containerElem, options, itemCreateFunc);
this.panes.push(pane);
this.scheduleUpdateSize();
};
/**
* Tells Scrolly to call updateSize after things have had a chance to render.
*/
Scrolly.prototype.scheduleUpdateSize = function() {
if (!this.isDisposed() && !this.delayedUpdateSize.isPending()) {
this.delayedUpdateSize.schedule(0, this.updateSize, this);
}
};
/**
* Measures the size of the panes and adjusts Scrolly parameters for how many rows to render.
* This should be called as soon as all Scrolly panes have been attached to the Document, and any
* time their outer size changes.
*/
Scrolly.prototype.updateSize = function() {
this.resetHeights();
this.shownHeight = Math.max(0, Math.max.apply(null, this.panes.map(function(pane) {
return pane.container.clientHeight;
})));
// Update counts of rows that are shown.
var numVisible = Math.max(1, Math.ceil(this.shownHeight / this.minRowHeight));
this.numBuffered = 5;
this.numRendered = numVisible + 2 * this.numBuffered;
// Re-render everything.
this.begin = gutil.clamp(this.begin, 0, this.numRows - this.numRendered);
this.end = gutil.clamp(this.begin + this.numRendered, 0, this.numRows);
this.render();
this.syncScrollPosition();
};
/**
* Called whenever any pane got scrolled. It syncs up all panes to the same scrollTop.
*/
Scrolly.prototype.onScroll = function(pane) {
this.scrollTo(pane.container.scrollTop);
};
/**
* Actively scroll all panes to the given scrollTop position, adjusting what is rendered as
* necessary.
*/
Scrolly.prototype.scrollTo = function(top) {
if (top === this.scrollTop) {
return;
}
this.scrollTop = top;
this.syncScrollPosition();
if (this.blockTop() <= top && this.blockBottom >= top + this.shownHeight) {
// Nothing needs to be re-rendered.
//console.log("scrollTo(%s): all elements already shown", top);
return;
}
// If we are scrolled to the bottom, restore our bottom position at the end. This happens
// in particular when reloading a page scrolled to the bottom. This is in no way general; it's
// just particularly easy to come across.
var atEnd = (top + this.shownHeight >= this.panes[0].container.scrollHeight);
var rowAtScrollTop = this.rowOffsetTree.getIndex(top);
this.begin = gutil.clamp(rowAtScrollTop - this.numBuffered, 0, this.numRows - this.numRendered);
this.end = gutil.clamp(this.begin + this.numRendered, 0, this.numRows);
// Do the magic.
this.render();
// If we were scrolled to the bottom, stay that way.
if (atEnd) {
this.scrollTop = this.panes[0].container.scrollHeight - this.shownHeight;
}
// Sometimes render() affects scrollTop of some panes; restore it to what we want by always
// calling syncScrollPosition() once more after render.
this.syncScrollPosition();
};
/**
* Called when the underlying data array changes.
*/
Scrolly.prototype.onDataSplice = function(splice) {
// We may need to adjust which rows are shown, but render does all the work of figuring out what
// changed and needs re-rendering.
this.numRows = this.data.peekLength;
// Update rowHeights: reproduce the splice, inserting minRowHeights for the new rows.
this.rowHeights.splice(splice.start, splice.deleted.length);
gutil.arraySplice(this.rowHeights, splice.start,
gutil.arrayRepeat(splice.added, this.minRowHeight));
// And rebuild the rowOffsetTree.
this.rowOffsetTree.fillFromValues(this.rowHeights);
this.totalHeight(this.rowOffsetTree.getTotal() + this.options.paddingBottom);
// We may be seeing the last row with space below it, so we'll use the same logic as in
// scrollTo, to make sure the rendered range includes all rows we should be seeing.
var rowAtScrollTop = this.rowOffsetTree.getIndex(this.scrollTop);
this.begin = gutil.clamp(rowAtScrollTop - this.numBuffered, 0, this.numRows - this.numRendered);
this.end = gutil.clamp(this.begin + this.numRendered, 0, this.numRows);
this.scheduleUpdateSize();
};
/**
* Set all panes to the common scroll position.
*/
Scrolly.prototype.syncScrollPosition = function() {
// Note that setting scrollTop triggers more scroll events, but those get ignored in onScroll
// because top === this.scrollTop.
var top = this.scrollTop;
for (var p = 0; p < this.panes.length; p++) {
// Reading .scrollTop may cause a synchronous reflow, so may be worse than setting it.
this.panes[p].container.scrollTop = top;
}
};
/**
* Creates a new item model. There is one for each rendered row. This uses the lazyArray to create
* the model, but adds a _rowHeightPx observable, used for controlling the row height.
*/
Scrolly.prototype.createItemModel = function() {
var item = this.data.makeItemModel();
item._rowHeightPx = ko.observable("");
return item;
};
/**
* Render rows in [begin, end) range, reusing any currently rendered rows as much as possible.
*/
Scrolly.prototype.render = function() {
//var startTime = Date.now();
// console.log("Scrolly render (top " + this.scrollTop + "): [" + this.begin + ", " +
// this.end + ") = " + (this.end - this.begin) + " rows");
// Invariant: all panes contain DOM elements parallel to this.activeItemModels.
// At the end, this.activeItemModels and DOM in panes represent the range [begin, end).
var i, p, item, index, delta,
count = this.end - this.begin,
array = this.data.peek(),
freeList = [];
assert(this.end <= array.length, "Scrolly render() exceeds data length of " + array.length);
// If scrolling up, we may adjust heights of rows, pushing down the row at scrollTop.
// If that happens, we will adjust scrollTop correspondingly.
var rowAtScrollTop = this.rowOffsetTree.getIndex(this.scrollTop);
var sumToScrollTop = this.rowOffsetTree.getSumTo(rowAtScrollTop);
// Place out-of-range itemModels into a free list.
for (i = 0; i < this.activeItemModels.length; i++) {
item = this.activeItemModels[i];
index = item._index();
if (index === null || index < this.begin || index >= this.end) {
freeList.push(item);
}
}
// Go through the models we need, and fill any missing ones.
for (i = 0, index = this.begin; i < count; i++, index++) {
if (!array[index]) {
// Use the freeList if possible, or create a new model otherwise.
item = freeList.shift() || this.createItemModel();
this.data.setItemModel(item, index);
// Unset the explicit height so that we can measure what it would naturally be.
item._rowHeightPx("");
}
}
// Unset anything else in the free list.
for (i = 0; i < freeList.length; i++) {
this.data.unsetItemModel(freeList[i]);
}
// Prepare DOM in all panes. This ensures that there is a DOM element for each active item.
// If prepareNewRows creates new DOM, it will unset _rowHeightPx, to mark it for measuring.
for (p = 0; p < this.panes.length; p++) {
this.panes[p].prepareNewRows();
}
// Measure the rows, and use the max across panes to update the stored heights.
// Note: this involves a reflow.
for (i = 0, index = this.begin; i < count; i++, index++) {
item = array[index];
if (item._rowHeightPx.peek() === "") {
var height = this.minRowHeight;
for (p = 0; p < this.panes.length; p++) {
height = Math.max(height, this.panes[p].measurePreparedRow(i));
}
height = Math.round(height);
delta = height - this.rowHeights[index];
if (delta !== 0) {
this.rowHeights[index] = height;
this.rowOffsetTree.addValue(index, delta);
}
}
}
// Set back the explicit heights of the rows. This is separate from the loop above to make sure
// we don't trigger additional reflows while measuring rows.
for (i = 0, index = this.begin; i < count; i++, index++) {
item = array[index];
item._rowHeightPx(this.rowHeights[index] + 'px');
}
// Render the new rows in the new order in each pane.
for (p = 0; p < this.panes.length; p++) {
this.panes[p].arrangePreparedRows();
}
// Save the current activeItemModels.
this.activeItemModels = array.slice(this.begin, this.end);
// console.log("activeItemModels now " + this.activeItemModels.length);
// console.log("rows in panes now are " + this.panes.map(
// function(p) { return p.blockDiv.childNodes.length; }).join(", "));
// Update heights and positions of the scrolling pane parts.
this.totalHeight(this.rowOffsetTree.getTotal() + this.options.paddingBottom);
this.blockTop(this.rowOffsetTree.getSumTo(this.begin));
this.blockBottom = this.rowOffsetTree.getSumTo(this.end);
// Adjust scrollTop if previously-shown top moved because of newly-rendered rows above.
delta = this.rowOffsetTree.getSumTo(rowAtScrollTop) - sumToScrollTop;
if (delta !== 0) {
//console.log("Adjusting scroll position by " + delta);
this.scrollTop += delta;
this.syncScrollPosition();
}
// this.debug();
// Report after timeout, to include the browser rendering time.
//var midTime = Date.now();
//setTimeout(function() {
// var endTime = Date.now();
// console.log("Scrolly render took " + (midTime - startTime) + " + " +
// (endTime - midTime) + " = " + (endTime - startTime) + " ms");
//}, 0);
};
/**
* Re-measure the given array of rows. Re-measures all rows if no array is given.
*/
Scrolly.prototype.resetHeights = function(optRowIndexList) {
var array = this.data.peek();
if (optRowIndexList) {
for (var i = 0; i < optRowIndexList.length; i++) {
var index = optRowIndexList[i];
var item = array[index];
if (item) {
item._rowHeightPx("");
}
}
} else {
this.activeItemModels.forEach(function(item) {
item._rowHeightPx("");
});
}
this.render();
};
/**
* Re-measure the given array of items.
* @param {Array[ItemModel]} items: The affected models (as returned by this.createItemModel).
*/
Scrolly.prototype.resetItemHeights = function(items) {
if (!this.isDisposed()) {
items.forEach(item => item._rowHeightPx(""));
this.render();
}
};
/**
* Scrolls to the position in pixels returned by calcPosition() function. The argument is a
* function because after the initial re-render, some rows may get re-measured and require
* an adjustment to the pixel position. So calcPosition() actually gets called twice.
*/
Scrolly.prototype.scrollToPosition = function(calcPosition) {
var scrollTop = calcPosition();
this.scrollTo(scrollTop);
// Repeat in case rows got re-measured during rendering and ended up being below the fold.
// We only may need to scroll a bit further, we should never have to re-render.
scrollTop = calcPosition();
if (scrollTop !== this.scrollTop) {
this.scrollTop = scrollTop;
this.syncScrollPosition();
}
};
/**
* Scrolls the given row into view.
*/
Scrolly.prototype.scrollRowIntoView = function(rowIndex) {
this.scrollToPosition(() => {
var top = this.rowOffsetTree.getSumTo(rowIndex);
var bottom = top + this.rowHeights[rowIndex];
// 43 = 23px to adjust for header, + 20px space
return gutil.clamp(this.scrollTop, bottom - this.shownHeight + 43, top - 10);
});
};
/**
* Takes a scroll position object, as stored in the section model, and scrolls to the saved
* position.
* @param {Integer} scrollPos.rowIndex: The index of the row to be scrolled to.
* @param {Integer} scrollPos.offset: The pixel distance of the scroll from the top of the row.
*/
Scrolly.prototype.scrollToSavedPos = function(scrollPos) {
this.scrollToPosition(() => this.rowOffsetTree.getSumTo(scrollPos.rowIndex) + scrollPos.offset);
};
/**
* Returns an object with the index of the first visible row in the view pane, and the
* scroll offset from the top of that row.
* Useful for recording the current state of the scrolly for later re-initialization.
*
* NOTE: There is a compelling case to scroll to the cursor after scrolling to the previous
* scroll position in either the case where rows are added/rearranged/removed, or simply in
* all cases. While this would likely prevent confusion in case changes push the cursor out
* of view, the case that the user scrolled away from the cursor intentionally should also be
* considered.
*/
Scrolly.prototype.getScrollPos = function() {
var rowIndex = this.rowOffsetTree.getIndex(this.scrollTop);
return {
rowIndex: rowIndex,
offset: this.scrollTop - this.rowOffsetTree.getSumTo(rowIndex)
};
};
/**
* Destroys a scrolly pane.
*/
Scrolly.prototype.destroyPane = function(pane) {
// When the last pane is removed, destroy the scrolly.
gutil.arrayRemove(this.panes, pane);
if (this.panes.length === 0) {
this.dispose();
}
};
//----------------------------------------------------------------------
/**
* Creates a virtual scrolling interface attached to a LazyArray. Multiple scrolly() calls used
* with the same `data` array will create parallel scrolling panes (e.g. row numbers and data
* scrolling together).
*
* The DOM for items is created using `itemCreateFunc`. As the user scrolls
* around, the item models are assigned to different items, and the DOM is moved around the page,
* to minimize rendering. This is intended to be used with koModel.mappedLazyArray.
*
* @param {LazyModelArray} data A LazyModelArray instance.
* @param {Object} options - Supported options include:
* paddingBottom {number} - Number of pixels to add to bottom of scrolly
* paddingRight {number} - Number of pixels to add to right of scrolly
* fitToWidth {bool} - Whether the scrolly holds a list of layouts
* @param {Function} itemCreateFunc A function called as `itemCreateFunc(item)` for a number of
* item models (which can get assigned to different items in `data`). Must return a single
* Node (not a DocumentFragment or null).
*/
function scrolly(data, options, itemCreateFunc) {
assert.equal(typeof itemCreateFunc, 'function');
options = options || {};
return function(elem) {
var scrollyObj = getInstance(data);
scrollyObj.addPane(elem, options, itemCreateFunc);
ko.utils.domData.set(elem, "scrolly", scrollyObj);
};
}
exports.scrolly = scrolly;

724
app/client/lib/koForm.css Normal file
View File

@@ -0,0 +1,724 @@
.kf_elem {
margin: 0.4rem 5%;
}
.kf_button_group {
border-radius: 4px;
overflow: hidden;
user-select: none;
border: 1px solid #e0e0e0;
}
.kf_button_group:hover {
border: 1px solid #d0d0d0;
}
.kf_button_group:active {
border: 1px solid #d0d0d0;
}
.kf_button_group.accent {
border: 1px solid #d8955a;
}
.kf_button_group.accent:hover {
border: 1px solid #c38045;
}
.kf_button_group.accent:active {
border: 1px solid #c38045;
}
.kf_button_group.lite {
border: none;
}
.kf_tooltip {
text-shadow: none;
position: absolute;
z-index: 10;
visibility: hidden;
}
.kf_tooltip_pointer {
width: 0;
height: 0;
margin: 0 auto;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-bottom: 5px solid rgba(60, 60, 60, .9);
}
.kf_tooltip_content {
cursor: default;
white-space: nowrap;
min-width: 16px;
min-height: 16px;
padding: 4px;
background-color: rgba(60, 60, 60, .9);
text-align: center;
color: #dadada;
border-radius: 5px;
}
div:hover > .kf_tooltip {
visibility: visible;
}
.kf_tooltip_info_text {
border-bottom: 1px solid #888;
margin-bottom: 3px;
}
.kf_tooltip_info_text > div {
padding-bottom: 4px;
}
.kf_tooltip_button {
cursor: pointer;
display: inline-block;
font-size: 1.2rem;
margin: 2px 4px;
}
.kf_tooltip_button:hover {
color: #fff;
}
.kf_tooltip_button.disabled {
cursor: default;
color: #222;
}
.kf_prompt {
position: relative;
width: 95%;
margin: 5px auto 10px auto;
}
.kf_prompt_content {
position: relative;
white-space: nowrap;
width: 100%;
min-width: 16px;
min-height: 16px;
padding: 4px;
background-color: white;
border-radius: 2px;
box-shadow: 0 1px 1px 1px rgba(0,0,0,0.15);
line-height: 1.1rem;
font-size: 1rem;
color: #606060;
z-index: 10;
}
.kf_prompt_pointer {
position: absolute;
top: -5px;
right: 20px;
width: 10px;
height: 10px;
transform: rotate(45deg);
box-shadow: 0 1px 1px 1px rgba(0,0,0,0.15);
z-index: 8;
}
.kf_prompt_pointer_overlap {
position: absolute;
top: -5px;
right: 20px;
width: 10px;
height: 10px;
background-color: white;
transform: rotate(45deg);
z-index: 11;
}
.kf_draggable {
display: inline-block;
cursor: grab;
}
.kf_draggable.ui-sortable-helper {
cursor: grabbing;
}
.kf_draggable.disabled {
cursor: default;
}
.kf_draggable__item {
margin: .2rem .5rem;
padding: .2rem;
background-color: var(--color-list-item);
}
.kf_draggable__item:hover {
background-color: var(--color-list-item-hover);
}
.kf_draggable__placeholder--horizontal {
display: inline-block;
height: 1px;
}
.kf_draggable__placeholder--vertical {
display: block;
width: 1px;
}
.kf_drag_indicator {
display: inline-block;
color: #777777;
}
.kf_draggable_content {
display: inline-block;
margin-left: 2px;
}
.kf_draggable:hover .drag_delete {
display: block;
}
.drag_delete {
display: none;
float: right;
cursor: pointer;
font-size: 1.0rem;
margin: 2px 2px 0 0;
color: #777777;
}
.kf_button {
text-align: center;
margin-left: -1px;
border-left: 1px solid #ddd;
padding: 0.5rem 0.5rem;
height: 2.3rem;
line-height: 1.1rem;
font-size: 1rem;
font-weight: bold;
color: #606060;
cursor: default;
user-select: none;
-moz-user-select: none;
background: linear-gradient(to bottom, #fafafa 0%,#f0f0f0 100%);
}
.kf_button.accent {
background: linear-gradient(to bottom, #f4a74e 0%,#ff9a00 100%);
color: #ffffff;
}
.kf_button.accent:active:not(.disabled) {
background: linear-gradient(to bottom, #ff9a00 0%,#f4a74e 100%);
color: #ffffff;
}
.kf_button.accent.disabled, .kf_button.accent.disabled:active {
color: #A0A0A0;
background: linear-gradient(to top, #fafafa 0%,#f0f0f0 100%);
}
.kf_button.lite {
height: 1.8rem;
padding: 0.4rem 0.2rem;
border: none;
background: none;
}
.kf_button.lite:hover:not(.disabled) {
background: #ddd;
color: black;
box-shadow: none;
}
.kf_check_button.lite.active:not(.disabled) {
background: #ddd;
color: black;
box-shadow: none;
}
.kf_check_button.lite:active:not(.disabled),
.kf_check_button.lite.active:active:not(.disabled) {
box-shadow: inset 0px 0px 2px 0px rgba(0,0,0,0.2);
background: #ddd;
}
.kf_button:first-child {
margin-left: 0;
border-left: none;
border-top-left-radius: 3px;
border-bottom-left-radius: 3px;
}
.kf_button:last-child {
border-right: none;
border-top-right-radius: 3px;
border-bottom-right-radius: 3px;
}
.kf_button:active:not(.disabled) {
background: linear-gradient(to bottom, #f0f0f0 0%,#fafafa 100%);
box-shadow: inset 0px 0px 2px 0px rgba(0,0,0,0.2);
}
.kf_button.active {
box-shadow: inset 0px 0px 2px 0px rgba(0,0,0,0.2);
background: linear-gradient(to bottom, #ff9a00 0%,#f4a74e 100%);
color: #ffffff;
}
.kf_button.active:active:not(.disabled) {
box-shadow: inset 0px 0px 3px 0px rgba(0,0,0,0.4);
background: linear-gradient(to bottom, #ff9a00 0%,#f4a74e 100%);
}
.kf_button.disabled, .kf_button.disabled:active {
color: #A0A0A0;
}
.kf_logo_button {
height: 34px;
}
.kf_btn_logo {
height: 25px;
width: 25px;
background-repeat: no-repeat;
background-size: contain;
background-position: center;
margin-right: 5px;
}
.kf_btn_text {
font-size: 1.1rem;
height: 1.1rem;
margin: 0.7rem;
}
.kf_check_button.disabled, .kf_check_button.disabled:active {
color: #A0A0A0;
background: linear-gradient(to bottom, #f4f4f4 0%,#e8e8e8 100%);
box-shadow: none;
}
.kf_checkbox_label {
}
.kf_checkbox {
width: 1.6rem;
height: 1.6rem;
margin: 0 0 0 0 !important;
vertical-align: middle;
position: relative;
}
.kf_checkbox:focus {
outline: none !important;
}
.kf_radio_label {
font-weight: normal;
font-size: 1.1rem;
margin: 0;
}
.kf_radio {
margin: 0 0.5rem !important;
outline: none !important;
vertical-align: middle;
}
/** spinner **/
.kf_spinner {
position: absolute;
box-sizing: content-box;
width: 9px;
height: 17px;
right: 1px;
top: -1px;
color: #606060;
overflow: hidden;
padding: 1px;
}
.kf_spinner:hover {
background: linear-gradient(to bottom, rgba(255,255,255,1) 0%, rgba(252,252,252,1) 29%, rgba(239,239,239,1) 50%, rgba(232,232,232,1) 50%, rgba(242,242,242,1) 100%);
border: 1px solid grey;
border-radius: 6px;
padding: 0px;
}
.kf_spinner_half {
height: 9px;
overflow: hidden;
}
.kf_spinner_half:active:not(.disabled) {
background: linear-gradient(to bottom, rgba(147,180,242,1) 0%, rgba(135,168,233,1) 10%, rgba(115,149,218,1) 25%, rgba(115,150,224,1) 37%, rgba(115,153,230,1) 50%, rgba(86,134,219,1) 51%, rgba(130,174,235,1) 83%, rgba(151,194,243,1) 100%);
}
.kf_spinner_arrow {
width: 0px;
height: 0px;
border-left: 3px solid transparent;
border-right: 3px solid transparent;
}
.kf_spinner_arrow.up {
border-top: none;
border-bottom: 5px solid #606060;
margin: 2px auto;
}
.kf_spinner_arrow.down {
border-top: 5px solid #606060;
border-bottom: none;
margin: 1px auto 2px auto;
}
.kf_collapser {
height: 2.2rem;
font-size: 1.1rem;
white-space: nowrap;
cursor: default;
user-select: none;
-moz-user-select: none;
margin: .5rem;
}
.kf_triangle_toggle {
display: inline-block;
font-size: .9rem;
width: 1.5rem;
color: #808080;
}
.kf_triangle_toggle:active {
color: #606060;
}
.kf_label {
white-space: nowrap;
font-size: 1.1rem;
cursor: default;
}
.kf_light_label {
font-size: 1.0rem;
white-space: nowrap;
}
.kf_text {
width: 100%;
}
.kf_text:focus {
outline: none;
border: 2px solid #ff9a00;
box-shadow: inset 0px 0px 1px 0px rgba(0,0,0,0.2);
}
.kf_text:disabled {
color: #888;
}
/** For editableLabel*/
.kf_editable_label {
min-height: 1.8rem;
white-space: nowrap;
position: relative;
overflow: hidden;
}
.kf_elabel_text {
overflow: hidden;
text-overflow: ellipsis;
}
.kf_elabel_input {
border-width: 0px;
text-align: inherit;
top: 0;
left: 0;
padding: 0;
color: #333;
}
.elabel_content_measure {
position: fixed;
left: 0px;
top: 0px;
padding-top: 2px;
padding-right: 1em;
border: none;
visibility: hidden;
overflow: visible;
}
/****/
.kf_num_text {
display: block;
width: 100%;
text-align: right;
}
.kf_row {
margin: 0.4rem 2.5%;
align-items: center;
-webkit-align-items: center;
}
.kf_row > .kf_elem {
margin: 0 2.5%;
}
.kf_elem > .kf_elem {
margin: 0;
}
.kf_help_row {
margin-top: -0.2rem;
text-align: center;
font-size: 1.1rem;
}
.kf_help {
font-weight: normal;
font-size: 1.1rem;
}
.kf_left {
text-align: left;
}
.kf_right {
text-align: right;
}
fieldset:disabled {
color: #A0A0A0;
}
.kf_status_panel {
padding:0.5rem;
box-shadow:0 1px 2px #aaa;
background: white;
margin:0 0.5rem 0.5rem;
border-radius:3px;
overflow:hidden;
}
.kf_status_indicator {
border-right: 1px black;
font-size: 4rem;
flex-grow: 0;
-moz-user-select: none;
-webkit-user-select: none;
-ms-user-select: none;
user-select:none;
}
.kf_status_detail {
align-self: center;
}
.kf_status_indicator.kf_status_success {
color: forestgreen;
}
.kf_status_indicator.kf_status_info {
color: royalblue;
}
.kf_status_indicator.kf_status_warning {
color: orange;
}
.kf_status_indicator.kf_status_error {
color: firebrick;
}
.kf_scroll_shadow_outer {
height: 0px;
position: relative;
}
.kf_scroll_shadow {
position: absolute;
bottom: 0;
width: 100%;
height: 9px;
border-bottom: 1px solid #A0A0A0;
box-shadow: 0px 6px 3px -3px #A0A0A0;
z-index: 100;
}
.kf_scrollable {
overflow-x: hidden;
overflow-y: auto;
}
/* Based on scrollbox CSS detailed by Roman Komarov - http://kizu.ru/en/fun/shadowscroll/ */
.scrollbox {
position: relative;
z-index: 1;
overflow: auto;
max-height: 200px;
background: #FFF no-repeat;
background-image:
radial-gradient(farthest-side at 50% 0, rgba(0,0,0,0.2), rgba(0,0,0,0)),
radial-gradient(farthest-side at 50% 100%, rgba(0,0,0,0.2), rgba(0,0,0,0));
background-position: 0 0, 0 100%;
background-size: 100% 14px;
}
.scrollbox:before,
.scrollbox:after {
content: "";
position: relative;
z-index: -1;
display: block;
height: 30px;
margin: 0 0 -30px;
background: linear-gradient(to bottom,#FFF,#FFF 30%,rgba(255,255,255,0));
}
.scrollbox:after {
margin: -30px 0 0;
background: linear-gradient(to bottom,rgba(255,255,255,0),#FFF 70%,#FFF);
}
.kf_select {
width: 100%;
height: 2.5rem;
border: 1px solid #e0e0e0;
padding: 0.5rem 0.5rem;
line-height: 1.1rem;
font-size: 1rem;
font-weight: bold;
color: #606060;
cursor: default;
border-radius: 4px;
background-image: none;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
background: linear-gradient(to bottom, #fafafa 0%,#f0f0f0 100%);
}
.kf_select:hover {
border: 1px solid #d0d0d0;
}
.kf_select:active {
box-shadow: inset 0px 0px 2px 0px rgba(0,0,0,0.2);
border: 1px solid #d0d0d0;
}
.kf_select:focus {
outline: none;
}
.kf_select:-moz-focusring {
color: transparent;
text-shadow: 0 0 0 #000;
}
.kf_select:disabled {
color: #A0A0A0;
}
.kf_select_arrow:after {
content: '\25bc';
margin-left: -1.4rem;
font-size: .7rem;
pointer-events:none;
}
.kf_separator {
color: #C8C8C8;
background-color: #C8C8C8;
border: 0;
height: 1px;
width: 100%;
margin: 1rem 0;
}
/*****************************************/
/* CSS for midTabs and midTab functions */
.kf_mid_tabs {
height: 100%;
position: relative;
}
.kf_mid_tab_labels {
padding: 0 4rem;
}
.kf_mid_tab_label {
margin-left: -1px;
border-left: 1px solid #e4e4e4;
text-align: center;
padding: 0.5rem 0.5rem;
font-size: 1.3rem;
font-weight: bold;
color: #bfbfbf;
cursor: pointer;
user-select: none;
-moz-user-select: none;
z-index: 1;
}
.kf_mid_tab_label:first-child {
border-left: none;
}
.kf_mid_tab_label:active, .kf_mid_tab_label.active:active {
color: black;
}
.kf_mid_tab_label.active {
color: black;
cursor: default;
}
.kf_mid_tab_content {
padding-top: 1rem;
}
/*****************************************/
/* CSS for topTabs and topTab functions. */
.kf_top_tabs {
height: 100%;
}
.kf_top_tab_labels {
}
.kf_top_tab_label {
margin-left: -1px;
border: 1px solid #C8C8C8;
text-align: center;
padding: 0.5rem 0.5rem;
font-weight: bold;
font-size: 1.1rem;
color: #606060;
cursor: default;
user-select: none;
-moz-user-select: none;
border-radius: 5px 5px 0 0;
background: #eee;
}
.kf_top_tab_label.active {
background: none;
border-bottom: none;
z-index: 10;
}
.kf_top_tab_label.active:active {
background: linear-gradient(to bottom, rgba(65,141,225,1) 0%,rgba(38,125,200,1) 100%);
}
.kf_top_tab_container {
height: 100%;
position: relative;
}
.kf_top_tab_content {
height: 100%;
padding-top: 1rem;
width: 100%;
position: relative;
}

1163
app/client/lib/koForm.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,90 @@
/**
* koSession offers observables whose values are tied to the browser session or history:
*
* sessionValue(key) - an observable preserved across history entries and reloads.
*
* Note: we could also support "browserValue", shared across all tabs and across browser restarts
* (same as sessionValue but using window.localStorage), but it seems more appropriate to store
* such values on the server.
*/
/* global window, $ */
var _ = require('underscore');
var ko = require('knockout');
/**
* Maps a string key to an observable. The space of keys is shared for all kinds of observables,
* and they differ only in where they store their state. Each observable gets several extra
* properties:
* @property {String} ksKey The key used for storage. It should be unique across koSession values.
* @property {Object} ksDefault The default value if the storage doesn't have one.
* @property {Function} ksFetch The method to fetch the value from storage.
* @property {Function} ksSave The method to save the value to storage.
*/
var _sessionValues = {};
function createObservable(key, defaultValue, methods) {
var obs = _sessionValues[key];
if (!obs) {
_sessionValues[key] = obs = ko.observable();
obs.ksKey = key;
obs.ksDefaultValue = defaultValue;
obs.ksFetch = methods.fetch;
obs.ksSave = methods.save;
obs.dispose = methods.dispose;
// We initialize the observable before setting rateLimit, to ensure that the initialization
// doesn't end up triggering subscribers that are about to be added (which seems to be a bit
// of a problem with rateLimit extender, and possibly deferred). This workaround relies on the
// fact that the extender modifies its target without creating a new one.
obs(obs.ksFetch());
obs.extend({deferred: true});
obs.subscribe(function(newValue) {
if (newValue !== this.ksFetch()) {
console.log("koSession: %s changed %s -> %s", this.ksKey, this.ksFetch(), newValue);
this.ksSave(newValue);
}
}, obs);
}
return obs;
}
/**
* Returns an observable whose value sticks across reloads and navigation, but is different for
* different browser tabs. E.g. it may be used to reflect whether a side pane is open.
* The `key` isn't visible to the user, so pick any unique string name.
*/
function sessionValue(key, optDefault) {
return createObservable(key, optDefault, sessionValueMethods);
}
exports.sessionValue = sessionValue;
var sessionValueMethods = {
'fetch': function() {
var value = window.sessionStorage.getItem(this.ksKey);
if (!value) {
return this.ksDefaultValue;
}
try {
return JSON.parse(value);
} catch (e) {
return this.ksDefaultValue;
}
},
'save': function(value) {
window.sessionStorage.setItem(this.ksKey, JSON.stringify(value));
},
'dispose': function(value) {
window.sessionStorage.removeItem(this.ksKey);
}
};
function onApplyState() {
_.each(_sessionValues, function(obs, key) {
obs(obs.ksFetch());
});
}
$(window).on('applyState', onApplyState);

217
app/client/lib/koUtil.js Normal file
View File

@@ -0,0 +1,217 @@
var _ = require('underscore');
var ko = require('knockout');
/**
* This is typed to declare that the observable/computed supports subscribable.fn methods
* added in this utility.
*/
function withKoUtils(obj) {
return obj;
}
exports.withKoUtils = withKoUtils;
/**
* subscribeInit is a convenience method, equivalent to knockout's observable.subscribe(), but
* also calls the callback immediately with the observable's current value.
*
* It is added to the prototype for all observables, as long as this module is included anywhere.
*/
ko.subscribable.fn.subscribeInit = function(callback, target, event) {
var sub = this.subscribe(callback, target, event);
callback.call(target, this.peek());
return sub;
};
/**
* Add a named method `assign` to knockout subscribables (including observables) to assign a new
* value. This way we can move away from using callable objects for everything, since callable
* objects require hacking at prototypes.
*/
ko.subscribable.fn.assign = function(value) {
this(value);
};
/**
* Convenience method to modify a non-primitive value and assign it back. E.g. if foo() is an
* observable whose value is an array, then
*
* foo.modifyAssign(function(array) { array.push("test"); });
*
* is one-liner equivalent to:
*
* var array = foo.peek();
* array.push("text");
* foo(array);
*
* Whenever using a non-primitive value, be careful that it's not shared with other code, which
* might modify it without any observable subscriptions getting triggered.
*/
ko.subscribable.fn.modifyAssign = function(modifierFunc) {
var value = this.peek();
modifierFunc(value);
this(value);
};
/**
* Tells a computed observable which may return non-primitive values (e.g. objects) that it should
* only notify subscribers when the computed value is not equal to the last one (using "===").
*/
ko.subscribable.fn.onlyNotifyUnequal = function() {
this.equalityComparer = function(a, b) { return a === b; };
return this;
};
let _handlerFunc = (err) => {};
let _origKoComputed = ko.computed;
/**
* If setComputedErrorHandler is used, this wrapper catches and swallows errors from the
* evaluation of any computed. Any exception gets passed to _handlerFunc, and the computed
* evaluates successfully to its previous value (or _handlerFunc may rethrow the error).
*/
function _wrapComputedRead(readFunc) {
return function() {
let lastValue;
try {
return (lastValue = readFunc.call(this));
} catch (err) {
console.error("ERROR in ko.computed: %s", err);
_handlerFunc(err);
return lastValue;
}
};
}
/**
* If called, exceptions thrown while evaluating any ko.computed observable will get passed to
* handlerFunc and swallowed. Unless the handlerFunc rethrows them, the computed will evaluate
* successfully to its previous value.
*
* Note that this is merely an attempt to do the best we can to keep going in the face of
* application bugs. The returned value is not actually correct, and relying on this incorrect
* value may cause even worse bugs elsewhere in the application. It is important that any errors
* caught via this mechanism get reported, debugged, and fixed.
*/
function setComputedErrorHandler(handlerFunc) {
_handlerFunc = handlerFunc;
// Note that ko.pureComputed calls to ko.computed, so doesn't need its own override.
ko.computed = function(funcOrOptions, funcTarget, options) {
if (typeof funcOrOptions === 'function') {
funcOrOptions = _wrapComputedRead(funcOrOptions);
} else {
funcOrOptions.read = _wrapComputedRead(funcOrOptions.read);
}
return _origKoComputed(funcOrOptions, funcTarget, options);
};
}
exports.setComputedErrorHandler = setComputedErrorHandler;
/**
* Returns an observable which mirrors the passed-in argument, but returns a default value if the
* underlying field is falsy. Writes to the returned observable translate directly to writes to the
* underlying one. The default may be a function, evaluated as for computed observables,
* with optContext as the context.
*/
function observableWithDefault(obs, defaultOrFunc, optContext) {
if (typeof defaultOrFunc !== 'function') {
var def = defaultOrFunc;
defaultOrFunc = function() { return def; };
}
return ko.pureComputed({
read: function() { return obs() || defaultOrFunc.call(this); },
write: function(val) { obs(val); },
owner: optContext
});
}
exports.observableWithDefault = observableWithDefault;
/**
* Return an observable which mirrors the passed-in argument, but convert to Number value. Write to
* to the returned observable translate to write to the underlying one a Number value.
*/
function observableNumber(obs) {
return ko.pureComputed({
read: () => Number(obs()),
write: (val) => {
obs(Number(val));
}
});
}
exports.observableNumber = observableNumber;
/**
* Same interface as ko.computed(), except that it disposes the values it evaluates to. If an
* observable is set to values which are created on the fly and need to be disposed (e.g.
* components), use foo = computedAutoDispose(...). Whenever the value of foo() changes (and when
* foo itself is disposed), the previous value's `dispose` method gets called.
*/
function computedAutoDispose(optionsOrReadFunc, target, options) {
// Note: this isn't quite possible to do as a knockout extender, specifically to get correct the
// pure vs non-pure distinction (sometimes the computed must be pure to avoid evaluation;
// sometimes it has side-effects and must not be pure).
var value = null;
function setNewValue(newValue) {
if (value && value !== newValue) {
ko.ignoreDependencies(value.dispose, value);
}
value = newValue;
return newValue;
}
var origRead;
if (typeof optionsOrReadFunc === "object") {
// Single-parameter syntax.
origRead = optionsOrReadFunc.read;
options = _.clone(optionsOrReadFunc);
} else {
origRead = optionsOrReadFunc;
options = _.defaults({ owner: target }, options || {});
}
options.read = function() {
return setNewValue(origRead.call(this));
};
var result = ko.computed(options);
var origDispose = result.dispose;
result.dispose = function() {
setNewValue(null);
origDispose.call(result);
};
return result;
}
exports.computedAutoDispose = computedAutoDispose;
/**
* Helper for building disposable components that depend on a few observables. The callback is
* evaluated as for a knockout computed observable, which creates dependencies on any observables
* mentioned in it. But the return value of the callback should be a function ("builder"), which
* is called to build the resulting value. Observables mentioned in the evaluation of the builder
* do NOT create dependencies. In addition, the built value gets disposed automatically when it
* changes.
*
* The optContext argument serves as the context for the callback.
*
* For example,
* var foo = ko.observable();
* koUtil.computedBuilder(function() {
* return MyComponent.create.bind(MyComponent, foo());
* }, this);
*
* In this case, whenever foo() changes, MyComponent.create(foo()) gets called, and
* previously-returned component gets disposed. Observables mentioned during MyComponent's
* construction do not trigger its rebuilding (as they would if a plain ko.computed() were used).
*/
function computedBuilder(callback, optContext) {
return computedAutoDispose(function() {
var builder = callback.call(optContext);
return builder ? ko.ignoreDependencies(builder) : null;
}, null, { pure: false });
}
exports.computedBuilder = computedBuilder;

212
app/client/lib/listEntry.ts Normal file
View File

@@ -0,0 +1,212 @@
import {basicButton, primaryButton} from 'app/client/ui2018/buttons';
import {colors, testId} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {Computed, Disposable, dom, DomElementArg, Observable, styled} from 'grainjs';
import isEqual = require('lodash/isEqual');
import uniq = require('lodash/uniq');
/**
* ListEntry class to build a textarea of unique newline separated values, with a nice
* display mode when the values are not being edited.
*
* Usage:
* > dom.create(ListEntry, values, (vals) => choices.saveOnly(vals));
*/
export class ListEntry extends Disposable {
// Should start in edit mode if there are no initial values.
private _isEditing: Observable<boolean> = Observable.create(this, this._values.get().length === 0);
private _textVal: Observable<string> = Observable.create(this, "");
constructor(
private _values: Observable<string[]>,
private _onSave: (values: string[]) => void
) {
super();
// Since the saved values can be modified outside the ListEntry (via undo/redo),
// add a listener to update edit status on changes.
this.autoDispose(this._values.addListener(values => {
if (values.length === 0) { this._textVal.set(""); }
this._isEditing.set(values.length === 0);
}));
}
// Arg maxRows indicates the number of rows to display when the textarea is inactive.
public buildDom(maxRows: number = 6): DomElementArg {
return dom.domComputed(this._isEditing, (editMode) => {
if (editMode) {
// Edit mode dom.
let textArea: HTMLTextAreaElement;
return cssVerticalFlex(
cssListBox(
textArea = cssListTextArea(
dom.prop('value', this._textVal),
dom.on('input', (ev, elem) => this._textVal.set(elem.value)),
(elem) => this._focusOnOpen(elem),
dom.on('blur', (ev, elem) => { setTimeout(() => this._save(elem), 0); }),
dom.onKeyDown({Escape: (ev, elem) => this._save(elem)}),
// Keep height to be two rows taller than the number of text rows
dom.style('height', (use) => {
const rows = use(this._textVal).split('\n').length;
return `${(rows + 2) * 22}px`;
})
),
cssHelpLine(
cssIdeaIcon('Idea'), 'Type one option per line'
),
testId('list-entry')
),
// Show buttons if the textArea has or had valid text content
dom.maybe((use) => use(this._values).length > 0 || use(this._textVal).trim().length > 0, () =>
cssButtonRow(
primaryButton('Save', {style: 'margin-right: 8px;'},
// Prevent textarea focus loss on mousedown
dom.on('mousedown', (ev) => ev.preventDefault()),
dom.on('click', () => this._save(textArea)),
testId('list-entry-save')
),
basicButton('Cancel',
// Prevent textarea focus loss on mousedown
dom.on('mousedown', (ev) => ev.preventDefault()),
dom.on('click', () => this._cancel()),
testId('list-entry-cancel')
)
)
)
);
} else {
// Inactive display dom.
const someValues = Computed.create(null, this._values, (use, values) =>
values.length <= maxRows ? values : values.slice(0, maxRows - 1));
return cssListBoxInactive(
dom.autoDispose(someValues),
dom.forEach(someValues, val => this._row(val)),
// Show description row for any remaining rows
dom.maybe(use => use(this._values).length > maxRows, () =>
this._row(
dom.text((use) => `+${use(this._values).length - (maxRows - 1)} more`)
)
),
dom.on('click', () => this._startEditing()),
testId('list-entry')
);
}
});
}
// Build a display row with the given text value
private _row(...domArgs: DomElementArg[]): Element {
return cssListRow(
...domArgs,
testId('list-entry-row')
);
}
// Indicates whether the listEntry currently has saved values.
private _hasValues(): boolean {
return this._values.get().length > 0;
}
private _startEditing(): void {
this._textVal.set(this._hasValues() ? (this._values.get().join('\n') + '\n') : '');
this._isEditing.set(true);
}
private _save(elem: HTMLTextAreaElement): void {
if (!this._isEditing.get()) { return; }
const newValues = uniq(
elem.value.split('\n')
.map(val => val.trim())
.filter(val => val !== '')
);
// Call user save function if the values have changed.
if (!isEqual(this._values.get(), newValues)) {
// Because of the listener on this._values, editing will stop if values are updated.
this._onSave(newValues);
} else {
this._cancel();
}
}
private _cancel(): void {
if (this._hasValues()) {
this._isEditing.set(false);
} else {
this._textVal.set("");
}
}
private _focusOnOpen(elem: HTMLTextAreaElement): void {
// Do not grab focus if the textArea is empty, since it indicates that the listEntry
// started in edit mode, and was not set to be so by the user.
if (this._textVal.get()) {
setTimeout(() => focus(elem), 0);
}
}
}
// Helper to focus on the textarea and select/scroll to the bottom
function focus(elem: HTMLTextAreaElement) {
elem.focus();
elem.setSelectionRange(elem.value.length, elem.value.length);
elem.scrollTo(0, elem.scrollHeight);
}
const cssListBox = styled('div', `
width: 100%;
background-color: white;
padding: 1px;
border: 1px solid ${colors.hover};
border-radius: 4px;
`);
const cssListBoxInactive = styled(cssListBox, `
cursor: pointer;
border: 1px solid ${colors.darkGrey};
&:hover {
border: 1px solid ${colors.hover};
}
`);
const cssListTextArea = styled('textarea', `
width: 100%;
max-height: 150px;
padding: 2px 12px;
line-height: 22px;
border: none;
outline: none;
resize: none;
`);
const cssListRow = styled('div', `
margin: 4px;
padding: 4px 8px;
color: ${colors.dark};
background-color: ${colors.mediumGrey};
border-radius: 4px;
overflow: hidden;
text-overflow: ellipsis;
`);
const cssHelpLine = styled('div', `
display: flex;
margin: 2px 8px 8px 8px;
color: ${colors.slate};
`);
const cssIdeaIcon = styled(icon, `
background-color: ${colors.lightGreen};
margin-right: 4px;
`);
const cssVerticalFlex = styled('div', `
width: 100%;
display: flex;
flex-direction: column;
`);
const cssButtonRow = styled('div', `
display: flex;
margin: 16px 0;
`);

View File

@@ -0,0 +1,19 @@
const Promise = require('bluebird');
const G = require('./browserGlobals').get('document');
/**
* Load dynamically an external JS script from the given URL. Returns a promise that is
* resolved when the script is loaded.
*/
function loadScript(url) {
return new Promise((resolve, reject) => {
let script = G.document.createElement("script");
script.type = "text/javascript";
script.onload = resolve;
script.onerror = reject;
script.src = url;
G.document.getElementsByTagName("head")[0].appendChild(script);
});
}
module.exports = loadScript;

View File

@@ -0,0 +1,19 @@
import {Observable} from 'grainjs';
/**
* Helper to create a boolean observable whose state is stored in localStorage.
*/
export function localStorageBoolObs(key: string): Observable<boolean> {
const obs = Observable.create(null, Boolean(localStorage.getItem(key)));
obs.addListener((val) => val ? localStorage.setItem(key, 'true') : localStorage.removeItem(key));
return obs;
}
/**
* Helper to create a string observable whose state is stored in localStorage.
*/
export function localStorageObs(key: string): Observable<string|null> {
const obs = Observable.create<string|null>(null, localStorage.getItem(key));
obs.addListener((val) => (val === null) ? localStorage.removeItem(key) : localStorage.setItem(key, val));
return obs;
}

View File

@@ -0,0 +1,37 @@
.multiselect-list-item {
margin: 5px;
padding: 5px;
font-size: 1.2rem;
color: black;
background-color: #555;
}
.multiselect-remove {
float: right;
font-size: 1.0rem;
visibility: hidden;
cursor: pointer;
}
.multiselect-label {
display: inline-block;
}
.multiselect-list-item:hover > .multiselect-remove {
visibility: visible;
}
.multiselect-input {
width: 95%;
margin: 0 2.5%;
}
.multiselect-hint {
font-size: 1.1rem;
margin: 0 2.5%;
color: var(--color-hint-text);
}
.multiselect-selected:not(.multiselect-empty) {
margin-bottom: 6px;
}

View File

@@ -0,0 +1,89 @@
/* global $ */
var ko = require('knockout');
var kf = require('./koForm');
var dom = require('./dom');
var kd = require('./koDom');
/**
* Creates a multi-select implemented with a draggable list of selected items followed by
* an autocomplete input containing the remaining selectable items.
*
* Items in `selected` list can be arbitrary objects, and get passed to remove()/reorder().
* Items for auto-complete should have 'value' and 'label' properties, and are passed to add().
*
* @param {Function} source(request, response):
* Called with the autocomplete request, containing .term with the search term entered so far.
* The response callback must be called with a list of suggested items (with 'value' and
* 'label' properties). The selected item is passed to add(). The caller should filter out
* items already selected if appropriate.
* @param {koArray} selected:
* KoArray of selected items.
* @param {Function} itemCreateFunc:
* Called as `itemCreateFunc(item)` for each element of the `selected` array. Should return a
* single Node, or null or undefined to omit that node.
* @param {Function} options.add(autoCompleteItem):
* Called to add a new item.
* @param {Function} options.remove(item):
* Called to remove a selected item.
* @param {Function} options.reorder(item, nextItem):
* Optional. Called to move item to just before nextItem (or to the end when nextItem is null).
* If omitted, items are not draggable. The callback must update the 'selected' array to
* match the UI. See koForm.draggableList for more details.
* @param {String} options.hint:
* Optional. Text to display above the input if nothing is selected.
*/
function multiselect(source, selected, itemCreateFunc, options) {
options = options || {};
var noneSelected = ko.computed(() => selected.all().length === 0);
var selector;
var input;
// Calls add on the item, closes the autocomplete and clears the input.
function selectItem(item) {
options.add(item);
$(input).autocomplete("close");
input.value = '';
}
// Searches for the item by label in the source and selects the first match.
function searchItem(searchTerm) {
source({ term: searchTerm }, resp => {
var item = resp.find(respItem => respItem.label === searchTerm);
if (item) { selectItem(item); }
});
}
// Main selector dom with draggable list.
selector = dom('div.multiselect',
dom.autoDispose(noneSelected),
dom('div.multiselect-selected',
kf.draggableList(selected, item => itemCreateFunc(item), {
drag_indicator: Boolean(options.reorder),
removeButton: true,
reorder: options.reorder,
remove: options.remove
}),
kd.toggleClass('multiselect-empty', noneSelected),
kd.maybe(noneSelected, () => dom('div.multiselect-hint', options.hint || ""))
),
input = dom('input.multiselect-input',
dom.on('focus', () => { $(input).autocomplete("search"); }),
dom.on('change', () => { searchItem(input.value); })
)
);
// Set up the auto-complete widget.
$(input).autocomplete({
source: source,
minLength: 0,
delay: 10,
focus: () => false, // Keeps input empty on focus
select: function(event, ui) {
selectItem(ui.item);
return false;
}
});
return selector;
}
module.exports = multiselect;

View File

@@ -0,0 +1,53 @@
/**
* createSessionObs() creates an observable tied to window.sessionStorage, i.e. preserved for the
* lifetime of a browser tab for the current origin.
*/
import {safeJsonParse} from 'app/common/gutil';
import {IDisposableOwner, Observable} from 'grainjs';
/**
* Creates and returns an Observable tied to sessionStorage, to make its value stick across
* reloads and navigation, but differ across browser tabs. E.g. whether a side pane is open.
*
* The `key` isn't visible to the user, so pick any unique string name. You may include the
* docId into the key, to remember a separate value for each doc.
*
* To use it, you must specify a default, and a validation function: this module exposes a few
* helpful ones. Some examples:
*
* panelWidth = createSessionObs(owner, "panelWidth", 240, isNumber); // Has type Observable<number>
*
* import {StringUnion} from 'app/common/StringUnion';
* const SomeTab = StringUnion("foo", "bar", "baz");
* tab = createSessionObs(owner, "tab", "baz", SomeTab.guard); // Type Observable<"foo"|"bar"|"baz">
*/
export function createSessionObs<T>(
owner: IDisposableOwner|null,
key: string,
_default: T,
isValid: (val: any) => val is T,
) {
function fromString(value: string|null): T {
const parsed = value == null ? null : safeJsonParse(value, null);
return isValid(parsed) ? parsed : _default;
}
function toString(value: T): string|null {
return value === _default || !isValid(value) ? null : JSON.stringify(value);
}
const obs = Observable.create<T>(owner, fromString(window.sessionStorage.getItem(key)));
obs.addListener((value: T) => {
const stored = toString(value);
if (stored == null) {
window.sessionStorage.removeItem(key);
} else {
window.sessionStorage.setItem(key, stored);
}
});
return obs;
}
/** Helper functions to check simple types, useful for the `isValid` argument to createSessionObs. */
export function isNumber(t: any): t is number { return typeof t === 'number'; }
export function isBoolean(t: any): t is boolean { return typeof t === 'boolean'; }
export function isString(t: any): t is string { return typeof t === 'string'; }

View File

@@ -0,0 +1,92 @@
import {GristDoc} from 'app/client/components/GristDoc';
import {ClientColumnGetters} from 'app/client/models/ClientColumnGetters';
import {ViewSectionRec} from 'app/client/models/entities/ViewSectionRec';
import * as rowset from 'app/client/models/rowset';
import {MANUALSORT} from 'app/common/gristTypes';
import {SortFunc} from 'app/common/SortFunc';
import * as ko from 'knockout';
import range = require('lodash/range');
/**
* Adds a column to the given sort spec, replacing its previous occurrence if
* it's already in the sort spec.
*/
export function addToSort(sortSpecObs: ko.Observable<number[]>, colRef: number) {
const spec = sortSpecObs.peek();
const index = spec.findIndex((colRefSpec) => Math.abs(colRefSpec) === Math.abs(colRef));
if (index !== -1) {
spec.splice(index, 1, colRef);
} else {
spec.push(colRef);
}
sortSpecObs(spec);
}
// Takes an activeSortSpec and sortRef to flip (negative sortRefs signify descending order) and returns a new
// activeSortSpec with that sortRef flipped (or original spec if sortRef not found).
export function flipColDirection(spec: number[], sortRef: number): number[] {
const idx = spec.findIndex(c => c === sortRef);
if (idx !== -1) {
const newSpec = Array.from(spec);
newSpec[idx] *= -1;
return newSpec;
}
return spec;
}
// Parses the sortColRefs string, defaulting to an empty array on invalid input.
export function parseSortColRefs(sortColRefs: string): number[] {
try {
return JSON.parse(sortColRefs);
} catch (err) {
return [];
}
}
// Given the current sort spec, moves sortRef to be immediately before nextSortRef. Moves sortRef
// to the end of the sort spec if nextSortRef is null.
// If the given sortRef or nextSortRef cannot be found, return sortSpec unchanged.
export function reorderSortRefs(spec: number[], sortRef: number, nextSortRef: number|null): number[] {
const updatedSpec = spec.slice();
// Remove sortRef from sortSpec.
const _idx = updatedSpec.findIndex(c => c === sortRef);
if (_idx === -1) { return spec; }
updatedSpec.splice(_idx, 1);
// Add sortRef to before nextSortRef
const _nextIdx = nextSortRef ? updatedSpec.findIndex(c => c === nextSortRef) : updatedSpec.length;
if (_nextIdx === -1) { return spec; }
updatedSpec.splice(_nextIdx, 0, sortRef);
return updatedSpec;
}
// Updates the manual sort positions to the positions currently displayed in the view, sets the
// view's default sort spec to be manual sort and broadcasts these changes.
// This is excel/google sheets' sort behavior.
export async function updatePositions(gristDoc: GristDoc, section: ViewSectionRec): Promise<void> {
const tableId = section.table.peek().tableId.peek();
const tableModel = gristDoc.getTableModel(tableId);
// Build a sorted array of rowIds the way a view would, using the active sort spec. We just need
// the sorted list, and can dispose the observable array immediately.
const sortFunc = new SortFunc(new ClientColumnGetters(tableModel));
sortFunc.updateSpec(section.activeDisplaySortSpec.peek());
const sortedRows = rowset.SortedRowSet.create(null, (a: rowset.RowId, b: rowset.RowId) =>
sortFunc.compare(a as number, b as number));
sortedRows.subscribeTo(tableModel);
const sortedRowIds = sortedRows.getKoArray().peek().slice(0);
sortedRows.dispose();
// The action just assigns consecutive positions to the sorted rows.
const colInfo = {[MANUALSORT]: range(0, sortedRowIds.length)};
await gristDoc.docData.sendActions([
// Update row positions and clear the saved sort spec as a single action bundle.
['BulkUpdateRecord', tableId, sortedRowIds, colInfo],
['UpdateRecord', '_grist_Views_section', section.getRowId(), {sortColRefs: '[]'}]
], `Updated table ${tableId} row positions.`);
// Finally clear out the local sort spec.
section.activeSortJson.revert();
}

217
app/client/lib/tableUtil.js Normal file
View File

@@ -0,0 +1,217 @@
var _ = require('underscore');
var dom = require('./dom');
var gutil = require('app/common/gutil');
var {tsvEncode} = require('app/common/tsvFormat');
const G = require('../lib/browserGlobals').get('document', 'DOMParser');
/**
* Returns unique positions given upper and lower position. This function returns a suitable
* position number for the to-be-inserted element to end up at the given index.
* Inserting n elements between a and b should give the positions:
* (a+(b-a)/(n+1)), (a+2(b-a)/(n+1)) , ..., (a+(n)(b-a)/(n+1))
* @param {number} lowerPos - a lower bound
* @param {number} upperPos - an upper bound, must be greater than or equal to lowerPos
* @param {number} numInserts - Number of new positions to insert
* @returns {number[]} A sorted Array of unique positions bounded by lowerPos and upperPos.
* If neither an upper nor lowerPos is given, return 0, 1, ..., numInserts - 1
* If an upperPos is not given, return consecutive values greater than lowerPos
* If a lowerPos is not given, return consecutive values lower than upperPos
* Else return the avg position of to-be neighboring elements.
* Ex: insertPositions(null, 0, 4) = [1, 2, 3, 4]
* insertPositions(0, null, 4) = [-4, -3, -2, -1]
* insertPositions(0, 1, 4) = [0.2, 0.4, 0.6, 0.8]
*/
function insertPositions(lowerPos, upperPos, numInserts) {
numInserts = (typeof numInserts === 'undefined') ? 1 : numInserts;
var start;
var step = 1;
var positions = [];
if (typeof lowerPos !== 'number' && typeof upperPos !== 'number') {
start = 0;
} else if (typeof lowerPos !== 'number') {
start = upperPos - numInserts;
} else if (typeof upperPos !== 'number') {
start = lowerPos + 1;
} else {
step = (upperPos - lowerPos)/(numInserts + 1);
start = lowerPos + step;
}
for(var i = 0; i < numInserts; i++ ){
positions.push(start + step*i);
}
return positions;
}
exports.insertPositions = insertPositions;
/**
* Returns a sorted array of parentPos values between the parentPos of the viewField at index-1 and index.
* @param {koArray} viewFields - koArray of viewFields
* @{param} {number} index - index to insert the viewFields into
* @{param} {number} numInserts - number of new fields to insert
*/
function fieldInsertPositions(viewFields, index, numInserts) {
var leftPos = (index > 0) ? viewFields.at(index-1).parentPos() : null;
var rightPos = (index < viewFields.peekLength) ? viewFields.at(index).parentPos() : null;
return insertPositions(leftPos, rightPos, numInserts);
}
exports.fieldInsertPositions = fieldInsertPositions;
/**
* Returns tsv formatted values from TableData at the given rowIDs and columnIds.
* @param {TableData} tableData - the table containing the values to convert
* @param {CopySelection} selection - a CopySelection instance
* @return {String}
**/
function makePasteText(tableData, selection) {
// tsvEncode expects data as a 2-d array with each a array representing a row
// i.e. [["1-1", "1-2", "1-3"],["2-1", "2-2", "2-3"]]
const values = selection.rowIds.map(rowId =>
selection.columns.map(col => col.fmtGetter(rowId)));
return tsvEncode(values);
}
exports.makePasteText = makePasteText;
/**
* Returns an html table of containing the cells denoted by the cross product of
* the given rows and columns, styled by the given table/row/col style dictionaries.
* @param {TableData} tableData - the table containing the values denoted by the grid selection
* @param {CopySelection} selection - a CopySelection instance
* @param {Boolean} showColHeader - whether to include a column header row
* @return {String} The html for a table containing the given data.
**/
function makePasteHtml(tableData, selection, includeColHeaders) {
let rowStyle = selection.rowStyle || {}; // Maps rowId to style object.
let colStyle = selection.colStyle || {}; // Maps colId to style object.
let elem = dom('table', {border: '1', cellspacing: '0', style: 'white-space: pre'},
dom('colgroup', selection.colIds.map(colId =>
dom('col', {
style: _styleAttr(colStyle[colId]),
'data-grist-col-type': tableData.getColType(colId)
})
)),
// Include column headers if requested.
(includeColHeaders ?
dom('tr', selection.colIds.map(colId => dom('th', colId))) :
null
),
// Fill with table cells.
selection.rowIds.map(rowId =>
dom('tr',
{style: _styleAttr(rowStyle[rowId])},
selection.columns.map(col => {
let rawValue = col.rawGetter(rowId);
let fmtValue = col.fmtGetter(rowId);
let dataOptions = {};
if (rawValue != fmtValue) {
dataOptions['data-grist-raw-value'] = JSON.stringify(rawValue);
}
return dom('td', dataOptions, fmtValue);
})
)
)
);
return elem.outerHTML;
}
exports.makePasteHtml = makePasteHtml;
/**
* @typedef RichPasteObject
* @type {object}
* @property {string} displayValue
* @property {string} [rawValue] - Optional rawValue that should be used if colType matches
* destination.
* @property {string} [colType] - Column type of the source column.
*/
/**
* Parses a 2-d array of objects from a text string containing an HTML table.
* @param {string} data - String of an HTML table.
* @return {Array<Array<RichPasteObj>>} - 2-d array of objects containing details of copied cells.
*/
function parsePasteHtml(data) {
let parser = new G.DOMParser();
let doc = parser.parseFromString(data, 'text/html');
let table = doc.querySelector('table');
let colTypes = Array.from(table.querySelectorAll('col'), col =>
col.getAttribute('data-grist-col-type'));
let result = Array.from(table.querySelectorAll('tr'), (row, rowIdx) =>
Array.from(row.querySelectorAll('td, th'), (cell, colIdx) => {
let o = { displayValue: cell.textContent };
// If there's a column type, add it to the object
if (colTypes[colIdx]) {
o.colType = colTypes[colIdx];
}
if (cell.hasAttribute('data-grist-raw-value')) {
o.rawValue = gutil.safeJsonParse(cell.getAttribute('data-grist-raw-value'),
o.displayValue);
}
return o;
}))
.filter((row) => (row.length > 0));
if (result.length === 0) {
throw new Error('Unable to parse data from text/html');
}
return result;
}
exports.parsePasteHtml = parsePasteHtml;
// Helper function to add css style properties to an html tag
function _styleAttr(style) {
return _.map(style, (value, prop) => `${prop}: ${value};`).join(' ');
}
/**
* groupBy takes in tableData and colId and returns an array of objects of unique values and counts.
*
* @param tableData
* @param colId
* @param {number} =optSort - Optional sort flag to return array sorted by count; 1 for asc, -1 for desc.
*/
function groupBy(tableData, colId, optSort) {
var groups = _.map(
_.countBy(tableData.getColValues(colId)),
function(value, key) {
return {
key: key,
count: value,
};
}
);
groups = _.sortBy(groups, 'key'); // first sort by key, then by count
return optSort ? _.sortBy(groups, function(el) { return optSort * el.count; }) : groups;
}
exports.groupBy = groupBy;
/**
* Given a selection object, creates a action to set all references in the object to the empty string.
* @param {Object} selection - an object with a list of selected row Ids, selected column Ids, a list of
* column metaRowModels and other information about the currently selected cells.
* See GridView.js getSelection and DetailView.js getSelection.
* @returns {Object} BulkUpdateRecord action
*/
function makeDeleteAction(selection) {
let blankRow = selection.rowIds.map(() => '');
let colIds = selection.fields
.filter(field => !field.column().isRealFormula() && !field.disableEditData())
.map(field => field.colId());
// Get the tableId from the first selected column.
let tableId = selection.fields[0].column().table().tableId();
return colIds.length === 0 ? null :
['BulkUpdateRecord', tableId, selection.rowIds, _.object(colIds, colIds.map(() => blankRow))];
}
exports.makeDeleteAction = makeDeleteAction;

View File

@@ -0,0 +1,11 @@
import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals';
import {TestState} from 'app/common/TestState';
const G = getBrowserGlobals('window');
export function setTestState(state: Partial<TestState>) {
if (!('testGrist' in G.window)) {
G.window.testGrist = {};
}
Object.assign(G.window.testGrist, state);
}

172
app/client/lib/uploads.ts Normal file
View File

@@ -0,0 +1,172 @@
/**
* This module contains several ways to create an upload on the server. In all cases, an
* UploadResult is returned, with an uploadId which may be used in other server calls to identify
* this upload.
*
* TODO: another proposed source for files is uploadUrl(url) which would fetch a file from URL and
* upload, and if that fails due to CORS, would fetch the file on the server side instead.
*/
import {DocComm} from 'app/client/components/DocComm';
import {UserError} from 'app/client/models/errors';
import {FileDialogOptions, openFilePicker} from 'app/client/ui/FileDialog';
import {GristLoadConfig} from 'app/common/gristUrls';
import {byteString, safeJsonParse} from 'app/common/gutil';
import {UPLOAD_URL_PATH, UploadResult} from 'app/common/uploads';
import {docUrl} from 'app/common/urlUtils';
import {OpenDialogOptions} from 'electron';
import noop = require('lodash/noop');
import trimStart = require('lodash/trimStart');
import {basename} from 'path'; // made available by webpack using path-browserify module.
type ProgressCB = (percent: number) => void;
export interface UploadOptions {
docWorkerUrl?: string;
sizeLimit?: 'import'|'attachment';
}
export interface SelectFileOptions extends UploadOptions {
multiple?: boolean; // Whether multiple files may be selected.
extensions?: string[]; // Comma-separated list of extensions (with a leading period),
// e.g. [".jpg", ".png"]
}
export const IMPORTABLE_EXTENSIONS = [".grist", ".csv", ".tsv", ".txt", ".xls", ".xlsx", ".xlsm"];
/**
* Shows the file-picker dialog with the given options, and uploads the selected files. If under
* electron, shows the native file-picker instead.
*
* If given, onProgress() callback will be called with 0 on initial call, and will go up to 100
* after files are selected to indicate percentage of data uploaded.
*/
export async function selectFiles(options: SelectFileOptions,
onProgress: ProgressCB = noop): Promise<UploadResult|null> {
onProgress(0);
let result: UploadResult|null = null;
const electronSelectFiles: any = (window as any).electronSelectFiles;
if (typeof electronSelectFiles === 'function') {
result = await electronSelectFiles(getElectronOptions(options));
} else {
const files: File[] = await openFilePicker(getFileDialogOptions(options));
result = await uploadFiles(files, options, onProgress);
}
onProgress(100);
return result;
}
// Helper to convert SelectFileOptions to the browser's FileDialogOptions.
function getFileDialogOptions(options: SelectFileOptions): FileDialogOptions {
const resOptions: FileDialogOptions = {};
if (options.multiple) {
resOptions.multiple = options.multiple;
}
if (options.extensions) {
resOptions.accept = options.extensions.join(",");
}
return resOptions;
}
// Helper to convert SelectFileOptions to electron's OpenDialogOptions.
function getElectronOptions(options: SelectFileOptions): OpenDialogOptions {
const resOptions: OpenDialogOptions = {
filters: [],
properties: ['openFile'],
};
if (options.extensions) {
// Electron does not expect leading period.
const extensions = options.extensions.map(e => trimStart(e, '.'));
resOptions.filters!.push({name: 'Select files', extensions});
}
if (options.multiple) {
resOptions.properties!.push('multiSelections');
}
return resOptions;
}
/**
* Uploads a list of File objects to the server.
*/
export async function uploadFiles(
fileList: File[], options: UploadOptions, onProgress: ProgressCB = noop
): Promise<UploadResult|null> {
if (!fileList.length) { return null; }
const formData = new FormData();
for (const file of fileList) {
formData.append('upload', file);
}
// Check for upload limits.
const gristConfig: GristLoadConfig = (window as any).gristConfig || {};
const {maxUploadSizeImport, maxUploadSizeAttachment} = gristConfig;
if (options.sizeLimit === 'import' && maxUploadSizeImport) {
// For imports, we limit the total upload size, but exempt .grist files from the upload limit.
// Grist docs can be uploaded to make copies or restore from backup, and may legitimately be
// very large (e.g. contain many attachments or on-demand tables).
const totalSize = fileList.reduce((acc, f) => acc + (f.name.endsWith(".grist") ? 0 : f.size), 0);
if (totalSize > maxUploadSizeImport) {
throw new UserError(`Imported files may not exceed ${byteString(maxUploadSizeImport)}`);
}
} else if (options.sizeLimit === 'attachment' && maxUploadSizeAttachment) {
// For attachments, we limit the size of each attachment.
if (fileList.some((f) => (f.size > maxUploadSizeAttachment))) {
throw new UserError(`Attachments may not exceed ${byteString(maxUploadSizeAttachment)}`);
}
}
return new Promise<UploadResult>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('post', docUrl(options.docWorkerUrl, UPLOAD_URL_PATH), true);
xhr.withCredentials = true;
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
onProgress(e.loaded / e.total * 100); // percentage complete
}
});
xhr.addEventListener('error', (e: ProgressEvent) => {
console.warn("Upload error", e); // tslint:disable-line:no-console
// The event does not seem to have any helpful info in it, to add to the message.
reject(new Error('Upload error'));
});
xhr.addEventListener('load', () => {
if (xhr.status !== 200) {
// tslint:disable-next-line:no-console
console.warn("Upload failed", xhr.status, xhr.responseText);
const err = safeJsonParse(xhr.responseText, null);
reject(new UserError('Upload failed: ' + (err && err.error || xhr.status)));
} else {
resolve(JSON.parse(xhr.responseText));
}
});
xhr.send(formData);
});
}
/**
* Fetches ressource from a url and returns an UploadResult. Tries to fetch from the client and
* upload the file to the server. If unsuccessful, tries to fetch directly from the server. In both
* case, it guesses the name of the file based on the response's content-type and the url.
*/
export async function fetchURL(
docComm: DocComm, url: string, onProgress: ProgressCB = noop
): Promise<UploadResult> {
let response: Response;
try {
response = await window.fetch(url);
} catch (err) {
console.log( // tslint:disable-line:no-console
`Could not fetch ${url} on the Client, falling back to server fetch: ${err.message}`
);
return docComm.fetchURL(url);
}
// TODO: We should probably parse response.headers.get('content-disposition') when available
// (see content-disposition npm module).
const fileName = basename(url);
const mimeType = response.headers.get('content-type');
const options = mimeType ? { type: mimeType } : {};
const fileObj = new File([await response.blob()], fileName, options);
const res = await uploadFiles([fileObj], {docWorkerUrl: docComm.docWorkerUrl}, onProgress);
return res!;
}