/*
* 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
*
*/
import type {
Cell,
Theme,
IArea,
Extent,
CellValue,
ICellAddress,
ImportedSheetData,
Complex,
Color,
CellStyle,
IRectangle } from 'treb-base-types';
import {
Area,
Is2DArray,
Rectangle,
ValueType,
Localization,
IsCellAddress,
LoadThemeProperties,
DefaultTheme,
ComplexToString,
IsComplex,
TextPartFlag,
IsArea,
Style,
} from 'treb-base-types';
import { Heuristics } from 'treb-data-model';
import type { ExpressionUnit, RenderOptions, UnitAddress } from 'treb-parser';
import {
DecimalMarkType,
ArgumentSeparatorType,
QuotedSheetNameRegex,
MDParser,
} from 'treb-parser';
import { SerializeHTML } from 'treb-utils';
import type { ParseResult as ParseResult2 } from 'treb-format';
import { NumberFormatCache, LotusDate, ValueParser, type Hints, NumberFormat } from 'treb-format';
import { SelectionRenderer } from '../render/selection-renderer';
import { TabBar } from './tab_bar';
import type { StatsEntry } from './tab_bar';
import { MockLayout } from '../layout/mock-layout';
import type { BaseLayout } from '../layout/base_layout';
import { TileRange } from '../layout/base_layout';
// this was conditional compilation. we're dropping as we no longer support IE11.
// import { CreateLayout } from '@grid-conditional/layout_manager';
// import { CreateLayout } from '../conditional/modern/layout_manager';
// now we can drop the conditional compilation as well...
import { GridLayout } from '../layout/grid_layout';
import { OverlayEditor } from '../editors/overlay_editor';
import { TileRenderer } from '../render/tile_renderer';
import type { GridEvent, GridSelectionEvent } from './grid_events';
import { ErrorCode } from './grid_events';
import type {
SerializedNamed,
DataModel,
GridSelection,
LegacySerializedSheet,
} from 'treb-data-model';
import { Annotation, type AnnotationData, Sheet } from 'treb-data-model';
import { FormulaBar } from '../editors/formula_bar';
import type { GridOptions } from './grid_options';
import { BorderConstants } from './border_constants';
import { UA } from '../util/ua';
import { Autocomplete } from '../editors/autocomplete';
import { MouseDrag } from './drag_mask';
import type {
Command,
SetRangeCommand, FreezeCommand,
InsertRowsCommand, InsertColumnsCommand, SetNameCommand,
ActivateSheetCommand, DataValidationCommand,
ResizeRowsCommand, ResizeColumnsCommand,
SelectCommand,
CreateAnnotationCommand,
RemoveAnnotationCommand
} from './grid_command';
import { CommandKey
} from './grid_command';
import { DOMContext } from 'treb-base-types';
import { GridBase } from './grid_base';
import type { SetRangeOptions } from './set_range_options';
import type { ClipboardCellData } from './clipboard_data';
import type { ExternalEditorConfig } from './external_editor_config';
import { ExternalEditor } from '../editors/external_editor';
import type { ClipboardData, PasteOptions } from './clipboard_data2';
interface DoubleClickData {
timeout?: number;
address?: ICellAddress;
}
enum EditingState {
NotEditing = 0,
CellEditor = 1,
FormulaBar = 2,
}
export class Grid extends GridBase {
// --- public members --------------------------------------------------------
public hide_selection = false;
// new...
public headless = false;
/**
* we're tracking the current selected style so we can use it for new
* cells. conceptually, if you are typing in a font (stack) like handwritten,
* and you enter a new cell, you probably want to type in the same font
* there. so we want to make that happen. TODO: size
*/
public readonly edit_state: CellStyle = {};
public get scale(): number {
return this.layout.scale;
}
public set scale(value: number) {
this.layout.scale = value;
this.UpdateLayout();
this.UpdateAnnotationLayout();
this.layout.UpdateAnnotation(this.active_sheet.annotations, this.theme);
this.layout.ApplyTheme(this.theme);
this.overlay_editor?.UpdateScale(value);
this.tab_bar?.UpdateScale(value);
this.grid_events.Publish({
type: 'scale',
scale: value,
});
for (const sheet of this.model.sheets.list) {
for (const annotation of sheet.annotations) {
annotation.dirty = true;
}
}
}
/**
* the theme object exists so we can pass it to constructors for
* various components, but it's no longer initialized until the
* initialization step (when we have a node).
*/
public readonly theme: Theme; // ExtendedTheme;
/**
* this was private, which made sense, but there's a case where the
* client (embedded sheet) wants to check if an annotation is selected.
* we need to allow that somehow, ideally without any reacharounds.
*
* I guess the concern is a client could modify it, but at this point
* we really only have one client and we trust it. making this public.
* we could maybe switch to an accessor or have a "is this selected?" method.
*/
public selected_annotation?: Annotation;
// --- private members -------------------------------------------------------
// testing
private hover_data: {
note?: boolean;
link?: boolean;
table_header?: boolean;
cell?: Cell;
point?: {x: number, y: number};
handler?: number;
pointer?: boolean;
address?: ICellAddress; // = { row: -1, column: -1 };
} = {};
/** are we editing? */
private editing_state: EditingState = EditingState.NotEditing;
/** if we are editing, what is the cell? */
private editing_cell: ICellAddress = { row: -1, column: -1, sheet_id: 0 };
/** */
private editing_selection: GridSelection|undefined;
/** */
private editing_annotation?: Annotation;
/** */
private pending_reset_selection = false;
/** */
private view_node?: HTMLElement;
/** containing element, passed in */
private container?: HTMLElement;
/** dom structure for grid */
private readonly layout: BaseLayout;
/**
* this flag is used to lazily rebuild tiles. that prevents flashing if
* there is a lot of work between a reset/document change and the next
* paint call.
*/
private tile_update_pending = false;
private scroll_offset_pending?: {x: number, y: number};
/**
* for coediting/remotes, we may make a structural change that requires
* a full repaint of another sheet -- we won't be able to do that until
* the sheet is displayed. so let's keep track of these.
*
* these are numbers so we can use a sparse array. (or we could use a set...)
*/
private pending_layout_update: Set = new Set();
/* *
* 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)
* /
private parser;
*/
/** this is used when testing if a typed character is numeric */
private decimal_separator_code = 0x2e; // "."
/** new key capture overlay/ICE */
private overlay_editor?: OverlayEditor;
/** moving autocomplete to a class field */
private autocomplete?: Autocomplete;
/** formula bar editor (optional) */
protected formula_bar?: FormulaBar;
private RESIZE_PIXEL_BUFFER = 5;
/**
* formalizing the concept of external selection to support outside tooling.
*
* FIXME: stop testing on this field. we need a better way to figure out
* if the external editor is active.
*/
private external_editor_config?: Partial;
/**
* support for external editor. created on demand.
*/
private external_editor?: ExternalEditor;
/**
* flag indicating we're resizing, or hovering over a resize.
* we use this so we know what to do when we see a click on the headers.
*/
private cell_resize = { row: -1, column: -1 };
/**
* this is the only thing that was used in the old 'render state',
* so we dropped the container.
*/
private render_tiles = new TileRange({ row: 0, column: 0 });
// primary and active selections now _always_ exist. we use flags
// to indicate that they're empty (i.e. nothing is selected). this
// allows us to pass and test actual objects.
//
// like const, readonly only affects the immediate property and not
// subproperties. so here we use it to ensure selections are never
// reassigned or deassigned.
/**
* the main selection for interacting with the spreadsheet
*/
protected readonly primary_selection: GridSelection = {
target: { row: 0, column: 0 },
area: new Area({ row: 0, column: 0 }),
empty: true,
};
/** reusing type. FIXME? we don't need a target */
protected readonly spill_selection: GridSelection = {
target: { row: 0, column: 0 },
area: new Area({ row: 0, column: 0 }),
empty: true,
};
/**
* active selection when selecting arguments (while editing)
*/
protected readonly active_selection: GridSelection = {
target: { row: 0, column: 0 },
area: new Area({ row: 0, column: 0 }),
empty: true,
};
/**
* this flag is for testing if we were previously in the nub
*/
private nub_select_flag = false;
/**
* additional selections that are rendered but not otherwise used.
* this array is now readonly, so we can bind it to the selection
* renderer (we do this with the primary selection as well)
*/
private readonly additional_selections: GridSelection[] = [];
/**
* state data for handling double-clicks. because we're using
* mousedown, we need to synthesize double-clicks with a timeout.
*/
private double_click_data: DoubleClickData = {};
/**
* window timer/request animation frame token. this is used to prevent
* excess repaints on scroll. FIXME: can this merge with the delayed
* render token? I think originally it was separate because that other
* method was too slow for scrolling... maybe...
*/
private layout_token = 0;
/** window timer/request animation frame token */
private render_token = 0;
/** */
private tile_renderer?: TileRenderer;
/** */
private selection_renderer?: SelectionRenderer;
// FIXME: move [why?]
private tab_bar?: TabBar;
private DOM = DOMContext.GetInstance();
// --- constructor -----------------------------------------------------------
/**
* FIXME: NO PARAMETER INITIALIZATIONS
*/
constructor(
options: GridOptions = {},
model: DataModel,
theme: Theme = DefaultTheme,
initialze_dom = true,
DOM: DOMContext ) {
super(options, model);
this.decimal_separator_code = Localization.decimal_separator.charCodeAt(0);
// set properties here, we will update in initialize()
this.theme = JSON.parse(JSON.stringify(theme));
if (!initialze_dom) {
this.headless = true;
this.layout = new MockLayout(this.model, this.view);
return;
}
this.DOM = DOM;
this.layout = new GridLayout(this.model, this.view, DOM);
if (options.initial_scale) {
if (typeof options.initial_scale === 'string') {
options.initial_scale = Number(options.initial_scale);
}
this.layout.scale = options.initial_scale;
// not created yet
// this.tab_bar?.UpdateScale(options.initial_scale);
}
this.tile_renderer = new TileRenderer(
this.theme,
this.layout,
this.model,
this.view,
this.options);
this.selection_renderer = new SelectionRenderer(
this.theme,
this.layout,
this.model,
this.view,
this.primary_selection,
this.additional_selections);
}
// --- Copy/paste API methods ------------------------------------------------
//
// moving here with a view towards (eventually) merging with the UI/browser
// copy/paste routines (in grid)
//
/**
* internal composite for cut/copy. mostly identical except we
* read data as A1 for cut, so it will retain references. also
* cut clears the data.
*
* FIXME: merge with grid cut/copy/paste routines. we already
* handle recycling and relative addressing, the only thing missing
* is alternate formats.
*/
public CopyArea(resolved: Area, semantics: 'cut'|'copy' = 'copy'): ClipboardData {
// resolve range so we can use it later -> Area
const sheet = (resolved.start.sheet_id ? this.model.sheets.Find(resolved.start.sheet_id) : this.active_sheet) || this.active_sheet;
// get style data, !apply theme but do apply r/c styles
const style_data = sheet.GetCellStyle(resolved, false);
// flag we want R1C1 (copy)
const r1c1 = (semantics !== 'cut');
// NOTE: we're losing arrays here. need to fix. also think
// about merges? we'll reimplement what grid does (only in part)
const data: ClipboardData = [];
for (const { cell, row, column } of sheet.cells.IterateRC(resolved)) {
// raw value
let value = cell.value;
// seems like we're using a loop function unecessarily
if (r1c1 && value && cell.type === ValueType.formula) {
value = this.FormatR1C1(value, { row, column })[0][0];
}
const r = row - resolved.start.row;
const c = column - resolved.start.column;
if (!data[r]) {
data[r] = [];
}
let array_head: IArea|undefined;
if (cell.area) {
// scrubbing to just area (and unlinking)
array_head = {
start: {
row: cell.area.start.row - resolved.start.row,
column: cell.area.start.column - resolved.start.column,
},
end: {
row: cell.area.end.row - resolved.start.row,
column: cell.area.end.column - resolved.start.column,
},
};
}
data[r][c] = {
value,
calculated: cell.calculated,
style: style_data[r][c],
area: array_head,
};
}
// EmbeddedSpreadsheet.clipboard = structuredClone(data);
if (semantics === 'cut') {
this.SetRange(resolved, undefined, { recycle: true }); // clear
}
return data;
}
/**
* paste clipboard data into a target range. this method does not use
* the system clipboard; pass in clipboard data returned from the Cut or
* Copy method.
*
* @param target - the target to paste data into. this can be larger
* than the clipboard data, in which case values will be recycled in
* blocks. if the target is smaller than the source data, we will expand
* it to fit the data.
*
* @param data - clipboard data to paste.
*
* @privateRemarks LLM API
*
* @privateRemarks this was async when we were thinking of using the
* system clipboard, but that's pretty broken so we're not going to
* bother atm.
*/
public PasteArea(resolved: Area, data: ClipboardData, options: PasteOptions = {}): void {
if (!data) {
throw new Error('no clipboad data');
}
// paste has some special semantics. if the target smaller than the
// source data, we write the full data irrespective of size (similar
// to "spill"). otherwise, we recycle in blocks.
// the setrange method will recycle, but we also need to recycle styles.
// start with data length
const rows = data.length;
const columns = data[0]?.length || 0;
// target -> block size
resolved.Resize(
Math.max(1, Math.floor(resolved.rows / rows)) * rows,
Math.max(1, Math.floor(resolved.columns / columns)) * columns );
const sheet = (resolved.start.sheet_id ? this.model.sheets.Find(resolved.start.sheet_id) : this.active_sheet) || this.active_sheet;
const values: CellValue[][] = [];
// optionally collect calculated values, instead of raw values
if (options.values) {
for (const [index, row] of data.entries()) {
values[index] = [];
for (const cell of row) {
values[index].push(typeof cell.calculated === 'undefined' ? cell.value : cell.calculated);
}
}
}
// this is to resolve the reference in the callback,
// but we should copy -- there's a possibility that
// this points to the static member, which could get
// overwritten. FIXME
const local = data;
// batch to limit events, sync up undo
const events = this.Batch(() => {
// this needs to change to support arrays (and potentially merges...)
// actually we could leave as is for just calculated values
if (options.values) {
this.SetRange(resolved, values, {
r1c1: true, recycle: true,
});
}
else {
// so this is for formulas only now
// start by clearing... (but leave styles as-is for now)
// probably a better way to do this
this.SetRange(resolved, undefined, { recycle: true });
for (const address of resolved) {
const r = (address.row - resolved.start.row) % rows;
const c = (address.column - resolved.start.column) % columns;
const cell_data = local[r][c];
if (cell_data.area) {
// only the head
if (cell_data.area.start.row === r && cell_data.area.start.column === c) {
const array_target = new Area(cell_data.area.start, cell_data.area.end);
array_target.Shift(resolved.start.row, resolved.start.column);
this.SetRange(array_target, cell_data.value, { r1c1: true, array: true });
}
}
else if (cell_data.value) {
this.SetRange(new Area(address), cell_data.value, { r1c1: true });
}
}
}
if (options.formatting === 'number-formats') {
// number format only, and apply delta
for (const address of resolved) {
const r = (address.row - resolved.start.row) % rows;
const c = (address.column - resolved.start.column) % columns;
const number_format = (local[r][c].style || {}).number_format;
sheet.UpdateCellStyle(address, { number_format }, true);
}
}
else if (options.formatting !== 'target') {
// use source formatting (default)
for (const address of resolved) {
const r = (address.row - resolved.start.row) % rows;
const c = (address.column - resolved.start.column) % columns;
sheet.UpdateCellStyle(address, local[r][c].style || {}, false);
}
}
}, true);
this.grid_events.Publish(events);
}
// --- public methods --------------------------------------------------------
/**
* re-select the current selection, for side effects. used when switching
* languages to update the function view (if any)
*/
public Reselect() {
// we might need to update the current displayed selection. depends
// on when we expect languages to be set.
if (!this.primary_selection.empty) {
this.Select(this.primary_selection, this.primary_selection.area, this.primary_selection.target);
}
}
/**
* set note at the given address, or current selection
* @param address optional address; if not used, note will be set/cleared
* at current selection
* @param note new note, or undefined to clear note
*/
public SetNote(address?: ICellAddress, note?: string): void {
if (!address) {
if (this.primary_selection.empty) return;
address = this.primary_selection.target;
}
this.ExecCommand({
key: CommandKey.SetNote,
area: address,
note,
});
}
/** find an annotation, given a node */
public FindAnnotation(node: HTMLElement): Annotation|undefined {
for (const annotation of this.active_sheet.annotations) {
const view = annotation.view[this.view_index] || {};
if (view.node === node) {
return annotation;
}
}
return undefined;
}
public CreateAnnotation(
properties: Partial = {},
sheet: Sheet = this.active_sheet,
add_to_sheet = true,
offset = false,
target?: IArea|IRectangle,
focus?: boolean ) {
this.ExecCommand({
key: CommandKey.CreateAnnotation,
properties,
add_to_sheet,
offset,
target,
focus,
sheet,
});
}
/**
* create an annotation, with properties, without an original object.
* optionally (and by default) add to sheet.
*
* @param offset check for a matching position (top-left) and if found,
* shift by (X) pixels. intended for copy-paste, where we don't want to
* paste immediately on top of the original.
*
* @param target new parameter allows setting annotation as rect or as
* cell range
*/
protected CreateAnnotationInternal(command: CreateAnnotationCommand) {
const annotation = new Annotation(command.properties);
if (command.offset) {
// to offset, we have to have layout (or at least scaled rect)
if (!annotation.data.layout && annotation.scaled_rect) {
annotation.data.layout = this.layout.RectToAnnotationLayout(annotation.scaled_rect);
}
if (!annotation.data.layout) {
console.warn(`can't offset annotation without layout`);
}
else {
let target_rect = this.layout.AnnotationLayoutToRect(annotation.data.layout).Shift(20, 20);
let recheck = true;
while (recheck) {
recheck = false;
for (const test of command.sheet.annotations) {
if (test === annotation) { continue; }
if (test.scaled_rect && test.scaled_rect.top === target_rect.top && test.scaled_rect.left === target_rect.left) {
target_rect = target_rect.Shift(20, 20);
recheck = true;
break;
}
}
}
annotation.data.layout = this.layout.RectToAnnotationLayout(target_rect);
}
}
if (command.target) {
if (Rectangle.IsRectangle(command.target)) {
// console.info('creating from rectangle,', target);
annotation.data.layout = undefined;
annotation.rect = Rectangle.Create(command.target);
}
else if (command.target.start) {
annotation.rect = undefined;
annotation.data.layout = this.layout.AddressToAnnotationLayout(command.target.start, command.target.end||command.target.start);
}
}
if (command.add_to_sheet) {
// ensure we haven't already added this
if (!command.sheet.annotations.some((test) => test === annotation)) {
command.sheet.annotations.push(annotation);
}
this.AddAnnotation(annotation);
}
if (command.focus) {
// pending... we need to know which view it was pasted in. maybe this
// should be the index?
const view = annotation.view[this.view_index];
if (view && view.node) {
const node = view.node;
setTimeout(() => {
node.focus();
}, 1);
}
}
// return annotation;
}
/** placeholder */
public UpdateAnnotationLayout(): void {
// ...
}
public AnnotationZOrder(annotation: Annotation, operation: number|'top'|'bottom') {
if (operation === 'top') {
operation = 9999;
}
if (operation === 'bottom') {
operation = -9999;
}
this.layout.AnnotationLayoutOrder(annotation, operation);
}
/** add an annotation. it will be returned with a usable node. */
public AddAnnotation(annotation: Annotation, toll_events = false, add_to_layout = true): void {
let view = annotation.view[this.view_index];
if (!view) {
view = {};
annotation.view[this.view_index] = view;
}
if (!view.node) {
// FIXME: why is this not in layout? it is layout.
const node = this.DOM.Div('annotation', undefined, {
data: { scale: this.layout.scale.toString() },
style: { fontSize: `${10 * this.layout.scale}pt` },
attrs: { tabindex: '-1', },
events: {
mousedown: (event) => {
if (event.button !== 0) {
return;
}
this.layout.AnnotationMouseDown(annotation, node, event, move_target, resize_target).then(event => {
// console.info('resolved', event);
if (event) {
this.grid_events.Publish(event);
}
else {
// probably a click on the annotation. if it is not already
// selected, send an event.
if (this.selected_annotation !== annotation) {
this.grid_events.Publish({
type: 'annotation',
annotation,
event: 'select',
})
}
}
if (annotation.data.layout) {
this.EnsureAddress(annotation.data.layout.br.address, 1);
}
});
},
focusin: () => {
// console.info("AFI");
for (const element of this.layout.GetFrozenAnnotations(annotation)) {
element.classList.add('clone-focus');
}
this.selected_annotation = annotation;
this.primary_selection.empty = true; // FIXME: not using method? (...)
// this is done for the side-effect when we start editing, we
// capture the sheet of the primary selection. if you switch
// sheets while editing, the selection won't be set so it persists.
// we need that to switch back to the correct sheet when an edit ends.
this.primary_selection.target = { row: -1, column: -1, sheet_id: this.active_sheet.id };
this.HideGridSelection();
},
focusout: (event) => {
// console.info("AFO");
// console.info('annotation focusout', annotation, event);
for (const element of this.layout.GetFrozenAnnotations(annotation)) {
element.classList.remove('clone-focus');
}
if (this.formula_bar && this.formula_bar.IsElement((event as FocusEvent).relatedTarget as HTMLElement)) {
// console.info('editing...');
this.primary_selection.empty = true;
this.RenderSelections();
this.editing_annotation = annotation;
this.layout.ShowSelections(true);
}
else if (this.formula_bar?.IsExpandButton(event.relatedTarget as HTMLElement)) {
// for this particular case, do nothing. basically you are
// expanding/contracting the formula bar. we want to preserve
// the selected annotation, if any. after the operation we'll
// restore focus.
}
else {
// here's where we need to make a change. if the new focus
// (the related target) is outside of the sheet hierarchy, we
// want to persist the selection, or at least remember it.
// the next time we focus in on the grid we want to have the
// opportunity to restore this focus. I think persisting the
// focus may be a problem? not sure...
const focus_in_layout = this.layout.FocusInLayout(event.relatedTarget||undefined);
if (focus_in_layout) {
if (this.selected_annotation === annotation) {
this.selected_annotation = undefined;
}
this.ShowGridSelection();
}
}
},
}
});
view.node = node;
view.content_node = this.DOM.Div('annotation-content', node);
const move_target = this.DOM.Div('annotation-move-target', node);
const resize_target = this.DOM.Div('annotation-resize-target', node);
node.addEventListener('keydown', (event) => {
const rect = annotation.scaled_rect;
if (!rect) {
console.info('missing scaled rect!');
return;
}
const elements = [node, ...this.layout.GetFrozenAnnotations(annotation)];
if (event.ctrlKey || (UA.is_mac && event.metaKey)) {
const annotation_style = annotation.data.style ? { ...annotation.data.style } : {};
let handled = true;
switch (event.key) {
case 'b':
this.ApplyAnnotationStyle({bold: !annotation_style.bold});
break;
case 'i':
this.ApplyAnnotationStyle({italic: !annotation_style.italic});
break;
case 'u':
this.ApplyAnnotationStyle({underline: !annotation_style.underline});
break;
default:
handled = false;
}
if (handled) {
event.stopPropagation();
event.preventDefault();
return;
}
}
const target = { x: rect.left, y: rect.top };
switch (event.key) {
case 'ArrowUp':
case 'Up':
if (event.ctrlKey) {
if (this.layout.AnnotationLayoutOrder(annotation, 1)) {
this.grid_events.Publish({ type: 'annotation', event: 'move', annotation });
}
node.focus();
}
else {
target.y--;
}
break;
case 'ArrowLeft':
case 'Left':
if (event.ctrlKey) {
return;
}
else {
target.x--;
}
break;
case 'ArrowRight':
case 'Right':
if (event.ctrlKey) {
return;
}
else {
target.x++;
}
break;
case 'ArrowDown':
case 'Down':
if (event.ctrlKey) {
if (this.layout.AnnotationLayoutOrder(annotation, -1)) {
this.grid_events.Publish({ type: 'annotation', event: 'move', annotation });
}
node.focus();
}
else {
target.y++;
}
break;
case 'Escape':
case 'Esc':
this.Focus();
break;
case 'Backspace':
if (event.metaKey && UA.is_mac) {
this.Focus();
this.RemoveAnnotation(annotation);
}
break;
case 'Delete':
case 'Del':
this.Focus();
this.RemoveAnnotation(annotation);
break;
default:
return;
}
event.stopPropagation();
event.preventDefault();
target.x = Math.max(target.x, 0);
target.y = Math.max(target.y, 0);
if (rect.left !== target.x || rect.top !== target.y) {
rect.left = Math.round(target.x);
rect.top = Math.round(target.y);
// node.style.top = (rect.top) + 'px';
// node.style.left = (rect.left) + 'px';
for (const element of elements) {
element.style.top = (rect.top) + 'px';
element.style.left = (rect.left) + 'px';
}
annotation.data.extent = undefined; // reset
this.grid_events.Publish({ type: 'annotation', event: 'move', annotation });
// annotation.rect = rect.Scale(1/this.layout.scale);
annotation.data.layout = this.layout.RectToAnnotationLayout(rect);
}
});
}
if (add_to_layout) {
this.layout.AddAnnotation(annotation, this.theme);
if (annotation.data.layout) {
this.EnsureAddress(annotation.data.layout.br.address, 1, toll_events);
}
}
else {
// console.info('not adding annotation node to layout...');
}
/*
// ensure we haven't already added this
if (!this.active_sheet.annotations.some((test) => test === annotation)){
this.active_sheet.annotations.push(annotation);
}
*/
if (!toll_events) {
this.grid_events.Publish({
type: 'annotation',
annotation,
event: 'create',
});
}
}
/**
* call this method if an annotation is updated externally -- we're not
* watching for mutation, so if they change and we need to update, we
* won't know about it unless you tell us. sometimes this will be
* superfluous but (assuming it's one at a time) should not be too expensive
*/
public AnnotationUpdated(annotation: Annotation): void {
// console.info('call trhog', annotation);
this.layout.CloneFrozenAnnotation(annotation);
}
/**
* removes an annotation from the list, and removes the node its
* the parent (although the node still exists in the annotation, if
* it existed before).
*/
public RemoveAnnotation(annotation: Annotation, sheet = this.active_sheet): void {
this.ExecCommand({
key: CommandKey.RemoveAnnotation,
annotation,
sheet,
});
}
protected RemoveAnnotationInternal(command: RemoveAnnotationCommand) {
super.RemoveAnnotationInternal(command);
this.layout.RemoveAnnotation(command.annotation);
}
/**
* this method removes annotation nodes from the grid/layout, but doesn't
* affect the underlying data. this should be used to remove annotations
* when switching sheets.
*
* you can also use it when cleaning up, if the underlying data will also
* be wiped from the model.
*/
public RemoveAnnotationNodes(): void {
if (!this.headless) {
this.layout.RemoveAnnotationNodes();
}
}
/* *
* specialization: update selection, scroll offset
* /
public Serialize(options: SerializeOptions = {}): SerializedModel {
// selection moved to sheet, but it's not "live"; so we need to
// capture the primary selection in the current active sheet before
// we serialize it
this.active_sheet.selection = JSON.parse(JSON.stringify(this.primary_selection));
// same for scroll offset
this.active_sheet.scroll_offset = this.layout.scroll_offset;
// now call the base class serialization method
return super.Serialize(options);
}
*/
/**
* show or hide headers
*
* FIXME: this shouldn't be sent if the current value === the desired value
*
* FIXME: this is a display option. I'm not sure it should go through the
* command queue, because it's a local choice. leaving for now, but FIXME
*/
public ShowHeaders(show = true): void {
this.ExecCommand({
key: CommandKey.ShowHeaders,
show,
});
}
/**
* this method is called after an XLSX import.
*
* FIXME: should this not be in base? (...)
*
* I suppose it's specific to an environment that _could_ import XLSX,
* so it's not strictly speaking generic.
*/
public FromImportData(
import_data: {
sheets: ImportedSheetData[],
// names?: Record,
named?: SerializedNamed[],
active_tab?: number,
},
render = false,
): void {
this.RemoveAnnotationNodes();
const sheet_data = import_data.sheets;
const base_sheets = sheet_data.map(() => {
return Sheet.Blank(this.model.theme_style_properties).toJSON();
});
// it's possible that the first sheet is hidden, in which case we
// want to activate a different sheet. we'll do this by peeking at
// the import data here to find the first non-hidden sheet; if there
// aren't any, default to zero.
// FIXME: use whatever sheet the import has active, no reason to reset
let visible_sheet: number|undefined;
if (typeof import_data.active_tab === 'number') {
visible_sheet = base_sheets[import_data.active_tab]?.id;
}
else {
for (let i = 0; i < import_data.sheets.length; i++) {
if (!import_data.sheets[i].hidden) {
visible_sheet = base_sheets[i].id;
break;
}
}
}
// why do we call this? it seems like we do all the work over again
// below. we should either import first, then call update, or not
// call update and just do the work here. although the better approach
// would be to unify and reduce any duplication.
this.UpdateSheets(base_sheets, true, visible_sheet);
// build a name map for fixing named ranges
const name_map: Record = {};
// FIXME: are there macro functions in the data? (...)
// this.model.macro_functions = {};
this.model.macro_functions.clear();
this.ClearSelection(this.primary_selection);
this.model.tables.clear();
// moved data import into sheet
for (let i = 0; i < sheet_data.length; i++) {
const sheet = this.model.sheets.list[i];
sheet.ImportData(sheet_data[i]);
name_map[sheet.name] = sheet.id;
// FIXME: list tables separately in import data so we don't have
// to look at every cell
for (const cell of sheet_data[i].cells) {
if (cell.table) {
cell.table.area.start.sheet_id = sheet.id;
this.model.tables.set(cell.table.name.toLowerCase(), cell.table);
}
}
for (const table of this.model.tables.values()) {
this.UpdateTableColumns(table);
}
}
this.model.sheets.UpdateIndexes();
this.model.named.Reset();
if (import_data.named) {
this.model.UnserializeNames(import_data.named, this.active_sheet, true);
}
// FIXME: do we need to rebuild autocomplete here (A: yes)
// ...
// should already be added, right?
for (const element of this.active_sheet.annotations) {
if (!this.headless) {
this.AddAnnotation(element, true);
}
}
// handle any necessary activation tasks
this.ActivateSheetTasks();
// no longer sending explicit layout event here
this.QueueLayoutUpdate();
this.StyleDefaultFromTheme();
if (render) {
this.Repaint(false, false); // true, true);
}
if (this.tab_bar) {
this.tab_bar.Update(false); // !user
}
}
/**
* This function is called via Shift+PageUp/PageDown. We need
* to update to account for hidden sheets, which can't be activated.
*/
public NextSheet(step = 1, user = false): void {
if (this.model.sheets.length === 1) {
return;
}
// we could build a list of allowable sheets, or we could walk...
// building a list would help identify cases where
// there's only one sheet visible, and we could leave early
// (walking would be simpler, in the end, there are three loops here)
// list of tuples: visible sheet, index
const visible = this.model.sheets.list.map((sheet, index) => ({ sheet, index })).filter(test => test.sheet.visible);
if (visible.length === 1) {
return;
}
for (let i = 0; i < visible.length; i++) {
if (visible[i].sheet === this.active_sheet) {
let index = (i + step) % visible.length;
while (index < 0) { index += visible.length; }
this.ActivateSheet(visible[index].index, user);
return;
}
}
}
/**
* 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).
*
*/
public UpdateSheets(data: LegacySerializedSheet[], render = false, activate_sheet?: number | string): void {
super.UpdateSheets(data, render, activate_sheet);
// remove existing annotations from layout
this.RemoveAnnotationNodes();
// selection
this.ClearSelection(this.primary_selection);
// FIXME: copying from updatesheet AND activatesheet... still need to unify
// support old style files
if (data[0] && data[0].primary_selection) {
const selection = (data[0].primary_selection) as GridSelection;
if (!selection.empty) {
this.Select(this.primary_selection,
new Area(selection.area.start, selection.area.end), selection.target);
}
}
// the new version, as fallback
else if (!this.active_sheet.selection.empty) {
const template = this.active_sheet.selection;
this.Select(this.primary_selection,
new Area(template.area.start, template.area.end), template.target);
}
// for this version, we want to add all annotations at once; we only
// add annotations on the active sheet to the layout, but the rest are
// also created. the intent here is to ensure that any dependent cells
// (like MC results) are marked even before we open a particular sheet.
// otherwise layout of annotations won't work properly
this.layout.ClearLayoutCaches();
for (const sheet of this.model.sheets.list) {
for (const annotation of sheet.annotations) {
this.AddAnnotation(annotation, true, (sheet === this.active_sheet));
}
}
// handle any necessary activation tasks
this.ActivateSheetTasks();
// we do the tile rebuild just before the next paint, to prevent
// flashing. seems to be stable but needs more testing. note that
// if you call this with render = true, that will happen immediately,
// synchronously.
// no longer sending layout event here
// we may need to update scroll after expanding the grid (especially
// if this is the first load), so temporarily cache the scroll target.
// use a clone, so we don't accidentally modify it.
this.QueueLayoutUpdate(JSON.parse(JSON.stringify(this.active_sheet.scroll_offset)));
this.StyleDefaultFromTheme();
// TODO: reset scroll
if (render) {
this.Repaint(false, false);
}
if (this.tab_bar) {
this.tab_bar.Update(false); // !user
}
}
/**
* rebuild layout on a resize. we are not trapping resize events, clients
* should do that (also this works for embedded elements that are not
* directly affected by document resize).
*/
public UpdateLayout(): void {
this.layout.UpdateTiles();
this.layout.UpdateContentsSize();
this.render_tiles = this.layout.VisibleTiles();
this.Repaint(true);
}
/**
* we need this method for split views
*/
public UpdateTabBar(): void {
if (this.tab_bar) {
this.tab_bar.Update(true); // user? ...
}
}
/**
* @param initial first call, from the grid Initialize() method
*/
public UpdateTheme(initial = false, additional_properties?: Partial): void {
if (!initial) {
for (const key of Object.keys(this.theme) as Array) {
delete this.theme[key];
}
}
let composite: Theme = JSON.parse(JSON.stringify(DefaultTheme));
if (this.view_node) {
const theme_properties = LoadThemeProperties(this.view_node, this.options.support_font_stacks);
composite = {...theme_properties};
}
// all this is super confusing, probably the result of theme going
// back and forth from css to object. needs a scrub.
// I think theme is permanent, so we wind up doing a lot of stuff
// to manage properties -- make it ephemeral with a getter or a wrapper?
// NOTE: this prevents it from rebuilding based on changes to the
// stylesheet; putting this.theme second overrides any new values.
// depending on whether we want to keep using object theme, we might
// remove that. for the time being we'll use a flag...
/*
const composite = CalculateSupplementalColors({
...theme_properties,
...this.theme,
...additional_properties,
});
*/
composite = {
// ...theme_properties,
...this.theme,
...composite,
...additional_properties,
};
Object.assign(this.theme, composite);
//for (const key of Object.keys(composite) as Array) {
// this.theme[key] = composite[key] as any; // solve this problem in typescript
//}
// update style for theme
this.StyleDefaultFromTheme();
this.active_sheet.UpdateDefaultRowHeight(this.theme);
this.active_sheet.FlushCellStyles();
this.layout.ApplyTheme(this.theme);
// this.tile_renderer.UpdateTheme(); // has reference
if (this.tab_bar) {
this.tab_bar.UpdateTheme();
}
if (!initial) {
this.UpdateLayout(); // in case we have changed font size
// this.selection_renderer.Flush();
this.overlay_editor?.UpdateScale(this.layout.scale);
// if (this.formula_bar) this.formula_bar.UpdateTheme();
this.Repaint(true, true, true);
}
}
/** set scale directly */
public SetScale(scale: number) {
scale = Math.round(scale * 1000) / 1000;
scale = Math.min(2, Math.max(scale, .5));
if (this.options.persist_scale_key) {
localStorage.setItem(this.options.persist_scale_key, JSON.stringify({scale}));
}
this.scale = scale;
}
/**
* @param container html container element
*/
public Initialize(view_node: HTMLElement, toll_initial_render = false): void {
if (!this.tile_renderer || !this.selection_renderer) {
return;
}
// grid no longer has access to the outer container, it just has
// the "view" container. so we need to move things like UA classes
// outside of this class. we should move theme parsing as well, so
// we don't do that twice if we have two views.
// going to rename to clarify.
this.view_node = view_node;
// MOVE
// this.ApplyTheme();
this.UpdateTheme(true);
const higher_level_container = view_node.querySelector('.treb-spreadsheet-body') as HTMLElement;
const container = higher_level_container.querySelector('div') as HTMLElement;
// let autocomplete: Autocomplete | undefined;
if (this.options.formula_bar) {
if (!this.autocomplete) {
this.autocomplete = new Autocomplete({ theme: this.theme, container });
}
this.InitFormulaBar(view_node, this.autocomplete);
}
if (this.options.tab_bar) {
this.tab_bar = new TabBar(this.layout, this.model, this.view, this.options, this.theme, view_node);
this.tab_bar.Subscribe((event) => {
switch (event.type) {
case 'cancel':
break;
case 'scale':
{
let scale = this.layout.scale;
scale = Math.round(event.value * 1000) / 1000;
scale = Math.min(2, Math.max(scale, .5));
if (this.options.persist_scale_key) {
localStorage.setItem(this.options.persist_scale_key, JSON.stringify({scale}));
}
this.scale = scale;
if (event.keep_focus) {
return; // prevent focus stealing (for now)
}
}
break;
case 'reorder-sheet':
this.ReorderSheet(event.index, event.move_before);
break;
case 'delete-sheet':
this.DeleteSheet();
break;
case 'add-sheet':
this.AddSheet();
break;
case 'rename-sheet':
this.RenameSheet(event.sheet, event.name);
break;
case 'activate-sheet':
this.ActivateSheetID(event.sheet.id);
break;
}
if (!this.SelectingArgument()) {
this.Focus();
}
});
}
// set container and add class for our styles
this.container = container;
this.container.classList.add('treb-grid');
// we can force scrollbars, but it breaks sticky (apparently)
//
// ...doesn't break sticky, at least AFAICT; also necessary for
// horizontal scrolling.
if (UA.is_mac && UA.is_safari) {
this.container.classList.add('safari');
}
// accept focus, keyboard input
this.container.setAttribute('tabindex', '-1');
/* see method comment
// if there's a sheet passed in, use that (serialized)
if (sheet_data) {
try {
Sheet.FromJSON(sheet_data, this.model.sheet);
}
catch (err) {
console.error('creating document failed');
console.error(err);
}
}
*/
// create dom structure
this.layout.Initialize(container, {
// scroll callback
scroll: () => this.OnScroll(),
// dropdown callback
dropdown: (value: CellValue) => this.OnDropdownSelect(value),
// sort (table) callback
sort: (name: string, column: number, asc: boolean) => {
const table = this.model.tables.get(name.toLowerCase());
if (table) {
this.SortTable(table, {
column,
asc,
});
}
},
// focus callback
focus: () => this.Focus(),
},
this.options.scrollbars);
this.selection_renderer.Initialize();
this.layout.UpdateTiles();
// event handlers and components
// Sheet.sheet_events.Subscribe(this.HandleSheetEvent.bind(this));
if (!this.autocomplete) {
this.autocomplete = new Autocomplete({ theme: this.theme, container });
}
this.InitOverlayEditor(this.autocomplete);
this.AttachListeners();
// set local state and update
this.render_tiles = this.layout.VisibleTiles();
// don't delay this, it looks terrible
if (!toll_initial_render) {
// tab bar was disappearing on initial load without a document; that's
// because it was only getting called on load/update. if we're not tolling,
// then we need to update here.
// if (this.tab_bar) {
// this.tab_bar.Update();
//}
this.tab_bar?.Update(false);
this.Repaint(true);
}
}
/**
* merge target area or selection
*/
public MergeCells(area?: Area): void {
if (!area && this.primary_selection.empty) {
return; // FIXME: warn?
}
this.layout.HideDropdownCaret();
this.ExecCommand({
key: CommandKey.MergeCells,
area: area || this.primary_selection.area,
});
}
/**
* unmerge cells
*/
public UnmergeCells(area?: Area): void {
if (!area && this.primary_selection.empty) {
return; // FIXME: warn?
}
this.layout.HideDropdownCaret();
this.ExecCommand({
key: CommandKey.UnmergeCells,
area: area || this.primary_selection.area,
});
}
/**
*
*/
public GetAnnotationStyle(): CellStyle|undefined {
if (this.selected_annotation) {
if (this.selected_annotation.data?.style) {
return JSON.parse(JSON.stringify(this.selected_annotation.data.style));
}
return {};
}
return undefined;
}
/**
*
*/
public ApplyAnnotationStyle(style: CellStyle = {}, delta = true) {
// get the logic from committing a formula, I guess? it should run
// through the command queue to update any related views.
//
// actually, FIXME? I think updating annotations generally is not
// running through the command queue, that's a larger issue we need
// to look at.
if (this.selected_annotation) {
const annotation = this.selected_annotation;
// we need to adjust textbox paragraph styles so they don't
// override the annotation style. OR, we need to apply this
// styling to the paragraphs. the former sounds better to me,
// but then if we allow you to edit the paragraphs we'll have
// to adjust the annotation style later. not sure which is
// preferable.
annotation.data.style = JSON.parse(JSON.stringify(
delta ? Style.Composite([annotation.data.style || {}, style]) : style));
const node = annotation.view[this.view_index]?.node;
this.layout.UpdateAnnotation(annotation, this.theme);
if (node) {
node.focus();
}
this.grid_events.Publish({ type: 'annotation', event: 'update', annotation });
this.DelayedRender();
}
}
/**
*
*/
public AnnotationSelected() {
return !!this.selected_annotation;
}
/**
* focus on the container. not sure what that text parameter was,
* legacy? TODO: remove
*/
public Focus(text = '', click = false): void {
if (this.selected_annotation) {
if (click) {
if (this.editing_annotation === this.selected_annotation) {
this.pending_reset_selection = true;
}
else {
this.selected_annotation = undefined;
this.ShowGridSelection();
}
}
else {
// console.info("reselect annotation...", this.selected_annotation);
// console.info("check", this.view_index, this.selected_annotation.view[this.view_index].node);
this.selected_annotation.view[this.view_index].node?.focus();
return;
}
}
// FIXME: cache a pointer
if (UA.is_mobile) {
this.container?.focus();
}
else {
this.overlay_editor?.Focus(text);
}
// this.container?.focus();
}
/**
* set "data validation", which (atm) only supports a list of options
* and will render as a dropdown; the list can be a list of values or
* a range reference.
*/
public SetValidation(target?: IArea, data?: CellValue[]|IArea, error?: boolean): void {
if (!target) {
if (this.primary_selection.empty) {
throw new Error('invalid target in set validation');
}
target = this.primary_selection.area;
}
const area = new Area(target.start, target.end);
// console.info({target, data});
const command: DataValidationCommand = {
key: CommandKey.DataValidation,
area: { start: area.start, end: area.end },
error,
};
if (data) {
if (Array.isArray(data)) {
command.list = data;
}
else if (typeof data === 'object'){
if (data.start && data.end && IsCellAddress(data.start) && IsCellAddress(data.end)) {
command.range = data as IArea;
}
}
}
this.ExecCommand(command);
//
// we should repaint or reselect the currect active cell if that cell
// is the target; otherwise the dropdown caret does not appear/ stays
// around.
//
if (!this.primary_selection.empty &&
(!target.start.sheet_id || target.start.sheet_id === this.active_sheet.id) &&
area.Contains(this.primary_selection.target)) {
// (this.primary_selection.target.row === target.start.row) &&
// (this.primary_selection.target.column === target.start.column)) {
// console.info('repaint selection');
requestAnimationFrame(() => this.Select(
this.primary_selection,
this.primary_selection.area,
this.primary_selection.target));
}
}
/**
* set or clear name. optionally overwrite existing name. note that
* you cannot overwrite function names, only existing named ranges/expressions.
*
* set name to refer to a range (named range) or expression/value (named
* expression). range has priority if you set both (FIXME: make that impossible)
*
* note that expression here must be a valid expression in SL. if you want
* to set a literal string, enclose it in double quotes (as you would when
* using a string as an argument to a function).
*/
public SetName(name: string, range?: ICellAddress | Area, expression?: string, scope?: number, overwrite = false): void {
// console.info('setname', { name, range, expression, scope, overwrite });
// validate/translate name first
const validated = this.model.named.ValidateNamed(name);
if (!validated) {
throw new Error('invalid name');
}
name = validated;
// check against functions. also an error if the name exists
// but the overwrite flag is not set.
if (this.autocomplete_matcher) {
const ac_entry = this.autocomplete_matcher.Get(name);
if (ac_entry) {
if (!ac_entry.named || !overwrite) {
if (range || expression) {
throw new Error('name already defined');
}
}
}
}
const command: SetNameCommand = {
key: CommandKey.SetName,
name,
scope,
};
if (range) {
if (IsCellAddress(range)) {
range = new Area(range);
}
// make sure that this has a sheet ID, use active if not otherwise
// set. FIXME: move to area? (...)
if (!range.start.sheet_id) {
range = new Area(
{ ...range.start, sheet_id: this.active_sheet.id }, range.end);
}
command.area = new Area(range.start, range.end);
}
else if (expression) {
const parse_result = this.parser.Parse(expression);
// there's a case we're missing here: if you pass a literal
// string, not as a function, that should be interpreted as
// a string. we still want to use the parser on functions
// and literal types... how to distinguish?
// actually, check that: let's require strings to be quoted,
// so they get interpreted as literals. if you want to do fancy
// switching do that at a higher level.
// ...
// resolve sheet. otherwise we wind up with dangling
// references. NOTE: need to do this on import as well
// FIXME: hmmm... I think I like the dangling reference?
// so if you define something as `=A1*2`, then it should
// resolve to the sheet you enter it into?
if (parse_result.valid && parse_result.expression) {
this.parser.Walk(parse_result.expression, unit => {
if (unit.type === 'address' || unit.type === 'range') {
if (unit.type === 'range') {
unit = unit.start;
}
if (!unit.sheet_id) {
if (unit.sheet) {
const sheet = this.model.sheets.Find(unit.sheet);
if (sheet) {
unit.sheet_id = sheet.id;
}
}
}
if (!unit.sheet_id) {
unit.sheet_id = this.active_sheet.id;
}
return false;
}
return true;
});
command.expression = parse_result.expression;
}
else {
console.info({expression, parse_result});
throw new Error('invalid expression');
}
}
this.ExecCommand(command);
}
/**
* this version selects blocks first, if the block is already selected
* then it selects everything
*/
public SelectAll2(): void {
if (this.primary_selection.empty) {
// no primary selection, select everything
this.Select(this.primary_selection, new Area({ row: Infinity, column: Infinity }), undefined, true);
}
else {
// expand region. if it's different, select that. otherwise select everything
const expanded = Heuristics.ExpandRegion(this.primary_selection.area, this.active_sheet);
if (expanded.Equals(this.primary_selection.area)) {
this.Select(this.primary_selection, new Area({ row: Infinity, column: Infinity }), undefined, true);
}
else {
this.Select(this.primary_selection, expanded, undefined, true);
}
}
this.RenderSelections();
}
public SelectAll(): void {
this.Select(this.primary_selection, new Area({ row: Infinity, column: Infinity }), undefined, true);
this.RenderSelections();
}
/**
* set or remove external editor. this is not an accessor because I don't
* want to have to use a duplicate internal field, it's clumsy and this
* class isn't public-facing so it's not super important.
*/
public ExternalEditor(config: Partial|undefined) {
this.external_editor_config = config;
if (config) {
const areas: Area[] = (config.dependencies || []).filter(
(test: T|undefined): test is T => !!test).map(reference =>
IsCellAddress(reference) ? new Area(reference) : new Area(reference.start, reference.end));
if (config.nodes?.length) {
if (!this.external_editor) {
const editor = new ExternalEditor(this.model, this.view);
this.external_editor = editor;
// should this persist, or should we only subscribe when we're active? (...)
// in theory, at least, it won't send any events unless something changes
editor.Subscribe(() => this.HighlightDependencies(editor.dependencies));
}
this.external_editor.AttachNodes(config.nodes, config.assume_formula ?? true);
this.external_editor.key_event_callback = (event: KeyboardEvent) => {
event.stopPropagation();
event.preventDefault();
// ??
this.OverlayKeyDown(event);
};
}
else {
if (this.external_editor) {
this.external_editor.Reset();
}
}
if (config.dependencies) {
this.HighlightDependencies(areas);
}
}
else {
if (this.external_editor) {
this.external_editor.Reset();
}
this.ClearAdditionalSelections();
this.RenderSelections(true);
}
}
/** API method */
public SelectRange(range?: Area): void {
this.Select(this.primary_selection, range);
this.RenderSelections();
}
/* *
* FIXME: who uses this? anyone?
* /
public GetNumberFormat(address: ICellAddress): string|undefined {
const style = this.active_sheet.CellStyleData(address);
if (style && style.number_format) {
return NumberFormatCache.Get(style.number_format).toString();
}
}
*/
/**
* I can't figure out a way in typescript to overload the GetRange function
* but call it with a variable to determine the overload (the aim is to have
* different return types).
*
* it seems to work fine if we use static values for the parameter (type,
* below) but not a variable which is (theoretically) type-restricted to the
* same values. One to work on, maybe.
*
* @param range
* @returns
*/
public GetRangeStyle(range: ICellAddress|IArea, apply_theme = false): CellStyle|CellStyle[][]|undefined {
let sheet_id = 0;
if (IsCellAddress(range)) {
sheet_id = range.sheet_id || this.active_sheet.id;
}
else {
sheet_id = range.start.sheet_id || this.active_sheet.id;
}
if (sheet_id) {
const sheet = this.model.sheets.Find(sheet_id);
return sheet?.GetCellStyle(range, apply_theme) || undefined;
}
return undefined;
}
/**
* render the given data as R1C1. the source address is a base for
* rendering relative addresses (is there a case for this method
* handling absolute offsets as well? ...)
*
* NOTE: this method modifies the input data (at least it does if
* it's an array). so this method should only be called with scratchpad
* data.
*
*/
public FormatR1C1(data: CellValue|CellValue[][], source: ICellAddress|IArea) {
// normalize
if (IsArea(source)) {
source = source.start;
}
if (!Array.isArray(data)) {
data = [[data]];
}
const base: UnitAddress = {
type: 'address',
label: '',
row: source.row,
column: source.column,
sheet: source.sheet_id ? this.model.sheets.Name(source.sheet_id) : this.active_sheet.name,
position: 0,
id: 0,
};
for (let r = 0; r < data.length; r++) {
const row = data[r];
for (let c = 0; c < row.length; c++) {
const cell = row[c];
if (typeof cell === 'string' && cell[0] === '=') {
const parse_result = this.parser.Parse(cell);
if (parse_result.expression) {
row[c] = '=' + this.parser.Render(parse_result.expression, {
missing: '',
r1c1: true,
r1c1_base: {
...base,
row: source.row + r,
column: source.column + c,
},
r1c1_force_relative: true,
r1c1_proper_semantics: true,
});
}
}
}
}
return data;
}
/**
* get data in a given range, optionally formulas
* API method
*/
public GetRange(range: ICellAddress | IArea, type?: 'formatted'|'A1'|'R1C1'): CellValue|CellValue[][]|undefined {
if (IsCellAddress(range)) {
const sheet = this.model.sheets.Find(range.sheet_id || this.active_sheet.id);
if (sheet) {
if (type === 'A1' || type === 'R1C1') {
const data = sheet.cells.RawValue(range);
if (type === 'R1C1') {
return this.FormatR1C1(data, range);
}
return data;
}
if (type === 'formatted') { return sheet.GetFormattedRange(range); }
return sheet.cells.GetRange(range);
}
return undefined;
}
const sheet = this.model.sheets.Find(range.start.sheet_id || this.active_sheet.id);
if (sheet) {
if (type === 'A1' || type === 'R1C1') {
const data = sheet.cells.RawValue(range.start, range.end);
if (type === 'R1C1') {
return this.FormatR1C1(data, range);
}
return data;
}
if (type === 'formatted') { return sheet.GetFormattedRange(range.start, range.end); }
return sheet.cells.GetRange(range.start, range.end);
}
return undefined;
}
/**
* set data in given range
* API method
*
* not sure why we have support for ArrayBufferView in here. this is an API
* method, called by the embed sheet's API method, so there are no particular
* requirements. we should lock down the allowable types.
*
* @param range target range. if range is smaller than data, range controls.
* if range is larger, behavior depends on the recycle parameter.
* @param data single value, array (column), or 2d array
* @param recycle recycle values. we only recycle single values or single
* rows/columns -- we will not recycle a matrix.
* @param transpose transpose before inserting (data is column-major)
* @param r1c1 - support R1C1 notation. this does not mean the data _is_ in
* R1C1, just that we need to check for it and handle it. R1C1 is useful for
* relative offsets.
*/
public SetRange(
range: Area,
data: CellValue|CellValue[]|CellValue[][],
options: SetRangeOptions = {}) { // recycle = false, transpose = false, array = false, r1c1 = false): void {
const { recycle, transpose, array, r1c1 } = options;
// do a batch conversion if we have an explicit separator set
if (options.argument_separator) {
const current = {
argument_separator: this.parser.argument_separator,
decimal_mark: this.parser.decimal_mark,
}
this.parser.Save();
let convert = false;
if (options.argument_separator === ',' && this.parser.argument_separator !== ArgumentSeparatorType.Comma) {
this.parser.SetLocaleSettings(DecimalMarkType.Period);
convert = true;
}
if (options.argument_separator === ';' && this.parser.argument_separator !== ArgumentSeparatorType.Semicolon) {
this.parser.SetLocaleSettings(DecimalMarkType.Comma);
convert = true;
}
if (convert) {
this.parser.flags.r1c1 = r1c1;
const Convert = (value: CellValue): CellValue => {
if (typeof value === 'string' && value[0] === '=') {
const result = this.parser.Parse(value);
if (result.expression) {
value = '=' + this.parser.Render(result.expression, {
missing: '',
convert_decimal: current.decimal_mark,
convert_argument_separator: current.argument_separator,
pass_through_addresses: true,
});
}
// console.info("CVT", this.parser.flags, result.expression, value);
}
return value;
};
if (Array.isArray(data)) {
data = data.map(entry => {
if (Array.isArray(entry)) {
return entry.map(cell => {
return Convert(cell);
});
}
else {
return Convert(entry);
}
}) as CellValue|CellValue[]|CellValue[][];
}
else {
data = Convert(data);
}
}
// reset
// this.parser.argument_separator = current.argument_separator;
// this.parser.decimal_mark = current.decimal_mark;
this.parser.Restore();
}
// this is public so we need to (un)translate.
// console.info("PRE", {r1c1}, data);
data = this.model.UntranslateData(data, { r1c1 });
// console.info("POST", data, "\n");
// single value, easiest
if (!Array.isArray(data)) {
if (recycle || array) {
this.ExecCommand({ key: CommandKey.SetRange, area: range, value: data, array, r1c1 });
}
else {
this.ExecCommand({ key: CommandKey.SetRange, area: range.start, value: data, array, r1c1 });
}
}
else {
if (!Is2DArray(data)) {
// we don't allow this anymore (at least we say we don't)
// flat array -- we can recycle. recycling is R style (values, not rows).
// in any event convert to [][]
if (recycle) {
const rows = range.entire_column ? this.active_sheet.rows : range.rows;
const columns = range.entire_row ? this.active_sheet.columns : range.columns;
const count = rows * columns;
if (count > data.length) {
let tmp = data.slice(0);
const multiple = Math.ceil(count / tmp.length);
for (let i = 1; i < multiple; i++) {
tmp = tmp.concat(data.slice(0));
}
data = tmp;
}
// reshape
const reshaped: CellValue[][] = [];
for (let c = 0, index = 0; c < columns; c++, index += rows) {
reshaped[c] = data.slice(index, index + rows);
}
data = reshaped;
}
else {
data = [data];
}
}
else {
if (recycle) {
// recycle 2D array. we'll do this if the target range is a multiple
// (or larger) of the source array. in blocks.
if (range.rows > data.length) {
const recycle_rows = Math.floor(range.rows / data.length);
if (recycle_rows > 1) {
const source = [...data];
for (let i = 0; i < recycle_rows; i++) {
const clone = JSON.parse(JSON.stringify(source));
data.push(...clone);
}
}
}
let cols = 0;
for (const row of data) {
cols = Math.max(cols, row.length);
}
if (range.columns > cols) {
const recycle_columns = Math.floor(range.columns / cols);
if (recycle_columns > 1) {
for (const row of data) {
// pad out all rows first, jic
while (row.length < cols) {
row.push(undefined);
}
// now recycle
const source = [...row];
for (let i = 0; i < recycle_columns; i++) {
const clone = JSON.parse(JSON.stringify(source));
row.push(...clone);
}
}
// ...
// console.info({recycle_columns});
}
}
}
}
if (transpose) { data = this.Transpose(data); }
this.ExecCommand({ key: CommandKey.SetRange, area: range, value: data, array, r1c1 });
}
// how does this make sense here, if the command is executed asynchronously? (...)
if (!this.primary_selection.empty && range.Contains(this.primary_selection.target)) {
this.UpdateFormulaBarFormula();
}
}
/**
* applies the given style properties to the passed array, or to the
* current primary selection
*
* API method
*/
public ApplyStyle(area?: Area, properties: CellStyle = {}, delta = true): void {
if (!area) {
if (this.primary_selection.empty) {
return;
}
else area = this.primary_selection.area;
}
// patch for old versions
Sheet.UpdateStyle(properties);
this.ExecCommand({
key: CommandKey.UpdateStyle,
area,
style: properties,
delta,
});
this.UpdateFormulaBarFormula();
}
/**
* returns the primary selection. we use a reference to the real selection
* sp callers can track; however, you can break things if you modify it.
* so don't modify it. FIXME: proxy view? (...)
*
* API method
*/
public GetSelection(): GridSelection {
return this.primary_selection;
}
/** repaint after an external event (calculation) */
public Update(force = false, area?: IArea|IArea[]): void {
this.DelayedRender(force, area);
}
/**
* update annotation layouts (not content)
*/
public UpdateAnnotations() {
if (this.active_sheet.annotations.length) {
this.layout.UpdateAnnotation(this.active_sheet.annotations, this.theme);
}
}
/* *
* API method
*
* @param area
* @param borders
* @param color
* @param width
* /
public ApplyBorders(area?: Area, borders: BorderConstants = BorderConstants.None, color?: string, width = 1): void {
if (!area) {
if (this.primary_selection.empty) { return; }
area = this.primary_selection.area;
}
if (borders === BorderConstants.None) {
width = 0;
}
this.ExecCommand({
key: CommandKey.UpdateBorders,
color: { text: color },
area,
borders,
width,
});
}
*/
public Indent(area?: Area, delta = 0): void {
if (!area) {
if (this.primary_selection.empty) { return; }
area = this.active_sheet.RealArea(this.primary_selection.area);
}
this.ExecCommand({
key: CommandKey.Indent,
area,
delta,
});
}
/** updated API method, probably change the name */
public ApplyBorders2(area?: Area, borders: BorderConstants = BorderConstants.None, color?: Color, width = 1): void {
if (!area) {
if (this.primary_selection.empty) { return; }
area = this.primary_selection.area;
}
if (borders === BorderConstants.None) {
width = 0;
}
this.ExecCommand({
key: CommandKey.UpdateBorders,
color,
area,
borders,
width,
});
}
/**
* batch updates. returns all the events that _would_ have been sent.
* also does a paint (can disable).
*
* update for nesting/stacking. we won't return events until the last
* batch is complete. paint will similarly toll until the last batch
* is complete, and we'll carry forward any paint requirement from
* inner batch funcs.
*
* @param func
*/
public Batch(func: () => void, paint = true): GridEvent[] {
if (this.batch === 0) {
this.batch_paint = paint; // clear any old setting
}
else {
this.batch_paint = this.batch_paint || paint; // ensure we honor it at the end
}
this.batch++;
func();
this.batch--;
if (this.batch === 0) {
const events = this.batch_events.slice(0);
this.batch_events = [];
if (this.batch_paint) {
this.DelayedRender(false);
}
return events;
}
return []; // either nothing happened or we're not done
}
/**
* scrolls so that the given cell is in the top-left (assuming that is
* possible)
*/
public ScrollTo(address: ICellAddress, x = true, y = true, smooth = false): void {
this.layout.ScrollTo(address, x, y, smooth);
}
/**
* scrolls the given address into view (assuming it's not in view now)
*
* FIXME: we need a way to do this without scrolling the containing
* page, in the event we do a scroll-on-load. small problem.
*/
public ScrollIntoView(address: ICellAddress, smooth = false): void {
if (this.options.scrollbars) {
this.layout.ScrollIntoView(address, smooth);
}
}
public GetScrollOffset(): {x: number, y: number} {
return this.layout.GetScrollOffset();
}
/**
* get/set the raw scroll offset (ignoring headers). support for API method.
* @param offset
*/
public ScrollOffset(offset?: {x: number, y: number}): {x: number, y: number}|undefined {
if (offset) {
this.layout.scroll_offset = offset;
}
else {
return this.layout.scroll_offset;
}
}
// --- private methods -------------------------------------------------------
protected RenameSheetInternal(target: Sheet, name: string) {
super.RenameSheetInternal(target, name);
this.tab_bar?.Update(false);
}
private StyleDefaultFromTheme() {
this.model.theme_style_properties.font_face = this.theme.grid_cell?.font_face || '';
this.model.theme_style_properties.font_size =
this.theme.grid_cell?.font_size || { unit: 'pt', value: 10 };
}
private AutoSizeRow(sheet: Sheet, row: number, allow_shrink = true): void {
if (!this.tile_renderer) {
return;
}
const current_height = sheet.GetRowHeight(row);
let max_height = 0;
const padding = 6; // 4 * 2; // FIXME: parameterize
for (let column = 0; column < sheet.cells.columns; column++) {
const cell = sheet.CellData({ row, column });
const { height } = this.tile_renderer.MeasureText(cell, sheet.GetColumnWidth(column), 1);
max_height = Math.max(max_height, height + padding);
}
if (!allow_shrink) {
max_height = Math.max(current_height, max_height);
}
if (max_height > padding + 2) {
sheet.SetRowHeight(row, max_height);
}
}
private AutoSizeColumn(sheet: Sheet, column: number, allow_shrink = true): void {
if (!this.tile_renderer) {
return;
}
const current_width = sheet.GetColumnWidth(column);
let max_width = 0;
const padding = 14; // 4 * 2; // FIXME: parameterize
for (let row = 0; row < sheet.cells.rows; row++) {
const cell = sheet.CellData({ row, column });
const { width } = this.tile_renderer.MeasureText(cell, current_width, 1);
max_width = Math.max(max_width, width + padding);
}
if (!allow_shrink) {
max_width = Math.max(current_width, max_width);
}
if (max_width > padding + 2) {
sheet.SetColumnWidth(column, max_width);
}
}
/**
* we have to handle the case where we have a split view and the model
* changes in some way -- remove a sheet, for example -- that invalidates
* our active sheet. if that happens, we need to switch to a different
* sheet.
*
* we don't get events about this (should we?) so someone will have to
* call it. FIXME: we should get events about that.
*/
public EnsureActiveSheet(force = false) {
for (const sheet of this.model.sheets.list) {
if (sheet === this.active_sheet) {
if (force) {
this.ActivateSheetInternal({
key: CommandKey.ActivateSheet,
id: sheet.id,
force: true,
});
}
return;
}
}
// invalid -- jump to 0
this.ActivateSheetInternal({
key: CommandKey.ActivateSheet,
index: 0,
});
}
/**
* this is intended to synchronize views when annotations are added/removed.
* there should be no need to call it if there's only one view.
*/
public RefreshAnnotations() {
this.RemoveAnnotationNodes();
for (const element of this.active_sheet.annotations) {
this.AddAnnotation(element, true, true);
}
}
/**
* specialization for grid. note that we don't call superclass,
* so we need to do everything that method does as well.
*/
protected ActivateSheetInternal(command: ActivateSheetCommand) {
const selecting_argument = this.SelectingArgument();
// console.info('activate sheet', command, 'sa?', selecting_argument);
const candidate = this.ResolveSheet(command) || this.model.sheets.list[0];
// ok, activate...
if (this.active_sheet === candidate && !command.force) {
return;
}
if (!candidate.visible) {
throw new Error('cannot activate hidden sheet');
}
// hide note, title
// FIXME: I don't like that we're maintaining these local flags. those
// should be encapsulated somewhere, along with the show/hide methods.
this.HideHoverInfo();
// cache primary selection in the sheet we are deactivating
// FIXME: cache scroll position, too!
this.active_sheet.selection = JSON.parse(JSON.stringify(this.primary_selection));
this.active_sheet.scroll_offset = this.layout.scroll_offset;
// hold this for the event (later)
const deactivate = this.active_sheet;
this.RemoveAnnotationNodes();
// select target
this.active_sheet = candidate;
// ---
// don't update selection if selecting argument... actually we _do_
// want to clear the primary selection, we just don't want the side
// effects of clearing the formula bar and so on
if (!selecting_argument) {
if (candidate.selection && !candidate.selection.empty) {
this.Select(this.primary_selection,
new Area(candidate.selection.area.start, candidate.selection.area.end),
candidate.selection.target, undefined, 'sheet-change');
}
else {
this.ClearSelection(this.primary_selection, 'sheet-change');
}
}
else {
this.RenderSelections();
}
// 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);
}
// handle any necessary activation tasks
this.ActivateSheetTasks();
// we do the tile rebuild just before the next paint, to prevent
// flashing. seems to be stable but needs more testing. note that
// if you call this with render = true, that will happen immediately,
// synchronously.
// no longer sending layout event here
this.QueueLayoutUpdate();
// this.StyleDefaultFromTheme(); // ?
// if (render) {
// our scheme for co-editing causes some cases where we will need to
// flush rendered data in background sheets. in that case we're using
// this set to list sheets as pending, so we can refresh when we activate
// the sheet.
if (this.pending_layout_update.has(this.active_sheet.id)) {
this.Repaint(true, true);
this.pending_layout_update.delete(this.active_sheet.id);
}
else {
this.Repaint(false, false);
}
// }
// FIXME: structure event
this.grid_events.Publish({
type: 'sheet-change',
deactivate,
activate: this.active_sheet,
});
if (this.tab_bar) { this.tab_bar.Update(!!command.user); }
this.layout.scroll_offset = this.active_sheet.scroll_offset;
if (this.formula_bar?.selecting) {
requestAnimationFrame(() => this.formula_bar?.FocusEditor());
}
}
/**
* handle any tasks the sheet needs to do on activation
*/
private ActivateSheetTasks() {
this.active_sheet.Activate(this.DOM);
if (this.active_sheet.image && !this.active_sheet.image.complete) {
// the image may not have loaded immediately -- in fact it usually
// won't, unless it's already in memory for some reason. schedule
// a repaint.
const image = this.active_sheet.image;
// limit so we don't do this forever
// can we do animationFrame or even promise.resolve?
let counter = 0;
const RepaintLayout = () => {
if (!image.complete) {
if (counter++ < 5) {
setTimeout(() => RepaintLayout(), 10);
}
}
else {
this.UpdateLayout();
}
};
RepaintLayout();
}
}
/**
* why is this not in layout? (...)
* how is this layout? it's an effect. make an effects class.
*/
private HighlightFreezeArea() {
for (const node of [
this.layout.corner_selection,
this.layout.row_header_selection,
this.layout.column_header_selection]) {
node.classList.add('highlight-area');
setTimeout(() => {
node.classList.remove('highlight-area');
}, 400);
}
}
/**
* layout has changed, and needs update. we clear the rectangle cache
* immediately, to prevent any garbage, but we don't actually do the layout
* until the next paint.
*
* FIXME: that makes no sense -- because rectangles will be calculated
* incorrectly until the layout has been updated.
*/
private QueueLayoutUpdate(scroll?: {x: number, y: number}) {
this.tile_update_pending = true;
// don't step on existing pending scroll. we will explicitly
// clear after we use it.
if (scroll) {
this.scroll_offset_pending = scroll;
}
}
private HandleAddressLabelEvent(text?: string) {
if (text) {
// can be one of:
//
// - it's an address (possibly including sheet): jump to that address
// - it's a range, same thing
// - it's a name, which exists: jump to that name
// - it's a name, which doesn't exist: create name for current selection
// we definitely do this a lot, need to consolidate
const resolve_sheet_name = (name = ''): number => {
const lc = name.toLowerCase();
for (const sheet of this.model.sheets.list) {
if (sheet.name.toLowerCase() === lc) { return sheet.id; }
}
return this.active_sheet.id; // default to active sheet on short-hand names like "A2"
}
const get_sheet = (id?: number) => {
for (const sheet of this.model.sheets.list) {
if (sheet.id === id) { return sheet; }
}
return this.active_sheet;
};
let target_area: Area|undefined;
const parse_result = this.parser.Parse(text);
if (parse_result.expression) {
switch (parse_result.expression.type) {
case 'address':
parse_result.expression.sheet_id = resolve_sheet_name(parse_result.expression.sheet);
target_area = new Area(parse_result.expression);
break;
case 'range':
parse_result.expression.start.sheet_id = resolve_sheet_name(parse_result.expression.start.sheet);
target_area = new Area(parse_result.expression.start, parse_result.expression.end);
break;
case 'identifier':
{
const named = this.model.GetName(parse_result.expression.name, this.active_sheet.id); // assuming it's in the active sheet? what if it's qualified?
if (named?.type === 'range') {
target_area = named.area;
}
if (!target_area) {
if (!this.primary_selection.empty) {
this.SetName(parse_result.expression.name.toUpperCase(), this.primary_selection.area);
}
}
}
break;
default:
// console.info('UNK', parse_result.expression.type);
break;
}
}
if (target_area) {
// check that range exists
const sheet = get_sheet(target_area.start.sheet_id);
if (sheet.columns >= target_area.end.column && sheet.rows >= target_area.end.row) {
this.ExecCommand({
key: CommandKey.Select,
area: target_area,
});
return;
}
else {
console.warn('address out of range');
}
}
}
this.UpdateAddressLabel();
this.Focus();
}
private InitFormulaBar(grid_container: HTMLElement, autocomplete: Autocomplete) {
this.formula_bar = new FormulaBar(
grid_container,
// this.parser,
// this.theme,
this.model,
this.view,
this.options, autocomplete);
this.formula_bar.autocomplete_matcher = this.autocomplete_matcher;
this.formula_bar.Subscribe((event) => {
switch (event.type) {
case 'address-label-event':
this.HandleAddressLabelEvent(event.text)
break;
case 'toggle-reference':
if (this.SelectingArgument() && !this.active_selection?.empty) {
this.UpdateSelectedArgument(this.active_selection, true);
}
break;
case 'stop-editing':
if (this.pending_reset_selection) {
this.ShowGridSelection();
}
this.pending_reset_selection = false;
this.editing_state = EditingState.NotEditing;
break;
case 'start-editing':
// NOTE: because this event (and stop-editing) are based on
// focus, they don't behave correctly when switching sheets,
// which (at least temporarily) steals focus. so we actually
// get extra start and stop events. it works, sort of, because
// we don't clear the primary selection when changing sheets
// when editing, so the sheet ID is correct. but that's an
// accident. see note below on 'commit'.
// FIXME: could this be resolved by using the "selecting argument"
// flag? that's sort of what that was originally created for,
// although it predates multi-sheet. TODO
this.editing_state = EditingState.FormulaBar;
this.editing_cell = { ...this.primary_selection.target };
this.editing_selection = { ...this.primary_selection };
break;
case 'toll':
// this is basically "stop editing", with some special
// semantics to capture the editing state. it's intended
// for external clients, specifically for an "insert function"
// dialog.
this.editing_state = EditingState.NotEditing;
this.ClearAdditionalSelections();
this.ClearSelection(this.active_selection);
this.DelayedRender();
break;
case 'discard':
this.editing_state = EditingState.NotEditing;
if (this.editing_annotation) {
this.ClearAdditionalSelections();
this.ClearSelection(this.active_selection);
const node = this.editing_annotation.view[this.view_index]?.node;
if (node) {
node.focus();
}
this.editing_annotation = undefined;
this.UpdateFormulaBarFormula();
this.DelayedRender();
return;
}
if (this.container) this.Focus();
this.ClearAdditionalSelections();
this.ClearSelection(this.active_selection);
this.UpdateFormulaBarFormula();
this.DelayedRender();
break;
case 'commit':
// FIXME: unify this (to the extent possible) w/ the other editor
// NOTE: this only works because (1) on activate sheet, if you are
// editing, we don't update the primary selection; and (2) for
// annotations, we set a fake primary selection target with the
// correct sheet ID.
// all that needs to be rewritten to be more sane.
if (this.active_sheet.id !== this.editing_cell.sheet_id) {
if (this.editing_cell.sheet_id) {
this.ActivateSheetID(this.editing_cell.sheet_id);
}
}
this.editing_state = EditingState.NotEditing;
// we added annotations to the formula bar, so there's some
// logic here that's not in the ICE commit handler
if (this.editing_annotation) {
const annotation = this.editing_annotation;
this.ClearAdditionalSelections();
this.ClearSelection(this.active_selection);
annotation.data.formula = event.value ? this.FixFormula(event.value) : '';
if (!this.pending_reset_selection) {
const node = this.editing_annotation.view[this.view_index]?.node;
if (node) {
node.focus();
}
}
this.grid_events.Publish({ type: 'annotation', event: 'update', annotation });
this.editing_annotation = undefined;
this.DelayedRender();
return;
}
if (this.container) this.Focus();
if (event.event) {
this.SetInferredType(this.primary_selection, event.value, event.array);
}
else {
if (this.editing_selection) {
this.SetInferredType(this.editing_selection, event.value, event.array);
}
}
this.ClearAdditionalSelections();
this.ClearSelection(this.active_selection);
if (this.options.repaint_on_cell_change) {
this.DelayedRender(false, this.primary_selection.area);
}
// unifying
if (event.event) {
// this was broken out because we used it in multiple places
// (here are in the ICE) but we don't do that anymore, it could
// come back inline.
// or we could probably even just drop it, since for the formula
// bar we only have to handle some basic keys
// and now that I think about it, why are we cloning and
// dispatching instead of just calling the method?
// this.RedispatchEvent(event.event);
this.OverlayKeyDown(event.event);
}
break;
case 'update':
if (event.dependencies) {
this.HighlightDependencies(event.dependencies);
}
break;
}
});
}
private InitOverlayEditor(autocomplete: Autocomplete) {
if (!this.container) {
return;
}
this.overlay_editor = new OverlayEditor(
this.container,
this.theme,
this.model,
this.view,
autocomplete);
this.overlay_editor.UpdateScale(this.layout.scale);
this.overlay_editor.autocomplete_matcher = this.autocomplete_matcher;
this.overlay_editor.Subscribe(event => {
switch (event.type) {
// see notes in formula editor event handler re: start editing,
// stop editing and commit.
// UPDATE: discard and commit are now moved inline in the key
// handler, since the nodes are unified
case 'stop-editing':
this.editing_state = EditingState.NotEditing;
break;
case 'start-editing':
this.editing_state = EditingState.CellEditor;
this.editing_cell = { ...this.primary_selection.target };
break;
case 'update':
if (event.dependencies) {
this.HighlightDependencies(event.dependencies);
}
break;
case 'end-selection':
case 'reset-selection':
this.ClearSelection(this.active_selection);
if (this.overlay_editor?.target_address?.sheet_id && this.active_sheet.id !== this.overlay_editor.target_address.sheet_id) {
this.ActivateSheetID(this.overlay_editor.target_address.sheet_id);
}
this.DelayedRender();
break;
}
});
}
private DelayedRender(force = false, area?: IArea|IArea[], full_tile = false) {
// if area is passed, set dirty before calling repaint
// this seems to be called _without_ an area when selection changes;
// that causes a selection repaint plus any necessary dirty tile updates.
// so that actually works ok. we still may need to force everyone
// to update, in somce cases, but that should be rare.
if (!this.tile_update_pending && area) {
this.layout.DirtyArea(area);
}
else if (!this.tile_update_pending && force) {
this.layout.DirtyAll();
}
if (!this.render_token) {
this.render_token = 1;
Promise.resolve().then(() => {
this.render_token = 0;
this.Repaint(force, full_tile);
});
}
}
protected Repaint(force = false, full_tile = false, force_headers = false) {
if (this.headless || !this.tile_renderer) { return; }
if (this.tile_update_pending) {
this.tile_update_pending = false;
this.layout.UpdateTiles();
if (this.scroll_offset_pending) {
this.layout.scroll_offset = this.scroll_offset_pending;
this.scroll_offset_pending = undefined; // clear
}
this.render_tiles = this.layout.VisibleTiles();
this.layout.UpdateAnnotation(this.active_sheet.annotations, this.theme);
// FIXME: why is this here, as opposed to coming from the command
// exec method? are we doubling up? (...)
// I think we are correctly handing all cases. not 100% sure, though.
// add/remove rows/columns is handled correctly. loading and resetting
// documents should not need this event, because there is implicit
// rebuild required.
// we should trace back every call that sets tile_update_pending (which
// is in queue layout update) and make sure the event is either sent
// or unecessary.
/*
this.grid_events.Publish({
type: 'structure',
rebuild_required: true,
});
*/
}
this.layout_token = 0;
// move this to the end. on first load, sheet will be smaller than
// viewport, so full-row/full-column selections will be truncated.
// by moving the selection render to the end, we get the result of
// any implicit sheet expansion.
// this.RenderSelections();
this.tile_renderer.OverflowDirty(full_tile);
if (force) {
// set dirty in case we're not painting them
for (const column of this.layout.grid_tiles) {
for (const tile of column) {
tile.dirty = true;
}
}
}
const start = this.render_tiles.start;
const end = this.render_tiles.end;
const row_list: number[] = [];
for (let row = start.row; row <= end.row; row++) row_list.push(row);
const column_list: number[] = [];
for (let column = start.column; column <= end.column; column++) column_list.push(column);
// FIXME: multiple tiles
if (start.row > 0 && this.active_sheet.freeze.rows) row_list.push(0);
if (start.column > 0 && this.active_sheet.freeze.columns) column_list.push(0);
for (const column of column_list) {
for (const row of row_list) {
const tile = this.layout.grid_tiles[column][row];
if (force || tile.dirty || tile.needs_full_repaint) {
this.tile_renderer.Render(tile);
tile.dirty = tile.needs_full_repaint = false;
}
}
}
this.tile_renderer.RenderHeaders(this.render_tiles, force_headers);
this.tile_renderer.RenderCorner();
this.RenderSelections();
}
private MouseMove_RowHeader(event: MouseEvent) {
const header = this.layout.CoordinateToRowHeader(event.offsetY);
// this is used for the grid, but we can cheat and use it for the header
const rect = this.layout.OffsetCellAddressToRectangle({ row: header.row, column: 0 });
if (this.hover_data.address && (this.hover_data.address.row !== -1 || this.hover_data.address.column !== -1)) {
this.HoverCell({ row: -1, column: -1 });
}
let resize_row = -1;
if (event.offsetY - rect.top <= this.RESIZE_PIXEL_BUFFER && header.row > 0) {
resize_row = header.row - 1;
}
else if (rect.bottom - event.offsetY <= this.RESIZE_PIXEL_BUFFER) {
resize_row = header.row;
}
if (resize_row >= 0) {
this.layout.ResizeCursor('row');
}
else if (this.cell_resize.row) {
this.cell_resize.row = -1;
this.layout.ResizeCursor();
}
this.cell_resize.row = resize_row;
}
private MouseMove_ColumnHeader(event: MouseEvent) {
const header = this.layout.CoordinateToColumnHeader(event.offsetX);
// this is used for the grid, but we can cheat and use it for the header
const rect = this.layout.OffsetCellAddressToRectangle({ row: 0, column: header.column });
if (this.hover_data.address && (this.hover_data.address.row !== -1 || this.hover_data.address.column !== -1)) {
this.HoverCell({ row: -1, column: -1 });
}
let resize_column = -1;
if (event.offsetX - rect.left <= this.RESIZE_PIXEL_BUFFER && header.column > 0) {
resize_column = header.column - 1;
}
else if (rect.right - event.offsetX <= this.RESIZE_PIXEL_BUFFER) {
resize_column = header.column;
}
if (resize_column >= 0) {
this.layout.ResizeCursor('column');
}
else if (this.cell_resize.column) {
this.cell_resize.column = -1;
this.layout.ResizeCursor();
}
this.cell_resize.column = resize_column;
}
/**
* handler for mousedown events on the row (left) header.
* handles selection and resizing.
*
* FIXME: argument selection
*/
private MouseDown_RowHeader(event: MouseEvent) {
if (event.button !== 0) {
return;
}
event.stopPropagation();
event.preventDefault();
let base_address = this.layout.CoordinateToRowHeader(event.offsetY);
const bounding_rect = this.layout.row_header_cover.getBoundingClientRect();
const offset = {
x: bounding_rect.left,
y: bounding_rect.top,
};
if (this.cell_resize.row >= 0) {
const row = this.cell_resize.row;
const base = offset.y + event.offsetY;
this.layout.HideDropdownCaret();
if (this.IsDoubleClick({ row, column: -1 })) {
let rows = [row];
if (!this.primary_selection.empty &&
this.primary_selection.area.rows > 1 &&
this.primary_selection.area.start.column === Infinity &&
this.primary_selection.area.ContainsRow(row)) {
// update all selected columns. these could be in different tiles.
// in case the whole sheet is selected
const area = this.active_sheet.RealArea(this.primary_selection.area);
rows = [];
for (let r = area.start.row; r <= area.end.row; r++) {
rows.push(r);
}
}
// call with height = undefined, means auto-size
this.ExecCommand({
key: CommandKey.ResizeRows,
row: rows,
});
return;
}
// height of ROW
const original_height = this.layout.RowHeight(row);
let height = original_height;
const rect = this.layout.OffsetCellAddressToRectangle({ row, column: 0 });
const tooltip_base = offset.y + rect.bottom;
this.layout.ShowTooltip({
left: true,
text: `${height}px`,
x: Math.round(bounding_rect.right + 10),
y: tooltip_base,
});
const move_annotation_list: Array<{ annotation: Annotation; y: number, nodes: HTMLElement[] }> = [];
const size_annotation_list: Array<{ annotation: Annotation; height: number, nodes: HTMLElement[] }> = [];
for (const annotation of this.active_sheet.annotations) {
const y = rect.bottom - 1; // -1? border or something?
if (!annotation.scaled_rect || annotation.scaled_rect.bottom < y) { continue; }
const nodes: HTMLElement[] = [...this.layout.GetFrozenAnnotations(annotation)];
const node = annotation.view[this.view_index]?.node;
if (node) { nodes.push(node); }
if (y <= annotation.scaled_rect.top && annotation.data.move_with_cells) {
move_annotation_list.push({ annotation, y: annotation.scaled_rect.top, nodes });
}
else if (y > annotation.scaled_rect.top && annotation.data.resize_with_cells) {
size_annotation_list.push({ annotation, height: annotation.scaled_rect.height, nodes });
}
}
MouseDrag(this.layout.mask, 'row-resize', (move_event: MouseEvent) => {
const delta = Math.max(-original_height, Math.round(move_event.offsetY - base));
if (delta + original_height !== height) {
height = delta + original_height;
// tile_sizes[tile_index] = tile_height + delta;
this.layout.SetRowHeight(row, height);
this.layout.UpdateTooltip({
text: `${height}px`,
y: tooltip_base + delta,
});
for (const { annotation, y } of move_annotation_list) {
if (annotation.scaled_rect) {
annotation.scaled_rect.top = y + delta;
}
}
for (const { annotation, height } of size_annotation_list) {
if (annotation.scaled_rect) {
annotation.scaled_rect.height = height + delta;
}
}
requestAnimationFrame(() => {
// don't call the standard layout functions here. just move
// the rects with the given deltas. we will sort it out later.
for (const {annotation, nodes} of size_annotation_list) {
if (annotation.scaled_rect) {
for (const node of nodes) {
annotation.scaled_rect.ApplyStyle(node);
}
}
}
for (const {annotation, nodes} of move_annotation_list) {
if (annotation.scaled_rect) {
for (const node of nodes) {
annotation.scaled_rect.ApplyStyle(node);
}
}
}
/*
for (const {annotation} of size_annotation_list) {
if (annotation.scaled_rect && annotation.node) {
annotation.scaled_rect.ApplyStyle(annotation.node);
}
}
for (const {annotation} of move_annotation_list) {
if (annotation.scaled_rect && annotation.node) {
annotation.scaled_rect.ApplyStyle(annotation.node);
}
}
*/
// FIXME: use command
this.layout.UpdateTileHeights(true, row);
this.Repaint(false, true); // repaint full tiles
this.layout.UpdateAnnotation(this.active_sheet.annotations, this.theme);
});
}
}, () => {
this.layout.HideTooltip();
requestAnimationFrame(() => {
// this bit updates rows if more than one are selected, because
// the resize routine only "live" resizes the first one. we can
// pass everything into the command.
let rows = [row];
if (!this.primary_selection.empty &&
this.primary_selection.area.rows > 1 &&
this.primary_selection.area.start.column === Infinity &&
this.primary_selection.area.ContainsRow(row)) {
// update all selected rows. these could be in different tiles.
// in case the whole sheet is selected
const area = this.active_sheet.RealArea(this.primary_selection.area);
rows = [];
for (let r = area.start.row; r <= area.end.row; r++) {
// this.model.sheet.RowHeight(r, height, true);
rows.push(r);
}
// row = area.start.row; // ??
}
// TEMPORARILY undo what we just did... this is so the standard
// resize handling can trigger an event, otherwise it would disappear
// maybe it would be preferable to do that via a flag in the event?
this.layout.SetRowHeight(row, original_height);
this.ExecCommand({
key: CommandKey.ResizeRows,
row: rows,
height: height / this.scale,
});
for (const { annotation } of move_annotation_list) {
if (annotation.scaled_rect) {
annotation.data.layout = this.layout.RectToAnnotationLayout(annotation.scaled_rect);
}
}
for (const { annotation } of size_annotation_list) {
if (annotation.scaled_rect) {
annotation.data.layout = this.layout.RectToAnnotationLayout(annotation.scaled_rect);
}
const view = annotation.view[this.view_index];
if (view && view.resize_callback) {
view.resize_callback.call(undefined);
}
}
});
});
}
else {
const selection = this.SelectingArgument() ?
this.active_selection : this.primary_selection;
// why would you _not_ want to focus? (...)
/*
if (!this.SelectingArgument() && this.selected_annotation) {
this.Focus();
}
*/
if (!this.SelectingArgument()) {
this.Focus(undefined, true);
}
if (event.shiftKey && !selection.empty) {
const tmp = selection.target;
this.Select(selection, new Area(selection.target, base_address, true), undefined, true);
base_address = tmp;
}
else {
this.Select(selection, new Area(base_address), { column: 0, row: base_address.row });
}
this.RenderSelections();
MouseDrag(this.layout.mask, [], (move_event: MouseEvent) => {
const address = this.layout.CoordinateToRowHeader(move_event.offsetY - offset.y);
const area = new Area(address, base_address, true);
if (selection.empty || !area.Equals(selection.area)) {
this.Select(selection, area, undefined, true);
this.RenderSelections();
}
}, () => {
// console.info('end');
});
}
}
/**
* handler for mousedown events on the column (top) header.
* handles selection and resizing.
*
* FIXME: argument selection
*/
private MouseDown_ColumnHeader(event: MouseEvent) {
if (event.button !== 0) {
return;
}
event.stopPropagation();
event.preventDefault();
let base_address = this.layout.CoordinateToColumnHeader(event.offsetX);
const bounding_rect = this.layout.column_header_cover.getBoundingClientRect();
const offset = {
x: bounding_rect.left,
y: bounding_rect.top,
};
if (this.cell_resize.column >= 0) {
const column = this.cell_resize.column;
const base = offset.x + event.offsetX;
this.layout.HideDropdownCaret();
// doubleclick
if (this.IsDoubleClick({ row: -1, column })) {
let columns = [column];
if (!this.primary_selection.empty &&
this.primary_selection.area.columns > 1 &&
this.primary_selection.area.start.row === Infinity &&
this.primary_selection.area.ContainsColumn(column)) {
// update all selected columns. these could be in different tiles.
// in case the whole sheet is selected
const area = this.active_sheet.RealArea(this.primary_selection.area);
columns = [];
for (let c = area.start.column; c <= area.end.column; c++) {
columns.push(c);
}
}
// call with width = undefined, means auto-size
this.ExecCommand({
key: CommandKey.ResizeColumns,
column: columns,
});
return;
}
//
// width of COLUMN
const original_width = this.layout.ColumnWidth(column);
let width = original_width;
const rect = this.layout.OffsetCellAddressToRectangle({ row: 0, column });
const tooltip_base = offset.x + rect.right;
this.layout.ShowTooltip({
up: true,
text: `${width}px`,
x: tooltip_base,
y: Math.round(bounding_rect.bottom + 10),
});
// list of annotations that may be affected by this operation.
// this operation will either affect position or size, but not both.
const move_annotation_list: Array<{ annotation: Annotation; x: number, nodes: HTMLElement[] }> = [];
const size_annotation_list: Array<{ annotation: Annotation; width: number, nodes: HTMLElement[] }> = [];
for (const annotation of this.active_sheet.annotations) {
const x = rect.right - 1; // -1? border or something?
if (!annotation.scaled_rect || annotation.scaled_rect.right < x) { continue; }
const nodes: HTMLElement[] = [...this.layout.GetFrozenAnnotations(annotation)];
const node = annotation.view[this.view_index]?.node;
if (node) { nodes.push(node); }
if (x <= annotation.scaled_rect.left && annotation.data.move_with_cells) {
move_annotation_list.push({ annotation, x: annotation.scaled_rect.left, nodes });
}
else if (x > annotation.scaled_rect.left && annotation.data.resize_with_cells) {
size_annotation_list.push({ annotation, width: annotation.scaled_rect.width, nodes });
}
}
MouseDrag(this.layout.mask, 'column-resize', (move_event: MouseEvent) => {
const delta = Math.max(-original_width, Math.round(move_event.offsetX - base));
if (delta + original_width !== width) {
width = delta + original_width;
this.layout.UpdateTooltip({
text: `${width}px`,
x: tooltip_base + delta,
});
// I don't get how this works. it's not scaling?
this.layout.SetColumnWidth(column, width);
for (const { annotation, x } of move_annotation_list) {
if (annotation.scaled_rect) {
annotation.scaled_rect.left = x + delta;
}
}
for (const { annotation, width } of size_annotation_list) {
if (annotation.scaled_rect) {
annotation.scaled_rect.width = width + delta;
}
}
requestAnimationFrame(() => {
// don't call the standard layout functions here. just move
// the rects with the given deltas. we will sort it out later.
/*
for (const {annotation} of size_annotation_list) {
if (annotation.scaled_rect && annotation.node) {
annotation.scaled_rect.ApplyStyle(annotation.node);
}
}
for (const {annotation} of move_annotation_list) {
if (annotation.scaled_rect && annotation.node) {
annotation.scaled_rect.ApplyStyle(annotation.node);
}
}
*/
for (const {annotation, nodes} of size_annotation_list) {
if (annotation.scaled_rect) {
for (const node of nodes) {
annotation.scaled_rect.ApplyStyle(node);
}
}
}
for (const {annotation, nodes} of move_annotation_list) {
if (annotation.scaled_rect) {
for (const node of nodes) {
annotation.scaled_rect.ApplyStyle(node);
}
}
}
this.layout.UpdateTileWidths(true, column);
this.Repaint(false, true); // repaint full tiles
// this.layout.UpdateAnnotation(this.active_sheet.annotations);
});
}
}, () => {
this.layout.HideTooltip();
requestAnimationFrame(() => {
// @see MouseDown_RowHeader
const columns = [column];
if (!this.primary_selection.empty &&
this.primary_selection.area.columns > 1 &&
this.primary_selection.area.start.row === Infinity &&
this.primary_selection.area.ContainsColumn(column)) {
// update all selected columns. these could be in different tiles.
// in case the whole sheet is selected
const area = this.active_sheet.RealArea(this.primary_selection.area);
for (let c = area.start.column; c <= area.end.column; c++) {
// this.model.sheet.ColumnWidth(c, width, true);
columns.push(c);
}
// for next call
// column = area.start.column; // ??
}
this.ExecCommand({
key: CommandKey.ResizeColumns,
column: columns,
width: width, // / this.scale,
});
for (const { annotation } of move_annotation_list) {
if (annotation.scaled_rect) {
annotation.data.layout = this.layout.RectToAnnotationLayout(annotation.scaled_rect);
}
}
for (const { annotation } of size_annotation_list) {
if (annotation.scaled_rect) {
annotation.data.layout = this.layout.RectToAnnotationLayout(annotation.scaled_rect);
}
const view = annotation.view[this.view_index];
if (view && view.resize_callback) {
view.resize_callback.call(undefined);
}
}
});
});
}
else {
const selection = this.SelectingArgument() ?
this.active_selection : this.primary_selection;
// @see Mousedown_RowHeader
if (!this.SelectingArgument()) {
this.Focus(undefined, true);
}
/*
if (!this.SelectingArgument() && this.selected_annotation) {
this.Focus();
}
*/
if (event.shiftKey && !selection.empty) {
const tmp = selection.target;
this.Select(selection, new Area(selection.target, base_address, true), undefined, true);
base_address = tmp;
}
else {
this.Select(selection, new Area(base_address), { row: 0, column: base_address.column });
}
this.RenderSelections();
MouseDrag(this.layout.mask, [], (move_event: MouseEvent) => {
const address = this.layout.CoordinateToColumnHeader(move_event.offsetX - offset.x);
const area = new Area(address, base_address, true);
if (selection.empty || !area.Equals(selection.area)) {
this.Select(selection, area, undefined, true);
this.RenderSelections();
}
});
}
}
private HoverCell(address: ICellAddress, event?: MouseEvent) {
let cell = this.active_sheet.cells.GetCell(address, false);
// check table. sortable can now be explicitly set (to false)
if (cell?.table && cell.table.area.start.row === address.row && cell.table.sortable !== false) {
this.hover_data.table_header = true;
this.layout.ShowTableSortButton(cell.table, address.column - cell.table.area.start.column, address);
}
else {
if (this.hover_data.table_header) {
this.layout.HideTableSortButton();
}
this.hover_data.table_header = false;
}
if (cell?.merge_area) {
const area = cell.merge_area;
address = area.start;
cell = this.active_sheet.cells.GetCell(address, false);
address = { row: area.start.row, column: area.end.column };
}
// does this cell have a note?
// just FYI we have separate note/tooltip because if not you could
// "mask" the one by using the other (whichever one was dominant).
if (cell?.note) {
// optional MD formatting
// UPDATE: we should allow MD in notes irrespective of the cell
// markdown setting. or maybe split the two settings? it makes
// a lot of sense in comments, even if you don't want it in cells.
const parsed = MDParser.instance.Parse(cell.note);
const md = this.options.comment_markdown ? MDParser.instance.HTML(parsed, { br: false }) : undefined;
this.layout.ShowNote(cell.note, address, event, md);
this.hover_data.note = true;
}
else if (this.hover_data.note) {
this.layout.HideNote();
this.hover_data.note = false;
}
// FIXME: hide hover_title on sheet change
// it shouldn't be necessary to check calculated here, but
// for some reason there was a lingering title if I deleted the
// cell function via an editor (cleaned correctly if I deleted the cell
// using the delete key outside of an editor).
if (cell?.hyperlink) {
this.layout.ShowTitle('Link: ' + cell.hyperlink, address); //, event);
this.hover_data.link = true;
this.hover_data.cell = cell;
}
else if (this.hover_data.link) {
this.layout.HideTitle();
this.hover_data.cell = undefined;
}
// set
this.hover_data.address = { ...address };
}
protected HideHoverInfo() {
this.layout.HideTitle();
this.layout.HideTooltip();
this.hover_data.note = this.hover_data.link = false;
this.hover_data.cell = undefined;
// clean up cursor, if modified
if (this.hover_data.pointer) {
this.layout.grid_cover.classList.remove('link-pointer');
}
this.hover_data.pointer = false;
}
/**
* grid move handler for hit-testing various areas
*/
private MouseMove_Grid(event: MouseEvent) {
event.stopPropagation();
event.preventDefault();
if (!this.selection_renderer) {
return;
}
// needed for legacy
if (this.cell_resize.row >= 0 || this.cell_resize.column >= 0) {
this.layout.ResizeCursor();
}
const offset_point = {
x: event.offsetX,
y: event.offsetY,
};
/*
if (this.model.annotations.length) {
this.PointToAnnotation(offset_point);
}
*/
// don't show hints if we are editing
let address: ICellAddress|undefined;
if (!this.overlay_editor?.editing) {
address = this.layout.PointToAddress_Grid(offset_point);
if (!this.hover_data.address
|| this.hover_data.address.row !== address.row
|| this.hover_data.address.column !== address.column) {
this.HoverCell(address, event);
}
}
if (this.hover_data.link && address) {
this.hover_data.point = offset_point;
if (!this.hover_data.handler) {
this.hover_data.handler = requestAnimationFrame(() => {
const link_active =
(this.hover_data.address
&& this.hover_data.point
&& this.hover_data.cell
&& this.PointInTextPart(this.hover_data.address, this.hover_data.point, this.hover_data.cell));
if (link_active !== !!this.hover_data.pointer) {
this.hover_data.pointer = link_active;
if (link_active) {
this.layout.grid_cover.classList.add('link-pointer');
}
else {
this.layout.grid_cover.classList.remove('link-pointer');
}
}
this.hover_data.handler = undefined;
});
}
}
if (this.primary_selection.empty || !this.selection_renderer.nub_rectangle) {
if (this.nub_select_flag) {
this.layout.grid_cover.classList.remove('nub-select');
this.nub_select_flag = false;
}
return;
}
const nub = this.selection_renderer.nub_rectangle.Contains(event.offsetX, event.offsetY);
if (nub === this.nub_select_flag) return;
if (nub) {
this.layout.grid_cover.classList.add('nub-select');
}
else {
this.layout.grid_cover.classList.remove('nub-select');
}
this.nub_select_flag = nub;
}
/**
* special case: we don't want the previous click to be treated
* as part of a double-click.
*/
private ClearDoubleClick() {
this.double_click_data.address = undefined;
}
/**
* unifying double-click. pass the test address. returns true if this looks
* like a double-click on that address. otherwise sets flags to capture the
* next one.
*
* FIXME: parameterize timeout?
*/
private IsDoubleClick(address: ICellAddress, timeout = 300) {
if (this.double_click_data.address
&& this.double_click_data.address.row === address.row
&& this.double_click_data.address.column === address.column) {
clearTimeout(this.double_click_data.timeout);
this.double_click_data.address = undefined;
this.double_click_data.timeout = undefined;
return true;
}
else {
if (this.double_click_data.timeout) clearTimeout(this.double_click_data.timeout);
this.double_click_data.address = { ...address };
this.double_click_data.timeout = window.setTimeout(() => {
this.double_click_data.address = undefined;
this.double_click_data.timeout = undefined;
}, timeout);
}
}
private PointInTextPart(address: ICellAddress, offset_point: {x: number, y: number}, cell: Cell) {
let rectangle = this.layout.CellAddressToRectangle(address);
if (cell.merge_area) {
rectangle = rectangle.Combine(
this.layout.CellAddressToRectangle(cell.merge_area.end));
}
const x = offset_point.x - rectangle.left;
const y = offset_point.y - rectangle.top;
const parts = cell.renderer_data?.text_data?.strings || [];
for (const line of parts) {
for (const part of line) {
// validate?
if (typeof part.left === 'number'
&& typeof part.top === 'number'
&& typeof part.width === 'number'
&& typeof part.height === 'number' ) {
if (x >= part.left
&& y >= part.top
&& x <= (part.left + part.width)
&& y <= (part.top + part.height)) {
return true;
}
}
}
}
return false;
}
/**
* handles mouse down events on the grid area:
* selection (click-drag) and editing (double-click)
*/
private MouseDown_Grid(event: MouseEvent) {
if (event.button !== 0) {
return;
}
/* removed, overlay editor does not use
if (this.overlay_editor?.HandleMouseEvent(event)) {
return;
}
*/
event.stopPropagation();
event.preventDefault();
const selecting_argument = this.SelectingArgument();
/*
let blocking_tooltip = false;
if (selecting_argument) {
if (this.autocomplete?.tooltip_visible) {
this.autocomplete.SetBlock();
this.autocomplete.Hide();
blocking_tooltip = true;
}
}
*/
if (!selecting_argument && this.additional_selections.length) {
this.ClearAdditionalSelections();
}
if (!selecting_argument || (!this.formula_bar?.selecting && !this.external_editor?.selecting)) {
// not sure why this breaks the formula bar handler
this.Focus(undefined, true);
}
// unless we're selecting an argument, close the ICE
if (this.overlay_editor?.editing && !this.overlay_editor?.selecting) {
// commit
if (this.overlay_editor?.selection) {
const value = this.overlay_editor?.edit_node.textContent || undefined;
this.SetInferredType(this.overlay_editor.selection, value, false);
}
this.DismissEditor();
}
const offset_point = {
x: event.offsetX,
y: event.offsetY,
};
/*
// FIXME: trident
if (!selecting_argument) {
const annotation = this.PointToAnnotation(offset_point);
if (annotation && annotation.node) {
this.selected_annotation = annotation;
const cloned_event = new MouseEvent(event.type, event);
annotation.node.dispatchEvent(cloned_event);
return;
}
}
*/
let base_address = this.layout.PointToAddress_Grid(offset_point);
const selection = selecting_argument ? this.active_selection : this.primary_selection;
// don't handle double-click when selecting arguments
if (!selecting_argument) {
if (this.IsDoubleClick(base_address)) {
if (UA.is_mobile) {
this.overlay_editor?.edit_node?.focus(); // FIXME: use method
}
this.OverlayEditCell({ target: base_address, area: new Area(base_address) }, false);
return;
}
}
// move events will be in mask (window) coordinates,
// so we will need to offset
let bounding_rect = this.layout.grid_cover.getBoundingClientRect();
const offset = {
x: bounding_rect.left,
y: bounding_rect.top,
};
const overlay_classes: string[] = [];
// on shift-click, consolidate selection. that only works if there's
// a target in the original selection.
// if you click the nub in the primary selection, select a rectangle
// keeping the original top-left. this can lose the target, but that's
// not ultimately a problem.
// UPDATING nub let's do drag-and-insert. start with selection rules:
// one dimension must be preserved.
let nub_area: Area | undefined;
if (event.shiftKey && !selection.empty) {
const tmp = selection.target;
this.Select(selection, new Area(base_address, selection.target, true), undefined, true);
base_address = tmp;
}
else if (this.nub_select_flag) {
base_address = selection.area.TopLeft();
overlay_classes.push('nub-select');
nub_area = this.active_sheet.RealArea(selection.area);
}
else {
let address = base_address;
let cell = this.active_sheet.CellData(address);
if (cell.merge_area) {
address = cell.merge_area.start;
cell = this.active_sheet.CellData(cell.merge_area.start);
}
if (cell.hyperlink) {
if (this.PointInTextPart(address, offset_point, cell)) {
const link = cell.hyperlink;
Promise.resolve().then(() => {
this.grid_events.Publish({
type: 'cell-event',
data: {
type: 'hyperlink',
reference: link,
}
});
});
this.ClearDoubleClick();
return;
}
}
if (cell.click_function) {
let rectangle = this.layout.CellAddressToRectangle(address);
if (cell.merge_area) {
rectangle = rectangle.Combine(
this.layout.CellAddressToRectangle(cell.merge_area.end));
}
const result = cell.click_function.call(this, {
cell,
x: offset_point.x - rectangle.left,
y: offset_point.y - rectangle.top,
width: rectangle.width,
height: rectangle.height,
scale: this.layout.scale,
});
if (result.value) {
// do we need to (un)translate this? I think we don't,
// because this is the result of a click_function
// (only checkbox, afaik) -- let's make a rule that
// click functions must use canonical (english) names.
this.ExecCommand({
key: CommandKey.SetRange,
value: result.value,
area: address,
});
}
if (result.block_selection) {
this.ClearDoubleClick();
// special case
if (!this.primary_selection.empty &&
this.primary_selection.target.row === address.row &&
this.primary_selection.target.column === address.column) {
// update formula bar formula
this.UpdateFormulaBarFormula();
}
return;
}
}
this.Select(selection, new Area(base_address), base_address);
}
this.RenderSelections();
if (selecting_argument) this.UpdateSelectedArgument(selection);
const grid_rect =
this.layout.CellAddressToRectangle({ row: 0, column: 0 }).Combine(
this.layout.CellAddressToRectangle({
row: this.active_sheet.rows - 1,
column: this.active_sheet.columns - 1,
})).Expand(-1, -1);
MouseDrag(this.layout.mask, overlay_classes, (move_event: MouseEvent) => {
// check if we are oob the grid
const point = {
x: move_event.offsetX - offset.x,
y: move_event.offsetY - offset.y,
};
const testpoint = grid_rect.Clamp(point.x, point.y);
const address = this.layout.PointToAddress_Grid(testpoint);
const scroll_node = this.layout.scroll_reference_node;
let reset_offset = false;
if (this.container && this.options.scrollbars) {
if (point.x < scroll_node.scrollLeft) {
scroll_node.scrollLeft -= 25;
reset_offset = true;
}
else if (point.x > scroll_node.scrollLeft + this.container.clientWidth) {
scroll_node.scrollLeft += 25;
reset_offset = true;
}
if (point.y < scroll_node.scrollTop) {
scroll_node.scrollTop -= 25;
reset_offset = true;
}
else if (point.y > scroll_node.scrollTop + this.container.clientHeight) {
scroll_node.scrollTop += 25;
reset_offset = true;
}
if (reset_offset) {
bounding_rect = this.layout.grid_cover.getBoundingClientRect();
offset.x = bounding_rect.left + document.body.scrollLeft;
offset.y = bounding_rect.top + document.body.scrollTop;
}
}
let area = new Area(address, base_address, true);
if (nub_area) {
area = nub_area.Clone();
area.ConsumeAddress(address);
// if you are moving from the nub you can only move one dimension,
// the other must be preserved. also the complete original selection
// is preserved.
if (area.rows !== nub_area.rows && area.columns !== nub_area.columns) {
// we're basing this on larger cell count, which is maybe wrong --
// should be based on larger pixel count
const delta = {
rows: address.row > nub_area.end.row ?
address.row - nub_area.end.row : nub_area.start.row - address.row,
columns: address.column > nub_area.end.column ?
address.column - nub_area.end.column : nub_area.start.column - address.column
};
if (delta.rows >= delta.columns) {
area = new Area(
{ row: area.start.row, column: nub_area.start.column },
{ row: area.end.row, column: nub_area.end.column });
}
else {
area = new Area(
{ row: nub_area.start.row, column: area.start.column },
{ row: nub_area.end.row, column: area.end.column });
}
}
}
if (selection.empty || !area.Equals(selection.area)) {
this.Select(selection, area, undefined, true);
this.RenderSelections();
if (selecting_argument) {
this.UpdateSelectedArgument(selection);
}
else {
this.UpdateAddressLabelArea(selection); // 3R x 2C
}
}
}, () => {
// console.info('end');
this.UpdateAddressLabel();
/*
if (blocking_tooltip) {
this.autocomplete?.ResetBlock();
}
*/
if (selecting_argument) {
if (this.overlay_editor?.editing) {
// ...
}
else if (this.external_editor_config) {
// there are two possible cases: either an update function
// or a full-on editor. we should probably test for both?
if (this.external_editor?.active) {
this.external_editor.FocusEditor();
}
if (this.external_editor_config.update) {
// not necessary?
// console.info('call update?');
}
}
else if (this.formula_bar) {
this.formula_bar.FocusEditor();
}
}
else if (nub_area) {
this.RecycleNubArea(selection.area, nub_area);
}
});
}
/**
* FIXME: -> util library
* @param arr
*/
private Transpose(arr: T[][]) {
const tmp: T[][] = [];
const cols = arr.length;
const rows = arr[0].length;
for (let r = 0; r < rows; r++) {
tmp[r] = [];
for (let c = 0; c < cols; c++) {
tmp[r][c] = arr[c][r];
}
}
return tmp;
}
/**
* when you drag from the nub, we copy the contents of the original
* selection into the new selection, but there are some special recycling
* rules.
*
* FIXME: expand tables
*/
private RecycleNubArea(target_area: Area, source_area: Area) {
// nothing to do
if (target_area.Equals(source_area)) { return; }
// get original area cell data
let cells: Cell[][] = [];
for (let row = 0; row < source_area.rows; row++) {
cells[row] = [];
for (let column = 0; column < source_area.columns; column++) {
const address = {
row: source_area.start.row + row,
column: source_area.start.column + column
};
cells[row][column] = this.active_sheet.CellData(address);
}
}
// we don't need to (un)translate this because it's getting the value
// from the cell, which will hold the canonical version.
// special case: top-left is array and source_area is the whole, exact array
if (cells[0][0].area && cells[0][0].area.Equals(source_area)) {
this.ExecCommand({
key: CommandKey.SetRange,
value: cells[0][0].value,
array: true,
area: target_area,
});
return;
}
// special case: table. expand table. we MIGHT want to shift the
// totals row, if there's a totals row. not sure atm.
if (cells[0][0].table && source_area.Equals(new Area(cells[0][0].table.area.start, cells[0][0].table.area.end))) {
const table = cells[0][0].table;
const sortable = table.sortable;
const totals = table.totals_row;
const theme = table.theme;
// remove the table, then re-insert, preserving flags
this.ExecCommand([{
key: CommandKey.RemoveTable,
table,
},
{
key: CommandKey.InsertTable,
area: target_area,
sortable,
totals,
theme,
},
]);
return;
}
const data: CellValue[][] = [];
let style: CellStyle[][] = [];
let source_columns = source_area.columns;
let target_rows = target_area.rows;
let inverted = false;
// rather than write this twice, for rows/columns, we will just write
// once and for the other direction we will transpose (twice).
let transposed = false;
// only one of columns or rows can be different
if (target_area.columns === source_area.columns) {
// check if we are going backwards
inverted = (target_area.start.row < source_area.start.row);
}
else {
source_columns = source_area.rows;
target_rows = target_area.columns;
inverted = (target_area.start.column < source_area.start.column);
cells = this.Transpose(cells);
transposed = true;
}
for (let row = 0; row < target_rows; row++) {
data[row] = [];
style[row] = [];
}
// do this on a column basis, so we only parse formula once
for (let column = 0; column < source_columns; column++) {
// check for a pattern... only if there are more than one value
let pattern_step = 0;
if (cells.length > 1) {
pattern_step = 1;
const pattern: number[] = [];
const indices: number[] = [];
for (let source_row = 0; source_row < cells.length; source_row++) {
const cell = cells[source_row][column];
if (cell.ValueIsNumber()) {
indices.push(source_row);
pattern.push(cell.value);
}
}
if (pattern.length > 1) {
const deltas = pattern.slice(1).map((value, index) => value - pattern[index]);
if (deltas.every((delta) => delta === deltas[0])) {
pattern_step = deltas[0];
}
}
if (pattern.length) {
pattern_step += (pattern[pattern.length - 1] - pattern[0]);
}
}
for (let source_row = 0; source_row < cells.length; source_row++) {
let translate: ExpressionUnit | undefined;
const cell = cells[source_row][column];
if (cell.ValueIsFormula()) {
const parsed = this.parser.Parse(cell.value);
if (parsed.expression
&& parsed.full_reference_list?.length) {
translate = parsed.expression;
}
}
let offset = 0;
let start = source_row;
let step = cells.length;
let pattern_increment = 0;
let pattern = pattern_step;
if (inverted) {
start = target_rows - cells.length + source_row;
step = -cells.length;
pattern = -pattern_step;
}
for (let row = start; row >= 0 && row < target_rows; row += step, offset += step, pattern_increment += pattern) {
const render_options: Partial = {
offset: transposed ? {
rows: 0, columns: offset,
} : {
rows: offset, columns: 0,
}
};
if (translate) {
data[row][column] = '=' + this.parser.Render(translate, render_options);
}
else {
const cell = cells[source_row][column];
if (cell.ValueIsNumber()) {
data[row][column] = cell.value + pattern_increment;
}
else {
data[row][column] = cell.value;
}
}
style[row][column] = cells[source_row][column].style || {};
}
}
}
// as above, we don't need to (un)translate this because it comes
// from existing cell data, which should be in canonical form.
const commands: Command[] = [{
key: CommandKey.SetRange,
value: transposed ? this.Transpose(data) : data,
array: false,
area: target_area,
}];
if (transposed) { style = this.Transpose(style); }
for (let row = 0; row < style.length; row++) {
for (let column = 0; column < style[row].length; column++) {
commands.push({
key: CommandKey.UpdateStyle,
area: {
row: row + target_area.start.row,
column: column + target_area.start.column },
style: style[row][column],
delta: false,
});
}
}
this.ExecCommand(commands);
}
protected CreateTypedReferences(start: ICellAddress, end?: ICellAddress) {
const references: string[] = [];
// five cases: (1) one cell; (2) range: (3) rows; (4) columns; (5) all
let single = false;
if (end) {
single = (start.row === end.row && start.column === end.column);
}
if (start.row === Infinity) {
// case 3/5
// ...
}
else if (start.column === Infinity) {
// case 4
// ...
}
else if (!end || single) {
// case 1
start.absolute_column = false;
start.absolute_row = false;
references.push(Area.CellAddressToLabel(start));
start.absolute_column = true;
start.absolute_row = true;
references.push(Area.CellAddressToLabel(start));
start.absolute_column = true;
start.absolute_row = false;
references.push(Area.CellAddressToLabel(start));
start.absolute_column = false;
start.absolute_row = true;
references.push(Area.CellAddressToLabel(start));
}
else {
// case 2
start.absolute_column = end.absolute_column = false;
start.absolute_row = end.absolute_row = false;
references.push(Area.CellAddressToLabel(start) + ':' + Area.CellAddressToLabel(end));
start.absolute_column = end.absolute_column = true;
start.absolute_row = end.absolute_row = true;
references.push(Area.CellAddressToLabel(start) + ':' + Area.CellAddressToLabel(end));
start.absolute_column = end.absolute_column = true;
start.absolute_row = end.absolute_row = false;
references.push(Area.CellAddressToLabel(start) + ':' + Area.CellAddressToLabel(end));
start.absolute_column = end.absolute_column = false;
start.absolute_row = end.absolute_row = true;
references.push(Area.CellAddressToLabel(start) + ':' + Area.CellAddressToLabel(end));
}
// ...other cases TODO...
return references;
}
private UpdateSelectedArgument(selection: GridSelection, toggle_reference_type = false) {
// console.info("USA", selection, toggle_reference_type);
// if this is a single merged block, we want to insert it as the
// root cell and not the range.
const data = this.active_sheet.CellData(selection.area.start);
const target = new Area(data.merge_area ? data.merge_area.start : selection.target);
let label = this.model.named.MatchSelection(selection.area, target);
// this seems like a clumsy way to do it, but for now...
let typed_reference_list: string[] = [];
if (!label) {
label = selection.area.spreadsheet_label;
if (data.merge_area && data.merge_area.Equals(selection.area)) {
label = Area.CellAddressToLabel(data.merge_area.start);
typed_reference_list = this.CreateTypedReferences({...data.merge_area.start});
}
else {
typed_reference_list = this.CreateTypedReferences({
...selection.area.start
}, {
...selection.area.end
});
}
if (this.external_editor_config || this.active_sheet.id !== this.editing_cell.sheet_id) {
let name = this.active_sheet.name;
if (QuotedSheetNameRegex.test(name)) {
name = `'${name}'`;
}
label = name + `!${label}`;
typed_reference_list = typed_reference_list.map(entry => name + entry);
/*
if (QuotedSheetNameRegex.test(name)) {
label = `'${name}'!${label}`;
}
else {
label = `${name}!${label}`;
}
*/
}
// console.info(typed_reference_list);
}
// the external editor should just handle normal select events
// for now, we might update that in the future.
if (this.overlay_editor?.editing && this.overlay_editor.selecting) {
this.overlay_editor.InsertReference(label, toggle_reference_type, typed_reference_list);
}
else if (this.formula_bar && this.formula_bar.selecting) {
this.formula_bar.InsertReference(label, toggle_reference_type, typed_reference_list);
}
else if (this.external_editor_config) {
if (this.external_editor?.active) {
this.external_editor.FocusEditor();
this.external_editor.InsertReference(label);
}
if (this.external_editor_config.update) {
const result = this.external_editor_config.update.call(0, label);
if (result && Array.isArray(result)) {
this.HighlightDependencies(
result.filter((entry: T|undefined): entry is T => !!entry).map(reference =>
IsCellAddress(reference) ? new Area(reference) : new Area(reference.start, reference.end)));
}
}
}
/*
else if (this.select_argument) {
this.grid_events.Publish({
type: 'alternate-selection',
selection: this.active_selection,
});
}
*/
}
/**
* unified method to check if we are selecting an argument in the formula
* bar editor or the in-cell editor
*
* FIXME: why is this not an accessor?
*/
protected SelectingArgument(): boolean {
return (this.overlay_editor?.editing && this.overlay_editor?.selecting)
|| (this.formula_bar && this.formula_bar.selecting)
|| (!!this.external_editor_config);
// || (this.select_argument);
}
/**
* for external clients. the expected pattern is
* - start overlay editor
* - click on "insert function"
* - do something
* - release editor (this function)
*/
public ReleaseOverlayEditor() {
this.editing_state = EditingState.NotEditing;
this.DismissEditor();
this.DelayedRender();
}
public RestoreOverlayEditor() {
// ?
this.overlay_editor?.FocusEditor();
}
/**
* consolidated event handler for overlay, which both handles grid keys
* and acts as the ICE, depending on state. going to be a little tricky
* to keep track of code paths.
*
* old comment:
*
* event handler for keyboard events. some we handle directly (directional
* navigation), some we ignore (most control-key combinations), and if you
* type text we start the in-cell editor and pass on the event.
*
* which is largely still true, except that we can handle the ICE more
* directly.
*
*/
private OverlayKeyDown(event: KeyboardEvent) {
// handle ICE here... (if ICE is already open; starting ICE is later)
// this can only be true if overlay editor is not undef, but typescript
// doesn't seem to get that, so we will reduce to a single test with
// somewhat clumsier code
let editor_open = false;
if (this.overlay_editor && this.overlay_editor.editing) {
editor_open = true;
const result = this.overlay_editor.HandleKeyDown(event);
switch (result) {
case 'handled':
return;
case 'discard':
this.editing_state = EditingState.NotEditing;
this.DismissEditor();
this.DelayedRender();
return;
case 'commit':
// FIXME: unify this (to the extent possible) w/ the other editor
if (this.active_sheet.id !== this.editing_cell.sheet_id) {
if (this.editing_cell.sheet_id) {
this.ActivateSheetID(this.editing_cell.sheet_id);
}
}
this.editing_state = EditingState.NotEditing;
if (this.overlay_editor?.selection) {
const value = this.overlay_editor?.edit_node.textContent || undefined;
// let's support command+shift+enter on mac
const array = (event.key === 'Enter' && (event.ctrlKey || (UA.is_mac && event.metaKey)) && event.shiftKey);
this.SetInferredType(this.overlay_editor.selection, value, array, undefined, this.overlay_editor.edit_style);
}
this.DismissEditor();
if (this.options.repaint_on_cell_change) {
this.DelayedRender(false, this.overlay_editor?.selection.area || undefined);
}
////
break;
}
}
// ---
const selecting_argument = this.SelectingArgument();
if (this.formula_bar && this.formula_bar.focused && !selecting_argument) {
return;
}
if (this.selected_annotation && !selecting_argument) {
return;
}
const selection = selecting_argument ? this.active_selection : this.primary_selection;
const delta = { rows: 0, columns: 0 };
let within_selection = false;
let expand_selection = false;
// handle some specific control-key combinations
if (event.ctrlKey || (UA.is_mac && event.metaKey)) {
// handle ctrl+shift+arrow AND ctrl+arrow (we used to just handle
// ctrl+shift+arrow). we don't handle any other ctrl+shift combinations.
switch (event.key) {
case 'ArrowDown':
case 'Down':
delta.rows++;
break;
case 'ArrowUp':
case 'Up':
delta.rows--;
break;
case 'ArrowLeft':
case 'Left':
delta.columns--;
break;
case 'ArrowRight':
case 'Right':
delta.columns++;
break;
case 'Backspace':
if (event.metaKey && UA.is_mac) {
if (!selection.empty) {
this.DeleteSelection(selection);
}
}
break;
case 'Delete':
case 'Del':
// if (event.shiftKey) // ctrl+shift+delete seems to be "delete history" in all browsers...
{
event.stopPropagation();
event.preventDefault();
for (let i = 0; i < this.model.sheets.length; i++) {
if (this.model.sheets.list[i] === this.active_sheet) {
this.DeleteSheet(i);
break;
}
}
return;
}
break;
case '/':
event.stopPropagation();
event.preventDefault();
this.SelectArrayOrTable(selection);
break;
default:
if (event.shiftKey) {
return;
}
}
if (delta.columns || delta.rows) {
// NOTE: we're not using the "advance selection" method, since
// we have particular requirements when block-selecting.
event.stopPropagation();
event.preventDefault();
// FIXME: we're handling blocks OK, but we need to handle jumping
// between blocks or jumping to the next block. call that a TODO.
if (!selection.empty && (delta.columns || delta.rows)) {
if (this.BlockSelection(selection, !!event.shiftKey, delta.columns, delta.rows)) {
if (event.shiftKey && !selecting_argument) {
this.UpdateAddressLabelArea(selection);
}
return;
}
}
else {
return;
}
}
else {
const applied_style: CellStyle = {};
const selected_style: CellStyle =
this.primary_selection.empty ? {} :
this.active_sheet.CellData(this.primary_selection.target).style || {};
// seems to be the best bet for xplatform
switch (event.key.toLowerCase()) {
/*
case 'c':
console.info("calling exec command copy")
const x = document.execCommand('copy');
console.info('x?', x);
break;
case 'x':
console.info("calling exec command cut")
document.execCommand('cut');
break;
case 'v':
console.info("calling exec command paste")
document.execCommand('paste');
break;
*/
case 'b':
applied_style.bold = !selected_style.bold;
break;
case 'i':
applied_style.italic = !selected_style.italic;
break;
case 'u':
applied_style.underline = !selected_style.underline;
break;
case 'a':
this.SelectAll2();
break;
// handle Ctrl+Alt+0 = select nothing
case '0':
if (!event.altKey) return;
this.ClearSelection(this.primary_selection); // not clear the selection, clear selection
this.RenderSelections();
break;
default:
if (event.key !== 'Control') {
// console.info('unhandled control key', event.key); // dev
}
return;
}
if (Object.keys(applied_style).length) {
this.ApplyStyle(undefined, applied_style);
}
}
}
else {
if (event.key === 'F4') {
// we're always intercepting f4, although we only actual handle it
// if you are selecting a cell.
event.stopPropagation();
event.preventDefault();
if (selecting_argument && !selection.empty) {
this.UpdateSelectedArgument(selection, true);
}
return;
}
// ignore function keys
if (/^F\d+$/.test(event.key)) {
return;
}
switch (event.key) {
case 'Tab':
if (event.shiftKey) delta.columns--;
else delta.columns++;
within_selection = true;
break;
case 'Enter':
if (event.shiftKey) delta.rows--;
else delta.rows++;
within_selection = true;
break;
case 'ArrowDown':
case 'Down':
delta.rows++;
expand_selection = event.shiftKey;
break;
case 'ArrowUp':
case 'Up':
delta.rows--;
expand_selection = event.shiftKey;
break;
case 'ArrowLeft':
case 'Left':
delta.columns--;
expand_selection = event.shiftKey;
break;
case 'ArrowRight':
case 'Right':
delta.columns++;
expand_selection = event.shiftKey;
break;
case 'Delete':
case 'Del':
if (!selection.empty) {
this.DeleteSelection(selection);
}
break;
case 'PageUp':
case 'PageDown':
if (event.shiftKey) {
this.NextSheet(event.key === 'PageUp' ? -1 : 1, true);
break;
}
return; // FIXME
case 'Control':
case 'Shift':
case 'Alt':
// console.info('skip', event.key);
return;
default:
// FIXME: we're handling F9 (optionally) in the embedded
// component. this handler should ignore all function keys.
// not sure there's a good global for that, though. regex?
if (!selection.empty) {
if (event.key !== 'Escape') {
this.OverlayEditCell(selection, true, event);
}
}
return;
}
}
event.stopPropagation();
event.preventDefault();
// console.info(event.key);
if (delta.rows || delta.columns) {
this.AdvanceSelection(delta, selection, within_selection, expand_selection, !editor_open);
if (event.shiftKey && !selecting_argument) {
this.UpdateAddressLabelArea(selection);
}
}
}
/**
* select the array containing the current cell, if any. if there's no
* array, do nothing. updated to support selecting tables as well as arrays.
*/
protected SelectArrayOrTable(target_selection: GridSelection) {
if (target_selection.empty) {
return;
}
const cell = this.active_sheet.CellData(target_selection.target);
if (!cell || (!cell.area && !cell.table && !cell.spill)) {
return;
}
if (cell.area) {
this.Select(target_selection, cell.area, cell.area.start);
}
if (cell.spill) {
this.Select(target_selection, cell.spill, cell.spill.start);
}
if (cell.table) {
const area = new Area(cell.table.area.start, cell.table.area.end);
this.Select(target_selection, area, area.start);
}
if (this.SelectingArgument()) {
this.UpdateSelectedArgument(target_selection);
}
else {
this.RenderSelections();
}
}
/**
* render selections. we are wrapping this up in a method so we can
* hide the primary selection in some cases (one case).
*/
protected RenderSelections(rerender = true) {
const show_primary_selection = this.hide_selection ? false :
(!this.editing_state) || (this.editing_cell.sheet_id === this.active_sheet.id);
const data = this.primary_selection.empty ? undefined :
this.active_sheet.CellData(this.primary_selection.target);
this.layout.ShowSpillBorder(data?.spill);
this.selection_renderer?.RenderSelections(show_primary_selection, rerender);
}
/**
* select a block. returns true if we've handled it; returns false
* if we want to revert to the standard behavior.
*
* (block selection refers to selecting more than one cell at once,
* using ctrl+arrow. selection jumps across all populated cells in
* a given direction for a given row/column).
*/
protected BlockSelection(selection: GridSelection, expand_selection: boolean,
columns: number, rows: number, render = true): boolean {
// select the containing block. note that we do not handle
// the case of BOTH rows and columns.
if (selection.empty) return false;
const start: ICellAddress = { ...selection.target };
// the starting cell for the purposes of a block depends on the
// direction we're heading
// changing behavior: always start at target... but clip the selection
// to target (if expanding) so that it's only in one direction vis-a-vis
// the target
// adjusted slightly: start at the target OR the end of the selection
// IF the end of the selection is in the right direction relative to
// the delta...
if (rows > 0) start.row = Math.max(start.row, selection.area.end.row);
else if (rows < 0) start.row = Math.min(start.row, selection.area.start.row);
if (columns > 0) start.column = Math.max(start.column, selection.area.end.column);
else if (columns < 0) start.column = Math.min(start.column, selection.area.start.column);
/*
if (rows > 0) start.row = selection.area.end.row;
else if (rows < 0) start.row = selection.area.start.row;
else if (columns > 0) start.column = selection.area.end.column;
else if (columns < 0) start.column = selection.area.start.column;
*/
const cells = this.active_sheet.cells;
let cell = cells.GetCell(selection.target, false);
if (!cell || (cell.type === ValueType.undefined && !cell.area && !cell.spill)) {
return false;
}
// ok, expand in the desired direction until we hit an empty cell
let end = { ...start };
for (; ;) {
// FIXME: merge/array handling
// a little more complicated: test every cell in the cross-dimension,
// so we capture jagged blocks. for example:
//
// [target] [ full ]
// [ full ] [ full ]
// [empty ] [ full ]
// [ full ] [ full ]
// [ full ] [ full ]
//
// if you are selecting across, then down, you want to capture the
// whole array but it still stop on the empty cell because we're
// testing from target.
//
const test = { row: end.row + rows, column: end.column + columns };
if (test.column < 0 || test.row < 0 ||
test.column >= this.active_sheet.columns ||
test.row >= this.active_sheet.rows) break;
let has_value = false;
if (rows) {
for (let column = selection.area.start.column; !has_value && column <= selection.area.end.column; column++) {
cell = cells.GetCell({ row: test.row, column }, false);
has_value = has_value || (!!cell && (cell.type !== ValueType.undefined || !!cell.area || !!cell.spill));
if (!has_value && cell && cell.merge_area) {
cell = cells.GetCell(cell.merge_area.start, false);
has_value = has_value || (!!cell && (cell.type !== ValueType.undefined || !!cell.area || !!cell.spill));
}
}
}
else {
for (let row = selection.area.start.row; !has_value && row <= selection.area.end.row; row++) {
cell = cells.GetCell({ row, column: test.column }, false);
has_value = has_value || (!!cell && (cell.type !== ValueType.undefined || !!cell.area || !!cell.spill));
if (!has_value && cell && cell.merge_area) {
cell = cells.GetCell(cell.merge_area.start, false);
has_value = has_value || (!!cell && (cell.type !== ValueType.undefined || !!cell.area || !!cell.spill));
}
}
}
if (!has_value) { break; }
end = test;
}
if (expand_selection) {
// new behavior: we have moved in one direction from the
// target, now clip the other direction. keep the other
// dimension consistent.
if (rows) {
// clip dimension
start.row = selection.target.row;
// preserve other dimension
start.column = selection.area.start.column;
end.column = selection.area.end.column;
}
else {
// clip dimension
start.column = selection.target.column;
// preserve other dimension
start.row = selection.area.start.row;
end.row = selection.area.end.row;
}
const area = new Area(start, end, true);
this.Select(selection, area, selection.target, true);
}
else {
this.Select(selection, new Area(end));
}
this.ScrollIntoView(end);
if (this.SelectingArgument()) this.UpdateSelectedArgument(selection);
if (render) {
this.DelayedRender();
}
return true;
}
/**
* deletes (clears) the selected area.
*
* FIXME: should probably be an API method, or have a second method that
* clears the primary selection
*/
private DeleteSelection(selection: GridSelection) {
if (selection.empty) return;
const area = this.active_sheet.RealArea(selection.area);
this.ExecCommand({ key: CommandKey.Clear, area });
}
/**
* sets cell value, inferring type and (possibly) inferring cell style
* (numbers only), so that 10% and $1,000 retain styles. this should only
* be used for direct editing -- copy and paste can copy and paste styles.
*
* @param address cell address
*
* @param value value entered, usually this will be a string (we will try
* to parse numbers/booleans)
*
* in what case would these not be strings? (...) not sure that's a thing.
*
*
* @param exec execute commands immediately; alternatively, return the list
* of commands. the former is the default for editor commits; the latter
* is used for paste.
*/
protected SetInferredType(selection: GridSelection, value: string|undefined, array = false, exec = true, apply_style?: CellStyle) {
// console.info("SIT", {apply_style});
// validation: cannot change part of an array without changing the
// whole array. so check the array. separately, if you are entering
// an array, make sure that no affected cell is part of an existing
// array.
let target = selection.target || selection.area.start;
const cell = this.active_sheet.CellData(target);
if (cell.area) {
if ((!array && cell.area.count > 1) || !selection.area || !selection.area.Equals(cell.area)) {
this.Error(ErrorCode.array);
return;
}
}
else if (array) {
for (const cell of this.active_sheet.cells.Iterate(selection.area, false)) {
if (cell?.area) {
this.Error(ErrorCode.array);
return;
}
}
/*
let existing_array = false;
this.active_sheet.cells.Apply(selection.area, (element: Cell) => {
if (element.area) {
existing_array = true;
}
}, false);
if (existing_array) {
this.Error(ErrorCode.array);
return;
}
*/
}
const validation = this.active_sheet.GetValidation(target)[0];
// only consider the first result
if (validation && validation.error) {
let list: CellValue[]|undefined;
if (validation.type === 'list') {
list = validation.list;
}
else if (validation.type === 'range') {
list = this.GetValidationRange(validation.area);
}
if (list && list.length) {
let match = false;
if (value) {
const uc = value.toUpperCase();
for (const entry of list) {
if (entry && entry.toString().toUpperCase() === uc) {
value = entry.toString();
match = true;
break;
}
}
}
if (!match) {
// removed in favor of error //this.layout.HighlightError(selection.target);
// this.Error(`Invalid value (data validation)`);
this.Error(ErrorCode.data_validation);
return;
}
}
}
if (cell.merge_area) target = cell.merge_area.start; // this probably can't happen at this point
const commands: Command[] = [];
/*
this was here to use the currently selected font stack, but it's broken - it
clears backgrounds. temp removed in advance of proper fix.
...because it was missing the `delta` parameter. I guess that defaults to false?
we never specified, and it's a boolean in an interface, so it defaults to false
*/
if (apply_style) {
commands.push({
key: CommandKey.UpdateStyle,
style: apply_style,
delta: true,
area: array ? selection.area : selection.target,
})
}
// first check functions
const is_function = (typeof value === 'string' && value.trim()[0] === '=');
if (is_function) {
value = this.FixFormula(value || '');
// so what we are doing now is copying style from a function argument,
// if a function argument has a number format, but only if there's no
// explicit number format already set for this cell (either in the cell
// directly or in the row/column).
// it might actually be preferable to override the local cell style,
// if there is one, if the argument has a style. (...)
// we can be a little clever here with date functions (if we want),
// forcing a date format. this is a bit fragile though. it would be
// nice to coordinate this w/ calculator, but (atm) that would be
// very circular -- we need some dependency management
if (!this.active_sheet.HasCellStyle(target)) {
const formula_parse_result = this.parser.Parse(value);
if (formula_parse_result) {
if (formula_parse_result.expression?.type === 'call') { // we know this but ts doesn't
if (!cell.style || !cell.style.number_format || NumberFormatCache.Equals(cell.style.number_format, 'General')) {
const func = formula_parse_result.expression.name.toLowerCase();
let number_format: string|undefined;
// FIXME: these should be defined on the functions themselves,
// so we don't have to maintain a list. that also implies we
// need the list of functions, which we don't have atm? maybe
// through the AC instance? (...)
switch (func) {
case 'today':
number_format = 'Short Date';
break;
case 'now':
number_format = 'Timestamp';
break;
}
if (number_format) {
commands.push({
key: CommandKey.UpdateStyle,
area: array ? selection.area : target, style: { number_format }, delta: true
});
}
}
}
if (formula_parse_result.expression) {
// we were previously looking at address arguments, and ignoring
// ranges. this leads to inconsistent behavior. we'll now look at
// ranges when inferring number format.
// there was also an issue with using the correct sheet, also fixed.
// this was set up to just use the first format we found.
// updating to change priority -- if the first one is a
// percentage formula, look for another one before using
// the percentage. this is almost always what you want.
let found_number_format: string|undefined = undefined;
/** returns true if we've found a non-% number format */
const Check = (address: ICellAddress, sheet = this.active_sheet) => {
if (sheet.HasCellStyle({...address})) {
const test = sheet.CellData({ ...address });
if (test.style && test.style.number_format) {
if (!found_number_format || /%/.test(found_number_format)) {
// convert to a string format if it's symbolic. that
// is purely so we can check for a %. FIXME: I don't
// like the name of this method (Translate)
found_number_format = NumberFormatCache.Translate(test.style.number_format);
if (!/%/.test(found_number_format)) {
return true;
}
}
}
}
return false;
};
let finished = false;
let sheet: Sheet|undefined = this.active_sheet;
this.parser.Walk(formula_parse_result.expression, unit => {
if (finished) {
return false;
}
switch (unit.type) {
case 'address':
this.model.ResolveSheetID(unit);
sheet = this.model.sheets.Find(unit.sheet_id || 0);
finished = finished || Check(unit, sheet);
break;
case 'range':
this.model.ResolveSheetID(unit);
sheet = this.model.sheets.Find(unit.start.sheet_id || 0);
{
const concrete = sheet?.RealArea(new Area(unit.start, unit.end));
if (concrete) {
for (const address of concrete) {
finished = finished || Check(address, sheet);
if (finished) { break; }
}
}
}
break;
}
return !finished;
});
if (found_number_format) {
const style: CellStyle = {
number_format: NumberFormatCache.SymbolicName(found_number_format) || found_number_format,
};
commands.push({
key: CommandKey.UpdateStyle,
area: array ? selection.area : target, style, delta: true
});
}
}
}
}
}
// next try to infer the number format, with hints as to format
let expression = this.parser.Parse(value || '').expression;
if (expression?.type === 'group' && expression.elements.length === 1
&& expression.elements[0].type === 'complex') {
// invert, following spreadsheet convention?. I don't like this
// and we should not do it, but I'm not sure what the alternative
// would be -- remove the parens?
/*
expression = expression.elements[0] as UnitComplex;
expression.real = -expression.real;
expression.imaginary = -expression.imaginary;
*/
expression = expression.elements[0];
}
let parse_result: ParseResult2|undefined;
if (expression?.type === 'complex') {
parse_result = {
type: ValueType.complex,
value: {
real: expression.real,
imaginary: expression.imaginary
},
};
}
else if (expression?.type === 'literal' && typeof expression.value === 'boolean') {
parse_result = {
type: ValueType.boolean,
value: expression.value,
};
}
else {
parse_result = ValueParser.TryParse(value);
}
if (!is_function && parse_result.type === ValueType.number) {
// const text = value.toString();
let number_format = '';
const hints: Hints = parse_result.hints || {};
// be stricter about number format. don't implicitly /change/
// the number format (you can /set/, but don't /change/).
// FIXME: in this case, if we're setting a number format from
// nothing, we could be a little smarter about setting the
// decimal places.
if (!cell.style || !cell.style.number_format || NumberFormatCache.Equals(cell.style.number_format, 'General')) {
if (hints.Date) {
number_format = 'Short Date';
}
else if (hints.Exponential) {
number_format = 'Exponential';
}
else if (hints.Percent) {
number_format = 'Percent';
}
else if (hints.Currency) {
number_format = 'Currency';
}
else if (hints.Grouping || hints.Parens) {
number_format = 'Accounting';
}
}
if (number_format) {
commands.push({
key: CommandKey.UpdateStyle,
area: array ? selection.area : target,
style: { number_format },
delta: true,
});
}
// always use // value = parse_result.value;
}
/*
if (array) {
this.model.sheet.SetArrayValue(selection.area, parse_result.value);
}
else {
this.model.sheet.SetCellValue(target, parse_result.value);
}
*/
// this is user-entered data, so we _do_ need to (un)translate it
// before calling SetRange.
commands.push({
key: CommandKey.SetRange,
area: array ? selection.area : target,
array,
value: is_function ? this.model.UntranslateFunction(value || '') : parse_result.value,
});
if (exec) {
this.ExecCommand(commands);
}
else {
return commands;
}
}
/**
* tries to correct some basic formula errors, opening with a space,
* not closing all parens, (...)
*/
private FixFormula(formula: string): string {
if (formula.trim()[0] !== '=') return formula;
// const original = formula;
formula = formula.replace(/^\s+/, '');
let q = false; // double-quote
let a = false; // single-quote (apostrophe)
let paren = 0;
let escape = false;
// this breaks on escaped quotes in strings
// also there's an escape character, but never unset? (...)
const len = formula.length;
for (let i = 0; i < len; i++) {
const char = formula[i];
if (!escape) {
switch (char) {
case '"':
if (q) {
if (formula[i+1] === '"') {
i++;
}
else {
q = false;
}
}
else if (!a) {
q = true;
}
break;
case '\'':
if (a) a = false;
else if (!q) a = true;
break;
case '\\':
escape = true;
break;
case '(':
if (!q && !a) paren++;
break;
case ')':
if (!q && !a) paren--;
break;
}
}
}
if (q) formula += '"';
else if (a) formula += '\'';
while (paren > 0) {
formula += ')';
paren--;
}
formula = this.NormalizeFormula(formula);
return formula;
}
/**
* normalize addresses (UC), function names (-> canonical) and
* defined names (UC, for now)
*/
private NormalizeFormula(formula: string) {
const parse_result = this.parser.Parse(formula);
if (parse_result.error) {
console.warn(parse_result.error);
}
if (parse_result && parse_result.expression) {
this.parser.Walk(parse_result.expression, (unit) => {
switch (unit.type) {
case 'call':
unit.name = this.autocomplete_matcher.NormalizeIdentifier(unit.name) || unit.name;
break;
case 'identifier':
{
const named_range = this.model.GetName(unit.name, this.active_sheet.id); // FIXME: is this the correct sheet ref?
if (named_range?.type === 'range') {
unit.name = unit.name.toUpperCase();
}
}
break;
}
return true;
});
formula = '=' + this.parser.Render(parse_result.expression, { missing: '' });
}
return formula;
}
/**
* dismisses the in-cell editor and returns to normal behavior.
* removes any highlighted selections (arguments).
*/
private DismissEditor() {
// console.info("dismiss editor", this.overlay_editor?.active_cell, this.overlay_editor?.selection);
if (this.overlay_editor?.active_cell) {
this.overlay_editor.active_cell.editing = false;
this.overlay_editor.active_cell.render_clean = [];
this.DelayedRender(undefined, this.overlay_editor.selection.area);
}
this.editing_state = EditingState.NotEditing;
this.Focus(); // not necessary [NECESSARY FOR MOBILE]
this.overlay_editor?.CloseEditor();
this.ClearAdditionalSelections();
this.ClearSelection(this.active_selection);
}
/**
* this prepares the cell value for _editing_ -- it's not the displayed
* value, it's how we want the value to be displayed in the editor and
* formula bar.
*
* NOTE: is this the spot to do translation -> local language? (...)
*
*/
private NormalizeCellValue(cell: Cell) {
let cell_value = cell.value;
if (cell.ValueIsNumber() && cell.style && cell.style.number_format) {
const format = NumberFormatCache.Get(cell.style.number_format);
if (format.date_format) {
const date = LotusDate(cell.value);
const number_format = (date.getUTCHours() || date.getUTCMinutes() || date.getUTCSeconds()) ?
'Timestamp' : 'Short Date';
cell_value = NumberFormatCache.Get(number_format).Format(cell_value);
}
else if (/(?:%|percent)/i.test(cell.style.number_format)) {
let precision = 0;
const match = cell.value.toString().match(/\.(.*?)$/);
if (match && match[1]) {
precision = Math.max(0, match[1].length - 2); // because we are *100
}
cell_value = (cell.value * 100).toFixed(precision) + '%';
if (Localization.decimal_separator === ',') {
// cell_value = (cell.value * 100).toString().replace(/\./, ',');
cell_value = cell_value.replace(/\./, ',');
}
}
else {
if (cell_value && Localization.decimal_separator === ',') {
cell_value = cell.value.toString().replace(/\./, ',');
}
}
}
else if (cell.ValueIsBoolean()) {
// TRANSLATION: FIXME/CENTRALIZE
return cell.value ? (this.model.language_model?.boolean_true || 'TRUE') : (this.model.language_model?.boolean_false || 'FALSE');
}
else if (cell.ValueIsNumber()) { // no style: I think this is no longer possible
if (cell_value && Localization.decimal_separator === ',') {
cell_value = cell.value.toString().replace(/\./, ',');
}
}
else if (cell.ValueIsComplex()) {
if (cell.value.imaginary) {
if (cell.value.real) {
// both parts: render with spacing
cell_value = `${cell.value.real.toString()}${cell.value.imaginary < 0 ? ' - ' : ' + '}${
(cell.value.imaginary === 1 || cell.value.imaginary === -1) ? '' : Math.abs(cell.value.imaginary).toString()}i`;
}
else {
// imaginary only, leave sign
if (cell.value.imaginary === 1) {
cell_value = 'i';
}
else if (cell.value.imaginary === -1) {
cell_value = '-i';
}
else {
cell_value = cell.value.imaginary.toString() + 'i';
}
}
}
else {
// real only (or 0)
cell_value = cell.value.real.toString();
}
if (Localization.decimal_separator === ',') {
cell_value = cell_value.replace(/\./, ',');
}
}
else if (cell.ValueIsFormula()){
// this is where we _render_ translation. so names in function
// calls shoule be translated before returning.
cell_value = this.model.TranslateFunction(cell.value);
}
return cell_value;
}
/**
* this is used to handle a trailing % sign when entering a new value.
* we need to decide if the user is typing a number, in which case we
* retain the %; or something else, like a formula or a string, in which
* case we want to drop the %.
*
* FIXME: move to utils lib
*/
private IsNumeric(c: number) {
// anything else?
return (c >= 0x30 && c <= 0x39) // 0-9
|| (c === this.decimal_separator_code) // cached
|| (c === 0x2d) // -
|| (c === 0x2b) // + // this one is kind of a stretch...
;
}
/**
* start the ICE, using the new overlay editor
*
* old comment:
*
* starts the in-cell editor at the given sheet address. this method doesn't
* handle scroll-into-view, do that first if necessary.
*
* @param address the cell address. we'll check for merge head before editing.
* @param flush flush contents before editing -- default for typing, !default
* for double-click
* @param event if this is triggered by typing, we want to pass the key
* event directly to the editor (actually we'll pass a synthetic copy)
*
* @param selection
* @param flush
* @param event
*/
private OverlayEditCell(selection: GridSelection, flush = true, event?: KeyboardEvent) {
if (!this.options.in_cell_editor) {
return; // no ICE
}
let address = selection.target || selection.area.start;
let cell = this.active_sheet.CellData(address);
let rect: Rectangle;
// hide note if visible
this.HideHoverInfo();
// merged cell, make sure we get/set value from the head
// also get full rect for the editor
if (cell.merge_area) {
rect = this.layout.OffsetCellAddressToRectangle(cell.merge_area.start).Combine(
this.layout.OffsetCellAddressToRectangle(cell.merge_area.end));
address = cell.merge_area.start;
cell = this.active_sheet.CellData(address);
}
else {
rect = this.layout.OffsetCellAddressToRectangle(address);
}
// locked: can't edit! note we have to do the merge check first
if (cell.style?.locked) {
console.info('cell is locked for editing');
return;
}
// FIXME: scroll into view (might be 1/2 visible)
// UPDATE: we switched the cell editor parent to the container, so that
// we can properly trap mouse move events. however that requires that we
// offset rectangles by the row/column headers.
// this width/height is actually in the sheet, although that's not really
// a good place for it. sheet should be data-specific, and header size
// is more of an application thing (although I guess you could resize the
// headers in a particular sheet...) FIXME?
// UPDATE: and we can't use offsetWidth/offsetHeight in legacy renderer...
rect = rect.Shift(this.layout.header_size.width, this.layout.header_size.height);
let cell_value = cell.value;
let edit_state: CellStyle|undefined;
if (typeof cell_value === 'undefined' && !cell.style?.font_face) {
edit_state = this.edit_state;
}
// if called from a keypress, we will overwrite whatever is in there so we
// can just leave text as is -- except for handling %, which needs to get
// injected.
if (flush) {
if ((cell.type === ValueType.number || cell.rendered_type === ValueType.number) && cell.style &&
cell.style.number_format && /(?:%|percent)/i.test(cell.style.number_format) &&
(!event || this.IsNumeric(event.key.charCodeAt(0)))) {
// UPDATE: don't do this if the user types '=', because they're typing a function.
// actually we could probably extend that to anything that doesn't look like a number...
cell_value = '%';
}
else {
cell_value = undefined;
}
}
else {
cell_value = this.NormalizeCellValue(cell);
}
// cell rect, offset for headers. FIXME: do we always offset for headers?
// if so, that should go in the method.
// this.overlay_editor?.Edit(selection, rect.Shift(-1, -1).Expand(1, 1), cell, cell_value, event);
this.overlay_editor?.Edit(selection, rect.Expand(-1, -1), cell, cell_value, event, edit_state);
cell.editing = true;
cell.render_clean = [];
this.DelayedRender(false, selection.area);
}
private BoundAddressArea(address: ICellAddress, area: Area) {
// order of overflow is different for vertical/horizontal movement.
// also we don't want to double-step. so there are four separate,
// double tests... it seems redundant.
// not possible to do modulo arithmetic? (need carry/underflow?)
if (address.column > area.end.column) {
address.row = this.StepVisibleRows(address.row, 1);
if (address.row > area.end.row) address.row = area.start.row;
address.column = area.start.column;
}
else if (address.column < area.start.column) {
address.row = this.StepVisibleRows(address.row, -1);
if (address.row < area.start.row) address.row = area.end.row;
address.column = area.end.column;
}
else if (address.row > area.end.row) {
address.column = this.StepVisibleColumns(address.column, 1);
if (address.column > area.end.column) address.column = area.start.column;
address.row = area.start.row;
}
else if (address.row < area.start.row) {
address.column = this.StepVisibleColumns(address.column, -1);
if (address.column < area.start.column) address.column = area.end.column;
address.row = area.end.row;
}
}
/**
* step from row X to row (X+Y). Y can be negative. we add this method to
* support stepping across hidden (0-height) rows, so if you down arrow
* it doesn't get stuck inside the hidden row (bad UX).
*
* UPDATE: no longer clamping. various methods rely on tests for < 0, so
* we can't change that behavior. [FIXME: return an underflow flag?]. just
* treat negative rows as not hidden, return the result.
*/
private StepVisibleRows(start: number, step: number) {
if (step > 0) {
for (let i = 0; i < step; i++) {
if (!this.layout.RowHeight(++start)) i--;
}
}
else if (step < 0) {
for (let i = 0; i > step; i--) {
if (--start >= 0 && !this.layout.RowHeight(start)) i++;
}
}
return start;
}
/**
* step columns.
* @see StepVisibleRows
*/
private StepVisibleColumns(start: number, step: number) {
if (step > 0) {
for (let i = 0; i < step; i++) {
if (!this.layout.ColumnWidth(++start)) i--;
}
}
else if (step < 0) {
for (let i = 0; i > step; i--) {
if (--start >= 0 && !this.layout.ColumnWidth(start)) i++;
}
}
return start;
}
/**
* if the address is outside of current extent, expand
*/
protected EnsureAddress(address: ICellAddress, step = 8, toll_layout = false): boolean {
let expanded = false;
if (this.options.expand) {
// what's the optimal order of doing this, given we are expanding
// a 2-dimensional array? (...)
if (address.row !== Infinity && address.row >= this.active_sheet.rows) {
let row = this.active_sheet.rows;
while (address.row >= row) { row += step; }
this.active_sheet.cells.EnsureRow(row);
expanded = true;
}
if (address.column !== Infinity && address.column >= this.active_sheet.columns) {
let column = this.active_sheet.columns;
while (address.column >= column) { column += step; }
this.active_sheet.cells.EnsureColumn(column);
expanded = true;
}
if (expanded && !toll_layout) {
this.layout.UpdateTiles();
this.layout.UpdateContentsSize();
this.Repaint(true, true);
}
}
return expanded;
}
/**
* advances selection by x rows and columns. you can also step around
* within a selection, generally by using enter and tab when there is
* a multi-cell selection.
*
* not sure why this function renders by default, but we don't want that
* in the case of closing the ICE. since normal operation may rely on this
* behavior, at least for now, we'll make a parameter to disable. but it
* should probably not render.
*/
protected AdvanceSelection(
delta: Extent,
selection: GridSelection,
within_selection = false,
expand_selection = false,
render = true) {
const selecting_argument = this.SelectingArgument();
// let expanded = false;
if (selection.empty) {
// different behavior
if (selecting_argument) {
const target = {
row: Math.max(0, this.StepVisibleRows(this.primary_selection.target.row, delta.rows)),
column: Math.max(0, this.StepVisibleColumns(this.primary_selection.target.column, delta.columns)),
};
this.Select(selection, new Area(target));
}
else {
this.Select(selection, new Area({ row: 0, column: 0 }));
}
}
else {
const target_cell = this.active_sheet.CellData(selection.target);
// if there's a merge, behavior may be a little different. for stepping,
// we step into the merge cell when we hit the head. otherwise we skip
// it entirely (which is a little complicated).
// for expanding, we need to make sure that we expand beyond (or contract
// to exclude) the merge cell. let's do this for each section, if it's
// possible to merge behavior we can do that later.
// one thing we need to check early is the "within selection" flag --
// if there's only a single merged cell selected, this is not correct.
if (target_cell.merge_area && within_selection) {
within_selection = !target_cell.merge_area.Equals(selection.area);
}
if (within_selection && selection.area.count > 1) {
// tab or enter within a larger selection moves the
// target, but doesn't change the selection. this one
// does not scroll (perhaps it should, if necessary?)
// could be a very large selection, or we have moused
// around -- so scroll the target into view.
const area = this.active_sheet.RealArea(selection.area);
const address = selection.target;
// two things happen when merged. (1) if the current target is a
// merge cell, then we need to step to the edge of the merge cell
// (only matters if delta is positive):
// (2) if the next cell is merged, then we either step onto the head
// or, if we would step onto a subcell, pass over it entirely.
//while (true) {
for (; ;) {
// step
address.row = this.StepVisibleRows(address.row, delta.rows);
address.column = this.StepVisibleColumns(address.column, delta.columns);
// bound
this.BoundAddressArea(address, area);
// merged? if we're not on the head, keep stepping (FIXME: step over
// for efficiency, don't waste multiple checks)
const check_cell = this.active_sheet.CellData(address);
if (!check_cell.merge_area ||
(check_cell.merge_area.start.row === address.row &&
check_cell.merge_area.start.column === address.column)) break;
}
this.Select(selection, area, address);
}
else if (expand_selection && selection && selection.target) {
// shift-arrow expands (contracts) the selection.
// FIXME: need to handle merges, where the step will be > 1
const area = selection.area;
const target = selection.target;
const start = area.start;
const end = area.end;
const scroll_target = { row: Infinity, column: Infinity };
if (delta.columns) {
if (area.columns === 1) {
if (delta.columns > 0) {
end.column = this.StepVisibleColumns(end.column, 1);
scroll_target.column = end.column;
}
else {
start.column = this.StepVisibleColumns(start.column, -1);
scroll_target.column = start.column;
}
}
else if (area.end.column > target.column) {
end.column = this.StepVisibleColumns(end.column, delta.columns);
scroll_target.column = end.column;
}
else if (area.start.column < target.column) {
start.column = this.StepVisibleColumns(start.column, delta.columns);
scroll_target.column = start.column;
}
end.column = Math.max(0, end.column);
start.column = Math.max(0, start.column);
}
if (delta.rows) {
if (area.rows === 1) {
if (delta.rows > 0) {
end.row = this.StepVisibleRows(end.row, 1);
scroll_target.row = end.row;
}
else {
start.row = this.StepVisibleRows(start.row, -1);
scroll_target.row = start.row;
}
}
else if (area.end.row > target.row) {
end.row = this.StepVisibleRows(end.row, delta.rows);
scroll_target.row = end.row;
}
else if (area.start.row < target.row) {
start.row = this.StepVisibleRows(start.row, delta.rows);
scroll_target.row = start.row;
}
end.row = Math.max(0, end.row);
start.row = Math.max(0, start.row);
}
if (!this.options.expand) {
for (const addr of [start, end, scroll_target]) {
if (addr.row !== Infinity) {
addr.row = Math.max(0, Math.min(addr.row, this.active_sheet.rows - 1));
}
if (addr.column !== Infinity) {
addr.column = Math.max(0, Math.min(addr.column, this.active_sheet.columns - 1));
}
}
this.ScrollIntoView(scroll_target);
this.Select(selection, new Area(start, end), undefined, true);
}
else {
for (const addr of [start, end, scroll_target]) {
if (addr.row !== Infinity) {
addr.row = Math.max(0, addr.row);
}
if (addr.column !== Infinity) {
addr.column = Math.max(0, addr.column);
}
}
/*
if (end.row !== Infinity && end.row >= this.active_sheet.rows && this.options.expand) {
let row = this.active_sheet.rows;
while (end.row >= row) { row += 8; }
this.active_sheet.cells.EnsureRow(row);
expanded = true;
}
if (end.column !== Infinity && end.column >= this.active_sheet.columns && this.options.expand) {
let column = this.active_sheet.columns;
while (end.column >= column) { column += 8; }
this.active_sheet.cells.EnsureColumn(column);
expanded = true;
}
if (expanded) {
this.layout.UpdateTiles();
this.layout.UpdateContentsSize();
this.Repaint(true, true);
render = true;
}
*/
if (this.EnsureAddress(end)) { render = true; }
this.ScrollIntoView(scroll_target);
this.Select(selection, new Area(start, end), undefined, true);
}
}
else {
// this section: no modifier, and either arrow keys or tab/enter
// but not inside a larger selection. move and make a new selection,
// so selection will be a single cell. scroll it into view.
const address = selection.target;
if (target_cell.merge_area) {
if (delta.columns < 0) {
address.column = this.StepVisibleColumns(target_cell.merge_area.start.column, -1);
}
else if (delta.columns > 0) {
address.column = this.StepVisibleColumns(target_cell.merge_area.end.column, 1);
}
if (delta.rows < 0) {
address.row = this.StepVisibleRows(target_cell.merge_area.start.row, -1);
}
else if (delta.rows > 0) {
address.row = this.StepVisibleRows(target_cell.merge_area.end.row, 1);
}
}
else {
address.row = this.StepVisibleRows(address.row, delta.rows);
address.column = this.StepVisibleColumns(address.column, delta.columns);
}
if (this.EnsureAddress(address)) { render = true; }
this.Select(selection, new Area({
row: Math.min(
Math.max(0, address.row),
this.active_sheet.rows - 1),
column: Math.min(
Math.max(0, address.column),
this.active_sheet.columns - 1),
}));
// we're calling renderselections early, to avoid jitter when
// scrolling. FIXME: create a method to just update the given
// selection, to minimize work here (maybe that's over-optimizing?)
// at the least, we could flag that we've already done this so
// it doesn't get called again on the next render
this.RenderSelections();
// then scroll.
this.ScrollIntoView(selection.target);
}
}
if (this.SelectingArgument()) this.UpdateSelectedArgument(selection);
if (render) {
this.DelayedRender();
}
}
/** highlight formula dependencies */
private HighlightDependencies(dependencies: Area[], render = true) {
// it's slow to make big selections, probably because they are
// giant SVGs; so we should cache. as a first try we can cache the
// depenendency list... the dumbest way possible...
//const json = JSON.stringify(dependencies);
//if (json === this.selection_renderer.cached_additional_selections) {
// return;
//}
//this.selection_renderer.cached_additional_selections = json;
// FIXME: cache, in case we parse the same string repeatedly?
// this.ClearAdditionalSelections(); // flush
// ok, we're no longer clearing, so we need to do some management...
// start by dropping any excess [actually we need to do this at the
// end to account for dupes]
// this.additional_selections.splice(dependencies.length);
// this was causing chaos when I was typing a function, because
// it was interpreting it as column 32 million. it started as
// =A5/...
// and I started typing round before A5, but that was interpreted
// as ROUNDA5 (which is a lot of columns), and we're not limiting.
//
// (I feel like we used to have a sanity check on that -- was that
// in parser?)
//
// nasty bug. so what we do here is limit selections to existing
// range.
// FIXME: we should actually limit by max of existing range or
// displayed range, which may be larger (or is that implicit? ...)
let index = 0;
step: for (let area of dependencies) {
if ((area.start.row === Infinity || area.start.row < this.active_sheet.rows) &&
(area.start.column === Infinity || area.start.column < this.active_sheet.columns)) {
if (area.start.spill && area.end.row === area.start.row && area.end.column === area.start.column) {
const sheet = this.model.sheets.Find(area.start.sheet_id || -1);
const cell = sheet?.CellData(area.start);
if (cell?.spill && cell.spill.start.row === area.start.row && cell.spill.start.column === area.start.column) {
area.ConsumeArea(cell.spill);
}
}
area = this.active_sheet.RealArea(area);
const label = area.spreadsheet_label;
if (this.additional_selections[index]
&& this.additional_selections[index].area.spreadsheet_label === label) {
// console.info('leaving selection @', index);
index++;
}
else {
// this.AddAdditionalSelection(area.start, area);
// check if there is a _prior_ add'l selection that matches
for (let i = 0; i < index; i++) {
if (this.additional_selections[i].area.spreadsheet_label === label
&& this.additional_selections[i].area.start.sheet_id === area.start.sheet_id) {
// console.info('skipping dupe');
continue step;
}
}
// ok, add
this.additional_selections[index++] = { target: area.start, area };
}
}
}
this.additional_selections.splice(index); // excess
if (render) this.RenderSelections(false); // allow cache!
}
/** remove all additonla (argument) selections */
private ClearAdditionalSelections() {
// NOTE: at the moment, additional selections are not
// reflected in headers, so this is unecessary. it might
// come back, though.
// this.additional_selections = [];
// this.additional_selections.length = 0; // legal? IE11?
this.additional_selections.splice(0, this.additional_selections.length);
}
/**
* utility method, internally calls Select with an undefined area
*/
protected ClearSelection(selection: GridSelection, reason?: GridSelectionEvent['reason']) {
this.Select(selection, undefined, undefined, undefined, reason);
}
private HideGridSelection() {
this.UpdateAddressLabel(undefined, '');
const formula = (this.selected_annotation && this.selected_annotation.data.formula) ?
this.selected_annotation.data.formula : '';
this.UpdateFormulaBarFormula(formula);
this.layout.ShowSelections(false);
}
private ShowGridSelection() {
this.UpdateAddressLabel();
this.UpdateFormulaBarFormula();
this.layout.ShowSelections(true);
}
/**
* updates a selection, and handles marking headers as dirty
* for subsequent renders (including any old selection).
*
* if the selection contains part of a merge area, it will be expanded
* to include the full merge area (because you can't select part of a merge).
*
* @param area selection area
* @param target optional selection target. if no target is passed and
* the preseve_target field is not set, target will be set to the first
* cell of the selection area
* @param preserve_target preserve existing selection target
*/
protected Select(selection: GridSelection, area?: Area, target?: ICellAddress, preserve_target = false, reason?: GridSelectionEvent['reason']) {
if (!selection.empty) {
if (preserve_target) target = selection.target;
}
if (area) {
let real_area = this.active_sheet.RealArea(area);
if (!target) target = real_area.start;
// there has to be a better way to do this...
// the operation here is composing the selection by adding all merge
// areas. the issue is that when you do that, you might be adding other
// cells that are not in the merge area (because the selection has to
// be a rectangle) and one of those new cells might itself be merged.
// so we need to iterate. there's a lot of duplication here, though.
// eslint-disable-next-line no-constant-condition
recheck_loop: while (true) {
for (const cell of this.active_sheet.cells.Iterate(real_area, false)) {
if (cell.merge_area && !real_area.ContainsArea(cell.merge_area)) {
area.ConsumeArea(cell.merge_area);
real_area = this.active_sheet.RealArea(area);
continue recheck_loop;
}
}
break;
}
/*
let recheck = true;
while (recheck) {
recheck = false;
this.active_sheet.cells.Apply(real_area, (cell: Cell) => {
if (cell.merge_area && !real_area.ContainsArea(cell.merge_area)) {
area.ConsumeArea(cell.merge_area);
real_area = this.active_sheet.RealArea(area);
recheck = true;
}
});
}
*/
selection.area = new Area({ ...area.start, sheet_id: this.active_sheet.id }, area.end);
if (target) {
selection.target = { ...target, sheet_id: this.active_sheet.id };
}
selection.empty = false;
const cell_data= this.active_sheet.CellData(selection.target);
let text = '';
if (cell_data.formatted) {
if (typeof cell_data.formatted === 'string') {
text = cell_data.formatted;
}
else {
text = cell_data.formatted.map(value =>
(value.flag === TextPartFlag.hidden || value.flag === TextPartFlag.formatting) ? '' : value.text).join('');
}
}
this.overlay_editor?.UpdateCaption(text);
}
else {
selection.empty = true;
}
// FIXME: this should clone
if (selection === this.primary_selection) {
// removed for overlay; return for IE?
// actually no, not for IE, because the mock function skips IE anyway;
// we left it in for safari (??)
// this.layout.MockSelection();
// FIXME: drop support for old edge
if (UA.is_edge) { this.Focus(); }
this.grid_events.Publish({
type: 'selection',
selection: this.primary_selection,
reason,
});
this.UpdateAddressLabel();
this.UpdateFormulaBarFormula();
if (this.formula_bar) {
this.formula_bar.target_address = {...this.primary_selection.target};
}
if (this.options.stats) {
this.UpdateStats();
}
}
}
/**
*
*/
protected UpdateFormulaBarFormula(override?: string) {
this.layout.HideDropdownCaret();
// NOTE: this means we won't set validation carets... that needs
// to be handled separately (FIXME)
// if (!this.formula_bar) { return; }
if (override) {
if (this.formula_bar) {
this.formula_bar.shadow = false;
this.formula_bar.formula = override;
}
return;
}
if (this.primary_selection.empty) {
if (this.formula_bar) {
this.formula_bar.shadow = false;
this.formula_bar.formula = '';
}
}
else {
let data = this.active_sheet.CellData(this.primary_selection.target);
// optimally we would do this check prior to this call, but
// it's the uncommon case... not sure how important that is
const head = data.merge_area || data.area || data.spill;
let shadow = false;
if (head) {
if (head.start.column !== this.primary_selection.target.column
|| head.start.row !== this.primary_selection.target.row) {
data = this.active_sheet.CellData(head.start);
if (data.spill) { shadow = true; }
}
}
if (this.formula_bar) {
this.formula_bar.editable = !data.style?.locked;
}
const value = this.NormalizeCellValue(data);
// this isn't necessarily the best place for this, except that
// (1) we already have cell data; (2) the calls would generally
// sync up, so it would be a separate function but called at the
// same time.
// less true now that they're maintained separately
const validation = this.active_sheet.GetValidation(this.primary_selection.target)[0];
if (validation && !data.style?.locked) {
let list: CellValue[] | undefined;
if (validation.type === 'list') {
list = validation.list;
}
else if (validation.type === 'range') {
list = this.GetValidationRange(validation.area);
}
if (list && list.length) {
this.layout.ShowDropdownCaret(
(data.merge_area || new Area(this.primary_selection.target)),
list, data.value);
}
}
if (this.formula_bar) {
this.formula_bar.shadow = shadow;
if (data.area) {
this.formula_bar.formula = '{' + (value || '') + '}';
}
else {
this.formula_bar.formula = (value ?? '').toString();
}
}
}
}
/**
* splitting the stats update call and the rendering method,
* if we want to make a pluggable stats method.
*
* default will show count/sum/average of numbers, either real
* or complex.
*/
protected RenderStats(values: CellValue|CellValue[][]): StatsEntry[] {
// we don't handle single values
if (!Array.isArray(values)) {
return [];
}
// we count numbers, in addition to accumulating, because
// it's possible for numbers to sum up to 0. therefore you
// can't switch on !sum to check if you have any numbers.
// also, "count" counts things that are non-empty but not
// numbers, so you need to count numbers for averages.
// we could possibly accept a function here to display different
// kind of stats (looking at you, 🧠🧠🍪). call that a TODO.
// ...complex...
let count = 0;
let numbers = 0;
const sum: Complex = { real: 0, imaginary: 0 };
for (const row of values) {
for (const cell of row) {
if (typeof cell === 'number') {
sum.real += cell;
numbers++;
}
else if (IsComplex(cell)) {
sum.real += cell.real;
sum.imaginary += cell.imaginary;
numbers++;
}
// count, not else if
if (typeof cell !== 'undefined') {
count++;
}
}
}
const SelectFormat = (value: number) => {
const log = Math.floor(Math.log10(value));
if (log < -6 || log > 10) {
return NumberFormatCache.Get('Exponential');
}
if (log <= -1) {
return NumberFormatCache.Get('General');
}
return NumberFormatCache.Get('Number');
};
if (count > 1) {
if (numbers > 0) {
const general = NumberFormatCache.Get('General')
if (sum.imaginary) {
const average: Complex = { real: sum.real / numbers, imaginary: sum.imaginary / numbers };
return [
{ label: 'Count', value: count.toString() },
{ label: 'Sum', value: NumberFormat.FormatPartsAsText(general.FormatComplex(sum)) },
{ label: 'Average', value: NumberFormat.FormatPartsAsText(general.FormatComplex(average)) },
];
}
else {
return [
{ label: 'Count', value: count.toString() },
{ label: 'Sum', value: (SelectFormat(sum.real)).Format(sum.real) },
{ label: 'Average', value: (SelectFormat(sum.real/numbers)).Format(sum.real/numbers) },
];
}
}
else {
return [{ label: 'Count', value: count.toString() }] // `Count: ${count}`;
}
}
return [];
}
public UpdateStats() {
if (this.tab_bar) {
let data: StatsEntry[] = [];
// why does this get tested on every call? just set the function
if (typeof this.options.stats === 'function') {
if (!this.primary_selection.empty) {
data = this.options.stats.call(undefined, this.GetRange(this.primary_selection.area));
}
}
else {
if (!this.primary_selection.empty && this.primary_selection.area.count > 1) {
data = this.RenderStats(this.GetRange(this.primary_selection.area));
}
}
this.tab_bar.stats_data = data;
}
}
protected UpdateAddressLabelArea(selection: GridSelection) {
if (!selection.empty && !selection.area.entire_sheet) {
if (selection.area.entire_column) {
this.UpdateAddressLabel(undefined, selection.area.columns + 'C');
}
else if (selection.area.entire_row) {
this.UpdateAddressLabel(undefined, selection.area.rows + 'R');
}
else if (selection.area.count > 1) {
this.UpdateAddressLabel(undefined, selection.area.rows + 'R x ' +
selection.area.columns + 'C');
}
else {
this.UpdateAddressLabel(selection);
}
}
}
protected UpdateAddressLabel(selection = this.primary_selection, text?: string) {
if (!this.formula_bar) { return; }
if (typeof text !== 'undefined') {
this.formula_bar.label = text;
}
else if (selection.empty) {
this.formula_bar.label = '';
}
else {
const data = this.active_sheet.CellData(this.primary_selection.target);
const target = new Area(data.merge_area ? data.merge_area.start : selection.target);
this.formula_bar.label =
this.model.named.MatchSelection(selection.area, target)
|| Area.CellAddressToLabel(target.start);
}
}
private OnDropdownSelect(value: CellValue) {
if (typeof value !== 'undefined') {
// FIXME: complex? (...)
const result = ValueParser.TryParse(value.toString());
if (result.type === ValueType.number) {
value = result.value;
}
}
const data = this.active_sheet.CellData(this.primary_selection.target);
const area = data.merge_area ? data.merge_area.start : this.primary_selection.target;
// I don't think we need to translate this -- you can't set a function
// using a dropdown (can you? ...)
this.ExecCommand({
key: CommandKey.SetRange,
area,
value,
});
this.UpdateFormulaBarFormula();
}
private OnScroll() {
const tiles = this.layout.VisibleTiles();
if (!tiles.Equals(this.render_tiles)) {
this.render_tiles = tiles;
if (!this.layout_token) {
// why raf here and not dispatcher?
this.layout_token = requestAnimationFrame(() => this.Repaint());
}
}
}
private AttachListeners() {
if (!this.container) throw new Error('invalid container');
this.container.addEventListener('copy', this.HandleCopy.bind(this));
this.container.addEventListener('cut', this.HandleCut.bind(this));
this.container.addEventListener('paste', this.HandlePaste.bind(this));
// mouse down events for selection
this.layout.grid_cover.addEventListener('mousedown', (event) => this.MouseDown_Grid(event));
this.layout.column_header_cover.addEventListener('mousedown', (event) => this.MouseDown_ColumnHeader(event));
this.layout.row_header_cover.addEventListener('mousedown', (event) => this.MouseDown_RowHeader(event));
// move events on headers, to show column/row resize cursors where appropriate
this.layout.column_header_cover.addEventListener('mousemove', (event) => this.MouseMove_ColumnHeader(event));
this.layout.row_header_cover.addEventListener('mousemove', (event) => this.MouseMove_RowHeader(event));
// this is for cursor changes
this.layout.grid_cover.addEventListener('mousemove', (event) => this.MouseMove_Grid(event));
// key handler
// this.container.addEventListener('keydown', (event) => this.KeyDown(event));
this.overlay_editor?.edit_node.addEventListener('keydown', (event) => this.OverlayKeyDown(event));
// select all?
this.layout.corner.addEventListener('dblclick', () => {
this.SelectAll();
});
// this is for resize: we want to repaint on scale events. we should
// probably not do this synchronously, because scale changes are usually
// repeated.
window.addEventListener('resize', () => {
const update = this.layout.UpdateDPR();
if (update) {
this.QueueLayoutUpdate();
this.Repaint(true, true, true);
}
});
}
private HandleCopy(event: ClipboardEvent) {
// console.info('handle copy', event, this.primary_selection);
event.stopPropagation();
event.preventDefault();
if (this.primary_selection.empty) {
if (event.clipboardData) {
event.clipboardData.clearData();
}
if (this.selected_annotation) {
if (event.clipboardData) {
const composite = JSON.stringify({
data: this.selected_annotation,
source: this.active_sheet.id,
});
event.clipboardData.setData('text/x-treb-annotation', composite);
const view = this.selected_annotation.view[this.view_index];
if (view && view.node) {
// this.selected_annotation.node.innerHTML;
const node = view.node.firstChild?.firstChild;
if (node) {
// trying to put svg on the clipboard here, which works, but
// is basically useless. the underlying method is good, though,
// clients could use it for better UX in saving images
const html = (SerializeHTML(node as Element) as HTMLElement).outerHTML;
event.clipboardData.setData('text/uri-list', `data:image/svg+xml;base64,` + btoa(html)); // <-- does this work? seems no
event.clipboardData.setData('text/html', html); // <-- does this work? (also no)
event.clipboardData.setData('text/plain', html);
}
}
}
}
}
else {
const area = this.active_sheet.RealArea(this.primary_selection.area);
const column_width: number[] = [];
const row_height: number[] = [];
if (this.primary_selection.area.entire_column) {
for (let c = area.start.column; c <= area.end.column; c++) {
const width = this.active_sheet.GetColumnWidth(c);
if (width !== this.active_sheet.default_column_width) {
column_width[c - area.start.column] = width;
}
}
}
if (this.primary_selection.area.entire_row) {
for (let r = area.start.row; r <= area.end.row; r++) {
const height = this.active_sheet.GetRowHeight(r);
if (height !== this.active_sheet.default_row_height) {
row_height[r - area.start.row] = height;
}
}
}
const columns = area.columns;
const rows = area.rows;
// const cells = this.active_sheet.cells;
const tsv_data: CellValue[][] = [];
const treb_data: ClipboardCellData[] = [];
// NOTE: we don't want to preserve table information when
// copying/cutting. that should happen naturally here, just
// wanted to note it.
// do this in row order, for tsv. we have to transpose one of them.
for (let row = 0; row < rows; row++) {
// FIXME: we don't want function errors in the tsv output...
const tsv_row: CellValue[] = [];
for (let column = 0; column < columns; column++) {
const address = { row: area.start.row + row, column: area.start.column + column };
const cell = this.active_sheet.CellData(address);
// NOTE: this now has to account for "text parts", which
// are returned from the format lib. we need to render them,
// accounting for a few differences (no expanding, for example,
// and I guess we should drop hidden characters).
// now that I think about it, why would we use the formatted value
// here instead of the calculated value? should use the latter...
// tsv_row.push(cell.formatted);
let text_value = '';
if (cell.calculated !== undefined) {
if (cell.calculated_type === ValueType.complex) {
text_value = ComplexToString(cell.calculated as Complex);
}
else {
text_value = cell.calculated.toString();
}
}
else {
if (cell.type === ValueType.complex) {
text_value = ComplexToString(cell.value as Complex);
}
else {
text_value = cell.value?.toString() || '';
}
}
// tsv_row.push(typeof cell.calculated === 'undefined' ? cell.value : cell.calculated);
tsv_row.push(text_value);
const data_entry: ClipboardCellData = {
address,
data: cell.value,
type: cell.type,
style: this.active_sheet.GetCopyStyle(address), // excludes row pattern
};
if (cell.area &&
cell.area.start.row === address.row &&
cell.area.start.column === address.column) {
data_entry.array = {
rows: cell.area.rows, columns: cell.area.columns
};
}
treb_data.push(JSON.parse(JSON.stringify(data_entry)));
}
tsv_data.push(tsv_row);
}
const tsv = tsv_data.map((row) => row.join('\t')).join('\n');
if (event.clipboardData) {
event.clipboardData.clearData();
event.clipboardData.setData('text/plain', tsv);
event.clipboardData.setData('text/x-treb', JSON.stringify({
source: area,
data: treb_data,
column_width,
row_height,
}));
}
}
}
private HandleCut(event: ClipboardEvent) {
event.stopPropagation();
event.preventDefault();
this.HandleCopy(event);
if (!this.primary_selection.empty) {
const area = this.active_sheet.RealArea(this.primary_selection.area);
// this.model.sheet.ClearArea(area);
this.ExecCommand({ key: CommandKey.Clear, area });
}
else if (this.selected_annotation) {
this.RemoveAnnotation(this.selected_annotation);
}
}
private RecyclePasteAreas(source_area: Area, target_area: Area) {
const paste_areas: Area[] = [];
if (source_area.count === 1) {
for (let row = target_area.start.row; row <= target_area.end.row; row++) {
for (let column = target_area.start.column; column <= target_area.end.column; column++) {
paste_areas.push(new Area({ row, column }));
}
}
}
else if (source_area.columns === target_area.columns
&& target_area.rows >= source_area.rows
&& target_area.rows % source_area.rows === 0) {
for (let row = target_area.start.row; row <= target_area.end.row; row += source_area.rows) {
paste_areas.push(new Area(
{ row, column: target_area.start.column },
{ row: row + source_area.rows - 1, column: target_area.end.column }));
}
}
else if (source_area.rows === target_area.rows
&& target_area.columns >= source_area.columns
&& target_area.columns % source_area.columns === 0) {
// console.info('rows match, recycle columns');
for (let column = target_area.start.column; column <= target_area.end.column; column += source_area.columns) {
paste_areas.push(new Area(
{ column, row: target_area.start.row },
{ column: column + source_area.columns - 1, row: target_area.end.row }));
}
}
else {
paste_areas.push(target_area.Clone().Resize(source_area.rows, source_area.columns));
}
return paste_areas;
}
private HandlePaste(event: ClipboardEvent) {
// otherwise we capture
if (this.overlay_editor?.editing) {
return;
}
event.stopPropagation();
event.preventDefault();
if (!event.clipboardData) return;
const annotation_data = event.clipboardData.getData('text/x-treb-annotation');
if (annotation_data) {
try {
const composite = JSON.parse(annotation_data);
if (composite.source && composite.source !== this.active_sheet.id) {
if (composite.data && composite.data.formula) {
let name = '';
const sheet = this.model.sheets.Find(composite.source as number);
if (sheet) {
name = sheet.name;
}
/*
for (const sheet of this.model.sheets.list) {
if (sheet.id === composite.source) {
name = sheet.name;
break;
}
}
*/
if (name) {
const parse_result = this.parser.Parse(composite.data.formula);
if (parse_result.expression) {
this.parser.Walk(parse_result.expression, (unit) => {
if (unit.type === 'range') {
if (!unit.start.sheet_id && !unit.start.sheet) {
unit.start.sheet = name;
}
return false; // don't recurse to individual addresses
}
else if (unit.type === 'address') {
if (!unit.sheet_id && !unit.sheet) {
unit.sheet = name;
}
}
return true;
});
composite.data.formula = '=' + this.parser.Render(parse_result.expression, { missing: '' });
}
}
}
}
this.CreateAnnotation(composite.data, undefined, true, true, undefined, true);
/*
const annotation = this.CreateAnnotation(composite.data, true, true);
const view = annotation.view[this.view_index];
if (view && view.node) {
const node = view.node;
setTimeout(() => {
node.focus();
}, 1);
}
*/
}
catch (e) {
console.error(e);
}
return;
}
if (this.primary_selection.empty) {
return;
}
const area = this.active_sheet.RealArea(this.primary_selection.area);
const commands: Command[] = [];
// FIXME: these iterate. that causes lots of events.
// FIXME: some options for pasting:
//
// (1) recycle a single value over an area;
// (2) recycle a shape over a shape with a similar dimension;
// (3) expand the selection to match the source data
const treb_data = event.clipboardData.getData('text/x-treb');
if (treb_data) {
try {
const object_data: {
source: Area,
data: ClipboardCellData[],
column_width?: number[],
row_height?: number[],
} = JSON.parse(treb_data);
const source_area = new Area(object_data.source.start, object_data.source.end);
// recycle...
const paste_areas = this.RecyclePasteAreas(source_area, area);
// resize if we're forcing a shape
if (paste_areas.length === 1) {
area.Resize(paste_areas[0].rows, paste_areas[0].columns);
}
if (!this.ValidatePasteAreas(paste_areas)) {
this.Error(ErrorCode.invalid_area_for_paste);
return;
}
// paste in, offsetting for the paste area. this part loops
// when recycling, so that the offsets are corrected
// UPDATE: use commands...
// keep track of arrays we paste; we don't want to set value
// on cells that are within those arrays.
const arrays: Area[] = [];
// const paste_area = new Area(area.start, area.end);
for (const paste_area of paste_areas) {
this.active_sheet.cells.EnsureCell(paste_area.end);
// FIXME: command
// this.model.sheet.ClearArea(paste_area, true);
commands.push({ key: CommandKey.Clear, area: paste_area });
const offsets = {
rows: paste_area.start.row - source_area.start.row,
columns: paste_area.start.column - source_area.start.column,
};
object_data.data.forEach((cell_info: ClipboardCellData) => {
let data = cell_info.data;
const target_address = {
row: cell_info.address.row - source_area.start.row + paste_area.start.row,
column: cell_info.address.column - source_area.start.column + paste_area.start.column,
};
if (cell_info.type === ValueType.formula) {
const parse_result = this.parser.Parse(data as string);
if (parse_result.expression) {
data = '=' + this.parser.Render(parse_result.expression, {
offset: offsets, missing: '' });
}
}
if (cell_info.array) {
const target_array = {
start: {
...target_address,
}, end: {
row: target_address.row + cell_info.array.rows - 1,
column: target_address.column + cell_info.array.columns - 1,
},
};
// we do not need to translate here. the data should be
// in canonical form on the clipboard.
const command: SetRangeCommand = {
key: CommandKey.SetRange,
value: data,
array: true,
area: target_array,
};
arrays.push(new Area(target_array.start, target_array.end));
commands.push(command);
}
else {
let skip = false;
for (const array of arrays) {
if (array.Contains(target_address)) {
skip = true;
break;
}
}
if (!skip) {
// we do not need to translate here -- the data should be
// in canonical form on the clipboard.
commands.push({ key: CommandKey.SetRange, value: data, area: target_address });
}
}
commands.push({ key: CommandKey.UpdateStyle, style: cell_info.style || {}, area: target_address });
});
if (object_data.column_width?.length) {
for (const [index, width] of object_data.column_width.entries()) {
if (typeof width === 'number') {
const column = index + paste_area.start.column;
commands.push({
key: CommandKey.ResizeColumns,
column,
width,
});
}
}
}
if (object_data.row_height?.length) {
for (const [index, height] of object_data.row_height.entries()) {
if (typeof height === 'number') {
const row = index + paste_area.start.row;
commands.push({
key: CommandKey.ResizeRows,
row,
height,
});
}
}
}
}
}
catch (e) {
console.error('invalid treb data on clipboard');
console.info(e);
return;
}
}
else {
// excel tsv is not very clean wrt newlines and quotes. it's easy
// to construct tsv that makes no sense. as a result parsing is not
// really helpful.
// there is html which uses tables, that might work better (TODO)
const text_data = event.clipboardData.getData('text/plain');
if (!text_data) return true;
// still, you could do better than this.
const lines = text_data.trim().split('\n');
const source = lines.map((line) => line.split('\t').map((x) => x.trim()));
const paste_areas = this.RecyclePasteAreas(
new Area({ row: 0, column: 0 }, { row: source.length - 1, column: source[0].length - 1 }), area);
if (paste_areas.length === 1) {
area.Resize(source.length, source[0].length);
area.Resize(paste_areas[0].rows, paste_areas[0].columns);
}
if (!this.ValidatePasteAreas(paste_areas)) {
this.Error(ErrorCode.invalid_area_for_paste);
return;
}
for (const paste_area of paste_areas) {
//for (let r = 0; r < lines.length; r++) {
for (let r = 0; r < source.length; r++) {
//for (let c = 0; c < lines[0].length; c++) {
for (let c = 0; c < source[0].length; c++) {
const target_area = new Area({ row: r + paste_area.start.row, column: c + paste_area.start.column });
this.active_sheet.cells.EnsureCell(target_area.end);
if (source[r][c]) {
const tmp = this.SetInferredType(
{ area: target_area, target: target_area.start, empty: false },
source[r][c], false, false); // true); // <- shouldn't that be false? ???
if (tmp) {
for (const command of tmp) { commands.push(command); }
}
}
else {
const current = this.active_sheet.cells.GetCell(target_area.start, false);
if (current && current.type !== ValueType.undefined) {
commands.push({ key: CommandKey.Clear, area: target_area.Clone() });
}
}
}
}
}
// console.info(commands);
// this.Select(this.primary_selection, area);
}
this.ExecCommand(commands);
this.Select(this.primary_selection, area);
/*
this.grid_events.Publish({
type: 'data', area,
});
*/
}
/**
* utility method returns all selections (primary, active and any additional
* selections) as an array. defaults to non-empty selections only, but
* can optionally returns empty non-null selections.
*/
private AllSelections(include_empty_selections = false) {
const selections = [this.primary_selection, this.active_selection].concat(this.additional_selections);
if (include_empty_selections) return selections;
return selections.filter((selection) => !selection.empty);
}
/**
* UI method for inserting rows; updates layout
*/
protected InsertRowsInternal(command: InsertRowsCommand) {
const result = super.InsertRowsInternal(command);
if (result.error) {
return result;
}
const target_sheet = this.FindSheet(command.sheet_id);
if (target_sheet === this.active_sheet) {
// fix selections
if (command.count < 0) {
for (const selection of this.AllSelections()) {
selection.empty = true; // lazy
}
}
else {
for (const selection of this.AllSelections()) {
if (selection.target.row >= command.before_row) {
selection.target.row += command.count;
}
if (!selection.area.entire_column) {
if (selection.area.start.row >= command.before_row) {
selection.area.Shift(command.count, 0);
}
else if (selection.area.end.row >= command.before_row) {
selection.area.ConsumeAddress({
row: selection.area.end.row + command.count,
column: selection.area.end.column,
}); // expand
}
}
}
}
for (const annotation of result.delete_annotations_list || []) {
this.layout.RemoveAnnotation(annotation);
}
// note event is sent in exec command, not implicit here
this.QueueLayoutUpdate();
// we need to repaint (not render) because repaint adjusts the selection
// canvas for tile layout. FIXME: move that out of repaint so we can call
// it directly.
this.Repaint();
if (result.update_annotations_list?.length) {
this.layout.UpdateAnnotation(result.update_annotations_list, this.theme);
for (const annotation of result.resize_annotations_list || []) {
const view = annotation.view[this.view_index];
if (view?.resize_callback) {
view.resize_callback.call(undefined);
}
}
}
}
else {
this.pending_layout_update.add(target_sheet.id);
}
return result;
}
/**
* UI method for inserting columns; updates layout
*/
protected InsertColumnsInternal(command: InsertColumnsCommand) {
const result = super.InsertColumnsInternal(command);
if (result.error) {
return result;
}
const target_sheet = this.FindSheet(command.sheet_id);
// ---
if (target_sheet === this.active_sheet) {
// fix selection(s)
// FIXME: sheet? (...) no, because the only way you have "active"
// selections on non-active sheet is if you are editing, and editing
// and insert/delete row/column can't happen at the same time.
// sheet selections are persisted in the sheets so they won't be affected
if (command.count < 0) {
for (const selection of this.AllSelections()) {
selection.empty = true; // lazy
}
}
else {
for (const selection of this.AllSelections()) {
if (selection.target.column >= command.before_column) {
selection.target.column += command.count;
}
if (!selection.area.entire_row) {
if (selection.area.start.column >= command.before_column) {
selection.area.Shift(0, command.count);
}
else if (selection.area.end.column >= command.before_column) {
selection.area.ConsumeAddress({
row: selection.area.end.row,
column: selection.area.end.column + command.count,
}); // expand
}
}
}
}
for (const annotation of result.delete_annotations_list || []) {
this.layout.RemoveAnnotation(annotation);
}
// overflows break or get orphaned if we muck up the list of
// columns. we should be able to fix this, or we can just flush
// all overflows and force them to get recreated.
this.tile_renderer?.FlushOverflows();
// note event is sent in exec command, not implicit here
this.QueueLayoutUpdate();
this.DelayedRender(true, undefined, true);
if (result.update_annotations_list?.length) {
this.layout.UpdateAnnotation(result.update_annotations_list, this.theme);
for (const annotation of result.resize_annotations_list || []) {
const view = annotation.view[this.view_index];
if (view?.resize_callback) {
view.resize_callback.call(undefined);
}
}
}
}
else {
this.pending_layout_update.add(target_sheet.id);
}
return result;
}
/**
* specialization
*/
protected ResetInternal() {
// inherit non-UI reset stuff
super.ResetInternal();
if (this.headless) {
return;
}
// now do the UI bits
this.RemoveAnnotationNodes();
this.ClearSelection(this.primary_selection);
this.ScrollIntoView({ row: 0, column: 0 });
this.QueueLayoutUpdate(); // necessary? (...)
this.layout.HideNote();
}
/**
* overload: see ResizeRowsInternal
*/
protected ResizeColumnsInternal(command: ResizeColumnsCommand) {
if (this.headless) {
return super.ResizeColumnsInternal(command);
}
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];
const auto = typeof command.width !== 'number';
const width = Math.round((command.width || 0) / this.scale);
if (auto) {
// defaults to true if not provided
const allow_shrinking = !(command.allow_shrinking === false);
for (const entry of column) {
this.AutoSizeColumn(sheet, entry, allow_shrinking);
}
}
else {
for (const entry of column) {
sheet.SetColumnWidth(entry, width);
}
}
if (sheet === this.active_sheet) {
this.layout.UpdateTotalSize();
if (this.layout.container
&& this.layout.container.offsetWidth
&& this.layout.container.offsetWidth > this.layout.total_width) {
this.UpdateLayout();
}
else {
this.layout.UpdateTileWidths(true);
this.render_tiles = this.layout.VisibleTiles();
this.Repaint(false, true); // repaint full tiles
}
this.layout.UpdateAnnotation(this.active_sheet.annotations, this.theme);
this.RenderSelections();
}
else {
this.pending_layout_update.add(sheet.id);
}
}
public override async Screenshot({ type, quality, download}: {
type?: 'png'|'webp'|'jpeg',
quality?: number,
download?: boolean,
}): Promise {
const selection: Area|undefined = this.primary_selection.empty ? undefined : this.primary_selection.area;
let target: Area|undefined = this.primary_selection.empty ? undefined : new Area(this.primary_selection.target);
if (target) {
const cell_data = this.active_sheet.CellData(target.start);
if (cell_data.merge_area) {
target = new Area(cell_data.merge_area.start, cell_data.merge_area.end);
}
}
return this.layout.Screenshot({
type, quality, download, selection, target, nub: true
});
}
/**
* UI grid supports scale and auto-size, so we're overloading.
* @param command
*/
protected ResizeRowsInternal(command: ResizeRowsCommand): IArea|undefined {
if (this.headless) {
return super.ResizeRowsInternal(command);
}
let updated: Area|undefined;
// this method is inconsistent for active sheet vs other sheets.
// for the active sheet, it uses the layout routine which incorporates
// scale. for other sheets, it calls sheet directly, which ignores scale.
// all the layout routine does is call the sheet routine (after adjusting
// for scale), so we could do that from here and simplify everything.
// scale is constant for all sheets (at least atm).
// NOTE: why do we scale the number? because we scale it the other way,
// if that makes sense. when we render we multiply height * scale.
// 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];
// allow shrink: this is default true, but optional
const shrink = (typeof command.shrink === 'boolean' ? command.shrink : true);
// scale height if provided
const auto = typeof command.height !== 'number';
const height = Math.round(command.height || 0 / this.scale);
// apply
if (auto) {
for (const entry of row) {
const current = sheet.GetRowHeight(entry);
if (!current) {
// console.info('zero -> something via auto');
if (!updated) {
updated = new Area({row: entry, column: Infinity, sheet_id: sheet.id});
}
else {
updated.ConsumeAddress({row: entry, column: 1});
}
}
// sheet.AutoSizeRow(entry, this.theme, shrink, this.scale);
this.AutoSizeRow(sheet, entry, shrink);
}
}
else {
for (const entry of row) {
const current = sheet.GetRowHeight(entry);
if ((!current && height) || (current && !height)) {
// console.info('update,', current, height);
if (!updated) {
updated = new Area({row: entry, column: Infinity, sheet_id: sheet.id});
}
else {
updated.ConsumeAddress({row: entry, column: 1});
}
}
sheet.SetRowHeight(entry, height);
}
}
if (sheet === this.active_sheet) {
this.layout.UpdateTotalSize();
if (this.layout.container
&& this.layout.container.offsetHeight
&& this.layout.container.offsetHeight > this.layout.total_height) {
this.UpdateLayout();
}
else {
this.layout.UpdateTileHeights(true);
this.render_tiles = this.layout.VisibleTiles();
this.Repaint(false, true); // repaint full tiles
}
this.layout.UpdateAnnotation(this.active_sheet.annotations, this.theme);
this.RenderSelections();
}
else {
// see below
this.pending_layout_update.add(sheet.id);
}
return updated ? { start: updated.start, end: updated.end } : undefined;
}
/**
* specialization
*/
protected SelectInternal(command: SelectCommand) {
// case: empty selection
if (!command.area) {
this.ClearSelection(this.primary_selection);
}
else {
// activate sheet, if necessary
if (command.area.start.sheet_id && command.area.start.sheet_id !== this.active_sheet.id) {
this.ActivateSheetInternal({
key: CommandKey.ActivateSheet,
id: command.area.start.sheet_id,
});
}
this.Select(this.primary_selection, new Area(command.area.start, command.area.end));
this.RenderSelections();
}
}
/**
* specialization. all the base class method does is set the sheet
* fields, so we don't need to call it, we can do that.
*
*/
protected FreezeInternal(command: FreezeCommand) {
const sheet = this.FindSheet(command.sheet_id || this.active_sheet.id);
// default true, if we're on the active sheet
const highlight = (((typeof command.highlight_transition) === 'boolean')
? command.highlight_transition
: true) && (sheet === this.active_sheet);
if (command.rows === sheet.freeze.rows &&
command.columns === sheet.freeze.columns) {
if (highlight) {
this.HighlightFreezeArea();
}
return;
}
sheet.freeze.rows = command.rows;
sheet.freeze.columns = command.columns;
// FIXME: should we do this via events? (...)
// we are sending an event via the exec command method that calls
// this method, so we are not relying on the side-effect event anymore
if (sheet === this.active_sheet) {
this.QueueLayoutUpdate();
this.Repaint();
if (highlight) {
this.HighlightFreezeArea();
}
if (command.rows || command.columns) {
this.layout.CloneFrozenAnnotations();
}
else {
this.layout.ClearFrozenAnnotations();
}
}
else {
this.pending_layout_update.add(sheet.id);
}
}
//////////////////////////////////////////////////////////////////////////////
public ExecCommand(commands: Command | Command[], queue = true) {
const flags = super.ExecCommand(commands, queue);
if (!this.batch) {
if (flags.render_area) {
this.DelayedRender(false, flags.render_area);
}
}
if (flags.repaint) {
this.Repaint();
}
for (const id of flags.pending || []) {
this.pending_layout_update.add(id);
}
if (flags.layout) {
this.QueueLayoutUpdate();
}
if (flags.sheets && this.tab_bar) {
this.tab_bar.Update();
}
if (flags.formula) {
this.UpdateFormulaBarFormula();
}
return flags;
}
}