/*
* This file is part of TREB.
*
* TREB is free software: you can redistribute it and/or modify it under the
* terms of the GNU General Public License as published by the Free Software
* Foundation, either version 3 of the License, or (at your option) any
* later version.
*
* TREB is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
* details.
*
* You should have received a copy of the GNU General Public License along
* with TREB. If not, see .
*
* Copyright 2022-2026 trebco, llc.
* info@treb.app
*
*/
/**
* grid base is a superclass for grid that takes over all (most) of the
* data operations, leaving UI operations (painting and interacting, plus
* layout) in the grid subclass.
*
* this is part of an effort to support running outside of the browser,
* but still using the command log to handle deltas.
*
* this turns out to be a little like the (old) layout where we had modern
* and legacy layouts -- a lot of stuff can be reused, but a lot can't.
*
* calling this "grid" doesn't really make sense anymore, but we're not in
* a hurry to change it either.
*
*/
import { EventSource } from 'treb-utils';
import type { DataModel, ConditionalFormat, // MacroFunction, SerializedModel, SerializedNamedExpression,
ViewModel,
DataValidation} from 'treb-data-model';
import type { Parser, UnitAddress} from 'treb-parser';
import { type ExpressionUnit, IllegalSheetNameRegex, ParseCSV, DecimalMarkType } from 'treb-parser';
import { Area, IsCellAddress, ValueType, DefaultTableSortOptions } from 'treb-base-types';
import type { ICellAddress, IArea, Cell, CellValue, CellStyle, Table, TableSortOptions, TableTheme, Complex, PatchOptions as PatchAreaOptions, Color } from 'treb-base-types';
import { Sheet, type SerializeOptions, type Annotation } from 'treb-data-model';
import type { FunctionDescriptor} from '../editors/autocomplete_matcher';
import { AutocompleteMatcher } from '../editors/autocomplete_matcher';
import { NumberFormat, ValueParser } from 'treb-format';
import type { DataEvent, GridEvent, StyleEvent } from './grid_events';
import { ErrorCode } from './grid_events';
import type { CommandRecord, CreateAnnotationCommand, DataValidationCommand, DuplicateSheetCommand, FreezeCommand, InsertColumnsCommand, InsertRowsCommand, RemoveAnnotationCommand, ResizeColumnsCommand, ResizeRowsCommand, SelectCommand, SetRangeCommand, ShowSheetCommand, SortTableCommand } from './grid_command';
import { DefaultGridOptions, type GridOptions } from './grid_options';
import { BorderConstants } from './border_constants';
import { CommandKey } from './grid_command';
import type { Command, ActivateSheetCommand,
DeleteSheetCommand, UpdateBordersCommand, SheetSelection } from './grid_command';
import type { UpdateFlags } from './update_flags';
import type { FreezePane, LegacySerializedSheet } from 'treb-data-model';
import type { ClipboardCellData } from './clipboard_data';
interface PatchOptions extends PatchAreaOptions {
sheet: Sheet;
}
// this is an assert, bascially, for completeness. we could probably
// resolve the eslint issue (there's no type option) but not sure it's
// useful to do so
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const AssertNever = (value: never) => {
console.error('invalid case');
};
export class GridBase {
// --- public members --------------------------------------------------------
/** events */
public grid_events = new EventSource();
/** for recording */
public command_log = new EventSource();
public readonly model: DataModel;
public readonly view: ViewModel;
// --- public accessors ------------------------------------------------------
public get active_sheet(): Sheet {
return this.view.active_sheet;
}
public set active_sheet(sheet: Sheet) {
this.view.active_sheet = sheet;
}
/** access the view index, if needed */
public get view_index() {
return this.view.view_index;
}
// --- protected members -----------------------------------------------------
/**
* switching to a stack, in case batching is nested. we don't need
* actual data so (atm) just count the depth.
*/
protected batch = 0; // false;
/**
* if any batch method along the way requests a paint update, we
* want to toll it until the last batch call is complete, but we don't
* want to lose it. just remember to reset. [FIXME: isn't tolling this
* paint implicit, since it's async? ...]
*/
protected batch_paint = false;
protected batch_events: GridEvent[] = [];
/**
* single instance of AC. editors (function bar, ICE) have references.
* this is in base, instead of subclass, because we use it to check
* for valid names.
*/
protected autocomplete_matcher = new AutocompleteMatcher();
/**
* flags/state (used for some recordkeeping -- not super important)
*/
protected flags: Record = {};
/** */
protected options: GridOptions;
/**
* spreadsheet language parser. used to pull out address
* references from functions, for highlighting
*
* ...
*
* it's used for lots of stuff now, in addition to highlighting.
* copy/paste with translation; csv; defines; and some other stuff.
* still would like to share w/ parent though, if possible.
*
*
* FIXME: need a way to share/pass parser flags
* UPDATE: sharing parser w/ owner (embedded sheet)
*/
protected parser: Parser;
// --- constructor -----------------------------------------------------------
constructor(
options: GridOptions = {},
model: DataModel) {
this.model = model;
this.view = {
active_sheet: this.model.sheets.list[0],
view_index: this.model.view_count++,
};
// shared parser
this.parser = model.parser;
// apply default options, meaning that you need to explicitly set/unset
// in order to change behavior. FIXME: this is ok for flat structure, but
// anything more complicated will need a nested merge
this.options = { ...DefaultGridOptions, ...options };
}
// --- API methods -----------------------------------------------------------
public RemoveConditionalFormat(options: {
format?: ConditionalFormat;
area?: IArea;
}) {
this.ExecCommand({
key: CommandKey.RemoveConditionalFormat,
...options,
});
}
public AddConditionalFormat(format: ConditionalFormat) {
this.ExecCommand({
key: CommandKey.AddConditionalFormat,
format,
});
}
/** remove a table. doesn't remove any data, just removes the overlay. */
public RemoveTable(table: Table) {
this.ExecCommand({
key: CommandKey.RemoveTable,
table,
});
}
/**
* create a table in the given area. the area cannot contain any
* merge cells, arrays, or be part of another table. if you add a table
* with a totals row, we don't insert a new row -- allocate enough space
* when you create it.
*
* @param area - the total area for the table, including headers and totals
* @param totals - set true to include a totals row. tables have different
* formatting and slightly different behavior when there's a totals row.
*/
public InsertTable(area: IArea, totals = true, sortable: boolean|undefined = undefined, theme?: TableTheme) {
// we should validate here, so that we can throw.
if (!area.start.sheet_id) {
area.start.sheet_id = this.active_sheet.id;
}
const sheet = this.FindSheet(area);
for (let row = area.start.row; row <= area.end.row; row++) {
for (let column = area.start.column; column <= area.end.column; column++) {
const cell = sheet.cells.GetCell({row, column}, false);
if (cell && (cell.area || cell.merge_area || cell.table)) {
// throw new Error('invalid area for table');
this.Error(ErrorCode.invalid_area_for_table);
return;
}
}
}
this.ExecCommand({
key: CommandKey.InsertTable,
area: JSON.parse(JSON.stringify(area)),
totals,
sortable,
theme,
});
}
/**
* activate sheet, by name or index number
* @param sheet number (index into the array) or string (name)
*/
public ActivateSheet(sheet: number | string, user?: boolean): void {
const index = (typeof sheet === 'number') ? sheet : undefined;
const name = (typeof sheet === 'string') ? sheet : undefined;
this.ExecCommand({
key: CommandKey.ActivateSheet,
index,
name,
user,
});
}
/**
* activate sheet, by ID
*/
public ActivateSheetID(id: number): void {
this.ExecCommand({
key: CommandKey.ActivateSheet,
id,
});
}
public async Screenshot({ type, quality, download }: {
type?: 'png'|'webp'|'jpeg',
quality?: number,
download?: boolean,
}): Promise {
return undefined;
}
/**
* duplicate sheet by index or (omitting index) the current active sheet
*/
public DuplicateSheet(index?: number, name?: string, insert_before?: number|string): void {
const command: DuplicateSheetCommand = {
key: CommandKey.DuplicateSheet,
new_name: name,
insert_before,
};
if (typeof index === 'undefined') {
command.id = this.active_sheet.id;
}
else {
command.index = index;
}
this.ExecCommand(command);
}
public AddSheet(name?: string): void {
this.ExecCommand({
key: CommandKey.AddSheet,
name,
show: true,
});
}
/**
* delete sheet, by index or (omitting index) the current active sheet
*/
public DeleteSheet(index?: number): void {
if (typeof index === 'undefined') {
if (!this.model.sheets.list.some((sheet, i) => {
if (sheet === this.active_sheet) {
index = i;
return true;
}
return false;
})) {
throw new Error('invalid index');
}
}
this.ExecCommand({
key: CommandKey.DeleteSheet,
index,
});
}
/** insert sheet at the given index (or current index) */
public InsertSheet(index?: number, name?: string): void {
if (typeof index === 'undefined') {
if (!this.model.sheets.list.some((sheet, i) => {
if (sheet === this.active_sheet) {
index = i + 1;
return true;
}
return false;
})) {
throw new Error('invalid index');
}
}
this.ExecCommand({
key: CommandKey.AddSheet,
insert_index: index,
name,
show: true,
});
}
public DeleteSheetID(id: number): void {
this.ExecCommand({
key: CommandKey.DeleteSheet,
id,
});
}
/**
* clear sheet, reset all data
*/
public Reset(): void {
this.ExecCommand({ key: CommandKey.Reset });
}
/**
* set hyperlink, like set note
*/
public SetLink(address: ICellAddress, reference?: string): void {
/*
if (!address) {
if (this.primary_selection.empty) return;
address = this.primary_selection.target;
}
*/
this.ExecCommand({
key: CommandKey.SetLink,
area: address,
reference,
});
}
public ShowAll(): void {
// obviously there are better ways to do this, but this
// will use the execcommand system and _should_ only fire
// a single event (FIXME: check)
const commands: ShowSheetCommand[] = [];
for (let index = 0; index < this.model.sheets.length; index++) {
commands.push({
key: CommandKey.ShowSheet,
index,
show: true,
});
}
this.ExecCommand(commands);
}
public ShowSheet(index: number|string = 0, show = true): void {
const command: ShowSheetCommand = {
key: CommandKey.ShowSheet,
show,
};
if (typeof index === 'string') { command.name = index; }
else { command.index = index; }
this.ExecCommand(command);
}
/**
* sort table. column is absolute.
*/
public SortTable(table: Table, options: Partial = {}) {
//
// table typically has an actual area, while we want a plain
// object in the command queue for serialization purposes. not
// sure how we wound up with this situation, it's problematic.
//
this.ExecCommand({
key: CommandKey.SortTable,
table: JSON.parse(JSON.stringify(table)),
...DefaultTableSortOptions,
...options,
});
}
/** return freeze area */
public GetFreeze(): FreezePane {
return { ...this.active_sheet.freeze };
}
/**
* insert rows(s) at some specific point
*/
public InsertRows(before_row = 0, count = 1): void {
this.ExecCommand({
key: CommandKey.InsertRows,
before_row,
count,
});
}
/**
* return the table (if any) at the given address
*/
public GetTableReference(address: ICellAddress): Table|undefined {
const sheet = this.model.sheets.Find(address.sheet_id || this.active_sheet.id);
return sheet?.CellData(address).table || undefined;
}
/**
* reset sheet, set data from CSV
*
* FIXME: this is problematic, because it runs around the exec command
* system. however it doesn't seem like a good candidate for a separate
* command. it should maybe move to the import class? (...)
*
* one problem with that is that import is really, really heavy (jszip).
* it seems wasteful to require all that just to import csv.
*/
public FromCSV(text: string): void {
// CSV assumes dot-decimal, correct? if we want to use the
// parser we will have to check (and set/reset) the separator
this.parser.Save();
this.parser.SetLocaleSettings(DecimalMarkType.Period);
/*
const toggle_separator = this.parser.decimal_mark === DecimalMarkType.Comma;
if (toggle_separator)
{
// swap
this.parser.argument_separator = ArgumentSeparatorType.Comma;
this.parser.decimal_mark = DecimalMarkType.Period;
}
*/
const records = ParseCSV(text);
const arr = records.map((record) =>
record.map((field) => {
if (field) {
const tmp = this.parser.Parse(field);
if (tmp.expression?.type === 'complex') {
return tmp.expression as Complex;
}
}
return ValueParser.TryParse(field).value;
}));
/*
if (toggle_separator) {
// reset
this.parser.argument_separator = ArgumentSeparatorType.Semicolon;
this.parser.decimal_mark = DecimalMarkType.Comma;
}
*/
this.parser.Restore();
const end = {
row: Math.max(0, arr.length - 1),
column: arr.reduce((max, row) => Math.max(max, Math.max(0, row.length - 1)), 0),
};
// NOTE: SetRange here does not need to be translated, because
// we're not expecting spreadsheet functions in the CSV. CSV should
// be data only. Famous last words.
this.ExecCommand([
{ key: CommandKey.Reset },
{
key: CommandKey.SetRange,
area: { start: { row: 0, column: 0 }, end },
value: arr,
},
// we took this out because the data may require a layout update
// (rebuilding tiles); in that case, this will be duplicative. maybe
// should use setTimeout or some sort of queue...
// { key: CommandKey.ResizeColumns }, // auto
]);
}
/**
* insert column(s) at some specific point
*/
public InsertColumns(before_column = 0, count = 1): void {
this.ExecCommand({
key: CommandKey.InsertColumns,
before_column,
count,
});
}
/** move sheet (X) before sheet (Y) */
public ReorderSheet(index: number, move_before: number): void {
this.ExecCommand({
key: CommandKey.ReorderSheet,
index,
move_before,
});
}
/**
* rename active sheet
*/
public RenameSheet(sheet: Sheet, name: string): void {
this.ExecCommand({
key: CommandKey.RenameSheet,
new_name: name,
id: sheet.id,
});
}
/**
* freeze rows or columns. set to 0 (or call with no arguments) to un-freeze.
*
* highglight is shown by default, but we can hide it(mostly for document load)
*/
public Freeze(rows = 0, columns = 0, highlight_transition = true): void {
this.ExecCommand({
key: CommandKey.Freeze,
rows,
columns,
highlight_transition,
});
}
/**
* API method
*/
public SetRowHeight(row?: number | number[], height?: number, shrink = true): void {
this.ExecCommand({
key: CommandKey.ResizeRows,
row,
height,
shrink,
});
}
/**
* API method
*
* @param column - column, columns, or undefined means all columns
* @param width - target width, or undefined means auto-size
* @param allow_shrinking - for auto-size, allow shrinking. defaults to true.
* set false to disallow shrinking.
*/
public SetColumnWidth(column?: number | number[], width?: number, allow_shrinking?: boolean): void {
this.ExecCommand({
key: CommandKey.ResizeColumns,
column,
width,
allow_shrinking,
});
}
/**
* filter table. what this means is "show the rows that match the filter
* and hide the other rows". it doesn't actually change data, but it does
* show/hide rows which (now) has some data effects.
*
* note that we don't pass the filter command through the command queue.
* it uses a callback, so that would not work. rather we filter first,
* then send hide/show row commands through the command queue. that will
* propagate updates.
*/
public FilterTable(table: Table, column: number, filter: (cell: Cell) => boolean) {
const command: Command[] = [];
if (!table.area.start.sheet_id) {
throw new Error('invalid table area');
}
const sheet = this.model.sheets.Find(table.area.start.sheet_id);
if (!sheet) {
throw new Error('invalid table sheet');
}
const show_rows: number[] = [];
const hide_rows: number[] = [];
const end = table.totals_row ? table.area.end.row - 1 : table.area.end.row;
column += table.area.start.column;
for (let row = table.area.start.row + 1; row <= end; row++) {
const cell = sheet.CellData({row, column});
const show = filter(cell);
const current = sheet.GetRowHeight(row);
if (show && !current) {
show_rows.push(row);
}
else if (!show && current) {
hide_rows.push(row);
}
}
if (show_rows) {
command.push({
key: CommandKey.ResizeRows,
sheet_id: sheet.id,
row: show_rows,
height: sheet.default_row_height,
});
}
if (hide_rows) {
command.push({
key: CommandKey.ResizeRows,
sheet_id: sheet.id,
row: hide_rows,
height: 0,
});
}
if (command.length) {
this.ExecCommand(command);
}
}
/**
* UpdateSheets means "set these as the sheets, drop any old stuff". there's
* an implicit reset (in fact we may do that twice in some cases).
*
* this is non-UI; specialization should handle the UI part
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public UpdateSheets(data: LegacySerializedSheet[], render = false, activate_sheet?: number | string): void {
Sheet.Reset(); // reset ID generation
const sheets = data.map((sheet) => Sheet.FromJSON(sheet, this.model.theme_style_properties));
// ensure we have a sheets[0] so we can set active
if (sheets.length === 0) {
sheets.push(Sheet.Blank(this.model.theme_style_properties));
}
// now assign sheets
this.model.sheets.Assign(sheets);
this.ResetMetadata(); // FIXME: shouldn't we just set metadata from the file?
// set active
this.active_sheet = sheets[0];
// possibly set an active sheet on load (shortcut)
// could we not use a command for this?
if (activate_sheet) {
const sheet = this.model.sheets.Find(activate_sheet);
if (sheet) {
this.active_sheet = sheet;
}
}
// NOTE: we're not handling annotations here. do we need to? (...)
}
/**
* set functions for AC matcher. should be called by calculator on init,
* or when any functions are added/removed.
*
* FIXME: we should use this to normalize function names, on insert and
* on paste (if we're doing that).
*
* FIXME: are named expressions included here? (this function predates
* named expressions).
*
*
* this moved to grid base because we use the list to check for conflicts
* when setting names.
*
*/
public SetAutocompleteFunctions(functions: FunctionDescriptor[]): void {
const expressions: FunctionDescriptor[] = [];
for (const entry of this.model.named.list) {
expressions.push({
name: entry.name,
named: true,
type: 'token',
});
}
/*
for (const name of this.model.named_expressions.keys()) {
expressions.push({
name,
named: true,
type: 'token',
});
}
*/
const consolidated= functions.slice(0).concat(expressions);
/*
const consolidated = functions.slice(0).concat(
this.model.named_ranges.List().map((named_range) => {
return {
name: named_range.name,
named: true,
type: 'token'
};
}),
expressions,
);
*/
this.autocomplete_matcher.SetFunctions(consolidated);
}
public ResetMetadata(): void {
this.model.document_name = undefined;
this.model.user_data = undefined;
}
// --- protected methods -----------------------------------------------------
/**
* see ResizeRowsInternal
*/
protected ResizeColumnsInternal(command: ResizeColumnsCommand) {
const sheet = command.sheet_id ? this.FindSheet(command.sheet_id) : this.active_sheet;
// normalize
let column = command.column;
if (typeof column === 'undefined') {
column = [];
for (let i = 0; i < sheet.columns; i++) column.push(i);
}
if (typeof column === 'number') column = [column];
if (typeof command.width === 'number') {
for (const entry of column) {
sheet.SetColumnWidth(entry, command.width);
}
}
else {
console.error('auto size not supported');
}
}
protected RemoveAnnotationInternal(command: RemoveAnnotationCommand) {
for (let i = 0; i < command.sheet.annotations.length; i++) {
if (command.annotation === command.sheet.annotations[i]) {
command.sheet.annotations.splice(i, 1);
// subclass only // this.layout.RemoveAnnotation(annotation);
// do we still need this message? not sure
this.grid_events.Publish({
type: 'annotation',
annotation: command.annotation,
event: 'delete',
});
return;
}
}
}
/** placeholder */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected CreateAnnotationInternal(command: CreateAnnotationCommand) {
}
/**
* resize rows. this supports auto size, but that will fail in !ui grid,
* because it uses HTML. also non-ui doesn't really need to worry about
* scale... we should split.
*/
protected ResizeRowsInternal(command: ResizeRowsCommand): IArea|undefined {
// we're guaranteed this now, we should have a way to represent that...
const sheet = command.sheet_id ? this.FindSheet(command.sheet_id) : this.active_sheet;
// normalize rows -> array. undefined means all rows.
let row = command.row;
if (typeof row === 'undefined') {
row = [];
for (let i = 0; i < sheet.rows; i++) row.push(i);
}
if (typeof row === 'number') row = [row];
// I guess this was intended to prevent auto-size, but what about 0?
if (typeof command.height === 'number') {
for (const entry of row) {
sheet.SetRowHeight(entry, command.height);
}
}
else {
console.error('auto size not supported');
}
return undefined;
}
protected ResetInternal() {
Sheet.Reset();
this.UpdateSheets([], true);
this.model.named.Reset();
this.model.macro_functions.clear(); // = {};
this.model.tables.clear();
}
/**
* check if we can paste into the target area(s). this will
* return false if the areas contain locked cells, or part of
* an array or merge but not the whole array or merge.
*
* @param areas
* @returns
*/
protected ValidatePasteAreas(areas: Area[]): boolean {
for (const area of areas) {
let sheet: Sheet|undefined = this.active_sheet;
if (area.start.sheet_id && area.start.sheet_id !== sheet.id) {
sheet = this.model.sheets.Find(area.start.sheet_id);
}
if (!sheet) {
return false;
}
// let valid = true;
for (const cell of sheet.cells.Iterate(area)) {
if (cell.style?.locked) {
console.info('invalid: locked cells');
return false;
}
if (cell.merge_area) {
if (!area.Contains(cell.merge_area.start) || !area.Contains(cell.merge_area.end)) {
console.info('invalid: merge area');
return false;
}
}
if (cell.area) {
if (!area.Contains(cell.area.start) || !area.Contains(cell.area.end)) {
console.info('invalid: array');
return false;
}
}
}
/*
sheet.cells.Apply2(area, cell => {
if (cell.style?.locked) {
console.info('invalid: locked cells');
valid = false;
}
if (cell.merge_area) {
if (!area.Contains(cell.merge_area.start) || !area.Contains(cell.merge_area.end)) {
console.info('invalid: merge area');
valid = false;
}
}
if (cell.area) {
if (!area.Contains(cell.area.start) || !area.Contains(cell.area.end)) {
console.info('invalid: array');
valid = false;
}
}
return valid;
});
*/
// if (!valid) {
// return false;
// }
}
return true;
}
protected SetValidationInternal(command: DataValidationCommand): void {
const sheet = this.FindSheet(command.area);
if (!sheet) {
throw new Error('invalid sheet in set validation');
}
const target = {start: command.area.start, end: command.area.end };
if (command.range) {
sheet.AddValidation({
type: 'range',
error: !!command.error,
area: command.range,
target: [target],
});
/*
cell.validation = {
type: ValidationType.Range,
area: command.range,
error: !!command.error,
};
*/
}
else if (command.list) {
sheet.AddValidation({
type: 'list',
error: !!command.error,
list: JSON.parse(JSON.stringify(command.list)),
target: [target],
});
/*
cell.validation = {
type: ValidationType.List,
list: JSON.parse(JSON.stringify(command.list)),
error: !!command.error,
}
*/
}
else {
// cell.validation = undefined;
sheet.RemoveValidations(target);
}
}
/**
* get values from a range of data
* @param area
*/
protected GetValidationRange(area: IArea): CellValue[]|undefined {
let list: CellValue[]|undefined;
const sheet = this.FindSheet(area);
if (sheet) {
list = [];
// clamp to actual area to avoid screwing up sheet
// FIXME: what does that cause [problem with selections], why, and fix it
area = sheet.RealArea(new Area(area.start, area.end), true);
for (let row = area.start.row; row <= area.end.row; row++) {
for (let column = area.start.column; column <= area.end.column; column++) {
const cell = sheet.CellData({row, column});
if (cell && cell.formatted) {
if (typeof cell.formatted === 'string') {
list.push(cell.formatted);
}
else {
list.push(NumberFormat.FormatPartsAsText(cell.formatted));
}
}
}
}
}
return list;
}
/**
* @returns true if we need a recalc, because references have broken.
*/
protected DeleteSheetInternal(command: DeleteSheetCommand): boolean {
let is_active = false;
let index = -1;
let target_name = '';
let requires_recalc = false;
// remove from array. check if this is the active sheet
const named_sheet = command.name ? command.name.toLowerCase() : '';
const sheets = this.model.sheets.list.filter((sheet, i) => {
if (i === command.index || sheet.id === command.id || sheet.name.toLowerCase() === named_sheet) {
is_active = (sheet === this.active_sheet);
this.model.named.RemoveRangesForSheet(sheet.id);
target_name = sheet.name;
index = i;
return false;
}
return true;
});
// NOTE: we might want to remove references to this sheet. see
// how we patch references in insert columns/rows functions.
// actually note the logic we need is already in the rename sheet
// function; we just need to split it out from actually renaming the
// sheet, then we can use it
if (target_name) {
const count = this.RenameSheetReferences(sheets, target_name, '#REF');
if (count > 0) {
requires_recalc = true;
}
}
// empty? create new, activate
// UPDATE: we also need to create if all remaining sheets are hidden
if (!sheets.length) {
sheets.push(Sheet.Blank(this.model.theme_style_properties));
index = 0;
}
else if (sheets.every(test => !test.visible)) {
// why insert at 0 here? shouldn't it still be last,
// even if all the others are empty?
sheets.unshift(Sheet.Blank(this.model.theme_style_properties));
index = 0;
}
else {
if (index >= sheets.length) {
index = 0;
}
while (!sheets[index].visible) {
index++;
}
}
// this.model.sheets = sheets;
this.model.sheets.Assign(sheets);
// need to activate a new sheet? use the next one (now in the slot
// we just removed). this will roll over properly if we're at the end.
// UPDATE: we need to make sure that the target is not hidden, or we
// can't activate it
if (is_active) {
// console.info('activate @', index);
this.ActivateSheetInternal({ key: CommandKey.ActivateSheet, index });
}
return requires_recalc;
}
/**
* rename a sheet. this requires changing any formulae that refer to the
* old name to refer to the new name. if there are any references by ID
* those don't have to change.
*
* FIXME: can we do this using the dependency graph? (...)
*/
protected RenameSheetInternal(target: Sheet, name: string) {
// validate name... ?
if (!name || IllegalSheetNameRegex.test(name)) {
throw new Error('invalid sheet name');
}
// also can't have two sheets with the same name
const compare = name.toLowerCase();
for (const sheet of this.model.sheets.list) {
if (sheet !== target && sheet.name.toLowerCase() === compare) {
throw new Error('sheet name already exists');
}
}
// function will LC the name
// const old_name = target.name.toLowerCase();
const old_name = target.name;
target.name = name;
// need to update indexes
this.model.sheets.Assign(this.model.sheets.list);
this.RenameSheetReferences(this.model.sheets.list, old_name, name);
}
/**
*
*/
protected SortTableInternal(command: SortTableCommand): Area|undefined {
if (!command.table.area.start.sheet_id) {
throw new Error('table has invalid area');
}
const sheet = this.model.sheets.Find(command.table.area.start.sheet_id);
if (!sheet) {
throw new Error('invalid sheet in table area');
}
// I guess we're sorting on calculated value? seems weird.
// NOTE: only sort hidden rows... what to do with !hidden rows? do they
// get sorted anyway? [A: no, leave them as-is]
const ranked: Array<{
row: number;
text: string;
number: number;
type: ValueType;
data: ClipboardCellData[];
}> = [];
// get a list of visible table rows. that will be our insert map at the end
const visible: number[] = [];
let end = command.table.area.end.row;
if (command.table.totals_row) {
end--;
}
// for auto-sort
let text_count = 0;
let number_count = 0;
for (let row = command.table.area.start.row + 1; row <= end; row++) {
const height = sheet.GetRowHeight(row);
if (height) {
visible.push(row);
}
else {
continue;
}
const row_data = {
row,
number: 0,
text: '',
type: ValueType.undefined,
data: [] as ClipboardCellData[],
};
for (let column = command.table.area.start.column; column <= command.table.area.end.column; column++) {
const cd = sheet.CellData({row, column});
// sort column is relative to table
if (column === command.column + command.table.area.start.column) {
const check_type = cd.calculated_type || cd.type;
if (check_type === ValueType.string) {
text_count++;
}
else if (check_type === ValueType.number) {
number_count++;
}
// we can precalculate the type for sorting
const value = cd.calculated_type ? cd.calculated : cd.value;
row_data.text = value?.toString() || '';
row_data.number = Number(value) || 0;
row_data.type = cd.calculated_type || cd.type;
}
row_data.data.push({
address: {row, column},
data: cd.value,
type: cd.type,
style: cd.style,
});
}
ranked.push(row_data);
}
// auto sort - default to text, unless we see more numbers
let sort_type = command.type;
if (sort_type === 'auto') {
if (number_count > text_count) {
sort_type = 'numeric';
}
else {
sort_type = 'text';
}
}
// console.info(visible, ranked);
// rank
const invert = command.asc ? 1 : -1;
switch (sort_type) {
case 'numeric':
ranked.sort((a, b) => {
if (a.type === ValueType.undefined) {
return ((b.type === ValueType.undefined) ? 0 : 1);
}
if (b.type === ValueType.undefined) {
return -1;
}
return (a.number - b.number) * invert;
});
break;
case 'text':
default:
ranked.sort((a, b) => {
if (a.type === ValueType.undefined) {
return ((b.type === ValueType.undefined) ? 0 : 1);
}
if (b.type === ValueType.undefined) {
return -1;
}
return a.text.localeCompare(b.text) * invert;
});
break;
}
// now apply the sort
const insert = {row: command.table.area.start.row + 1, column: command.table.area.start.column };
for (let i = 0; i < visible.length; i++) {
insert.row = visible[i];
const entry = ranked[i];
insert.column = command.table.area.start.column; // reset
for (const cell of entry.data) {
if (cell.type === ValueType.formula) {
let data = cell.data as string;
const offsets = { columns: 0, rows: insert.row - entry.row };
const parse_result = this.parser.Parse(data);
if (parse_result.expression) {
data = '=' + this.parser.Render(parse_result.expression, { offset: offsets, missing: ''});
}
sheet.SetCellValue(insert, data);
}
else {
sheet.SetCellValue(insert, cell.data);
}
sheet.UpdateCellStyle(insert, cell.style || {}, false);
insert.column++; // step
}
}
// keep reference. we don't have the actual table, we have a copy.
// this is done because the command queue might be broadcast, so
// references won't work.
const ref = this.model.tables.get(command.table.name.toLowerCase());
if (ref) {
ref.sort = {
type: command.type,
asc: !!command.asc,
column: command.column,
};
}
// flush style in rows that don't change, to force repainting. this
// has to do with how table styles are overlaid on other styles; it's
// not optimal at the moment.
{
let row = command.table.area.start.row;
for (let column = command.table.area.start.column; column <= command.table.area.end.column; column++) {
sheet.cells.data[row][column]?.FlushStyle();
}
if (command.table.totals_row) {
row = command.table.area.end.row;
for (let column = command.table.area.start.column; column <= command.table.area.end.column; column++) {
sheet.cells.data[row][column]?.FlushStyle();
}
}
}
// console.info(ordered);
return new Area(command.table.area.start, command.table.area.end);
}
/**
* update all columns of a table (collect column names). this
* method rebuilds all columns; that's probably unecessary in
* many cases, but we'll start here and we can drill down later.
*
* we do two things here: we normalize column header values, and
* we collect them for table headers.
*
* @param table
*/
protected UpdateTableColumns(table: Table): IArea {
if (!table.area.start.sheet_id) {
throw new Error('invalid area in table');
}
const sheet = this.model.sheets.Find(table.area.start.sheet_id);
if (!sheet) {
throw new Error('invalid sheet in table');
}
// this can get called when a document is loaded, we might
// not have column names when we start. but if we do, we will
// need to keep the old ones so we can check deltas.
const current_columns = table.columns?.slice(0) || undefined;
const columns: string[] = [];
const row = table.area.start.row;
const count = table.area.end.column - table.area.start.column + 1;
let column = table.area.start.column;
for (let i = 0; i < count; i++, column++) {
const header = sheet.CellData({row, column});
let value = '';
if (header.type !== ValueType.string) {
if (typeof header.formatted !== 'undefined') {
value = (header.formatted).toString();
}
else if (typeof header.calculated !== 'undefined') {
value = (header.calculated).toString();
}
else if (typeof header.value !== 'undefined') {
value = (header.value).toString();
}
header.Set(value, ValueType.string);
}
else {
value = (header.value as string) || '';
}
if (!value) {
value = `Column${i + 1}`;
}
let proposed = value;
let success = false;
let index = 1;
while (!success) {
success = true;
inner_loop:
for (const check of columns) {
if (check.toLowerCase() === proposed.toLowerCase()) {
success = false;
proposed = `${value}${++index}`;
break inner_loop;
}
}
}
header.Set(proposed, ValueType.string);
columns.push(proposed.toLowerCase());
}
// TODO: this is good, and works, but we are going to have to
// look for structured references and update them if the column
// names change.
if (current_columns) {
// if we are inserting/removing columns, we're probably
// not changing names at the same time. on remove, some
// references will break, but that's to be expected. on
// insert, new columns will get added but we don't have
// to change references.
if (current_columns.length === columns.length) {
const update: Map = new Map();
for (let i = 0; i < current_columns.length; i++) {
const compare = current_columns[i].toLowerCase();
if (compare !== columns[i]) {
update.set(compare, columns[i]); // add old -> new
}
}
if (update.size) {
// OK, we need to update. we're iterating cells, then
// updates, so we don't accidentally oscillate if we have
// columns that swap names. going through once should
// ensure that doesn't happen.
const table_name = table.name.toLowerCase();
for (const sheet of this.model.sheets.list) {
// there's an additional complication: we support anonymous
// tables, if the cell is in the table. so we also have to
// know the address. so we can't use the IterateAll method.
// duh, no we don't. if the cell is in the table it will have
// a reference.
for (const cell of sheet.cells.Iterate()) {
if (cell.ValueIsFormula()) {
let updated_formula = false;
const parse_result = this.parser.Parse(cell.value);
if (parse_result.expression) {
this.parser.Walk(parse_result.expression, (unit) => {
if (unit.type === 'structured-reference') {
if (unit.table.toLowerCase() === table_name ||
(!unit.table && cell.table === table)) {
// we may need to rewrite...
for (const [key, value] of update.entries()) {
if (unit.column.toLowerCase() === key) {
// ok we need to update
unit.column = value;
updated_formula = true;
}
}
}
}
return true;
});
if (updated_formula) {
console.info('updating value');
cell.value = '=' + this.parser.Render(parse_result.expression, {
missing: '',
});
}
}
}
}
/*
sheet.cells.IterateAll(cell => {
if (cell.ValueIsFormula()) {
let updated_formula = false;
const parse_result = this.parser.Parse(cell.value);
if (parse_result.expression) {
this.parser.Walk(parse_result.expression, (unit) => {
if (unit.type === 'structured-reference') {
if (unit.table.toLowerCase() === table_name ||
(!unit.table && cell.table === table)) {
// we may need to rewrite...
for (const [key, value] of update.entries()) {
if (unit.column.toLowerCase() === key) {
// ok we need to update
unit.column = value;
updated_formula = true;
}
}
}
}
return true;
});
if (updated_formula) {
console.info('updating value');
cell.value = '=' + this.parser.Render(parse_result.expression, {
missing: '',
});
}
}
}
});
*/
}
}
}
}
table.columns = columns;
return {
start: {
...table.area.start,
}, end: {
row: table.area.start.row,
column: table.area.end.column,
}
};
}
/**
* set range, via command. returns affected area.
*
* Adding a flags parameter (in/out) to support indicating
* that we need to update layout.
*/
protected SetRangeInternal(command: SetRangeCommand, flags: UpdateFlags = {}): Area|undefined {
// NOTE: apparently if we call SetRange with a single target
// and the array flag set, it gets translated to an area. which
// is OK, I guess, but there may be an unecessary branch in here.
const area = IsCellAddress(command.area)
? new Area(command.area)
: new Area(command.area.start, command.area.end);
const sheet = this.FindSheet(area);
if (!area.start.sheet_id) {
area.start.sheet_id = sheet.id;
}
if (!area.entire_row && !area.entire_column && (
area.end.row >= sheet.rows
|| area.end.column >= sheet.columns)) {
// we have to call this because the 'set area' method calls RealArea
sheet.cells.EnsureCell(area.end);
// should we send a structure event here? we may be increasing the
// size, in which case we should send the event. even though no addresses
// change, there are new cells.
if (sheet === this.active_sheet) {
flags.layout = true;
}
}
// originally we called sheet methods here, but all the sheet
// does is call methods on the cells object -- we can shortcut.
// is that a good idea? (...)
// at a minimum we can consolidate...
if (IsCellAddress(command.area)) {
// FIXME: should throw if we try to set part of an array
const cell = sheet.CellData(command.area);
if (cell.area && (cell.area.rows > 1 || cell.area.columns > 1)) {
this.Error(ErrorCode.array);
return;
}
// single cell
// UPDATE: could be array
// type is value|value[][], pull out first value. at some point
// we may have supported value[], or maybe they were passed in
// accidentally, but check regardless.
// FIXME: no, that should throw (or otherwise error) (or fix the data?).
// we can't handle errors all the way down the call stack.
let value = Array.isArray(command.value) ?
Array.isArray(command.value[0]) ? command.value[0][0] : command.value[0] : command.value;
// translate R1C1. in this case, we translate relative to the
// target address, irrspective of the array flag. this is the
// easiest case?
// NOTE: as noted above (top of function), if a single cell target
// is set with the array flag, it may fall into the next branch. not
// sure that makes much of a difference.
if (command.r1c1) {
value = this.TranslateR1C1(command.area, value);
}
if (command.array) {
// what is the case for this? not saying it doesn't happen, just
// when is it useful?
// A: there is the case in Excel where there are different semantics
// for array calculation; something we mentioned in one of the kb
// articles, something about array functions... [FIXME: ref?]
sheet.SetArrayValue(area, value);
}
else {
sheet.SetCellValue(command.area, value);
}
return area;
}
else {
// there are a couple of options here, from the methods that
// have accumulated in Sheet.
// SetArrayValue -- set data as an array
// SetAreaValues -- set values from data one-to-one
// SetAreaValue -- single value repeated in range
// FIXME: clean this up!
if (command.array) {
let value = Array.isArray(command.value) ?
Array.isArray(command.value[0]) ? command.value[0][0] : command.value[0] : command.value;
if (command.r1c1) {
value = this.TranslateR1C1(area.start, value);
}
sheet.SetArrayValue(area, value);
}
else {
// in this case, either value is a single value or it's a 2D array;
// and area is a range of unknown size. we do a 1-1 map from area
// member to data member. if the data is not the same shape, it just
// results in empty cells (if area is larger) or dropped data (if value
// is larger).
// so for the purposes of R1C1, we have to run the same loop that
// happens internally in the Cells.SetArea routine. but I definitely
// don't want R1C1 to get all the way in there.
// FIXME/TODO: we're doing this the naive way for now. it could be
// optimized in several ways.
if (command.r1c1) {
if (Array.isArray(command.value)) {
// loop on DATA, since that's what we care about here. we can
// expand data, since it won't spill in the next call (spill is
// handled earlier in the call stack).
for (let r = 0; r < command.value.length && r < area.rows; r++) {
if (!command.value[r]) {
command.value[r] = [];
}
const row = command.value[r];
for (let c = 0; c < row.length && c < area.columns; c++) {
const target: ICellAddress = { ...area.start, row: area.start.row + r, column: area.start.column + c };
row[c] = this.TranslateR1C1(target, row[c]);
}
}
}
else {
// only have to do this for strings
if (typeof command.value === 'string' && command.value[0] === '=') {
// we need to rebuild the value so it is an array, so that
// relative addresses will be relative to the cell.
const value: CellValue[][] = [];
for (let r = 0; r < area.rows; r++) {
const row: CellValue[] = [];
for (let c = 0; c < area.columns; c++) {
const target: ICellAddress = { ...area.start, row: area.start.row + r, column: area.start.column + c };
row.push(this.TranslateR1C1(target, command.value));
}
value.push(row);
}
command.value = value;
}
}
}
sheet.SetAreaValues2(area, command.value);
}
return area;
}
}
/**
* basic implementation does not handle any UI, painting, or layout.
*/
protected ActivateSheetInternal(command: ActivateSheetCommand) {
const candidate = this.ResolveSheet(command) || this.model.sheets.list[0];
if (this.active_sheet === candidate && !command.force) {
return;
}
if (!candidate.visible) {
throw new Error('cannot activate hidden sheet');
}
// hold this for the event (later)
const deactivate = this.active_sheet;
// select target
this.active_sheet = candidate;
/*
// scrub, then add any sheet annotations. note the caller will
// still have to inflate these or do whatever step is necessary to
// render.
const annotations = this.active_sheet.annotations;
for (const element of annotations) {
this.AddAnnotation(element, true);
}
*/
this.grid_events.Publish({
type: 'sheet-change',
deactivate,
activate: this.active_sheet,
});
}
protected ShowSheetInternal(command: ShowSheetCommand) {
const sheet = this.ResolveSheet(command);
// invalid
if (!sheet) { return; }
// not changed
if (sheet.visible === command.show) { return; }
// make sure at least one will be visible after the operation
if (!command.show) {
let count = 0;
for (const test of this.model.sheets.list) {
if (!sheet.visible || test === sheet) { count++; }
}
if (count >= this.model.sheets.length) {
throw new Error('can\'t hide all sheets');
}
}
// ok, set
sheet.visible = command.show;
// is this current?
if (sheet === this.active_sheet) {
// this needs to check the visibility field, or else we'll throw
// when we call the activate method. given the above check we know
// that there's at least one visible sheet.
const list = this.model.sheets.list;
// first find the _next_ visible sheet...
for (let i = 0; i < list.length; i++) {
if (list[i] === this.active_sheet) {
for (let j = i + 1; j < list.length; j++) {
if (list[j].visible) {
this.ActivateSheetInternal({
key: CommandKey.ActivateSheet,
index: j,
});
return;
}
}
// if we got here, then we need to start again from the beginning
for (let j = 0; j< list.length; j++) {
if (list[j].visible) {
this.ActivateSheetInternal({
key: CommandKey.ActivateSheet,
index: j,
});
return;
}
}
// should not be possible
throw new Error('no visible sheet');
}
}
}
}
/**
* normalize commands. for co-editing support we need to ensure that
* commands properly have sheet IDs in areas/addresses (and explicit
* fields in some cases).
*
* at the same time we're editing the commands a little bit to make
* them a little more consistent (within reason).
*
* @param commands
*/
protected NormalizeCommands(commands: Command|Command[]): Command[] {
if (!Array.isArray(commands)) {
commands = [commands];
}
const id = this.active_sheet.id;
for (const command of commands) {
switch (command.key) {
// nothing
case CommandKey.Null:
case CommandKey.TabColor:
case CommandKey.ShowHeaders:
case CommandKey.ShowSheet:
case CommandKey.AddSheet:
case CommandKey.DuplicateSheet:
case CommandKey.DeleteSheet:
case CommandKey.ActivateSheet:
case CommandKey.RenameSheet:
case CommandKey.ReorderSheet:
case CommandKey.Reset:
case CommandKey.CreateAnnotation:
case CommandKey.RemoveAnnotation:
break;
/*
// both
case CommandKey.Clear:
if (command.area) {
if (!command.area.start.sheet_id) {
command.area.start.sheet_id = id;
}
}
else {
if (!command.sheet_id) {
command.sheet_id = id;
}
}
break;
*/
// special cases
case CommandKey.SortTable:
case CommandKey.RemoveTable:
if (!command.table.area.start.sheet_id) {
command.table.area.start.sheet_id = id;
}
break;
case CommandKey.AddConditionalFormat:
if (!command.format.area.start.sheet_id) {
command.format.area.start.sheet_id = id;
}
break;
// field
case CommandKey.ResizeRows:
case CommandKey.ResizeColumns:
case CommandKey.InsertColumns:
case CommandKey.InsertRows:
case CommandKey.Freeze:
if (!command.sheet_id) {
command.sheet_id = id;
}
break;
// area: Area|Address (may be optional)
case CommandKey.Clear:
case CommandKey.SetNote:
case CommandKey.SetLink:
case CommandKey.Indent:
case CommandKey.UpdateBorders:
case CommandKey.MergeCells:
case CommandKey.UnmergeCells:
case CommandKey.DataValidation:
case CommandKey.SetRange:
case CommandKey.UpdateStyle:
case CommandKey.SetName:
case CommandKey.Select:
case CommandKey.InsertTable:
// note that remove conditional format could have a format
// object (with an area) instead of an area. but in that case,
// the format object must match an existing format, so it would
// have to have a proper area. there's no case where we would
// want to add it. so we only handle the area case.
// eslint-disable-next-line no-fallthrough
case CommandKey.RemoveConditionalFormat:
if (command.area) {
if (IsCellAddress(command.area)) {
if (!command.area.sheet_id) {
command.area.sheet_id = id;
}
}
else {
if (!command.area.start.sheet_id) {
command.area.start.sheet_id = id;
}
}
}
break;
default:
// this is intended to check that we've handled all cases. if you
// add additional commands and they're not handled, this should
// raise a ts error.
AssertNever(command);
}
}
return commands;
}
public TabColor(sheet: Sheet, color?: Color) {
this.ExecCommand({
key: CommandKey.TabColor,
sheet,
color,
});
}
/**
* add sheet. data only.
*/
protected AddSheetInternal(name = Sheet.default_sheet_name, insert_index = -1): number|undefined {
if (!this.options.add_tab) {
console.warn('add tab option not set or false');
return;
}
// validate name...
while (this.model.sheets.list.some((test) => test.name === name)) {
const match = name.match(/^(.*?)(\d+)$/);
if (match) {
name = match[1] + (Number(match[2]) + 1);
}
else {
name = name + '2';
}
}
// FIXME: structure event
const sheet = Sheet.Blank(this.model.theme_style_properties, name);
if (insert_index >= 0) {
this.model.sheets.Splice(insert_index, 0, sheet);
}
else {
this.model.sheets.Add(sheet);
}
// moved to ExecCmomand
// if (this.tab_bar) { this.tab_bar.Update(); }
return sheet.id;
}
/**
* resolve sheet in a command that uses the SheetSelection interface;
* that allows sheet selection by name, id or index.
*/
protected ResolveSheet(command: SheetSelection): Sheet|undefined {
// NOTE: since you are using typeof here to check for undefined,
// it seems like it would be efficient to use typeof to check
// the actual type; hence merging "index" and "name" might be
// more efficient than checking each one separately.
if (typeof command.index !== 'undefined') {
return this.model.sheets.list[command.index];
}
if (typeof command.name !== 'undefined') {
return this.model.sheets.Find(command.name);
}
if (command.id) {
return this.model.sheets.Find(command.id);
}
return undefined;
}
/**
* find sheet matching sheet_id in area.start, or active sheet
*
* FIXME: should return undefined on !match
* FIXME: should be in model, which should be a class
*/
protected FindSheet(identifier: number|IArea|ICellAddress|undefined): Sheet {
if (identifier === undefined) {
return this.active_sheet;
}
const id = typeof identifier === 'number' ? identifier : IsCellAddress(identifier) ? identifier.sheet_id : identifier.start.sheet_id;
if (!id || id === this.active_sheet.id) {
return this.active_sheet;
}
const sheet = this.model.sheets.Find(id);
if (sheet) {
return sheet;
}
/*
for (const test of this.model.sheets) {
if (test.id === id) {
return test;
}
}
*/
// FIXME: should return undefined here
return this.active_sheet;
}
/**
* this function now works for both rows and columns, and can handle
* sheets other than the active sheet. it does assume that you only ever
* add rows/columns on the active sheet, but since that's all parameterized
* you could get it to work either way.
*
* in fact we should change the names of those parameters so it's a little
* more generic.
*/
protected PatchFormulasInternal(source: string,
before_row: number,
row_count: number,
before_column: number,
column_count: number,
target_sheet_name: string,
is_target: boolean) {
const parsed = this.parser.Parse(source || '');
let modified = false;
// the sheet test is different for active sheet/non-active sheet.
// on the active sheet, check for no name OR name === active sheet name.
// on other sheets, check for name AND name === active sheet name.
if (parsed.expression) {
this.parser.Walk(parsed.expression, (element: ExpressionUnit) => {
if (element.type === 'range' || element.type === 'address') {
// we can test if we need to modify a range or an address, but the
// second address in a range can't be tested properly. so the solution
// here is to just capture the addresses that need to be modified
// from the range, and then not recurse (we should never get here
// as an address in a range).
const addresses: UnitAddress[] = [];
if (element.type === 'range') {
// there's a problem: this breaks because the inner test fails when
// this is TRUE... we may need to modify
// recurse if (1) explicit name match; or (2) no name AND we are on the active sheet
// return ((element.start.sheet && element.start.sheet.toLowerCase() === active_sheet_name) || (!element.start.sheet && active_sheet));
if ((element.start.sheet && element.start.sheet.toLowerCase() === target_sheet_name) || (!element.start.sheet && is_target)) {
addresses.push(element.start, element.end);
}
}
else if (element.type === 'address') {
if ((element.sheet && element.sheet.toLowerCase() === target_sheet_name) || (!element.sheet && is_target)) {
addresses.push(element);
}
}
// could switch the tests around? (referring to the count
// tests, which switch on operation)
for (const address of addresses) {
if (row_count && address.row >= before_row) {
if (row_count < 0 && address.row + row_count < before_row) {
address.column = address.row = -1;
}
else {
address.row += row_count;
}
modified = true;
}
if (column_count && address.column >= before_column) {
if (column_count < 0 && address.column + column_count < before_column) {
address.column = address.row = -1; // set as invalid (-1)
}
else {
address.column += column_count;
}
modified = true;
}
}
return false; // always explicit
}
return true; // recurse for everything else
});
if (modified) {
return '=' + this.parser.Render(parsed.expression, { missing: '' });
}
}
return undefined;
}
protected PatchExpressionSheetName(expression: string, old_name: string, name: string): string|undefined {
let modified = false;
const parsed = this.parser.Parse(expression || '');
if (parsed.expression) {
this.parser.Walk(parsed.expression, (element: ExpressionUnit) => {
if (element.type === 'address') {
if (element.sheet && element.sheet.toLowerCase() === old_name) {
element.sheet = name;
modified = true;
}
}
return true; // continue walk
});
if (modified) {
return '=' + this.parser.Render(parsed.expression, { missing: '' });
}
}
return undefined;
}
/**
* splitting this logic into a new function so we can reuse it
* for invalidating broken references. generally we'll call this
* on all sheets, but I wanted to leave the option open.
*
* @returns count of changes made. it's useful for the delete routine,
* so we can force a recalc.
*/
protected RenameSheetReferences(sheets: Sheet[], old_name: string, name: string): number {
let changes = 0;
old_name = old_name.toLowerCase();
for (const sheet of sheets) {
// cells
for (const cell of sheet.cells.Iterate()) {
if (cell.ValueIsFormula()) {
const updated = this.PatchExpressionSheetName(cell.value||'', old_name, name);
if (updated) {
cell.value = updated;
changes++;
}
}
}
/*
sheet.cells.IterateAll((cell: Cell) => {
if (cell.ValueIsFormula()) {
const updated = this.PatchExpressionSheetName(cell.value||'', old_name, name);
if (updated) {
cell.value = updated;
changes++;
}
}
});
*/
// conditionals
if (sheet.conditional_formats?.length) {
for (const format of sheet.conditional_formats) {
switch (format.type) {
case 'cell-match':
case 'expression':
{
const updated = this.PatchExpressionSheetName(format.expression, old_name, name);
if (updated) {
format.expression = updated;
changes++;
}
}
break;
}
}
}
// annotations
for (const annotation of sheet.annotations) {
if (annotation.data.formula) {
const updated = this.PatchExpressionSheetName(annotation.data.formula, old_name, name);
if (updated) {
annotation.data.formula = updated;
changes++;
}
}
}
}
for (const element of this.model.connected_elements.values()) {
if (element.formula) {
const updated = this.PatchExpressionSheetName(element.formula, old_name, name);
if (updated) {
element.formula = updated;
changes++;
}
}
}
return changes;
}
/**
* these are all addative except for "none", which removes all borders.
*
* we no longer put borders into two cells at once (hurrah!). however
* we still need to do some maintenance on the mirror cells -- because
* if you apply a border to cell A1, then that should take precedence
* over any border previously applied to cell A2.
*
* FIXME: is that right? perhaps we should just leave whatever the user
* did -- with the exception of clearing, which should always mirror.
*
*
* UPDATE: modifying function for use with ExecCommand. runs the style
* updates and returns the affected area.
*
*/
protected ApplyBordersInternal(command: UpdateBordersCommand) {
const borders = command.borders;
const width = (command.borders === BorderConstants.None)
? 0 : command.width;
const area = new Area(command.area.start, command.area.end);
const sheet = this.FindSheet(area);
area.start.sheet_id = sheet.id; // ensure
/*
let sheet = this.active_sheet;
if (command.area.start.sheet_id && command.area.start.sheet_id !== this.active_sheet.id) {
for (const compare of this.model.sheets) {
if (compare.id === command.area.start.sheet_id) {
sheet = compare;
break;
}
}
}
*/
const top: CellStyle = { border_top: width };
const bottom: CellStyle = { border_bottom: width };
const left: CellStyle = { border_left: width };
const right: CellStyle = { border_right: width };
const clear_top: CellStyle = { border_top: 0, border_top_fill: {} };
const clear_bottom: CellStyle = { border_bottom: 0, border_bottom_fill: {} };
const clear_left: CellStyle = { border_left: 0, border_left_fill: {} };
const clear_right: CellStyle = { border_right: 0, border_right_fill: {} };
// default to "none", which means "default"
//if (!command.color) {
// command.color = 'none';
//}
//if (typeof command.color !== 'undefined') {
if (command.color) {
// this is now an object so we need to clone it (might be faster to JSON->JSON)
top.border_top_fill = {...command.color};
bottom.border_bottom_fill = {...command.color};
left.border_left_fill = {...command.color};
right.border_right_fill = {...command.color};
}
else {
// otherwise we should be sure to clear any color
top.border_top_fill = {};
bottom.border_bottom_fill = {};
left.border_left_fill = {};
right.border_right_fill = {};
}
// inside all/none
if (borders === BorderConstants.None || borders === BorderConstants.All) {
sheet.UpdateAreaStyle(area, {
...top, ...bottom, ...left, ...right,
}, true);
}
// top
if (borders === BorderConstants.Top || borders === BorderConstants.Outside) {
if (!area.entire_column) {
sheet.UpdateAreaStyle(area.top, { ...top }, true);
}
}
// mirror top (CLEAR)
if (borders === BorderConstants.None || borders === BorderConstants.All ||
borders === BorderConstants.Outside || borders === BorderConstants.Top) {
if (!area.entire_column) {
if (area.start.row) {
sheet.UpdateAreaStyle(new Area(
{ row: area.start.row - 1, column: area.start.column },
{ row: area.start.row - 1, column: area.end.column }), { ...clear_bottom }, true);
}
}
}
// bottom
if (borders === BorderConstants.Bottom || borders === BorderConstants.Outside) {
if (!area.entire_column) {
sheet.UpdateAreaStyle(area.bottom, { ...bottom }, true);
}
}
// mirror bottom (CLEAR)
if (borders === BorderConstants.None || borders === BorderConstants.All ||
borders === BorderConstants.Outside || borders === BorderConstants.Bottom) {
if (!area.entire_column) {
sheet.UpdateAreaStyle(new Area(
{ row: area.end.row + 1, column: area.start.column },
{ row: area.end.row + 1, column: area.end.column }), { ...clear_top }, true);
}
}
// left
if (borders === BorderConstants.Left || borders === BorderConstants.Outside) {
if (!area.entire_row) {
sheet.UpdateAreaStyle(area.left, { ...left }, true);
}
}
// mirror left (CLEAR)
if (borders === BorderConstants.None || borders === BorderConstants.All ||
borders === BorderConstants.Outside || borders === BorderConstants.Left) {
if (!area.entire_row) {
if (area.start.column) {
sheet.UpdateAreaStyle(new Area(
{ row: area.start.row, column: area.start.column - 1 },
{ row: area.end.row, column: area.start.column - 1 }), { ...clear_right }, true);
}
}
}
// right
if (borders === BorderConstants.Right || borders === BorderConstants.Outside) {
if (!area.entire_row) {
sheet.UpdateAreaStyle(area.right, { ...right }, true);
}
}
// mirror right (CLEAR)
if (borders === BorderConstants.None || borders === BorderConstants.All ||
borders === BorderConstants.Outside || borders === BorderConstants.Right) {
if (!area.entire_row) {
sheet.UpdateAreaStyle(new Area(
{ row: area.start.row, column: area.end.column + 1 },
{ row: area.end.row, column: area.end.column + 1 }), { ...clear_left }, true);
}
}
/*
// why is there not an expand method on area? (FIXME)
this.DelayedRender(false, new Area({
row: Math.max(0, area.start.row - 1),
column: Math.max(0, area.start.column - 1),
}, {
row: area.end.row + 1,
column: area.end.column + 1,
}));
// NOTE: we don't have to route through the sheet. we are the only client
// (we republish). we can just publish directly.
this.grid_events.Publish({ type: 'style', area });
*/
return Area.Bleed(area);
/*
return new Area(
{
row: Math.max(0, area.start.row - 1),
column: Math.max(0, area.start.column - 1),
}, {
row: area.end.row + 1,
column: area.end.column + 1,
},
);
*/
}
protected TranslateR1C1(address: ICellAddress, value: CellValue): CellValue {
let transformed = false;
const cached = this.parser.flags.r1c1;
this.parser.flags.r1c1 = true; // set
if (typeof value === 'string' && value[0] === '=') {
const result = this.parser.Parse(value);
if (result.expression) {
this.parser.Walk(result.expression, unit => {
if (unit.type === 'address' && unit.r1c1) {
transformed = true;
// translate...
if (unit.offset_column) {
unit.absolute_column = false;
unit.column = unit.column + address.column;
}
else {
unit.absolute_column = true;
}
if (unit.offset_row) {
unit.absolute_row = false;
unit.row = unit.row + address.row;
}
else {
unit.absolute_row = true;
}
}
return true;
});
if (transformed) {
/*
if (!this.flags.warned_r1c1) {
// 1-time warning
this.flags.warned_r1c1 = true;
console.warn('NOTE: R1C1 support is experimental. the semantics may change in the future.');
}
*/
value = '=' + this.parser.Render(result.expression, { missing: '' });
}
}
}
this.parser.flags.r1c1 = cached; // reset
return value;
}
protected ClearAreaInternal(area: Area) {
// updated to use sheet ID. not sure why this was still using
// active sheet without checking ID.
let sheet: Sheet|undefined;
if (area.start.sheet_id) {
sheet = this.model.sheets.Find(area.start.sheet_id);
}
else {
sheet = this.active_sheet;
}
if (!sheet) {
console.warn(`can't resolve sheet in ClearAreaInternal`);
return;
}
// let error = false;
area = sheet.RealArea(area); // collapse
for (const cell of sheet.cells.Iterate(area)) {
if (cell.area && !area.ContainsArea(cell.area)) {
this.Error(ErrorCode.array);
return;
}
}
/*
sheet.cells.Apply(area, (cell) => {
if (cell.area && !area.ContainsArea(cell.area)) {
// throw new Error('can\'t change part of an array');
error = true;
}
});
*/
// if the area completely encloses a table, delete the table
const table_keys = this.model.tables.keys();
for (const key of table_keys) {
const table = this.model.tables.get(key);
if (table && table.area.start.sheet_id === sheet.id) {
const table_area = new Area(table.area.start, table.area.end);
if (area.ContainsArea(table_area)) {
for (let row = table_area.start.row; row <= table_area.end.row; row++) {
for (let column = table_area.start.column; column <= table.area.end.column; column++) {
const cell = sheet.cells.GetCell({row, column}, false);
if (cell) {
cell.table = undefined;
}
}
}
this.model.tables.delete(key);
}
}
}
/*
if (error) {
this.Error(ErrorCode.array); // `You can't change part of an array.`
}
else {
sheet.ClearArea(area);
}
*/
sheet.ClearArea(area);
}
/**
* send an error message. subscriber can figure out how to communicate it
* to users.
*
* dropping strings, now we only allow error constants (via enum)
*
* @param message
*/
protected Error(message: ErrorCode) {
/*
console.info('Error', message);
if (typeof message === 'string') {
this.grid_events.Publish({
type: 'error',
message,
});
}
else {
this.grid_events.Publish({
type: 'error',
code: message,
});
}
*/
this.grid_events.Publish({
type: 'error',
code: message,
});
}
/**
* this breaks (or doesn't work) if the add_tab option is false; that's
* fine, although we might want to make a distinction between UI add-tab
* and API add-tab. And allow it from the API.
*
* @param command
* @returns
*/
private DuplicateSheetInternal(command: DuplicateSheetCommand) {
if (!this.options.add_tab) {
console.warn('add tab option not set or false');
return;
}
const source = this.ResolveSheet(command);
const next_id = this.model.sheets.list.reduce((id, sheet) => Math.max(id, sheet.id), 0) + 1;
let insert_index = -1;
for (let i = 0; i < this.model.sheets.length; i++) {
if (this.model.sheets.list[i] === source) {
insert_index = i + 1;
}
}
if (!source || insert_index < 0) {
throw new Error('source sheet not found');
}
// explicit insert index
if (typeof command.insert_before === 'number') {
insert_index = command.insert_before;
}
else if (typeof command.insert_before === 'string') {
const lc = command.insert_before.toLowerCase();
for (let i = 0; i < this.model.sheets.length; i++) {
if (this.model.sheets.list[i].name.toLowerCase() === lc) {
insert_index = i;
break;
}
}
}
const options: SerializeOptions = {
rendered_values: true,
};
const clone = Sheet.FromJSON(source.toJSON(options), this.model.theme_style_properties);
let name = command.new_name || source.name;
while (this.model.sheets.list.some((test) => test.name === name)) {
const match = name.match(/^(.*?)(\d+)$/);
if (match) {
name = match[1] + (Number(match[2]) + 1);
}
else {
name = name + '2';
}
}
clone.name = name;
clone.id = next_id;
// console.info('CLONE', clone.id, clone);
this.model.sheets.Splice(insert_index, 0, clone);
// if (this.tab_bar) { this.tab_bar.Update(); }
return clone.id;
}
/**
* this is the callback method for the command-log select command
* (which is not widely used). it does nothing. the specialization
* should do something.
*
* @param command
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected SelectInternal(command: SelectCommand) {
// does nothing
}
protected FreezeInternal(command: FreezeCommand) {
const sheet = this.FindSheet(command.sheet_id || this.active_sheet.id);
sheet.freeze.rows = command.rows;
sheet.freeze.columns = command.columns;
}
/**
* patch an expression for insert/delete row/column operations.
* FIXME: should move, maybe to parser? (...)
*
* NOTE: did I write this twice? we only need one. check which one is better.
* @see PatchFormulasInternal
*
* @returns the updated expression, or `undefined` if no changes were made.
*/
protected PatchExpression(expression: string, options: PatchOptions) {
let count = 0;
const parse_result = this.parser.Parse(expression);
if (parse_result.expression) {
this.parser.Walk(parse_result.expression, unit => {
if (unit.type === 'range') {
if (!unit.start.sheet || (unit.start.sheet.toLowerCase() === options.sheet.name.toLowerCase())) {
const updated = Area.PatchArea(unit, options);
if (updated) {
unit.start.row = updated.start.row;
unit.start.column = updated.start.column;
unit.end.row = updated.end.row;
unit.end.column = updated.end.column;
}
else {
// FIXME: maybe have options for this? we don't really have a
// good way to replace nodes atm
unit.start.row = unit.end.row = unit.start.column = unit.end.column = -1;
}
}
count++;
return false;
}
else if (unit.type === 'address') {
const updated = Area.PatchArea({start: unit, end: unit}, options);
if (updated) {
unit.row = updated.start.row;
unit.column = updated.start.column;
}
else {
// see above
unit.row = unit.column = -1;
}
count++;
return false;
}
return true;
});
if (count) {
const rendered = this.parser.Render(parse_result.expression, {
missing: '',
});
// console.info("FROM", expression, "TO", rendered);
return rendered;
}
}
return undefined;
}
/**
* patch sheet conditionals for insert/delete row/column operations.
* some of them may be deleted.
*
* UPDATE: using this routine to also patch data validations
*/
protected PatchConditionalsAndValidations(options: PatchOptions) {
if (options.sheet.data_validation.length) {
const delete_list: Set = new Set();
for (const validation of options.sheet.data_validation) {
const targets: IArea[] = [];
for (const area of validation.target) {
const updated = Area.PatchArea(area, options);
if (updated) {
targets.push(updated);
}
}
if (targets.length > 0) {
validation.target = targets;
// format the range, if necessary
if (validation.type === 'range') {
if (validation.area.start.sheet_id === options.sheet.id) {
const updated = Area.PatchArea(validation.area, options);
if (updated) {
validation.area = updated;
}
else {
delete_list.add(validation);
}
}
}
}
else {
delete_list.add(validation);
}
}
if (delete_list.size) {
options.sheet.data_validation = options.sheet.data_validation.filter(test => !delete_list.has(test));
}
}
if (options.sheet.conditional_formats?.length) {
const delete_list: Set = new Set();
for (const format of options.sheet.conditional_formats) {
// first adjust the format area
const updated = Area.PatchArea(format.area, options);
if (updated) {
format.area = JSON.parse(JSON.stringify(updated));
}
else {
delete_list.add(format);
continue; // don't bother with formula
}
// next update the formula, if necessary. what do we do if the
// area has disappeared? should be a #REF error, not sure we
// can encode that properly
switch (format.type) {
case 'expression':
case 'cell-match':
{
const updated = this.PatchExpression(format.expression, options);
if (updated) {
format.expression = updated;
}
}
break;
}
}
if (delete_list.size) {
options.sheet.conditional_formats = options.sheet.conditional_formats.filter(test => !delete_list.has(test));
}
}
}
/**
* FIXME: should be API method
* FIXME: need to handle annotations that are address-based
*
* @see InsertColumns for inline comments
*/
protected InsertRowsInternal(command: InsertRowsCommand): {
error?: boolean;
update_annotations_list?: Annotation[];
resize_annotations_list?: Annotation[];
delete_annotations_list?: Annotation[];
} {
const target_sheet = this.FindSheet(command.sheet_id);
if (command.count === Infinity) {
command.count = 1; // ?
}
else if (command.count === -Infinity) {
command.count = -target_sheet.rows; // delete all
}
if (!target_sheet.InsertRows(command.before_row, command.count)){
// this.Error(`You can't change part of an array.`);
this.Error(ErrorCode.array);
return { error: true };
}
// conditionals
this.PatchConditionalsAndValidations({
sheet: target_sheet,
before_column: 0,
column_count: 0,
before_row: command.before_row,
row_count: command.count
});
// connected elements
for (const external of this.model.connected_elements.values()) {
if (external.formula) {
const modified = this.PatchFormulasInternal(external.formula,
command.before_row, command.count, 0, 0,
target_sheet.name.toLowerCase(), false);
if (modified) {
external.formula = modified;
}
}
}
// see InsertColumnsInternal re: tables. rows are less complicated,
// except that if you delete the header row we want to remove the
// table entirely.
const tables = Array.from(this.model.tables.values());
for (const table of tables) {
if (table.area.start.sheet_id === command.sheet_id) {
if (command.count > 0) {
if (command.before_row <= table.area.start.row) {
// shift the table down
//console.info("shift table down");
table.area.start.row += command.count;
table.area.end.row += command.count;
}
else if (command.before_row <= table.area.end.row) {
// insert rows. we need to add references to
// cells that have been inserted.
// console.info("insert table rows");
table.area.end.row += command.count;
for (let row = table.area.start.row; row <= table.area.end.row; row++) {
for (let column = table.area.start.column; column <= table.area.end.column; column++) {
const cell = target_sheet.CellData({row, column});
cell.table = table;
}
}
}
}
else {
if (command.before_row <= table.area.start.row) {
if (command.before_row - command.count <= table.area.start.row) {
// shift table up
table.area.start.row += command.count;
table.area.end.row += command.count;
}
else if (command.before_row - command.count >= table.area.end.row) {
// remove the entire table
this.model.tables.delete(table.name.toLowerCase());
}
else {
// assuming this will remove the header row, drop the table
// altogether. the alternative is to just not let you remove
// this row. but that should be handled before you get here;
// if you get here, and you want to delete the row, then the
// table will go.
this.model.tables.delete(table.name.toLowerCase());
for (let row = command.before_row; row <= table.area.end.row; row++) {
for (let column = table.area.start.column; column <= table.area.end.column; column++) {
const cell = target_sheet.CellData({row, column});
if (cell.table === table) {
cell.table = undefined;
}
}
}
}
}
else if (command.before_row <= table.area.end.row) {
// remove table rows from the end. cap.
// we may be removing the totals row -- in that case, update the table to reflect.
if (command.before_row - command.count > table.area.end.row) {
table.totals_row = false;
}
table.area.end.row = Math.max(0, table.area.end.row + command.count, command.before_row - 1);
}
}
}
}
this.model.named.PatchNamedRanges(target_sheet.id, 0, 0, command.before_row, command.count);
const target_sheet_name = target_sheet.name.toLowerCase();
for (const sheet of this.model.sheets.list) {
const is_target = sheet === target_sheet;
for (const cell of sheet.cells.Iterate()) {
if (cell.ValueIsFormula()) {
const modified = this.PatchFormulasInternal(cell.value || '',
command.before_row, command.count, 0, 0,
target_sheet_name, is_target);
if (modified) {
cell.value = modified;
}
}
}
/*
sheet.cells.IterateAll((cell: Cell) => {
if (cell.ValueIsFormula()) {
const modified = this.PatchFormulasInternal(cell.value || '',
command.before_row, command.count, 0, 0,
target_sheet_name, is_target);
if (modified) {
cell.value = modified;
}
}
});
*/
for (const annotation of sheet.annotations) {
if (annotation.data.formula) {
const modified = this.PatchFormulasInternal(annotation.data.formula || '',
command.before_row, command.count, 0, 0,
target_sheet_name, is_target);
if (modified) {
annotation.data.formula = modified;
}
}
}
}
// annotations
const update_annotations_list: Annotation[] = [];
const resize_annotations_list: Annotation[] = [];
const delete_annotations_list: Annotation[] = [];
if (command.count > 0) { // insert
const first = command.before_row;
for (const annotation of target_sheet.annotations) {
if (annotation.data.layout) {
const [start, end, endy] = [
annotation.data.layout.tl.address.row,
annotation.data.layout.br.address.row,
annotation.data.layout.br.offset.y,
];
if (first <= start ) {
// start case 1: starts above the annotation (including exactly at the top)
// shift
annotation.data.layout.tl.address.row += command.count;
annotation.data.layout.br.address.row += command.count;
}
else if (first < end || first === end && endy > 0) {
// start case 2: starts in the annotation, omitting the first row
annotation.data.layout.br.address.row += command.count;
// size changing
resize_annotations_list.push(annotation);
}
else {
// do nothing
continue;
}
update_annotations_list.push(annotation);
}
}
}
else if (command.count < 0) { // delete
// first and last column deleted
const first = command.before_row;
const last = command.before_row - command.count - 1;
for (const annotation of target_sheet.annotations) {
if (annotation.data.layout) {
// start and end row of the annotation. recall that in
// this layout, the annotation may extend into the (first,last)
// row but not beyond it. the offset is _within_ the row.
const [start, end, endy] = [
annotation.data.layout.tl.address.row,
annotation.data.layout.br.address.row,
annotation.data.layout.br.offset.y,
];
if (first <= start ) {
// start case 1: starts above the annotation (including exactly at the top)
if (last < start) {
// end case 1: ends before the annotation
// shift
annotation.data.layout.tl.address.row += command.count;
annotation.data.layout.br.address.row += command.count;
}
else if (last < end - 1 || (last === end -1 && endy > 0)) {
// end case 2: ends before the end of the annotation
// shift + cut
annotation.data.layout.tl.address.row = first;
annotation.data.layout.tl.offset.y = 0;
annotation.data.layout.br.address.row += command.count;
// size changing
resize_annotations_list.push(annotation);
}
else {
// end case 3: ends after the annotation
// drop the annotation
delete_annotations_list.push(annotation);
continue;
}
}
else if (first < end || first === end && endy > 0) {
// start case 2: starts in the annotation, omitting the first row
if (last < end - 1 || (last === end -1 && endy > 0)) {
// end case 2: ends before the end of the annotation
// shorten
annotation.data.layout.br.address.row += command.count;
// size changing
resize_annotations_list.push(annotation);
}
else {
// end case 3: ends after the annotation
// clip
annotation.data.layout.br.address.row = first;
annotation.data.layout.br.offset.y = 0;
// size changing
resize_annotations_list.push(annotation);
}
}
else {
// start case 3: starts after the annotation
// do nothing
continue;
}
update_annotations_list.push(annotation);
}
}
}
for (const annotation of delete_annotations_list) {
target_sheet.annotations = target_sheet.annotations.filter(test => test !== annotation);
}
return {
update_annotations_list,
resize_annotations_list,
delete_annotations_list,
};
}
/**
*
*/
protected InsertColumnsInternal(command: InsertColumnsCommand): {
error?: boolean;
update_annotations_list?: Annotation[];
resize_annotations_list?: Annotation[];
delete_annotations_list?: Annotation[];
} {
const target_sheet = this.FindSheet(command.sheet_id);
// it seems like we never get an insert infinity. not sure why,
// but the UI is blocking that. we should handle it anyway jic
if (command.count === Infinity) {
command.count = 1; // ?
}
else if (command.count === -Infinity) {
command.count = -target_sheet.columns; // delete all
}
// FIXME: we need to get this error out earlier. before this call,
// in the call that generates the insert event. otherwise if we
// have remotes, everyone will see the error -- we only want the
// actual actor to see the error.
if (!target_sheet.InsertColumns(command.before_column, command.count)) {
// this.Error(`You can't change part of an array.`);
this.Error(ErrorCode.array);
return { error: true };
}
// conditionals
this.PatchConditionalsAndValidations({
sheet: target_sheet,
before_column: command.before_column,
column_count: command.count,
before_row: 0,
row_count: 0 });
// connected elements
for (const element of this.model.connected_elements.values()) {
if (element.formula) {
const modified = this.PatchFormulasInternal(element.formula,
0, 0, command.before_column, command.count,
target_sheet.name.toLowerCase(), false);
if (modified) {
element.formula = modified;
}
}
}
// patch tables. we removed this from the sheet routine entirely,
// we need to rebuild any affected tables now.
// NOTE: we may drop tables, so we can't use a live iterator. or
// is the iterator precomputed? not sure. let's flatten immediately jic.
const tables = Array.from(this.model.tables.values());
for (const table of tables) {
if (table.area.start.sheet_id === command.sheet_id) {
if (command.count > 0) {
if (command.before_column <= table.area.start.column) {
// shift the table to the right. update the table reference,
// we can skip updating headers as the columns haven't changed.
// console.info("shift table right");
table.area.start.column += command.count;
table.area.end.column += command.count;
}
else if (command.before_column <= table.area.end.column) {
// insert columns -- we need to add references to new
// cells, and update headers.
// console.info("insert table columns");
table.area.end.column += command.count;
for (let row = table.area.start.row; row <= table.area.end.row; row++) {
for (let column = table.area.start.column; column <= table.area.end.column; column++) {
const cell = target_sheet.CellData({row, column});
cell.table = table;
}
}
this.UpdateTableColumns(table);
}
}
else {
if (command.before_column <= table.area.start.column) {
if (command.before_column - command.count <= table.area.start.column) {
// shift table left. update the table reference, we can skip headers.
// console.info("shift table left");
table.area.start.column += command.count;
table.area.end.column += command.count;
}
else if (command.before_column - command.count >= table.area.end.column ){
// remove entire table. cells are already removed, we can just
// drop the table from the model.
// console.info("remove table");
this.model.tables.delete(table.name.toLowerCase());
}
else {
// shift to the left, then remove table columns. cells are
// already removed, so we don't need to touch cells; just
// update the reference and column headers.
// console.info("remove table columns (1)");
table.area.start.column = command.before_column;
table.area.end.column += command.count;
this.UpdateTableColumns(table);
}
}
else if (command.before_column <= table.area.end.column) {
// remove table columns. as above. cap.
// console.info("remove table columns (2)");
table.area.end.column = Math.max(0, table.area.end.column + command.count, command.before_column - 1);
this.UpdateTableColumns(table);
}
}
}
}
this.model.named.PatchNamedRanges(target_sheet.id, command.before_column, command.count, 0, 0);
// FIXME: we need an event here?
// A: caller sends a "structure" event after this call. that doesn't include
// affected areas, though. need to think about whether structure event
// triggers a recalc (probably should). we could track whether we've made
// any modifications (and maybe also whether we now have any invalid
// references)
// patch all sheets
// you know we have a calculator that has backward-and-forward references.
// we could theoretically ask the calculator what needs to be changed.
//
// for the most part, we try to maintain separation between the display
// (this) and the calculator. we could ask, but this isn't terrible and
// helps maintain that separation.
const target_sheet_name = target_sheet.name.toLowerCase();
for (const sheet of this.model.sheets.list) {
const is_target = sheet === target_sheet;
for (const cell of sheet.cells.Iterate()) {
if (cell.ValueIsFormula()) {
const modified = this.PatchFormulasInternal(cell.value || '', 0, 0,
command.before_column, command.count,
target_sheet_name, is_target);
if (modified) {
cell.value = modified;
}
}
}
/*
sheet.cells.IterateAll((cell: Cell) => {
if (cell.ValueIsFormula()) {
const modified = this.PatchFormulasInternal(cell.value || '', 0, 0,
command.before_column, command.count,
target_sheet_name, is_target);
if (modified) {
cell.value = modified;
}
}
});
*/
for (const annotation of sheet.annotations) {
if (annotation.data.formula) {
const modified = this.PatchFormulasInternal(annotation.data.formula,
0, 0, command.before_column, command.count,
target_sheet_name, is_target);
if (modified) {
annotation.data.formula = modified;
}
}
}
}
// annotations
const update_annotations_list: Annotation[] = [];
const resize_annotations_list: Annotation[] = [];
const delete_annotations_list: Annotation[] = [];
if (command.count > 0) { // insert
const first = command.before_column;
for (const annotation of target_sheet.annotations) {
if (annotation.data.layout) {
const [start, end, endx] = [
annotation.data.layout.tl.address.column,
annotation.data.layout.br.address.column,
annotation.data.layout.br.offset.x,
];
if (first <= start ) {
// start case 1: starts to the left of the annotation (including exactly at the left)
// shift
annotation.data.layout.tl.address.column += command.count;
annotation.data.layout.br.address.column += command.count;
}
else if (first < end || first === end && endx > 0) {
// start case 2: starts in the annotation, omitting the first column
annotation.data.layout.br.address.column += command.count;
// size changing
resize_annotations_list.push(annotation);
}
else {
// do nothing
continue;
}
update_annotations_list.push(annotation);
}
}
}
else if (command.count < 0) { // delete
// first and last column deleted
const first = command.before_column;
const last = command.before_column - command.count - 1;
for (const annotation of target_sheet.annotations) {
if (annotation.data.layout) {
// start and end column of the annotation. recall that in
// this layout, the annotation may extend into the (first,last)
// column but not beyond it. the offset is _within_ the column.
const [start, end, endx] = [
annotation.data.layout.tl.address.column,
annotation.data.layout.br.address.column,
annotation.data.layout.br.offset.x,
];
if (first <= start ) {
// start case 1: starts to the left of the annotation (including exactly at the left)
if (last < start) {
// end case 1: ends before the annotation
// shift
annotation.data.layout.tl.address.column += command.count;
annotation.data.layout.br.address.column += command.count;
}
else if (last < end - 1 || (last === end -1 && endx > 0)) {
// end case 2: ends before the end of the annotation
// shift + cut
annotation.data.layout.tl.address.column = first;
annotation.data.layout.tl.offset.x = 0;
annotation.data.layout.br.address.column += command.count;
// size changing
resize_annotations_list.push(annotation);
}
else {
// end case 3: ends after the annotation
// drop the annotation
delete_annotations_list.push(annotation);
continue;
}
}
else if (first < end || first === end && endx > 0) {
// start case 2: starts in the annotation, omitting the first column
if (last < end - 1 || (last === end -1 && endx > 0)) {
// end case 2: ends before the end of the annotation
// shorten
annotation.data.layout.br.address.column += command.count;
// size changing
resize_annotations_list.push(annotation);
}
else {
// end case 3: ends after the annotation
// clip
annotation.data.layout.br.address.column = first;
annotation.data.layout.br.offset.x = 0;
// size changing
resize_annotations_list.push(annotation);
}
}
else {
// start case 3: starts after the annotation
// do nothing
continue;
}
update_annotations_list.push(annotation);
}
}
}
for (const annotation of delete_annotations_list) {
target_sheet.annotations = target_sheet.annotations.filter(test => test !== annotation);
}
return {
update_annotations_list,
resize_annotations_list,
delete_annotations_list,
};
}
//////////////////////////////////////////////////////////////////////////////
/**
* pass all data/style/structure operations through a command mechanism.
* this method should optimally act as a dispatcher, so try to minimize
* inline code in favor of method calls.
*
* [NOTE: don't go crazy with that, some simple operations can be inlined]
*
* NOTE: working on coediting. we will need to handle different sheets.
* going to work one command at a time...
*
* @param queue -- push on the command log. this is default true so it
* doesn't change existing behavior, but you can turn it off if the message
* comes from a remote queue.
*
*/
public ExecCommand(commands: Command | Command[], queue = true): UpdateFlags {
// FIXME: support ephemeral commands (...)
// data and style events were triggered by the areas being set.
// we are not necessarily setting them for offsheet changes, so
// we need an explicit flag. this should be logically OR'ed with
// the area existing (for purposes of sending an event).
// all flags/areas moved to this struct
const flags: UpdateFlags = {
pending: [],
};
const events: GridEvent[] = [];
// should we normalize always, or only if we're queueing?
// it seems like it's useful here, then we can be a little
// sloppier in the actual handlers. after normalizing, any
// command that has an address/area (or sheet ID parameter)
// will have an explicit sheet ID.
commands = this.NormalizeCommands(commands);
// FIXME: we should queue later, so we can remove any commands
// that fail... throw errors, and so on
if (queue) {
this.command_log.Publish({ command: commands, timestamp: new Date().getTime() });
}
for (const command of commands) {
// console.log(CommandKey[command.key], JSON.stringify(command));
switch (command.key) {
case CommandKey.Reset:
// not sure how well this fits in with the command queue. it
// doesn't look like it sends any events, so what's the point?
// just to get a command log event?
// the problem is that load doesn't run through the queue, so
// even if you did a reset -> load we'd just get the reset part.
// ...
// OK, actually this is used in the CSV import routine. we need
// to support it until we get rid of that (it needs to move).
this.ResetInternal();
break;
case CommandKey.Clear:
if (command.area) {
const area = new Area(command.area.start, command.area.end);
this.ClearAreaInternal(area);
flags.data_area = Area.Join(area, flags.data_area);
flags.formula = true;
}
break;
case CommandKey.Select:
// nobody (except one routine) is using commands for selection.
// not sure why or why not, or if that's a problem. (it's definitely
// a problem if we are recording the log for playback)
// ATM the base class is just going to do nothing.
this.SelectInternal(command);
break;
case CommandKey.Freeze:
// COEDITING: ok
this.FreezeInternal(command);
// is the event necessary here? not sure. we were sending it as a
// side effect, so it was added here in case there was some reason
// it was necessary. at a minimum, it should not require a rebuild
// because no addresses change. (although we leave it in case someone
// else sets it).)
flags.structure_event = true;
break;
case CommandKey.AddConditionalFormat:
{
const sheet = this.FindSheet(command.format.area);
sheet.conditional_formats.push(command.format);
sheet.Invalidate(new Area(command.format.area.start, command.format.area.end));
if (sheet === this.active_sheet) {
// flags.style_area = Area.Join(command.format.area, flags.style_area);
flags.render_area = Area.Join(command.format.area, flags.render_area);
}
else {
// flags.style_event = true;
}
flags.structure_event = true;
flags.conditional_formatting_event = true;
}
break;
case CommandKey.RemoveConditionalFormat:
{
let sheet: Sheet|undefined;
let count = 0;
if (command.format) {
// we're removing by object equivalence, not strict equality.
// this is in case we're switching contexts and the objects
// are not strictly equal. may be unecessary. do we need to
// normalize in some way? (...)
const format = JSON.stringify(command.format);
sheet = this.FindSheet(command.format.area);
sheet.conditional_formats = sheet.conditional_formats.filter(test => {
// if (test === command.format) {
if (JSON.stringify(test) === format) {
count++;
flags.render_area = Area.Join(test.area, flags.render_area);
return false;
}
return true;
});
}
else if (command.area) {
const area = new Area(command.area.start, command.area.end);
sheet = this.FindSheet(command.area);
sheet.conditional_formats = sheet.conditional_formats.filter(test => {
const compare = new Area(test.area.start, test.area.end);
if (compare.Intersects(area)) {
count++;
flags.render_area = Area.Join(compare, flags.render_area);
return false;
}
return true;
});
}
if (sheet && count) {
sheet.FlushConditionalFormats();
flags.structure_event = true;
// this will flush leaf vertices. but it's expensive because
// it's rebuilding the whole graph. we could maybe reduce a
// bit... the question is what's worse: rebuilding the graph
// or leaving orphans for a while?
// flags.structure_rebuild_required = true;
flags.conditional_formatting_event = true;
}
}
break;
case CommandKey.InsertTable:
// the most important thing here is validating that we can
// create the table in the target area.
{
const sheet = this.FindSheet(command.area);
const area = new Area(command.area.start, command.area.end);
// validate first
let valid = true;
validation_loop:
for (let row = area.start.row; row <= area.end.row; row++) {
for (let column = area.start.column; column <= area.end.column; column++) {
const cell = sheet.cells.GetCell({row, column}, false);
if (cell && (cell.area || cell.merge_area || cell.table)) {
valid = false;
break validation_loop;
}
}
}
if (valid) {
// we need a name for the table. needs to be unique.
let index = this.model.tables.size + 1;
let name = '';
for (;;) {
name = `Table${index++}`;
if (!this.model.tables.has(name.toLowerCase())) {
break;
}
}
const table: Table = {
area: command.area,
name,
sortable: command.sortable, // defaults to true if !present
theme: command.theme,
};
if (command.totals) {
table.totals_row = true;
}
this.model.tables.set(name.toLowerCase(), table);
for (let row = area.start.row; row <= area.end.row; row++) {
for (let column = area.start.column; column <= area.end.column; column++) {
const cell = sheet.cells.GetCell({row, column}, true);
cell.table = table;
}
}
this.UpdateTableColumns(table);
// force rerendering, we don't need to flush the values
sheet.Invalidate(new Area(table.area.start, table.area.end));
if (sheet === this.active_sheet) {
flags.style_area = Area.Join(area, flags.style_area);
flags.render_area = Area.Join(area, flags.render_area);
}
else {
flags.style_event = true;
}
}
}
break;
case CommandKey.RemoveTable:
// this is pretty easy, we can do it inline
{
const sheet = this.FindSheet(command.table.area);
const area = new Area(command.table.area.start, command.table.area.end);
for (let row = area.start.row; row <= area.end.row; row++) {
for (let column = area.start.column; column <= area.end.column; column++) {
const cell = sheet.cells.GetCell({row, column}, false);
if (cell) {
cell.table = undefined;
}
}
}
// drop from model
// console.info('deleting...', command.table.name);
this.model.tables.delete(command.table.name.toLowerCase());
// tables use nonstandard styling, we need to invalidate the sheet.
// for edges invalidate an extra cell around the table
const invalid = sheet.RealArea(area.Clone().Shift(-1, -1).Resize(area.rows + 2, area.columns + 2));
sheet.Invalidate(invalid);
if (sheet === this.active_sheet) {
flags.style_area = Area.Join(area, flags.style_area);
flags.render_area = Area.Join(area, flags.render_area);
}
else {
flags.style_event = true;
}
}
break;
case CommandKey.MergeCells:
{
// COEDITING: ok
const sheet = this.FindSheet(command.area);
sheet.MergeCells(
new Area(command.area.start, command.area.end));
// sheet publishes a data event here, too. probably a good
// idea because references to the secondary (non-head) merge
// cells will break.
flags.structure_event = true;
flags.structure_rebuild_required = true;
if (sheet === this.active_sheet) {
flags.data_area = Area.Join(command.area, flags.data_area);
flags.render_area = Area.Join(command.area, flags.render_area);
}
else {
flags.data_event = true;
// this.pending_layout_update.add(sheet.id);
if (!flags.pending) {
flags.pending = [];
}
flags.pending.push(sheet.id);
}
}
break;
case CommandKey.UnmergeCells:
{
// COEDITING: ok
// the sheet unmerge routine requires a single, contiguous merge area.
// we want to support multiple unmerges at the same time, though,
// so let's check for multiple. create a list.
// FIXME: use a set
const sheet = this.FindSheet(command.area);
const list: Record = {};
const area = new Area(command.area.start, command.area.end);
for (const cell of sheet.cells.Iterate(area, false)) {
if (cell.merge_area) {
const label = Area.CellAddressToLabel(cell.merge_area.start) + ':'
+ Area.CellAddressToLabel(cell.merge_area.end);
list[label] = cell.merge_area;
}
}
/*
sheet.cells.Apply(area, (cell: Cell) => {
if (cell.merge_area) {
const label = Area.CellAddressToLabel(cell.merge_area.start) + ':'
+ Area.CellAddressToLabel(cell.merge_area.end);
list[label] = cell.merge_area;
}
}, false);
*/
const keys = Object.keys(list);
for (let i = 0; i < keys.length; i++) {
sheet.UnmergeCells(list[keys[i]]);
}
// see above
if (sheet === this.active_sheet) {
flags.render_area = Area.Join(command.area, flags.render_area);
flags.data_area = Area.Join(command.area, flags.data_area);
}
else {
flags.data_event = true;
// this.pending_layout_update.add(sheet.id);
if (!flags.pending) {
flags.pending = [];
}
flags.pending.push(sheet.id);
}
flags.structure_event = true;
flags.structure_rebuild_required = true;
}
break;
case CommandKey.Indent:
{
let area: Area|undefined;
const sheet = this.FindSheet(command.area);
if (IsCellAddress(command.area)) {
area = new Area(command.area);
const style = sheet.GetCellStyle(command.area, true);
sheet.UpdateCellStyle(command.area, {
indent: Math.max(0, (style.indent || 0) + command.delta),
}, true);
}
else {
area = new Area(command.area.start, command.area.end);
for (const address of area) {
const style = sheet.GetCellStyle(address, true);
sheet.UpdateCellStyle(address, {
indent: Math.max(0, (style.indent || 0) + command.delta),
}, true);
}
}
if (sheet === this.active_sheet) {
flags.style_area = Area.Join(area, flags.style_area);
flags.render_area = Area.Join(area, flags.render_area);
}
else {
flags.style_event = true;
}
}
break;
case CommandKey.UpdateStyle:
{
// COEDITING: handles sheet ID properly
// to account for our background bleeding up/left, when applying
// style changes we may need to render one additional row/column.
let area: Area|undefined;
const sheet = this.FindSheet(command.area);
if (IsCellAddress(command.area)) {
area = new Area(command.area);
sheet.UpdateCellStyle(command.area, command.style, !!command.delta);
}
else {
area = new Area(command.area.start, command.area.end);
sheet.UpdateAreaStyle(area, command.style, !!command.delta);
}
if (sheet === this.active_sheet) {
flags.style_area = Area.Join(area, flags.style_area);
// we can limit bleed handling to cases where it's necessary...
// if we really wanted to optimize we could call invalidate on .left, .top, &c
if (!command.delta
|| command.style.fill
|| command.style.border_top
|| command.style.border_left
|| command.style.border_right
|| command.style.border_bottom) {
area = Area.Bleed(area); // bleed by 1 to account for borders/background
this.active_sheet.Invalidate(area);
}
flags.render_area = Area.Join(area, flags.render_area);
}
else {
flags.style_event = true;
}
}
break;
case CommandKey.DataValidation:
// COEDITING: ok
this.SetValidationInternal(command);
if (!command.area.start.sheet_id || command.area.start.sheet_id === this.active_sheet.id) {
flags.render_area = Area.Join(new Area(command.area.start, command.area.end), flags.render_area);
}
break;
case CommandKey.SetName:
// it seems like we're allowing overwriting names if those
// names exist as expressions or named ranges. however we
// should not allow overriding a built-in function name (or
// a macro function name?)
// FOR THE TIME BEING we're going to add that restriction to
// the calling function, which (atm) is the only way to get here.
{
const ac_token: FunctionDescriptor = { type: 'token', named: true, name: command.name, scope: command.scope };
if (command.area || command.expression) {
if (command.area) {
this.model.named.SetNamedRange(command.name, new Area(command.area.start, command.area.end), command.scope);
}
else if (command.expression) {
this.model.named.SetNamedExpression(command.name, command.expression, command.scope);
}
this.autocomplete_matcher.AddFunctions(ac_token);
}
else {
this.model.named.ClearName(command.name, command.scope);
this.autocomplete_matcher.RemoveFunctions(ac_token);
}
flags.structure_event = true;
flags.structure_rebuild_required = true;
}
break;
case CommandKey.UpdateBorders:
{
// COEDITING: ok
// UPDATE: actually had a problem with Area.Bleed dropping the
// sheet ID. fixed.
const area = this.ApplyBordersInternal(command);
if (area.start.sheet_id === this.active_sheet.id) {
flags.render_area = Area.Join(area, flags.render_area);
flags.style_area = Area.Join(area, flags.style_area);
}
else {
flags.style_event = true;
}
}
break;
case CommandKey.ShowSheet:
// COEDITING: we probably don't want this to pass through
// when coediting, but it won't break anything. you can filter.
this.ShowSheetInternal(command);
flags.sheets = true; // repaint tab bar
flags.structure_event = true;
break;
case CommandKey.TabColor:
command.sheet.tab_color = command.color;
// NOTE: the flag.sheets originally did not update tab colors,
// which were cached. we could create a new flag for that, or
// we could just refresh the colors. since there aren't that
// many of them, it's probably OK to just refresh the colors any
// time this flag is set. if that becomes a perf issue in the
// future we could add a new flag.
flags.sheets = true; // repaint tab bar
flags.structure_event = true;
break;
case CommandKey.ReorderSheet:
{
// COEDITING: seems OK, irrespective of active sheet
const sheets: Sheet[] = [];
const target = this.model.sheets.list[command.index];
for (let i = 0; i < this.model.sheets.length; i++) {
if (i !== command.index) {
if (i === command.move_before) {
sheets.push(target);
}
sheets.push(this.model.sheets.list[i]);
}
}
if (command.move_before >= this.model.sheets.length) {
sheets.push(target);
}
// this.model.sheets = sheets;
this.model.sheets.Assign(sheets);
flags.sheets = true;
flags.structure_event = true;
}
break;
case CommandKey.RenameSheet:
{
// COEDITING: seems OK, irrespective of active sheet
const sheet = this.ResolveSheet(command);
if (sheet) {
this.RenameSheetInternal(sheet, command.new_name);
flags.sheets = true;
flags.structure_event = true;
}
}
break;
case CommandKey.RemoveAnnotation:
this.RemoveAnnotationInternal(command);
flags.structure_event = true;
flags.structure_rebuild_required = true;
flags.annotation_event = true;
break;
case CommandKey.CreateAnnotation:
this.CreateAnnotationInternal(command);
flags.structure_event = true;
flags.structure_rebuild_required = true;
flags.annotation_event = true;
break;
case CommandKey.ResizeRows:
// moving this to a method so we can specialize: non-UI grid
// should not support autosize (it can't)
// this may impact the SUBTOTAL function. which is dumb, but
// there you go. so treat this as a data event for rows that
// change visibility one way or the other.
// COEDITING: ok
{
const area = this.ResizeRowsInternal(command);
if (area) {
if (area.start.sheet_id === this.active_sheet.id) {
const real_area = this.active_sheet.RealArea(new Area(area.start, area.end));
flags.render_area = Area.Join(real_area, flags.render_area);
flags.data_area = Area.Join(real_area, flags.data_area);
flags.data_event = true;
}
else {
flags.data_event = true;
if (!flags.pending) {
flags.pending = [];
}
if (area.start.sheet_id) {
flags.pending.push(area.start.sheet_id);
}
}
}
flags.structure_event = true;
}
break;
case CommandKey.ResizeColumns:
this.ResizeColumnsInternal(command);
flags.structure_event = true;
break;
case CommandKey.ShowHeaders:
// FIXME: now that we don't support 2-level headers (or anything
// other than 1-level headers), headers should be managed by/move into
// the grid class.
this.active_sheet.SetHeaderSize(command.show ? undefined : 1, command.show ? undefined : 1);
this.flags.layout = true;
this.flags.repaint = true;
break;
case CommandKey.InsertRows:
// COEDITING: annotations are broken
this.InsertRowsInternal(command);
flags.structure_event = true;
flags.structure_rebuild_required = true;
break;
case CommandKey.InsertColumns:
// COEDITING: annotations are broken
this.InsertColumnsInternal(command);
flags.structure_event = true;
flags.structure_rebuild_required = true;
break;
case CommandKey.SetLink:
case CommandKey.SetNote:
{
// COEDITING: ok
// note and link are basically the same, although there's a
// method for setting note (not sure why)
const sheet = this.FindSheet(command.area);
let cell = sheet.cells.GetCell(command.area, true);
if (cell) {
let area: Area;
if (cell.merge_area) {
area = new Area(cell.merge_area.start);
cell = sheet.cells.GetCell(cell.merge_area.start, true);
}
else {
area = new Area(command.area);
}
if (command.key === CommandKey.SetNote) {
cell.SetNote(command.note);
}
else {
cell.hyperlink = command.reference || undefined;
cell.render_clean = [];
}
if (sheet === this.active_sheet) {
// this isn't necessary because it's what the render area does
// this.DelayedRender(false, area);
// treat this as style, because it affects painting but
// does not require calculation.
flags.style_area = Area.Join(area, flags.style_area);
flags.render_area = Area.Join(area, flags.render_area);
}
else {
flags.style_event = true;
}
}
}
break;
case CommandKey.SortTable:
{
// console.info(command.table.area.spreadsheet_label);
const area = this.SortTableInternal(command);
if (area && area.start.sheet_id === this.active_sheet.id) {
flags.data_area = Area.Join(area, flags.data_area);
// normally we don't paint, we wait for the calculator to resolve
if (this.options.repaint_on_cell_change) {
flags.render_area = Area.Join(area, flags.render_area);
}
}
else {
flags.data_event = true;
}
}
break;
case CommandKey.SetRange:
{
// COEDITING: handles sheet ID properly
// FIXME: areas should check sheet
// area could be undefined if there's an error
// (try to change part of an array)
const area = this.SetRangeInternal(command, flags);
if (area) {
const sheet = this.model.sheets.Find(area.start.sheet_id || this.active_sheet.id);
const tables = sheet?.TablesFromArea(area, true) || [];
for (const table of tables) {
this.UpdateTableColumns(table);
}
}
if (area && area.start.sheet_id === this.active_sheet.id) {
flags.data_area = Area.Join(area, flags.data_area);
// normally we don't paint, we wait for the calculator to resolve
if (this.options.repaint_on_cell_change) {
flags.render_area = Area.Join(area, flags.render_area);
}
}
else {
flags.data_event = true;
}
}
break;
case CommandKey.DeleteSheet:
// COEDITING: looks fine
this.DeleteSheetInternal(command);
flags.sheets = true;
flags.structure_event = true;
flags.structure_rebuild_required = true;
break;
case CommandKey.DuplicateSheet:
// FIXME: what happens to named ranges? we don't have sheet-local names...
this.DuplicateSheetInternal(command);
flags.sheets = true;
flags.structure_event = true;
flags.structure_rebuild_required = true;
break;
case CommandKey.AddSheet:
// COEDITING: this won't break, but it shouldn't change the
// active sheet if this is a remote command. is there a way
// to know? we can guess implicitly from the queue parameter,
// but it would be better to be explicit.
{
const id = this.AddSheetInternal(command.name, command.insert_index); // default name
if (typeof id === 'number' && command.show) {
this.ActivateSheetInternal({
key: CommandKey.ActivateSheet,
id,
});
}
flags.structure_event = true;
flags.sheets = true;
flags.structure = true;
}
break;
case CommandKey.ActivateSheet:
this.ActivateSheetInternal(command);
break;
default:
console.warn(`unhandled command: ${CommandKey[command.key]} (${command.key})`);
}
}
// consolidate events and merge areas
let data_event: DataEvent|undefined;
let style_event: StyleEvent|undefined;
if (flags.data_area) {
if (!flags.data_area.start.sheet_id) {
flags.data_area.SetSheetID(this.active_sheet.id);
}
// events.push({ type: 'data', area: flags.data_area });
data_event = { type: 'data', area: flags.data_area };
}
else if (flags.data_event) {
// events.push({ type: 'data' });
data_event = { type: 'data' };
}
if (flags.style_area) {
if (!flags.style_area.start.sheet_id) {
flags.style_area.SetSheetID(this.active_sheet.id);
}
// events.push({ type: 'style', area: flags.style_area });
style_event = { type: 'style', area: flags.style_area };
}
else if (flags.style_event) {
// events.push({ type: 'style' });
style_event = { type: 'style' };
}
if (data_event && style_event) {
events.push({
type: 'composite',
data_area: data_event.area,
style_area: style_event.area,
});
}
else {
if (data_event) {
events.push(data_event);
}
if (style_event) {
events.push(style_event);
}
}
if (flags.structure_event) {
events.push({
type: 'structure',
rebuild_required: flags.structure_rebuild_required,
conditional_format: flags.conditional_formatting_event,
update_annotations: flags.annotation_event,
});
}
if (this.batch > 0) {
this.batch_events.push(...events);
}
else {
this.grid_events.Publish(events);
//if (flags.render_area) {
// this.DelayedRender(false, flags.render_area);
//}
}
return flags;
}
}