mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(core) Adding font options to the style picker
Summary: Redesigning color picker: - Single color palette (no light/dark switch) - Ability to remove color (new empty button) New font options in the color picker. Font options are available on: - Default cell style - Conditional rules styles - Choice/ChoiceList editor and token field - Filters for Choice/ChoiceList columns Design document: https://www.figma.com/file/bRTsb47VIOVBfJPj0qF3C9/Grist-Updates?node-id=415%3A8135 Test Plan: new and updated tests Reviewers: georgegevoian, alexmojaki Reviewed By: georgegevoian, alexmojaki Subscribers: alexmojaki Differential Revision: https://phab.getgrist.com/D3335
This commit is contained in:
@@ -223,9 +223,13 @@ export class ColumnTransform extends Disposable {
|
||||
[
|
||||
'CopyFromColumn',
|
||||
this._tableData.tableId,
|
||||
this.transformColumn.colId(),
|
||||
this.origColumn.colId(),
|
||||
JSON.stringify(this._fieldBuilder.options()),
|
||||
this.transformColumn.colId.peek(),
|
||||
this.origColumn.colId.peek(),
|
||||
// Get the options from builder rather the transforming columns.
|
||||
// Those options are supposed to be set by prepTransformColInfo(TypeTransform) and
|
||||
// adjusted by client.
|
||||
// TODO: is this really needed? Aren't those options already in the data-engine?
|
||||
JSON.stringify(this._fieldBuilder.options.peek()),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -15,12 +15,13 @@ import {dateTimeWidgetOptions, guessDateFormat} from 'app/common/parseDate';
|
||||
import {TableData} from 'app/common/TableData';
|
||||
import {decodeObject} from 'app/plugin/objtypes';
|
||||
|
||||
export interface ColInfo {
|
||||
interface ColInfo {
|
||||
type: string;
|
||||
isFormula: boolean;
|
||||
formula: string;
|
||||
visibleCol: number;
|
||||
widgetOptions?: string;
|
||||
rules: gristTypes.RefListValue
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,18 +88,21 @@ export async function prepTransformColInfo(docModel: DocModel, origCol: ColumnRe
|
||||
const toType = gristTypes.extractTypeFromColType(toTypeMaybeFull);
|
||||
const tableData: TableData = docModel.docData.getTable(origCol.table().tableId())!;
|
||||
let widgetOptions: any = null;
|
||||
let rules: gristTypes.RefListValue = null;
|
||||
|
||||
const colInfo: ColInfo = {
|
||||
type: addColTypeSuffix(toTypeMaybeFull, origCol, docModel),
|
||||
isFormula: true,
|
||||
visibleCol: 0,
|
||||
formula: "CURRENT_CONVERSION(rec)",
|
||||
rules,
|
||||
};
|
||||
|
||||
const visibleCol = origCol.visibleColModel();
|
||||
// Column used to derive previous widget options and sample values for guessing
|
||||
const sourceCol = visibleCol.getRowId() !== 0 ? visibleCol : origCol;
|
||||
const prevOptions = sourceCol.widgetOptionsJson.peek() || {};
|
||||
const prevRules = sourceCol.rules.peek();
|
||||
switch (toType) {
|
||||
case 'Date':
|
||||
case 'DateTime': {
|
||||
@@ -112,12 +116,13 @@ export async function prepTransformColInfo(docModel: DocModel, origCol: ColumnRe
|
||||
}
|
||||
case 'Numeric':
|
||||
case 'Int': {
|
||||
if (["Numeric", "Int"].includes(sourceCol.type())) {
|
||||
widgetOptions = prevOptions;
|
||||
} else {
|
||||
if (!["Numeric", "Int"].includes(sourceCol.type())) {
|
||||
const numberParse = NumberParse.fromSettings(docModel.docData.docSettings());
|
||||
const colValues = tableData.getColValues(sourceCol.colId()) || [];
|
||||
widgetOptions = numberParse.guessOptions(colValues.filter(isString));
|
||||
} else {
|
||||
widgetOptions = prevOptions;
|
||||
rules = prevRules;
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -202,6 +207,9 @@ export async function prepTransformColInfo(docModel: DocModel, origCol: ColumnRe
|
||||
if (widgetOptions) {
|
||||
colInfo.widgetOptions = JSON.stringify(widgetOptions);
|
||||
}
|
||||
if (rules) {
|
||||
colInfo.rules = rules;
|
||||
}
|
||||
return colInfo;
|
||||
}
|
||||
|
||||
|
||||
@@ -91,11 +91,19 @@ export class TypeTransform extends ColumnTransform {
|
||||
protected async addTransformColumn(toType: string) {
|
||||
const docModel = this.gristDoc.docModel;
|
||||
const colInfo = await TypeConversion.prepTransformColInfo(docModel, this.origColumn, this.origDisplayCol, toType);
|
||||
// NOTE: We could add rules with AddColumn action, but there are some optimizations that converts array values.
|
||||
const rules = colInfo.rules;
|
||||
delete colInfo.rules;
|
||||
const newColInfos = await this._tableData.sendTableActions([
|
||||
['AddColumn', 'gristHelper_Converted', {...colInfo, isFormula: false, formula: ''}],
|
||||
['AddColumn', 'gristHelper_Transform', colInfo],
|
||||
]);
|
||||
const transformColRef = newColInfos[1].colRef;
|
||||
if (rules) {
|
||||
await this.gristDoc.docData.sendActions([
|
||||
['UpdateRecord', '_grist_Tables_column', transformColRef, { rules }]
|
||||
]);
|
||||
}
|
||||
this.transformColumn = docModel.columns.getRowModel(transformColRef);
|
||||
await this.convertValues();
|
||||
return transformColRef;
|
||||
|
||||
@@ -36,7 +36,7 @@ import {createValidationRec, ValidationRec} from 'app/client/models/entities/Val
|
||||
import {createViewFieldRec, ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
|
||||
import {createViewRec, ViewRec} from 'app/client/models/entities/ViewRec';
|
||||
import {createViewSectionRec, ViewSectionRec} from 'app/client/models/entities/ViewSectionRec';
|
||||
import {GristObjCode} from 'app/plugin/GristData';
|
||||
import {RefListValue} from 'app/common/gristTypes';
|
||||
import {decodeObject} from 'app/plugin/objtypes';
|
||||
|
||||
// Re-export all the entity types available. The recommended usage is like this:
|
||||
@@ -97,7 +97,7 @@ export function refRecord<TRow extends MetaRowModel>(
|
||||
return ko.pureComputed(() => tableModel.getRowModel(rowIdObs() || 0, true));
|
||||
}
|
||||
|
||||
type RefListValue = [GristObjCode.List, ...number[]]|null;
|
||||
|
||||
/**
|
||||
* Returns an observable with a list of records from another table, selected using RefList column.
|
||||
* @param {TableModel} tableModel: The model for the table to return a record from.
|
||||
|
||||
@@ -1,18 +1,34 @@
|
||||
export interface Style {
|
||||
textColor?: string;
|
||||
fillColor?: string;
|
||||
fontBold?: boolean;
|
||||
fontUnderline?: boolean;
|
||||
fontItalic?: boolean;
|
||||
fontStrikethrough?: boolean;
|
||||
}
|
||||
|
||||
export class CombinedStyle implements Style {
|
||||
public readonly textColor?: string;
|
||||
public readonly fillColor?: string;
|
||||
constructor(rules: Style[], flags: any[]) {
|
||||
public readonly fontBold?: boolean;
|
||||
public readonly fontUnderline?: boolean;
|
||||
public readonly fontItalic?: boolean;
|
||||
public readonly fontStrikethrough?: boolean;
|
||||
constructor(rules: (Style|undefined|null)[], flags: any[]) {
|
||||
for (let i = 0; i < rules.length; i++) {
|
||||
if (flags[i]) {
|
||||
const textColor = rules[i].textColor;
|
||||
const fillColor = rules[i].fillColor;
|
||||
const textColor = rules[i]?.textColor;
|
||||
const fillColor = rules[i]?.fillColor;
|
||||
const fontBold = rules[i]?.fontBold;
|
||||
const fontUnderline = rules[i]?.fontUnderline;
|
||||
const fontItalic = rules[i]?.fontItalic;
|
||||
const fontStrikethrough = rules[i]?.fontStrikethrough;
|
||||
this.textColor = textColor || this.textColor;
|
||||
this.fillColor = fillColor || this.fillColor;
|
||||
this.fontBold = fontBold || this.fontBold;
|
||||
this.fontUnderline = fontUnderline || this.fontUnderline;
|
||||
this.fontItalic = fontItalic || this.fontItalic;
|
||||
this.fontStrikethrough = fontStrikethrough || this.fontStrikethrough;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,10 +67,11 @@ export interface ViewFieldRec extends IRowModel<"_grist_Views_section_field"> {
|
||||
disableEditData: ko.Computed<boolean>;
|
||||
|
||||
textColor: modelUtil.KoSaveableObservable<string|undefined>;
|
||||
fillColor: modelUtil.KoSaveableObservable<string>;
|
||||
|
||||
computedColor: ko.Computed<string|undefined>;
|
||||
computedFill: ko.Computed<string>;
|
||||
fillColor: modelUtil.KoSaveableObservable<string|undefined>;
|
||||
fontBold: modelUtil.KoSaveableObservable<boolean|undefined>;
|
||||
fontUnderline: modelUtil.KoSaveableObservable<boolean|undefined>;
|
||||
fontItalic: modelUtil.KoSaveableObservable<boolean|undefined>;
|
||||
fontStrikethrough: modelUtil.KoSaveableObservable<boolean|undefined>;
|
||||
|
||||
documentSettings: ko.PureComputed<DocumentSettings>;
|
||||
|
||||
@@ -227,16 +228,12 @@ export function createViewFieldRec(this: ViewFieldRec, docModel: DocModel): void
|
||||
this.disableModify = ko.pureComputed(() => this.column().disableModify());
|
||||
this.disableEditData = ko.pureComputed(() => this.column().disableEditData());
|
||||
|
||||
this.textColor = this.widgetOptionsJson.prop('textColor') as modelUtil.KoSaveableObservable<string>;
|
||||
|
||||
const fillColorProp = modelUtil.fieldWithDefault(
|
||||
this.widgetOptionsJson.prop('fillColor') as modelUtil.KoSaveableObservable<string>, "#FFFFFF00");
|
||||
// Store empty string in place of the default white color, so that we can keep it transparent in
|
||||
// GridView, to avoid interfering with zebra stripes.
|
||||
this.fillColor = modelUtil.savingComputed({
|
||||
read: () => fillColorProp(),
|
||||
write: (setter, val) => setter(fillColorProp, val?.toUpperCase() === '#FFFFFF' ? '' : (val ?? '')),
|
||||
});
|
||||
this.textColor = this.widgetOptionsJson.prop('textColor');
|
||||
this.fillColor = this.widgetOptionsJson.prop('fillColor');
|
||||
this.fontBold = this.widgetOptionsJson.prop('fontBold');
|
||||
this.fontUnderline = this.widgetOptionsJson.prop('fontUnderline');
|
||||
this.fontItalic = this.widgetOptionsJson.prop('fontItalic');
|
||||
this.fontStrikethrough = this.widgetOptionsJson.prop('fontStrikethrough');
|
||||
|
||||
this.documentSettings = ko.pureComputed(() => docModel.docInfoRow.documentSettingsJson());
|
||||
|
||||
|
||||
@@ -378,6 +378,10 @@ function getRenderFunc(columnType: string, fieldOrColumn: ViewFieldRec|ColumnRec
|
||||
{
|
||||
fillColor: choiceOptions[value.label]?.fillColor,
|
||||
textColor: choiceOptions[value.label]?.textColor,
|
||||
fontBold: choiceOptions[value.label]?.fontBold ?? false,
|
||||
fontUnderline: choiceOptions[value.label]?.fontUnderline ?? false,
|
||||
fontItalic: choiceOptions[value.label]?.fontItalic ?? false,
|
||||
fontStrikethrough: choiceOptions[value.label]?.fontStrikethrough ?? false,
|
||||
},
|
||||
dom.cls(cssToken.className),
|
||||
cssInvalidToken.cls('-invalid', !choiceSet.has(value.label)),
|
||||
|
||||
@@ -1,68 +1,71 @@
|
||||
/*
|
||||
* The palettes were inspired by comparisons of a handful of popular services.
|
||||
*/
|
||||
export const lighter = [
|
||||
export const swatches = [
|
||||
// white-black
|
||||
"#FFFFFF",
|
||||
"#DCDCDC",
|
||||
"#B4B4B4",
|
||||
"#888888",
|
||||
"#000000",
|
||||
|
||||
// red
|
||||
"#FECBCC",
|
||||
"#FD8182",
|
||||
"#FC363B",
|
||||
"#E00A17",
|
||||
"#740206",
|
||||
|
||||
// brown
|
||||
"#F3E1D2",
|
||||
"#D6A77F",
|
||||
"#C37739",
|
||||
"#AA632B",
|
||||
"#653008",
|
||||
|
||||
// orange
|
||||
"#FEE7C3",
|
||||
"#FECC81",
|
||||
"#FDA630",
|
||||
"#FD9D28",
|
||||
"#B36F19",
|
||||
|
||||
// yellow
|
||||
"#FFFACD",
|
||||
"#FEF47A",
|
||||
"#FEEB36",
|
||||
"#E8D62F",
|
||||
"#928619",
|
||||
|
||||
// green
|
||||
"#E1FEDE",
|
||||
"#98FD90",
|
||||
"#35FD31",
|
||||
"#2AE028",
|
||||
"#126E0E",
|
||||
|
||||
// light blue
|
||||
"#CCFEFE",
|
||||
"#8AFCFE",
|
||||
"#2EF8FE",
|
||||
"#24D6DB",
|
||||
"#0C686A",
|
||||
|
||||
// dark blue
|
||||
"#D3E7FE",
|
||||
"#75B5FC",
|
||||
"#2486FB",
|
||||
"#157AFB",
|
||||
"#084794",
|
||||
|
||||
// violet
|
||||
"#E8D0FE",
|
||||
"#BC77FC",
|
||||
"#9633FB",
|
||||
"#8725FB",
|
||||
"#460D81",
|
||||
|
||||
// pink
|
||||
"#FED6FB",
|
||||
"#FD79F4",
|
||||
"#FC2AED"
|
||||
];
|
||||
|
||||
export const darker = [
|
||||
"#888888",
|
||||
"#414141",
|
||||
"#000000",
|
||||
"#E00A17",
|
||||
"#B60610",
|
||||
"#740206",
|
||||
"#AA632B",
|
||||
"#824617",
|
||||
"#653008",
|
||||
"#FD9D28",
|
||||
"#E38D22",
|
||||
"#B36F19",
|
||||
"#E8D62F",
|
||||
"#C0B225",
|
||||
"#928619",
|
||||
"#2AE028",
|
||||
"#1FAA1C",
|
||||
"#126E0E",
|
||||
"#24D6DB",
|
||||
"#189DA1",
|
||||
"#0C686A",
|
||||
"#157AFB",
|
||||
"#0F64CF",
|
||||
"#084794",
|
||||
"#8725FB",
|
||||
"#6318B8",
|
||||
"#460D81",
|
||||
"#E621D7",
|
||||
"#B818AC",
|
||||
"#760C6E"
|
||||
];
|
||||
|
||||
/**
|
||||
* Tells if swatch is a light color or dark (2 first are light 2 last are dark)
|
||||
*/
|
||||
export function isLight(index: number) {
|
||||
return index % 4 <= 1;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,39 @@
|
||||
import { darker, lighter } from "app/client/ui2018/ColorPalette";
|
||||
import { colors, testId, vars } from 'app/client/ui2018/cssVars';
|
||||
import { textInput } from "app/client/ui2018/editableLabel";
|
||||
import { icon } from "app/client/ui2018/icons";
|
||||
import { isValidHex } from "app/common/gutil";
|
||||
import { cssSelectBtn } from 'app/client/ui2018/select';
|
||||
import { Computed, Disposable, dom, DomArg, Observable, onKeyDown, styled } from "grainjs";
|
||||
import { defaultMenuOptions, IOpenController, setPopupToCreateDom } from "popweasel";
|
||||
import {basicButton, primaryButton} from 'app/client/ui2018/buttons';
|
||||
import {isLight, swatches} from 'app/client/ui2018/ColorPalette';
|
||||
import {colors, testId, vars} from 'app/client/ui2018/cssVars';
|
||||
import {textInput} from 'app/client/ui2018/editableLabel';
|
||||
import {IconName} from 'app/client/ui2018/IconList';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {cssSelectBtn} from 'app/client/ui2018/select';
|
||||
import {isValidHex} from 'app/common/gutil';
|
||||
import {Computed, Disposable, dom, Observable, onKeyDown, styled} from 'grainjs';
|
||||
import {defaultMenuOptions, IOpenController, setPopupToCreateDom} from 'popweasel';
|
||||
|
||||
export interface StyleOptions {
|
||||
textColor: ColorOption,
|
||||
fillColor: ColorOption,
|
||||
fontBold: Observable<boolean|undefined>,
|
||||
fontUnderline: Observable<boolean|undefined>,
|
||||
fontItalic: Observable<boolean|undefined>,
|
||||
fontStrikethrough: Observable<boolean|undefined>,
|
||||
}
|
||||
|
||||
export class ColorOption {
|
||||
constructor(
|
||||
public color: Observable<string|undefined>,
|
||||
// If the color accepts undefined/empty as a value. Controls empty selector in the picker.
|
||||
public allowsNone: boolean = false,
|
||||
// Default color to show when value is empty or undefined (itself can be empty).
|
||||
public defaultColor: string = '',
|
||||
// Text to be shown in the picker when color is not set.
|
||||
public noneText: string = '',
|
||||
// Preview color to show when value is undefined.
|
||||
public previewNoneColor: string = '',) {
|
||||
if (defaultColor && allowsNone) {
|
||||
throw new Error("Allowing an empty value is not compatible with a default color");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* colorSelect allows to select color for both fill and text cell color. It allows for fast
|
||||
@@ -13,98 +41,118 @@ import { defaultMenuOptions, IOpenController, setPopupToCreateDom } from "popwea
|
||||
* native color picker. Pressing Escape reverts to the saved value. Caller is expected to handle
|
||||
* logging of onSave() callback rejection. In case of rejection, values are reverted to their saved one.
|
||||
*/
|
||||
export function colorSelect(textColor: Observable<string|undefined>, fillColor: Observable<string|undefined>,
|
||||
onSave: () => Promise<void>, allowNone = false): Element {
|
||||
export function colorSelect(
|
||||
styleOptions: StyleOptions,
|
||||
onSave: () => Promise<void>,
|
||||
placeholder = 'Default cell style'): Element {
|
||||
const {
|
||||
textColor,
|
||||
fillColor,
|
||||
} = styleOptions;
|
||||
const selectBtn = cssSelectBtn(
|
||||
cssContent(
|
||||
cssButtonIcon(
|
||||
'T',
|
||||
dom.style('color', use => use(textColor) || ''),
|
||||
dom.style('background-color', (use) => use(fillColor)?.slice(0, 7) || ''),
|
||||
dom.style('color', use => use(textColor.color) || textColor.previewNoneColor),
|
||||
dom.style('background-color', (use) => use(fillColor.color)?.slice(0, 7) || fillColor.previewNoneColor),
|
||||
dom.cls('font-bold', use => use(styleOptions.fontBold) ?? false),
|
||||
dom.cls('font-italic', use => use(styleOptions.fontItalic) ?? false),
|
||||
dom.cls('font-underline', use => use(styleOptions.fontUnderline) ?? false),
|
||||
dom.cls('font-strikethrough', use => use(styleOptions.fontStrikethrough) ?? false),
|
||||
cssLightBorder.cls(''),
|
||||
testId('btn-icon'),
|
||||
),
|
||||
'Cell Color',
|
||||
placeholder,
|
||||
),
|
||||
icon('Dropdown'),
|
||||
testId('color-select'),
|
||||
);
|
||||
|
||||
const domCreator = (ctl: IOpenController) => buildColorPicker(ctl, textColor, fillColor, onSave, allowNone);
|
||||
const domCreator = (ctl: IOpenController) => buildColorPicker(ctl, styleOptions, onSave);
|
||||
setPopupToCreateDom(selectBtn, domCreator, {...defaultMenuOptions, placement: 'bottom-end'});
|
||||
|
||||
return selectBtn;
|
||||
}
|
||||
|
||||
export function colorButton(textColor: Observable<string|undefined>, fillColor: Observable<string|undefined>,
|
||||
export function colorButton(
|
||||
styleOptions: StyleOptions,
|
||||
onSave: () => Promise<void>): Element {
|
||||
const { textColor, fillColor } = styleOptions;
|
||||
const iconBtn = cssIconBtn(
|
||||
icon(
|
||||
'Dropdown',
|
||||
dom.style('background-color', use => use(textColor) || ''),
|
||||
testId('color-button-dropdown')
|
||||
),
|
||||
dom.style('background-color', (use) => use(fillColor)?.slice(0, 7) || ''),
|
||||
dom.on('click', (e) => { e.stopPropagation(); e.preventDefault(); }),
|
||||
'T',
|
||||
dom.style('color', use => use(textColor.color) || textColor.previewNoneColor),
|
||||
dom.style('background-color', (use) => use(fillColor.color)?.slice(0, 7) || fillColor.previewNoneColor),
|
||||
dom.cls('font-bold', use => use(styleOptions.fontBold) ?? false),
|
||||
dom.cls('font-italic', use => use(styleOptions.fontItalic) ?? false),
|
||||
dom.cls('font-underline', use => use(styleOptions.fontUnderline) ?? false),
|
||||
dom.cls('font-strikethrough', use => use(styleOptions.fontStrikethrough) ?? false),
|
||||
testId('color-button'),
|
||||
);
|
||||
|
||||
const domCreator = (ctl: IOpenController) => buildColorPicker(ctl, textColor, fillColor, onSave);
|
||||
const domCreator = (ctl: IOpenController) => buildColorPicker(ctl, styleOptions, onSave);
|
||||
setPopupToCreateDom(iconBtn, domCreator, { ...defaultMenuOptions, placement: 'bottom-end' });
|
||||
|
||||
return iconBtn;
|
||||
}
|
||||
|
||||
function buildColorPicker(ctl: IOpenController, textColor: Observable<string|undefined>,
|
||||
fillColor: Observable<string|undefined>,
|
||||
onSave: () => Promise<void>,
|
||||
allowNone = false): Element {
|
||||
const textColorModel = PickerModel.create(null, textColor);
|
||||
const fillColorModel = PickerModel.create(null, fillColor);
|
||||
function buildColorPicker(ctl: IOpenController,
|
||||
{
|
||||
textColor,
|
||||
fillColor,
|
||||
fontBold,
|
||||
fontUnderline,
|
||||
fontItalic,
|
||||
fontStrikethrough
|
||||
}: StyleOptions,
|
||||
onSave: () => Promise<void>): Element {
|
||||
const textColorModel = ColorModel.create(null, textColor.color);
|
||||
const fillColorModel = ColorModel.create(null, fillColor.color);
|
||||
const fontBoldModel = BooleanModel.create(null, fontBold);
|
||||
const fontUnderlineModel = BooleanModel.create(null, fontUnderline);
|
||||
const fontItalicModel = BooleanModel.create(null, fontItalic);
|
||||
const fontStrikethroughModel = BooleanModel.create(null, fontStrikethrough);
|
||||
|
||||
const models = [textColorModel, fillColorModel, fontBoldModel, fontUnderlineModel,
|
||||
fontItalicModel, fontStrikethroughModel];
|
||||
|
||||
const notChanged = Computed.create(null, use => models.every(m => use(m.needsSaving) === false));
|
||||
|
||||
function revert() {
|
||||
textColorModel.revert();
|
||||
fillColorModel.revert();
|
||||
models.forEach(m => m.revert());
|
||||
ctl.close();
|
||||
}
|
||||
|
||||
ctl.onDispose(async () => {
|
||||
if (textColorModel.needsSaving() || fillColorModel.needsSaving()) {
|
||||
if (!notChanged.get()) {
|
||||
try {
|
||||
// TODO: disable the trigger btn while saving
|
||||
await onSave();
|
||||
} catch (e) {
|
||||
/* Does no logging: onSave() callback is expected to handle their reporting */
|
||||
textColorModel.revert();
|
||||
fillColorModel.revert();
|
||||
models.forEach(m => m.revert());
|
||||
}
|
||||
}
|
||||
textColorModel.dispose();
|
||||
fillColorModel.dispose();
|
||||
models.forEach(m => m.dispose());
|
||||
notChanged.dispose();
|
||||
});
|
||||
|
||||
const colorSquare = (...args: DomArg[]) => cssColorSquare(
|
||||
...args,
|
||||
dom.style('color', use => use(textColor) || ''),
|
||||
dom.style('background-color', use => use(fillColor) || ''),
|
||||
cssLightBorder.cls(''),
|
||||
);
|
||||
|
||||
return cssContainer(
|
||||
dom.create(PickerComponent, fillColorModel, {
|
||||
colorSquare: colorSquare(),
|
||||
title: 'fill',
|
||||
defaultMode: 'lighter',
|
||||
allowNone
|
||||
dom.create(FontComponent, {
|
||||
fontBoldModel,
|
||||
fontUnderlineModel,
|
||||
fontItalicModel,
|
||||
fontStrikethroughModel,
|
||||
}),
|
||||
cssVSpacer(),
|
||||
dom.create(PickerComponent, textColorModel, {
|
||||
colorSquare: colorSquare('T'),
|
||||
title: 'text',
|
||||
defaultMode: 'darker',
|
||||
allowNone
|
||||
...textColor
|
||||
}),
|
||||
cssVSpacer(),
|
||||
dom.create(PickerComponent, fillColorModel, {
|
||||
title: 'fill',
|
||||
...fillColor
|
||||
}),
|
||||
|
||||
// gives focus and binds keydown events
|
||||
(elem: any) => { setTimeout(() => elem.focus(), 0); },
|
||||
onKeyDown({
|
||||
@@ -112,36 +160,53 @@ function buildColorPicker(ctl: IOpenController, textColor: Observable<string|und
|
||||
Enter: () => { ctl.close(); },
|
||||
}),
|
||||
|
||||
cssButtonRow(
|
||||
primaryButton('Apply',
|
||||
dom.on('click', () => ctl.close()),
|
||||
dom.boolAttr("disabled", notChanged),
|
||||
testId('colors-save')
|
||||
),
|
||||
basicButton('Cancel',
|
||||
dom.on('click', () => revert()),
|
||||
testId('colors-cancel')
|
||||
)
|
||||
),
|
||||
|
||||
// Set focus when `focusout` is bubbling from a children element. This is to allow to receive
|
||||
// keyboard event again after user interacted with the hex box text input.
|
||||
dom.on('focusout', (ev, elem) => (ev.target !== elem) && elem.focus()),
|
||||
);
|
||||
}
|
||||
|
||||
interface PickerComponentOptions {
|
||||
colorSquare: Element;
|
||||
title: string;
|
||||
defaultMode: 'darker'|'lighter';
|
||||
allowNone?: boolean;
|
||||
}
|
||||
|
||||
// PickerModel is a helper model that helps keep track of the server value for an observable that
|
||||
// needs to be changed locally without saving. To use, you must call `model.setValue(...)` instead
|
||||
// of `obs.set(...)`. Then it offers `model.needsSaving()` that tells you whether current value
|
||||
// needs saving, and `model.revert()` that reverts obs to the its server value.
|
||||
class PickerModel extends Disposable {
|
||||
private _serverValue = this.obs.get();
|
||||
class PickerModel<T extends boolean|string|undefined> extends Disposable {
|
||||
|
||||
// Is current value different from the server value?
|
||||
public needsSaving: Observable<boolean>;
|
||||
private _serverValue: Observable<T>;
|
||||
private _localChange: boolean = false;
|
||||
constructor(public obs: Observable<string|undefined>) {
|
||||
constructor(public obs: Observable<T>) {
|
||||
super();
|
||||
this._serverValue = Observable.create(this, this.obs.get());
|
||||
this.needsSaving = Computed.create(this, use => {
|
||||
const current = use(this.obs);
|
||||
const server = use(this._serverValue);
|
||||
// We support booleans and strings only for now, so if current is false and server
|
||||
// is undefined, we assume they are the same.
|
||||
// TODO: this probably should be a strategy method.
|
||||
return current !== (typeof current === 'boolean' ? (server ?? false) : server);
|
||||
});
|
||||
this.autoDispose(this.obs.addListener((val) => {
|
||||
if (this._localChange) { return; }
|
||||
this._serverValue = val;
|
||||
this._serverValue.set(val);
|
||||
}));
|
||||
}
|
||||
|
||||
// Set the value picked by the user
|
||||
public setValue(val: string|undefined) {
|
||||
public setValue(val: T) {
|
||||
this._localChange = true;
|
||||
this.obs.set(val);
|
||||
this._localChange = false;
|
||||
@@ -149,31 +214,50 @@ class PickerModel extends Disposable {
|
||||
|
||||
// Revert obs to its server value
|
||||
public revert() {
|
||||
this.obs.set(this._serverValue);
|
||||
}
|
||||
|
||||
// Is current value different from the server value?
|
||||
public needsSaving() {
|
||||
return this.obs.get() !== this._serverValue;
|
||||
this.obs.set(this._serverValue.get());
|
||||
}
|
||||
}
|
||||
|
||||
class ColorModel extends PickerModel<string|undefined> {}
|
||||
class BooleanModel extends PickerModel<boolean|undefined> {}
|
||||
|
||||
interface PickerComponentOptions {
|
||||
title: string;
|
||||
allowsNone: boolean;
|
||||
// Default color to show when value is empty or undefined (itself can be empty).
|
||||
defaultColor: string;
|
||||
// Text to be shown in the picker when color is not set.
|
||||
noneText: string;
|
||||
// Preview color to show when value is undefined.
|
||||
previewNoneColor: string;
|
||||
}
|
||||
class PickerComponent extends Disposable {
|
||||
|
||||
private _color = Computed.create(this, this._model.obs, (use, val) => (val || '').toUpperCase().slice(0, 7));
|
||||
private _mode = Observable.create<'darker'|'lighter'>(this, this._guessMode());
|
||||
private _color = Computed.create(this,
|
||||
this._model.obs,
|
||||
(use, val) => (val || this._options.defaultColor).toUpperCase().slice(0, 7));
|
||||
|
||||
constructor(private _model: PickerModel, private _options: PickerComponentOptions) {
|
||||
constructor(
|
||||
private _model: PickerModel<string|undefined>,
|
||||
private _options: PickerComponentOptions) {
|
||||
super();
|
||||
}
|
||||
|
||||
public buildDom() {
|
||||
const title = this._options.title;
|
||||
const colorText = Computed.create(null, use => use(this._color) || this._options.noneText);
|
||||
return [
|
||||
cssHeaderRow(
|
||||
cssHeaderRow(title),
|
||||
cssControlRow(
|
||||
cssColorPreview(
|
||||
dom.update(
|
||||
this._options.colorSquare,
|
||||
cssColorSquare(
|
||||
cssLightBorder.cls(''),
|
||||
dom.style('background-color', this._color),
|
||||
cssNoneIcon('Empty',
|
||||
dom.hide(use => Boolean(use(this._color)) === true)
|
||||
),
|
||||
),
|
||||
cssColorInput(
|
||||
{type: 'color'},
|
||||
dom.attr('value', this._color),
|
||||
@@ -182,52 +266,38 @@ class PickerComponent extends Disposable {
|
||||
),
|
||||
),
|
||||
cssHexBox(
|
||||
this._color,
|
||||
colorText,
|
||||
async (val) => {
|
||||
if ((this._options.allowNone && !val) || isValidHex(val)) {
|
||||
this._model.setValue(val);
|
||||
if (!val || isValidHex(val)) {
|
||||
this._model.setValue(val || undefined);
|
||||
}
|
||||
},
|
||||
dom.autoDispose(colorText),
|
||||
testId(`${title}-hex`),
|
||||
// select the hex value on click. Doing it using settimeout allows to avoid some
|
||||
// sporadically losing the selection just after the click.
|
||||
dom.on('click', (ev, elem) => setTimeout(() => elem.select(), 0)),
|
||||
)
|
||||
),
|
||||
title,
|
||||
cssBrickToggle(
|
||||
cssBrick(
|
||||
cssBrick.cls('-selected', (use) => use(this._mode) === 'darker'),
|
||||
dom.on('click', () => this._mode.set('darker')),
|
||||
testId(`${title}-darker-brick`),
|
||||
),
|
||||
cssBrick(
|
||||
cssBrick.cls('-lighter'),
|
||||
cssBrick.cls('-selected', (use) => use(this._mode) === 'lighter'),
|
||||
dom.on('click', () => this._mode.set('lighter')),
|
||||
testId(`${title}-lighter-brick`),
|
||||
),
|
||||
),
|
||||
cssEmptyBox(
|
||||
cssEmptyBox.cls('-selected', (use) => !use(this._color)),
|
||||
dom.on('click', () => this._setValue(undefined)),
|
||||
dom.hide(!this._options.allowsNone),
|
||||
cssNoneIcon('Empty'),
|
||||
testId(`${title}-empty`),
|
||||
)
|
||||
),
|
||||
cssPalette(
|
||||
dom.domComputed(this._mode, (mode) => (mode === 'lighter' ? lighter : darker).map(color => (
|
||||
swatches.map((color, index) => (
|
||||
cssColorSquare(
|
||||
dom.style('background-color', color),
|
||||
cssLightBorder.cls('', (use) => use(this._mode) === 'lighter'),
|
||||
cssLightBorder.cls('', isLight(index)),
|
||||
cssColorSquare.cls('-selected', (use) => use(this._color) === color),
|
||||
dom.style('outline-color', (use) => use(this._mode) === 'lighter' ? '' : color),
|
||||
dom.on('click', () => {
|
||||
// Clicking same color twice - removes the selection.
|
||||
if (this._model.obs.get() === color &&
|
||||
this._options.allowNone) {
|
||||
this._setValue(undefined);
|
||||
} else {
|
||||
this._setValue(color);
|
||||
}
|
||||
}),
|
||||
dom.style('outline-color', isLight(index) ? '' : color),
|
||||
dom.on('click', () => this._setValue(color)),
|
||||
testId(`color-${color}`),
|
||||
)
|
||||
))),
|
||||
)),
|
||||
testId(`${title}-palette`),
|
||||
),
|
||||
];
|
||||
@@ -236,18 +306,61 @@ class PickerComponent extends Disposable {
|
||||
private _setValue(val: string|undefined) {
|
||||
this._model.setValue(val);
|
||||
}
|
||||
}
|
||||
|
||||
private _guessMode(): 'darker'|'lighter' {
|
||||
if (lighter.indexOf(this._color.get()) > -1) {
|
||||
return 'lighter';
|
||||
class FontComponent extends Disposable {
|
||||
constructor(
|
||||
private _options: {
|
||||
fontBoldModel: BooleanModel,
|
||||
fontUnderlineModel: BooleanModel,
|
||||
fontItalicModel: BooleanModel,
|
||||
fontStrikethroughModel: BooleanModel,
|
||||
}
|
||||
if (darker.indexOf(this._color.get()) > -1) {
|
||||
return 'darker';
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
public buildDom() {
|
||||
function option(iconName: IconName, model: BooleanModel) {
|
||||
return cssFontOption(
|
||||
cssFontIcon(iconName),
|
||||
dom.on('click', () => model.setValue(!model.obs.get())),
|
||||
cssFontOption.cls('-selected', use => use(model.obs) ?? false),
|
||||
testId(`font-option-${iconName}`)
|
||||
);
|
||||
}
|
||||
return this._options.defaultMode;
|
||||
return cssFontOptions(
|
||||
option('FontBold', this._options.fontBoldModel),
|
||||
option('FontUnderline', this._options.fontUnderlineModel),
|
||||
option('FontItalic', this._options.fontItalicModel),
|
||||
option('FontStrikethrough', this._options.fontStrikethroughModel),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const cssFontOptions = styled('div', `
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
background: ${colors.darkGrey};
|
||||
border: 1px solid ${colors.darkGrey};
|
||||
`);
|
||||
|
||||
const cssFontOption = styled('div', `
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-grow: 1;
|
||||
background: white;
|
||||
height: 24px;
|
||||
cursor: pointer;
|
||||
&:hover:not(&-selected) {
|
||||
background: ${colors.lightGrey};
|
||||
}
|
||||
&-selected {
|
||||
background: ${colors.dark};
|
||||
--icon-color: ${colors.light}
|
||||
}
|
||||
`);
|
||||
|
||||
const cssColorInput = styled('input', `
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
@@ -259,39 +372,25 @@ const cssColorInput = styled('input', `
|
||||
border: none;
|
||||
`);
|
||||
|
||||
const cssBrickToggle = styled('div', `
|
||||
display: flex;
|
||||
`);
|
||||
|
||||
const cssBrick = styled('div', `
|
||||
height: 20px;
|
||||
width: 34px;
|
||||
background-color: #414141;
|
||||
box-shadow: inset 0 0 0 2px #FFFFFF;
|
||||
&-selected {
|
||||
border: 1px solid #414141;
|
||||
box-shadow: inset 0 0 0 1px #FFFFFF;
|
||||
}
|
||||
&-lighter {
|
||||
background-color: #DCDCDC;
|
||||
}
|
||||
`);
|
||||
|
||||
const cssColorPreview = styled('div', `
|
||||
display: flex;
|
||||
`);
|
||||
|
||||
const cssHeaderRow = styled('div', `
|
||||
const cssControlRow = styled('div', `
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
text-transform: capitalize;
|
||||
`);
|
||||
|
||||
const cssHeaderRow = styled('div', `
|
||||
text-transform: uppercase;
|
||||
font-size: ${vars.smallFontSize};
|
||||
margin-bottom: 12px;
|
||||
`);
|
||||
|
||||
const cssPalette = styled('div', `
|
||||
width: 236px;
|
||||
height: 68px;
|
||||
height: calc(4 * 20px + 3 * 4px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-wrap: wrap;
|
||||
@@ -300,7 +399,7 @@ const cssPalette = styled('div', `
|
||||
`);
|
||||
|
||||
const cssVSpacer = styled('div', `
|
||||
height: 12px;
|
||||
height: 24px;
|
||||
`);
|
||||
|
||||
const cssContainer = styled('div', `
|
||||
@@ -320,7 +419,7 @@ const cssContent = styled('div', `
|
||||
`);
|
||||
|
||||
const cssHexBox = styled(textInput, `
|
||||
border: 1px solid #D9D9D9;
|
||||
border: 1px solid ${colors.darkGrey};
|
||||
border-left: none;
|
||||
font-size: ${vars.smallFontSize};
|
||||
display: flex;
|
||||
@@ -350,17 +449,42 @@ const cssColorSquare = styled('div', `
|
||||
}
|
||||
`);
|
||||
|
||||
const cssEmptyBox = styled(cssColorSquare, `
|
||||
--icon-color: ${colors.error};
|
||||
border: 1px solid #D9D9D9;
|
||||
&-selected {
|
||||
outline: 1px solid ${colors.dark};
|
||||
outline-offset: 1px;
|
||||
}
|
||||
`);
|
||||
|
||||
const cssFontIcon = styled(icon, `
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
`);
|
||||
|
||||
const cssNoneIcon = styled(icon, `
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
--icon-color: ${colors.error}
|
||||
`);
|
||||
|
||||
const cssButtonIcon = styled(cssColorSquare, `
|
||||
margin-right: 6px;
|
||||
margin-left: 4px;
|
||||
`);
|
||||
|
||||
const cssIconBtn = styled('div', `
|
||||
const cssIconBtn = styled(cssColorSquare, `
|
||||
min-width: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
`);
|
||||
|
||||
const cssButtonRow = styled('div', `
|
||||
gap: 8px;
|
||||
display: flex;
|
||||
margin-top: 24px;
|
||||
`);
|
||||
|
||||
@@ -51,6 +51,7 @@ export type IconName = "ChartArea" |
|
||||
"DragDrop" |
|
||||
"Dropdown" |
|
||||
"DropdownUp" |
|
||||
"Empty" |
|
||||
"Expand" |
|
||||
"EyeHide" |
|
||||
"EyeShow" |
|
||||
@@ -58,6 +59,10 @@ export type IconName = "ChartArea" |
|
||||
"Filter" |
|
||||
"FilterSimple" |
|
||||
"Folder" |
|
||||
"FontBold" |
|
||||
"FontItalic" |
|
||||
"FontStrikethrough" |
|
||||
"FontUnderline" |
|
||||
"FunctionResult" |
|
||||
"Help" |
|
||||
"Home" |
|
||||
@@ -168,6 +173,7 @@ export const IconList: IconName[] = ["ChartArea",
|
||||
"DragDrop",
|
||||
"Dropdown",
|
||||
"DropdownUp",
|
||||
"Empty",
|
||||
"Expand",
|
||||
"EyeHide",
|
||||
"EyeShow",
|
||||
@@ -175,6 +181,10 @@ export const IconList: IconName[] = ["ChartArea",
|
||||
"Filter",
|
||||
"FilterSimple",
|
||||
"Folder",
|
||||
"FontBold",
|
||||
"FontItalic",
|
||||
"FontStrikethrough",
|
||||
"FontUnderline",
|
||||
"FunctionResult",
|
||||
"Help",
|
||||
"Home",
|
||||
|
||||
@@ -143,8 +143,27 @@ const cssInputFonts = `
|
||||
}
|
||||
`;
|
||||
|
||||
// Font style classes used by style selector.
|
||||
const cssFontStyles = `
|
||||
.font-italic {
|
||||
font-style: italic;
|
||||
}
|
||||
.font-bold {
|
||||
font-weight: 800;
|
||||
}
|
||||
.font-underline {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.font-strikethrough {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
.font-strikethrough.font-underline {
|
||||
text-decoration: line-through underline;
|
||||
}
|
||||
`;
|
||||
|
||||
const cssVarsOnly = styled('div', cssColors + cssVars);
|
||||
const cssBodyVars = styled('div', cssFontParams + cssColors + cssVars + cssBorderBox + cssInputFonts);
|
||||
const cssBodyVars = styled('div', cssFontParams + cssColors + cssVars + cssBorderBox + cssInputFonts + cssFontStyles);
|
||||
|
||||
const cssBody = styled('body', `
|
||||
margin: 0;
|
||||
|
||||
@@ -14,6 +14,7 @@ function AbstractWidget(field, opts = {}) {
|
||||
const {defaultTextColor = '#000000'} = opts;
|
||||
this.defaultTextColor = defaultTextColor;
|
||||
this.valueFormatter = this.field.visibleColFormatter;
|
||||
this.defaultTextColor = opts.defaultTextColor || '#000000';
|
||||
}
|
||||
dispose.makeDisposable(AbstractWidget);
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import {Style} from 'app/client/models/Styles';
|
||||
import {cssFieldFormula} from 'app/client/ui/FieldConfig';
|
||||
import {cssIcon, cssLabel, cssRow} from 'app/client/ui/RightPanel';
|
||||
import {textButton} from 'app/client/ui2018/buttons';
|
||||
import {colorSelect} from 'app/client/ui2018/ColorSelect';
|
||||
import {ColorOption, colorSelect} from 'app/client/ui2018/ColorSelect';
|
||||
import {colors} from 'app/client/ui2018/cssVars';
|
||||
import {setupEditorCleanup} from 'app/client/widgets/FieldEditor';
|
||||
import {cssError, openFormulaEditor} from 'app/client/widgets/FormulaEditor';
|
||||
@@ -18,8 +18,12 @@ import debounce = require('lodash/debounce');
|
||||
const testId = makeTestId('test-widget-style-');
|
||||
|
||||
export class CellStyle extends Disposable {
|
||||
protected textColor: Observable<string>;
|
||||
protected fillColor: Observable<string>;
|
||||
protected textColor: Observable<string|undefined>;
|
||||
protected fillColor: Observable<string|undefined>;
|
||||
protected fontBold: Observable<boolean|undefined>;
|
||||
protected fontUnderline: Observable<boolean|undefined>;
|
||||
protected fontItalic: Observable<boolean|undefined>;
|
||||
protected fontStrikethrough: Observable<boolean|undefined>;
|
||||
// Holds data from currently selected record (holds data only when this field has conditional styles).
|
||||
protected currentRecord: Computed<RowRecord | undefined>;
|
||||
// Helper field for refreshing current record data.
|
||||
@@ -28,14 +32,15 @@ export class CellStyle extends Disposable {
|
||||
constructor(
|
||||
protected field: ViewFieldRec,
|
||||
protected gristDoc: GristDoc,
|
||||
defaultTextColor: string = '#000000'
|
||||
protected defaultTextColor: string
|
||||
) {
|
||||
super();
|
||||
this.textColor = Computed.create(
|
||||
this,
|
||||
use => use(this.field.textColor) || defaultTextColor
|
||||
).onWrite(val => this.field.textColor(val === defaultTextColor ? '' : val));
|
||||
this.textColor = fromKo(this.field.textColor);
|
||||
this.fillColor = fromKo(this.field.fillColor);
|
||||
this.fontBold = fromKo(this.field.fontBold);
|
||||
this.fontUnderline = fromKo(this.field.fontUnderline);
|
||||
this.fontItalic = fromKo(this.field.fontItalic);
|
||||
this.fontStrikethrough = fromKo(this.field.fontStrikethrough);
|
||||
this.currentRecord = Computed.create(this, use => {
|
||||
if (!use(this.field.hasRules)) {
|
||||
return;
|
||||
@@ -75,8 +80,14 @@ export class CellStyle extends Disposable {
|
||||
cssLabel('CELL STYLE', dom.autoDispose(holder)),
|
||||
cssRow(
|
||||
colorSelect(
|
||||
this.textColor,
|
||||
this.fillColor,
|
||||
{
|
||||
textColor: new ColorOption(this.textColor, false, this.defaultTextColor),
|
||||
fillColor: new ColorOption(this.fillColor, true, '', 'none', '#FFFFFF'),
|
||||
fontBold: this.fontBold,
|
||||
fontItalic: this.fontItalic,
|
||||
fontUnderline: this.fontUnderline,
|
||||
fontStrikethrough: this.fontStrikethrough
|
||||
},
|
||||
// Calling `field.widgetOptionsJson.save()` saves both fill and text color settings.
|
||||
() => this.field.widgetOptionsJson.save()
|
||||
)
|
||||
@@ -98,6 +109,10 @@ export class CellStyle extends Disposable {
|
||||
...rules.map((column, ruleIndex) => {
|
||||
const textColor = this._buildStyleOption(owner, ruleIndex, 'textColor');
|
||||
const fillColor = this._buildStyleOption(owner, ruleIndex, 'fillColor');
|
||||
const fontBold = this._buildStyleOption(owner, ruleIndex, 'fontBold');
|
||||
const fontItalic = this._buildStyleOption(owner, ruleIndex, 'fontItalic');
|
||||
const fontUnderline = this._buildStyleOption(owner, ruleIndex, 'fontUnderline');
|
||||
const fontStrikethrough = this._buildStyleOption(owner, ruleIndex, 'fontStrikethrough');
|
||||
const save = async () => {
|
||||
// This will save both options.
|
||||
await this.field.rulesStyles.save();
|
||||
@@ -131,7 +146,18 @@ export class CellStyle extends Disposable {
|
||||
dom.show(hasError),
|
||||
testId(`rule-error-${ruleIndex}`),
|
||||
),
|
||||
colorSelect(textColor, fillColor, save, true)
|
||||
colorSelect(
|
||||
{
|
||||
textColor: new ColorOption(textColor, true, '', 'default'),
|
||||
fillColor: new ColorOption(fillColor, true, '', 'none'),
|
||||
fontBold,
|
||||
fontItalic,
|
||||
fontUnderline,
|
||||
fontStrikethrough
|
||||
},
|
||||
save,
|
||||
'Cell style'
|
||||
)
|
||||
),
|
||||
cssRemoveButton(
|
||||
'Remove',
|
||||
@@ -152,12 +178,12 @@ export class CellStyle extends Disposable {
|
||||
];
|
||||
}
|
||||
|
||||
private _buildStyleOption(owner: Disposable, index: number, option: keyof Style) {
|
||||
private _buildStyleOption<T extends keyof Style>(owner: Disposable, index: number, option: T) {
|
||||
const obs = Computed.create(owner, use => use(this.field.rulesStyles)[index]?.[option]);
|
||||
obs.onWrite(value => {
|
||||
const list = Array.from(this.field.rulesStyles.peek() ?? []);
|
||||
list[index] = list[index] ?? {};
|
||||
list[index][option] = value;
|
||||
list[index][option] = value as any;
|
||||
this.field.rulesStyles(list);
|
||||
});
|
||||
return obs;
|
||||
|
||||
@@ -12,7 +12,7 @@ import {csvEncodeRow} from 'app/common/csvFormat';
|
||||
import {CellValue} from "app/common/DocActions";
|
||||
import {decodeObject, encodeObject} from 'app/plugin/objtypes';
|
||||
import {dom, styled} from 'grainjs';
|
||||
import {ChoiceOptions, getFillColor, getTextColor} from 'app/client/widgets/ChoiceTextBox';
|
||||
import {ChoiceOptions, getRenderFillColor, getRenderTextColor} from 'app/client/widgets/ChoiceTextBox';
|
||||
import {choiceToken, cssChoiceACItem} from 'app/client/widgets/ChoiceToken';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
|
||||
@@ -73,8 +73,12 @@ export class ChoiceListEditor extends NewBaseEditor {
|
||||
initialValue: startTokens,
|
||||
renderToken: item => [
|
||||
item.label,
|
||||
dom.style('background-color', getFillColor(this._choiceOptionsByName[item.label])),
|
||||
dom.style('color', getTextColor(this._choiceOptionsByName[item.label])),
|
||||
dom.style('background-color', getRenderFillColor(this._choiceOptionsByName[item.label])),
|
||||
dom.style('color', getRenderTextColor(this._choiceOptionsByName[item.label])),
|
||||
dom.cls('font-bold', this._choiceOptionsByName[item.label]?.fontBold ?? false),
|
||||
dom.cls('font-underline', this._choiceOptionsByName[item.label]?.fontUnderline ?? false),
|
||||
dom.cls('font-italic', this._choiceOptionsByName[item.label]?.fontItalic ?? false),
|
||||
dom.cls('font-strikethrough', this._choiceOptionsByName[item.label]?.fontStrikethrough ?? false),
|
||||
cssInvalidToken.cls('-invalid', item.isInvalid)
|
||||
],
|
||||
createToken: label => new ChoiceItem(label, !choiceSet.has(label)),
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import {IToken, TokenField} from 'app/client/lib/TokenField';
|
||||
import {cssBlockedCursor} from 'app/client/ui/RightPanel';
|
||||
import {basicButton, primaryButton} from 'app/client/ui2018/buttons';
|
||||
import {colorButton} from 'app/client/ui2018/ColorSelect';
|
||||
import {colorButton, ColorOption} from 'app/client/ui2018/ColorSelect';
|
||||
import {colors, testId} from 'app/client/ui2018/cssVars';
|
||||
import {editableLabel} from 'app/client/ui2018/editableLabel';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {ChoiceOptionsByName, IChoiceOptions} from 'app/client/widgets/ChoiceTextBox';
|
||||
import {DEFAULT_TEXT_COLOR} from 'app/client/widgets/ChoiceToken';
|
||||
import {Computed, Disposable, dom, DomContents, DomElementArg, Holder, Observable, styled} from 'grainjs';
|
||||
import {createCheckers, iface, ITypeSuite, opt} from 'ts-interface-checker';
|
||||
import {createCheckers, iface, ITypeSuite, opt, union} from 'ts-interface-checker';
|
||||
import isEqual = require('lodash/isEqual');
|
||||
import uniqBy = require('lodash/uniqBy');
|
||||
|
||||
@@ -33,7 +32,7 @@ class ChoiceItem implements IToken {
|
||||
public label: string,
|
||||
// We will keep the previous label value for a token, to tell us which token
|
||||
// was renamed. For new tokens this should be null.
|
||||
public readonly previousLabel: string | null,
|
||||
public previousLabel: string | null,
|
||||
public options?: IChoiceOptions
|
||||
) {}
|
||||
|
||||
@@ -41,19 +40,24 @@ class ChoiceItem implements IToken {
|
||||
return new ChoiceItem(label, this.previousLabel, this.options);
|
||||
}
|
||||
|
||||
public changeColors(options: IChoiceOptions) {
|
||||
public changeStyle(options: IChoiceOptions) {
|
||||
return new ChoiceItem(this.label, this.previousLabel, {...this.options, ...options});
|
||||
}
|
||||
}
|
||||
|
||||
const ChoiceItemType = iface([], {
|
||||
label: "string",
|
||||
previousLabel: union("string", "null"),
|
||||
options: opt("ChoiceOptionsType"),
|
||||
});
|
||||
|
||||
const ChoiceOptionsType = iface([], {
|
||||
textColor: "string",
|
||||
fillColor: "string",
|
||||
textColor: opt("string"),
|
||||
fillColor: opt("string"),
|
||||
fontBold: opt("boolean"),
|
||||
fontUnderline: opt("boolean"),
|
||||
fontItalic: opt("boolean"),
|
||||
fontStrikethrough: opt("boolean"),
|
||||
});
|
||||
|
||||
const choiceTypes: ITypeSuite = {
|
||||
@@ -63,8 +67,6 @@ const choiceTypes: ITypeSuite = {
|
||||
|
||||
const {ChoiceItemType: ChoiceItemChecker} = createCheckers(choiceTypes);
|
||||
|
||||
const UNSET_COLOR = '#ffffff';
|
||||
|
||||
/**
|
||||
* ChoiceListEntry - Editor for choices and choice colors.
|
||||
*
|
||||
@@ -166,17 +168,29 @@ export class ChoiceListEntry extends Disposable {
|
||||
dom.forEach(someValues, val => {
|
||||
return row(
|
||||
cssTokenColorInactive(
|
||||
dom.style('background-color', getFillColor(choiceOptions.get(val))),
|
||||
dom.style('background-color', getFillColor(choiceOptions.get(val)) || '#FFFFFF'),
|
||||
dom.style('color', getTextColor(choiceOptions.get(val)) || '#000000'),
|
||||
dom.cls('font-bold', choiceOptions.get(val)?.fontBold ?? false),
|
||||
dom.cls('font-underline', choiceOptions.get(val)?.fontUnderline ?? false),
|
||||
dom.cls('font-italic', choiceOptions.get(val)?.fontItalic ?? false),
|
||||
dom.cls('font-strikethrough', choiceOptions.get(val)?.fontStrikethrough ?? false),
|
||||
'T',
|
||||
testId('choice-list-entry-color')
|
||||
),
|
||||
cssTokenLabel(val)
|
||||
cssTokenLabel(
|
||||
val,
|
||||
testId('choice-list-entry-label')
|
||||
)
|
||||
);
|
||||
}),
|
||||
),
|
||||
// Show description row for any remaining rows
|
||||
dom.maybe(use => use(this._values).length > maxRows, () =>
|
||||
row(
|
||||
dom.text((use) => `+${use(this._values).length - (maxRows - 1)} more`)
|
||||
dom('span',
|
||||
testId('choice-list-entry-label'),
|
||||
dom.text((use) => `+${use(this._values).length - (maxRows - 1)} more`)
|
||||
)
|
||||
)
|
||||
),
|
||||
dom.on('click', () => this._startEditing()),
|
||||
@@ -215,12 +229,15 @@ export class ChoiceListEntry extends Disposable {
|
||||
const newTokens = uniqBy(tokens, t => t.label);
|
||||
const newValues = newTokens.map(t => t.label);
|
||||
const newOptions: ChoiceOptionsByName = new Map();
|
||||
const keys: Array<keyof IChoiceOptions> = [
|
||||
'fillColor', 'textColor', 'fontBold', 'fontItalic', 'fontStrikethrough', 'fontUnderline'
|
||||
];
|
||||
for (const t of newTokens) {
|
||||
if (t.options) {
|
||||
newOptions.set(t.label, {
|
||||
fillColor: t.options.fillColor,
|
||||
textColor: t.options.textColor
|
||||
});
|
||||
const options: IChoiceOptions = {};
|
||||
keys.filter(k => t.options![k] !== undefined)
|
||||
.forEach(k => options[k] = t.options![k] as any);
|
||||
newOptions.set(t.label, options);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,6 +262,10 @@ export class ChoiceListEntry extends Disposable {
|
||||
private _renderToken(token: ChoiceItem) {
|
||||
const fillColorObs = Observable.create(null, getFillColor(token.options));
|
||||
const textColorObs = Observable.create(null, getTextColor(token.options));
|
||||
const fontBoldObs = Observable.create(null, token.options?.fontBold);
|
||||
const fontItalicObs = Observable.create(null, token.options?.fontItalic);
|
||||
const fontUnderlineObs = Observable.create(null, token.options?.fontUnderline);
|
||||
const fontStrikethroughObs = Observable.create(null, token.options?.fontStrikethrough);
|
||||
const choiceText = Observable.create(null, token.label);
|
||||
|
||||
const rename = async (to: string) => {
|
||||
@@ -275,15 +296,32 @@ export class ChoiceListEntry extends Disposable {
|
||||
dom.autoDispose(fillColorObs),
|
||||
dom.autoDispose(textColorObs),
|
||||
dom.autoDispose(choiceText),
|
||||
colorButton(textColorObs,
|
||||
fillColorObs,
|
||||
colorButton({
|
||||
textColor: new ColorOption(textColorObs, false, '#000000'),
|
||||
fillColor: new ColorOption(fillColorObs, true, '', 'none', '#FFFFFF'),
|
||||
fontBold: fontBoldObs,
|
||||
fontItalic: fontItalicObs,
|
||||
fontUnderline: fontUnderlineObs,
|
||||
fontStrikethrough: fontStrikethroughObs
|
||||
},
|
||||
async () => {
|
||||
const tokenField = this._tokenFieldHolder.get();
|
||||
if (!tokenField) { return; }
|
||||
|
||||
const fillColor = fillColorObs.get();
|
||||
const textColor = textColorObs.get();
|
||||
tokenField.replaceToken(token.label, ChoiceItem.from(token).changeColors({fillColor, textColor}));
|
||||
const fontBold = fontBoldObs.get();
|
||||
const fontItalic = fontItalicObs.get();
|
||||
const fontUnderline = fontUnderlineObs.get();
|
||||
const fontStrikethrough = fontStrikethroughObs.get();
|
||||
tokenField.replaceToken(token.label, ChoiceItem.from(token).changeStyle({
|
||||
fillColor,
|
||||
textColor,
|
||||
fontBold,
|
||||
fontItalic,
|
||||
fontUnderline,
|
||||
fontStrikethrough,
|
||||
}));
|
||||
}
|
||||
),
|
||||
editableLabel(choiceText,
|
||||
@@ -320,11 +358,11 @@ function row(...domArgs: DomElementArg[]): Element {
|
||||
}
|
||||
|
||||
function getTextColor(choiceOptions?: IChoiceOptions) {
|
||||
return choiceOptions?.textColor ?? DEFAULT_TEXT_COLOR;
|
||||
return choiceOptions?.textColor;
|
||||
}
|
||||
|
||||
function getFillColor(choiceOptions?: IChoiceOptions) {
|
||||
return choiceOptions?.fillColor ?? UNSET_COLOR;
|
||||
return choiceOptions?.fillColor;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -336,8 +374,9 @@ function getFillColor(choiceOptions?: IChoiceOptions) {
|
||||
function clipboardToChoices(clipboard: DataTransfer): ChoiceItem[] {
|
||||
const maybeTokens = clipboard.getData('application/json');
|
||||
if (maybeTokens && isJSON(maybeTokens)) {
|
||||
const tokens = JSON.parse(maybeTokens);
|
||||
const tokens: ChoiceItem[] = JSON.parse(maybeTokens);
|
||||
if (Array.isArray(tokens) && tokens.every((t): t is ChoiceItem => ChoiceItemChecker.test(t))) {
|
||||
tokens.forEach(t => t.previousLabel = null);
|
||||
return tokens;
|
||||
}
|
||||
}
|
||||
@@ -424,6 +463,8 @@ const cssTokenColorInactive = styled('div', `
|
||||
flex-shrink: 0;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
`);
|
||||
|
||||
const cssTokenLabel = styled('span', `
|
||||
|
||||
@@ -1,26 +1,23 @@
|
||||
import {ChoiceListEntry} from 'app/client/widgets/ChoiceListEntry';
|
||||
import {DataRowModel} from 'app/client/models/DataRowModel';
|
||||
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
|
||||
import {KoSaveableObservable} from 'app/client/models/modelUtil';
|
||||
import {Style} from 'app/client/models/Styles';
|
||||
import {cssLabel, cssRow} from 'app/client/ui/RightPanel';
|
||||
import {testId} from 'app/client/ui2018/cssVars';
|
||||
import {ChoiceListEntry} from 'app/client/widgets/ChoiceListEntry';
|
||||
import {choiceToken, DEFAULT_FILL_COLOR, DEFAULT_TEXT_COLOR} from 'app/client/widgets/ChoiceToken';
|
||||
import {NTextBox} from 'app/client/widgets/NTextBox';
|
||||
import {Computed, dom, fromKo, styled} from 'grainjs';
|
||||
import {choiceToken, DEFAULT_FILL_COLOR, DEFAULT_TEXT_COLOR} from 'app/client/widgets/ChoiceToken';
|
||||
|
||||
export interface IChoiceOptions {
|
||||
textColor: string;
|
||||
fillColor: string;
|
||||
}
|
||||
|
||||
export type IChoiceOptions = Style
|
||||
export type ChoiceOptions = Record<string, IChoiceOptions | undefined>;
|
||||
export type ChoiceOptionsByName = Map<string, IChoiceOptions | undefined>;
|
||||
|
||||
export function getFillColor(choiceOptions?: IChoiceOptions) {
|
||||
export function getRenderFillColor(choiceOptions?: IChoiceOptions) {
|
||||
return choiceOptions?.fillColor ?? DEFAULT_FILL_COLOR;
|
||||
}
|
||||
|
||||
export function getTextColor(choiceOptions?: IChoiceOptions) {
|
||||
export function getRenderTextColor(choiceOptions?: IChoiceOptions) {
|
||||
return choiceOptions?.textColor ?? DEFAULT_TEXT_COLOR;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import {dom, DomContents, DomElementArg, styled} from "grainjs";
|
||||
import {colors, vars} from "app/client/ui2018/cssVars";
|
||||
import {Style} from 'app/client/models/Styles';
|
||||
|
||||
export const DEFAULT_FILL_COLOR = colors.mediumGreyOpaque.value;
|
||||
export const DEFAULT_TEXT_COLOR = '#000000';
|
||||
|
||||
export interface IChoiceTokenOptions {
|
||||
fillColor?: string;
|
||||
textColor?: string;
|
||||
}
|
||||
export type IChoiceTokenOptions = Style;
|
||||
|
||||
/**
|
||||
* Creates a colored token representing a choice (e.g. Choice and Choice List values).
|
||||
@@ -25,13 +23,17 @@ export interface IChoiceTokenOptions {
|
||||
*/
|
||||
export function choiceToken(
|
||||
label: DomElementArg,
|
||||
{fillColor, textColor}: IChoiceTokenOptions,
|
||||
{fillColor, textColor, fontBold, fontItalic, fontUnderline, fontStrikethrough}: IChoiceTokenOptions,
|
||||
...args: DomElementArg[]
|
||||
): DomContents {
|
||||
return cssChoiceToken(
|
||||
label,
|
||||
dom.style('background-color', fillColor ?? DEFAULT_FILL_COLOR),
|
||||
dom.style('color', textColor ?? DEFAULT_TEXT_COLOR),
|
||||
dom.cls('font-bold', fontBold ?? false),
|
||||
dom.cls('font-underline', fontUnderline ?? false),
|
||||
dom.cls('font-italic', fontItalic ?? false),
|
||||
dom.cls('font-strikethrough', fontStrikethrough ?? false),
|
||||
...args
|
||||
);
|
||||
}
|
||||
|
||||
@@ -54,6 +54,22 @@ function getTypeDefinition(type: string | false) {
|
||||
return UserType.typeDefs[type] || UserType.typeDefs.Text;
|
||||
}
|
||||
|
||||
type ComputedStyle = {style?: Style; error?: true} | null | undefined;
|
||||
/**
|
||||
* Builds a font option computed property.
|
||||
*/
|
||||
function buildFontOptions(
|
||||
builder: FieldBuilder,
|
||||
computedRule: ko.Computed<ComputedStyle>,
|
||||
optionName: keyof Style) {
|
||||
return koUtil.withKoUtils(ko.computed(function() {
|
||||
if (builder.isDisposed()) { return false; }
|
||||
const style = computedRule()?.style;
|
||||
const styleFlag = style?.[optionName] || this.field[optionName]();
|
||||
return styleFlag;
|
||||
}, builder)).onlyNotifyUnequal();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an instance of FieldBuilder. Used to create all column configuration DOMs, cell DOMs,
|
||||
* and cell editor DOMs for all Grist Types.
|
||||
@@ -427,7 +443,7 @@ export class FieldBuilder extends Disposable {
|
||||
// rules there is a brief moment that rule is still not evaluated
|
||||
// (rules.length != value.length), in this case return last value
|
||||
// and wait for the update.
|
||||
const computedRule = koUtil.withKoUtils(ko.pureComputed(() => {
|
||||
const computedRule = koUtil.withKoUtils(ko.pureComputed<ComputedStyle>(() => {
|
||||
if (this.isDisposed()) { return null; }
|
||||
const styles: Style[] = this.field.rulesStyles();
|
||||
// Make sure that rules where computed.
|
||||
@@ -469,12 +485,21 @@ export class FieldBuilder extends Disposable {
|
||||
return fromRules || this.field.textColor() || '';
|
||||
}, this)).onlyNotifyUnequal();
|
||||
|
||||
const background = koUtil.withKoUtils(ko.computed(function() {
|
||||
const fillColor = koUtil.withKoUtils(ko.computed(function() {
|
||||
if (this.isDisposed()) { return null; }
|
||||
const fromRules = computedRule()?.style?.fillColor;
|
||||
return fromRules || this.field.fillColor();
|
||||
let fill = fromRules || this.field.fillColor();
|
||||
// If user set white color - remove it to play nice with zebra strips.
|
||||
// If there is no color we are using fully transparent white color (for tests mainly).
|
||||
fill = fill ? fill.toUpperCase() : fill;
|
||||
return (fill === '#FFFFFF' ? '' : fill) || '#FFFFFF00';
|
||||
}, this)).onlyNotifyUnequal();
|
||||
|
||||
const fontBold = buildFontOptions(this, computedRule, 'fontBold');
|
||||
const fontItalic = buildFontOptions(this, computedRule, 'fontItalic');
|
||||
const fontUnderline = buildFontOptions(this, computedRule, 'fontUnderline');
|
||||
const fontStrikethrough = buildFontOptions(this, computedRule, 'fontStrikethrough');
|
||||
|
||||
const errorInStyle = ko.pureComputed(() => Boolean(computedRule()?.error));
|
||||
|
||||
return (elem: Element) => {
|
||||
@@ -485,7 +510,11 @@ export class FieldBuilder extends Disposable {
|
||||
dom.autoDispose(errorInStyle),
|
||||
dom.autoDispose(textColor),
|
||||
dom.autoDispose(computedRule),
|
||||
dom.autoDispose(background),
|
||||
dom.autoDispose(fillColor),
|
||||
dom.autoDispose(fontBold),
|
||||
dom.autoDispose(fontItalic),
|
||||
dom.autoDispose(fontUnderline),
|
||||
dom.autoDispose(fontStrikethrough),
|
||||
this._options.isPreview ? null : kd.cssClass(this.field.formulaCssClass),
|
||||
kd.toggleClass("readonly", toKo(ko, this._readonly)),
|
||||
kd.maybe(isSelected, () => dom('div.selected_cursor',
|
||||
@@ -496,8 +525,12 @@ export class FieldBuilder extends Disposable {
|
||||
const cellDom = widget ? widget.buildDom(row) : buildErrorDom(row, this.field);
|
||||
return dom(cellDom, kd.toggleClass('has_cursor', isActive),
|
||||
kd.toggleClass('field-error-from-style', errorInStyle),
|
||||
kd.toggleClass('font-bold', fontBold),
|
||||
kd.toggleClass('font-underline', fontUnderline),
|
||||
kd.toggleClass('font-italic', fontItalic),
|
||||
kd.toggleClass('font-strikethrough', fontStrikethrough),
|
||||
kd.style('--grist-cell-color', textColor),
|
||||
kd.style('--grist-cell-background-color', background));
|
||||
kd.style('--grist-cell-background-color', fillColor));
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
@@ -42,10 +42,9 @@ export abstract class NewAbstractWidget extends Disposable {
|
||||
|
||||
constructor(protected field: ViewFieldRec, opts: Options = {}) {
|
||||
super();
|
||||
const {defaultTextColor = '#000000'} = opts;
|
||||
this.defaultTextColor = defaultTextColor;
|
||||
this.options = field.widgetOptionsJson;
|
||||
this.valueFormatter = fromKo(field.formatter);
|
||||
this.defaultTextColor = opts?.defaultTextColor || '#000000';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -346,3 +346,5 @@ export function isValidRuleValue(value: CellValue|undefined) {
|
||||
// indicate other number in the future.
|
||||
return value === null || typeof value === 'boolean';
|
||||
}
|
||||
|
||||
export type RefListValue = [GristObjCode.List, ...number[]]|null;
|
||||
|
||||
Reference in New Issue
Block a user