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:
72
app/client/widgets/AbstractWidget.js
Normal file
72
app/client/widgets/AbstractWidget.js
Normal file
@@ -0,0 +1,72 @@
|
||||
var dispose = require('../lib/dispose');
|
||||
const ko = require('knockout');
|
||||
const {fromKo} = require('grainjs');
|
||||
const ValueFormatter = require('app/common/ValueFormatter');
|
||||
|
||||
const {cssLabel, cssRow} = require('app/client/ui/RightPanel');
|
||||
const {colorSelect} = require('app/client/ui2018/buttonSelect');
|
||||
const {testId} = require('app/client/ui2018/cssVars');
|
||||
const {cssHalfWidth, cssInlineLabel} = require('app/client/widgets/NewAbstractWidget');
|
||||
|
||||
/**
|
||||
* AbstractWidget - The base of the inheritance tree for widgets.
|
||||
* @param {Function} field - The RowModel for this view field.
|
||||
*/
|
||||
function AbstractWidget(field) {
|
||||
this.field = field;
|
||||
this.options = field.widgetOptionsJson;
|
||||
|
||||
this.valueFormatter = this.autoDispose(ko.computed(() => {
|
||||
return ValueFormatter.createFormatter(field.displayColModel().type(), this.options());
|
||||
}));
|
||||
}
|
||||
dispose.makeDisposable(AbstractWidget);
|
||||
|
||||
/**
|
||||
* Builds the DOM showing configuration buttons and fields in the sidebar.
|
||||
*/
|
||||
AbstractWidget.prototype.buildConfigDom = function() {
|
||||
throw new Error("Not Implemented");
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the transform prompt config DOM in the few cases where it is necessary.
|
||||
* Child classes need not override this function if they do not require transform config options.
|
||||
*/
|
||||
AbstractWidget.prototype.buildTransformConfigDom = function() {
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the data cell DOM.
|
||||
* @param {DataRowModel} row - The rowModel object.
|
||||
*/
|
||||
AbstractWidget.prototype.buildDom = function(row) {
|
||||
throw new Error("Not Implemented");
|
||||
};
|
||||
|
||||
AbstractWidget.prototype.buildColorConfigDom = function() {
|
||||
return [
|
||||
cssLabel('CELL COLOR'),
|
||||
cssRow(
|
||||
cssHalfWidth(
|
||||
colorSelect(
|
||||
fromKo(this.field.textColor),
|
||||
(val) => this.field.textColor.saveOnly(val),
|
||||
testId('text-color'),
|
||||
),
|
||||
cssInlineLabel('Text')
|
||||
),
|
||||
cssHalfWidth(
|
||||
colorSelect(
|
||||
fromKo(this.field.fillColor),
|
||||
(val) => this.field.fillColor.saveOnly(val),
|
||||
testId('fill-color'),
|
||||
),
|
||||
cssInlineLabel('Fill')
|
||||
)
|
||||
)
|
||||
];
|
||||
};
|
||||
|
||||
module.exports = AbstractWidget;
|
||||
51
app/client/widgets/BaseEditor.js
Normal file
51
app/client/widgets/BaseEditor.js
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Required parameters:
|
||||
* @param {RowModel} options.field: ViewSectionField (i.e. column) being edited.
|
||||
* @param {Object} options.cellValue: The value in the underlying cell being edited.
|
||||
* @param {String} options.editValue: String to be edited, or undefined to use cellValue.
|
||||
* @param {Number} options.cursorPos: The initial position where to place the cursor.
|
||||
* @param {Object} options.commands: Object mapping command names to functions, to enable as part
|
||||
* of the command group that should be activated while the editor exists.
|
||||
*/
|
||||
function BaseEditor(options) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Called after the editor is instantiated to attach its DOM to the page.
|
||||
* - cellRect: Bounding box of the element representing the cell that this editor should match
|
||||
* in size and position. Used by derived classes, e.g. to construct an EditorPlacement object.
|
||||
*/
|
||||
BaseEditor.prototype.attach = function(cellRect) {
|
||||
// No-op by default.
|
||||
};
|
||||
|
||||
/**
|
||||
* Called to get the value to save back to the cell.
|
||||
*/
|
||||
BaseEditor.prototype.getCellValue = function() {
|
||||
throw new Error("Not Implemented");
|
||||
};
|
||||
|
||||
/**
|
||||
* Used if an editor needs preform any actions before a save
|
||||
*/
|
||||
BaseEditor.prototype.prepForSave = function() {
|
||||
// No-op by default.
|
||||
};
|
||||
|
||||
/**
|
||||
* Called to get the text in the editor, used when switching between editing data and formula.
|
||||
*/
|
||||
BaseEditor.prototype.getTextValue = function() {
|
||||
throw new Error("Not Implemented");
|
||||
};
|
||||
|
||||
/**
|
||||
* Called to get the position of the cursor in the editor. Used when switching between editing
|
||||
* data and formula.
|
||||
*/
|
||||
BaseEditor.prototype.getCursorPos = function() {
|
||||
throw new Error("Not Implemented");
|
||||
};
|
||||
|
||||
module.exports = BaseEditor;
|
||||
58
app/client/widgets/CheckBox.css
Normal file
58
app/client/widgets/CheckBox.css
Normal file
@@ -0,0 +1,58 @@
|
||||
|
||||
.widget_checkbox {
|
||||
position: relative;
|
||||
margin: -1px auto;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.field_clip.has_cursor > .widget_checkbox {
|
||||
cursor: pointer;
|
||||
box-shadow: inset 0 0 0 1px #606060;
|
||||
border-radius: 3px;
|
||||
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%);
|
||||
}
|
||||
.widget_checkbox:hover:not(.disabled) {
|
||||
cursor: pointer;
|
||||
box-shadow: inset 0 0 0 1px #606060;
|
||||
border-radius: 3px;
|
||||
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%);
|
||||
}
|
||||
|
||||
.widget_checkbox: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%);
|
||||
}
|
||||
|
||||
.widget_checkbox:focus {
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
.widget_checkmark {
|
||||
position: relative;
|
||||
width: 6px;
|
||||
height: 12px;
|
||||
-ms-transform: rotate(40deg); /* IE 9 */
|
||||
-webkit-transform: rotate(40deg); /* Chrome, Safari, Opera */
|
||||
transform: rotate(40deg);
|
||||
left: 4px;
|
||||
top: 2px;
|
||||
}
|
||||
|
||||
.checkmark_stem {
|
||||
position: relative;
|
||||
width: 3px;
|
||||
height: 12px;
|
||||
background-color: #606060;
|
||||
border: 1px solid #606060;
|
||||
left: 3px;
|
||||
top: -5px;
|
||||
}
|
||||
|
||||
.checkmark_kick {
|
||||
position: relative;
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
background-color: #606060;
|
||||
border: 1px solid #606060;
|
||||
top: 7px;
|
||||
}
|
||||
44
app/client/widgets/CheckBox.js
Normal file
44
app/client/widgets/CheckBox.js
Normal file
@@ -0,0 +1,44 @@
|
||||
var dom = require('../lib/dom');
|
||||
var dispose = require('../lib/dispose');
|
||||
var _ = require('underscore');
|
||||
var kd = require('../lib/koDom');
|
||||
var AbstractWidget = require('./AbstractWidget');
|
||||
|
||||
/**
|
||||
* CheckBox - A bi-state CheckBox widget
|
||||
*/
|
||||
function CheckBox(field) {
|
||||
AbstractWidget.call(this, field);
|
||||
}
|
||||
dispose.makeDisposable(CheckBox);
|
||||
_.extend(CheckBox.prototype, AbstractWidget.prototype);
|
||||
|
||||
CheckBox.prototype.buildConfigDom = function() {
|
||||
return null;
|
||||
};
|
||||
|
||||
CheckBox.prototype.buildDom = function(row) {
|
||||
var value = row[this.field.colId()];
|
||||
return dom('div.field_clip',
|
||||
dom('div.widget_checkbox',
|
||||
dom.on('click', () => {
|
||||
if (!this.field.column().isRealFormula()) {
|
||||
value.setAndSave(!value.peek());
|
||||
}
|
||||
}),
|
||||
dom('div.widget_checkmark',
|
||||
kd.show(value),
|
||||
dom('div.checkmark_kick',
|
||||
kd.style('background-color', this.field.textColor),
|
||||
kd.style('border-color', this.field.textColor)
|
||||
),
|
||||
dom('div.checkmark_stem',
|
||||
kd.style('background-color', this.field.textColor),
|
||||
kd.style('border-color', this.field.textColor)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
module.exports = CheckBox;
|
||||
21
app/client/widgets/CheckBoxEditor.js
Normal file
21
app/client/widgets/CheckBoxEditor.js
Normal file
@@ -0,0 +1,21 @@
|
||||
var dispose = require('../lib/dispose');
|
||||
var _ = require('underscore');
|
||||
var TextEditor = require('./TextEditor');
|
||||
|
||||
|
||||
function CheckBoxEditor(options) {
|
||||
TextEditor.call(this, options);
|
||||
}
|
||||
dispose.makeDisposable(CheckBoxEditor);
|
||||
_.extend(CheckBoxEditor.prototype, TextEditor.prototype);
|
||||
|
||||
// For documentation, see NewBaseEditor.ts
|
||||
CheckBoxEditor.skipEditor = function(typedVal, cellVal) {
|
||||
if (typedVal === ' ') {
|
||||
// This is a special case when user hits <space>. We return the toggled value to save, and by
|
||||
// this indicate that the editor should not open.
|
||||
return !cellVal;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = CheckBoxEditor;
|
||||
27
app/client/widgets/ChoiceEditor.js
Normal file
27
app/client/widgets/ChoiceEditor.js
Normal file
@@ -0,0 +1,27 @@
|
||||
var _ = require('underscore');
|
||||
var dispose = require('../lib/dispose');
|
||||
var TextEditor = require('./TextEditor');
|
||||
|
||||
const {autocomplete} = require('app/client/ui2018/menus');
|
||||
|
||||
/**
|
||||
* ChoiceEditor - TextEditor with a dropdown for possible choices.
|
||||
*/
|
||||
function ChoiceEditor(options) {
|
||||
TextEditor.call(this, options);
|
||||
|
||||
this.choices = options.field.widgetOptionsJson.peek().choices || [];
|
||||
|
||||
// Add autocomplete if there are any choices to select from
|
||||
if (this.choices.length > 0) {
|
||||
|
||||
autocomplete(this.textInput, this.choices, {
|
||||
allowNothingSelected: true,
|
||||
onClick: () => this.options.commands.fieldEditSave(),
|
||||
});
|
||||
}
|
||||
}
|
||||
dispose.makeDisposable(ChoiceEditor);
|
||||
_.extend(ChoiceEditor.prototype, TextEditor.prototype);
|
||||
|
||||
module.exports = ChoiceEditor;
|
||||
96
app/client/widgets/ChoiceTextBox.js
Normal file
96
app/client/widgets/ChoiceTextBox.js
Normal file
@@ -0,0 +1,96 @@
|
||||
var commands = require('../components/commands');
|
||||
var dispose = require('../lib/dispose');
|
||||
var kd = require('../lib/koDom');
|
||||
var _ = require('underscore');
|
||||
var TextBox = require('./TextBox');
|
||||
|
||||
const {fromKoSave} = require('app/client/lib/fromKoSave');
|
||||
const {ListEntry} = require('app/client/lib/listEntry');
|
||||
const {alignmentSelect} = require('app/client/ui2018/buttonSelect');
|
||||
const {colors, testId} = require('app/client/ui2018/cssVars');
|
||||
const {icon} = require('app/client/ui2018/icons');
|
||||
const {menu, menuItem} = require('app/client/ui2018/menus');
|
||||
const {cssLabel, cssRow} = require('app/client/ui/RightPanel');
|
||||
const {dom, Computed, styled} = require('grainjs');
|
||||
|
||||
/**
|
||||
* ChoiceTextBox - A textbox for choice values.
|
||||
*/
|
||||
function ChoiceTextBox(field) {
|
||||
TextBox.call(this, field);
|
||||
|
||||
this.docData = field._table.docModel.docData;
|
||||
this.colId = field.column().colId;
|
||||
this.tableId = field.column().table().tableId;
|
||||
|
||||
this.choices = this.options.prop('choices');
|
||||
this.choiceValues = Computed.create(this, (use) => use(this.choices) || [])
|
||||
}
|
||||
dispose.makeDisposable(ChoiceTextBox);
|
||||
_.extend(ChoiceTextBox.prototype, TextBox.prototype);
|
||||
|
||||
|
||||
ChoiceTextBox.prototype.buildDom = function(row) {
|
||||
let value = row[this.field.colId()];
|
||||
return cssChoiceField(
|
||||
cssChoiceText(
|
||||
kd.style('text-align', this.alignment),
|
||||
kd.text(() => row._isAddRow() ? '' : this.valueFormatter().format(value())),
|
||||
),
|
||||
cssDropdownIcon('Dropdown',
|
||||
// When choices exist, click dropdown icon to open edit autocomplete.
|
||||
dom.on('click', () => this._hasChoices() && commands.allCommands.editField.run()),
|
||||
// When choices do not exist, open a single-item menu to open the sidepane choice option editor.
|
||||
menu(() => [
|
||||
menuItem(commands.allCommands.fieldTabOpen.run, 'Add Choice Options')
|
||||
], {
|
||||
trigger: [(elem, ctl) => {
|
||||
// Only open this menu if there are no choices.
|
||||
dom.onElem(elem, 'click', () => this._hasChoices() || ctl.open());
|
||||
}]
|
||||
}),
|
||||
testId('choice-dropdown')
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
ChoiceTextBox.prototype.buildConfigDom = function() {
|
||||
return [
|
||||
cssRow(
|
||||
alignmentSelect(fromKoSave(this.alignment))
|
||||
),
|
||||
cssLabel('OPTIONS'),
|
||||
cssRow(
|
||||
dom.create(ListEntry, this.choiceValues, (values) => this.choices.saveOnly(values))
|
||||
)
|
||||
];
|
||||
};
|
||||
|
||||
ChoiceTextBox.prototype.buildTransformConfigDom = function() {
|
||||
return this.buildConfigDom();
|
||||
};
|
||||
|
||||
ChoiceTextBox.prototype._hasChoices = function() {
|
||||
return this.choiceValues.get().length > 0;
|
||||
};
|
||||
|
||||
const cssChoiceField = styled('div.field_clip', `
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
`);
|
||||
|
||||
const cssChoiceText = styled('div', `
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
`);
|
||||
|
||||
const cssDropdownIcon = styled(icon, `
|
||||
cursor: pointer;
|
||||
background-color: ${colors.lightGreen};
|
||||
min-width: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
`);
|
||||
|
||||
module.exports = ChoiceTextBox;
|
||||
156
app/client/widgets/DateEditor.js
Normal file
156
app/client/widgets/DateEditor.js
Normal file
@@ -0,0 +1,156 @@
|
||||
/* global $, document */
|
||||
const moment = require('moment-timezone');
|
||||
const _ = require('underscore');
|
||||
const gutil = require('app/common/gutil');
|
||||
const commands = require('../components/commands');
|
||||
const dispose = require('../lib/dispose');
|
||||
const dom = require('../lib/dom');
|
||||
const kd = require('../lib/koDom');
|
||||
const TextEditor = require('./TextEditor');
|
||||
const { parseDate } = require('app/common/parseDate');
|
||||
|
||||
// DatePicker unfortunately requires an <input> (not <textarea>). But textarea is better for us,
|
||||
// because sometimes it's taller than a line, and an <input> looks worse. The following
|
||||
// unconsionable hack tricks Datepicker into thinking anything it's attached to is an input.
|
||||
// It's more reasonable to just modify boostrap-datepicker, but that has its own downside (with
|
||||
// upgrading and minification). This hack, however, is simpler than other workarounds.
|
||||
var Datepicker = $.fn.datepicker.Constructor;
|
||||
// datepicker.isInput can now be set to anything, but when read, always returns true. Tricksy.
|
||||
Object.defineProperty(Datepicker.prototype, 'isInput', {
|
||||
get: function() { return true; },
|
||||
set: function(v) {},
|
||||
});
|
||||
|
||||
/**
|
||||
* DateEditor - Editor for Date type. Includes a dropdown datepicker.
|
||||
* See reference: http://bootstrap-datepicker.readthedocs.org/en/latest/index.html
|
||||
*
|
||||
* @param {String} options.timezone: Optional timezone to use instead of UTC.
|
||||
*/
|
||||
function DateEditor(options) {
|
||||
// A string that is always `UTC` in the DateEditor, eases DateTimeEditor inheritance.
|
||||
this.timezone = options.timezone || 'UTC';
|
||||
this.dateFormat = options.field.widgetOptionsJson.peek().dateFormat;
|
||||
|
||||
// Strip moment format string to remove markers unsupported by the datepicker.
|
||||
this.safeFormat = DateEditor.parseMomentToSafe(this.dateFormat);
|
||||
|
||||
// Use the default local timezone to format the placeholder date.
|
||||
let defaultTimezone = moment.tz.guess();
|
||||
let placeholder = moment.tz(defaultTimezone).format(this.safeFormat);
|
||||
TextEditor.call(this, _.defaults(options, { placeholder: placeholder }));
|
||||
|
||||
// Set the edited value, if not explicitly given, to the formatted version of cellValue.
|
||||
this.textInput.value = gutil.undefDefault(options.editValue,
|
||||
this.formatValue(options.cellValue, this.safeFormat));
|
||||
|
||||
// Indicates whether keyboard navigation is active for the datepicker.
|
||||
this._keyboardNav = false;
|
||||
|
||||
// Attach the datepicker.
|
||||
this._datePickerWidget = $(this.textInput).datepicker({
|
||||
keyboardNavigation: false,
|
||||
forceParse: false,
|
||||
todayHighlight: true,
|
||||
todayBtn: 'linked',
|
||||
// Convert the stripped format string to one suitable for the datepicker.
|
||||
format: DateEditor.parseSafeToCalendar(this.safeFormat)
|
||||
});
|
||||
this.autoDisposeCallback(() => this._datePickerWidget.datepicker('remove'));
|
||||
|
||||
// NOTE: Datepicker interferes with normal enter and escape functionality. Add an event handler
|
||||
// to the DatePicker to prevent interference with normal behavior.
|
||||
this._datePickerWidget.on('keydown', e => {
|
||||
// If enter or escape is pressed, destroy the datepicker and re-dispatch the event.
|
||||
if (e.keyCode === 13 || e.keyCode === 27) {
|
||||
this._datePickerWidget.datepicker('remove');
|
||||
// The current target of the event will be the textarea.
|
||||
setTimeout(() => e.currentTarget.dispatchEvent(e.originalEvent), 0);
|
||||
}
|
||||
});
|
||||
|
||||
// When the up/down arrow is pressed, modify the datepicker options to take control of
|
||||
// the arrow keys for date selection.
|
||||
let datepickerCommands = Object.assign({}, options.commands, {
|
||||
datepickerFocus: () => { this._allowKeyboardNav(true); }
|
||||
});
|
||||
this._datepickerCommands = this.autoDispose(commands.createGroup(datepickerCommands, this, true));
|
||||
|
||||
this._datePickerWidget.on('show', () => {
|
||||
// A workaround to allow clicking in the datepicker without losing focus.
|
||||
dom(document.querySelector('.datepicker'),
|
||||
kd.attr('tabIndex', 0), // allows datepicker to gain focus
|
||||
kd.toggleClass('clipboard_focus', true) // tells clipboard to not steal focus from us
|
||||
);
|
||||
// Attach command group to the input to allow switching keyboard focus to the datepicker.
|
||||
dom(this.textInput,
|
||||
// If the user inputs text into the textbox, take keyboard focus from the datepicker.
|
||||
dom.on('input', () => { this._allowKeyboardNav(false); }),
|
||||
this._datepickerCommands.attach()
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
dispose.makeDisposable(DateEditor);
|
||||
_.extend(DateEditor.prototype, TextEditor.prototype);
|
||||
|
||||
/** @inheritdoc */
|
||||
DateEditor.prototype.getCellValue = function() {
|
||||
let timestamp = parseDate(this.textInput.value, {
|
||||
dateFormat: this.safeFormat,
|
||||
timezone: this.timezone
|
||||
});
|
||||
return timestamp !== null ? timestamp : this.textInput.value;
|
||||
};
|
||||
|
||||
// Helper to allow/disallow keyboard navigation within the datepicker.
|
||||
DateEditor.prototype._allowKeyboardNav = function(bool) {
|
||||
if (this._keyboardNav !== bool) {
|
||||
this._keyboardNav = bool;
|
||||
$(this.textInput).data().datepicker.o.keyboardNavigation = bool;
|
||||
// Force parse must be turned on with keyboard navigation, since it forces the highlighted date
|
||||
// to be used when enter is pressed. Otherwise, keyboard date selection will have no effect.
|
||||
$(this.textInput).data().datepicker.o.forceParse = bool;
|
||||
}
|
||||
};
|
||||
|
||||
// Moment value formatting helper.
|
||||
DateEditor.prototype.formatValue = function(value, formatString) {
|
||||
if (_.isNumber(value) && formatString) {
|
||||
return moment.tz(value*1000, this.timezone).format(formatString);
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
// Formats Moment string to remove markers unsupported by the datepicker.
|
||||
// Moment reference: http://momentjs.com/docs/#/displaying/
|
||||
DateEditor.parseMomentToSafe = function(mFormat) {
|
||||
// Remove markers not representing year, month, or date, and also DDD, DDDo, DDDD, d, do,
|
||||
// (and following whitespace/punctuation) since they are unsupported by the datepicker.
|
||||
mFormat = mFormat.replace(/\b(?:[^DMY\W]+|D{3,4}o*)\b\W+/g, '');
|
||||
// Convert other markers unsupported by the datepicker to similar supported markers.
|
||||
mFormat = mFormat.replace(/\b([MD])o\b/g, '$1'); // Mo -> M, Do -> D
|
||||
// Check which information the format contains. Format is only valid for editing if it
|
||||
// contains day, month and year information.
|
||||
var dayRe = /D{1,2}/g;
|
||||
var monthRe = /M{1,4}/g;
|
||||
var yearRe = /Y{2,4}/g;
|
||||
var valid = dayRe.test(mFormat) && monthRe.test(mFormat) && yearRe.test(mFormat);
|
||||
return valid ? mFormat : 'YYYY-MM-DD'; // Use basic format if given is invalid.
|
||||
};
|
||||
|
||||
// Formats Moment string without datepicker unsupported markers for the datepicker.
|
||||
// Datepicker reference: http://bootstrap-datepicker.readthedocs.org/en/latest/options.html#format
|
||||
DateEditor.parseSafeToCalendar = function(sFormat) {
|
||||
// M -> m, MM -> mm, D -> d, DD -> dd, YY -> yy, YYYY -> yyyy
|
||||
sFormat = sFormat.replace(/\b(?:[MD]{1,2}|Y{2,4})\b/g, function(x) {
|
||||
return x.toLowerCase();
|
||||
});
|
||||
sFormat = sFormat.replace(/\bM{2}(?=M{1,2}\b)/g, ''); // MMM -> M, MMMM -> MM
|
||||
sFormat = sFormat.replace(/\bddd\b/g, 'D'); // ddd -> D
|
||||
return sFormat.replace(/\bdddd\b/g, 'DD'); // dddd -> DD
|
||||
};
|
||||
|
||||
|
||||
module.exports = DateEditor;
|
||||
89
app/client/widgets/DateTextBox.js
Normal file
89
app/client/widgets/DateTextBox.js
Normal file
@@ -0,0 +1,89 @@
|
||||
var _ = require('underscore');
|
||||
var ko = require('knockout');
|
||||
var dom = require('../lib/dom');
|
||||
var dispose = require('../lib/dispose');
|
||||
var kd = require('../lib/koDom');
|
||||
var kf = require('../lib/koForm');
|
||||
var AbstractWidget = require('./AbstractWidget');
|
||||
|
||||
const {fromKoSave} = require('app/client/lib/fromKoSave');
|
||||
const {alignmentSelect} = require('app/client/ui2018/buttonSelect');
|
||||
const {cssRow} = require('app/client/ui/RightPanel');
|
||||
|
||||
/**
|
||||
* DateTextBox - The most basic widget for displaying simple date information.
|
||||
*/
|
||||
function DateTextBox(field) {
|
||||
AbstractWidget.call(this, field);
|
||||
|
||||
this.alignment = this.options.prop('alignment');
|
||||
this.dateFormat = this.options.prop('dateFormat');
|
||||
this.isCustomDateFormat = this.options.prop('isCustomDateFormat');
|
||||
|
||||
this.dateFormatOptions = [
|
||||
'YYYY-MM-DD',
|
||||
'MM-DD-YYYY',
|
||||
'MM/DD/YYYY',
|
||||
'MM-DD-YY',
|
||||
'MM/DD/YY',
|
||||
'DD MMM YYYY',
|
||||
'MMMM Do, YYYY',
|
||||
'DD-MM-YYYY',
|
||||
'Custom'
|
||||
];
|
||||
|
||||
// Helper to set 'dateFormat' and 'isCustomDateFormat' from the set of default date format strings.
|
||||
this.standardDateFormat = this.autoDispose(ko.computed({
|
||||
owner: this,
|
||||
read: function() { return this.isCustomDateFormat() ? 'Custom' : this.dateFormat(); },
|
||||
write: function(val) {
|
||||
if (val === 'Custom') { this.isCustomDateFormat.setAndSave(true); }
|
||||
else {
|
||||
this.options.update({isCustomDateFormat: false, dateFormat: val});
|
||||
this.options.save();
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// An observable that always returns `UTC`, eases DateTimeEditor inheritance.
|
||||
this.timezone = ko.observable('UTC');
|
||||
}
|
||||
dispose.makeDisposable(DateTextBox);
|
||||
_.extend(DateTextBox.prototype, AbstractWidget.prototype);
|
||||
|
||||
DateTextBox.prototype.buildDateConfigDom = function() {
|
||||
var self = this;
|
||||
return dom('div',
|
||||
kf.row(
|
||||
1, dom('div.glyphicon.glyphicon-calendar.config_icon'),
|
||||
8, kf.label('Date Format'),
|
||||
9, dom(kf.select(self.standardDateFormat, self.dateFormatOptions), dom.testId("Widget_dateFormat"))
|
||||
),
|
||||
kd.maybe(self.isCustomDateFormat, function() {
|
||||
return dom(kf.text(self.dateFormat), dom.testId("Widget_dateCustomFormat"));
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
DateTextBox.prototype.buildConfigDom = function() {
|
||||
return dom('div',
|
||||
this.buildDateConfigDom(),
|
||||
cssRow(
|
||||
alignmentSelect(fromKoSave(this.alignment))
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
DateTextBox.prototype.buildTransformConfigDom = function() {
|
||||
return this.buildDateConfigDom();
|
||||
};
|
||||
|
||||
DateTextBox.prototype.buildDom = function(row) {
|
||||
let value = row[this.field.colId()];
|
||||
return dom('div.field_clip',
|
||||
kd.style('text-align', this.alignment),
|
||||
kd.text(() => row._isAddRow() ? '' : this.valueFormatter().format(value()))
|
||||
);
|
||||
};
|
||||
|
||||
module.exports = DateTextBox;
|
||||
28
app/client/widgets/DateTimeEditor.css
Normal file
28
app/client/widgets/DateTimeEditor.css
Normal file
@@ -0,0 +1,28 @@
|
||||
.default_editor.celleditor_datetime {
|
||||
box-shadow: none;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.celleditor_datetime_editor.celleditor_cursor_editor {
|
||||
flex: auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
box-shadow: none;
|
||||
z-index: 9;
|
||||
outline: 1px solid var(--grist-color-cursor);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.celleditor_datetime_editor:focus-within {
|
||||
box-shadow: 0 0 3px 2px var(--grist-color-cursor);
|
||||
z-index: 10;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.celleditor_datetime_editor > .celleditor_text_editor {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.datepicker {
|
||||
outline: none;
|
||||
}
|
||||
118
app/client/widgets/DateTimeEditor.js
Normal file
118
app/client/widgets/DateTimeEditor.js
Normal file
@@ -0,0 +1,118 @@
|
||||
/* global document */
|
||||
const moment = require('moment-timezone');
|
||||
const _ = require('underscore');
|
||||
const dom = require('../lib/dom');
|
||||
const dispose = require('../lib/dispose');
|
||||
const kd = require('../lib/koDom');
|
||||
const DateEditor = require('./DateEditor');
|
||||
const gutil = require('app/common/gutil');
|
||||
const { parseDate } = require('app/common/parseDate');
|
||||
|
||||
/**
|
||||
* DateTimeEditor - Editor for DateTime type. Includes a dropdown datepicker.
|
||||
* See reference: http://bootstrap-datepicker.readthedocs.org/en/latest/index.html
|
||||
*/
|
||||
function DateTimeEditor(options) {
|
||||
// Get the timezone from the end of the type string.
|
||||
options.timezone = gutil.removePrefix(options.field.column().type(), "DateTime:");
|
||||
|
||||
// Adjust the command group.
|
||||
var origCommands = options.commands;
|
||||
options.commands = Object.assign({}, origCommands, {
|
||||
prevField: () => this._focusIndex() === 1 ? this._setFocus(0) : origCommands.prevField(),
|
||||
nextField: () => this._focusIndex() === 0 ? this._setFocus(1) : origCommands.nextField(),
|
||||
});
|
||||
|
||||
// Call the superclass.
|
||||
DateEditor.call(this, options);
|
||||
|
||||
this._timeFormat = options.field.widgetOptionsJson.peek().timeFormat;
|
||||
|
||||
// To reuse code, this knows all about the DOM that DateEditor builds (using TextEditor), and
|
||||
// modifies that to be two side-by-side textareas.
|
||||
this._dateSizer = this.contentSizer; // For consistency with _timeSizer and _timeInput.
|
||||
this._dateInput = this.textInput;
|
||||
dom(this.dom, kd.toggleClass('celleditor_datetime', true));
|
||||
dom(this.dom.firstChild, kd.toggleClass('celleditor_datetime_editor', true));
|
||||
this.dom.appendChild(
|
||||
dom('div.celleditor_cursor_editor.celleditor_datetime_editor',
|
||||
this._timeSizer = dom('div.celleditor_content_measure'),
|
||||
this._timeInput = dom('textarea.celleditor_text_editor',
|
||||
// Use a placeholder of 12:00am, since that is the autofill time value.
|
||||
kd.attr('placeholder', moment.tz('0', 'H', this.timezone).format(this._timeFormat)),
|
||||
kd.value(this.formatValue(options.cellValue, this._timeFormat)),
|
||||
this.commandGroup.attach(),
|
||||
dom.on('input', () => this._resizeInput())
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
dispose.makeDisposable(DateTimeEditor);
|
||||
_.extend(DateTimeEditor.prototype, DateEditor.prototype);
|
||||
|
||||
DateTimeEditor.prototype.setSizerLimits = function() {
|
||||
var maxSize = this.editorPlacement.calcSize({width: Infinity, height: Infinity}, {calcOnly: true});
|
||||
this._dateSizer.style.maxWidth =
|
||||
this._timeSizer.style.maxWidth = Math.ceil(maxSize.width / 2 - 6) + 'px';
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns which element has focus: 0 if date, 1 if time, null if neither.
|
||||
*/
|
||||
DateTimeEditor.prototype._focusIndex = function() {
|
||||
return document.activeElement === this._dateInput ? 0 :
|
||||
(document.activeElement === this._timeInput ? 1 : null);
|
||||
};
|
||||
|
||||
/**
|
||||
* Sets focus to date if index is 0, or time if index is 1.
|
||||
*/
|
||||
DateTimeEditor.prototype._setFocus = function(index) {
|
||||
var elem = (index === 0 ? this._dateInput : (index === 1 ? this._timeInput : null));
|
||||
if (elem) {
|
||||
elem.focus();
|
||||
elem.selectionStart = 0;
|
||||
elem.selectionEnd = elem.value.length;
|
||||
}
|
||||
};
|
||||
|
||||
DateTimeEditor.prototype.getCellValue = function() {
|
||||
let date = this._dateInput.value;
|
||||
let time = this._timeInput.value;
|
||||
let timestamp = parseDate(date, {
|
||||
dateFormat: this.safeFormat,
|
||||
time: time,
|
||||
timeFormat: this._timeFormat,
|
||||
timezone: this.timezone
|
||||
});
|
||||
return timestamp !== null ? timestamp :
|
||||
(date && time ? `${date} ${time}` : date || time);
|
||||
};
|
||||
|
||||
/**
|
||||
* Overrides the resizing function in TextEditor.
|
||||
*/
|
||||
DateTimeEditor.prototype._resizeInput = function() {
|
||||
// Use the size calculation provided in options.calcSize (that takes into account cell size and
|
||||
// screen size), with both date and time parts as the input. The resulting size is applied to
|
||||
// the parent (containing date + time), with date and time each expanding or shrinking from the
|
||||
// measured sizes using flexbox logic.
|
||||
this._dateSizer.textContent = this._dateInput.value;
|
||||
this._timeSizer.textContent = this._timeInput.value;
|
||||
var dateRect = this._dateSizer.getBoundingClientRect();
|
||||
var timeRect = this._timeSizer.getBoundingClientRect();
|
||||
// Textboxes get 3px of padding on left/right/top (see TextEditor.css); we specify it manually
|
||||
// since editorPlacement can't do a good job figuring it out with the flexbox arrangement.
|
||||
var size = this.editorPlacement.calcSize({
|
||||
width: dateRect.width + timeRect.width + 12,
|
||||
height: Math.max(dateRect.height, timeRect.height) + 3
|
||||
});
|
||||
this.dom.style.width = size.width + 'px';
|
||||
this._dateInput.parentNode.style.flexBasis = (dateRect.width + 6) + 'px';
|
||||
this._timeInput.parentNode.style.flexBasis = (timeRect.width + 6) + 'px';
|
||||
this._dateInput.style.height = Math.ceil(size.height - 3) + 'px';
|
||||
this._timeInput.style.height = Math.ceil(size.height - 3) + 'px';
|
||||
};
|
||||
|
||||
module.exports = DateTimeEditor;
|
||||
120
app/client/widgets/DateTimeTextBox.js
Normal file
120
app/client/widgets/DateTimeTextBox.js
Normal file
@@ -0,0 +1,120 @@
|
||||
/* globals $ */
|
||||
var _ = require('underscore');
|
||||
var ko = require('knockout');
|
||||
var moment = require('moment-timezone');
|
||||
var dom = require('../lib/dom');
|
||||
var dispose = require('../lib/dispose');
|
||||
var kd = require('../lib/koDom');
|
||||
var kf = require('../lib/koForm');
|
||||
var DateTextBox = require('./DateTextBox');
|
||||
var gutil = require('app/common/gutil');
|
||||
|
||||
const {fromKoSave} = require('app/client/lib/fromKoSave');
|
||||
const {alignmentSelect} = require('app/client/ui2018/buttonSelect');
|
||||
const {testId} = require('app/client/ui2018/cssVars');
|
||||
const {cssRow} = require('app/client/ui/RightPanel');
|
||||
|
||||
/**
|
||||
* DateTimeTextBox - The most basic widget for displaying date and time information.
|
||||
*/
|
||||
function DateTimeTextBox(field) {
|
||||
DateTextBox.call(this, field);
|
||||
|
||||
this.timezoneOptions = moment.tz.names();
|
||||
|
||||
this.isInvalidTimezone = ko.observable(false);
|
||||
|
||||
// Returns the timezone from the end of the type string
|
||||
this.timezone = this.autoDispose(ko.computed({
|
||||
owner: this,
|
||||
read: function() {
|
||||
return gutil.removePrefix(field.column().type(), "DateTime:");
|
||||
},
|
||||
write: function(val) {
|
||||
if (_.contains(this.timezoneOptions, val)) {
|
||||
field.column().type.setAndSave('DateTime:' + val);
|
||||
this.isInvalidTimezone(false);
|
||||
} else {
|
||||
this.isInvalidTimezone(true);
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
this.timeFormat = this.options.prop('timeFormat');
|
||||
this.isCustomTimeFormat = this.options.prop('isCustomTimeFormat');
|
||||
|
||||
this.timeFormatOptions = [
|
||||
'h:mma',
|
||||
'h:mma z',
|
||||
'HH:mm',
|
||||
'HH:mm z',
|
||||
'HH:mm:ss',
|
||||
'HH:mm:ss z',
|
||||
'Custom'
|
||||
];
|
||||
|
||||
// Helper to set 'timeFormat' and 'isCustomTimeFormat' from the set of default time format strings.
|
||||
this.standardTimeFormat = this.autoDispose(ko.computed({
|
||||
owner: this,
|
||||
read: function() { return this.isCustomTimeFormat() ? 'Custom' : this.timeFormat(); },
|
||||
write: function(val) {
|
||||
if (val === 'Custom') { this.isCustomTimeFormat.setAndSave(true); }
|
||||
else {
|
||||
this.isCustomTimeFormat.setAndSave(false);
|
||||
this.timeFormat.setAndSave(val);
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
dispose.makeDisposable(DateTimeTextBox);
|
||||
_.extend(DateTimeTextBox.prototype, DateTextBox.prototype);
|
||||
|
||||
/**
|
||||
* Builds the config dom for the DateTime TextBox. If isTransformConfig is true,
|
||||
* builds only the necessary dom for the transform config menu.
|
||||
*/
|
||||
DateTimeTextBox.prototype.buildConfigDom = function(isTransformConfig) {
|
||||
var self = this;
|
||||
|
||||
// Set up autocomplete for the timezone entry.
|
||||
var textDom = kf.text(self.timezone);
|
||||
var tzInput = textDom.querySelector('input');
|
||||
$(tzInput).autocomplete({
|
||||
source: self.timezoneOptions,
|
||||
minLength: 1,
|
||||
delay: 10,
|
||||
select: function(event, ui) {
|
||||
self.timezone(ui.item.value);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
return dom('div',
|
||||
kf.row(
|
||||
1, dom('div.glyphicon.glyphicon-globe.config_icon'),
|
||||
8, kf.label('Timezone'),
|
||||
9, dom(textDom,
|
||||
kd.toggleClass('invalid-text', this.isInvalidTimezone),
|
||||
dom.testId("Widget_tz"),
|
||||
testId('widget-tz'))
|
||||
),
|
||||
self.buildDateConfigDom(),
|
||||
kf.row(
|
||||
1, dom('div.glyphicon.glyphicon-dashboard.config_icon'),
|
||||
8, kf.label('Time Format'),
|
||||
9, dom(kf.select(self.standardTimeFormat, self.timeFormatOptions), dom.testId("Widget_timeFormat"))
|
||||
),
|
||||
kd.maybe(self.isCustomTimeFormat, function() {
|
||||
return dom(kf.text(self.timeFormat), dom.testId("Widget_timeCustomFormat"));
|
||||
}),
|
||||
isTransformConfig ? null : cssRow(
|
||||
alignmentSelect(fromKoSave(this.alignment))
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
DateTimeTextBox.prototype.buildTransformConfigDom = function() {
|
||||
return this.buildConfigDom(true);
|
||||
};
|
||||
|
||||
module.exports = DateTimeTextBox;
|
||||
128
app/client/widgets/DiffBox.ts
Normal file
128
app/client/widgets/DiffBox.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { DataRowModel } from 'app/client/models/DataRowModel';
|
||||
import { NewAbstractWidget } from 'app/client/widgets/NewAbstractWidget';
|
||||
import { CellValue } from 'app/common/DocActions';
|
||||
import { isVersions } from 'app/common/gristTypes';
|
||||
import { BaseFormatter } from 'app/common/ValueFormatter';
|
||||
import { Diff, DIFF_DELETE, DIFF_INSERT, diff_match_patch as DiffMatchPatch, DIFF_EQUAL } from 'diff-match-patch';
|
||||
import { Computed, dom } from 'grainjs';
|
||||
|
||||
/**
|
||||
*
|
||||
* A special widget used for rendering cell-level comparisons and conflicts.
|
||||
*
|
||||
*/
|
||||
export class DiffBox extends NewAbstractWidget {
|
||||
|
||||
private _diffTool = new DiffMatchPatch();
|
||||
|
||||
public buildConfigDom() {
|
||||
return dom('div');
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a cell-level diff as a series of styled spans.
|
||||
*/
|
||||
public buildDom(row: DataRowModel) {
|
||||
const formattedValue = Computed.create(null, (use) => {
|
||||
if (use(row._isAddRow) || this.isDisposed() || use(this.field.displayColModel).isDisposed()) {
|
||||
// Work around JS errors during certain changes, following code in Reference.js
|
||||
return [] as Diff[];
|
||||
}
|
||||
const value = use(row.cells[use(use(this.field.displayColModel).colId)]);
|
||||
const formatter = use(this.valueFormatter);
|
||||
return this._prepareCellDiff(value, formatter);
|
||||
});
|
||||
return dom(
|
||||
'div.field_clip',
|
||||
dom.autoDispose(formattedValue),
|
||||
dom.style('text-align', this.options.prop('alignment')),
|
||||
dom.cls('text_wrapping', (use) => Boolean(use(this.options.prop('wrap')))),
|
||||
dom.forEach(formattedValue, ([code, txt]) => {
|
||||
if (code === DIFF_DELETE) {
|
||||
return dom("span.diff-parent", txt);
|
||||
} else if (code === DIFF_INSERT) {
|
||||
return dom("span.diff-remote", txt);
|
||||
} else if (code === DIFF_LOCAL) {
|
||||
return dom("span.diff-local", txt);
|
||||
} else {
|
||||
return dom("span.diff-common", txt);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Given the cell value and the formatter, construct a list of fragments in
|
||||
* diff-match-patch format expressing the difference between versions.
|
||||
* The format is a list of [CODE, STRING] pairs, where the possible values of
|
||||
* CODE are:
|
||||
* -1 -- meaning DELETION of the parent value.
|
||||
* 0 -- meaning text common to all versions.
|
||||
* 1 -- meaning INSERTION of the remote value.
|
||||
* 2 -- meaning INSERTION of the local value.
|
||||
*
|
||||
* When a change is made only locally or remotely, then the list returned may
|
||||
* include common text, deletions and insertions in any order.
|
||||
*
|
||||
* When a change is made both locally and remotely, the list returned does not
|
||||
* include any common text, but just reports the parent value, then the local value,
|
||||
* then the remote value. This may be optimized in future.
|
||||
*/
|
||||
private _prepareCellDiff(value: CellValue, formatter: BaseFormatter): Diff[] {
|
||||
if (!isVersions(value)) {
|
||||
// This can happen for reference columns, where the diff widget is
|
||||
// selected on the basis of one column, but we are displaying the
|
||||
// content of another. We have more version information for the
|
||||
// reference column than for its display column.
|
||||
return [[DIFF_EQUAL, formatter.format(value)]];
|
||||
}
|
||||
const versions = value[1];
|
||||
if (!('local' in versions)) {
|
||||
// Change was made remotely only.
|
||||
return this._prepareTextDiff(formatter.format(versions.parent),
|
||||
formatter.format(versions.remote));
|
||||
} else if (!('remote' in versions)) {
|
||||
// Change was made locally only.
|
||||
return this._prepareTextDiff(formatter.format(versions.parent),
|
||||
formatter.format(versions.local))
|
||||
.map(([code, txt]) => [code === DIFF_INSERT ? DIFF_LOCAL : code, txt]);
|
||||
}
|
||||
// Change was made both locally and remotely.
|
||||
return [[DIFF_DELETE, formatter.format(versions.parent)],
|
||||
[DIFF_LOCAL, formatter.format(versions.local)],
|
||||
[DIFF_INSERT, formatter.format(versions.remote)]];
|
||||
}
|
||||
|
||||
// Run diff-match-patch on the text, do its cleanup, and then some extra
|
||||
// ad-hoc cleanup of our own. Diffs are hard to read if they are too
|
||||
// "choppy".
|
||||
private _prepareTextDiff(txt1: string, txt2: string): Diff[] {
|
||||
const diffs = this._diffTool.diff_main(txt1, txt2);
|
||||
this._diffTool.diff_cleanupSemantic(diffs);
|
||||
if (diffs.length > 2 && this._notDiffWorthy(txt1, diffs.length) &&
|
||||
this._notDiffWorthy(txt2, diffs.length)) {
|
||||
return [[DIFF_DELETE, txt1], [DIFF_INSERT, txt2]];
|
||||
}
|
||||
if (diffs.length === 1 && diffs[0][0] === DIFF_DELETE) {
|
||||
// Add an empty set symbol, since otherwise it will be ambiguous
|
||||
// whether the deletion was done locally or remotely.
|
||||
diffs.push([1, '\u2205']);
|
||||
}
|
||||
return diffs;
|
||||
}
|
||||
|
||||
// Heuristic for whether to show common parts of versions, or to treat them
|
||||
// as entirely distinct.
|
||||
private _notDiffWorthy(txt: string, parts: number) {
|
||||
return txt.length < 5 * parts || this._isMostlyNumeric(txt);
|
||||
}
|
||||
|
||||
// Check is text has a lot of numeric content.
|
||||
private _isMostlyNumeric(txt: string) {
|
||||
return [...txt].filter(c => c >= '0' && c <= '9').length > txt.length / 2;
|
||||
}
|
||||
}
|
||||
|
||||
// A constant marking text fragments present locally but not in parent (or remote).
|
||||
// Must be distinct from DiffMatchPatch.DIFF_* constants (-1, 0, 1).
|
||||
const DIFF_LOCAL = 2;
|
||||
104
app/client/widgets/EditorPlacement.ts
Normal file
104
app/client/widgets/EditorPlacement.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import {Disposable, dom} from 'grainjs';
|
||||
|
||||
export interface ISize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface ISizeOpts {
|
||||
// Don't reposition the editor as part of the size calculation.
|
||||
calcOnly?: boolean;
|
||||
}
|
||||
|
||||
// edgeMargin is how many pixels to leave before the edge of the browser window.
|
||||
const edgeMargin = 12;
|
||||
|
||||
// How large the editor can get when it needs to shift to the left or upwards.
|
||||
const maxShiftWidth = 560;
|
||||
const maxShiftHeight = 400;
|
||||
|
||||
|
||||
/**
|
||||
* This class implements the placement and sizing of the cell editor, such as TextEditor and
|
||||
* FormulaEditor. These try to match the size and position of the cell being edited, expanding
|
||||
* when needed.
|
||||
*
|
||||
* This class also takes care of attaching the editor DOM and destroying it on disposal.
|
||||
*/
|
||||
export class EditorPlacement extends Disposable {
|
||||
private _editorRoot: HTMLElement;
|
||||
|
||||
// - editorDom is the DOM to attach. It gets destroyed when EditorPlacement is disposed.
|
||||
// - cellRect is the bounding box of the cell being mirrored by the editor; the editor generally
|
||||
// expands to match the size of the cell.
|
||||
constructor(editorDom: HTMLElement, private _cellRect: ClientRect|DOMRect) {
|
||||
super();
|
||||
|
||||
const editorRoot = this._editorRoot = dom('div.cell_editor', editorDom);
|
||||
// To hide from the user the incorrectly-sized element, we set visibility to hidden, and
|
||||
// reset it in _calcEditorSize() as soon as we have the sizes.
|
||||
editorRoot.style.visibility = 'hidden';
|
||||
|
||||
document.body.appendChild(editorRoot);
|
||||
this.onDispose(() => {
|
||||
// When the editor is destroyed, destroy and remove its DOM.
|
||||
dom.domDispose(editorRoot);
|
||||
editorRoot.remove();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the size of the full editor and shift the editor if needed to give it more space.
|
||||
* The position and size are applied to the editor unless {calcOnly: true} option is given.
|
||||
*/
|
||||
public calcSize(desiredSize: ISize, options: ISizeOpts = {}): ISize {
|
||||
const maxRect = document.body.getBoundingClientRect();
|
||||
|
||||
const noShiftMaxWidth = maxRect.right - edgeMargin - this._cellRect.left;
|
||||
const maxWidth = Math.min(maxRect.width - 2 * edgeMargin, Math.max(maxShiftWidth, noShiftMaxWidth));
|
||||
const width = Math.min(maxWidth, Math.max(this._cellRect.width, desiredSize.width));
|
||||
const left = Math.max(edgeMargin, Math.min(this._cellRect.left - maxRect.left, maxRect.width - edgeMargin - width));
|
||||
|
||||
const noShiftMaxHeight = maxRect.bottom - edgeMargin - this._cellRect.top;
|
||||
const maxHeight = Math.min(maxRect.height - 2 * edgeMargin, Math.max(maxShiftHeight, noShiftMaxHeight));
|
||||
const height = Math.min(maxHeight, Math.max(this._cellRect.height, desiredSize.height));
|
||||
const top = Math.max(edgeMargin, Math.min(this._cellRect.top - maxRect.top, maxRect.height - edgeMargin - height));
|
||||
|
||||
// To hide from the user the split second before things are sized correctly, we set visibility
|
||||
// to hidden until we can get the sizes. As soon as sizes are available, restore visibility.
|
||||
if (!options.calcOnly) {
|
||||
Object.assign(this._editorRoot.style, {
|
||||
visibility: 'visible',
|
||||
left: left + 'px',
|
||||
top: top + 'px',
|
||||
// Set the width (but not the height) of the outer container explicitly to accommodate the
|
||||
// particular setup where a formula may include error details below -- these should
|
||||
// stretch to the calculated width (so need an explicit value), but may be dynamic in
|
||||
// height. (This feels hacky, but solves the problem.)
|
||||
width: width + 'px',
|
||||
});
|
||||
}
|
||||
|
||||
return {width, height};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the size for the editable part of the editor, given in elem. This assumes that the
|
||||
* size of the full editor differs from the editable part only in constant padding. The full
|
||||
* editor may be shifted as part of this call.
|
||||
*/
|
||||
public calcSizeWithPadding(elem: HTMLElement, desiredElemSize: ISize, options: ISizeOpts = {}): ISize {
|
||||
const rootRect = this._editorRoot.getBoundingClientRect();
|
||||
const elemRect = elem.getBoundingClientRect();
|
||||
const heightDelta = rootRect.height - elemRect.height;
|
||||
const widthDelta = rootRect.width - elemRect.width;
|
||||
const {width, height} = this.calcSize({
|
||||
width: desiredElemSize.width + widthDelta,
|
||||
height: desiredElemSize.height + heightDelta,
|
||||
}, options);
|
||||
return {
|
||||
width: width - widthDelta,
|
||||
height: height - heightDelta,
|
||||
};
|
||||
}
|
||||
}
|
||||
19
app/client/widgets/ErrorDom.ts
Normal file
19
app/client/widgets/ErrorDom.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import {DataRowModel} from 'app/client/models/DataRowModel';
|
||||
|
||||
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
|
||||
import {getObjCode} from 'app/common/gristTypes';
|
||||
import {formatUnknown} from 'app/common/ValueFormatter';
|
||||
import {dom} from 'grainjs';
|
||||
|
||||
export function buildErrorDom(row: DataRowModel, field: ViewFieldRec) {
|
||||
const value = row.cells[field.colId.peek()];
|
||||
const options = field.widgetOptionsJson;
|
||||
// The "invalid" class sets the pink background, as long as the error text is non-empty.
|
||||
return dom('div.field_clip.invalid',
|
||||
// Sets CSS class field-error-P, field-error-U, etc.
|
||||
dom.clsPrefix('field-error-', (use) => getObjCode(use(value)) || ''),
|
||||
dom.style('text-align', options.prop('alignment')),
|
||||
dom.cls('text_wrapping', (use) => Boolean(use(options.prop('wrap')))),
|
||||
dom.text((use) => formatUnknown(value ? use(value) : '???'))
|
||||
);
|
||||
}
|
||||
32
app/client/widgets/FieldBuilder.css
Normal file
32
app/client/widgets/FieldBuilder.css
Normal file
@@ -0,0 +1,32 @@
|
||||
.transform_editor {
|
||||
width: 90%;
|
||||
margin: 5px auto;
|
||||
border: 1px solid #DDDDDD;
|
||||
}
|
||||
|
||||
.transform_menu {
|
||||
padding: 5px 0;
|
||||
margin: 5px;
|
||||
border-top: 1px solid rgba(200, 200, 200, .3);
|
||||
border-bottom: 1px solid rgba(200, 200, 200, .3);
|
||||
}
|
||||
|
||||
.fieldbuilder_settings {
|
||||
background-color: #e8e8e8;
|
||||
margin: 1rem -1px -4px -1px;
|
||||
padding-bottom: 1px;
|
||||
}
|
||||
|
||||
.fieldbuilder_settings_header {
|
||||
height: 2rem;
|
||||
margin-top: 0px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.fieldbuilder_settings_button {
|
||||
display: inline-block;
|
||||
float: right;
|
||||
padding: 0 1rem;
|
||||
border-radius: 5px;
|
||||
background-color: lightgrey;
|
||||
}
|
||||
498
app/client/widgets/FieldBuilder.ts
Normal file
498
app/client/widgets/FieldBuilder.ts
Normal file
@@ -0,0 +1,498 @@
|
||||
import { ColumnTransform } from 'app/client/components/ColumnTransform';
|
||||
import { Cursor } from 'app/client/components/Cursor';
|
||||
import { FormulaTransform } from 'app/client/components/FormulaTransform';
|
||||
import { GristDoc } from 'app/client/components/GristDoc';
|
||||
import { addColTypeSuffix } from 'app/client/components/TypeConversion';
|
||||
import { TypeTransform } from 'app/client/components/TypeTransform';
|
||||
import * as dom from 'app/client/lib/dom';
|
||||
import { KoArray } from 'app/client/lib/koArray';
|
||||
import * as kd from 'app/client/lib/koDom';
|
||||
import * as kf from 'app/client/lib/koForm';
|
||||
import * as koUtil from 'app/client/lib/koUtil';
|
||||
import { reportError } from 'app/client/models/AppModel';
|
||||
import { DataRowModel } from 'app/client/models/DataRowModel';
|
||||
import { ColumnRec, DocModel, ViewFieldRec } from 'app/client/models/DocModel';
|
||||
import { SaveableObjObservable, setSaveValue } from 'app/client/models/modelUtil';
|
||||
import { FieldSettingsMenu } from 'app/client/ui/FieldMenus';
|
||||
import { cssLabel, cssRow } from 'app/client/ui/RightPanel';
|
||||
import { buttonSelect } from 'app/client/ui2018/buttonSelect';
|
||||
import { IOptionFull, menu, select } from 'app/client/ui2018/menus';
|
||||
import { DiffBox } from 'app/client/widgets/DiffBox';
|
||||
import { buildErrorDom } from 'app/client/widgets/ErrorDom';
|
||||
import { FieldEditor, saveWithoutEditor } from 'app/client/widgets/FieldEditor';
|
||||
import { NewAbstractWidget } from 'app/client/widgets/NewAbstractWidget';
|
||||
import * as UserType from 'app/client/widgets/UserType';
|
||||
import * as UserTypeImpl from 'app/client/widgets/UserTypeImpl';
|
||||
import * as gristTypes from 'app/common/gristTypes';
|
||||
import * as gutil from 'app/common/gutil';
|
||||
import { CellValue } from 'app/plugin/GristData';
|
||||
import { delay } from 'bluebird';
|
||||
import { Computed, Disposable, dom as grainjsDom, fromKo, Holder, IDisposable, makeTestId } from 'grainjs';
|
||||
import * as ko from 'knockout';
|
||||
import * as _ from 'underscore';
|
||||
|
||||
const testId = makeTestId('test-fbuilder-');
|
||||
|
||||
// Creates a FieldBuilder object for each field in viewFields
|
||||
export function createAllFieldWidgets(gristDoc: GristDoc, viewFields: ko.Computed<KoArray<ViewFieldRec>>,
|
||||
cursor: Cursor) {
|
||||
// TODO: Handle disposal from the map when fields are removed.
|
||||
return viewFields().map(function(field) {
|
||||
return new FieldBuilder(gristDoc, field, cursor);
|
||||
}).setAutoDisposeValues();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the appropriate object from UserType.typeDefs, defaulting to Text for unknown types.
|
||||
*/
|
||||
function getTypeDefinition(type: string | false) {
|
||||
if (!type) { return UserType.typeDefs.Text; }
|
||||
return UserType.typeDefs[type] || UserType.typeDefs.Text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an instance of FieldBuilder. Used to create all column configuration DOMs, cell DOMs,
|
||||
* and cell editor DOMs for all Grist Types.
|
||||
* @param {Object} field - The field for which the DOMs are to be created.
|
||||
* @param {Object} cursor - The cursor object, used to get the cursor position while saving values.
|
||||
*/
|
||||
export class FieldBuilder extends Disposable {
|
||||
public columnTransform: ColumnTransform | null;
|
||||
public readonly origColumn: ColumnRec;
|
||||
public readonly options: SaveableObjObservable<any>;
|
||||
public readonly widget: ko.PureComputed<any>;
|
||||
public readonly isCallPending: ko.Observable<boolean>;
|
||||
public readonly widgetImpl: ko.Computed<NewAbstractWidget>;
|
||||
public readonly diffImpl: NewAbstractWidget;
|
||||
|
||||
private readonly availableTypes: Computed<Array<IOptionFull<string>>>;
|
||||
private readonly readOnlyPureType: ko.PureComputed<string>;
|
||||
private readonly isRightType: ko.PureComputed<(value: CellValue, options?: any) => boolean>;
|
||||
private readonly refTableId: ko.Computed<string | null>;
|
||||
private readonly isRef: ko.Computed<boolean>;
|
||||
private readonly _rowMap: Map<DataRowModel, Element>;
|
||||
private readonly isTransformingFormula: ko.Computed<boolean>;
|
||||
private readonly isTransformingType: ko.Computed<boolean>;
|
||||
private readonly _fieldEditorHolder: Holder<IDisposable>;
|
||||
private readonly widgetCons: ko.Computed<{create: (...args: any[]) => NewAbstractWidget}>;
|
||||
private readonly docModel: DocModel;
|
||||
|
||||
public constructor(readonly gristDoc: GristDoc, readonly field: ViewFieldRec,
|
||||
private _cursor: Cursor) {
|
||||
super();
|
||||
|
||||
this.docModel = gristDoc.docModel;
|
||||
this.origColumn = field.column();
|
||||
this.options = field.widgetOptionsJson;
|
||||
|
||||
this.readOnlyPureType = ko.pureComputed(() => this.field.column().pureType());
|
||||
|
||||
// Observable with a list of available types.
|
||||
this.availableTypes = Computed.create(this, (use) => {
|
||||
const isFormula = use(this.origColumn.isFormula);
|
||||
const types: Array<IOptionFull<string>> = [];
|
||||
_.each(UserType.typeDefs, (def: any, key: string|number) => {
|
||||
const o: IOptionFull<string> = {
|
||||
value: key as string,
|
||||
label: def.label,
|
||||
icon: def.icon
|
||||
};
|
||||
if (key === 'Any') {
|
||||
// User is unable to select the Any type in non-formula columns.
|
||||
o.disabled = !isFormula;
|
||||
}
|
||||
types.push(o);
|
||||
});
|
||||
return types;
|
||||
});
|
||||
|
||||
// Observable which evaluates to a *function* that decides if a value is valid.
|
||||
this.isRightType = ko.pureComputed(function() {
|
||||
return gristTypes.isRightType(this.readOnlyPureType()) || _.constant(false);
|
||||
}, this);
|
||||
|
||||
// Returns a boolean indicating whether the column is type Reference.
|
||||
this.isRef = this.autoDispose(ko.computed(() => {
|
||||
return gutil.startsWith(this.field.column().type(), 'Ref:');
|
||||
}));
|
||||
|
||||
// Gives the table ID to which the reference points.
|
||||
this.refTableId = this.autoDispose(ko.computed({
|
||||
read: () => gutil.removePrefix(this.field.column().type(), "Ref:"),
|
||||
write: val => this._setType(`Ref:${val}`)
|
||||
}));
|
||||
|
||||
this.widget = ko.pureComputed({
|
||||
owner: this,
|
||||
read() { return this.options().widget; },
|
||||
write(widget) {
|
||||
// Reset the entire JSON, so that all options revert to their defaults.
|
||||
|
||||
this.options.setAndSave({
|
||||
widget,
|
||||
// persists color settings across widgets
|
||||
fillColor: field.fillColor.peek(),
|
||||
textColor: field.textColor.peek()
|
||||
}).catch(reportError);
|
||||
}
|
||||
});
|
||||
|
||||
// Whether there is a pending call that transforms column.
|
||||
this.isCallPending = ko.observable(false);
|
||||
|
||||
// Maintains an instance of the transform object if the field is currently being transformed,
|
||||
// and null if not. Gets disposed along with the transform menu dom.
|
||||
this.columnTransform = null;
|
||||
|
||||
// Returns a boolean indicating whether a formula transform is in progress.
|
||||
this.isTransformingFormula = this.autoDispose(ko.computed(() => {
|
||||
return this.field.column().isTransforming() && this.columnTransform instanceof FormulaTransform;
|
||||
}));
|
||||
// Returns a boolean indicating whether a type transform is in progress.
|
||||
this.isTransformingType = this.autoDispose(ko.computed(() => {
|
||||
return (this.field.column().isTransforming() || this.isCallPending()) &&
|
||||
(this.columnTransform instanceof TypeTransform);
|
||||
}));
|
||||
|
||||
// This holds a single FieldEditor. When a new FieldEditor is created (on edit), it replaces the
|
||||
// previous one if any.
|
||||
this._fieldEditorHolder = Holder.create(this);
|
||||
|
||||
// Map from rowModel to cell dom for the field to which this fieldBuilder applies.
|
||||
this._rowMap = new Map();
|
||||
|
||||
// Returns the constructor for the widget, and only notifies subscribers on changes.
|
||||
this.widgetCons = this.autoDispose(koUtil.withKoUtils(ko.computed(function() {
|
||||
return UserTypeImpl.getWidgetConstructor(this.options().widget,
|
||||
this.readOnlyPureType());
|
||||
}, this)).onlyNotifyUnequal());
|
||||
|
||||
// Computed builder for the widget.
|
||||
this.widgetImpl = this.autoDispose(koUtil.computedBuilder(() => {
|
||||
const cons = this.widgetCons();
|
||||
// Must subscribe to `colId` so that field.colId is rechecked on transform.
|
||||
return cons.create.bind(cons, this.field, this.field.colId());
|
||||
}, this).extend({ deferred: true }));
|
||||
|
||||
this.diffImpl = this.autoDispose(DiffBox.create(this.field));
|
||||
}
|
||||
|
||||
// dispose.makeDisposable(FieldBuilder);
|
||||
|
||||
|
||||
public buildSelectWidgetDom() {
|
||||
return grainjsDom.maybe((use) => !use(this.isTransformingType) && use(this.readOnlyPureType), type => {
|
||||
const typeWidgets = getTypeDefinition(type).widgets;
|
||||
const widgetOptions = Object.keys(typeWidgets).map(label => ({
|
||||
label,
|
||||
value: label,
|
||||
icon: typeWidgets[label].icon
|
||||
}));
|
||||
return widgetOptions.length <= 1 ? null : [
|
||||
cssLabel('CELL FORMAT'),
|
||||
cssRow(
|
||||
widgetOptions.length <= 2 ? buttonSelect(fromKo(this.widget), widgetOptions) :
|
||||
select(fromKo(this.widget), widgetOptions),
|
||||
testId('widget-select')
|
||||
)
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the type change dom.
|
||||
*/
|
||||
public buildSelectTypeDom() {
|
||||
const selectType = Computed.create(null, (use) => use(fromKo(this.readOnlyPureType)));
|
||||
selectType.onWrite(newType => newType === this.readOnlyPureType.peek() || this._setType(newType));
|
||||
const onDispose = () => (this.isDisposed() || selectType.set(this.field.column().pureType()));
|
||||
|
||||
return [
|
||||
cssRow(
|
||||
grainjsDom.autoDispose(selectType),
|
||||
select(selectType, this.availableTypes, {
|
||||
disabled: (use) => use(this.isTransformingFormula) || use(this.origColumn.disableModify) ||
|
||||
use(this.isCallPending)
|
||||
}),
|
||||
testId('type-select')
|
||||
),
|
||||
grainjsDom.maybe((use) => use(this.isRef) && !use(this.isTransformingType), () => this._buildRefTableSelect()),
|
||||
grainjsDom.maybe(this.isTransformingType, () => {
|
||||
// Editor dom must be built before preparing transform.
|
||||
return dom('div.type_transform_prompt',
|
||||
kf.prompt(
|
||||
dom('div',
|
||||
grainjsDom.maybe(this.isRef, () => this._buildRefTableSelect()),
|
||||
grainjsDom.maybe((use) => use(this.field.column().isTransforming),
|
||||
() => this.columnTransform!.buildDom())
|
||||
)
|
||||
),
|
||||
grainjsDom.onDispose(onDispose)
|
||||
);
|
||||
})
|
||||
];
|
||||
}
|
||||
|
||||
// Helper function to set the column type to newType.
|
||||
public _setType(newType: string): Promise<unknown>|undefined {
|
||||
if (this.origColumn.isFormula()) {
|
||||
// Do not type transform a new/empty column or a formula column. Just make a best guess for
|
||||
// the full type, and set it.
|
||||
const column = this.field.column();
|
||||
column.type.setAndSave(addColTypeSuffix(newType, column, this.docModel)).catch(reportError);
|
||||
} else if (!this.columnTransform) {
|
||||
this.columnTransform = TypeTransform.create(null, this.gristDoc, this);
|
||||
return this.columnTransform.prepare(newType);
|
||||
} else {
|
||||
if (this.columnTransform instanceof TypeTransform) {
|
||||
return this.columnTransform.setType(newType);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Builds the reference type table selector. Built when the column is type reference.
|
||||
public _buildRefTableSelect() {
|
||||
const allTables = Computed.create(null, (use) =>
|
||||
use(this.docModel.allTableIds.getObservable()).map(tableId => ({
|
||||
value: tableId,
|
||||
label: tableId,
|
||||
icon: 'FieldTable' as const
|
||||
}))
|
||||
);
|
||||
return [
|
||||
cssLabel('DATA FROM TABLE'),
|
||||
cssRow(
|
||||
dom.autoDispose(allTables),
|
||||
select(fromKo(this.refTableId), allTables),
|
||||
testId('ref-table-select')
|
||||
)
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the formula transform dom
|
||||
*/
|
||||
public buildTransformDom() {
|
||||
const transformButton = ko.computed({
|
||||
read: () => this.field.column().isTransforming(),
|
||||
write: val => {
|
||||
if (val) {
|
||||
this.columnTransform = FormulaTransform.create(null, this.gristDoc, this);
|
||||
return this.columnTransform.prepare();
|
||||
} else {
|
||||
return this.columnTransform && this.columnTransform.cancel();
|
||||
}
|
||||
}
|
||||
});
|
||||
return dom('div',
|
||||
dom.autoDispose(transformButton),
|
||||
dom.onDispose(() => {
|
||||
// When losing focus, if there's an active column transform, finalize it.
|
||||
if (this.columnTransform) {
|
||||
this.columnTransform.finalize();
|
||||
}
|
||||
}),
|
||||
kf.row(
|
||||
15, kf.label('Apply Formula to Data'),
|
||||
3, kf.buttonGroup(
|
||||
kf.checkButton(transformButton,
|
||||
dom('span.glyphicon.glyphicon-flash'),
|
||||
dom.testId("FieldBuilder_editTransform"),
|
||||
kd.toggleClass('disabled', () => this.isTransformingType() || this.origColumn.isFormula() ||
|
||||
this.origColumn.disableModify())
|
||||
)
|
||||
)
|
||||
),
|
||||
kd.maybe(this.isTransformingFormula, () => {
|
||||
return this.columnTransform!.buildDom();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the FieldBuilder Options Config DOM. Calls the buildConfigDom function of its widgetImpl.
|
||||
*/
|
||||
public buildConfigDom() {
|
||||
// NOTE: adding a grainjsDom .maybe here causes the disposable order of the widgetImpl and
|
||||
// the dom created by the widgetImpl to get out of sync.
|
||||
return dom('div',
|
||||
kd.maybe(() => !this.isTransformingType() && this.widgetImpl(), (widget: NewAbstractWidget) =>
|
||||
dom('div',
|
||||
widget.buildConfigDom(),
|
||||
widget.buildColorConfigDom(),
|
||||
|
||||
// If there is more than one field for this column (i.e. present in multiple views).
|
||||
kd.maybe(() => this.origColumn.viewFields().all().length > 1, () =>
|
||||
dom('div.fieldbuilder_settings',
|
||||
kf.row(
|
||||
kd.toggleClass('fieldbuilder_settings_header', true),
|
||||
kf.label(
|
||||
dom('div.fieldbuilder_settings_button',
|
||||
dom.testId('FieldBuilder_settings'),
|
||||
kd.text(() => this.field.useColOptions() ? 'Common' : 'Separate'), ' ▾',
|
||||
menu(ctl => FieldSettingsMenu(this.field.useColOptions(), {
|
||||
useSeparate: () => this.fieldSettingsUseSeparate(),
|
||||
saveAsCommon: () => this.fieldSettingsSaveAsCommon(),
|
||||
revertToCommon: () => this.fieldSettingsRevertToCommon()
|
||||
}))
|
||||
),
|
||||
'Field in ',
|
||||
kd.text(() => this.origColumn.viewFields().all().length),
|
||||
' views'
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public fieldSettingsUseSeparate() {
|
||||
return this.gristDoc.docData.bundleActions(
|
||||
`Use separate field settings for ${this.origColumn.colId()}`, () => {
|
||||
return Promise.all([
|
||||
setSaveValue(this.field.widgetOptions, this.field.column().widgetOptions()),
|
||||
setSaveValue(this.field.visibleCol, this.field.column().visibleCol()),
|
||||
this.field.saveDisplayFormula(this.field.column()._displayColModel().formula() || '')
|
||||
]);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public fieldSettingsSaveAsCommon() {
|
||||
return this.gristDoc.docData.bundleActions(
|
||||
`Save field settings for ${this.origColumn.colId()} as common`, () => {
|
||||
return Promise.all([
|
||||
setSaveValue(this.field.column().widgetOptions, this.field.widgetOptions()),
|
||||
setSaveValue(this.field.column().visibleCol, this.field.visibleCol()),
|
||||
this.field.column().saveDisplayFormula(this.field._displayColModel().formula() || ''),
|
||||
setSaveValue(this.field.widgetOptions, ''),
|
||||
setSaveValue(this.field.visibleCol, 0),
|
||||
this.field.saveDisplayFormula('')
|
||||
]);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public fieldSettingsRevertToCommon() {
|
||||
return this.gristDoc.docData.bundleActions(
|
||||
`Revert field settings for ${this.origColumn.colId()} to common`, () => {
|
||||
return Promise.all([
|
||||
setSaveValue(this.field.widgetOptions, ''),
|
||||
setSaveValue(this.field.visibleCol, 0),
|
||||
this.field.saveDisplayFormula('')
|
||||
]);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the cell and editor DOM for the chosen UserType. Calls the buildDom and
|
||||
* buildEditorDom functions of its widgetImpl.
|
||||
*/
|
||||
public buildDomWithCursor(row: DataRowModel, isActive: boolean, isSelected: boolean) {
|
||||
const widgetObs = koUtil.withKoUtils(ko.computed(function() {
|
||||
// TODO: Accessing row values like this doesn't always work (row and field might not be updated
|
||||
// simultaneously).
|
||||
if (this.isDisposed()) { return null; } // Work around JS errors during field removal.
|
||||
const value = row.cells[this.field.colId()];
|
||||
const cell = value && value();
|
||||
if (value && this.isRightType()(cell, this.options) || row._isAddRow.peek()) {
|
||||
return this.widgetImpl();
|
||||
} else if (gristTypes.isVersions(cell)) {
|
||||
return this.diffImpl;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}, this).extend({ deferred: true })).onlyNotifyUnequal();
|
||||
|
||||
return (elem: Element) => {
|
||||
this._rowMap.set(row, elem);
|
||||
dom(elem,
|
||||
dom.autoDispose(widgetObs),
|
||||
kd.cssClass(this.field.formulaCssClass),
|
||||
kd.maybe(isSelected, () => dom('div.selected_cursor',
|
||||
kd.toggleClass('active_cursor', isActive)
|
||||
)),
|
||||
kd.scope(widgetObs, (widget: NewAbstractWidget) => {
|
||||
if (this.isDisposed()) { return null; } // Work around JS errors during field removal.
|
||||
const cellDom = widget ? widget.buildDom(row) : buildErrorDom(row, this.field);
|
||||
return dom(cellDom, kd.toggleClass('has_cursor', isActive));
|
||||
})
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Flash the cursor in the given row briefly to indicate that editing in this cell is disabled.
|
||||
*/
|
||||
public async flashCursorReadOnly(mainRow: DataRowModel) {
|
||||
const mainCell = this._rowMap.get(mainRow);
|
||||
// Abort if a cell is not found (i.e. if this is a ChartView)
|
||||
if (!mainCell) { return; }
|
||||
const elem = mainCell.querySelector('.active_cursor');
|
||||
if (elem && !elem.classList.contains('cursor_read_only')) {
|
||||
elem.classList.add('cursor_read_only');
|
||||
const div = elem.appendChild(dom('div.cursor_read_only_lock.glyphicon.glyphicon-lock'));
|
||||
try {
|
||||
await delay(200);
|
||||
elem.classList.add('cursor_read_only_fade');
|
||||
await delay(400);
|
||||
} finally {
|
||||
elem.classList.remove('cursor_read_only', 'cursor_read_only_fade');
|
||||
elem.removeChild(div);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public buildEditorDom(editRow: DataRowModel, mainRowModel: DataRowModel, options: {
|
||||
init?: string
|
||||
}) {
|
||||
// If the user attempts to edit a value during transform, finalize (i.e. cancel or execute)
|
||||
// the transform.
|
||||
if (this.columnTransform) {
|
||||
this.columnTransform.finalize();
|
||||
return;
|
||||
}
|
||||
|
||||
const editorCtor = UserTypeImpl.getEditorConstructor(this.options().widget, this.readOnlyPureType());
|
||||
// constructor may be null for a read-only non-formula field, though not today.
|
||||
if (!editorCtor) {
|
||||
// Actually, we only expect buildEditorDom() to be called when isEditorActive() is false (i.e.
|
||||
// _fieldEditorHolder is already clear), but clear here explicitly for clarity.
|
||||
this._fieldEditorHolder.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
if (saveWithoutEditor(editorCtor, editRow, this.field, options.init)) {
|
||||
this._fieldEditorHolder.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
const cellElem = this._rowMap.get(mainRowModel)!;
|
||||
|
||||
// The editor may dispose itself; the Holder will know to clear itself in this case.
|
||||
const fieldEditor = FieldEditor.create(this._fieldEditorHolder, {
|
||||
gristDoc: this.gristDoc,
|
||||
field: this.field,
|
||||
cursor: this._cursor,
|
||||
editRow,
|
||||
cellElem,
|
||||
editorCtor,
|
||||
startVal: options.init,
|
||||
});
|
||||
|
||||
// Put the FieldEditor into a holder in GristDoc too. This way any existing FieldEditor (perhaps
|
||||
// for another field, or for another BaseView) will get disposed at this time. The reason to
|
||||
// still maintain a Holder in this FieldBuilder is mainly to match older behavior; changing that
|
||||
// will entail a number of other tweaks related to the order of creating and disposal.
|
||||
this.gristDoc.fieldEditorHolder.autoDispose(fieldEditor);
|
||||
}
|
||||
|
||||
|
||||
public isEditorActive() {
|
||||
return !this._fieldEditorHolder.isEmpty();
|
||||
}
|
||||
}
|
||||
267
app/client/widgets/FieldEditor.ts
Normal file
267
app/client/widgets/FieldEditor.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import * as commands from 'app/client/components/commands';
|
||||
import {Cursor} from 'app/client/components/Cursor';
|
||||
import {GristDoc} from 'app/client/components/GristDoc';
|
||||
import {UnsavedChange} from 'app/client/components/UnsavedChanges';
|
||||
import {DataRowModel} from 'app/client/models/DataRowModel';
|
||||
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
|
||||
import {reportError} from 'app/client/models/errors';
|
||||
import {FormulaEditor} from 'app/client/widgets/FormulaEditor';
|
||||
import {NewBaseEditor} from 'app/client/widgets/NewBaseEditor';
|
||||
import {CellValue} from "app/common/DocActions";
|
||||
import {isRaisedException} from 'app/common/gristTypes';
|
||||
import * as gutil from 'app/common/gutil';
|
||||
import {Disposable, Holder, Observable} from 'grainjs';
|
||||
|
||||
type IEditorConstructor = typeof NewBaseEditor;
|
||||
interface ICommandGroup { [cmd: string]: () => void; }
|
||||
|
||||
/**
|
||||
* Check if the typed-in value should change the cell without opening the cell editor, and if so,
|
||||
* saves and returns true. E.g. on typing space, CheckBoxEditor toggles the cell without opening.
|
||||
*/
|
||||
export function saveWithoutEditor(
|
||||
editorCtor: IEditorConstructor, editRow: DataRowModel, field: ViewFieldRec, typedVal: string|undefined
|
||||
): boolean {
|
||||
// Never skip the editor if editing a formula. Also, check that skipEditor static function
|
||||
// exists (we don't bother adding it on old-style JS editors that don't need it).
|
||||
if (!field.column.peek().isRealFormula.peek() && editorCtor.skipEditor) {
|
||||
const origVal = editRow.cells[field.colId()].peek();
|
||||
const skipEditorValue = editorCtor.skipEditor(typedVal, origVal);
|
||||
if (skipEditorValue !== undefined) {
|
||||
setAndSave(editRow, field, skipEditorValue).catch(reportError);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Set the given field of editRow to value, only if different from the current value of the cell.
|
||||
export async function setAndSave(editRow: DataRowModel, field: ViewFieldRec, value: CellValue): Promise<void> {
|
||||
const obs = editRow.cells[field.colId()];
|
||||
if (value !== obs.peek()) {
|
||||
return obs.setAndSave(value);
|
||||
}
|
||||
}
|
||||
|
||||
export class FieldEditor extends Disposable {
|
||||
private _gristDoc: GristDoc;
|
||||
private _field: ViewFieldRec;
|
||||
private _cursor: Cursor;
|
||||
private _editRow: DataRowModel;
|
||||
private _cellRect: ClientRect|DOMRect;
|
||||
private _editCommands: ICommandGroup;
|
||||
private _editorCtor: IEditorConstructor;
|
||||
private _editorHolder: Holder<NewBaseEditor> = Holder.create(this);
|
||||
private _saveEditPromise: Promise<boolean>|null = null;
|
||||
|
||||
constructor(options: {
|
||||
gristDoc: GristDoc,
|
||||
field: ViewFieldRec,
|
||||
cursor: Cursor,
|
||||
editRow: DataRowModel,
|
||||
cellElem: Element,
|
||||
editorCtor: IEditorConstructor,
|
||||
startVal?: string,
|
||||
}) {
|
||||
super();
|
||||
this._gristDoc = options.gristDoc;
|
||||
this._field = options.field;
|
||||
this._cursor = options.cursor;
|
||||
this._editRow = options.editRow;
|
||||
this._editorCtor = options.editorCtor;
|
||||
this._cellRect = rectWithoutBorders(options.cellElem);
|
||||
|
||||
const startVal = options.startVal;
|
||||
|
||||
const column = this._field.column();
|
||||
let isFormula: boolean, editValue: string|undefined;
|
||||
if (startVal && gutil.startsWith(startVal, '=')) {
|
||||
// If we entered the cell by typing '=', we immediately convert to formula.
|
||||
isFormula = true;
|
||||
editValue = gutil.removePrefix(startVal, '=') as string;
|
||||
} else {
|
||||
// Initially, we mark the field as editing formula if it's a non-empty formula field. This can
|
||||
// be changed by typing "=", but the field won't be an actual formula field until saved.
|
||||
isFormula = column.isRealFormula.peek();
|
||||
editValue = startVal;
|
||||
}
|
||||
|
||||
// These are the commands for while the editor is active.
|
||||
this._editCommands = {
|
||||
// _saveEdit disables this command group, so when we run fieldEditSave again, it triggers
|
||||
// another registered group, if any. E.g. GridView listens to it to move the cursor down.
|
||||
fieldEditSave: () => {
|
||||
this._saveEdit().then((jumped: boolean) => {
|
||||
// To avoid confusing cursor movement, do not increment the rowIndex if the row
|
||||
// was re-sorted after editing.
|
||||
if (!jumped) { commands.allCommands.fieldEditSave.run(); }
|
||||
})
|
||||
.catch(reportError);
|
||||
},
|
||||
fieldEditSaveHere: () => { this._saveEdit().catch(reportError); },
|
||||
fieldEditCancel: () => { this.dispose(); },
|
||||
prevField: () => { this._saveEdit().then(commands.allCommands.prevField.run).catch(reportError); },
|
||||
nextField: () => { this._saveEdit().then(commands.allCommands.nextField.run).catch(reportError); },
|
||||
makeFormula: () => this._makeFormula(),
|
||||
unmakeFormula: () => this._unmakeFormula(),
|
||||
};
|
||||
|
||||
this.rebuildEditor(isFormula, editValue, Number.POSITIVE_INFINITY);
|
||||
|
||||
// Whenever focus returns to the Clipboard component, close the editor by saving the value.
|
||||
this._gristDoc.app.on('clipboard_focus', this._saveEdit, this);
|
||||
|
||||
// TODO: This should ideally include a callback that returns true only when the editor value
|
||||
// has changed. Currently an open editor is considered unsaved even when unchanged.
|
||||
UnsavedChange.create(this, async () => { await this._saveEdit(); });
|
||||
|
||||
this.onDispose(() => {
|
||||
this._gristDoc.app.off('clipboard_focus', this._saveEdit, this);
|
||||
// Unset field.editingFormula flag when the editor closes.
|
||||
this._field.editingFormula(false);
|
||||
});
|
||||
}
|
||||
|
||||
// cursorPos refers to the position of the caret within the editor.
|
||||
public rebuildEditor(isFormula: boolean, editValue: string|undefined, cursorPos: number) {
|
||||
const editorCtor: IEditorConstructor = isFormula ? FormulaEditor : this._editorCtor;
|
||||
|
||||
const column = this._field.column();
|
||||
const cellCurrentValue = this._editRow.cells[this._field.colId()].peek();
|
||||
const cellValue = column.isFormula() ? column.formula() : cellCurrentValue;
|
||||
|
||||
// Enter formula-editing mode (e.g. click-on-column inserts its ID) only if we are opening the
|
||||
// editor by typing into it (and overriding previous formula). In other cases (e.g. double-click),
|
||||
// we defer this mode until the user types something.
|
||||
this._field.editingFormula(isFormula && editValue !== undefined);
|
||||
|
||||
let formulaError: Observable<CellValue>|undefined;
|
||||
if (column.isFormula() && isRaisedException(cellCurrentValue)) {
|
||||
const fv = formulaError = Observable.create(null, cellCurrentValue);
|
||||
this._gristDoc.docData.getFormulaError(column.table().tableId(),
|
||||
this._field.colId(),
|
||||
this._editRow.getRowId()
|
||||
)
|
||||
.then(value => { fv.set(value); })
|
||||
.catch(reportError);
|
||||
}
|
||||
|
||||
// Replace the item in the Holder with a new one, disposing the previous one.
|
||||
const editor = this._editorHolder.autoDispose(editorCtor.create({
|
||||
gristDoc: this._gristDoc,
|
||||
field: this._field,
|
||||
cellValue,
|
||||
formulaError,
|
||||
editValue,
|
||||
cursorPos,
|
||||
commands: this._editCommands,
|
||||
}));
|
||||
editor.attach(this._cellRect);
|
||||
}
|
||||
|
||||
private _makeFormula() {
|
||||
const editor = this._editorHolder.get();
|
||||
// On keyPress of "=" on textInput, turn the value into a formula.
|
||||
if (editor && !this._field.editingFormula.peek() && editor.getCursorPos() === 0) {
|
||||
this.rebuildEditor(true, editor.getTextValue(), 0);
|
||||
return false;
|
||||
}
|
||||
return true; // don't stop propagation.
|
||||
}
|
||||
|
||||
private _unmakeFormula() {
|
||||
const editor = this._editorHolder.get();
|
||||
// Only convert to data if we are undoing a to-formula conversion. To convert formula to
|
||||
// data, delete the formula first (which makes the column "empty").
|
||||
if (editor && this._field.editingFormula.peek() && editor.getCursorPos() === 0 &&
|
||||
!this._field.column().isRealFormula()) {
|
||||
// Restore a plain '=' character. This gives a way to enter "=" at the start if line. The
|
||||
// second backspace will delete it.
|
||||
this.rebuildEditor(false, '=' + editor.getTextValue(), 1);
|
||||
return false;
|
||||
}
|
||||
return true; // don't stop propagation.
|
||||
}
|
||||
|
||||
private async _saveEdit() {
|
||||
return this._saveEditPromise || (this._saveEditPromise = this._doSaveEdit());
|
||||
}
|
||||
|
||||
// Returns whether the cursor jumped, i.e. current record got reordered.
|
||||
private async _doSaveEdit(): Promise<boolean> {
|
||||
const editor = this._editorHolder.get();
|
||||
if (!editor) { return false; }
|
||||
// Make sure the editor is save ready
|
||||
const saveIndex = this._cursor.rowIndex();
|
||||
await editor.prepForSave();
|
||||
if (this.isDisposed()) {
|
||||
// We shouldn't normally get disposed here, but if we do, avoid confusing JS errors.
|
||||
console.warn("Unable to finish saving edited cell"); // tslint:disable-line:no-console
|
||||
return false;
|
||||
}
|
||||
|
||||
// Then save the value the appropriate way
|
||||
// TODO: this isFormula value doesn't actually reflect if editing the formula, since
|
||||
// editingFormula() is used for toggling column headers, and this is deferred to start of
|
||||
// typing (a double-click or Enter) does not immediately set it. (This can cause a
|
||||
// console.warn below, although harmless.)
|
||||
let isFormula = this._field.editingFormula();
|
||||
const col = this._field.column();
|
||||
let waitPromise: Promise<unknown>|null = null;
|
||||
|
||||
if (isFormula) {
|
||||
const formula = editor.getCellValue();
|
||||
if (col.isRealFormula() && formula === "") {
|
||||
// A somewhat surprising feature: deleting the formula converts the column to data, keeping
|
||||
// the values. To clear the column, enter an empty formula again (now into a data column).
|
||||
// TODO: this should probably be made more intuitive.
|
||||
isFormula = false;
|
||||
}
|
||||
|
||||
// Bundle multiple changes so that we can undo them in one step.
|
||||
if (isFormula !== col.isFormula.peek() || formula !== col.formula.peek()) {
|
||||
waitPromise = this._gristDoc.docData.bundleActions(null, () => Promise.all([
|
||||
col.updateColValues({isFormula, formula}),
|
||||
// If we're saving a non-empty formula, then also add an empty record to the table
|
||||
// so that the formula calculation is visible to the user.
|
||||
(this._editRow._isAddRow.peek() && formula !== "" ?
|
||||
this._editRow.updateColValues({}) : undefined),
|
||||
]));
|
||||
}
|
||||
} else {
|
||||
const value = editor.getCellValue();
|
||||
if (col.isRealFormula()) {
|
||||
// tslint:disable-next-line:no-console
|
||||
console.warn("It should be impossible to save a plain data value into a formula column");
|
||||
} else {
|
||||
// This could still be an isFormula column if it's empty (isEmpty is true), but we don't
|
||||
// need to toggle isFormula in that case, since the data engine takes care of that.
|
||||
waitPromise = setAndSave(this._editRow, this._field, value);
|
||||
}
|
||||
}
|
||||
const cursor = this._cursor;
|
||||
// Deactivate the editor. We are careful to avoid using `this` afterwards.
|
||||
this.dispose();
|
||||
await waitPromise;
|
||||
return (saveIndex !== cursor.rowIndex());
|
||||
}
|
||||
}
|
||||
|
||||
// Get the bounding rect of elem excluding borders. This allows the editor to match cellElem more
|
||||
// closely which is more visible in case of DetailView.
|
||||
function rectWithoutBorders(elem: Element): ClientRect {
|
||||
const rect = elem.getBoundingClientRect();
|
||||
const style = getComputedStyle(elem, null);
|
||||
const bTop = parseFloat(style.getPropertyValue('border-top-width'));
|
||||
const bRight = parseFloat(style.getPropertyValue('border-right-width'));
|
||||
const bBottom = parseFloat(style.getPropertyValue('border-bottom-width'));
|
||||
const bLeft = parseFloat(style.getPropertyValue('border-left-width'));
|
||||
return {
|
||||
width: rect.width - bLeft - bRight,
|
||||
height: rect.height - bTop - bBottom,
|
||||
top: rect.top + bTop,
|
||||
bottom: rect.bottom - bBottom,
|
||||
left: rect.left + bLeft,
|
||||
right: rect.right - bRight,
|
||||
};
|
||||
}
|
||||
175
app/client/widgets/FormulaEditor.ts
Normal file
175
app/client/widgets/FormulaEditor.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import * as AceEditor from 'app/client/components/AceEditor';
|
||||
import {createGroup} from 'app/client/components/commands';
|
||||
import {DataRowModel} from 'app/client/models/DataRowModel';
|
||||
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
|
||||
import {colors, testId} from 'app/client/ui2018/cssVars';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {EditorPlacement, ISize} from 'app/client/widgets/EditorPlacement';
|
||||
import {NewBaseEditor, Options} from 'app/client/widgets/NewBaseEditor';
|
||||
import {undefDefault} from 'app/common/gutil';
|
||||
import {dom, Observable, styled} from 'grainjs';
|
||||
|
||||
// How wide to expand the FormulaEditor when an error is shown in it.
|
||||
const minFormulaErrorWidth = 400;
|
||||
|
||||
/**
|
||||
* Required parameters:
|
||||
* @param {RowModel} options.field: ViewSectionField (i.e. column) being edited.
|
||||
* @param {Object} options.cellValue: The value in the underlying cell being edited.
|
||||
* @param {String} options.editValue: String to be edited.
|
||||
* @param {Number} options.cursorPos: The initial position where to place the cursor.
|
||||
* @param {Object} options.commands: Object mapping command names to functions, to enable as part
|
||||
* of the command group that should be activated while the editor exists.
|
||||
* @param {Boolean} options.omitBlurEventForObservableMode: Flag to indicate whether ace editor
|
||||
* should save the value on `blur` event.
|
||||
*/
|
||||
export class FormulaEditor extends NewBaseEditor {
|
||||
private _formulaEditor: any;
|
||||
private _commandGroup: any;
|
||||
private _dom: HTMLElement;
|
||||
private _editorPlacement: EditorPlacement;
|
||||
|
||||
constructor(options: Options) {
|
||||
super(options);
|
||||
this._formulaEditor = AceEditor.create({
|
||||
// A bit awkward, but we need to assume calcSize is not used until attach() has been called
|
||||
// and _editorPlacement created.
|
||||
calcSize: this._calcSize.bind(this),
|
||||
gristDoc: options.gristDoc,
|
||||
saveValueOnBlurEvent: true,
|
||||
});
|
||||
|
||||
const allCommands = Object.assign({ setCursor: this._onSetCursor }, options.commands);
|
||||
this._commandGroup = this.autoDispose(createGroup(allCommands, this, options.field.editingFormula));
|
||||
|
||||
const hideErrDetails = Observable.create(null, true);
|
||||
const formulaError = options.formulaError;
|
||||
|
||||
this.autoDispose(this._formulaEditor);
|
||||
this._dom = dom('div.default_editor',
|
||||
// This shouldn't be needed, but needed for tests.
|
||||
dom.on('mousedown', (ev) => {
|
||||
ev.preventDefault();
|
||||
this._formulaEditor.getEditor().focus();
|
||||
}),
|
||||
dom('div.formula_editor.formula_field_edit', testId('formula-editor'),
|
||||
// We don't always enter editing mode immediately, e.g. not on double-clicking a cell.
|
||||
// In those cases, we'll switch as soon as the user types or clicks into the editor.
|
||||
dom.on('mousedown', () => options.field.editingFormula(true)),
|
||||
this._formulaEditor.buildDom((aceObj: any) => {
|
||||
aceObj.setFontSize(11);
|
||||
aceObj.setHighlightActiveLine(false);
|
||||
aceObj.getSession().setUseWrapMode(false);
|
||||
aceObj.renderer.setPadding(0);
|
||||
const val = undefDefault(options.editValue, String(options.cellValue));
|
||||
const pos = Math.min(options.cursorPos, val.length);
|
||||
this._formulaEditor.setValue(val, pos);
|
||||
this._formulaEditor.attachCommandGroup(this._commandGroup);
|
||||
|
||||
// This catches any change to the value including e.g. via backspace or paste.
|
||||
aceObj.once("change", () => options.field.editingFormula(true));
|
||||
})
|
||||
),
|
||||
(formulaError ?
|
||||
dom('div.error_box',
|
||||
dom('div.error_msg', testId('formula-error-msg'),
|
||||
dom.on('click', () => {
|
||||
hideErrDetails.set(!hideErrDetails.get());
|
||||
this._formulaEditor.resize();
|
||||
}),
|
||||
dom.domComputed(hideErrDetails, (hide) => cssCollapseIcon(hide ? 'Expand' : 'Collapse')),
|
||||
dom.text((use) => { const f = use(formulaError) as string[]; return `${f[1]}: ${f[2]}`; }),
|
||||
),
|
||||
dom('div.error_details',
|
||||
dom.hide(hideErrDetails),
|
||||
dom.text((use) => (use(formulaError) as string[])[3]),
|
||||
testId('formula-error-details'),
|
||||
),
|
||||
) : null
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public attach(cellRect: ClientRect|DOMRect): void {
|
||||
this._editorPlacement = EditorPlacement.create(this, this._dom, cellRect);
|
||||
this._formulaEditor.onAttach();
|
||||
this._formulaEditor.editor.focus();
|
||||
}
|
||||
|
||||
public getCellValue() {
|
||||
return this._formulaEditor.getValue();
|
||||
}
|
||||
|
||||
public getTextValue() {
|
||||
return this._formulaEditor.getValue();
|
||||
}
|
||||
|
||||
public getCursorPos() {
|
||||
const aceObj = this._formulaEditor.getEditor();
|
||||
return aceObj.getSession().getDocument().positionToIndex(aceObj.getCursorPosition());
|
||||
}
|
||||
|
||||
private _calcSize(elem: HTMLElement, desiredElemSize: ISize) {
|
||||
// If we have an error to show, ask for a larger size for formulaEditor.
|
||||
const desiredSize = {
|
||||
width: Math.max(desiredElemSize.width, (this.options.formulaError ? minFormulaErrorWidth : 0)),
|
||||
height: desiredElemSize.height,
|
||||
};
|
||||
return this._editorPlacement.calcSizeWithPadding(elem, desiredSize);
|
||||
}
|
||||
|
||||
// TODO: update regexes to unicode?
|
||||
private _onSetCursor(row: DataRowModel, col: ViewFieldRec) {
|
||||
if (!col) { return; } // if clicked on row header, no col to insert
|
||||
|
||||
const aceObj = this._formulaEditor.getEditor();
|
||||
|
||||
if (!aceObj.selection.isEmpty()) { // If text selected, replace whole selection
|
||||
aceObj.session.replace(aceObj.selection.getRange(), '$' + col.colId());
|
||||
|
||||
} else { // Not a selection, gotta figure out what to replace
|
||||
const pos = aceObj.getCursorPosition();
|
||||
const line = aceObj.session.getLine(pos.row);
|
||||
const result = _isInIdentifier(line, pos.column); // returns {start, end, id} | null
|
||||
|
||||
if (!result) { // Not touching an identifier, insert colId as normal
|
||||
aceObj.insert("$" + col.colId());
|
||||
|
||||
// We are touching an identifier
|
||||
} else if (result.ident.startsWith("$")) { // If ident is a colId, replace it
|
||||
|
||||
const idRange = AceEditor.makeRange(pos.row, result.start, pos.row, result.end);
|
||||
aceObj.session.replace(idRange, "$" + col.colId());
|
||||
}
|
||||
|
||||
// Else touching a normal identifier, dont mangle it
|
||||
}
|
||||
|
||||
// Resize editor in case it is needed.
|
||||
this._formulaEditor.resize();
|
||||
aceObj.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// returns whether the column in that line is inside or adjacent to an identifier
|
||||
// if yes, returns {start, end, ident}, else null
|
||||
function _isInIdentifier(line: string, column: number) {
|
||||
// If cursor is in or after an identifier, scoot back to the start of it
|
||||
const prefix = line.slice(0, column);
|
||||
let startOfIdent = prefix.search(/[$A-Za-z0-9_]+$/);
|
||||
if (startOfIdent < 0) { startOfIdent = column; } // if no match, maybe we're right before it
|
||||
|
||||
// We're either before an ident or nowhere near one. Try to match to its end
|
||||
const match = line.slice(startOfIdent).match(/^[$a-zA-Z0-9_]+/);
|
||||
if (match) {
|
||||
const ident = match[0];
|
||||
return { ident, start: startOfIdent, end: startOfIdent + ident.length};
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const cssCollapseIcon = styled(icon, `
|
||||
margin: -3px 4px 0 4px;
|
||||
--icon-color: ${colors.slate};
|
||||
`);
|
||||
13
app/client/widgets/HyperLinkEditor.ts
Normal file
13
app/client/widgets/HyperLinkEditor.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import {Options} from 'app/client/widgets/NewBaseEditor';
|
||||
import {NTextEditor} from 'app/client/widgets/NTextEditor';
|
||||
|
||||
/**
|
||||
* HyperLinkEditor - Is the same NTextEditor but with some placeholder text to help explain
|
||||
* to the user how links should be formatted.
|
||||
*/
|
||||
export class HyperLinkEditor extends NTextEditor {
|
||||
constructor(options: Options) {
|
||||
super(options);
|
||||
this.textInput.setAttribute('placeholder', '[link label] url');
|
||||
}
|
||||
}
|
||||
78
app/client/widgets/HyperLinkTextBox.js
Normal file
78
app/client/widgets/HyperLinkTextBox.js
Normal file
@@ -0,0 +1,78 @@
|
||||
var _ = require('underscore');
|
||||
var dom = require('../lib/dom');
|
||||
var dispose = require('../lib/dispose');
|
||||
var kd = require('../lib/koDom');
|
||||
var TextBox = require('./TextBox');
|
||||
const G = require('../lib/browserGlobals').get('URL');
|
||||
|
||||
const {colors, testId} = require('app/client/ui2018/cssVars');
|
||||
const {icon} = require('app/client/ui2018/icons');
|
||||
const {styled} = require('grainjs');
|
||||
|
||||
/**
|
||||
* Creates a widget for displaying links. Links can entered directly or following a title.
|
||||
* The last entry following a space is used as the url.
|
||||
* ie 'google https://www.google.com' would apears as 'google' to the user but link to the url.
|
||||
*/
|
||||
function HyperLinkTextBox(field) {
|
||||
TextBox.call(this, field);
|
||||
}
|
||||
dispose.makeDisposable(HyperLinkTextBox);
|
||||
_.extend(HyperLinkTextBox.prototype, TextBox.prototype);
|
||||
|
||||
HyperLinkTextBox.prototype.buildDom = function(row) {
|
||||
var value = row[this.field.colId()];
|
||||
return cssFieldClip(
|
||||
kd.style('text-align', this.alignment),
|
||||
kd.toggleClass('text_wrapping', this.wrapping),
|
||||
kd.maybe(value, () =>
|
||||
dom('a',
|
||||
kd.attr('href', () => _constructUrl(value())),
|
||||
kd.attr('target', '_blank'),
|
||||
// As per Google and Mozilla recommendations to prevent opened links
|
||||
// from running on the same process as Grist:
|
||||
// https://developers.google.com/web/tools/lighthouse/audits/noopener
|
||||
kd.attr('rel', 'noopener noreferrer'),
|
||||
cssLinkIcon('FieldLink'),
|
||||
testId('tb-link')
|
||||
)
|
||||
),
|
||||
kd.text(() => _formatValue(value()))
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats value like `foo bar baz` by discarding `baz` and returning `foo bar`.
|
||||
*/
|
||||
function _formatValue(value) {
|
||||
// value might be null, at least transiently. Handle it to avoid an exception.
|
||||
if (typeof value !== 'string') { return value; }
|
||||
const index = value.lastIndexOf(' ');
|
||||
return index >= 0 ? value.slice(0, index) : value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given value like `foo bar baz`, constructs URL by checking if `baz` is a valid URL and,
|
||||
* if not, prepending `http://`.
|
||||
*/
|
||||
function _constructUrl(value) {
|
||||
const url = value.slice(value.lastIndexOf(' ') + 1);
|
||||
try {
|
||||
// Try to construct a valid URL
|
||||
return (new G.URL(url)).toString();
|
||||
} catch (e) {
|
||||
// Not a valid URL, so try to prefix it with http
|
||||
return 'http://' + url;
|
||||
}
|
||||
}
|
||||
|
||||
const cssFieldClip = styled('div.field_clip', `
|
||||
color: ${colors.lightGreen};
|
||||
`);
|
||||
|
||||
const cssLinkIcon = styled(icon, `
|
||||
background-color: ${colors.lightGreen};
|
||||
margin: -1px 2px 2px 0;
|
||||
`);
|
||||
|
||||
module.exports = HyperLinkTextBox;
|
||||
66
app/client/widgets/NTextBox.ts
Normal file
66
app/client/widgets/NTextBox.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import {fromKoSave} from 'app/client/lib/fromKoSave';
|
||||
import {DataRowModel} from 'app/client/models/DataRowModel';
|
||||
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
|
||||
import {cssRow} from 'app/client/ui/RightPanel';
|
||||
import {alignmentSelect, makeButtonSelect} from 'app/client/ui2018/buttonSelect';
|
||||
import {testId} from 'app/client/ui2018/cssVars';
|
||||
import {NewAbstractWidget} from 'app/client/widgets/NewAbstractWidget';
|
||||
import {Computed, dom, Observable} from 'grainjs';
|
||||
|
||||
/**
|
||||
* TextBox - The most basic widget for displaying text information.
|
||||
*/
|
||||
export class NTextBox extends NewAbstractWidget {
|
||||
protected alignment: Observable<string>;
|
||||
protected wrapping: Observable<boolean>;
|
||||
|
||||
constructor(field: ViewFieldRec) {
|
||||
super(field);
|
||||
|
||||
this.alignment = fromKoSave<string>(this.options.prop('alignment'));
|
||||
const wrap = this.options.prop('wrap');
|
||||
|
||||
this.wrapping = Computed.create(this, (use) => {
|
||||
const w = use(wrap);
|
||||
if (w === null || w === undefined) {
|
||||
// When user has yet to specify a desired wrapping state, GridView and DetailView have
|
||||
// different default states. GridView defaults to wrapping disabled, while DetailView
|
||||
// defaults to wrapping enabled.
|
||||
return (this.field.viewSection().parentKey() === 'record') ? false : true;
|
||||
} else {
|
||||
return w;
|
||||
}
|
||||
});
|
||||
|
||||
this.autoDispose(this.wrapping.addListener(() => {
|
||||
this.field.viewSection().events.trigger('rowHeightChange');
|
||||
}));
|
||||
}
|
||||
|
||||
public buildConfigDom() {
|
||||
return dom('div',
|
||||
cssRow(
|
||||
alignmentSelect(this.alignment),
|
||||
dom('div', {style: 'margin-left: 8px;'},
|
||||
makeButtonSelect(this.wrapping, [{value: true, icon: 'Wrap'}], this._toggleWrap.bind(this), {}),
|
||||
testId('tb-wrap-text')
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public buildDom(row: DataRowModel) {
|
||||
const value = row.cells[this.field.colId.peek()];
|
||||
return dom('div.field_clip',
|
||||
dom.style('text-align', this.alignment),
|
||||
dom.cls('text_wrapping', this.wrapping),
|
||||
dom.text((use) => use(row._isAddRow) ? '' : use(this.valueFormatter).format(use(value))),
|
||||
);
|
||||
}
|
||||
|
||||
private _toggleWrap(value: boolean) {
|
||||
const newValue = !this.wrapping.get();
|
||||
this.options.update({wrap: newValue});
|
||||
(this.options as any).save();
|
||||
}
|
||||
}
|
||||
98
app/client/widgets/NTextEditor.ts
Normal file
98
app/client/widgets/NTextEditor.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* This is a copy of TextEditor.js, converted to typescript.
|
||||
*/
|
||||
import {createGroup} from 'app/client/components/commands';
|
||||
import {testId} from 'app/client/ui2018/cssVars';
|
||||
import {EditorPlacement} from 'app/client/widgets/EditorPlacement';
|
||||
import {NewBaseEditor, Options} from 'app/client/widgets/NewBaseEditor';
|
||||
import {CellValue} from "app/common/DocActions";
|
||||
import {undefDefault} from 'app/common/gutil';
|
||||
import {dom} from 'grainjs';
|
||||
|
||||
|
||||
export class NTextEditor extends NewBaseEditor {
|
||||
protected cellEditorDiv: HTMLElement;
|
||||
protected textInput: HTMLTextAreaElement;
|
||||
protected commandGroup: any;
|
||||
|
||||
private _dom: HTMLElement;
|
||||
private _editorPlacement: EditorPlacement;
|
||||
private _contentSizer: HTMLElement;
|
||||
private _alignment: string;
|
||||
|
||||
// Note: TextEditor supports also options.placeholder for use by derived classes, but this is
|
||||
// easy to apply to this.textInput without needing a separate option.
|
||||
constructor(options: Options) {
|
||||
super(options);
|
||||
|
||||
this.commandGroup = this.autoDispose(createGroup(options.commands, null, true));
|
||||
this._alignment = options.field.widgetOptionsJson.peek().alignment || 'left';
|
||||
this._dom = dom('div.default_editor',
|
||||
this.cellEditorDiv = dom('div.celleditor_cursor_editor', testId('widget-text-editor'),
|
||||
this._contentSizer = dom('div.celleditor_content_measure'),
|
||||
this.textInput = dom('textarea', dom.cls('celleditor_text_editor'),
|
||||
dom.style('text-align', this._alignment),
|
||||
dom.prop('value', undefDefault(options.editValue, String(options.cellValue))),
|
||||
this.commandGroup.attach(),
|
||||
// Resize the textbox whenever user types in it.
|
||||
dom.on('input', () => this.resizeInput())
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public attach(cellRect: ClientRect|DOMRect): void {
|
||||
// Attach the editor dom to page DOM.
|
||||
this._editorPlacement = EditorPlacement.create(this, this._dom, cellRect);
|
||||
this.setSizerLimits();
|
||||
|
||||
// Once the editor is attached to DOM, resize it to content, focus, and set cursor.
|
||||
this.resizeInput();
|
||||
this.textInput.focus();
|
||||
const pos = Math.min(this.options.cursorPos, this.textInput.value.length);
|
||||
this.textInput.setSelectionRange(pos, pos);
|
||||
}
|
||||
|
||||
public getCellValue(): CellValue {
|
||||
return this.textInput.value;
|
||||
}
|
||||
|
||||
public getTextValue() {
|
||||
return this.textInput.value;
|
||||
}
|
||||
|
||||
public getCursorPos() {
|
||||
return this.textInput.selectionStart;
|
||||
}
|
||||
|
||||
public setSizerLimits() {
|
||||
// Set the max width of the sizer to the max we could possibly grow to, so that it knows to wrap
|
||||
// once we reach it.
|
||||
const maxSize = this._editorPlacement.calcSizeWithPadding(this.textInput,
|
||||
{width: Infinity, height: Infinity}, {calcOnly: true});
|
||||
this._contentSizer.style.maxWidth = Math.ceil(maxSize.width) + 'px';
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper which resizes textInput to match its content. It relies on having a contentSizer element
|
||||
* with the same font/size settings as the textInput, and on having `calcSize` helper,
|
||||
* which is provided by the EditorPlacement class.
|
||||
*/
|
||||
protected resizeInput() {
|
||||
const textInput = this.textInput;
|
||||
// \u200B is a zero-width space; it is used so the textbox will expand vertically
|
||||
// on newlines, but it does not add any width.
|
||||
this._contentSizer.textContent = textInput.value + '\u200B';
|
||||
const rect = this._contentSizer.getBoundingClientRect();
|
||||
|
||||
// Allow for a bit of extra space after the cursor (only desirable when text is left-aligned).
|
||||
if (this._alignment === "left") {
|
||||
// Modifiable in modern browsers: https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect
|
||||
rect.width += 16;
|
||||
}
|
||||
|
||||
const size = this._editorPlacement.calcSizeWithPadding(textInput, rect);
|
||||
textInput.style.width = size.width + 'px';
|
||||
textInput.style.height = size.height + 'px';
|
||||
}
|
||||
}
|
||||
107
app/client/widgets/NewAbstractWidget.ts
Normal file
107
app/client/widgets/NewAbstractWidget.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* NewAbstractWidget is equivalent to AbstractWidget for outside code, but is in typescript, and
|
||||
* so is friendlier and clearer to derive TypeScript classes from.
|
||||
*/
|
||||
import {DocComm} from 'app/client/components/DocComm';
|
||||
import {DocData} from 'app/client/models/DocData';
|
||||
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
|
||||
import {SaveableObjObservable} from 'app/client/models/modelUtil';
|
||||
import {cssLabel, cssRow} from 'app/client/ui/RightPanel';
|
||||
import {colorSelect} from 'app/client/ui2018/buttonSelect';
|
||||
import {colors, testId} from 'app/client/ui2018/cssVars';
|
||||
import {BaseFormatter, createFormatter} from 'app/common/ValueFormatter';
|
||||
import {Disposable, fromKo, Observable, styled} from 'grainjs';
|
||||
import * as ko from 'knockout';
|
||||
|
||||
|
||||
/**
|
||||
* NewAbstractWidget - The base of the inheritance tree for widgets.
|
||||
* @param {Function} field - The RowModel for this view field.
|
||||
*/
|
||||
export abstract class NewAbstractWidget extends Disposable {
|
||||
/**
|
||||
* Override the create() method to match the parameters of create() expected by FieldBuilder.
|
||||
*/
|
||||
public static create(field: ViewFieldRec) {
|
||||
return Disposable.create.call(this as any, null, field);
|
||||
}
|
||||
|
||||
protected options: SaveableObjObservable<any>;
|
||||
protected valueFormatter: Observable<BaseFormatter>;
|
||||
|
||||
constructor(protected field: ViewFieldRec) {
|
||||
super();
|
||||
this.options = field.widgetOptionsJson;
|
||||
|
||||
// Note that its easier to create a knockout computed from the several knockout observables,
|
||||
// but then we turn it into a grainjs observable.
|
||||
const formatter = this.autoDispose(ko.computed(() =>
|
||||
createFormatter(field.displayColModel().type(), this.options())));
|
||||
this.valueFormatter = fromKo(formatter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the DOM showing configuration buttons and fields in the sidebar.
|
||||
*/
|
||||
public buildConfigDom(): Element|null { return null; }
|
||||
|
||||
/**
|
||||
* Builds the transform prompt config DOM in the few cases where it is necessary.
|
||||
* Child classes need not override this function if they do not require transform config options.
|
||||
*/
|
||||
public buildTransformConfigDom(): Element|null { return null; }
|
||||
|
||||
public buildColorConfigDom(): Element[] {
|
||||
return [
|
||||
cssLabel('CELL COLOR'),
|
||||
cssRow(
|
||||
cssHalfWidth(
|
||||
colorSelect(
|
||||
fromKo(this.field.textColor) as Observable<string>,
|
||||
(val) => this.field.textColor.saveOnly(val),
|
||||
testId('text-color')
|
||||
),
|
||||
cssInlineLabel('Text'),
|
||||
),
|
||||
cssHalfWidth(
|
||||
colorSelect(
|
||||
fromKo(this.field.fillColor) as Observable<string>,
|
||||
(val) => this.field.fillColor.saveOnly(val),
|
||||
testId('fill-color')
|
||||
),
|
||||
cssInlineLabel('Fill')
|
||||
)
|
||||
)
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the data cell DOM.
|
||||
* @param {DataRowModel} row - The rowModel object.
|
||||
*/
|
||||
public abstract buildDom(row: any): Element;
|
||||
|
||||
/**
|
||||
* Returns the DocData object to which this field belongs.
|
||||
*/
|
||||
protected _getDocData(): DocData {
|
||||
// TODO: There should be a better way to access docData and docComm, or better yet GristDoc.
|
||||
return this.field._table.tableData.docData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the docComm object for communicating with the server.
|
||||
*/
|
||||
protected _getDocComm(): DocComm { return this._getDocData().docComm; }
|
||||
}
|
||||
|
||||
export const cssHalfWidth = styled('div', `
|
||||
display: flex;
|
||||
flex: 1 1 50%;
|
||||
align-items: center;
|
||||
`);
|
||||
|
||||
export const cssInlineLabel = styled('span', `
|
||||
margin: 0 8px;
|
||||
color: ${colors.dark};
|
||||
`);
|
||||
86
app/client/widgets/NewBaseEditor.ts
Normal file
86
app/client/widgets/NewBaseEditor.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* NewBaseEditor is equivalent to BaseEditor for outside code, but is in typescript, and
|
||||
* so is friendlier and clearer to derive TypeScript classes from.
|
||||
*/
|
||||
import {GristDoc} from 'app/client/components/GristDoc';
|
||||
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
|
||||
import {CellValue} from "app/common/DocActions";
|
||||
import {Disposable, IDisposableOwner, Observable} from 'grainjs';
|
||||
|
||||
export interface Options {
|
||||
gristDoc: GristDoc;
|
||||
field: ViewFieldRec;
|
||||
cellValue: CellValue;
|
||||
formulaError?: Observable<CellValue>;
|
||||
editValue?: string;
|
||||
cursorPos: number;
|
||||
commands: {[cmd: string]: () => void};
|
||||
}
|
||||
|
||||
/**
|
||||
* Required parameters:
|
||||
* @param {RowModel} options.field: ViewSectionField (i.e. column) being edited.
|
||||
* @param {String} options.cellValue: The value in the underlying cell being edited.
|
||||
* @param {String} options.editValue: String to be edited, or undefined to use cellValue.
|
||||
* @param {Number} options.cursorPos: The initial position where to place the cursor.
|
||||
* @param {Element} option.cellRect: Bounding box of the element representing the cell that this
|
||||
* editor should match in size and position.
|
||||
* @param {Object} options.commands: Object mapping command names to functions, to enable as part
|
||||
* of the command group that should be activated while the editor exists.
|
||||
*/
|
||||
export abstract class NewBaseEditor extends Disposable {
|
||||
/**
|
||||
* Override the create() method to allow the parameters of create() expected by old-style
|
||||
* Editors and provided by FieldBuilder. TODO: remove this method once all editors have been
|
||||
* updated to new-style Disposables.
|
||||
*/
|
||||
public static create(owner: IDisposableOwner|null, options: Options): NewBaseEditor;
|
||||
public static create(options: Options): NewBaseEditor;
|
||||
public static create(ownerOrOptions: any, options?: any): NewBaseEditor {
|
||||
return options ?
|
||||
Disposable.create.call(this as any, ownerOrOptions, options) :
|
||||
Disposable.create.call(this as any, null, ownerOrOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the typed-in value should change the cell without opening the editor, and if so,
|
||||
* returns the value to save. E.g. on typing " ", CheckBoxEditor toggles value without opening.
|
||||
*/
|
||||
public static skipEditor(typedVal: string|undefined, origVal: CellValue): CellValue|undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
constructor(protected options: Options) {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* Called after the editor is instantiated to attach its DOM to the page.
|
||||
* - cellRect: Bounding box of the element representing the cell that this editor should match
|
||||
* in size and position. Used by derived classes, e.g. to construct an EditorPlacement object.
|
||||
*/
|
||||
public abstract attach(cellRect: ClientRect|DOMRect): void;
|
||||
|
||||
/**
|
||||
* Called to get the value to save back to the cell.
|
||||
*/
|
||||
public abstract getCellValue(): CellValue;
|
||||
|
||||
/**
|
||||
* Used if an editor needs preform any actions before a save
|
||||
*/
|
||||
public prepForSave(): void | Promise<void> {
|
||||
// No-op by default.
|
||||
}
|
||||
|
||||
/**
|
||||
* Called to get the text in the editor, used when switching between editing data and formula.
|
||||
*/
|
||||
public abstract getTextValue(): string;
|
||||
|
||||
/**
|
||||
* Called to get the position of the cursor in the editor. Used when switching between editing
|
||||
* data and formula.
|
||||
*/
|
||||
public abstract getCursorPos(): number;
|
||||
}
|
||||
200
app/client/widgets/NumericTextBox.ts
Normal file
200
app/client/widgets/NumericTextBox.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* See app/common/NumberFormat for description of options we support.
|
||||
*/
|
||||
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
|
||||
import {reportError} from 'app/client/models/errors';
|
||||
import {cssLabel, cssRow} from 'app/client/ui/RightPanel';
|
||||
import {ISelectorOption, makeButtonSelect} from 'app/client/ui2018/buttonSelect';
|
||||
import {colors, testId} from 'app/client/ui2018/cssVars';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {NTextBox} from 'app/client/widgets/NTextBox';
|
||||
import {clamp} from 'app/common/gutil';
|
||||
import {buildNumberFormat, NumberFormatOptions, NumMode, NumSign} from 'app/common/NumberFormat';
|
||||
import {Computed, dom, DomElementArg, fromKo, MultiHolder, Observable, styled} from 'grainjs';
|
||||
|
||||
|
||||
const modeOptions: Array<ISelectorOption<NumMode>> = [
|
||||
{value: 'currency', label: '$'},
|
||||
{value: 'decimal', label: ','},
|
||||
{value: 'percent', label: '%'},
|
||||
{value: 'scientific', label: 'Exp'}
|
||||
];
|
||||
|
||||
const signOptions: Array<ISelectorOption<NumSign>> = [
|
||||
{value: 'parens', label: '(-)'},
|
||||
];
|
||||
|
||||
/**
|
||||
* NumericTextBox - The most basic widget for displaying numeric information.
|
||||
*/
|
||||
export class NumericTextBox extends NTextBox {
|
||||
constructor(field: ViewFieldRec) {
|
||||
super(field);
|
||||
}
|
||||
|
||||
public buildConfigDom() {
|
||||
// Holder for all computeds created here. It gets disposed with the returned DOM element.
|
||||
const holder = new MultiHolder();
|
||||
|
||||
// Resolved options, to show default min/max decimals, which change depending on numMode.
|
||||
const resolved = Computed.create<Intl.ResolvedNumberFormatOptions>(holder, (use) =>
|
||||
buildNumberFormat({numMode: use(this.options).numMode}).resolvedOptions());
|
||||
|
||||
// Prepare various observables that reflect the options in the UI.
|
||||
const options = fromKo(this.options);
|
||||
const numMode = Computed.create(holder, options, (use, opts) => opts.numMode || null);
|
||||
const numSign = Computed.create(holder, options, (use, opts) => opts.numSign || null);
|
||||
const minDecimals = Computed.create(holder, options, (use, opts) => numberOrDefault(opts.decimals, ''));
|
||||
const maxDecimals = Computed.create(holder, options, (use, opts) => numberOrDefault(opts.maxDecimals, ''));
|
||||
const defaultMin = Computed.create(holder, resolved, (use, res) => res.minimumFractionDigits);
|
||||
const defaultMax = Computed.create(holder, resolved, (use, res) => res.maximumFractionDigits);
|
||||
|
||||
// Save a value as the given property in this.options() observable. Set it, save, and revert
|
||||
// on save error. This is similar to what modelUtil.setSaveValue() does.
|
||||
const setSave = (prop: keyof NumberFormatOptions, value: unknown) => {
|
||||
const orig = {...this.options.peek()};
|
||||
if (value !== orig[prop]) {
|
||||
this.options({...orig, [prop]: value, ...updateOptions(prop, value)});
|
||||
this.options.save().catch((err) => { reportError(err); this.options(orig); });
|
||||
}
|
||||
};
|
||||
|
||||
// Prepare setters for the UI elements.
|
||||
// Min/max fraction digits may range from 0 to 20; other values are invalid.
|
||||
const setMinDecimals = (val?: number) => setSave('decimals', val && clamp(val, 0, 20));
|
||||
const setMaxDecimals = (val?: number) => setSave('maxDecimals', val && clamp(val, 0, 20));
|
||||
// Mode and Sign behave as toggles: clicking a selected on deselects it.
|
||||
const setMode = (val: NumMode) => setSave('numMode', val !== numMode.get() ? val : undefined);
|
||||
const setSign = (val: NumSign) => setSave('numSign', val !== numSign.get() ? val : undefined);
|
||||
|
||||
return dom.update(super.buildConfigDom(),
|
||||
dom.autoDispose(holder),
|
||||
cssLabel('Number Format'),
|
||||
cssRow(
|
||||
makeButtonSelect(numMode, modeOptions, setMode, {}, cssModeSelect.cls(''), testId('numeric-mode')),
|
||||
makeButtonSelect(numSign, signOptions, setSign, {}, cssSignSelect.cls(''), testId('numeric-sign')),
|
||||
),
|
||||
cssLabel('Decimals'),
|
||||
cssRow(
|
||||
decimals('min', minDecimals, defaultMin, setMinDecimals, testId('numeric-min-decimals')),
|
||||
decimals('max', maxDecimals, defaultMax, setMaxDecimals, testId('numeric-max-decimals')),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function numberOrDefault<T>(value: unknown, def: T): number|T {
|
||||
return typeof value !== 'undefined' ? Number(value) : def;
|
||||
}
|
||||
|
||||
// Helper used by setSave() above to reset some properties when switching modes.
|
||||
function updateOptions(prop: keyof NumberFormatOptions, value: unknown): NumberFormatOptions {
|
||||
// Reset the numSign to default when toggling mode to percent or scientific.
|
||||
if (prop === 'numMode' && (!value || value === 'scientific' || value === 'percent')) {
|
||||
return {numSign: undefined};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function decimals(
|
||||
label: string,
|
||||
value: Observable<number|''>,
|
||||
defaultValue: Observable<number>,
|
||||
setFunc: (val?: number) => void, ...args: DomElementArg[]
|
||||
) {
|
||||
return cssDecimalsBox(
|
||||
cssNumLabel(label),
|
||||
cssNumInput({type: 'text', size: '2', min: '0'},
|
||||
dom.prop('value', value),
|
||||
dom.prop('placeholder', defaultValue),
|
||||
dom.on('change', (ev, elem) => {
|
||||
const newVal = parseInt(elem.value, 10);
|
||||
// Set value explicitly before its updated via setFunc; this way the value reflects the
|
||||
// observable in the case the observable is left unchanged (e.g. because of clamping).
|
||||
elem.value = String(value.get());
|
||||
setFunc(Number.isNaN(newVal) ? undefined : newVal);
|
||||
elem.blur();
|
||||
}),
|
||||
dom.on('focus', (ev, elem) => elem.select()),
|
||||
),
|
||||
cssSpinner(
|
||||
cssSpinnerBtn(cssSpinnerTop('DropdownUp'),
|
||||
dom.on('click', () => setFunc(numberOrDefault(value.get(), defaultValue.get()) + 1))),
|
||||
cssSpinnerBtn(cssSpinnerBottom('Dropdown'),
|
||||
dom.on('click', () => setFunc(numberOrDefault(value.get(), defaultValue.get()) - 1))),
|
||||
),
|
||||
...args
|
||||
);
|
||||
}
|
||||
|
||||
const cssDecimalsBox = styled('div', `
|
||||
position: relative;
|
||||
flex: auto;
|
||||
--icon-color: ${colors.slate};
|
||||
color: ${colors.slate};
|
||||
font-weight: normal;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
&:first-child {
|
||||
margin-right: 16px;
|
||||
}
|
||||
`);
|
||||
|
||||
const cssNumLabel = styled('div', `
|
||||
position: absolute;
|
||||
padding-left: 8px;
|
||||
pointer-events: none;
|
||||
`);
|
||||
|
||||
const cssNumInput = styled('input', `
|
||||
padding: 4px 32px 4px 40px;
|
||||
border: 1px solid ${colors.darkGrey};
|
||||
border-radius: 3px;
|
||||
color: ${colors.dark};
|
||||
width: 100%;
|
||||
text-align: right;
|
||||
appearance: none;
|
||||
-moz-appearance: none;
|
||||
-webkit-appearance: none;
|
||||
`);
|
||||
|
||||
const cssSpinner = styled('div', `
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
width: 16px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`);
|
||||
|
||||
const cssSpinnerBtn = styled('div', `
|
||||
flex: 1 1 0px;
|
||||
min-height: 0px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
&:hover {
|
||||
--icon-color: ${colors.dark};
|
||||
}
|
||||
`);
|
||||
|
||||
const cssSpinnerTop = styled(icon, `
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
`);
|
||||
|
||||
const cssSpinnerBottom = styled(icon, `
|
||||
position: absolute;
|
||||
bottom: 0px;
|
||||
`);
|
||||
|
||||
const cssModeSelect = styled(makeButtonSelect, `
|
||||
flex: 4 4 0px;
|
||||
background-color: white;
|
||||
`);
|
||||
|
||||
const cssSignSelect = styled(makeButtonSelect, `
|
||||
flex: 1 1 0px;
|
||||
background-color: white;
|
||||
margin-left: 16px;
|
||||
`);
|
||||
405
app/client/widgets/PreviewModal.ts
Normal file
405
app/client/widgets/PreviewModal.ts
Normal file
@@ -0,0 +1,405 @@
|
||||
// External dependencies
|
||||
import {computed, Computed, computedArray, Disposable} from 'grainjs';
|
||||
import {MutableObsArray, obsArray, ObsArray, observable, Observable} from 'grainjs';
|
||||
import {dom, input, LiveIndex, makeLiveIndex, noTestId, styled, TestId} from 'grainjs';
|
||||
import noop = require('lodash/noop');
|
||||
|
||||
// Grist client libs
|
||||
import {DocComm} from 'app/client/components/DocComm';
|
||||
import {dragOverClass} from 'app/client/lib/dom';
|
||||
import {selectFiles, uploadFiles} from 'app/client/lib/uploads';
|
||||
import {DocData} from 'app/client/models/DocData';
|
||||
import {TableData} from 'app/client/models/TableData';
|
||||
import {button1Small, button1SmallBright} from 'app/client/ui/buttons';
|
||||
import {cssModalBody, cssModalTitle, IModalControl, modal} from 'app/client/ui2018/modals';
|
||||
import {encodeQueryParams, mod} from 'app/common/gutil';
|
||||
import {UploadResult} from 'app/common/uploads';
|
||||
|
||||
|
||||
const modalPreview = styled('div', `
|
||||
position: relative;
|
||||
max-width: 80%;
|
||||
margin: 0 auto;
|
||||
background-color: white;
|
||||
border: 1px solid #bbb;
|
||||
`);
|
||||
|
||||
const modalPreviewImage = styled('img', `
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 400px;
|
||||
`);
|
||||
|
||||
const noPreview = styled('div', `
|
||||
margin: 40% 0;
|
||||
text-align: center;
|
||||
vertical-align: top;
|
||||
color: #bbb;
|
||||
`);
|
||||
|
||||
const thumbnail = styled('div', `
|
||||
position: relative;
|
||||
height: 100%;
|
||||
margin: 0 5px;
|
||||
cursor: pointer;
|
||||
`);
|
||||
|
||||
const thumbnailOuterRow = styled('div', `
|
||||
display: flex;
|
||||
position: relative;
|
||||
height: 60px;
|
||||
margin: 0 10px;
|
||||
justify-content: center;
|
||||
`);
|
||||
|
||||
const thumbnailInnerRow = styled('div', `
|
||||
display: inline-flex;
|
||||
height: 100%;
|
||||
padding: 10px 0;
|
||||
overflow-x: auto;
|
||||
`);
|
||||
|
||||
const addButton = styled('div.glyphicon.glyphicon-paperclip', `
|
||||
position: relative;
|
||||
flex: 0 0 40px;
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
margin: 9px 10px;
|
||||
padding: 12px 6px;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
border: 1px dashed #bbbbbb;
|
||||
cursor: pointer;
|
||||
font-size: 12pt;
|
||||
`);
|
||||
|
||||
const addButtonPlus = styled('div.glyphicon.glyphicon-plus', `
|
||||
position: absolute;
|
||||
left: 9px;
|
||||
top: 9px;
|
||||
font-size: 5pt;
|
||||
`);
|
||||
|
||||
const selectedOverlay = styled('div', `
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #3390bf80;
|
||||
`);
|
||||
|
||||
const noAttachments = styled('div', `
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
padding: 125px 0;
|
||||
text-align: center;
|
||||
border: 2px dashed #bbbbbb;
|
||||
font-size: 12pt;
|
||||
cursor: pointer;
|
||||
`);
|
||||
|
||||
const menuBtnStyle = `
|
||||
float: right;
|
||||
margin: 0 5px;
|
||||
cursor: pointer;
|
||||
color: black;
|
||||
text-decoration: none;
|
||||
`;
|
||||
|
||||
const navArrowStyle = `
|
||||
width: 10%;
|
||||
height: 60px;
|
||||
padding: 20px 0;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
font-size: 14pt;
|
||||
`;
|
||||
|
||||
interface Attachment {
|
||||
rowId: number;
|
||||
fileIdent: string;
|
||||
filename: Observable<string>;
|
||||
hasPreview: boolean;
|
||||
url: Observable<string>;
|
||||
}
|
||||
|
||||
// Used as a placeholder for the currently selected attachment if all attachments are removed.
|
||||
const nullAttachment: Attachment = {
|
||||
rowId: 0,
|
||||
fileIdent: '',
|
||||
filename: observable(''),
|
||||
hasPreview: false,
|
||||
url: observable('')
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* PreviewModal - Modal showing an attachment and options to rename, download or remove the
|
||||
* attachment from the cell.
|
||||
*/
|
||||
export class PreviewModal extends Disposable {
|
||||
private _attachmentsTable: TableData;
|
||||
private _docComm: DocComm;
|
||||
|
||||
private _isEditingName: Observable<boolean> = observable(false);
|
||||
private _newName: Observable<string> = observable('');
|
||||
private _isRenameValid: Computed<boolean>;
|
||||
|
||||
private _rowIds: MutableObsArray<number>;
|
||||
private _attachments: ObsArray<Attachment>;
|
||||
private _index: LiveIndex;
|
||||
private _selected: Computed<Attachment>;
|
||||
|
||||
constructor(
|
||||
docData: DocData,
|
||||
cellValue: number[],
|
||||
testId: TestId = noTestId,
|
||||
initRowId?: number,
|
||||
onClose: (cellValue: any) => void = noop,
|
||||
) {
|
||||
super();
|
||||
this._attachmentsTable = docData.getTable('_grist_Attachments')!;
|
||||
this._docComm = docData.docComm;
|
||||
|
||||
this._rowIds = obsArray(Array.isArray(cellValue) ? cellValue.slice(1) : []);
|
||||
this._attachments = computedArray(this._rowIds, (val: number): Attachment => {
|
||||
const fileIdent: string = this._attachmentsTable.getValue(val, 'fileIdent') as string;
|
||||
const filename: Observable<string> =
|
||||
observable(this._attachmentsTable.getValue(val, 'fileName') as string);
|
||||
return {
|
||||
rowId: val,
|
||||
fileIdent,
|
||||
filename,
|
||||
hasPreview: Boolean(this._attachmentsTable.getValue(val, 'imageHeight')),
|
||||
url: computed((use) => this._getUrl(fileIdent, use(filename)))
|
||||
};
|
||||
});
|
||||
this._index = makeLiveIndex(this, this._attachments,
|
||||
initRowId ? this._rowIds.get().indexOf(initRowId) : 0);
|
||||
this._selected = this.autoDispose(computed((use) => {
|
||||
const index = use(this._index);
|
||||
return index === null ? nullAttachment : use(this._attachments)[index];
|
||||
}));
|
||||
|
||||
this._isRenameValid = this.autoDispose(computed((use) =>
|
||||
Boolean(use(this._newName) && (use(this._newName) !== use(use(this._selected).filename)))
|
||||
));
|
||||
|
||||
modal((ctl, owner) => {
|
||||
owner.onDispose(() => { onClose(this.getCellValue()); });
|
||||
return [
|
||||
dom.style('padding', '16px'), // To match the previous style more closely.
|
||||
cssModalTitle(
|
||||
dom('span',
|
||||
dom.text((use) => use(use(this._selected).filename) || 'No attachments'),
|
||||
testId('modal-title'),
|
||||
),
|
||||
// This is a bootstrap-styled button, for now only to match previous style more closely.
|
||||
dom('button', dom.cls('close'), '×', testId('modal-close-x'),
|
||||
dom.on('click', () => ctl.close()),
|
||||
)
|
||||
),
|
||||
cssModalBody(this._buildDom(testId, ctl)),
|
||||
];
|
||||
});
|
||||
|
||||
this.autoDispose(this._selected.addListener(att => { this._scrollToThumbnail(att.rowId); }));
|
||||
|
||||
// Initialize with the selected attachment's thumbnail in view.
|
||||
if (initRowId) {
|
||||
this._scrollToThumbnail(initRowId);
|
||||
}
|
||||
}
|
||||
|
||||
public getCellValue() {
|
||||
const rowIds = this._rowIds.get() as any[];
|
||||
return rowIds.length > 0 ? ["L"].concat(this._rowIds.get() as any[]) : '';
|
||||
}
|
||||
|
||||
// Builds the attachment preview modal.
|
||||
private _buildDom(testId: TestId, ctl: IModalControl): Element {
|
||||
return dom('div',
|
||||
// Prevent focus from switching away from the modal on mousedown.
|
||||
dom.on('mousedown', (e) => e.preventDefault()),
|
||||
dom.domComputed((use) =>
|
||||
use(this._rowIds).length > 0 ? this._buildPreviewNav(testId, ctl) : this._buildEmptyMenu(testId)),
|
||||
// Drag-over logic
|
||||
dragOverClass('attachment_drag_over'),
|
||||
dom.on('drop', ev => this._upload(ev.dataTransfer!.files)),
|
||||
testId('modal')
|
||||
);
|
||||
}
|
||||
|
||||
private _buildPreviewNav(testId: TestId, ctl: IModalControl) {
|
||||
const modalPrev = (selected: Attachment) => modalPreviewImage(
|
||||
dom.attr('src', (use) => use(selected.url)),
|
||||
testId('image')
|
||||
);
|
||||
const noPrev = noPreview({style: 'padding: 60px;'},
|
||||
dom('div.glyphicon.glyphicon-file'),
|
||||
dom('div', 'Preview not available')
|
||||
);
|
||||
return [
|
||||
// Preview and left/right nav arrows
|
||||
dom('div', {style: 'display: flex; align-items: center; height: 400px;'},
|
||||
dom('div.glyphicon.glyphicon-chevron-left', {style: navArrowStyle},
|
||||
dom.on('click', () => this._moveIndex(-1)),
|
||||
testId('left')
|
||||
),
|
||||
modalPreview(
|
||||
dom.maybe(this._selected, (selected) =>
|
||||
dom.domComputed((use) => selected.hasPreview ? modalPrev(selected) : noPrev)
|
||||
),
|
||||
dom.attr('title', (use) => use(use(this._selected).filename)),
|
||||
),
|
||||
dom('div.glyphicon.glyphicon-chevron-right', {style: navArrowStyle},
|
||||
dom.on('click', () => this._moveIndex(1)),
|
||||
testId('right')
|
||||
)
|
||||
),
|
||||
// Nav thumbnails
|
||||
thumbnailOuterRow(
|
||||
thumbnailInnerRow(
|
||||
dom.forEach(this._attachments, (a: Attachment) => this._buildThumbnail(a)),
|
||||
testId('thumbnails')
|
||||
),
|
||||
addButton(
|
||||
addButtonPlus(),
|
||||
dom.on('click', () => { this._select(); }), // tslint:disable-line:no-floating-promises TODO
|
||||
testId('add')
|
||||
)
|
||||
),
|
||||
// Menu buttons
|
||||
dom('div', {style: 'height: 10px; margin: 10px;'},
|
||||
dom('div.glyphicon.glyphicon-trash', {style: menuBtnStyle},
|
||||
dom.on('click', () => this._remove()),
|
||||
testId('remove')
|
||||
),
|
||||
dom('a.glyphicon.glyphicon-download-alt', {style: menuBtnStyle},
|
||||
dom.attr('href', (use) => use(use(this._selected).url)),
|
||||
dom.attr('target', '_blank'),
|
||||
dom.attr('download', (use) => use(use(this._selected).filename)),
|
||||
testId('download')
|
||||
),
|
||||
dom('div.glyphicon.glyphicon-pencil', {style: menuBtnStyle},
|
||||
dom.on('click', () => this._isEditingName.set(!this._isEditingName.get())),
|
||||
testId('rename')
|
||||
)
|
||||
),
|
||||
// Rename menu
|
||||
dom.maybe(this._isEditingName, () => {
|
||||
this._newName.set(this._selected.get().filename.get());
|
||||
return dom('div', {style: 'display: flex; margin: 20px 0 0 0;'},
|
||||
input(this._newName, {onInput: true},
|
||||
{style: 'flex: 4 1 0; margin: 0 5px;', placeholder: 'Rename file...'},
|
||||
// Allow the input element to gain focus.
|
||||
dom.on('mousedown', (e) => e.stopPropagation()),
|
||||
dom.onKeyPress({Enter: () => this._renameFile()}),
|
||||
// Prevent the dialog from losing focus and disposing on input completion.
|
||||
dom.on('blur', (ev) => ctl.focus()),
|
||||
dom.onDispose(() => ctl.focus()),
|
||||
testId('rename-input')
|
||||
),
|
||||
button1Small('Cancel', {style: 'flex: 1 1 0; margin: 0 5px;'},
|
||||
dom.on('click', () => this._isEditingName.set(false)),
|
||||
testId('rename-cancel')
|
||||
),
|
||||
button1SmallBright('Save', {style: 'flex: 1 1 0; margin: 0 5px;'},
|
||||
dom.on('click', () => this._renameFile()),
|
||||
dom.boolAttr('disabled', (use) => !use(this._isRenameValid)),
|
||||
testId('rename-save')
|
||||
)
|
||||
);
|
||||
})
|
||||
];
|
||||
}
|
||||
|
||||
private _buildEmptyMenu(testId: TestId): Element {
|
||||
return noAttachments('Click or drag to add attachments',
|
||||
addButton({style: 'border: none; margin: 2px;'},
|
||||
addButtonPlus(),
|
||||
),
|
||||
dom.on('click', () => { this._select(); }), // tslint:disable-line:no-floating-promises TODO
|
||||
testId('empty-add')
|
||||
);
|
||||
}
|
||||
|
||||
private _buildThumbnail(att: Attachment): Element {
|
||||
const isSelected: Computed<boolean> = computed((use) =>
|
||||
att.rowId === use(this._selected).rowId);
|
||||
return thumbnail({id: `thumbnail-${att.rowId}`, style: att.hasPreview ? '' : 'width: 30px;'},
|
||||
dom.autoDispose(isSelected),
|
||||
// TODO: Update to legitimately determine whether a file preview exists.
|
||||
att.hasPreview ? dom('img', {style: 'height: 100%; vertical-align: top;'},
|
||||
dom.attr('src', (use) => this._getUrl(att.fileIdent, use(att.filename)))
|
||||
) : noPreview({style: 'width: 30px;'},
|
||||
dom('div.glyphicon.glyphicon-file')
|
||||
),
|
||||
dom.maybe(isSelected, () => selectedOverlay()),
|
||||
// Add a filename tooltip to the thumbnails.
|
||||
dom.attr('title', (use) => use(att.filename)),
|
||||
dom.style('border', (use) => use(isSelected) ? '1px solid #317193' : '1px solid #bbb'),
|
||||
dom.on('click', () => {
|
||||
this._index.set(this._attachments.get().findIndex(a => a.rowId === att.rowId));
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private _getUrl(fileIdent: string, filename: string): string {
|
||||
return this._docComm.docUrl('attachment') + '?' + encodeQueryParams({
|
||||
...this._docComm.getUrlParams(),
|
||||
ident: fileIdent,
|
||||
name: filename
|
||||
});
|
||||
}
|
||||
|
||||
private _renameFile(): void {
|
||||
const val = this._newName.get();
|
||||
if (this._isRenameValid.get()) {
|
||||
this._selected.get().filename.set(val);
|
||||
const rowId = this._selected.get().rowId;
|
||||
this._attachmentsTable.sendTableAction(['UpdateRecord', rowId, {fileName: val}]);
|
||||
this._isEditingName.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
private _moveIndex(dir: -1|1): void {
|
||||
const len = this._attachments.get().length;
|
||||
this._index.set(mod(this._index.get()! + dir, len));
|
||||
}
|
||||
|
||||
private _scrollToThumbnail(rowId: number): void {
|
||||
const tn = document.getElementById(`thumbnail-${rowId}`);
|
||||
if (tn) {
|
||||
tn.scrollIntoView();
|
||||
}
|
||||
}
|
||||
|
||||
// Removes the attachment being previewed from the cell (but not the document).
|
||||
private _remove(): void {
|
||||
this._rowIds.splice(this._index.get()!, 1);
|
||||
}
|
||||
|
||||
private async _select(): Promise<void> {
|
||||
const uploadResult = await selectFiles({docWorkerUrl: this._docComm.docWorkerUrl,
|
||||
multiple: true, sizeLimit: 'attachment'});
|
||||
return this._add(uploadResult);
|
||||
}
|
||||
|
||||
private async _upload(files: FileList): Promise<void> {
|
||||
const uploadResult = await uploadFiles(Array.from(files),
|
||||
{docWorkerUrl: this._docComm.docWorkerUrl,
|
||||
sizeLimit: 'attachment'});
|
||||
return this._add(uploadResult);
|
||||
}
|
||||
|
||||
private async _add(uploadResult: UploadResult|null): Promise<void> {
|
||||
if (!uploadResult) { return; }
|
||||
const rowIds = await this._docComm.addAttachments(uploadResult.uploadId);
|
||||
const len = this._rowIds.get().length;
|
||||
if (rowIds.length > 0) {
|
||||
this._rowIds.push(...rowIds);
|
||||
this._index.set(len);
|
||||
}
|
||||
}
|
||||
}
|
||||
12
app/client/widgets/PreviewsWidget.css
Normal file
12
app/client/widgets/PreviewsWidget.css
Normal file
@@ -0,0 +1,12 @@
|
||||
.attachment_hover_icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.attachment_widget:hover .attachment_hover_icon {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.attachment_drag_over {
|
||||
outline: 2px dashed #ff9a00;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
247
app/client/widgets/PreviewsWidget.ts
Normal file
247
app/client/widgets/PreviewsWidget.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import {Computed, dom, fromKo, input, makeTestId, onElem, styled, TestId} from 'grainjs';
|
||||
|
||||
import {dragOverClass} from 'app/client/lib/dom';
|
||||
import {selectFiles, uploadFiles} from 'app/client/lib/uploads';
|
||||
import {cssRow} from 'app/client/ui/RightPanel';
|
||||
import {colors} from 'app/client/ui2018/cssVars';
|
||||
import {NewAbstractWidget} from 'app/client/widgets/NewAbstractWidget';
|
||||
import {NewBaseEditor} from 'app/client/widgets/NewBaseEditor';
|
||||
import {PreviewModal} from 'app/client/widgets/PreviewModal';
|
||||
import {encodeQueryParams} from 'app/common/gutil';
|
||||
import {TableData} from 'app/common/TableData';
|
||||
import {UploadResult} from 'app/common/uploads';
|
||||
|
||||
const testId: TestId = makeTestId('test-pw-');
|
||||
|
||||
const attachmentWidget = styled('div.attachment_widget.field_clip', `
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
white-space: pre-wrap;
|
||||
`);
|
||||
|
||||
const attachmentIcon = styled('div.attachment_icon.glyphicon.glyphicon-paperclip', `
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
padding: 2px;
|
||||
background-color: #D0D0D0;
|
||||
color: white;
|
||||
border-radius: 2px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 0 0 1px white;
|
||||
z-index: 1;
|
||||
|
||||
&:hover {
|
||||
background-color: #3290BF;
|
||||
}
|
||||
`);
|
||||
|
||||
const attachmentPreview = styled('div', `
|
||||
color: black;
|
||||
background-color: white;
|
||||
border: 1px solid #bbb;
|
||||
margin: 0 2px 2px 0;
|
||||
position: relative;
|
||||
vertical-align: top;
|
||||
z-index: 0;
|
||||
|
||||
&:hover > .show_preview {
|
||||
display: block;
|
||||
}
|
||||
`);
|
||||
|
||||
const showPreview = styled('div.glyphicon.glyphicon-eye-open.show_preview', `
|
||||
position: absolute;
|
||||
display: none;
|
||||
right: -2px;
|
||||
top: -2px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 2px;
|
||||
background-color: #888;
|
||||
color: white;
|
||||
font-size: 6pt;
|
||||
padding: 2px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
display: block;
|
||||
}
|
||||
`);
|
||||
|
||||
const noPreviewIcon = styled('div.glyphicon.glyphicon-file', `
|
||||
min-width: 100%;
|
||||
color: #bbb;
|
||||
text-align: center;
|
||||
margin: calc(50% * 1.33 - 6px) 0;
|
||||
font-size: 6pt;
|
||||
vertical-align: top;
|
||||
`);
|
||||
|
||||
const sizeLabel = styled('div', `
|
||||
color: ${colors.slate};
|
||||
margin-right: 9px;
|
||||
`);
|
||||
|
||||
export interface SavingObservable<T> extends ko.Observable<T> {
|
||||
setAndSave(value: T): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* PreviewsWidget - A widget for displaying attachments as image previews.
|
||||
*/
|
||||
export class PreviewsWidget extends NewAbstractWidget {
|
||||
|
||||
private _attachmentsTable: TableData;
|
||||
private _height: SavingObservable<string>;
|
||||
|
||||
constructor(field: any) {
|
||||
super(field);
|
||||
|
||||
// TODO: the Attachments table currently treated as metadata, and loaded on open,
|
||||
// but should probably be loaded on demand as it contains user data, which may be large.
|
||||
this._attachmentsTable = this._getDocData().getTable('_grist_Attachments')!;
|
||||
|
||||
this._height = this.options.prop('height') as SavingObservable<string>;
|
||||
|
||||
this.autoDispose(this._height.subscribe(() => {
|
||||
this.field.viewSection().events.trigger('rowHeightChange');
|
||||
}));
|
||||
}
|
||||
|
||||
public buildDom(_row: any): Element {
|
||||
// NOTE: A cellValue of the correct type includes the list encoding designator 'L' as the
|
||||
// first element.
|
||||
const cellValue: SavingObservable<number[]> = _row[this.field.colId()];
|
||||
const values = Computed.create(null, fromKo(cellValue), (use, _cellValue) =>
|
||||
Array.isArray(_cellValue) ? _cellValue.slice(1) : []);
|
||||
|
||||
return attachmentWidget(
|
||||
dom.autoDispose(values),
|
||||
|
||||
dragOverClass('attachment_drag_over'),
|
||||
attachmentIcon(
|
||||
dom.cls('attachment_hover_icon', (use) => use(values).length > 0),
|
||||
dom.on('click', () => this._selectAndSave(cellValue))
|
||||
),
|
||||
dom.forEach(values, (value: number) =>
|
||||
isNaN(value) ? null : this._buildAttachment(value, cellValue)
|
||||
),
|
||||
dom.on('drop', ev => this._uploadAndSave(cellValue, ev.dataTransfer!.files))
|
||||
);
|
||||
}
|
||||
|
||||
public buildConfigDom(): Element {
|
||||
const inputRange = input(fromKo(this._height), {onInput: true}, {
|
||||
style: 'margin: 0 5px;',
|
||||
type: 'range',
|
||||
min: '16',
|
||||
max: '96',
|
||||
value: '36'
|
||||
}, testId('thumbnail-size'));
|
||||
// Save the height on change event (when the user releases the drag button)
|
||||
onElem(inputRange, 'change', (ev: any) => { this._height.setAndSave(ev.target.value); });
|
||||
return cssRow(
|
||||
sizeLabel('Size'),
|
||||
inputRange
|
||||
);
|
||||
}
|
||||
|
||||
protected _buildAttachment(value: number, cellValue: SavingObservable<number[]>): Element {
|
||||
const filename: string = this._attachmentsTable.getValue(value, 'fileName') as string;
|
||||
const height: number = this._attachmentsTable.getValue(value, 'imageHeight') as number;
|
||||
const width: number = this._attachmentsTable.getValue(value, 'imageWidth') as number;
|
||||
const hasPreview: boolean = Boolean(height);
|
||||
const ratio: number = hasPreview ? (width / height) : .75;
|
||||
|
||||
return attachmentPreview({title: filename}, // Add a filename tooltip to the previews.
|
||||
dom.style('height', (use) => `${use(this._height)}px`),
|
||||
dom.style('width', (use) => `${parseInt(use(this._height), 10) * ratio}px`),
|
||||
// TODO: Update to legitimately determine whether a file preview exists.
|
||||
hasPreview ? dom('img', {style: 'height: 100%; min-width: 100%; vertical-align: top;'},
|
||||
dom.attr('src', this._getUrl(value))
|
||||
) : noPreviewIcon(),
|
||||
dom.cls('no_preview', () => !hasPreview),
|
||||
showPreview(
|
||||
dom.on('click', () => this._showPreview(value, cellValue)),
|
||||
testId(`preview-${value}`)
|
||||
),
|
||||
testId(String(value))
|
||||
);
|
||||
}
|
||||
|
||||
// Returns the attachment download url.
|
||||
private _getUrl(rowId: number): string {
|
||||
const ident = this._attachmentsTable.getValue(rowId, 'fileIdent') as string;
|
||||
if (!ident) {
|
||||
return '';
|
||||
} else {
|
||||
const docComm = this._getDocComm();
|
||||
return docComm.docUrl('attachment') + '?' + encodeQueryParams({
|
||||
...docComm.getUrlParams(),
|
||||
ident,
|
||||
name: this._attachmentsTable.getValue(rowId, 'fileName') as string
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async _selectAndSave(value: SavingObservable<number[]>): Promise<void> {
|
||||
const uploadResult = await selectFiles({docWorkerUrl: this._getDocComm().docWorkerUrl,
|
||||
multiple: true, sizeLimit: 'attachment'});
|
||||
return this._save(value, uploadResult);
|
||||
}
|
||||
|
||||
private async _uploadAndSave(value: SavingObservable<number[]>, files: FileList): Promise<void> {
|
||||
const uploadResult = await uploadFiles(Array.from(files),
|
||||
{docWorkerUrl: this._getDocComm().docWorkerUrl,
|
||||
sizeLimit: 'attachment'});
|
||||
return this._save(value, uploadResult);
|
||||
}
|
||||
|
||||
private async _save(value: SavingObservable<number[]>, uploadResult: UploadResult|null): Promise<void> {
|
||||
if (!uploadResult) { return; }
|
||||
const rowIds = await this._getDocComm().addAttachments(uploadResult.uploadId);
|
||||
// Values should be saved with a leading "L" to fit Grist's list value encoding.
|
||||
const formatted: any[] = value() ? value() : ["L"];
|
||||
value.setAndSave(formatted.concat(rowIds));
|
||||
// Trigger a row height change in case the added attachment wraps to the next line.
|
||||
this.field.viewSection().events.trigger('rowHeightChange');
|
||||
}
|
||||
|
||||
// Show a preview for the attachment with the given rowId value in the attachments table.
|
||||
private _showPreview(value: number, cellValue: SavingObservable<number[]>): void {
|
||||
// Modal should be disposed on close.
|
||||
const onClose = (_cellValue: number[]) => {
|
||||
cellValue.setAndSave(_cellValue);
|
||||
modal.dispose();
|
||||
};
|
||||
const modal = PreviewModal.create(this, this._getDocData(), cellValue.peek(), testId, value,
|
||||
onClose);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A PreviewsEditor is just the PreviewModal, which allows adding and removing attachments.
|
||||
*/
|
||||
export class PreviewsEditor extends NewBaseEditor {
|
||||
private _modal: any;
|
||||
|
||||
public attach(cellRect: ClientRect|DOMRect) {
|
||||
const docData = this.options.gristDoc.docData;
|
||||
// Disposal on close is handled by the FieldBuilder editor logic.
|
||||
this._modal = PreviewModal.create(this, docData, this.options.cellValue as number[], testId);
|
||||
}
|
||||
|
||||
public getCellValue(): any {
|
||||
return this._modal.getCellValue();
|
||||
}
|
||||
|
||||
public getCursorPos(): number {
|
||||
return 0;
|
||||
}
|
||||
|
||||
public getTextValue(): string {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
4
app/client/widgets/Reference.css
Normal file
4
app/client/widgets/Reference.css
Normal file
@@ -0,0 +1,4 @@
|
||||
.cell_icon {
|
||||
margin-right: 3px;
|
||||
color: #808080;
|
||||
}
|
||||
112
app/client/widgets/Reference.js
Normal file
112
app/client/widgets/Reference.js
Normal file
@@ -0,0 +1,112 @@
|
||||
var _ = require('underscore');
|
||||
var ko = require('knockout');
|
||||
|
||||
var gutil = require('app/common/gutil');
|
||||
|
||||
var dispose = require('../lib/dispose');
|
||||
var dom = require('../lib/dom');
|
||||
var kd = require('../lib/koDom');
|
||||
|
||||
var TextBox = require('./TextBox');
|
||||
|
||||
const {colors, testId} = require('app/client/ui2018/cssVars');
|
||||
const {icon} = require('app/client/ui2018/icons');
|
||||
const {select} = require('app/client/ui2018/menus');
|
||||
const {cssLabel, cssRow} = require('app/client/ui/RightPanel');
|
||||
const {isVersions} = require('app/common/gristTypes');
|
||||
const {Computed, styled} = require('grainjs');
|
||||
|
||||
/**
|
||||
* Reference - The widget for displaying references to another table's records.
|
||||
*/
|
||||
function Reference(field) {
|
||||
TextBox.call(this, field);
|
||||
var col = field.column();
|
||||
this.field = field;
|
||||
this.colId = col.colId;
|
||||
this.docModel = this.field._table.docModel;
|
||||
|
||||
this._refValueFormatter = this.autoDispose(ko.computed(() =>
|
||||
field.createVisibleColFormatter()));
|
||||
|
||||
this._visibleColRef = Computed.create(this, (use) => use(this.field.visibleColRef));
|
||||
// Note that saveOnly is used here to prevent display value flickering on visible col change.
|
||||
this._visibleColRef.onWrite((val) => this.field.visibleColRef.saveOnly(val));
|
||||
|
||||
this._validCols = Computed.create(this, (use) => {
|
||||
const refTable = use(use(this.field.column).refTable);
|
||||
if (!refTable) { return []; }
|
||||
return use(use(refTable.columns))
|
||||
.filter(col => !use(col.isHiddenCol))
|
||||
.map(col => ({
|
||||
label: use(col.label),
|
||||
value: col.getRowId(),
|
||||
icon: 'FieldColumn',
|
||||
disabled: gutil.startsWith(use(col.type), 'Ref:') || use(col.isTransforming)
|
||||
}))
|
||||
.concat([{label: 'Row ID', value: 0, icon: 'FieldColumn'}]);
|
||||
});
|
||||
|
||||
// Computed returns a function that formats cell values.
|
||||
this._formatValue = this.autoDispose(ko.computed(() => {
|
||||
// If the field is pulling values from a display column, use a general-purpose formatter.
|
||||
if (this.field.displayColRef() !== this.field.colRef()) {
|
||||
const fmt = this._refValueFormatter();
|
||||
return (val) => fmt.formatAny(val);
|
||||
} else {
|
||||
const refTable = this.field.column().refTable();
|
||||
const refTableId = refTable ? refTable.tableId() : "";
|
||||
return (val) => val > 0 ? `${refTableId}[${val}]` : "";
|
||||
}
|
||||
}));
|
||||
}
|
||||
dispose.makeDisposable(Reference);
|
||||
_.extend(Reference.prototype, TextBox.prototype);
|
||||
|
||||
Reference.prototype.buildConfigDom = function() {
|
||||
return [
|
||||
cssLabel('SHOW COLUMN'),
|
||||
cssRow(
|
||||
select(this._visibleColRef, this._validCols),
|
||||
testId('fbuilder-ref-col-select')
|
||||
)
|
||||
];
|
||||
};
|
||||
|
||||
Reference.prototype.buildTransformConfigDom = function() {
|
||||
return this.buildConfigDom();
|
||||
};
|
||||
|
||||
Reference.prototype.buildDom = function(row, options) {
|
||||
const formattedValue = ko.computed(() => {
|
||||
if (row._isAddRow() || this.isDisposed() || this.field.displayColModel().isDisposed()) {
|
||||
// Work around JS errors during certain changes (noticed when visibleCol field gets removed
|
||||
// for a column using per-field settings).
|
||||
return "";
|
||||
}
|
||||
const value = row.cells[this.field.displayColModel().colId()];
|
||||
if (!value) { return ""; }
|
||||
const content = value();
|
||||
if (isVersions(content)) {
|
||||
// We can arrive here if the reference value is unchanged (viewed as a foreign key)
|
||||
// but the content of its displayCol has changed. Postponing doing anything about
|
||||
// this until we have three-way information for computed columns. For now,
|
||||
// just showing one version of the cell. TODO: elaborate.
|
||||
return this._formatValue()(content[1].local || content[1].parent);
|
||||
}
|
||||
return this._formatValue()(content);
|
||||
});
|
||||
return dom('div.field_clip',
|
||||
dom.autoDispose(formattedValue),
|
||||
cssRefIcon('FieldReference',
|
||||
testId('ref-link-icon')
|
||||
),
|
||||
kd.text(formattedValue));
|
||||
};
|
||||
|
||||
const cssRefIcon = styled(icon, `
|
||||
background-color: ${colors.slate};
|
||||
margin: -1px 2px 2px 0;
|
||||
`);
|
||||
|
||||
module.exports = Reference;
|
||||
247
app/client/widgets/ReferenceEditor.ts
Normal file
247
app/client/widgets/ReferenceEditor.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import {ACResults, buildHighlightedDom, HighlightFunc} from 'app/client/lib/ACIndex';
|
||||
import {Autocomplete} from 'app/client/lib/autocomplete';
|
||||
import {ICellItem} from 'app/client/models/ColumnACIndexes';
|
||||
import {reportError} from 'app/client/models/errors';
|
||||
import {TableData} from 'app/client/models/TableData';
|
||||
import {colors, testId, vars} from 'app/client/ui2018/cssVars';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {menuCssClass} from 'app/client/ui2018/menus';
|
||||
import {Options} from 'app/client/widgets/NewBaseEditor';
|
||||
import {NTextEditor} from 'app/client/widgets/NTextEditor';
|
||||
import {CellValue} from 'app/common/DocActions';
|
||||
import {removePrefix, undefDefault} from 'app/common/gutil';
|
||||
import {BaseFormatter} from 'app/common/ValueFormatter';
|
||||
import {styled} from 'grainjs';
|
||||
|
||||
|
||||
/**
|
||||
* A ReferenceEditor offers an autocomplete of choices from the referenced table.
|
||||
*/
|
||||
export class ReferenceEditor extends NTextEditor {
|
||||
private _tableData: TableData;
|
||||
private _formatter: BaseFormatter;
|
||||
private _enableAddNew: boolean;
|
||||
private _showAddNew: boolean = false;
|
||||
private _visibleCol: string;
|
||||
private _autocomplete?: Autocomplete<ICellItem>;
|
||||
|
||||
constructor(options: Options) {
|
||||
super(options);
|
||||
|
||||
const field = options.field;
|
||||
|
||||
// Get the table ID to which the reference points.
|
||||
const refTableId = removePrefix(field.column().type(), "Ref:");
|
||||
if (!refTableId) {
|
||||
throw new Error("ReferenceEditor used for non-Reference column");
|
||||
}
|
||||
|
||||
const docData = options.gristDoc.docData;
|
||||
const tableData = docData.getTable(refTableId);
|
||||
if (!tableData) {
|
||||
throw new Error("ReferenceEditor: invalid referenced table");
|
||||
}
|
||||
this._tableData = tableData;
|
||||
|
||||
// Construct the formatter for the displayed values using the options from the target column.
|
||||
this._formatter = field.createVisibleColFormatter();
|
||||
|
||||
// Whether we should enable the "Add New" entry to allow adding new items to the target table.
|
||||
const vcol = field.visibleColModel();
|
||||
this._enableAddNew = vcol && !vcol.isRealFormula();
|
||||
|
||||
this._visibleCol = vcol.colId() || 'id';
|
||||
|
||||
// Decorate the editor to look like a reference column value (with a "link" icon).
|
||||
this.cellEditorDiv.classList.add(cssRefEditor.className);
|
||||
this.cellEditorDiv.appendChild(cssRefEditIcon('FieldReference'));
|
||||
this.textInput.value = undefDefault(options.editValue, this._idToText(options.cellValue));
|
||||
|
||||
const needReload = (options.editValue === undefined && !tableData.isLoaded);
|
||||
|
||||
// The referenced table has probably already been fetched (because there must already be a
|
||||
// Reference widget instantiated), but it's better to avoid this assumption.
|
||||
docData.fetchTable(refTableId).then(() => {
|
||||
if (this.isDisposed()) { return; }
|
||||
if (needReload && this.textInput.value === '') {
|
||||
this.textInput.value = undefDefault(options.editValue, this._idToText(options.cellValue));
|
||||
this.resizeInput();
|
||||
}
|
||||
if (this._autocomplete) {
|
||||
if (options.editValue === undefined) {
|
||||
this._autocomplete.search((items) => items.findIndex((item) => item.rowId === options.cellValue));
|
||||
} else {
|
||||
this._autocomplete.search();
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(reportError);
|
||||
}
|
||||
|
||||
public attach(cellRect: ClientRect|DOMRect): void {
|
||||
super.attach(cellRect);
|
||||
this._autocomplete = this.autoDispose(new Autocomplete<ICellItem>(this.textInput, {
|
||||
menuCssClass: menuCssClass + ' ' + cssRefList.className,
|
||||
search: this._doSearch.bind(this),
|
||||
renderItem: this._renderItem.bind(this),
|
||||
getItemText: (item) => item.text,
|
||||
onClick: () => this.options.commands.fieldEditSaveHere(),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* If the 'new' item is saved, add it to the referenced table first. See _buildSourceList
|
||||
*/
|
||||
public async prepForSave() {
|
||||
const selectedItem = this._autocomplete && this._autocomplete.getSelectedItem();
|
||||
if (selectedItem &&
|
||||
selectedItem.rowId === 'new' &&
|
||||
selectedItem.text === this.textInput.value) {
|
||||
const colInfo = {[this._visibleCol]: this.textInput.value};
|
||||
selectedItem.rowId = await this._tableData.sendTableAction(["AddRecord", null, colInfo]);
|
||||
}
|
||||
}
|
||||
|
||||
public getCellValue() {
|
||||
const selectedItem = this._autocomplete && this._autocomplete.getSelectedItem();
|
||||
|
||||
if (selectedItem) {
|
||||
// Selected from the autocomplete dropdown; so we know the *value* (i.e. rowId).
|
||||
return selectedItem.rowId;
|
||||
} else if (nocaseEqual(this.textInput.value, this._idToText(this.options.cellValue))) {
|
||||
// Unchanged from what's already in the cell.
|
||||
return this.options.cellValue;
|
||||
}
|
||||
|
||||
// Search for textInput's value, or else use the typed value itself (as alttext).
|
||||
if (this.textInput.value === '') {
|
||||
return 0; // This is the default value for a reference column.
|
||||
}
|
||||
const searchFunc = (value: any) => nocaseEqual(value, this.textInput.value);
|
||||
const matches = this._tableData.columnSearch(this._visibleCol, this._formatter, searchFunc, 1);
|
||||
if (matches.length > 0) {
|
||||
return matches[0].value;
|
||||
} else {
|
||||
return this.textInput.value;
|
||||
}
|
||||
}
|
||||
|
||||
private _idToText(value: CellValue) {
|
||||
if (typeof value === 'number') {
|
||||
return this._formatter.formatAny(this._tableData.getValue(value, this._visibleCol));
|
||||
}
|
||||
return String(value || '');
|
||||
}
|
||||
|
||||
private async _doSearch(text: string): Promise<ACResults<ICellItem>> {
|
||||
const acIndex = this._tableData.columnACIndexes.getColACIndex(this._visibleCol, this._formatter);
|
||||
const result = acIndex.search(text);
|
||||
// If the search text does not match anything exactly, add 'new' item for it. See also prepForSave.
|
||||
this._showAddNew = false;
|
||||
if (this._enableAddNew && text) {
|
||||
const cleanText = text.trim().toLowerCase();
|
||||
if (!result.items.find((item) => item.cleanText === cleanText)) {
|
||||
result.items.push({rowId: 'new', text, cleanText});
|
||||
this._showAddNew = true;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private _renderItem(item: ICellItem, highlightFunc: HighlightFunc) {
|
||||
if (item.rowId === 'new') {
|
||||
return cssRefItem(cssRefItem.cls('-new'),
|
||||
cssPlusButton(cssPlusIcon('Plus')), item.text,
|
||||
testId('ref-editor-item'), testId('ref-editor-new-item'),
|
||||
);
|
||||
}
|
||||
return cssRefItem(cssRefItem.cls('-with-new', this._showAddNew),
|
||||
buildHighlightedDom(item.text, highlightFunc, cssMatchText),
|
||||
testId('ref-editor-item'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function nocaseEqual(a: string, b: string) {
|
||||
return a.trim().toLowerCase() === b.trim().toLowerCase();
|
||||
}
|
||||
|
||||
const cssRefEditor = styled('div', `
|
||||
& > .celleditor_text_editor, & > .celleditor_content_measure {
|
||||
padding-left: 21px;
|
||||
}
|
||||
`);
|
||||
|
||||
const cssRefList = styled('div', `
|
||||
overflow-y: auto;
|
||||
padding: 8px 0 0 0;
|
||||
--weaseljs-menu-item-padding: 8px 16px;
|
||||
`);
|
||||
|
||||
// We need to now the height of the sticky "+" element.
|
||||
const addNewHeight = '37px';
|
||||
|
||||
const cssRefItem = styled('li', `
|
||||
display: block;
|
||||
font-family: ${vars.fontFamily};
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
outline: none;
|
||||
padding: var(--weaseljs-menu-item-padding, 8px 24px);
|
||||
cursor: pointer;
|
||||
|
||||
&.selected {
|
||||
background-color: var(--weaseljs-selected-background-color, #5AC09C);
|
||||
color: var(--weaseljs-selected-color, white);
|
||||
}
|
||||
&-with-new {
|
||||
scroll-margin-bottom: ${addNewHeight};
|
||||
}
|
||||
&-new {
|
||||
color: ${colors.slate};
|
||||
position: sticky;
|
||||
bottom: 0px;
|
||||
height: ${addNewHeight};
|
||||
background-color: white;
|
||||
border-top: 1px solid ${colors.mediumGrey};
|
||||
scroll-margin-bottom: initial;
|
||||
}
|
||||
&-new.selected {
|
||||
color: ${colors.lightGrey};
|
||||
}
|
||||
`);
|
||||
|
||||
const cssPlusButton = styled('div', `
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 20px;
|
||||
margin-right: 8px;
|
||||
text-align: center;
|
||||
background-color: ${colors.lightGreen};
|
||||
color: ${colors.light};
|
||||
|
||||
.selected > & {
|
||||
background-color: ${colors.darkGreen};
|
||||
}
|
||||
`);
|
||||
|
||||
const cssPlusIcon = styled(icon, `
|
||||
background-color: ${colors.light};
|
||||
`);
|
||||
|
||||
const cssRefEditIcon = styled(icon, `
|
||||
background-color: ${colors.slate};
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
margin: 2px 3px 0 3px;
|
||||
`);
|
||||
|
||||
const cssMatchText = styled('span', `
|
||||
color: ${colors.lightGreen};
|
||||
.selected > & {
|
||||
color: ${colors.lighterGreen};
|
||||
}
|
||||
`);
|
||||
3
app/client/widgets/Spinner.css
Normal file
3
app/client/widgets/Spinner.css
Normal file
@@ -0,0 +1,3 @@
|
||||
.widget_spinner {
|
||||
padding-right: 15px;
|
||||
}
|
||||
32
app/client/widgets/Spinner.ts
Normal file
32
app/client/widgets/Spinner.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import * as kf from 'app/client/lib/koForm';
|
||||
import {DataRowModel} from 'app/client/models/DataRowModel';
|
||||
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
|
||||
import {NumericTextBox} from 'app/client/widgets/NumericTextBox';
|
||||
import {buildNumberFormat} from 'app/common/NumberFormat';
|
||||
import {dom} from 'grainjs';
|
||||
import * as ko from 'knockout';
|
||||
|
||||
/**
|
||||
* Spinner - A widget with a text field and spinner.
|
||||
*/
|
||||
export class Spinner extends NumericTextBox {
|
||||
private _stepSize: ko.Computed<number>;
|
||||
|
||||
constructor(field: ViewFieldRec) {
|
||||
super(field);
|
||||
const resolved = this.autoDispose(ko.computed(() =>
|
||||
buildNumberFormat({numMode: this.options().numMode}).resolvedOptions()));
|
||||
this._stepSize = this.autoDispose(ko.computed(() => {
|
||||
const extraScaling = (this.options().numMode === 'percent') ? 2 : 0;
|
||||
return Math.pow(10, -(this.options().decimals || resolved().minimumFractionDigits) - extraScaling);
|
||||
}));
|
||||
}
|
||||
|
||||
public buildDom(row: DataRowModel) {
|
||||
const value = row.cells[this.field.colId.peek()];
|
||||
return dom.update(super.buildDom(row),
|
||||
dom.cls('widget_spinner'),
|
||||
kf.spinner(value, this._stepSize)
|
||||
);
|
||||
}
|
||||
}
|
||||
48
app/client/widgets/Switch.css
Normal file
48
app/client/widgets/Switch.css
Normal file
@@ -0,0 +1,48 @@
|
||||
.widget_switch {
|
||||
position: relative;
|
||||
margin: -1px auto;
|
||||
width: 30px;
|
||||
height: 17px;
|
||||
}
|
||||
|
||||
.switch_slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: #ccc;
|
||||
border-radius: 17px;
|
||||
}
|
||||
|
||||
.switch_slider:hover {
|
||||
box-shadow: 0 0 1px #2196F3;
|
||||
}
|
||||
|
||||
.switch_circle {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
content: "";
|
||||
height: 13px;
|
||||
width: 13px;
|
||||
left: 2px;
|
||||
bottom: 2px;
|
||||
background-color: white;
|
||||
border-radius: 17px;
|
||||
}
|
||||
|
||||
.switch_on > .switch_slider {
|
||||
background-color: #2CB0AF;
|
||||
}
|
||||
|
||||
.switch_on > .switch_circle {
|
||||
-webkit-transform: translateX(13px);
|
||||
-ms-transform: translateX(13px);
|
||||
transform: translateX(13px);
|
||||
}
|
||||
|
||||
.switch_transition > .switch_slider, .switch_transition > .switch_circle {
|
||||
-webkit-transition: .4s;
|
||||
transition: .4s;
|
||||
}
|
||||
37
app/client/widgets/Switch.js
Normal file
37
app/client/widgets/Switch.js
Normal file
@@ -0,0 +1,37 @@
|
||||
var dom = require('../lib/dom');
|
||||
var dispose = require('../lib/dispose');
|
||||
var _ = require('underscore');
|
||||
var kd = require('../lib/koDom');
|
||||
var AbstractWidget = require('./AbstractWidget');
|
||||
|
||||
/**
|
||||
* Switch - A bi-state Switch widget
|
||||
*/
|
||||
function Switch(field) {
|
||||
AbstractWidget.call(this, field);
|
||||
}
|
||||
dispose.makeDisposable(Switch);
|
||||
_.extend(Switch.prototype, AbstractWidget.prototype);
|
||||
|
||||
Switch.prototype.buildConfigDom = function() {
|
||||
return null;
|
||||
};
|
||||
|
||||
Switch.prototype.buildDom = function(row) {
|
||||
var value = row[this.field.colId()];
|
||||
return dom('div.field_clip',
|
||||
dom('div.widget_switch',
|
||||
kd.toggleClass('switch_on', value),
|
||||
kd.toggleClass('switch_transition', row._isRealChange),
|
||||
dom('div.switch_slider'),
|
||||
dom('div.switch_circle'),
|
||||
dom.on('click', () => {
|
||||
if (!this.field.column().isRealFormula()) {
|
||||
value.setAndSave(!value.peek());
|
||||
}
|
||||
})
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
module.exports = Switch;
|
||||
51
app/client/widgets/TextBox.css
Normal file
51
app/client/widgets/TextBox.css
Normal file
@@ -0,0 +1,51 @@
|
||||
.record-add .field_clip {
|
||||
background-color: inherit;
|
||||
}
|
||||
.transform_field {
|
||||
background-color: #FEFFE8;
|
||||
}
|
||||
|
||||
@media not print {
|
||||
.formula_field, .formula_field_edit {
|
||||
padding-left: 18px;
|
||||
}
|
||||
.formula_field_edit {
|
||||
color: #D0D0D0;
|
||||
}
|
||||
|
||||
.formula_field::before, .formula_field_edit::before {
|
||||
content: '=';
|
||||
position: absolute;
|
||||
left: 2px;
|
||||
top: 4px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 2px;
|
||||
line-height: 12px;
|
||||
font-family: sans-serif;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.formula_field::before {
|
||||
background-color: #D0D0D0;
|
||||
}
|
||||
.formula_field_edit::before {
|
||||
background-color: var(--grist-color-cursor);
|
||||
}
|
||||
.formula_field.invalid::before {
|
||||
background-color: white;
|
||||
color: #ffb6c1;
|
||||
}
|
||||
.formula_field_edit.invalid::before {
|
||||
background-color: var(--grist-color-cursor);
|
||||
color: #ffb6c1;
|
||||
}
|
||||
}
|
||||
|
||||
.invalid-text input {
|
||||
background-color: #ffb6c1;
|
||||
}
|
||||
71
app/client/widgets/TextBox.js
Normal file
71
app/client/widgets/TextBox.js
Normal file
@@ -0,0 +1,71 @@
|
||||
var _ = require('underscore');
|
||||
var ko = require('knockout');
|
||||
var dom = require('../lib/dom');
|
||||
var dispose = require('../lib/dispose');
|
||||
var kd = require('../lib/koDom');
|
||||
var AbstractWidget = require('./AbstractWidget');
|
||||
var modelUtil = require('../models/modelUtil');
|
||||
|
||||
const {fromKoSave} = require('app/client/lib/fromKoSave');
|
||||
const {alignmentSelect, buttonToggleSelect} = require('app/client/ui2018/buttonSelect');
|
||||
const {testId} = require('app/client/ui2018/cssVars');
|
||||
const {cssRow} = require('app/client/ui/RightPanel');
|
||||
const {Computed} = require('grainjs');
|
||||
|
||||
/**
|
||||
* TextBox - The most basic widget for displaying text information.
|
||||
*/
|
||||
function TextBox(field) {
|
||||
AbstractWidget.call(this, field);
|
||||
this.alignment = this.options.prop('alignment');
|
||||
let wrap = this.options.prop('wrap');
|
||||
this.wrapping = this.autoDispose(ko.computed({
|
||||
read: () => {
|
||||
let w = wrap();
|
||||
if (w === null || w === undefined) {
|
||||
// When user has yet to specify a desired wrapping state, GridView and DetailView have
|
||||
// different default states. GridView defaults to wrapping disabled, while DetailView
|
||||
// defaults to wrapping enabled.
|
||||
return (this.field.viewSection().parentKey() === 'record') ? false : true;
|
||||
} else {
|
||||
return w;
|
||||
}
|
||||
},
|
||||
write: val => wrap(val)
|
||||
}));
|
||||
modelUtil.addSaveInterface(this.wrapping, val => wrap.saveOnly(val));
|
||||
|
||||
this.autoDispose(this.wrapping.subscribe(() =>
|
||||
this.field.viewSection().events.trigger('rowHeightChange')
|
||||
));
|
||||
|
||||
}
|
||||
dispose.makeDisposable(TextBox);
|
||||
_.extend(TextBox.prototype, AbstractWidget.prototype);
|
||||
|
||||
TextBox.prototype.buildConfigDom = function() {
|
||||
const wrapping = Computed.create(null, use => use(this.wrapping));
|
||||
wrapping.onWrite((val) => modelUtil.setSaveValue(this.wrapping, Boolean(val)));
|
||||
|
||||
return dom('div',
|
||||
cssRow(
|
||||
dom.autoDispose(wrapping),
|
||||
alignmentSelect(fromKoSave(this.alignment)),
|
||||
dom('div', {style: 'margin-left: 8px;'},
|
||||
buttonToggleSelect(wrapping, [{value: true, icon: 'Wrap'}]),
|
||||
testId('tb-wrap-text')
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
TextBox.prototype.buildDom = function(row) {
|
||||
let value = row[this.field.colId()];
|
||||
return dom('div.field_clip',
|
||||
kd.style('text-align', this.alignment),
|
||||
kd.toggleClass('text_wrapping', this.wrapping),
|
||||
kd.text(() => row._isAddRow() ? '' : this.valueFormatter().format(value()))
|
||||
);
|
||||
};
|
||||
|
||||
module.exports = TextBox;
|
||||
80
app/client/widgets/TextEditor.css
Normal file
80
app/client/widgets/TextEditor.css
Normal file
@@ -0,0 +1,80 @@
|
||||
.cell_editor {
|
||||
position: absolute;
|
||||
z-index: 1000; /* make it higher than popper's 999 */
|
||||
}
|
||||
|
||||
.default_editor {
|
||||
box-shadow: 0 0 3px 2px var(--grist-color-cursor);
|
||||
}
|
||||
|
||||
.formula_editor {
|
||||
background-color: white;
|
||||
padding: 2px 0 2px 18px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.celleditor_cursor_editor {
|
||||
background-color: white;
|
||||
|
||||
/* the following are copied from .field_clip */
|
||||
padding: 3px 3px 0px 3px;
|
||||
font-family: var(--grist-font-family-data);
|
||||
font-size: var(--grist-medium-font-size);
|
||||
line-height: 18px;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.celleditor_text_editor {
|
||||
display: block;
|
||||
outline: none;
|
||||
padding: 0px;
|
||||
border: none;
|
||||
resize: none;
|
||||
z-index: 10;
|
||||
color: black;
|
||||
|
||||
/* Inherit styles, same as for .celleditor_content_measure, to ensure that sizes correspond. */
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.celleditor_content_measure {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
border: none;
|
||||
visibility: hidden;
|
||||
overflow: visible;
|
||||
/* with 'pre-wrap', this lets the editor gets as wide as needed before wrapping; */
|
||||
/* width is limited only by max-width (which is set in JS code). */
|
||||
width: max-content;
|
||||
|
||||
/* Inherit styles, same as for .celleditor_text_editor, to ensure that sizes correspond. */
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.error_msg {
|
||||
color: black;
|
||||
cursor: default;
|
||||
margin: 4px;
|
||||
}
|
||||
|
||||
.error_details {
|
||||
padding: 2px 2px 2px 2px;
|
||||
background-color: #F8ECEA;
|
||||
margin: 0 0 -2px 0;
|
||||
}
|
||||
|
||||
.error_box {
|
||||
background-color: #ffb6c1;
|
||||
padding: 2px 0px 2px 0px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.kf_collapser {
|
||||
height: 1.2rem;
|
||||
}
|
||||
108
app/client/widgets/TextEditor.js
Normal file
108
app/client/widgets/TextEditor.js
Normal file
@@ -0,0 +1,108 @@
|
||||
var _ = require('underscore');
|
||||
var gutil = require('app/common/gutil');
|
||||
var dom = require('../lib/dom');
|
||||
var kd = require('../lib/koDom');
|
||||
var dispose = require('../lib/dispose');
|
||||
var BaseEditor = require('./BaseEditor');
|
||||
var commands = require('../components/commands');
|
||||
const {testId} = require('app/client/ui2018/cssVars');
|
||||
const {EditorPlacement} = require('app/client/widgets/EditorPlacement');
|
||||
|
||||
/**
|
||||
* Required parameters:
|
||||
* @param {RowModel} options.field: ViewSectionField (i.e. column) being edited.
|
||||
* @param {Object} options.cellValue: The value in the underlying cell being edited.
|
||||
* @param {String} options.editValue: String to be edited, or undefined to use cellValue.
|
||||
* @param {Number} options.cursorPos: The initial position where to place the cursor.
|
||||
* @param {Object} options.commands: Object mapping command names to functions, to enable as part
|
||||
* of the command group that should be activated while the editor exists.
|
||||
*
|
||||
* Optional parameters:
|
||||
* @param {String} options.placeholder: Optional placeholder for the textarea.
|
||||
*
|
||||
* TextEditor exposes the following members, which derived classes may use:
|
||||
* @member {Object} this.options: Options as passed into the constructor.
|
||||
* @member {Node} this.dom: The DOM element for the editor.
|
||||
* @member {Node} this.textInput: The textarea element of the editor (contained within this.dom).
|
||||
* @member {Object} this.commandGroup: The CommandGroup created from options.commands.
|
||||
*/
|
||||
function TextEditor(options) {
|
||||
this.options = options;
|
||||
this.commandGroup = this.autoDispose(commands.createGroup(options.commands, null, true));
|
||||
this._alignment = options.field.widgetOptionsJson.peek().alignment || 'left';
|
||||
|
||||
this.dom = dom('div.default_editor',
|
||||
dom('div.celleditor_cursor_editor', dom.testId('TextEditor_editor'),
|
||||
testId('widget-text-editor'), // new-style testId matches NTextEditor, for more uniform tests.
|
||||
this.contentSizer = dom('div.celleditor_content_measure'),
|
||||
this.textInput = dom('textarea.celleditor_text_editor',
|
||||
kd.attr('placeholder', options.placeholder || ''),
|
||||
kd.style('text-align', this._alignment),
|
||||
kd.value(gutil.undefDefault(options.editValue, String(options.cellValue))),
|
||||
this.commandGroup.attach(),
|
||||
|
||||
// Resize the textbox whenever user types in it.
|
||||
dom.on('input', () => this._resizeInput())
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
dispose.makeDisposable(TextEditor);
|
||||
_.extend(TextEditor.prototype, BaseEditor.prototype);
|
||||
|
||||
TextEditor.prototype.attach = function(cellRect) {
|
||||
// Attach the editor dom to page DOM.
|
||||
this.editorPlacement = EditorPlacement.create(this, this.dom, cellRect);
|
||||
this.setSizerLimits();
|
||||
|
||||
// Once the editor is attached to DOM, resize it to content, focus, and set cursor.
|
||||
this._resizeInput();
|
||||
this.textInput.focus();
|
||||
var pos = Math.min(this.options.cursorPos, this.textInput.value.length);
|
||||
this.textInput.setSelectionRange(pos, pos);
|
||||
};
|
||||
|
||||
TextEditor.prototype.setSizerLimits = function() {
|
||||
// Set the max width of the sizer to the max we could possibly grow to, so that it knows to wrap
|
||||
// once we reach it.
|
||||
const maxSize = this.editorPlacement.calcSizeWithPadding(this.textInput,
|
||||
{width: Infinity, height: Infinity}, {calcOnly: true});
|
||||
this.contentSizer.style.maxWidth = Math.ceil(maxSize.width) + 'px';
|
||||
};
|
||||
|
||||
TextEditor.prototype.getCellValue = function() {
|
||||
return this.textInput.value;
|
||||
};
|
||||
|
||||
TextEditor.prototype.getTextValue = function() {
|
||||
return this.textInput.value;
|
||||
};
|
||||
|
||||
TextEditor.prototype.getCursorPos = function() {
|
||||
return this.textInput.selectionStart;
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper which resizes textInput to match its content. It relies on having a contentSizer element
|
||||
* with the same font/size settings as the textInput, and on having `calcSize` helper,
|
||||
* which is provided by the EditorPlacement class.
|
||||
*/
|
||||
TextEditor.prototype._resizeInput = function() {
|
||||
var textInput = this.textInput;
|
||||
// \u200B is a zero-width space; it is used so the textbox will expand vertically
|
||||
// on newlines, but it does not add any width.
|
||||
this.contentSizer.textContent = textInput.value + '\u200B';
|
||||
var rect = this.contentSizer.getBoundingClientRect();
|
||||
|
||||
// Allow for a bit of extra space after the cursor (only desirable when text is left-aligned).
|
||||
if (this._alignment === 'left') {
|
||||
rect.width += 16;
|
||||
}
|
||||
|
||||
var size = this.editorPlacement.calcSizeWithPadding(textInput, rect);
|
||||
textInput.style.width = size.width + 'px';
|
||||
textInput.style.height = size.height + 'px';
|
||||
};
|
||||
|
||||
module.exports = TextEditor;
|
||||
236
app/client/widgets/UserType.js
Normal file
236
app/client/widgets/UserType.js
Normal file
@@ -0,0 +1,236 @@
|
||||
var _ = require('underscore');
|
||||
|
||||
/**
|
||||
* Given a widget name and a type, return the name of the widget that would
|
||||
* actually be used for that type (hopefully the same, unless falling back
|
||||
* on a default if widget name is unlisted), and all default configuration
|
||||
* information for that widget/type combination.
|
||||
* Returns something of form:
|
||||
* {
|
||||
* name:"WidgetName",
|
||||
* config: {
|
||||
* cons: "NameOfWidgetClass",
|
||||
* editCons: "NameOfEditorClass",
|
||||
* options: { ... default options for widget ... }
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
function getWidgetConfiguration(widgetName, type) {
|
||||
const oneTypeDef = typeDefs[type] || typeDefs.Text;
|
||||
if (!(widgetName in oneTypeDef.widgets)) {
|
||||
widgetName = oneTypeDef.default;
|
||||
}
|
||||
return {
|
||||
name: widgetName,
|
||||
config: oneTypeDef.widgets[widgetName]
|
||||
};
|
||||
}
|
||||
exports.getWidgetConfiguration = getWidgetConfiguration;
|
||||
|
||||
function mergeOptions(options, type) {
|
||||
const {name, config} = getWidgetConfiguration(options.widget, type);
|
||||
return _.defaults({widget: name}, options, config.options);
|
||||
}
|
||||
exports.mergeOptions = mergeOptions;
|
||||
|
||||
|
||||
// Contains the list of types with their storage types, possible widgets, default widgets,
|
||||
// and defaults for all widget settings
|
||||
// The names of widgets are used, instead of the actual classes needed, in order to limit
|
||||
// the spread of dependencies. See ./UserTypeImpl for actual classes.
|
||||
var typeDefs = {
|
||||
Any: {
|
||||
label: 'Any',
|
||||
icon: 'FieldAny',
|
||||
widgets: {
|
||||
TextBox: {
|
||||
cons: 'TextBox',
|
||||
editCons: 'TextEditor',
|
||||
icon: 'FieldTextbox',
|
||||
options: {
|
||||
alignment: 'left'
|
||||
}
|
||||
}
|
||||
},
|
||||
default: 'TextBox'
|
||||
},
|
||||
Text: {
|
||||
label: 'Text',
|
||||
icon: 'FieldText',
|
||||
widgets: {
|
||||
TextBox: {
|
||||
cons: 'TextBox',
|
||||
editCons: 'TextEditor',
|
||||
icon: 'FieldTextbox',
|
||||
options: {
|
||||
alignment: 'left',
|
||||
}
|
||||
},
|
||||
HyperLink: {
|
||||
cons: 'HyperLinkTextBox',
|
||||
editCons: 'HyperLinkEditor',
|
||||
icon: 'FieldLink',
|
||||
options: {
|
||||
alignment: 'left',
|
||||
}
|
||||
}
|
||||
},
|
||||
default: 'TextBox'
|
||||
},
|
||||
Numeric: {
|
||||
label: 'Numeric',
|
||||
icon: 'FieldNumeric',
|
||||
widgets: {
|
||||
TextBox: {
|
||||
cons: 'NumericTextBox',
|
||||
editCons: 'TextEditor',
|
||||
icon: 'FieldTextbox',
|
||||
options: {
|
||||
alignment: 'right'
|
||||
}
|
||||
},
|
||||
Spinner: {
|
||||
cons: 'Spinner',
|
||||
editCons: 'TextEditor',
|
||||
icon: 'FieldSpinner',
|
||||
options: {
|
||||
alignment: 'right'
|
||||
}
|
||||
}
|
||||
},
|
||||
default: 'TextBox'
|
||||
},
|
||||
Int: {
|
||||
label: 'Integer',
|
||||
icon: 'FieldInteger',
|
||||
widgets: {
|
||||
TextBox: {
|
||||
cons: 'NumericTextBox',
|
||||
editCons: 'TextEditor',
|
||||
icon: 'FieldTextbox',
|
||||
options: {
|
||||
decimals: 0,
|
||||
alignment: 'right'
|
||||
}
|
||||
},
|
||||
Spinner: {
|
||||
cons: 'Spinner',
|
||||
editCons: 'TextEditor',
|
||||
icon: 'FieldSpinner',
|
||||
options: {
|
||||
decimals: 0,
|
||||
alignment: 'right'
|
||||
}
|
||||
}
|
||||
},
|
||||
default: 'TextBox'
|
||||
},
|
||||
Bool: {
|
||||
label: 'Toggle',
|
||||
icon: 'FieldToggle',
|
||||
widgets: {
|
||||
TextBox: {
|
||||
cons: 'TextBox',
|
||||
editCons: 'TextEditor',
|
||||
icon: 'FieldTextbox',
|
||||
options: {
|
||||
alignment: 'center'
|
||||
}
|
||||
},
|
||||
CheckBox: {
|
||||
cons: 'CheckBox',
|
||||
editCons: 'CheckBoxEditor',
|
||||
icon: 'FieldCheckbox',
|
||||
options: {}
|
||||
},
|
||||
Switch: {
|
||||
cons: 'Switch',
|
||||
editCons: 'CheckBoxEditor',
|
||||
icon: 'FieldSwitcher',
|
||||
options: {}
|
||||
}
|
||||
},
|
||||
default: 'CheckBox'
|
||||
},
|
||||
Date: {
|
||||
label: 'Date',
|
||||
icon: 'FieldDate',
|
||||
widgets: {
|
||||
TextBox: {
|
||||
cons: 'DateTextBox',
|
||||
editCons: 'DateEditor',
|
||||
icon: 'FieldTextbox',
|
||||
options: {
|
||||
dateFormat: 'YYYY-MM-DD',
|
||||
isCustomDateFormat: false,
|
||||
alignment: 'left'
|
||||
}
|
||||
}
|
||||
},
|
||||
default: 'TextBox'
|
||||
},
|
||||
DateTime: {
|
||||
label: 'DateTime',
|
||||
icon: 'FieldDateTime',
|
||||
widgets: {
|
||||
TextBox: {
|
||||
cons: 'DateTimeTextBox',
|
||||
editCons: 'DateTimeEditor',
|
||||
icon: 'FieldTextbox',
|
||||
options: {
|
||||
dateFormat: 'YYYY-MM-DD', // Default to ISO standard: https://xkcd.com/1179/
|
||||
timeFormat: 'h:mma',
|
||||
isCustomDateFormat: false,
|
||||
isCustomTimeFormat: false,
|
||||
alignment: 'left'
|
||||
}
|
||||
}
|
||||
},
|
||||
default: 'TextBox'
|
||||
},
|
||||
Choice: {
|
||||
label: 'Choice',
|
||||
icon: 'FieldChoice',
|
||||
widgets: {
|
||||
TextBox: {
|
||||
cons: 'ChoiceTextBox',
|
||||
editCons: 'ChoiceEditor',
|
||||
icon: 'FieldTextbox',
|
||||
options: {
|
||||
alignment: 'left',
|
||||
choices: null
|
||||
}
|
||||
}
|
||||
},
|
||||
default: 'TextBox'
|
||||
},
|
||||
Ref: {
|
||||
label: 'Reference',
|
||||
icon: 'FieldReference',
|
||||
widgets: {
|
||||
Reference: {
|
||||
cons: 'Reference',
|
||||
editCons: 'ReferenceEditor',
|
||||
icon: 'FieldReference',
|
||||
options: {}
|
||||
}
|
||||
},
|
||||
default: 'Reference'
|
||||
},
|
||||
Attachments: {
|
||||
label: 'Attachment',
|
||||
icon: 'FieldAttachment',
|
||||
widgets: {
|
||||
Previews: {
|
||||
cons: 'PreviewsWidget',
|
||||
editCons: 'PreviewsEditor',
|
||||
icon: 'FieldAttachment',
|
||||
options: {
|
||||
height: '36'
|
||||
}
|
||||
}
|
||||
},
|
||||
default: 'Previews'
|
||||
}
|
||||
};
|
||||
exports.typeDefs = typeDefs;
|
||||
49
app/client/widgets/UserTypeImpl.js
Normal file
49
app/client/widgets/UserTypeImpl.js
Normal file
@@ -0,0 +1,49 @@
|
||||
const {NTextBox} = require('./NTextBox');
|
||||
const {NumericTextBox} = require('./NumericTextBox');
|
||||
const {Spinner} = require('./Spinner');
|
||||
const {PreviewsWidget, PreviewsEditor} = require('./PreviewsWidget');
|
||||
const UserType = require('./UserType');
|
||||
const {HyperLinkEditor} = require('./HyperLinkEditor');
|
||||
const {NTextEditor} = require('./NTextEditor');
|
||||
const {ReferenceEditor} = require('./ReferenceEditor');
|
||||
|
||||
/**
|
||||
* Convert the name of a widget to its implementation.
|
||||
*/
|
||||
const nameToWidget = {
|
||||
'TextBox': NTextBox,
|
||||
'TextEditor': NTextEditor,
|
||||
'NumericTextBox': NumericTextBox,
|
||||
'HyperLinkTextBox': require('./HyperLinkTextBox'),
|
||||
'HyperLinkEditor': HyperLinkEditor,
|
||||
'Spinner': Spinner,
|
||||
'CheckBox': require('./CheckBox'),
|
||||
'CheckBoxEditor': require('./CheckBoxEditor'),
|
||||
'Reference': require('./Reference'),
|
||||
'Switch': require('./Switch'),
|
||||
'ReferenceEditor': ReferenceEditor,
|
||||
'ChoiceTextBox': require('./ChoiceTextBox'),
|
||||
'ChoiceEditor': require('./ChoiceEditor'),
|
||||
'DateTimeTextBox': require('./DateTimeTextBox'),
|
||||
'DateTextBox': require('./DateTextBox'),
|
||||
'DateEditor': require('./DateEditor'),
|
||||
'PreviewsWidget': PreviewsWidget,
|
||||
'PreviewsEditor': PreviewsEditor,
|
||||
'DateTimeEditor': require('./DateTimeEditor'),
|
||||
};
|
||||
|
||||
exports.nameToWidget = nameToWidget;
|
||||
|
||||
/** return a good class to instantiate for viewing a widget/type combination */
|
||||
function getWidgetConstructor(widget, type) {
|
||||
const {config} = UserType.getWidgetConfiguration(widget, type);
|
||||
return nameToWidget[config.cons];
|
||||
}
|
||||
exports.getWidgetConstructor = getWidgetConstructor;
|
||||
|
||||
/** return a good class to instantiate for editing a widget/type combination */
|
||||
function getEditorConstructor(widget, type) {
|
||||
const {config} = UserType.getWidgetConfiguration(widget, type);
|
||||
return nameToWidget[config.editCons];
|
||||
}
|
||||
exports.getEditorConstructor = getEditorConstructor;
|
||||
Reference in New Issue
Block a user