mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(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:
250
app/client/lib/ACIndex.ts
Normal file
250
app/client/lib/ACIndex.ts
Normal 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;
|
||||
}
|
||||
47
app/client/lib/CustomSectionElement.ts
Normal file
47
app/client/lib/CustomSectionElement.ts
Normal 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
91
app/client/lib/Delay.ts
Normal 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;
|
||||
}
|
||||
66
app/client/lib/DocPluginManager.ts
Normal file
66
app/client/lib/DocPluginManager.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
35
app/client/lib/ImportSourceElement.ts
Normal file
35
app/client/lib/ImportSourceElement.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
67
app/client/lib/Mousetrap.js
Normal file
67
app/client/lib/Mousetrap.js
Normal 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;
|
||||
}
|
||||
155
app/client/lib/ObservableMap.js
Normal file
155
app/client/lib/ObservableMap.js
Normal 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;
|
||||
74
app/client/lib/ObservableSet.js
Normal file
74
app/client/lib/ObservableSet.js
Normal 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;
|
||||
331
app/client/lib/SafeBrowser.ts
Normal file
331
app/client/lib/SafeBrowser.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
12
app/client/lib/SafeBrowserProcess.css
Normal file
12
app/client/lib/SafeBrowserProcess.css
Normal 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;
|
||||
}
|
||||
239
app/client/lib/autocomplete.ts
Normal file
239
app/client/lib/autocomplete.ts
Normal 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;
|
||||
`);
|
||||
54
app/client/lib/browserGlobals.js
Normal file
54
app/client/lib/browserGlobals.js
Normal 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;
|
||||
13
app/client/lib/browserInfo.ts
Normal file
13
app/client/lib/browserInfo.ts
Normal 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');
|
||||
}
|
||||
20
app/client/lib/chartUtil.ts
Normal file
20
app/client/lib/chartUtil.ts
Normal 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]);
|
||||
}
|
||||
}
|
||||
38
app/client/lib/copyToClipboard.ts
Normal file
38
app/client/lib/copyToClipboard.ts
Normal 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
16
app/client/lib/dispose.d.ts
vendored
Normal 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
369
app/client/lib/dispose.js
Normal 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
453
app/client/lib/dom.js
Normal 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;
|
||||
25
app/client/lib/domAsync.ts
Normal file
25
app/client/lib/domAsync.ts
Normal 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; });
|
||||
}];
|
||||
}
|
||||
31
app/client/lib/download.js
Normal file
31
app/client/lib/download.js
Normal 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;
|
||||
32
app/client/lib/fromKoSave.ts
Normal file
32
app/client/lib/fromKoSave.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
11
app/client/lib/guessTimezone.ts
Normal file
11
app/client/lib/guessTimezone.ts
Normal 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
209
app/client/lib/helpScout.ts
Normal 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
20
app/client/lib/imports.d.ts
vendored
Normal 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
16
app/client/lib/imports.js
Normal 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
31
app/client/lib/koArray.d.ts
vendored
Normal 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
457
app/client/lib/koArray.js
Normal 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;
|
||||
};
|
||||
42
app/client/lib/koArrayWrap.ts
Normal file
42
app/client/lib/koArrayWrap.ts
Normal 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
426
app/client/lib/koDom.js
Normal 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;
|
||||
3
app/client/lib/koDomScrolly.css
Normal file
3
app/client/lib/koDomScrolly.css
Normal file
@@ -0,0 +1,3 @@
|
||||
.scrolly_outer {
|
||||
position: relative; /* Forces absolutely-positiong scrolly-div to be within scrolly outer*/
|
||||
}
|
||||
649
app/client/lib/koDomScrolly.js
Normal file
649
app/client/lib/koDomScrolly.js
Normal 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
724
app/client/lib/koForm.css
Normal 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
1163
app/client/lib/koForm.js
Normal file
File diff suppressed because it is too large
Load Diff
90
app/client/lib/koSession.js
Normal file
90
app/client/lib/koSession.js
Normal 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
217
app/client/lib/koUtil.js
Normal 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
212
app/client/lib/listEntry.ts
Normal 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;
|
||||
`);
|
||||
19
app/client/lib/loadScript.js
Normal file
19
app/client/lib/loadScript.js
Normal 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;
|
||||
19
app/client/lib/localStorageObs.ts
Normal file
19
app/client/lib/localStorageObs.ts
Normal 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;
|
||||
}
|
||||
37
app/client/lib/multiselect.css
Normal file
37
app/client/lib/multiselect.css
Normal 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;
|
||||
}
|
||||
89
app/client/lib/multiselect.js
Normal file
89
app/client/lib/multiselect.js
Normal 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;
|
||||
53
app/client/lib/sessionObs.ts
Normal file
53
app/client/lib/sessionObs.ts
Normal 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'; }
|
||||
92
app/client/lib/sortUtil.ts
Normal file
92
app/client/lib/sortUtil.ts
Normal 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
217
app/client/lib/tableUtil.js
Normal 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;
|
||||
11
app/client/lib/testState.ts
Normal file
11
app/client/lib/testState.ts
Normal 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
172
app/client/lib/uploads.ts
Normal 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!;
|
||||
}
|
||||
Reference in New Issue
Block a user