diff --git a/app/client/components/ColumnTransform.ts b/app/client/components/ColumnTransform.ts index 69f798c9..315e9202 100644 --- a/app/client/components/ColumnTransform.ts +++ b/app/client/components/ColumnTransform.ts @@ -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()), ], ]; } diff --git a/app/client/components/TypeConversion.ts b/app/client/components/TypeConversion.ts index 921d3a9b..16a4a68d 100644 --- a/app/client/components/TypeConversion.ts +++ b/app/client/components/TypeConversion.ts @@ -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; } diff --git a/app/client/components/TypeTransform.ts b/app/client/components/TypeTransform.ts index 3941897d..95cf1173 100644 --- a/app/client/components/TypeTransform.ts +++ b/app/client/components/TypeTransform.ts @@ -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; diff --git a/app/client/models/DocModel.ts b/app/client/models/DocModel.ts index fbe51a0c..d7a85a4b 100644 --- a/app/client/models/DocModel.ts +++ b/app/client/models/DocModel.ts @@ -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( 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. diff --git a/app/client/models/Styles.ts b/app/client/models/Styles.ts index 348ebaf5..36507424 100644 --- a/app/client/models/Styles.ts +++ b/app/client/models/Styles.ts @@ -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; } } } diff --git a/app/client/models/entities/ViewFieldRec.ts b/app/client/models/entities/ViewFieldRec.ts index ba2bbe12..f9c57f7c 100644 --- a/app/client/models/entities/ViewFieldRec.ts +++ b/app/client/models/entities/ViewFieldRec.ts @@ -67,10 +67,11 @@ export interface ViewFieldRec extends IRowModel<"_grist_Views_section_field"> { disableEditData: ko.Computed; textColor: modelUtil.KoSaveableObservable; - fillColor: modelUtil.KoSaveableObservable; - - computedColor: ko.Computed; - computedFill: ko.Computed; + fillColor: modelUtil.KoSaveableObservable; + fontBold: modelUtil.KoSaveableObservable; + fontUnderline: modelUtil.KoSaveableObservable; + fontItalic: modelUtil.KoSaveableObservable; + fontStrikethrough: modelUtil.KoSaveableObservable; documentSettings: ko.PureComputed; @@ -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; - - const fillColorProp = modelUtil.fieldWithDefault( - this.widgetOptionsJson.prop('fillColor') as modelUtil.KoSaveableObservable, "#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()); diff --git a/app/client/ui/ColumnFilterMenu.ts b/app/client/ui/ColumnFilterMenu.ts index 189ec2a2..bb2bdae2 100644 --- a/app/client/ui/ColumnFilterMenu.ts +++ b/app/client/ui/ColumnFilterMenu.ts @@ -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)), diff --git a/app/client/ui2018/ColorPalette.ts b/app/client/ui2018/ColorPalette.ts index eb869083..773e4202 100644 --- a/app/client/ui2018/ColorPalette.ts +++ b/app/client/ui2018/ColorPalette.ts @@ -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", - "#FECBCC", - "#FD8182", - "#FC363B", - "#F3E1D2", - "#D6A77F", - "#C37739", - "#FEE7C3", - "#FECC81", - "#FDA630", - "#FFFACD", - "#FEF47A", - "#FEEB36", - "#E1FEDE", - "#98FD90", - "#35FD31", - "#CCFEFE", - "#8AFCFE", - "#2EF8FE", - "#D3E7FE", - "#75B5FC", - "#2486FB", - "#E8D0FE", - "#BC77FC", - "#9633FB", - "#FED6FB", - "#FD79F4", - "#FC2AED" -]; - -export const darker = [ "#888888", - "#414141", "#000000", + + // red + "#FECBCC", + "#FD8182", "#E00A17", - "#B60610", "#740206", + + // brown + "#F3E1D2", + "#D6A77F", "#AA632B", - "#824617", "#653008", + + // orange + "#FEE7C3", + "#FECC81", "#FD9D28", - "#E38D22", "#B36F19", + + // yellow + "#FFFACD", + "#FEF47A", "#E8D62F", - "#C0B225", "#928619", + + // green + "#E1FEDE", + "#98FD90", "#2AE028", - "#1FAA1C", "#126E0E", + + // light blue + "#CCFEFE", + "#8AFCFE", "#24D6DB", - "#189DA1", "#0C686A", + + // dark blue + "#D3E7FE", + "#75B5FC", "#157AFB", - "#0F64CF", "#084794", + + // violet + "#E8D0FE", + "#BC77FC", "#8725FB", - "#6318B8", "#460D81", + + // pink + "#FED6FB", + "#FD79F4", "#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; +} diff --git a/app/client/ui2018/ColorSelect.ts b/app/client/ui2018/ColorSelect.ts index 82e297d9..4bad0245 100644 --- a/app/client/ui2018/ColorSelect.ts +++ b/app/client/ui2018/ColorSelect.ts @@ -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, + fontUnderline: Observable, + fontItalic: Observable, + fontStrikethrough: Observable, +} + +export class ColorOption { + constructor( + public color: Observable, + // 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, fillColor: Observable, - onSave: () => Promise, allowNone = false): Element { +export function colorSelect( + styleOptions: StyleOptions, + onSave: () => Promise, + 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, fillColor: Observable, +export function colorButton( + styleOptions: StyleOptions, onSave: () => Promise): 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, - fillColor: Observable, - onSave: () => Promise, - 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): 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 { 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 extends Disposable { + + // Is current value different from the server value? + public needsSaving: Observable; + private _serverValue: Observable; private _localChange: boolean = false; - constructor(public obs: Observable) { + constructor(public obs: Observable) { 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 {} +class BooleanModel extends PickerModel {} + +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, + 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: grid; + place-items: center; +`); + +const cssButtonRow = styled('div', ` + gap: 8px; display: flex; - align-items: center; - justify-content: center; + margin-top: 24px; `); diff --git a/app/client/ui2018/IconList.ts b/app/client/ui2018/IconList.ts index 762ecb81..6e6797ca 100644 --- a/app/client/ui2018/IconList.ts +++ b/app/client/ui2018/IconList.ts @@ -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", diff --git a/app/client/ui2018/cssVars.ts b/app/client/ui2018/cssVars.ts index 2c8c380e..2337e549 100644 --- a/app/client/ui2018/cssVars.ts +++ b/app/client/ui2018/cssVars.ts @@ -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; diff --git a/app/client/widgets/AbstractWidget.js b/app/client/widgets/AbstractWidget.js index 2445f872..f83e8b67 100644 --- a/app/client/widgets/AbstractWidget.js +++ b/app/client/widgets/AbstractWidget.js @@ -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); diff --git a/app/client/widgets/CellStyle.ts b/app/client/widgets/CellStyle.ts index c4660ecd..55355ddb 100644 --- a/app/client/widgets/CellStyle.ts +++ b/app/client/widgets/CellStyle.ts @@ -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; - protected fillColor: Observable; + protected textColor: Observable; + protected fillColor: Observable; + protected fontBold: Observable; + protected fontUnderline: Observable; + protected fontItalic: Observable; + protected fontStrikethrough: Observable; // Holds data from currently selected record (holds data only when this field has conditional styles). protected currentRecord: Computed; // 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(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; diff --git a/app/client/widgets/ChoiceListEditor.ts b/app/client/widgets/ChoiceListEditor.ts index d8b0ed9b..e634922f 100644 --- a/app/client/widgets/ChoiceListEditor.ts +++ b/app/client/widgets/ChoiceListEditor.ts @@ -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)), diff --git a/app/client/widgets/ChoiceListEntry.ts b/app/client/widgets/ChoiceListEntry.ts index 12591599..cc3e411a 100644 --- a/app/client/widgets/ChoiceListEntry.ts +++ b/app/client/widgets/ChoiceListEntry.ts @@ -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 = [ + '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', ` diff --git a/app/client/widgets/ChoiceTextBox.ts b/app/client/widgets/ChoiceTextBox.ts index cae32ee8..5d24bf30 100644 --- a/app/client/widgets/ChoiceTextBox.ts +++ b/app/client/widgets/ChoiceTextBox.ts @@ -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; export type ChoiceOptionsByName = Map; -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; } diff --git a/app/client/widgets/ChoiceToken.ts b/app/client/widgets/ChoiceToken.ts index 024d258a..1137cfe7 100644 --- a/app/client/widgets/ChoiceToken.ts +++ b/app/client/widgets/ChoiceToken.ts @@ -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 ); } diff --git a/app/client/widgets/FieldBuilder.ts b/app/client/widgets/FieldBuilder.ts index d87ef490..c5a8edf2 100644 --- a/app/client/widgets/FieldBuilder.ts +++ b/app/client/widgets/FieldBuilder.ts @@ -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, + 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(() => { 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)); }) ); }; diff --git a/app/client/widgets/NewAbstractWidget.ts b/app/client/widgets/NewAbstractWidget.ts index ff42472d..d3b80801 100644 --- a/app/client/widgets/NewAbstractWidget.ts +++ b/app/client/widgets/NewAbstractWidget.ts @@ -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'; } /** diff --git a/app/common/gristTypes.ts b/app/common/gristTypes.ts index 2e845575..ebebeacc 100644 --- a/app/common/gristTypes.ts +++ b/app/common/gristTypes.ts @@ -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; diff --git a/sandbox/grist/useractions.py b/sandbox/grist/useractions.py index 6d04084d..162f1f2a 100644 --- a/sandbox/grist/useractions.py +++ b/sandbox/grist/useractions.py @@ -776,9 +776,10 @@ class UserActions(object): # Look at the actual data for that column (first 1000 values) to decide on the type. col_values['type'] = guess_type(self._get_column_values(col), convert=False) - # If changing the type of a column, unset its widgetOptions and displayCol by default. + # If changing the type of a column, unset its widgetOptions, displayCol and rules by default. if 'type' in col_values: col_values.setdefault('widgetOptions', '') + col_values.setdefault('rules', None) col_values.setdefault('displayCol', 0) source_table = col.parentId.summarySourceTable @@ -1066,7 +1067,12 @@ class UserActions(object): for vf in self._docmodel.view_fields.lookupRecords(visibleCol=c.id)]) # Remove also all autogenereted formula columns for conditional styles. - more_removals.update([rule for col in col_recs + # But not from transform columns, as those columns borrow rules from original columns + more_removals.update([rule + for col in col_recs if not col.colId.startswith(( + 'gristHelper_Transform', + 'gristHelper_Converted', + )) for rule in col.rules]) # Add any extra removals after removing the requested columns in the requested order. @@ -1227,6 +1233,8 @@ class UserActions(object): 'widgetOptions': col_info.get('widgetOptions', ''), 'label': col_info.get('label', col_id), }) + if 'rules' in col_info: + values['rules'] = col_info['rules'] if 'recalcWhen' in col_info: values['recalcWhen'] = col_info['recalcWhen'] if 'recalcDeps' in col_info: @@ -1435,12 +1443,28 @@ class UserActions(object): if src_column.is_formula(): self._engine.bring_col_up_to_date(src_column) - # Update the destination column to match the source's type and options. Also unset displayCol, - # except if src_col has a displayCol, then keep it unchanged until SetDisplayFormula below. + # NOTE: This action is invoked only in a single place (during type/colum/data) + # transformation - where user has a chance to adjust some widgetOptions (though + # the UI is limited). Those widget options were already cleared (in js) and are either + # nullish (default ones) or are truly adjusted. As Grist doesn't know if the widgetOptions + # were adjusted or not - it will populate it on UI side and pass it here - so the code below + # is not used actually (widgetOptions are always set). But there are set with the things + # copied from dst_col or were cleared during typeConversion. if widgetOptions is None: widgetOptions = src_col.widgetOptions + + # Update the destination column to match the source's type and options. Also unset displayCol, + # except if src_col has a displayCol, then keep it unchanged until SetDisplayFormula below. self._docmodel.update([dst_col], type=src_col.type, widgetOptions=[widgetOptions], visibleCol=[src_col.visibleCol if src_col.visibleCol else 0], + # TypeConversion (in js) has decided if rules should be copied or not. If yes, rules were + # copied to transforming column (it borrowed rules from us [us as dst_col]), in that case + # here is no-op. But it could also decide to clear rules, in that case here we will clear + # rules (as transforming column doesn't have it). + + # RulesOptions (fonts, etc) are copied separately in the widgetOptions with the same + # logic (where removed or copied to the transforming column). + rules=[src_col.rules if src_col.rules else None], displayCol=[dst_col.displayCol if src_col.displayCol else 0]) # Copy over display column as well, if the source column has one. diff --git a/static/icons/icons.css b/static/icons/icons.css index c1dd6c73..a9628849 100644 --- a/static/icons/icons.css +++ b/static/icons/icons.css @@ -52,6 +52,7 @@ --icon-DragDrop: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTYuNSwzIEM2Ljc3NjE0MjM3LDMgNywzLjIyMzg1NzYzIDcsMy41IEw3LDEyLjUgQzcsMTIuNzc2MTQyNCA2Ljc3NjE0MjM3LDEzIDYuNSwxMyBDNi4yMjM4NTc2MywxMyA2LDEyLjc3NjE0MjQgNiwxMi41IEw2LDMuNSBDNiwzLjIyMzg1NzYzIDYuMjIzODU3NjMsMyA2LjUsMyBaIE05LjUsMyBDOS43NzYxNDIzNywzIDEwLDMuMjIzODU3NjMgMTAsMy41IEwxMCwxMi41IEMxMCwxMi43NzYxNDI0IDkuNzc2MTQyMzcsMTMgOS41LDEzIEM5LjIyMzg1NzYzLDEzIDksMTIuNzc2MTQyNCA5LDEyLjUgTDksMy41IEM5LDMuMjIzODU3NjMgOS4yMjM4NTc2MywzIDkuNSwzIFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg=='); --icon-Dropdown: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTgsOS4xNzQ2MzA1MiBMMTAuOTIxODI3Myw2LjE4OTAyMzIgQzExLjE2ODQ3NDIsNS45MzY5OTIyNyAxMS41NjgzNjc5LDUuOTM2OTkyMjcgMTEuODE1MDE0OCw2LjE4OTAyMzIgQzEyLjA2MTY2MTcsNi40NDEwNTQxMyAxMi4wNjE2NjE3LDYuODQ5Njc3MDEgMTEuODE1MDE0OCw3LjEwMTcwNzk0IEw4LDExIEw0LjE4NDk4NTE5LDcuMTAxNzA3OTQgQzMuOTM4MzM4MjcsNi44NDk2NzcwMSAzLjkzODMzODI3LDYuNDQxMDU0MTMgNC4xODQ5ODUxOSw2LjE4OTAyMzIgQzQuNDMxNjMyMTEsNS45MzY5OTIyNyA0LjgzMTUyNTc4LDUuOTM2OTkyMjcgNS4wNzgxNzI3LDYuMTg5MDIzMiBMOCw5LjE3NDYzMDUyIFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg=='); --icon-DropdownUp: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTgsOS4xNzQ2MzA1MiBMMTAuOTIxODI3Myw2LjE4OTAyMzIgQzExLjE2ODQ3NDIsNS45MzY5OTIyNyAxMS41NjgzNjc5LDUuOTM2OTkyMjcgMTEuODE1MDE0OCw2LjE4OTAyMzIgQzEyLjA2MTY2MTcsNi40NDEwNTQxMyAxMi4wNjE2NjE3LDYuODQ5Njc3MDEgMTEuODE1MDE0OCw3LjEwMTcwNzk0IEw4LDExIEw0LjE4NDk4NTE5LDcuMTAxNzA3OTQgQzMuOTM4MzM4MjcsNi44NDk2NzcwMSAzLjkzODMzODI3LDYuNDQxMDU0MTMgNC4xODQ5ODUxOSw2LjE4OTAyMzIgQzQuNDMxNjMyMTEsNS45MzY5OTIyNyA0LjgzMTUyNTc4LDUuOTM2OTkyMjcgNS4wNzgxNzI3LDYuMTg5MDIzMiBMOCw5LjE3NDYzMDUyIFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIgdHJhbnNmb3JtPSJtYXRyaXgoMSAwIDAgLTEgMCAxNykiLz48L3N2Zz4='); + --icon-Empty: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyMCIgaGVpZ2h0PSIyMCIgdmlld0JveD0iMCAwIDUuMjkyIDUuMjkyIj48cGF0aCBkPSJtIDAuMDMyNjIyNjMsMjkxLjcyMjI1IDUuMjMwMDMyMjcsNS4yNzY5IiBmaWxsPSJub25lIiBzdHJva2U9IiMwMDAiIHN0cm9rZS13aWR0aD0iLjUyOSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCAtMjkxLjcwOCkiLz48L3N2Zz4='); --icon-Expand: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTgsOS4xNzQ2MzA1MiBMMTAuOTIxODI3Myw2LjE4OTAyMzIgQzExLjE2ODQ3NDIsNS45MzY5OTIyNyAxMS41NjgzNjc5LDUuOTM2OTkyMjcgMTEuODE1MDE0OCw2LjE4OTAyMzIgQzEyLjA2MTY2MTcsNi40NDEwNTQxMyAxMi4wNjE2NjE3LDYuODQ5Njc3MDEgMTEuODE1MDE0OCw3LjEwMTcwNzk0IEw4LDExIEw0LjE4NDk4NTE5LDcuMTAxNzA3OTQgQzMuOTM4MzM4MjcsNi44NDk2NzcwMSAzLjkzODMzODI3LDYuNDQxMDU0MTMgNC4xODQ5ODUxOSw2LjE4OTAyMzIgQzQuNDMxNjMyMTEsNS45MzY5OTIyNyA0LjgzMTUyNTc4LDUuOTM2OTkyMjcgNS4wNzgxNzI3LDYuMTg5MDIzMiBMOCw5LjE3NDYzMDUyIFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIgdHJhbnNmb3JtPSJyb3RhdGUoLTkwIDggOC41KSIvPjwvc3ZnPg=='); --icon-EyeHide: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTIuNzY3ODUwMDUsOC4zODc3NjE4NiBMOC40ODI0Nzg2NSwyLjk0NTI1ODQzIEM3LjY5MTM3ODkxLDIuMzM3Nzg0NzggNi44NTk1NjUyNywyIDYsMiBDNC42ODcxNTc3OSwyIDMuNDM5MDUzMDYsMi43ODc5NjU5MSAyLjMwMTg2ODgzLDQuMTIxMjgwMjIgQzEuODk1MDk5OTEsNC41OTgyMDQ1NSAxLjUzMjcyNzg0LDUuMTEwOTc5MTEgMS4yMjA1NDE3NCw1LjYyMzQxOTQ0IEMxLjEzMzk0MTAxLDUuNzY1NTcwOSAxLjA2MDI2MjE4LDUuODkyMzE1NDggMSw2IEMxLjA2MDI2MjE4LDYuMTA3Njg0NTIgMS4xMzM5NDEwMSw2LjIzNDQyOTEgMS4yMjA1NDE3NCw2LjM3NjU4MDU2IEMxLjUzMjcyNzg0LDYuODg5MDIwODkgMS44OTUwOTk5MSw3LjQwMTc5NTQ1IDIuMzAxODY4ODMsNy44Nzg3MTk3OCBDMi40NTUyMTY2Niw4LjA1ODUxNTQ5IDIuNjEwNTgxNDgsOC4yMjgzOTQ0OSAyLjc2Nzg1MDA1LDguMzg3NzYxODYgWiBNMy41MTc1MjEzNSw5LjA1NDc0MTU3IEM0LjMwODYyMTA5LDkuNjYyMjE1MjIgNS4xNDA0MzQ3MywxMCA2LDEwIEM3LjMxMjg0MjIxLDEwIDguNTYwOTQ2OTQsOS4yMTIwMzQwOSA5LjY5ODEzMTE3LDcuODc4NzE5NzggQzEwLjEwNDkwMDEsNy40MDE3OTU0NSAxMC40NjcyNzIyLDYuODg5MDIwODkgMTAuNzc5NDU4Myw2LjM3NjU4MDU2IEMxMC44NjYwNTksNi4yMzQ0MjkxIDEwLjkzOTczNzgsNi4xMDc2ODQ1MiAxMSw2IEMxMC45Mzk3Mzc4LDUuODkyMzE1NDggMTAuODY2MDU5LDUuNzY1NTcwOSAxMC43Nzk0NTgzLDUuNjIzNDE5NDQgQzEwLjQ2NzI3MjIsNS4xMTA5NzkxMSAxMC4xMDQ5MDAxLDQuNTk4MjA0NTUgOS42OTgxMzExNyw0LjEyMTI4MDIyIEM5LjU0NDc4MzM0LDMuOTQxNDg0NTEgOS4zODk0MTg1MiwzLjc3MTYwNTUxIDkuMjMyMTQ5OTUsMy42MTIyMzgxNCBMMy41MTc1MjEzNSw5LjA1NDc0MTU3IFogTTkuMTk4MDQ5MzEsMi4yNjM3NjI1NyBMMTAuOTA1MTcyNCwwLjYzNzkzMTAzNCBDMTEuMTA1MTM3NiwwLjQ0NzQ4ODAxNyAxMS40MjE2MjU5LDAuNDU1MjA3MjQ2IDExLjYxMjA2OSwwLjY1NTE3MjQxNCBDMTEuODAyNTEyLDAuODU1MTM3NTgyIDExLjc5NDc5MjgsMS4xNzE2MjU5NSAxMS41OTQ4Mjc2LDEuMzYyMDY4OTcgTDkuOTM1ODc1NzEsMi45NDIwMjMxMyBDMTAuMDcyNjIyNCwzLjA4MjQ0NjUzIDEwLjIwNzYxOTksMy4yMjk0MzM5IDEwLjM0MDg0MjYsMy4zODI2MzIwNSBDMTAuNzgzMjMyOSwzLjg5MTM1NDQ3IDExLjE3NDcyMzEsNC40MzQ2ODc1NSAxMS41MTI0MTU1LDQuOTc4MzQ0MDkgQzExLjcxNjM0MSw1LjMwNjY0NzE4IDExLjg1ODUzNjYsNS41NjUxMDg2MyAxMS45MzUyMjgsNS43MTk0NDIxMiBDMTIuMDIxNTkwNyw1Ljg5MzIzNzgyIDEyLjAyMTU5MDcsNi4xMDY3NjIxOCAxMS45MzUyMjgsNi4yODA1NTc4OCBDMTEuODU4NTM2Niw2LjQzNDg5MTM3IDExLjcxNjM0MSw2LjY5MzM1MjgyIDExLjUxMjQxNTUsNy4wMjE2NTU5MSBDMTEuMTc0NzIzMSw3LjU2NTMxMjQ1IDEwLjc4MzIzMjksOC4xMDg2NDU1MyAxMC4zNDA4NDI2LDguNjE3MzY3OTUgQzkuMDUyMTkwMjYsMTAuMDk5MjQxMiA3LjU5NzQ3MzE1LDExIDYsMTEgQzQuODU4MjE4ODYsMTEgMy43ODkzNjU1MiwxMC41Mzk4NDE5IDIuODAxOTUwNjksOS43MzYyMzc0MyBMMS4wOTQ4Mjc1OSwxMS4zNjIwNjkgQzAuODk0ODYyNDE4LDExLjU1MjUxMiAwLjU3ODM3NDA1MiwxMS41NDQ3OTI4IDAuMzg3OTMxMDM0LDExLjM0NDgyNzYgQzAuMTk3NDg4MDE3LDExLjE0NDg2MjQgMC4yMDUyMDcyNDYsMTAuODI4Mzc0MSAwLjQwNTE3MjQxNCwxMC42Mzc5MzEgTDIuMDY0MTI0MjksOS4wNTc5NzY4NyBDMS45MjczNzc2Myw4LjkxNzU1MzQ3IDEuNzkyMzgwMDcsOC43NzA1NjYxIDEuNjU5MTU3MzcsOC42MTczNjc5NSBDMS4yMTY3NjcwNyw4LjEwODY0NTUzIDAuODI1Mjc2OTI4LDcuNTY1MzEyNDUgMC40ODc1ODQ1MjEsNy4wMjE2NTU5MSBDMC4yODM2NTg5NjYsNi42OTMzNTI4MiAwLjE0MTQ2MzM3OCw2LjQzNDg5MTM3IDAuMDY0NzcxOTUwNCw2LjI4MDU1Nzg4IEMtMC4wMjE1OTA2NTAxLDYuMTA2NzYyMTggLTAuMDIxNTkwNjUwMSw1Ljg5MzIzNzgyIDAuMDY0NzcxOTUwNCw1LjcxOTQ0MjEyIEMwLjE0MTQ2MzM3OCw1LjU2NTEwODYzIDAuMjgzNjU4OTY2LDUuMzA2NjQ3MTggMC40ODc1ODQ1MjEsNC45NzgzNDQwOSBDMC44MjUyNzY5MjgsNC40MzQ2ODc1NSAxLjIxNjc2NzA3LDMuODkxMzU0NDcgMS42NTkxNTczNywzLjM4MjYzMjA1IEMyLjk0NzgwOTc0LDEuOTAwNzU4OCA0LjQwMjUyNjg1LDEgNiwxIEM3LjE0MTc4MTE0LDEgOC4yMTA2MzQ0OCwxLjQ2MDE1ODE0IDkuMTk4MDQ5MzEsMi4yNjM3NjI1NyBaIE00LjI1LDYgQzQuMjUsNi4yNzYxNDIzNyA0LjAyNjE0MjM3LDYuNSAzLjc1LDYuNSBDMy40NzM4NTc2Myw2LjUgMy4yNSw2LjI3NjE0MjM3IDMuMjUsNiBDMy4yNSw0LjUzNDY4MjU0IDQuNDg2NDE2NjcsMy4zNTcxNDI4NiA2LDMuMzU3MTQyODYgQzYuMjc2MTQyMzcsMy4zNTcxNDI4NiA2LjUsMy41ODEwMDA0OCA2LjUsMy44NTcxNDI4NiBDNi41LDQuMTMzMjg1MjMgNi4yNzYxNDIzNyw0LjM1NzE0Mjg2IDYsNC4zNTcxNDI4NiBDNS4wMjgwODMzMyw0LjM1NzE0Mjg2IDQuMjUsNS4wOTgxNzQ2IDQuMjUsNiBaIE03Ljc1LDYgQzcuNzUsNS43MjM4NTc2MyA3Ljk3Mzg1NzYzLDUuNSA4LjI1LDUuNSBDOC41MjYxNDIzNyw1LjUgOC43NSw1LjcyMzg1NzYzIDguNzUsNiBDOC43NSw3LjQ2NTMxNzQ2IDcuNTEzNTgzMzMsOC42NDI4NTcxNCA2LDguNjQyODU3MTQgQzUuNzIzODU3NjMsOC42NDI4NTcxNCA1LjUsOC40MTg5OTk1MiA1LjUsOC4xNDI4NTcxNCBDNS41LDcuODY2NzE0NzcgNS43MjM4NTc2Myw3LjY0Mjg1NzE0IDYsNy42NDI4NTcxNCBDNi45NzE5MTY2Nyw3LjY0Mjg1NzE0IDcuNzUsNi45MDE4MjU0IDcuNzUsNiBaIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgyIDIpIiBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iLz48L3N2Zz4='); --icon-EyeShow: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTMuMjM1MDA0NzcsOC40MDA0MDAzNyBDMy41NDczMDQ3OSw4LjkxMDM0MTgyIDMuOTA5NzM5NSw5LjQyMDUxNzkxIDQuMzE2NDc5NzEsOS44OTQ5MDk5MyBDNS40NTE0MTkyNiwxMS4yMTg2MjA0IDYuNjk1NjEwNDMsMTIgOCwxMiBDOS4zMDQzODk1NywxMiAxMC41NDg1ODA3LDExLjIxODYyMDQgMTEuNjgzNTIwMyw5Ljg5NDkwOTkzIEMxMi4wOTAyNjA1LDkuNDIwNTE3OTEgMTIuNDUyNjk1Miw4LjkxMDM0MTgyIDEyLjc2NDk5NTIsOC40MDA0MDAzNyBDMTIuODU4NTc0Nyw4LjI0NzU5ODQ0IDEyLjkzNzExNjQsOC4xMTI2MTA2NCAxMyw4IEMxMi45MzcxMTY0LDcuODg3Mzg5MzYgMTIuODU4NTc0Nyw3Ljc1MjQwMTU2IDEyLjc2NDk5NTIsNy41OTk1OTk2MyBDMTIuNDUyNjk1Miw3LjA4OTY1ODE4IDEyLjA5MDI2MDUsNi41Nzk0ODIwOSAxMS42ODM1MjAzLDYuMTA1MDkwMDcgQzEwLjU0ODU4MDcsNC43ODEzNzk2MSA5LjMwNDM4OTU3LDQgOCw0IEM2LjY5NTYxMDQzLDQgNS40NTE0MTkyNiw0Ljc4MTM3OTYxIDQuMzE2NDc5NzEsNi4xMDUwOTAwNyBDMy45MDk3Mzk1LDYuNTc5NDgyMDkgMy41NDczMDQ3OSw3LjA4OTY1ODE4IDMuMjM1MDA0NzcsNy41OTk1OTk2MyBDMy4xNDE0MjUzMSw3Ljc1MjQwMTU2IDMuMDYyODgzNTgsNy44ODczODkzNiAzLDggQzMuMDYyODgzNTgsOC4xMTI2MTA2NCAzLjE0MTQyNTMxLDguMjQ3NTk4NDQgMy4yMzUwMDQ3Nyw4LjQwMDQwMDM3IFogTTIuMDU5ODA2MjUsNy43NDExNjEwMSBDMi4xMzYwNDgxMSw3LjU4NjkwMjg4IDIuMjc3NzU4NjcsNy4zMjc5MzA3MSAyLjQ4MTE1OTA2LDYuOTk4NzAzMDcgQzIuODE4MjI0ODgsNi40NTMxMjIwNSAzLjIwOTA1NjQ4LDUuOTA3NzcwOTIgMy42NTA4MDU0NSw1LjM5NzA0MDE0IEM0LjkzOTgwNDA1LDMuOTA2NzU2NDYgNi4zOTYzMzQxOSwzIDgsMyBDOS42MDM2NjU4MSwzIDExLjA2MDE5NTksMy45MDY3NTY0NiAxMi4zNDkxOTQ2LDUuMzk3MDQwMTQgQzEyLjc5MDk0MzUsNS45MDc3NzA5MiAxMy4xODE3NzUxLDYuNDUzMTIyMDUgMTMuNTE4ODQwOSw2Ljk5ODcwMzA3IEMxMy43MjIyNDEzLDcuMzI3OTMwNzEgMTMuODYzOTUxOSw3LjU4NjkwMjg4IDEzLjk0MDE5MzcsNy43NDExNjEwMSBDMTQuMDE5OTM1NCw3LjkwMjUwMDE5IDE0LjAxOTkzNTQsOC4wOTc0OTk4MSAxMy45NDAxOTM3LDguMjU4ODM4OTkgQzEzLjg2Mzk1MTksOC40MTMwOTcxMiAxMy43MjIyNDEzLDguNjcyMDY5MjkgMTMuNTE4ODQwOSw5LjAwMTI5NjkzIEMxMy4xODE3NzUxLDkuNTQ2ODc3OTUgMTIuNzkwOTQzNSwxMC4wOTIyMjkxIDEyLjM0OTE5NDYsMTAuNjAyOTU5OSBDMTEuMDYwMTk1OSwxMi4wOTMyNDM1IDkuNjAzNjY1ODEsMTMgOCwxMyBDNi4zOTYzMzQxOSwxMyA0LjkzOTgwNDA1LDEyLjA5MzI0MzUgMy42NTA4MDU0NSwxMC42MDI5NTk5IEMzLjIwOTA1NjQ4LDEwLjA5MjIyOTEgMi44MTgyMjQ4OCw5LjU0Njg3Nzk1IDIuNDgxMTU5MDYsOS4wMDEyOTY5MyBDMi4yNzc3NTg2Nyw4LjY3MjA2OTI5IDIuMTM2MDQ4MTEsOC40MTMwOTcxMiAyLjA1OTgwNjI1LDguMjU4ODM4OTkgQzEuOTgwMDY0NTgsOC4wOTc0OTk4MSAxLjk4MDA2NDU4LDcuOTAyNTAwMTkgMi4wNTk4MDYyNSw3Ljc0MTE2MTAxIFogTTguMDY0MjE5NjIsMTAuMjg3Mjc2NiBDNi43Mjc2MDM2MiwxMC4yODcyNzY2IDUuNjQ0MDYyMjYsOS4yNjMyMjc5NiA1LjY0NDA2MjI2LDggQzUuNjQ0MDYyMjYsNi43MzY3NzIwNCA2LjcyNzYwMzYyLDUuNzEyNzIzNDQgOC4wNjQyMTk2Miw1LjcxMjcyMzQ0IEM5LjQwMDgzNTYzLDUuNzEyNzIzNDQgMTAuNDg0Mzc3LDYuNzM2NzcyMDQgMTAuNDg0Mzc3LDggQzEwLjQ4NDM3Nyw5LjI2MzIyNzk2IDkuNDAwODM1NjMsMTAuMjg3Mjc2NiA4LjA2NDIxOTYyLDEwLjI4NzI3NjYgWiBNOC4wNjQyMTk2Miw5LjMyNDIxMjc1IEM4LjgzODA0OTk0LDkuMzI0MjEyNzUgOS40NjUzNjMzNiw4LjczMTM0MjUxIDkuNDY1MzYzMzYsOCBDOS40NjUzNjMzNiw3LjI2ODY1NzQ5IDguODM4MDQ5OTQsNi42NzU3ODcyNSA4LjA2NDIxOTYyLDYuNjc1Nzg3MjUgQzcuMjkwMzg5MzEsNi42NzU3ODcyNSA2LjY2MzA3NTg5LDcuMjY4NjU3NDkgNi42NjMwNzU4OSw4IEM2LjY2MzA3NTg5LDguNzMxMzQyNTEgNy4yOTAzODkzMSw5LjMyNDIxMjc1IDguMDY0MjE5NjIsOS4zMjQyMTI3NSBaIiBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iLz48L3N2Zz4='); @@ -59,6 +60,10 @@ --icon-Filter: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTIuNSw0IEwxLjUsNCBDMS4yMjQsNCAxLDMuNzc2IDEsMy41IEMxLDMuMjI0IDEuMjI0LDMgMS41LDMgTDIuNSwzIEMyLjc3NiwzIDMsMy4yMjQgMywzLjUgQzMsMy43NzYgMi43NzYsNCAyLjUsNCBaIE00LjUsNyBMMS41LDcgQzEuMjI0LDcgMSw2Ljc3NiAxLDYuNSBDMSw2LjIyNCAxLjIyNCw2IDEuNSw2IEw0LjUsNiBDNC43NzYsNiA1LDYuMjI0IDUsNi41IEM1LDYuNzc2IDQuNzc2LDcgNC41LDcgWiBNNi41LDEwIEwxLjUsMTAgQzEuMjI0LDEwIDEsOS43NzYgMSw5LjUgQzEsOS4yMjQgMS4yMjQsOSAxLjUsOSBMNi41LDkgQzYuNzc2LDkgNyw5LjIyNCA3LDkuNSBDNyw5Ljc3NiA2Ljc3NiwxMCA2LjUsMTAgWiBNOC41LDEzIEwxLjUsMTMgQzEuMjI0LDEzIDEsMTIuNzc2IDEsMTIuNSBDMSwxMi4yMjQgMS4yMjQsMTIgMS41LDEyIEw4LjUsMTIgQzguNzc2LDEyIDksMTIuMjI0IDksMTIuNSBDOSwxMi43NzYgOC43NzYsMTMgOC41LDEzIFogTTE0Ljk3Mjg4OCwyLjI4NDkwODg1IEMxNS4wMjY1MzgyLDIuNDU3MzYxNzggMTQuOTk2ODg2NCwyLjY1NTk2NzI1IDE0Ljg5Nzg5MzIsMi43ODc4MDY4MyBMMTEuNjgxMzA2OCw3LjA3NjY2OTY1IEMxMS42MTE3MzQ3LDcuMTY5NTg4MDkgMTEuNTM4MDA5LDcuNDA2ODA3MDIgMTEuNTM4MDA5LDcuNTM4MTg1MDggTDExLjUzODAwOSwxMy41Mzc4ODU2IEMxMS41MzgwMDksMTMuNzI0NjQ1NSAxMS40NTM3ODQsMTMuODkyOTQ0OCAxMS4zMjQzMzE0LDEzLjk2NDE3MiBDMTEuMjgxNTI2NiwxMy45ODgwMTcgMTEuMjM2NTI5NywxMy45OTk0MDEgMTEuMTkxOTk0MywxMy45OTk0MDEgQzExLjEwMTg4NTEsMTMuOTk5NDAxIDExLjAxMzI3NTgsMTMuOTUyNDgwMyAxMC45NDcxNjUsMTMuODY0MTc3IEw5LjU2MjY0NDg2LDEyLjAxODExNTMgQzkuNDk3Njg3NzksMTEuOTMxNjU4MSA5LjQ2MTIyODc2LDExLjgxNDI3OTMgOS40NjEyMjg3NiwxMS42OTE4MjM5IEw5LjQ2MTIyODc2LDcuNTM4MTg1MDggQzkuNDYxMjI4NzYsNy40MDY4MDcwMiA5LjM4NzYxODQ0LDcuMTY5NTg4MDkgOS4zMTc5MzA5Miw3LjA3Njk3NzMzIEw2LjEwMTM0NDUyLDIuNzg3OTYwNjcgQzYuMDAyMzUxMzMsMi42NTU5NjcyNSA1Ljk3MjgxNDksMi40NTc1MTU2MiA2LjAyNjM0OTY4LDIuMjg0OTA4ODUgQzYuMDc5ODg0NDUsMi4xMTIzMDIwOSA2LjIwNjEwNjU0LDIgNi4zNDYwNTg0NSwyIEwxNC42NTMxNzkzLDIgQzE0Ljc5MzI0NjYsMiAxNC45MTk0Njg2LDIuMTEyMzAyMDkgMTQuOTcyODg4LDIuMjg0OTA4ODUgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+'); --icon-FilterSimple: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTkuOTUwODMzMzMsMC4yMiBDOS44NzgyODY2NywwLjA4NDQ5NzcxMDggOS43MzcwMzQsLTYuMTY5Njc5NTFlLTA1IDkuNTgzMzMzMzMsLTQuNjI1OTMyOGUtMTcgTDAuNDE2NjY2NjY3LC00LjYyNTkzMjhlLTE3IEMwLjI2MzEwMzQ1NCw3LjkzMjczNDM1ZS0wNSAwLjEyMjAzMTQsMC4wODQ2MTg0ODc1IDAuMDQ5NTQ5NDAxMiwwLjIxOTk5OTUyOSBDLTAuMDIyOTMyNTk3OCwwLjM1NTM4MDU3MSAtMC4wMTUwNzQwNTE3LDAuNTE5NjU2MDYyIDAuMDcsMC42NDc1IEwzLjMzMzMzMzMzLDUuNTQyNSBMMy4zMzMzMzMzMyw5LjU4MzMzMzMzIEMzLjMzMzMzMzMzLDkuODEzNDUyIDMuNTE5ODgxMzYsMTAgMy43NSwxMCBMNi4yNSwxMCBDNi40ODAxMTg2NCwxMCA2LjY2NjY2NjY3LDkuODEzNDUyIDYuNjY2NjY2NjcsOS41ODMzMzMzMyBMNi42NjY2NjY2Nyw1LjU0MjUgTDkuOTMsMC42NDc1IEMxMC4wMTUxOTU2LDAuNTE5NzI3OTkxIDEwLjAyMzIwMTMsMC4zNTU0NTA1NDcgOS45NTA4MzMzMywwLjIyIFoiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDMgMykiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg=='); --icon-Folder: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTMsNSBMMTMsNSBDMTMuNTUyMjg0Nyw1IDE0LDUuNDQ3NzE1MjUgMTQsNiBMMTQsMTMgQzE0LDEzLjU1MjI4NDcgMTMuNTUyMjg0NywxNCAxMywxNCBMMywxNCBDMi40NDc3MTUyNSwxNCAyLDEzLjU1MjI4NDcgMiwxMyBMMiw2IEMyLDUuNDQ3NzE1MjUgMi40NDc3MTUyNSw1IDMsNSBaIE00LDIgTDEyLDIgTDEyLDQgTDQsNCBMNCwyIFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg=='); + --icon-FontBold: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZD0iTTMuMTk1MDk4OCAxLjgzNTQxOTloNS42OTAzODE4Yy43NTQ1OTUyIDAgMS40NzgyNzI0LjI5OTc1NjcgMi4wMTE4NTM0LjgzMzMzNzUuNTMzNTgxLjUzMzU4MDguODMzMzM3IDEuMjU3MjU4Mi44MzMzMzcgMi4wMTE4NTM1IDAgLjc1NDU5NTItLjI5OTc1NiAxLjQ3ODI3MjctLjgzMzMzNyAyLjAxMTg1MzVDMTAuMzYzNzUzIDcuMjI2MDQ1MSA5LjY0MDA3NTggNy41MjU4MDE4IDguODg1NDgwNiA3LjUyNTgwMThINS4wOTE4OTI3TTUuMDkxODkyNyA3LjUyNTgwMThoNS4yMTYxODMzYy44ODAzNTIgMCAxLjcyNDY1My4zNDk3MTgyIDIuMzQ3MTY5Ljk3MjIyMDcuNjIyNTAyLjYyMjUxNTIuOTcyMjIgMS40NjY4MTYxLjk3MjIyIDIuMzQ3MTY4NXYwYzAgLjg4MDM1My0uMzQ5NzE4IDEuNzI0NjU0LS45NzIyMiAyLjM0NzIxOS0uNjIyNTE2LjYyMjQwMi0xLjQ2NjgxNy45NzIxNy0yLjM0NzE2OS45NzIxN0gzLjE5NTA5ODhNNS4wOTE4OTI3IDEuODM1NDE5OVYxNC4xNjQ1OCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDMwMDAwIiBzdHJva2Utd2lkdGg9IjEuMjY1IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48L3N2Zz4='); + --icon-FontItalic: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PGRlZnM+PGNsaXBQYXRoIGlkPSJhIj48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMCAwSDEyVjEySDB6Ii8+PC9jbGlwUGF0aD48L2RlZnM+PGcgdHJhbnNmb3JtPSJtYXRyaXgoMS4xMzU2IDAgMCAxLjEzNTYgLjk4MyAxLjM5KSIgY2xpcC1wYXRoPSJ1cmwoI2EpIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGZpbGw9Im5vbmUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+PHBhdGggZD0ibSA0Ljg3NSwwLjM3NSBoIDQuNSIgc3Ryb2tlPSIjMDAwIi8+PHBhdGggZD0ibSAyLjYyNSwxMS42MjUgaCA0LjUiIHN0cm9rZT0iIzA2MDAwMCIvPjxwYXRoIGQ9Im0gNy4xMjUsMC4zNzUgLTIuMjUsMTEuMjUiIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLW9wYWNpdHk9Ii45OSIvPjwvZz48L3N2Zz4='); + --icon-FontStrikethrough: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZD0iTSAxLjgwNTY1ODUsMy4zOTcxNzIgViAxLjczNTc0MzEgSCAxNC4xOTQzNDIgViAzLjM5NzE3MiIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjOTI5Mjk5IiBzdHJva2Utd2lkdGg9IjEuMTQzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48cGF0aCBkPSJNIDcuOTk5OTk5OSwxMC4wNDI4ODcgViAxNC4xOTY0NiIgc3Ryb2tlPSIjOTI5Mjk5IiBzdHJva2Utd2lkdGg9IjEuMTQzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48cGF0aCBkPSJNIDcuOTk5OTk5OSwxLjczNTc0MzEgViA3LjU1MDc0NCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjOTI5Mjk5IiBzdHJva2Utd2lkdGg9IjEuMTQzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48cGF0aCBkPSJNNS4zNDUyODIyIDE0LjE5NjQ2SDEwLjY1NDcxOE0xLjgwNTY1ODUgNy41NTA3NDRIMTQuMTk0MzQyIiBzdHJva2U9IiM5MjkyOTkiIHN0cm9rZS13aWR0aD0iMS4xNDMiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPjwvc3ZnPg=='); + --icon-FontUnderline: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PGRlZnM+PGNsaXBQYXRoIGlkPSJhIj48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMCAwSDEyVjEySDB6Ii8+PC9jbGlwUGF0aD48L2RlZnM+PGcgdHJhbnNmb3JtPSJtYXRyaXgoMS4xNDI5NSAwIDAgMS4xNDI5NSAxLjE0MiAuNzIxKSIgY2xpcC1wYXRoPSJ1cmwoI2EpIiBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNLjM3NSAxMS42MjVoMTEuMjVNOS4zNzUgMS4xMjVWNkM5LjM3NSA2Ljg5NTExIDkuMDE5NDIgNy43NTM1NSA4LjM4NjQ5IDguMzg2NDkgNy43NTM1NSA5LjAxOTQyIDYuODk1MTEgOS4zNzUgNiA5LjM3NXYwQzUuMTA0ODkgOS4zNzUgNC4yNDY0NSA5LjAxOTQyIDMuNjEzNTEgOC4zODY0OSAyLjk4MDU4IDcuNzUzNTUgMi42MjUgNi44OTUxMSAyLjYyNSA2VjEuMTI1TTEuMTI1IDEuMTI1aDNNNy44NzUgMS4xMjVoMyIvPjwvZz48L3N2Zz4='); --icon-FunctionResult: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTQsMiBMMTIsMiBDMTMuMTA0NTY5NSwyIDE0LDIuODk1NDMwNSAxNCw0IEwxNCwxMiBDMTQsMTMuMTA0NTY5NSAxMy4xMDQ1Njk1LDE0IDEyLDE0IEw0LDE0IEMyLjg5NTQzMDUsMTQgMiwxMy4xMDQ1Njk1IDIsMTIgTDIsNCBDMiwyLjg5NTQzMDUgMi44OTU0MzA1LDIgNCwyIFogTTQuNSw2IEM0LjIyMzg1NzYzLDYgNCw2LjIyMzg1NzYzIDQsNi41IEM0LDYuNzc2MTQyMzcgNC4yMjM4NTc2Myw3IDQuNSw3IEwxMS41LDcgQzExLjc3NjE0MjQsNyAxMiw2Ljc3NjE0MjM3IDEyLDYuNSBDMTIsNi4yMjM4NTc2MyAxMS43NzYxNDI0LDYgMTEuNSw2IEw0LjUsNiBaIE00LjUsOSBDNC4yMjM4NTc2Myw5IDQsOS4yMjM4NTc2MyA0LDkuNSBDNCw5Ljc3NjE0MjM3IDQuMjIzODU3NjMsMTAgNC41LDEwIEwxMS41LDEwIEMxMS43NzYxNDI0LDEwIDEyLDkuNzc2MTQyMzcgMTIsOS41IEMxMiw5LjIyMzg1NzYzIDExLjc3NjE0MjQsOSAxMS41LDkgTDQuNSw5IFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg=='); --icon-Help: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTIuNzA5MjY0NDMsMy40MTYzNzEyMSBDMS42NDQ0MDc2OSw0LjY0NDQzMjIyIDEsNi4yNDY5NjEzNyAxLDggQzEsOS43NTMwMzg2MyAxLjY0NDQwNzY5LDExLjM1NTU2NzggMi43MDkyNjQ0MywxMi41ODM2Mjg4IEw0Ljg0MDA3MTI5LDEwLjQ1MjgyMTkgQzQuMzEzNTQwOCw5Ljc3NTQ4MDA3IDQsOC45MjQzNTU3MyA0LDggQzQsNy4wNzU2NDQyNyA0LjMxMzU0MDgsNi4yMjQ1MTk5MyA0Ljg0MDA3MTI5LDUuNTQ3MTc4MDcgTDIuNzA5MjY0NDMsMy40MTYzNzEyMSBaIE0zLjQxNjM3MTIxLDIuNzA5MjY0NDMgTDUuNTQ3MTc4MDcsNC44NDAwNzEyOSBDNi4yMjQ1MTk5Myw0LjMxMzU0MDggNy4wNzU2NDQyNyw0IDgsNCBDOC45MjQzNTU3Myw0IDkuNzc1NDgwMDcsNC4zMTM1NDA4IDEwLjQ1MjgyMTksNC44NDAwNzEyOSBMMTIuNTgzNjI4OCwyLjcwOTI2NDQzIEMxMS4zNTU1Njc4LDEuNjQ0NDA3NjkgOS43NTMwMzg2MywxIDgsMSBDNi4yNDY5NjEzNywxIDQuNjQ0NDMyMjIsMS42NDQ0MDc2OSAzLjQxNjM3MTIxLDIuNzA5MjY0NDMgWiBNNS45MDE5ODczMSw1Ljg1NTYyMzc2IEM1Ljg5NDgyMDAxLDUuODYzNzgzMDcgNS44ODczNDIwNCw1Ljg3MTc2NDc0IDUuODc5NTUzMzksNS44Nzk1NTMzOSBDNS44NzE3NjQ3NCw1Ljg4NzM0MjA0IDUuODYzNzgzMDcsNS44OTQ4MjAwMSA1Ljg1NTYyMzc2LDUuOTAxOTg3MzEgQzUuMzI2Mjk1NSw2LjQ0MjkzOTMyIDUsNy4xODMzNjQ0NiA1LDggQzUsOC44MTY2MzU1NCA1LjMyNjI5NTUsOS41NTcwNjA2OCA1Ljg1NTYyMzc2LDEwLjA5ODAxMjcgQzUuODYzNzgzMDcsMTAuMTA1MTggNS44NzE3NjQ3NCwxMC4xMTI2NTggNS44Nzk1NTMzOSwxMC4xMjA0NDY2IEM1Ljg4NzM0MjA0LDEwLjEyODIzNTMgNS44OTQ4MjAwMSwxMC4xMzYyMTY5IDUuOTAxOTg3MzEsMTAuMTQ0Mzc2MiBDNi40NDI5MzkzMiwxMC42NzM3MDQ1IDcuMTgzMzY0NDYsMTEgOCwxMSBDOC44MTY2MzU1NCwxMSA5LjU1NzA2MDY4LDEwLjY3MzcwNDUgMTAuMDk4MDEyNywxMC4xNDQzNzYyIEMxMC4xMDUxOCwxMC4xMzYyMTY5IDEwLjExMjY1OCwxMC4xMjgyMzUzIDEwLjEyMDQ0NjYsMTAuMTIwNDQ2NiBDMTAuMTI4MjM1MywxMC4xMTI2NTggMTAuMTM2MjE2OSwxMC4xMDUxOCAxMC4xNDQzNzYyLDEwLjA5ODAxMjcgQzEwLjY3MzcwNDUsOS41NTcwNjA2OCAxMSw4LjgxNjYzNTU0IDExLDggQzExLDcuMTgzMzY0NDYgMTAuNjczNzA0NSw2LjQ0MjkzOTMyIDEwLjE0NDM3NjIsNS45MDE5ODczMSBDMTAuMTM2MjE2OSw1Ljg5NDgyMDAxIDEwLjEyODIzNTMsNS44ODczNDIwNCAxMC4xMjA0NDY2LDUuODc5NTUzMzkgQzEwLjExMjY1OCw1Ljg3MTc2NDc0IDEwLjEwNTE4LDUuODYzNzgzMDcgMTAuMDk4MDEyNyw1Ljg1NTYyMzc2IEM5LjU1NzA2MDY4LDUuMzI2Mjk1NSA4LjgxNjYzNTU0LDUgOCw1IEM3LjE4MzM2NDQ2LDUgNi40NDI5MzkzMiw1LjMyNjI5NTUgNS45MDE5ODczMSw1Ljg1NTYyMzc2IFogTTMuNDE2MzcxMjEsMTMuMjkwNzM1NiBDNC42NDQ0MzIyMiwxNC4zNTU1OTIzIDYuMjQ2OTYxMzcsMTUgOCwxNSBDOS43NTMwMzg2MywxNSAxMS4zNTU1Njc4LDE0LjM1NTU5MjMgMTIuNTgzNjI4OCwxMy4yOTA3MzU2IEwxMC40NTI4MjE5LDExLjE1OTkyODcgQzkuNzc1NDgwMDcsMTEuNjg2NDU5MiA4LjkyNDM1NTczLDEyIDgsMTIgQzcuMDc1NjQ0MjcsMTIgNi4yMjQ1MTk5MywxMS42ODY0NTkyIDUuNTQ3MTc4MDcsMTEuMTU5OTI4NyBMMy40MTYzNzEyMSwxMy4yOTA3MzU2IFogTTEzLjI5MDczNTYsMTIuNTgzNjI4OCBDMTQuMzU1NTkyMywxMS4zNTU1Njc4IDE1LDkuNzUzMDM4NjMgMTUsOCBDMTUsNi4yNDY5NjEzNyAxNC4zNTU1OTIzLDQuNjQ0NDMyMjIgMTMuMjkwNzM1NiwzLjQxNjM3MTIxIEwxMS4xNTk5Mjg3LDUuNTQ3MTc4MDcgQzExLjY4NjQ1OTIsNi4yMjQ1MTk5MyAxMiw3LjA3NTY0NDI3IDEyLDggQzEyLDguOTI0MzU1NzMgMTEuNjg2NDU5Miw5Ljc3NTQ4MDA3IDExLjE1OTkyODcsMTAuNDUyODIxOSBMMTMuMjkwNzM1NiwxMi41ODM2Mjg4IFogTTgsMTYgQzMuNTgxNzIyLDE2IDAsMTIuNDE4Mjc4IDAsOCBDMCwzLjU4MTcyMiAzLjU4MTcyMiwwIDgsMCBDMTIuNDE4Mjc4LDAgMTYsMy41ODE3MjIgMTYsOCBDMTYsMTIuNDE4Mjc4IDEyLjQxODI3OCwxNiA4LDE2IFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg=='); --icon-Home: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTgsMi4wODMwOTUxOSBMMS40NzE4MjUzMiw2IEw4LDkuOTE2OTA0ODEgTDE0LjUyODE3NDcsNiBMOCwyLjA4MzA5NTE5IFogTTguMjU3MjQ3ODgsMS4wNzEyNTM1NCBMMTUuNzU3MjQ3OSw1LjU3MTI1MzU0IEMxNi4wODA5MTc0LDUuNzY1NDU1MjMgMTYuMDgwOTE3NCw2LjIzNDU0NDc3IDE1Ljc1NzI0NzksNi40Mjg3NDY0NiBMOC4yNTcyNDc4OCwxMC45Mjg3NDY1IEM4LjA5ODkwNjY4LDExLjAyMzc1MTIgNy45MDEwOTMzMiwxMS4wMjM3NTEyIDcuNzQyNzUyMTIsMTAuOTI4NzQ2NSBMMC4yNDI3NTIxMjIsNi40Mjg3NDY0NiBDLTAuMDgwOTE3Mzc0MSw2LjIzNDU0NDc3IC0wLjA4MDkxNzM3NDEsNS43NjU0NTUyMyAwLjI0Mjc1MjEyMiw1LjU3MTI1MzU0IEw3Ljc0Mjc1MjEyLDEuMDcxMjUzNTQgQzcuOTAxMDkzMzIsMC45NzYyNDg4MjEgOC4wOTg5MDY2OCwwLjk3NjI0ODgyMSA4LjI1NzI0Nzg4LDEuMDcxMjUzNTQgWiBNMTQuNTI4MTc0NywxMCBMMTMuNzQyNzUyMSw5LjUyODc0NjQ2IEMxMy41MDU5NjIsOS4zODY2NzIzOCAxMy40MjkxNzk1LDkuMDc5NTQyMjYgMTMuNTcxMjUzNSw4Ljg0Mjc1MjEyIEMxMy43MTMzMjc2LDguNjA1OTYxOTkgMTQuMDIwNDU3Nyw4LjUyOTE3OTQ2IDE0LjI1NzI0NzksOC42NzEyNTM1NCBMMTUuNzU3MjQ3OSw5LjU3MTI1MzU0IEMxNi4wODA5MTc0LDkuNzY1NDU1MjMgMTYuMDgwOTE3NCwxMC4yMzQ1NDQ4IDE1Ljc1NzI0NzksMTAuNDI4NzQ2NSBMOC4yNTcyNDc4OCwxNC45Mjg3NDY1IEM4LjA5ODkwNjY4LDE1LjAyMzc1MTIgNy45MDEwOTMzMiwxNS4wMjM3NTEyIDcuNzQyNzUyMTIsMTQuOTI4NzQ2NSBMMC4yNDI3NTIxMjIsMTAuNDI4NzQ2NSBDLTAuMDgwOTE3Mzc0MSwxMC4yMzQ1NDQ4IC0wLjA4MDkxNzM3NDEsOS43NjU0NTUyMyAwLjI0Mjc1MjEyMiw5LjU3MTI1MzU0IEwxLjc0Mjc1MjEyLDguNjcxMjUzNTQgQzEuOTc5NTQyMjYsOC41MjkxNzk0NiAyLjI4NjY3MjM4LDguNjA1OTYxOTkgMi40Mjg3NDY0Niw4Ljg0Mjc1MjEyIEMyLjU3MDgyMDU0LDkuMDc5NTQyMjYgMi40OTQwMzgwMSw5LjM4NjY3MjM4IDIuMjU3MjQ3ODgsOS41Mjg3NDY0NiBMMS40NzE4MjUzMiwxMCBMOCwxMy45MTY5MDQ4IEwxNC41MjgxNzQ3LDEwIFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg=='); diff --git a/static/ui-icons/UI/Empty.svg b/static/ui-icons/UI/Empty.svg new file mode 100644 index 00000000..1668db55 --- /dev/null +++ b/static/ui-icons/UI/Empty.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/static/ui-icons/UI/FontBold.svg b/static/ui-icons/UI/FontBold.svg new file mode 100644 index 00000000..aaec2b78 --- /dev/null +++ b/static/ui-icons/UI/FontBold.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/static/ui-icons/UI/FontItalic.svg b/static/ui-icons/UI/FontItalic.svg new file mode 100644 index 00000000..d36b053f --- /dev/null +++ b/static/ui-icons/UI/FontItalic.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/static/ui-icons/UI/FontStrikethrough.svg b/static/ui-icons/UI/FontStrikethrough.svg new file mode 100644 index 00000000..efac64b2 --- /dev/null +++ b/static/ui-icons/UI/FontStrikethrough.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/static/ui-icons/UI/FontUnderline.svg b/static/ui-icons/UI/FontUnderline.svg new file mode 100644 index 00000000..5f2b6033 --- /dev/null +++ b/static/ui-icons/UI/FontUnderline.svg @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + diff --git a/test/nbrowser/gristUtils.ts b/test/nbrowser/gristUtils.ts index 7cbc6c13..cb07ae98 100644 --- a/test/nbrowser/gristUtils.ts +++ b/test/nbrowser/gristUtils.ts @@ -1601,6 +1601,28 @@ export function session(): Session { return Session.default; } +/** + * Sets font style in opened color picker. + */ +export async function setFont(type: 'bold'|'underline'|'italic'|'strikethrough', onOff: boolean|number) { + const optionToClass = { + bold: '.test-font-option-FontBold', + italic: '.test-font-option-FontItalic', + underline: '.test-font-option-FontUnderline', + strikethrough: '.test-font-option-FontStrikethrough', + }; + async function clickFontOption() { + await driver.find(optionToClass[type]).click(); + } + async function isFontOption() { + return (await driver.findAll(`${optionToClass[type]}[class*=-selected]`)).length === 1; + } + const current = await isFontOption(); + if (onOff && !current || !onOff && current) { + await clickFontOption(); + } +} + // Set the value of an `` element to `color` and trigger the `change` // event. Accepts `color` to be of following forms `rgb(120, 10, 3)` or '#780a03'. export async function setColor(colorInputEl: WebElement, color: string) {