/*
* 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 { DOMContext } from 'treb-base-types';
import type { DataModel, ViewModel, Annotation, GridSelection } from 'treb-data-model';
import type { Tile } from '../types/tile';
import type { Theme, Point, Extent, Size, Position, ICellAddress, Table, IArea } from 'treb-base-types';
import { Style, Area, Rectangle, ResolveThemeColor } from 'treb-base-types';
import { MouseDrag } from '../types/drag_mask';
import type { GridEvent } from '../types/grid_events';
// aliasing Area as TileRange. this seemed like a good idea, initially, because
// it can help clarify the function calls and return values when we "overload"
// area to refer to ranges of tiles.
//
// on the other hand, it seems like it might be error-prone because we can swap
// one for the other pretty easily and typescript won't complain.
//
// a more thorough (and probably over-engineered) way to do this would be to
// define area as a generic, then define it on some arbitrary value. that would
// force separation of all the functions between the two types (I think)
import type { CellValue, AnnotationLayout, Corner } from 'treb-base-types';
import { Area as TileRange } from 'treb-base-types';
import { SerializeHTML } from 'treb-utils';
export { Area as TileRange } from 'treb-base-types';
export interface TooltipOptions {
up?: true;
left?: true;
text?: string;
x?: number;
y?: number;
}
/**
* layout structure and management functions
*/
export abstract class BaseLayout {
public column_header!: HTMLDivElement;
public row_header!: HTMLDivElement;
public contents!: HTMLDivElement;
public buffer_canvas!: HTMLCanvasElement;
public corner!: HTMLDivElement;
public corner_canvas!: HTMLCanvasElement; // HTMLDivElement;
public grid_selection!: SVGElement;
public grid_cover!: HTMLDivElement;
public column_header_cover!: HTMLDivElement;
public row_header_cover!: HTMLDivElement;
public annotation_container!: HTMLDivElement;
public mask!: HTMLDivElement;
public mock_selection!: HTMLDivElement;
public container?: HTMLElement; // reference to container
public grid_tiles: Tile[][] = [];
public column_header_tiles: Tile[] = [];
public row_header_tiles: Tile[] = [];
public corner_selection!: SVGElement;
public row_header_selection!: SVGElement;
public column_header_selection!: SVGElement;
public corner_annotations!: HTMLDivElement;
public row_header_annotations!: HTMLDivElement;
public column_header_annotations!: HTMLDivElement;
public frozen_row_tiles: Tile[] = [];
public frozen_column_tiles: Tile[] = [];
public header_size: Size = { width: 0, height: 0 };
public applied_theme_colors: string[] = [];
/**
* last rendered column. this is used to calculate the limits of
* cell overflows, which may exceed actual data in the sheet.
*/
public last_column = 0;
public total_height = 0;
public total_width = 0;
public default_row_height = 0;
public default_column_width = 0;
public header_offset = {
x: 0, y: 0,
};
/** freeze rows/columns */
// public freeze = { rows: 0, columns: 0 };
/**
* NOTE: dpr can probably change, on zoom; but I'm not sure there's
* an event we can trap for that. it might be necessary to test this
* periodically.
*/
public dpr = 1; // Math.max(1, self.devicePixelRatio || 1);
/** separate scale, user-controlled (testing...) */
public scale = 1;
/**
* this is a reference to the node that handles scrolling. it needs
* to be different for legacy renderer.
*/
public scroll_reference_node!: HTMLElement;
public get scroll_offset(): { x: number, y: number } {
if (!this.scroll_reference_node) {
return { x: 0, y: 0 };
}
return {
x: this.scroll_reference_node.scrollLeft,
y: this.scroll_reference_node.scrollTop,
};
}
public set scroll_offset(offset: { x: number; y: number }) {
if (!this.scroll_reference_node) {
return;
}
this.scroll_reference_node.scrollLeft = offset.x;
this.scroll_reference_node.scrollTop = offset.y;
}
protected dropdown_caret!: SVGSVGElement;
protected spill_border!: SVGSVGElement;
/** we have to disable mock selection for IE or it breaks key handling */
private trident = ((typeof navigator !== 'undefined') &&
navigator.userAgent && /trident/i.test(navigator.userAgent));
// private default_tile_size: Size = { width: 600, height: 400 };
private default_tile_size: Size = { width: 1200, height: 800 };
private tooltip_state?: 'up' | 'left';
private tooltip!: HTMLDivElement;
private dropdown_list!: HTMLDivElement;
private dropdown_caret_visible = false;
private dropdown_callback?: (value: CellValue) => void;
private dropdown_selected?: HTMLElement;
// private selection_layout_token?: any;
// private error_highlight: HTMLDivElement;
// private error_highlight_timeout?: any;
private note_node!: HTMLDivElement;
private sort_button!: HTMLButtonElement;
private title_node!: HTMLDivElement;
private row_cache: number[] = [];
private column_cache: number[] = [];
/**
* flag so we don't try to paint before we have tiles
*/
private initialized = false;
constructor(protected model: DataModel, protected view: ViewModel, mock = false, public DOM = DOMContext.GetInstance()) {
if (mock) {
return;
}
if (!DOM) {
throw new Error('missing DOM context');
}
this.dpr = Math.max(1, self.devicePixelRatio || 1);
// now attaching to node... no longer global
// actually if we are not in a web component, we might as well
// use global...
// can't use global if it's inside a block because of z-stacking
// contexts; the mask will be under the next sheet. so either
// global in body, or instance local.
this.mask = DOM.Div('treb-mouse-mask');
this.tooltip = DOM.Div('treb-tooltip');
this.spill_border = DOM.SVG('svg', 'treb-spill-border');
this.spill_border.tabIndex = -1;
this.dropdown_caret = DOM.SVG('svg', 'treb-dropdown-caret');
this.dropdown_caret.setAttribute('viewBox', '0 0 24 24');
this.dropdown_caret.tabIndex = -1;
const caret = DOM.SVG('path');
caret.setAttribute('d', 'M5,7 L12,17 L19,7');
this.dropdown_caret.appendChild(caret);
this.dropdown_caret.addEventListener('click', (event) => {
event.stopPropagation();
event.preventDefault();
this.grid_cover.classList.remove('nub-select');
// the classList polyfill doesn't apply to svg elements (not sure
// if that's an oversight, or IE11 just won't support it) -- but
// either way we can't use it
const class_name = this.dropdown_caret.getAttribute('class') || '';
if (/active/i.test(class_name)) {
this.dropdown_caret.setAttribute('class', 'treb-dropdown-caret');
}
else {
this.dropdown_caret.setAttribute('class', 'treb-dropdown-caret active');
this.dropdown_list.focus();
}
});
// we used to focus on caret. that broke when we started supporting
// long lists and scrolling. so now we focus on the list.
/*
this.dropdown_caret.addEventListener('focusout', () => {
this.dropdown_caret.setAttribute('class', 'treb-dropdown-caret');
this.container?.focus();
});
*/
this.dropdown_list = DOM.Div('treb-dropdown-list', undefined, {
attrs: { tabindex: '-1' },
});
// this.dropdown_list.setAttribute('tabindex', '-1'); // focusable
// this.dropdown_caret.addEventListener('keydown', (event) => {
this.dropdown_list.addEventListener('keydown', (event) => {
let delta = 0;
switch (event.key) {
case 'ArrowDown':
delta = 1;
break;
case 'ArrowUp':
delta = -1;
break;
case 'Escape':
break;
case 'Enter':
break;
default:
console.info(event.key);
return;
}
event.stopPropagation();
event.preventDefault();
if (event.key === 'Escape' || event.key === 'Enter') {
this.container?.focus();
this.dropdown_caret.setAttribute('class', 'treb-dropdown-caret');
if (event.key === 'Enter' && this.dropdown_callback) {
if (this.dropdown_selected) {
const value = this.dropdown_selected.dataset.dropdown_value;
// this.dropdown_callback.call(0, (this.dropdown_selected as any).dropdown_value);
this.dropdown_callback.call(0, value ? JSON.parse(value) : undefined);
}
}
}
else if (delta) {
if (this.dropdown_selected) {
if (delta > 0 && this.dropdown_selected.nextSibling) {
(this.dropdown_selected.nextSibling as HTMLElement).classList.add('selected');
this.dropdown_selected.classList.remove('selected');
this.dropdown_selected = this.dropdown_selected.nextSibling as HTMLElement;
// support scrolling
const bottom = this.dropdown_selected.offsetTop + this.dropdown_selected.offsetHeight;
if (bottom >
this.dropdown_list.offsetHeight + this.dropdown_list.scrollTop) {
this.dropdown_list.scrollTop = bottom - this.dropdown_list.offsetHeight;
}
}
else if (delta < 0 && this.dropdown_selected.previousSibling) {
(this.dropdown_selected.previousSibling as HTMLElement).classList.add('selected');
this.dropdown_selected.classList.remove('selected');
this.dropdown_selected = this.dropdown_selected.previousSibling as HTMLElement;
// support scrolling
if (this.dropdown_selected.offsetTop < this.dropdown_list.scrollTop) {
this.dropdown_list.scrollTop = this.dropdown_selected.offsetTop;
}
}
}
}
});
this.dropdown_list.addEventListener('mousedown', (event) => {
const target = event.target as HTMLElement;
if (event.target === this.dropdown_list) {
return;
}
event.stopPropagation();
event.preventDefault();
this.container?.focus();
this.dropdown_caret.setAttribute('class', 'treb-dropdown-caret');
if (this.dropdown_callback) {
// this.dropdown_callback.call(0, (target as any).dropdown_value);
const value = target.dataset.dropdown_value;
this.dropdown_callback.call(0, value ? JSON.parse(value) : undefined);
}
});
this.dropdown_list.addEventListener('mousemove', (event) => {
const target = event.target as HTMLElement;
if (target === this.dropdown_selected) {
return;
}
this.grid_cover.classList.remove('nub-select');
if (this.dropdown_selected) {
this.dropdown_selected.classList.remove('selected');
}
target.classList.add('selected');
this.dropdown_selected = target as HTMLElement;
});
this.mock_selection = DOM.Div('mock-selection-node', undefined, {
html: ' ',
});
// this.mock_selection.innerHTML = ' ';
this.note_node = DOM.Div('treb-note');
this.title_node = DOM.Div('treb-hover-title');
this.sort_button = DOM.Create(
'button',
'treb-sort-button', undefined, { attrs: { title: 'Sort table', tabindex: '-1' }});
this.HideNote();
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public FocusInLayout(target?: EventTarget): boolean {
return false;
}
/**
* if the DPR has changed, update it and return true. otherwise return
* false. this is used on resize events: if the scale has changed, we
* probably want to repaint (and we need to update scale).
*/
public UpdateDPR(): boolean {
const dpr = Math.max(1, self.devicePixelRatio || 1);
if (dpr === this.dpr) {
return false;
}
this.dpr = dpr;
return true;
}
/** wrapper around sheet method, incorporating scale */
public ColumnWidth(column: number): number {
return Math.round(this.view.active_sheet.GetColumnWidth(column) * this.scale);
}
/** wrapper around sheet method, incorporating scale */
public RowHeight(row: number): number {
return Math.round(this.view.active_sheet.GetRowHeight(row) * this.scale);
}
/**
* wrapper around sheet method, incorporating scale
*
* NOTE: this does not update total size, so unless there's a subsequent call
* to a layout update, total size will be out of sync
*/
public SetRowHeight(row: number, height: number): void {
this.view.active_sheet.SetRowHeight(row, Math.round(height / this.scale));
}
/**
* wrapper around sheet method, incorporating scale
*
* NOTE: this does not update total size, so unless there's a subsequent call
* to a layout update, total size will be out of sync
*/
public SetColumnWidth(column: number, width: number): void {
this.view.active_sheet.SetColumnWidth(column, Math.round(width / this.scale));
}
/**
* show/hide grid selections. used when selecting annotations.
*/
public ShowSelections(show = true): void {
this.grid_selection.style.display = show ? 'block' : 'none';
}
public HideTitle(): void {
this.title_node.style.opacity = '0';
// this.title_node.style.pointerEvents = 'none';
}
public ShowTitle(text: string, address: ICellAddress /*, event?: MouseEvent */): void {
this.title_node.textContent = text;
if (!this.title_node.parentElement) return;
// const note_size = this.title_node.getBoundingClientRect();
const container = this.title_node.parentElement.getBoundingClientRect();
const rect = this.OffsetCellAddressToRectangle(address).Shift(
this.header_size.width, this.header_size.height);
this.title_node.style.left = (
container.left + rect.left - this.scroll_reference_node.scrollLeft + 0) + 'px';
this.title_node.style.top = (
container.top + rect.bottom - this.scroll_reference_node.scrollTop + 8) + 'px';
// FIXME: use class
this.title_node.style.opacity = '1';
// this.title_node.style.pointerEvents = 'auto';
}
public HideNote(): void {
// FIXME: use class
this.note_node.style.opacity = '0';
this.note_node.style.pointerEvents = 'none';
}
public HideTableSortButton() {
this.sort_button.style.opacity = '0';
this.sort_button.style.pointerEvents = 'none';
this.sort_button.setAttribute('tabindex', '-1');
}
public AreaToRect(area: IArea, offset?: {x: number, y: number}) {
const start = this.OffsetCellAddressToRectangle(area.start);
const end = this.OffsetCellAddressToRectangle(area.end);
return new Rectangle(start.left + (offset?.x || 0), start.top + (offset?.y || 0), end.right - start.left, end.bottom - start.top);
}
public async Screenshot({
type = 'png', quality, download, selection, target, nub,
}:
{
type?: 'png'|'jpeg'|'webp',
quality?: number,
download?: boolean,
selection?: Area,
target?: Area,
nub?: boolean,
}){
const left = this.scroll_reference_node.scrollLeft;
const width = this.scroll_reference_node.clientWidth; // this.scroll_reference_node.offsetWidth;
const right = left + width;
const top = this.scroll_reference_node.scrollTop;
const height = this.scroll_reference_node.clientHeight; // this.scroll_reference_node.offsetHeight
const bottom= top + height;
// console.info({left, width, top, height});
// create a temporary canvas
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const context = canvas.getContext('2d', { alpha: false });
if (context) {
context.fillStyle = 'yellow';
context.fillRect(0, 0, width, height);
for (const column of this.grid_tiles) {
for (const cell of column) {
if (cell.pixel_end.x < left || cell.pixel_start.x > right) {
continue;
}
if (cell.pixel_end.y < top || cell.pixel_start.y > bottom) {
continue;
}
const paint_left =
this.header_size.width + cell.pixel_start.x - left;
const paint_top =
this.header_size.height + cell.pixel_start.y - top;
const paint_width = cell.pixel_end.x - cell.pixel_start.x;
const paint_height = cell.pixel_end.y - cell.pixel_start.y;
// console.info("DRAWING CELL", cell, 0, 0, paint_left, paint_top);
context.drawImage(
cell,
0, 0,
paint_width, paint_height,
paint_left,
paint_top,
paint_width, paint_height,
);
}
}
for (const cell of this.row_header_tiles) {
if (cell.pixel_end.y < top || cell.pixel_start.y > bottom) {
continue;
}
const paint_left = 0;
const paint_top = this.header_size.height + cell.pixel_start.y - top;
const paint_width = cell.pixel_end.x - cell.pixel_start.x;
const paint_height = cell.pixel_end.y - cell.pixel_start.y;
context.drawImage(
cell,
0, 0,
paint_width, paint_height,
paint_left,
paint_top,
paint_width, paint_height,
);
}
for (const cell of this.column_header_tiles) {
if (cell.pixel_end.x < left || cell.pixel_start.y > right) {
continue;
}
const paint_left = this.header_size.width + cell.pixel_start.x - left;
const paint_top = 0;
const paint_width = cell.pixel_end.x - cell.pixel_start.x;
const paint_height = cell.pixel_end.y - cell.pixel_start.y;
context.drawImage(
cell,
0, 0,
paint_width, paint_height,
paint_left,
paint_top,
paint_width, paint_height,
);
}
{
let paint_left = 0;
let paint_top = 0;
let paint_width = this.header_size.width;
let paint_height = this.header_size.height;
context.drawImage(
this.corner_canvas,
0, 0,
paint_width, paint_height,
paint_left,
paint_top,
paint_width, paint_height,
);
}
// set clip before rendering selections, annotations
context.save();
context.beginPath();
context.rect(this.header_size.width, this.header_size.height, width, height);
// context.clip();
if (selection) {
/////
const styles = getComputedStyle(this.grid_selection);
const selection_color = styles.getPropertyValue('--treb-selection-color');
const selection_opacity = styles.getPropertyValue('--treb-selection-fill-opacity');
const default_color = '#4caaf1';
const default_opacity = 0.1;
//////
const offset = {
x: this.header_size.width - left,
y: this.header_size.height - top,
};
context.fillStyle = context.strokeStyle = selection_color || default_color;
context.lineWidth = 2;
context.globalAlpha = selection_opacity ? Number(selection_opacity) : default_opacity;
for (const cell of selection) {
if (target?.Contains(cell)) {
continue;
}
const rect = this.AreaToRect(new Area(cell), offset);
context.fillRect(rect.left, rect.top, rect.width, rect.height);
}
const rect = this.AreaToRect(selection, offset);
context.globalAlpha = 1;
context.strokeRect(
rect.left, rect.top, rect.width, rect.height
);
if (nub) {
context.fillRect(
rect.right - 3, rect.bottom - 3, 6, 6,
);
}
}
else {
// console.info("No selection?", selection);
}
const PaintBorder = (position: {top: number, left: number, width: number, height: number}) => {
context.strokeStyle = '#999';
context.lineWidth = 1;
context.strokeRect(position.left, position.top, position.width, position.height);
};
const PaintSVG = async (
position: {top: number, left: number, width: number, height: number},
svg: SVGElement,
border = true,
) => {
let clone = (SerializeHTML(svg as Element) as HTMLElement).outerHTML;
/*
if (add_view_box) {
console.info("AVB");
clone = clone.replace(/((resolve, reject) => {
const url = URL.createObjectURL(blob);
const img = new Image();
img.onload = () => {
context.drawImage(img, position.left, position.top);
if (border) {
PaintBorder(position);
}
URL.revokeObjectURL(url);
resolve();
};
img.onerror = reject;
img.src = url;
});
};
/*
if (this.grid_selection) {
const rect = this.grid_selection.getBoundingClientRect();
const position = {
top: -top, // this.grid_selection.offsetTop + this.header_size.height - top,
left: -left, // element.offsetLeft + this.header_size.width - left,
width: rect.width,
height: rect.height,
};
// our selections don't have a viewbox, which
// breaks painting
const viewbox = `viewBox="0 0 ${rect.width} ${rect.height}" `;
await PaintSVG(position, this.grid_selection, false, viewbox);
}
*/
const annotation_elements = Array.from(this.annotation_container.children);
if (annotation_elements.length) {
for (const element of annotation_elements) {
if (!(element instanceof HTMLElement)) {
continue;
}
const rect = element.getBoundingClientRect();
const position = {
top: element.offsetTop + this.header_size.height - top,
left: element.offsetLeft + this.header_size.width - left,
width: rect.width,
height: rect.height,
};
const svg_child = element.querySelector('.annotation-content svg');
if (svg_child) {
await PaintSVG(position, svg_child as SVGElement);
}
else {
const image_child = element.querySelector('.annotation-content img');
if (image_child instanceof HTMLImageElement) {
context.drawImage(image_child, position.left, position.top, position.width, position.height);
PaintBorder(position);
}
}
}
}
const dataurl = canvas.toDataURL('image/' + type, quality);
if (download) {
const link = document.createElement('a');
link.href = dataurl;
link.download = 'screenshot.' + type; // This attribute forces the download
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
return dataurl;
}
return undefined;
}
public ShowTableSortButton(table: Table, column: number, address: ICellAddress): void {
if (!this.sort_button.parentElement) {
return;
}
let asc = true;
let initial = false;
if (table.sort) {
if (table.sort.column === column) {
asc = !table.sort.asc;
initial = true;
}
}
this.sort_button.setAttribute('tabindex', '0 ');
this.sort_button.style.opacity = '1';
this.sort_button.style.pointerEvents = 'initial';
this.sort_button.classList.remove('asc', 'desc');
if (initial) {
this.sort_button.classList.add(asc ? 'asc' : 'desc');
}
this.sort_button.dataset.asc = asc.toString();
this.sort_button.dataset.table = table.name;
this.sort_button.dataset.column = column.toString();
const rect = this.OffsetCellAddressToRectangle(address).Shift(
this.header_size.width, this.header_size.height);
const button_size = this.sort_button.getBoundingClientRect();
// const container = this.sort_button.parentElement.getBoundingClientRect();
// const offset = { x: 8, y: 2 };
this.sort_button.style.left = (rect.right - button_size.width - button_size.width / 2) + 'px';
this.sort_button.style.top =
(rect.top + (rect.height - button_size.height) / 2) + 'px';
}
/**
* adding html parameter at the end, so we can keep the function
* signature otherwise the same. this is for markdown formatting.
*/
public ShowNote(note: string, address: ICellAddress, event?: MouseEvent, markdown?: string): void {
// UPDATE for MD (optional)
if (markdown) {
this.note_node.innerHTML = markdown;
}
else {
this.note_node.textContent = note;
}
if (!this.note_node.parentElement) return;
const note_size = this.note_node.getBoundingClientRect();
const container = this.note_node.parentElement.getBoundingClientRect();
const offset = { x: 8, y: 2 };
const rect = this.OffsetCellAddressToRectangle(address).Shift(
this.header_size.width, this.header_size.height);
this.note_node.style.left = (
container.left + rect.right - this.scroll_reference_node.scrollLeft + offset.x) + 'px';
this.note_node.style.top = (
container.top + rect.top - this.scroll_reference_node.scrollTop - (note_size.height / 5) - offset.y) + 'px';
// FIXME: use class
this.note_node.style.opacity = '1';
this.note_node.style.pointerEvents = 'auto';
}
/* * needed for IE11, legacy only * /
public FixBrokenSelection() {
// ...
}
*/
/**
* raise or lower annotation in z-order (implicit)
*
* returns true if we've made changes, so you can trigger any necessary
* events or side-effects
*/
public AnnotationLayoutOrder(annotation: Annotation, delta: number): boolean {
// find index
let index = -1;
for (let i = 0; i < this.view.active_sheet.annotations.length; i++) {
if (this.view.active_sheet.annotations[i] === annotation) {
index = i;
break;
}
}
if (index < 0) {
return false; // not found
}
const target = Math.min(Math.max(0, index + delta), this.view.active_sheet.annotations.length - 1);
if (target === index) {
return false; // not moving (probably at edge)
}
// change in array order, so it's preserved
this.view.active_sheet.annotations.splice(index, 1);
this.view.active_sheet.annotations.splice(target, 0, annotation);
// update layout, use z-indexes
for (let i = 0; i < this.view.active_sheet.annotations.length; i++) {
// updating to shift frozen annotations as well
const key = this.view.active_sheet.annotations[i].key;
const elements = this.container?.querySelectorAll(`.annotation[data-key="${key}"]`)
if (elements) {
for (let j = 0; j < elements?.length; j++) {
(elements[j] as HTMLElement).style.zIndex = (i + 1).toString();
}
}
/*
const node = this.view.active_sheet.annotations[i].node;
if (node) {
node.style.zIndex = (i + 1).toString();
}
*/
}
return true;
}
/**
*
*/
public PointToAnnotationCorner(point: Point): Corner {
const address = this.PointToAddress_Grid(point, false);
const cell_rect = this.CellAddressToRectangle(address);
return {
address,
offset: {
x: (point.x - cell_rect.left) / cell_rect.width,
y: (point.y - cell_rect.top) / cell_rect.height,
},
};
}
/**
* utility for managing (new) annotation layout. we offset the {top, left}
* by {1, 1} pixel so that the alignment snaps to cell boundaries.
*/
public RectToAnnotationLayout(rect: Partial): AnnotationLayout {
return {
tl: this.PointToAnnotationCorner({ x: (rect.left || 0) + 1, y: (rect.top || 0) + 1 }),
br: this.PointToAnnotationCorner({ x: rect.right || rect.left || 100, y: rect.bottom || rect.top || 100 }),
};
}
public AddressToAnnotationLayout(tl: ICellAddress, br: ICellAddress): AnnotationLayout {
const rects = {
tl: this.CellAddressToRectangle(tl),
br: this.CellAddressToRectangle(br),
};
return {
tl: this.PointToAnnotationCorner({ x: (rects.tl.left || 0), y: (rects.tl.top || 0) }),
br: this.PointToAnnotationCorner({ x: rects.br.right || rects.tl.left || 100, y: rects.br.bottom || rects.tl.left || 100 }),
};
}
/**
* @see RectToAnnotationLayout regarding the 1 pixel shift
*/
public AnnotationLayoutToRect(layout: AnnotationLayout): Rectangle {
const tl = this.CellAddressToRectangle(layout.tl.address);
const br = this.CellAddressToRectangle(layout.br.address);
const left = tl.left + tl.width * layout.tl.offset.x - 1;
const top = tl.top + tl.height * layout.tl.offset.y - 1;
return new Rectangle(
left, top,
br.left + br.width * layout.br.offset.x - left,
br.top + br.height * layout.br.offset.y - top,
);
}
public UpdateAnnotation(elements: Annotation | Annotation[], theme: Theme): void {
if (!Array.isArray(elements)) elements = [elements];
for (const annotation of elements) {
const view = annotation.view[this.view.view_index] || {};
if (view.node) {
/*
if (annotation.node.dataset.scale && annotation.node.dataset.scale !== this.scale.toString()) {
console.info('scale out of sync');
}
*/
let font_size_pt = 10 * this.scale;
// console.info(annotation.data.style);
if (annotation.data.style?.font_size?.unit === 'em') {
font_size_pt *= annotation.data.style.font_size.value;
// console.info("scaling up...", font_size_pt)
}
view.node.dataset.scale = this.scale.toString();
// maybe we should just reset and then apply?
if (annotation.data.style?.font_face && annotation.data.style.font_face.startsWith('stack:')) {
const name = annotation.data.style.font_face.substring(6);
const stack = theme.font_stacks[name];
if(stack) {
view.node.classList.add('treb-inherit-font');
view.node.style.fontFamily = stack.font || '';
// font_size_pt *= stack.scale;
if (stack.variants) {
view.node.style.fontVariant = stack.variants;
}
}
else {
view.node.classList.remove('treb-inherit-font');
view.node.style.fontFamily = '';
view.node.style.fontVariant = '';
}
}
else {
view.node.classList.remove('treb-inherit-font');
view.node.style.fontFamily = '';
view.node.style.fontVariant = '';
}
if (annotation.data.style?.bold) {
view.node.style.fontWeight = '600';
}
else {
view.node.style.fontWeight = '400';
}
if (annotation.data.style?.italic) {
view.node.style.fontStyle = 'italic';
}
else {
view.node.style.fontStyle = '';
}
if (annotation.data.style?.underline) {
view.node.style.textDecoration = 'underline';
}
else {
view.node.style.textDecoration = '';
}
view.node.style.fontSize = `${font_size_pt}pt`;
// update the layout here if necessary. after that it should
// be persisted (assuming it's saved). eventually this should
// be superfluous...
if (annotation.rect && !annotation.data.layout) {
// this is breaking on freeze when the spreadsheet is scrolled because
// the top-left uses the freeze panes. stop doing that.
annotation.scaled_rect = annotation.rect.Scale(this.scale);
annotation.data.layout = this.RectToAnnotationLayout(annotation.scaled_rect);
}
// FIXME: merge cells? [...]
if (annotation.data.layout) {
const rect = this.AnnotationLayoutToRect(annotation.data.layout);
rect.ApplyStyle(view.node);
// NOTE: we still set the scaled rect, because that's used in
// manipulating at scale. we will need to make sure that we update
// the layout when the scaled rect / regular rect changes...
annotation.scaled_rect = rect; // .Scale(this.scale);
}
view.node.dataset.key = annotation.key.toString();
// FIXME: only do this if necessary (if frozen).
if (this.view.active_sheet.freeze.rows || this.view.active_sheet.freeze.columns) {
this.CloneFrozenAnnotation(annotation);
}
}
}
}
/** returns a list of copies painted to frozen panes, for move/size */
public GetFrozenAnnotations(annotation: Annotation): HTMLElement[] {
const containers = [this.row_header_annotations, this.column_header_annotations, this.corner_annotations];
return containers.map((container) => container.querySelector(`.annotation[data-key="${annotation.key}"]`)).filter(test => test !== null) as HTMLElement[];
}
/**
* clone all annotations into freeze panes
*/
public CloneFrozenAnnotations(): void {
for (const annotation of this.view.active_sheet.annotations) {
const view = annotation.view[this.view.view_index];
if (view?.node && annotation.key) {
this.CloneFrozenAnnotation(annotation);
}
}
}
/**
* remove all annotations from freeze panes
*
*/
public ClearFrozenAnnotations(): void {
for (const container of [this.row_header_annotations, this.column_header_annotations, this.corner_annotations]) {
const elements = container.querySelectorAll('.annotation');
for (let i = 0; i < elements.length; i++) {
// FIXME: remove event listeners
elements[i].parentElement?.removeChild(elements[i]);
}
}
}
/**
* remove a frozen annotation
* @param annotation
*/
public RemoveFrozenAnnotation(annotation: Annotation): void {
for (const container of [this.row_header_annotations, this.column_header_annotations, this.corner_annotations]) {
const element = container.querySelector(`.annotation[data-key="${annotation.key}"]`);
if (element) {
// FIXME: remove event listeners
element.parentElement?.removeChild(element);
}
}
}
/**
* clone a single annotation. usually this will be used on create, but
* we batch them when we freeze panes (from unfrozen)
*/
public CloneFrozenAnnotation(annotation: Annotation): void {
for (const container of [this.row_header_annotations, this.column_header_annotations, this.corner_annotations]) {
// FIXME: could reuse? not sure it's worth it
let element: Element | Node | null | undefined = container.querySelector(`.annotation[data-key="${annotation.key}"]`);
if (element) {
element.parentElement?.removeChild(element);
}
const view = annotation.view[this.view.view_index];
element = view?.node?.cloneNode(true);
if (element) {
const move_target = (element as HTMLElement).querySelector('.annotation-move-target') as HTMLElement;
const resize_target = (element as HTMLElement).querySelector('.annotation-resize-target') as HTMLElement;
(element as HTMLElement).addEventListener('mousedown', (event: MouseEvent) => {
const node = view.node;
requestAnimationFrame(() => {
// console.info('calling focus on', node);
node?.focus();
});
this.AnnotationMouseDown(annotation, view.node as HTMLElement, event, move_target, resize_target);
});
container.appendChild(element);
}
}
}
public RemoveAnnotation(annotation: Annotation): void {
const view = annotation.view[this.view.view_index] || {};
if (view.node) {
view.node.parentElement?.removeChild(view.node);
}
this.RemoveFrozenAnnotation(annotation);
}
/**
* remove annotation nodes from the container, without impacting
* the underlying data. annotations will still retain nodes, they
* just won't be attached to anything.
*
* NOTE: IE destroys nodes if you do this? (...)
* patch in legacy... actually we'll do it here
*/
public RemoveAnnotationNodes(): void {
// we were using a shortcut, innerText = '', but if you do that
// in IE it destroys the nodes (!) -- so we need to explicitly
// remove them
// FIXME: we are explicitly adding them, why not just maintain a list?
const children = Array.prototype.map.call(
this.annotation_container.children, (node) => node) as HTMLElement[];
for (const child of children) {
this.annotation_container.removeChild(child);
}
if (this.view.active_sheet.freeze.rows || this.view.active_sheet.freeze.columns) {
this.ClearFrozenAnnotations();
}
}
public AddAnnotation(annotation: Annotation, theme: Theme): void {
const view = annotation.view[this.view.view_index] || {};
if (!view.node) {
throw new Error('annotation view/node missing');
}
this.annotation_container.appendChild(view.node);
this.UpdateAnnotation(annotation, theme);
}
// testing moving this here...
public AnnotationMouseDown(annotation: Annotation, node: HTMLElement, event: MouseEvent, move_target: HTMLElement, resize_target: HTMLElement): Promise {
// console.info('annotation mousedown (in layout)', annotation);
const rect = annotation.scaled_rect;
if (!rect) {
console.info('missing scaled rect!');
return Promise.reject(); // ?
}
return new Promise((resolve) => {
const origin = {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
};
const scroll_node = this.scroll_reference_node;
const scroll_rect = scroll_node.getBoundingClientRect();
const bounding_rect = node.getBoundingClientRect();
// IE11 is not targeting the child nodes? why not? (...)
// console.info('target', (event.target as HTMLElement)?.className);
if (event.target === move_target || (event.target !== resize_target && event.altKey)) {
event.stopPropagation();
event.preventDefault();
node.focus();
const offset = {
x: bounding_rect.left + event.offsetX - rect.left,
y: bounding_rect.top + event.offsetY - rect.top,
};
const elements = [node, ...this.GetFrozenAnnotations(annotation)];
const scroll_delta = 25;
const grid_rect =
this.CellAddressToRectangle({ row: 0, column: 0 }).Combine(
this.CellAddressToRectangle({
row: this.view.active_sheet.rows - 1,
column: this.view.active_sheet.columns - 1,
})).Expand(-1, -1);
MouseDrag(this.mask, 'move', (move_event) => {
// check if we are oob the grid
// FIXME: clamp annotation to cell bounds (...) this is OK for now though
if (move_event.offsetY - scroll_rect.top < this.header_offset.y) {
const delta = Math.min(scroll_delta, scroll_node.scrollTop);
scroll_node.scrollTop -= delta;
offset.y += delta;
}
else if (move_event.offsetY - scroll_rect.top >= scroll_rect.height) {
if (scroll_node.scrollTop + scroll_rect.height < grid_rect.height) {
const delta = scroll_delta;
scroll_node.scrollTop += delta;
offset.y -= delta;
}
}
if (move_event.offsetX - scroll_rect.left < this.header_offset.x) {
const delta = Math.min(scroll_delta, scroll_node.scrollLeft);
scroll_node.scrollLeft -= delta;
offset.x += delta;
}
else if (move_event.offsetX - scroll_rect.left >= scroll_rect.width) {
if (scroll_node.scrollLeft + scroll_rect.width < grid_rect.width) {
const delta = scroll_delta;
scroll_node.scrollLeft += delta;
offset.x -= delta;
}
}
rect.top = move_event.offsetY - offset.y;
rect.left = move_event.offsetX - offset.x;
if (move_event.shiftKey) {
// move in one direction at a time
const dx = Math.abs(rect.left - origin.left);
const dy = Math.abs(rect.top - origin.top);
if (dx <= dy) { rect.left = origin.left; }
else { rect.top = origin.top; }
}
if (move_event.ctrlKey) {
const point = this.ClampToGrid({
x: rect.left, y: rect.top,
});
rect.left = point.x;
rect.top = point.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
// annotation.rect = rect.Scale(1/this.scale);
annotation.data.layout = this.RectToAnnotationLayout(rect);
// this.grid_events.Publish({ type: 'annotation', annotation, event: 'move' });
resolve({ type: 'annotation', annotation, event: 'move' })
});
return;
}
else if (event.target === resize_target) {
//if ((bounding_rect.width - event.offsetX <= 13) &&
// (bounding_rect.height - event.offsetY <= 13)) {
event.stopPropagation();
event.preventDefault();
node.focus();
let aspect = 0;
if (annotation.data.type === 'image') {
if (annotation.data.data
&& annotation.data.data.original_size
&& annotation.data.data.original_size.width
&& annotation.data.data.original_size.height) {
aspect = annotation.data.data.original_size.width /
annotation.data.data.original_size.height;
}
}
const bounds = node.getBoundingClientRect();
const offset = {
x: bounds.left + event.offsetX - rect.width + resize_target.offsetLeft,
y: bounds.top + event.offsetY - rect.height + resize_target.offsetTop,
};
MouseDrag(this.mask, 'nw-resize', (move_event) => {
const elements = [node, ...this.GetFrozenAnnotations(annotation)];
rect.height = move_event.offsetY - offset.y;
rect.width = move_event.offsetX - offset.x;
if (move_event.shiftKey && move_event.ctrlKey) {
if (aspect) {
const dx = Math.abs(rect.width - origin.width);
const dy = Math.abs(rect.height - origin.height);
if (dx < dy) {
rect.width = aspect * rect.height;
}
else {
rect.height = rect.width / aspect;
}
}
}
else if (move_event.shiftKey) {
// move in one direction at a time [is this backwards? ...]
const dx = Math.abs(rect.height - origin.height);
const dy = Math.abs(rect.width - origin.width);
if (dx > dy) { rect.width = origin.width; }
else { rect.height = origin.height; }
}
else if (move_event.ctrlKey) {
const point = this.ClampToGrid({
x: rect.right, y: rect.bottom,
});
rect.width = point.x - rect.left + 1;
rect.height = point.y - rect.top + 1;
}
// node.style.height = (rect.height) + 'px';
// node.style.width = (rect.width) + 'px';
for (const element of elements) {
element.style.height = (rect.height) + 'px';
element.style.width = (rect.width) + 'px';
}
}, () => {
annotation.data.extent = undefined; // reset
// annotation.rect = rect.Scale(1/this.scale);
annotation.data.layout = this.RectToAnnotationLayout(rect);
// this.grid_events.Publish({ type: 'annotation', annotation, event: 'resize' });
resolve({ type: 'annotation', annotation, event: 'resize' });
});
return;
}
else {
resolve();
}
});
}
/**
* this used to be an abstract method for initializing. we're taking it
* over to do some additional work post init, and renaming the subclass-specific
* method (@see InitializeInternal).
*/
public Initialize(container: HTMLElement, callbacks: {
scroll: () => void,
dropdown: (value: CellValue) => void,
sort: (table: string, column: number, asc: boolean) => void,
focus: () => void,
},
// scroll_callback: () => void,
// dropdown_callback: (value: CellValue) => void,
// sort_callback: (table: string, column: number, asc: boolean) => void,
// focus_callback: () => void,
scroll = true): void {
// this is getting stuck under the toolbar... we need better z-stacking
// and this needs to be higher up in the node list
if (!this.mask.parentElement) {
container.parentElement?.parentElement?.appendChild(this.mask);
}
//if (!this.error_highlight.parentElement) {
// container.appendChild(this.error_highlight);
//}
if (!this.tooltip.parentElement) {
container.appendChild(this.tooltip);
}
// FIXME: -> instance specific, b/c trident
if (!this.spill_border.parentElement) {
container.appendChild(this.spill_border);
}
if (!this.dropdown_caret.parentElement) {
container.appendChild(this.dropdown_caret);
}
if (!this.dropdown_list.parentElement) {
container.appendChild(this.dropdown_list);
}
if (!this.note_node.parentElement) {
container.appendChild(this.note_node);
}
if (!this.sort_button.parentElement) {
container.appendChild(this.sort_button);
this.sort_button.addEventListener('click', () => {
// console.info(this.sort_button.dataset);
callbacks.sort(
this.sort_button.dataset.table || '',
Number(this.sort_button.dataset.column || '0') || 0,
/true/i.test(this.sort_button.dataset.asc || ''));
this.sort_button.classList.remove('asc', 'desc');
if (this.sort_button.dataset.asc === 'true') {
this.sort_button.dataset.asc = 'false';
this.sort_button.classList.add('desc');
}
else {
this.sort_button.dataset.asc = 'true';
this.sort_button.classList.add('asc');
}
callbacks.focus();
});
}
if (!this.title_node.parentElement) {
container.appendChild(this.title_node);
}
this.InitializeInternal(container, callbacks.scroll);
if (!scroll && this.scroll_reference_node) {
this.scroll_reference_node.style.overflow = 'hidden';
}
this.dropdown_callback = callbacks.dropdown;
this.initialized = true;
}
/**
* create a selection so that this node (and parents) receive
* a copy event on ctrl+c (or any other system copy event).
* seems to break IE, so split.
*/
public MockSelection(): void {
if (!this.container) {
return;
}
// disable for IE, but leave in legacy renderer because it works
// in safari/edge. there may be some way to fix IE... although copy
// events aren't available, so we would have to do the fake-csv thing
// (which I don't want to do).
if (this.trident) {
return;
}
// edge handles this differently than chrome/ffx. in edge, the
// cursor does not move to the end of the selection, which is
// what we want. so we need to fix that for edge:
// FIXME: limit to edge (causing problems in chrome? ...)
if (this.DOM.doc) {
const selection = this.DOM.GetSelection();
if (selection) {
const range = this.DOM.doc.createRange();
range.selectNodeContents(this.mock_selection);
selection.removeAllRanges();
selection.addRange(range);
}
}
}
/**
* FIXME: this is public for now but tiles should move into
* this class, then this method can become private
*/
public CreateTile(
classes: string,
size: Size,
position: Position,
first_cell: Position,
cell_extent: Extent,
pixel_start: Point,
parent: HTMLElement,
mark_dirty = true): Tile {
const tile = this.DOM.Create('canvas') as Tile;
tile.setAttribute('class', classes);
tile.logical_size = size;
tile.width = size.width * this.dpr;
tile.height = size.height * this.dpr;
tile.style.width = `${size.width}px`;
tile.style.height = `${size.height}px`;
tile.tile_position = position;
tile.first_cell = first_cell;
this.UpdateTileGridPosition(tile);
tile.last_cell = {
row: first_cell.row + cell_extent.rows - 1,
column: first_cell.column + cell_extent.columns - 1,
};
tile.pixel_start = pixel_start;
tile.pixel_end = {
x: pixel_start.x + size.width,
y: pixel_start.y + size.height,
};
tile.dirty = !!mark_dirty;
tile.needs_full_repaint = true; // never painted
parent.appendChild(tile);
return tile;
}
public ApplyThemeColors() {
// what's the best node for this? (...)
if (this.container) {
for (const [index, entry] of this.applied_theme_colors.entries()) {
this.container.style.setProperty(`--treb-applied-theme-color-${index + 1}`, entry);
}
}
}
/**
* applies theme to nodes, as necessary
*/
public ApplyTheme(theme: Theme): void {
this.row_header.style.backgroundColor =
this.column_header.style.backgroundColor =
this.corner.style.backgroundColor =
theme.headers?.fill ? ResolveThemeColor(theme, theme.headers.fill, 0) : '';
// theme.headers?.background || '';
// theme.header_background_color || ''; // this.theme.header_background;
this.corner.style.borderColor =
theme.grid_color || ''; // this.theme.header_border_color;
// this.row_header.style.backgroundColor = this.theme.header_background;
// this.column_header.style.backgroundColor = this.theme.header_background;
for (const row of this.grid_tiles) {
for (const tile of row) {
tile.style.backgroundColor = ResolveThemeColor(theme, theme.grid_cell?.fill, 0) || '#fff';
}
}
// TODO: dropdown caret
this.dropdown_list.style.font = Style.CompositeFont(theme.grid_cell_font_size, {
font_face: 'stack:default',
}, this.scale, theme).font;
// testing
this.applied_theme_colors = theme.theme_colors?.slice(4, 10) || [];
if (this.container) {
this.ApplyThemeColors();
}
}
public UpdateTotalSize(): void {
this.total_height = 0;
const rows = this.view.active_sheet.rows;
for (let i = 0; i < rows; i++) {
this.total_height += this.RowHeight(i);
}
this.total_width = 0;
const columns = this.view.active_sheet.columns;
for (let i = 0; i < columns; i++) {
this.total_width += this.ColumnWidth(i);
}
}
public UpdateContentsSize(): void {
const height = this.row_header_tiles.reduce((a, tile) => a + tile.logical_size.height, 0);
const width = this.column_header_tiles.reduce((a, tile) => a + tile.logical_size.width, 0);
this.column_header.style.width = this.contents.style.width = `${width}px`;
this.row_header.style.height = this.contents.style.height = `${height}px`;
}
/** hides column/row resize tooltip and removes any specific classes */
public HideTooltip(): void {
this.tooltip.style.display = 'none';
this.tooltip_state = undefined;
this.tooltip.classList.remove('arrow-up');
this.tooltip.classList.remove('arrow-left');
}
/*
highlight error removed in favor of container errors, event reporting
* briefly flash red, to indicate an error * /
public HighlightError(address: ICellAddress): void {
const target_rect = this.OffsetCellAddressToRectangle(address).Shift(
this.header_size.width, this.header_size.height);
target_rect.ApplyStyle(this.error_highlight);
this.error_highlight.style.opacity = '1';
// we don't like to rely on transitionend events. the concern is that
// if they overlap eventually one will get lost... because this can be
// triggered faster than the transition, we can almost always make that
// happen
if (this.error_highlight_timeout) {
clearTimeout(this.error_highlight_timeout);
}
this.error_highlight_timeout = setTimeout(() => {
this.error_highlight.style.opacity = '0';
this.error_highlight_timeout = undefined;
}, 250)
}
*/
/** show column/row resize tooltip */
public ShowTooltip(options: TooltipOptions = {}): void {
if (options.up) {
this.tooltip.classList.add('arrow-up');
this.tooltip_state = 'up';
}
else if (options.left) {
this.tooltip.classList.add('arrow-left');
this.tooltip_state = 'left';
}
this.tooltip.style.display = 'block';
this.UpdateTooltip(options);
}
public ShowDropdownCaret(area: Area, list: CellValue[], current: CellValue): void {
let target_rect = this.OffsetCellAddressToRectangle(area.start);
if (area.count > 1) {
target_rect = target_rect.Combine(this.OffsetCellAddressToRectangle(area.end));
}
target_rect = target_rect.Shift(
this.header_size.width, this.header_size.height);
// FIXME: max size? (...)
const height = Math.round(this.scale * Math.max(8, Math.min(20, target_rect.height)));
this.dropdown_caret.style.height = `${height}px`;
this.dropdown_caret.style.width = `${height}px`;
this.dropdown_caret.style.left = `${target_rect.right + 1}px`;
this.dropdown_caret.style.top = `${target_rect.bottom - height}px`;
this.dropdown_list.style.top = `${target_rect.bottom + 2}px`;
this.dropdown_list.style.left = `${target_rect.left + 2}px`;
this.dropdown_list.style.minWidth = `${target_rect.width}px`;
this.dropdown_list.style.fontSize = (this.scale.toFixed(2) + 'em');
this.dropdown_list.textContent = '';
for (const value of list) {
const entry = this.DOM.Div(undefined, this.dropdown_list);
if (current === value) {
this.dropdown_selected = entry;
entry.classList.add('selected');
}
// we're attaching random data to DOM nodes here. that works, but
// it's sloppy. I think the reason is we want to preserve type, and
// this is simpler than any other solution.
// (entry as any).dropdown_value = value;
entry.dataset.dropdown_value = JSON.stringify(value);
entry.textContent = value?.toString() || '';
}
//this.dropdown_caret.classList.remove('active');
this.dropdown_caret.setAttribute('class', 'treb-dropdown-caret');
this.dropdown_caret.style.display = 'block';
this.dropdown_caret_visible = true;
}
public ShowSpillBorder(area?: IArea) {
this.spill_border.textContent = '';
if (area) {
const resolved = new Area(area.start, area.end);
let target_rect = this.OffsetCellAddressToRectangle(resolved.start);
if (resolved.count > 1) {
target_rect = target_rect.Combine(this.OffsetCellAddressToRectangle(resolved.end));
}
target_rect = target_rect.Shift(
this.header_size.width, this.header_size.height);
this.spill_border.style.display = 'block';
this.spill_border.style.top = (target_rect.top - 5).toString() + 'px';
this.spill_border.style.left = (target_rect.left - 5).toString() + 'px';
this.spill_border.style.width = (target_rect.width + 10).toString() + 'px';
this.spill_border.style.height = (target_rect.height + 10).toString() + 'px';
const rect = this.DOM.SVG('rect', undefined, this.spill_border);
rect.setAttribute('x', '4.5');
rect.setAttribute('y', '4.5');
rect.setAttribute('width', (target_rect.width + 1).toString());
rect.setAttribute('height', (target_rect.height + 1).toString());
}
else {
this.spill_border.style.display = 'none';
}
}
public HideDropdownCaret(): void {
if (this.dropdown_caret_visible) {
// this.dropdown_caret.classList.remove('active');
this.dropdown_caret.setAttribute('class', 'treb-dropdown-caret');
this.dropdown_caret_visible = false;
this.dropdown_caret.style.display = 'none';
}
}
/**
* this method returns the scroll offset adjusted for headers.
* if you just want the raw scroll offset, use the accessor.
*
* @param offset_headers
* @returns
*/
public GetScrollOffset(): Point {
return {
x: this.scroll_reference_node.scrollLeft + this.header_offset.x,
y: this.scroll_reference_node.scrollTop + this.header_offset.y,
};
}
public ScrollTo(address: ICellAddress, x = true, y = true, smooth = false): void {
const target_rect = this.CellAddressToRectangle(address);
if (smooth && !!this.scroll_reference_node.scrollTo) {
const current = {
left: this.scroll_reference_node.scrollLeft,
top: this.scroll_reference_node.scrollTop,
};
const options: ScrollToOptions = {
left: x ? target_rect.left : current.left,
top: y ? target_rect.top : current.top,
behavior: 'smooth',
}
this.scroll_reference_node.scrollTo(options);
}
else {
if (y) {
this.scroll_reference_node.scrollTop = target_rect.top;
}
if (x) {
this.scroll_reference_node.scrollLeft = target_rect.left;
}
}
}
/**
* scroll address into view, at top-left or bottom-right depending on
* target and current position. also offsets for frozen rows, columns.
*/
public ScrollIntoView(address: ICellAddress, smooth = false): void {
const target_rect = this.CellAddressToRectangle(address);
const width = this.scroll_reference_node.clientWidth - this.row_header.offsetWidth;
const height = this.scroll_reference_node.clientHeight - this.column_header.offsetHeight;
const offset = { x: 0, y: 0 };
const lock = { x: false, y: false };
const viewport = new Rectangle(
this.scroll_reference_node.scrollLeft,
this.scroll_reference_node.scrollTop,
width, height);
// if there are frozen rows/columns, we need to scroll such that the
// cell is visible outside of the frozen area. but only if we're *outside*
// the frozen area, because otherwise we're on screen essentially by default.
if (this.view.active_sheet.freeze.rows || this.view.active_sheet.freeze.columns) {
if (this.view.active_sheet.freeze.rows && address.row >= this.view.active_sheet.freeze.rows) {
offset.y = this.frozen_row_tiles[0].logical_size.height;
}
else if (this.view.active_sheet.freeze.rows) lock.y = true;
if (this.view.active_sheet.freeze.columns && address.column >= this.view.active_sheet.freeze.columns) {
offset.x = this.frozen_column_tiles[0].logical_size.width;
}
else if (this.view.active_sheet.freeze.columns) lock.x = true;
}
// NOTE: in theory it's possible we scroll twice, which would result
// in two scroll events. however in practice this is called on key events,
// so it's unlikely.
const options: ScrollToOptions = {
behavior: smooth ? 'smooth' : 'auto',
};
if (address.row !== Infinity) {
if (target_rect.top < viewport.top + offset.y && !lock.y) {
// this.scroll_reference_node.scrollTop = target_rect.top - offset.y;
options.top = target_rect.top - offset.y;
}
else if (target_rect.bottom > viewport.bottom) {
// this.scroll_reference_node.scrollTop = target_rect.bottom - height;
options.top = target_rect.bottom - height;
}
}
if (address.column !== Infinity) {
if (target_rect.left < viewport.left + offset.x && !lock.x) {
// this.scroll_reference_node.scrollLeft = target_rect.left - offset.x;
options.left = target_rect.left - offset.x;
}
else if (target_rect.right > viewport.right) {
// this.scroll_reference_node.scrollLeft = target_rect.right - width;
options.left = target_rect.right - width;
}
}
this.scroll_reference_node.scrollTo(options);
}
public UpdateTooltip(options: TooltipOptions = {}): void {
if (typeof options.text !== 'undefined') {
this.tooltip.textContent = options.text;
}
if (typeof options.x !== 'undefined') {
let x = options.x || 0;
if (this.tooltip_state === 'up') {
x -= this.tooltip.offsetWidth / 2;
}
this.tooltip.style.left = Math.round(x) + 'px';
}
if (typeof options.y !== 'undefined') {
let y = options.y || 0;
if (this.tooltip_state === 'left') {
y -= this.tooltip.offsetHeight / 2;
}
this.tooltip.style.top = Math.round(y) + 'px';
}
}
/**
* y coordinate to row header. for consistency we return an address.
*/
public CoordinateToRowHeader(y: number): ICellAddress {
const result = { column: Infinity, row: 0 };
if (this.view.active_sheet.freeze.rows &&
this.frozen_row_tiles[0].pixel_end.y >= y - this.scroll_reference_node.scrollTop) {
let height = 0;
y -= this.scroll_reference_node.scrollTop;
for (let i = 0; i < this.view.active_sheet.freeze.rows; i++) {
height += this.RowHeight(i);
if (height >= y) {
result.row = i;
return result;
}
}
}
for (const tile of this.row_header_tiles) {
if (tile.pixel_end.y >= y) {
// now map within the tile
let top = y - tile.pixel_start.y;
let height = 0;
result.row = tile.first_cell.row;
for (; result.row <= tile.last_cell.row; result.row++, top -= height) {
height = this.RowHeight(result.row);
if (height > top) {
return result;
}
}
return result;
}
}
return result;
}
/**
* x coordinate to colum header. for consistency we return an address.
*/
public CoordinateToColumnHeader(x: number): ICellAddress {
const result = { row: Infinity, column: 0 };
if (this.view.active_sheet.freeze.columns &&
this.frozen_column_tiles[0].pixel_end.x >= x - this.scroll_reference_node.scrollLeft) {
let width = 0;
x -= this.scroll_reference_node.scrollLeft;
for (let i = 0; i < this.view.active_sheet.freeze.columns; i++) {
width += this.ColumnWidth(i);
if (width >= x) {
result.column = i;
return result;
}
}
}
for (const tile of this.column_header_tiles) {
if (tile.pixel_end.x >= x) {
// now map within the tile
let left = x - tile.pixel_start.x;
let width = 0;
result.column = tile.first_cell.column;
for (; result.column <= tile.last_cell.column; result.column++, left -= width) {
width = this.ColumnWidth(result.column);
if (width > left) return result;
}
return result;
}
}
return result;
}
/**
* point to cell address (grid only)
*
* FIXME: implement cap_maximum parameter (not sure where we would need it)
*/
public PointToAddress_Grid(point: Point, offset_freeze = true): ICellAddress {
// offset for freeze pane
if (offset_freeze) {
if (this.view.active_sheet.freeze.rows) {
const frozen_height = this.frozen_row_tiles[0].logical_size.height;
if (point.y - this.scroll_reference_node.scrollTop < frozen_height) {
point.y -= this.scroll_reference_node.scrollTop;
}
}
if (this.view.active_sheet.freeze.columns) {
const frozen_width = this.frozen_column_tiles[0].logical_size.width;
if (point.x - this.scroll_reference_node.scrollLeft < frozen_width) {
point.x -= this.scroll_reference_node.scrollLeft;
}
}
}
// we used to find the containing tile and then calculate row and column.
// to support overflow, we now have two separate loops.
const result = {
row: 0,
column: 0,
};
// FIXME: these could be cached when created
const last_column = this.grid_tiles[this.grid_tiles.length - 1];
const last_tile = last_column[last_column.length - 1];
// ---- find row -----------------------------------------------------------
if (point.y > last_tile.pixel_end.y) {
// overflow case
let top = point.y - last_tile.pixel_end.y;
result.row = last_tile.last_cell.row;
while (top > 0) {
result.row++;
top -= this.default_row_height;
}
}
else {
// normal behavior
for (const cell of last_column) {
if (cell.pixel_start.y <= point.y && cell.pixel_end.y >= point.y) {
let top = point.y - cell.pixel_start.y;
let height = 0;
result.row = cell.first_cell.row;
for (; result.row <= cell.last_cell.row; result.row++, top -= height) {
height = this.RowHeight(result.row);
if (height > top) {
break;
}
}
break;
}
}
}
// ---- find column --------------------------------------------------------
if (point.x > last_tile.pixel_end.x) {
// overflow case
let left = point.x - last_tile.pixel_end.x;
result.column = last_tile.last_cell.column;
while (left > 0) {
result.column++;
left -= this.default_column_width;
}
}
else {
// normal behavior
for (const column of this.grid_tiles) {
if (column[0].pixel_start.x <= point.x && column[0].pixel_end.x >= point.x) {
const cell = column[0];
let left = point.x - cell.pixel_start.x;
let width = 0;
result.column = cell.first_cell.column;
for (; result.column <= cell.last_cell.column; result.column++, left -= width) {
width = this.ColumnWidth(result.column);
if (width > left) {
break;
}
}
break;
}
}
}
return result;
}
/**
* get an adjacent tile. this is used by the renderer when a merge or
* overflow runs out of the painted tile, and we need to paint it.
*/
public AdjacentTile(tile: Tile, row_offset = 0, column_offset = 0): Tile|undefined {
if (!row_offset && !column_offset) {
return tile;
}
const position = tile.tile_position;
const row = tile.tile_position.row + row_offset;
const column = tile.tile_position.column + column_offset;
if (row < 0 || column < 0) return undefined;
// check various stores for match
if (this.grid_tiles[position.column] && this.grid_tiles[position.column][position.row] === tile) {
if (this.grid_tiles[column]) return this.grid_tiles[column][row];
}
if (!position.column && this.frozen_column_tiles[position.row] === tile) {
return this.frozen_column_tiles[row];
}
if (!position.row && this.frozen_row_tiles[position.column] === tile) {
return this.frozen_row_tiles[column];
}
return undefined;
}
public UpdateTiles(): void {
// so the new layout uses variable-sized tiles, which are sized
// to a number of rows/columns (FIXME: nearest to a given size?)
// that way we don't have to worry about overlap, and resizing
// is much easier.
// note that this doesn't mean there isn't overlapping rendering,
// because there will be on merges.
if (!this.container) throw new Error('invalid container');
// flush... FIXME: why not reuse? maybe more trouble than it's worth?
this.grid_tiles.forEach((arr) => {
arr.forEach((tile) => {
if (tile.parentElement) {
tile.parentElement.removeChild(tile);
}
});
});
for (const tileset of [
this.column_header_tiles,
this.row_header_tiles,
this.frozen_row_tiles,
this.frozen_column_tiles,
]) {
for (const tile of tileset) {
if (tile.parentElement) {
tile.parentElement.removeChild(tile);
}
}
}
/*
this.column_header_tiles.forEach((tile) => {
if (tile.parentElement) {
tile.parentElement.removeChild(tile);
}
});
this.row_header_tiles.forEach((tile) => {
if (tile.parentElement) {
tile.parentElement.removeChild(tile);
}
});
*/
this.frozen_row_tiles = [];
this.frozen_column_tiles = [];
this.row_header_tiles = [];
this.column_header_tiles = [];
this.grid_tiles = [];
// update local references (scaled). this has to be done before layout.
const sheet = this.view.active_sheet;
this.default_row_height = Math.round(sheet.default_row_height * this.scale);
this.default_column_width = Math.round(sheet.default_column_width * this.scale);
this.header_offset = {
x: Math.round(sheet.header_offset.x * this.scale),
y: Math.round(sheet.header_offset.y * this.scale),
};
this.UpdateContainingGrid();
let rows = this.view.active_sheet.rows;
let columns = this.view.active_sheet.columns;
if (!rows) rows = 100;
if (!columns) columns = 40;
// get total size of the grid from sheet
let total_height = 0;
let total_width = 0;
for (let i = 0; i < rows; i++) {
total_height += this.RowHeight(i);
}
for (let i = 0; i < columns; i++) {
total_width += this.ColumnWidth(i);
}
if (!total_width || !total_height) {
throw ('unexpected missing total size');
}
// console.info(`${rows} rows; total size: ${total_width} x ${total_height}`);
if (!total_height) total_height = this.default_row_height * rows;
if (!total_width) total_width = this.default_column_width * columns;
if (this.container.clientWidth > total_width + this.header_size.width) {
const add_columns = Math.ceil((this.container.offsetWidth - total_width) /
this.default_column_width);
total_width += add_columns * this.default_column_width;
columns += add_columns;
}
this.last_column = columns;
// FIXME: header size should be scaled?
if (this.container.clientHeight > total_height + this.header_size.height) {
const add_rows = Math.ceil((this.container.offsetHeight - total_height) /
this.default_row_height);
total_height += add_rows * this.default_row_height;
rows += add_rows;
}
// console.info(this.container.offsetWidth, this.container.offsetHeight)
// console.info('total size:', total_width, ', ', total_height);
// update node sizes to match
this.column_header.style.width = this.contents.style.width = `${total_width}px`;
this.row_header.style.height = this.contents.style.height = `${total_height}px`;
// generate a set of tiles given an approximate target size.
// keep track of the tile width/height and the starting column/row.
// rows:
const tile_widths: number[] = [];
const tile_columns: number[] = [];
let tile_width = 0;
let tile_column = 0;
for (let c = 0; c < columns; c++) {
const column_width = this.ColumnWidth(c);
if (tile_width && tile_width + column_width > this.default_tile_size.width) {
// push and move to the next tile, starting with this column
tile_widths.push(tile_width);
tile_columns.push(tile_column);
// for the next one, start here
tile_column = c;
tile_width = 0;
}
tile_width += column_width;
}
// last one
tile_widths.push(tile_width);
tile_columns.push(tile_column);
// columns:
const tile_heights: number[] = [];
const tile_rows: number[] = [];
let tile_height = 0;
let tile_row = 0;
for (let r = 0; r < rows; r++) {
const row_height = this.RowHeight(r);
if (tile_height && tile_height + row_height > this.default_tile_size.height) {
tile_heights.push(tile_height);
tile_rows.push(tile_row);
tile_height = 0;
tile_row = r;
}
tile_height += row_height;
}
tile_heights.push(tile_height);
tile_rows.push(tile_row);
// now create tiles and set metadata
const column_tile_count = tile_widths.length;
const row_tile_count = tile_heights.length;
let pixel_y = 0;
let pixel_x = 0;
let header_height = 0;
let header_width = 0;
for (let i = 0; i < this.view.active_sheet.freeze.rows; i++) {
header_height += this.RowHeight(i);
}
for (let i = 0; i < this.view.active_sheet.freeze.columns; i++) {
header_width += this.ColumnWidth(i);
}
for (let c = 0; c < column_tile_count; c++) {
const column: Tile[] = [];
pixel_y = 0; // reset
const column_extent = (c === column_tile_count - 1) ?
columns - tile_columns[c] :
tile_columns[c + 1] - tile_columns[c];
// create a column header tile for this column
this.column_header_tiles.push(this.CreateTile('column-header-tile',
{ height: this.header_offset.y, width: tile_widths[c] },
{ row: 0, column: c },
{ row: 0, column: tile_columns[c] },
{ rows: 0, columns: column_extent },
{ x: pixel_x, y: 0 },
this.column_header));
// also frozen
if (this.view.active_sheet.freeze.rows) {
this.frozen_row_tiles.push(this.CreateTile('frozen-row-tile',
{ height: header_height, width: tile_widths[c] },
{ row: 1, column: c },
{ row: 0, column: tile_columns[c] },
{ rows: 0, columns: column_extent },
{ x: pixel_x, y: 0 },
this.column_header));
}
// loop over rows
for (let r = 0; r < row_tile_count; r++) {
const row_extent = (r === row_tile_count - 1) ?
rows - tile_rows[r] :
tile_rows[r + 1] - tile_rows[r];
// first column, create header
if (!c) {
this.row_header_tiles.push(this.CreateTile('row-header-tile',
{ height: tile_heights[r], width: this.header_offset.x },
{ row: r, column: 0 },
{ row: tile_rows[r], column: 0 }, // first cell
{ rows: row_extent, columns: 1 },
{ x: 0, y: pixel_y },
this.row_header));
// also frozen
if (this.view.active_sheet.freeze.columns) {
this.frozen_column_tiles.push(this.CreateTile('frozen-column-tile',
{ height: tile_heights[r], width: header_width },
{ row: r, column: 1 },
{ row: tile_rows[r], column: 0 },
{ rows: row_extent, columns: 1 },
{ x: 0, y: pixel_y },
this.row_header));
}
}
column.push(this.CreateTile('grid-tile',
{ height: tile_heights[r], width: tile_widths[c] }, // tile size in pixels
{ row: r, column: c }, // grid position
{ row: tile_rows[r], column: tile_columns[c] }, // first cell
{ rows: row_extent, columns: column_extent }, // extent
{ x: pixel_x, y: pixel_y },
this.contents));
pixel_y += tile_heights[r];
}
this.grid_tiles.push(column);
pixel_x += tile_widths[c];
}
this.total_height = total_height;
this.total_width = total_width;
this.ClearLayoutCaches();
this.UpdateGridTemplates(true, true);
}
public ClearLayoutCaches(): void {
this.row_cache = [];
this.column_cache = [];
}
/**
* returns the tile index for a given column. this is used to map
* a column to a tile in either the header or the grid.
* FIXME: speed up w/ lookup cache
*/
public TileIndexForColumn(column: number): number {
for (const tile of this.column_header_tiles) {
if (tile.first_cell.column <= column && tile.last_cell.column >= column) {
return tile.tile_position.column;
}
}
return -1;
}
/**
* returns the tile index for a given row. this is used to map
* a column to a tile in either the header or the grid.
* FIXME: speed up w/ lookup cache
*/
public TileIndexForRow(row: number): number {
for (const tile of this.row_header_tiles) {
if (tile.first_cell.row <= row && tile.last_cell.row >= row) {
return tile.tile_position.row;
}
}
return -1;
}
/**
* marks header tiles as dirty, for next repaint call
*
* UPDATE fix for entire column/row/sheet (the Intersects() method
* doesn't support infinities, for some reason: FIX THAT)
*/
public DirtyHeaders(area?: Area): void {
if (!area) return;
// FIXME: visible only?
// why, who cares? render should be based on visible, not dirty
for (const tile of this.column_header_tiles) {
if (tile.dirty) continue;
const test: Area = new Area(
{ row: area.start.row, column: tile.first_cell.column },
{ row: area.start.row, column: tile.last_cell.column },
);
if (area.entire_row || test.Intersects(area)) {
tile.dirty = true;
}
}
for (const tile of this.row_header_tiles) {
if (tile.dirty) continue;
const test: Area = new Area(
{ column: area.start.column, row: tile.first_cell.row },
{ column: area.start.column, row: tile.last_cell.row },
);
if (area.entire_column || test.Intersects(area)) {
tile.dirty = true;
}
}
}
public DirtyAll(): void {
for (const column of this.grid_tiles) {
for (const tile of column) {
tile.dirty = true;
}
}
}
public DirtyArea(areas: IArea|IArea[]): void {
if (!this.initialized) return;
if (!Array.isArray(areas)) {
areas = [areas];
}
for (const area of areas) {
const start = { row: 0, column: 0 };
const end = { row: this.grid_tiles[0].length - 1, column: this.grid_tiles.length - 1 };
if (area.start.column !== Infinity) {
start.column = end.column = this.TileIndexForColumn(area.start.column);
if (area.end.column !== area.start.column) end.column = this.TileIndexForColumn(area.end.column);
}
if (area.start.row !== Infinity) {
start.row = end.row = this.TileIndexForRow(area.start.row);
if (area.end.row !== area.start.row) end.row = this.TileIndexForRow(area.end.row);
}
for (let column = start.column; column <= end.column; column++) {
for (let row = start.row; row <= end.row; row++) {
this.grid_tiles[column][row].dirty = true;
}
}
}
}
/** calculate first visible tile based on scroll position */
public VisibleTiles(): TileRange {
const tiles: Position[] = [{ row: 0, column: 0 }, { row: 0, column: 0 }];
if (!this.container || !this.grid_tiles.length || !this.grid_tiles[0].length) {
return new TileRange(tiles[0], tiles[1]); // too much?
}
const left = this.scroll_reference_node.scrollLeft;
const right = left + this.scroll_reference_node.offsetWidth;
const top = this.scroll_reference_node.scrollTop;
const bottom = top + this.scroll_reference_node.offsetHeight;
for (const column of this.grid_tiles) {
let cell = column[0];
if (cell.pixel_start.x <= left && cell.pixel_end.x >= left) {
for (cell of column) {
if (cell.pixel_start.y <= top && cell.pixel_end.y >= top) {
tiles[0] = cell.tile_position;
break;
}
}
}
if (column === this.grid_tiles[this.grid_tiles.length - 1] ||
cell.pixel_start.x <= right && cell.pixel_end.x >= right) {
for (cell of column) {
if (cell === column[column.length - 1] ||
cell.pixel_start.y <= bottom && cell.pixel_end.y >= bottom) {
tiles[1] = cell.tile_position;
// return tiles;
return new TileRange(tiles[0], tiles[1]); // too much?
}
}
}
}
// return tiles;
return new TileRange(tiles[0], tiles[1]); // too much?
}
public UpdateTileHeights(mark_dirty = true, start_row = -1): void {
let y = 0;
for (let i = 0; i < this.row_header_tiles.length; i++) {
const tile = this.row_header_tiles[i];
// const column = this.grid_tiles[i];
if (start_row > tile.last_cell.row) {
y += tile.logical_size.height;
continue;
}
let height = 0;
for (let r = tile.first_cell.row; r <= tile.last_cell.row; r++) {
height += this.RowHeight(r);
}
const height_match = (tile.logical_size.height === height);
tile.pixel_start.y = y;
y += height;
tile.pixel_end.y = y;
if (!height_match) {
tile.logical_size.height = height;
tile.style.height = `${height}px`;
tile.height = this.dpr * height;
if (this.view.active_sheet.freeze.columns) {
const frozen_tile = this.frozen_column_tiles[i];
frozen_tile.logical_size.height = height;
frozen_tile.style.height = `${height}px`;
frozen_tile.height = this.dpr * height;
}
if (mark_dirty) {
tile.dirty = true;
tile.needs_full_repaint = true;
}
}
for (const column of this.grid_tiles) {
const grid_tile = column[i];
grid_tile.pixel_start.y = tile.pixel_start.y;
grid_tile.pixel_end.y = tile.pixel_end.y;
if (!height_match) {
grid_tile.logical_size.height = height;
grid_tile.style.height = `${height}px`;
grid_tile.height = this.dpr * height;
if (mark_dirty) {
grid_tile.dirty = true;
grid_tile.needs_full_repaint = true;
}
}
}
}
if (this.view.active_sheet.freeze.rows) {
let freeze_height = 0;
for (let i = 0; i < this.view.active_sheet.freeze.rows; i++) freeze_height += this.RowHeight(i);
for (const tile of this.frozen_row_tiles) {
tile.style.height = `${freeze_height}px`;
tile.height = freeze_height * this.dpr;
}
// corner includes header size
freeze_height += this.header_offset.y;
this.corner_canvas.style.height = `${freeze_height}px`;
this.corner_canvas.height = freeze_height * this.dpr;
// mark these as dirty so we get painted
for (const column of this.grid_tiles) {
column[0].dirty = true;
}
}
this.UpdateGridTemplates(false, true);
this.row_header.style.height = this.contents.style.height = `${y}px`;
this.ClearLayoutCaches();
}
/**
* update all widths. start_column is a hint that can save some work
* by skipping unaffected tiles
*/
public UpdateTileWidths(mark_dirty = true, start_column = -1): void {
let x = 0;
for (let i = 0; i < this.column_header_tiles.length; i++) {
const tile = this.column_header_tiles[i];
const column = this.grid_tiles[i];
if (start_column > tile.last_cell.column) {
x += tile.logical_size.width;
continue;
}
let width = 0;
for (let c = tile.first_cell.column; c <= tile.last_cell.column; c++) {
width += this.ColumnWidth(c);
}
const width_match = (tile.logical_size.width === width);
tile.pixel_start.x = x;
x += width;
tile.pixel_end.x = x;
if (!width_match) {
tile.logical_size.width = width;
tile.style.width = `${width}px`;
tile.width = this.dpr * width;
if (this.view.active_sheet.freeze.rows) {
const frozen_tile = this.frozen_row_tiles[i];
frozen_tile.logical_size.width = width;
frozen_tile.style.width = `${width}px`;
frozen_tile.width = this.dpr * width;
}
if (mark_dirty) {
tile.dirty = true;
tile.needs_full_repaint = true;
}
}
for (const grid_tile of column) {
grid_tile.pixel_start.x = tile.pixel_start.x;
grid_tile.pixel_end.x = tile.pixel_end.x;
if (!width_match) {
grid_tile.logical_size.width = width;
grid_tile.style.width = `${width}px`;
grid_tile.width = this.dpr * width;
if (mark_dirty) {
grid_tile.dirty = true;
grid_tile.needs_full_repaint = true;
}
}
}
}
if (this.view.active_sheet.freeze.columns) {
let freeze_width = 0;
for (let i = 0; i < this.view.active_sheet.freeze.columns; i++) freeze_width += this.ColumnWidth(i);
for (const tile of this.frozen_column_tiles) {
tile.style.width = `${freeze_width}px`;
tile.width = freeze_width * this.dpr;
}
// corner includes header size
freeze_width += this.header_offset.x;
this.corner_canvas.style.width = `${freeze_width}px`;
this.corner_canvas.width = freeze_width * this.dpr;
// mark these as dirty so we get painted
for (const tile of this.grid_tiles[0]) {
tile.dirty = true;
}
}
this.UpdateGridTemplates(true, false);
this.column_header.style.width = this.contents.style.width = `${x}px`;
this.ClearLayoutCaches();
}
public ClampToGrid(point: Point): Point {
const address = this.PointToAddress_Grid(point);
const rect = this.OffsetCellAddressToRectangle(address);
if (point.x > rect.left + rect.width / 2) {
point.x = rect.left + rect.width - 1;
}
else {
point.x = rect.left - 1;
}
if (point.y > rect.top + rect.height / 2) {
point.y = rect.top + rect.height - 1;
}
else {
point.y = rect.top - 1;
}
return point;
}
/**
* wrapper method for CellAddressToRectangle allows us to offset for
* frozen rows/columns. in some cases we may not want to do this, so
* the underlying method is still visible (and cache contains the raw
* rectangles, not offset).
*/
public OffsetCellAddressToRectangle(address: ICellAddress): Rectangle {
let rect = this.CellAddressToRectangle(address);
if (address.column >= 0 && address.column < this.view.active_sheet.freeze.columns) {
rect = rect.Shift(this.scroll_reference_node.scrollLeft, 0);
}
if (address.row >= 0 && address.row < this.view.active_sheet.freeze.rows) {
rect = rect.Shift(0, this.scroll_reference_node.scrollTop);
}
return rect;
}
/**
* finds the rectangle, in the coordinate space of the grid node,
* of the cell with the given address. uses a cache since we wind
* up looking up the same rectangles a lot.
*
* UPDATE dropping rectangle cache in favor of holding row and
* column edges. I realized we were holding a lot of redundant
* information, and this should be resonably fast.
*
* TODO could probably be slightly more efficient by holding the
* left edge of the column/row at the index; then we don't have to
* have special behavior for column/row 0.
*/
public CellAddressToRectangle(address: ICellAddress): Rectangle {
// limit
const row = address.row === Infinity || address.row < 0 ? 0 : address.row;
const column = address.column === Infinity || address.column < 0 ? 0 : address.column;
// build out the caches if necessary
if (this.column_cache.length <= column + 1) {
if (!this.column_cache.length) {
this.column_cache[0] = 0;
}
for (let i = this.column_cache.length - 1; i <= column; i++) {
this.column_cache[i + 1] = this.column_cache[i] + this.ColumnWidth(i);
}
}
if (this.row_cache.length <= row + 1) {
if (!this.row_cache.length) {
this.row_cache[0] = 0;
}
for (let i = this.row_cache.length - 1; i <= row; i++) {
this.row_cache[i + 1] = this.row_cache[i] + this.RowHeight(i);
}
}
// now we can construct the rectangle
const left = this.column_cache[column];
const top = this.row_cache[row];
return new Rectangle(left, top,
this.column_cache[column + 1] - left,
this.row_cache[row + 1] - top);
}
/**
* resizes the tile at this index, rebuilds structure of subsequent tiles.
* this is necessary because tiles keep track of pixel position: so if a
* tile is resized, subsequent tiles have to change.
*/
public ResizeTileWidth(index: number, width: number, mark_dirty = true): void {
// start with headers...
let tile = this.column_header_tiles[index];
const delta = width - tile.logical_size.width;
tile.logical_size.width = width;
tile.style.width = `${width}px`;
tile.width = this.dpr * width;
tile.pixel_end.x += delta;
if (mark_dirty) {
tile.dirty = true;
tile.needs_full_repaint = true;
}
for (let i = index + 1; i < this.column_header_tiles.length; i++) {
this.column_header_tiles[i].pixel_start.x += delta;
this.column_header_tiles[i].pixel_end.x += delta;
for (const cell of this.grid_tiles[i]) {
cell.pixel_start.x += delta;
cell.pixel_end.x += delta;
}
}
const column = this.grid_tiles[index];
for (tile of column) {
tile.logical_size.width = width;
tile.style.width = `${width}px`;
tile.width = this.dpr * width;
tile.pixel_end.x += delta;
if (mark_dirty) {
tile.dirty = true;
tile.needs_full_repaint = true;
}
}
this.UpdateTotalSize();
this.UpdateGridTemplates(true, false);
this.UpdateContentsSize();
}
/**
* resizes the tile at this index, rebuilds structure of subsequent tiles
*/
public ResizeTileHeight(index: number, height: number, mark_dirty = true): void {
// start with headers...
let tile = this.row_header_tiles[index];
const delta = height - tile.logical_size.height;
tile.logical_size.height = height;
tile.style.height = `${height}px`;
tile.height = this.dpr * height;
tile.pixel_end.y += delta;
if (mark_dirty) {
tile.dirty = true;
tile.needs_full_repaint = true;
}
for (let r = index + 1; r < this.row_header_tiles.length; r++) {
tile = this.row_header_tiles[r];
tile.pixel_start.y += delta;
tile.pixel_end.y += delta;
}
for (const column of this.grid_tiles) {
tile = column[index];
tile.logical_size.height = height;
tile.style.height = `${height}px`;
tile.height = this.dpr * height;
tile.pixel_end.y += delta;
if (mark_dirty) {
tile.dirty = true;
tile.needs_full_repaint = true;
}
for (let i = index + 1; i < column.length; i++) {
column[i].pixel_start.y += delta;
column[i].pixel_end.y += delta;
}
}
this.UpdateTotalSize();
this.UpdateGridTemplates(false, true);
this.UpdateContentsSize();
}
/**
* do subclass-specific initialization
*/
public abstract InitializeInternal(container: HTMLElement, scroll_callback: () => void): void;
/**
* set resize cursor. this is subclass-specific because it's set on
* different nodes depending on layout.
*/
public abstract ResizeCursor(resize?: 'row' | 'column'): void;
protected abstract UpdateTileGridPosition(tile: Tile): void;
protected abstract UpdateContainingGrid(): void;
protected abstract UpdateGridTemplates(columns: boolean, rows: boolean): void;
}