Files
IronCalc/webapp/src/components/WorksheetCanvas/worksheetCanvas.ts

1474 lines
48 KiB
TypeScript

import type { Model } from "@ironcalc/wasm";
import { columnNameFromNumber } from "@ironcalc/wasm";
import type { Cell } from "../types";
import type { WorkbookState } from "../workbookState";
import {
COLUMN_WIDTH_SCALE,
LAST_COLUMN,
LAST_ROW,
ROW_HEIGH_SCALE,
defaultTextColor,
gridColor,
gridSeparatorColor,
headerBackground,
headerBorderColor,
headerSelectedBackground,
headerSelectedColor,
headerTextColor,
outlineColor,
} from "./constants";
import { getColor } from "../editor/util";
export interface CanvasSettings {
model: Model;
width: number;
height: number;
workbookState: WorkbookState;
elements: {
canvas: HTMLCanvasElement;
cellOutline: HTMLDivElement;
areaOutline: HTMLDivElement;
cellOutlineHandle: HTMLDivElement;
extendToOutline: HTMLDivElement;
columnGuide: HTMLDivElement;
rowGuide: HTMLDivElement;
columnHeaders: HTMLDivElement;
editor: HTMLDivElement;
};
onColumnWidthChanges: (sheet: number, column: number, width: number) => void;
onRowHeightChanges: (sheet: number, row: number, height: number) => void;
}
export const fonts = {
regular: 'Inter, "Adjusted Arial Fallback", sans-serif',
mono: '"Fira Mono", "Adjusted Courier New Fallback", serif',
};
export const headerRowHeight = 24;
export const headerColumnWidth = 30;
export const devicePixelRatio = window.devicePixelRatio || 1;
export const defaultCellFontFamily = fonts.regular;
export const headerFontFamily = fonts.regular;
export const frozenSeparatorWidth = 3;
// Get a 10% transparency of an hex color
function hexToRGBA10Percent(colorHex: string): string {
// Remove the leading hash (#) if present
const hex = colorHex.replace(/^#/, "");
// Parse the hex color
const red = Number.parseInt(hex.substring(0, 2), 16);
const green = Number.parseInt(hex.substring(2, 4), 16);
const blue = Number.parseInt(hex.substring(4, 6), 16);
// Set the alpha (opacity) to 0.1 (10%)
const alpha = 0.1;
// Return the RGBA color string
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
}
export default class WorksheetCanvas {
sheetWidth: number;
sheetHeight: number;
width: number;
height: number;
ctx: CanvasRenderingContext2D;
canvas: HTMLCanvasElement;
editor: HTMLDivElement;
areaOutline: HTMLDivElement;
cellOutline: HTMLDivElement;
cellOutlineHandle: HTMLDivElement;
extendToOutline: HTMLDivElement;
workbookState: WorkbookState;
model: Model;
rowGuide: HTMLDivElement;
columnHeaders: HTMLDivElement;
columnGuide: HTMLDivElement;
onColumnWidthChanges: (sheet: number, column: number, width: number) => void;
onRowHeightChanges: (sheet: number, row: number, height: number) => void;
constructor(options: CanvasSettings) {
this.model = options.model;
this.sheetWidth = 0;
this.sheetHeight = 0;
this.canvas = options.elements.canvas;
this.width = options.width;
this.height = options.height;
this.ctx = this.setContext();
this.workbookState = options.workbookState;
this.editor = options.elements.editor;
this.cellOutline = options.elements.cellOutline;
this.cellOutlineHandle = options.elements.cellOutlineHandle;
this.areaOutline = options.elements.areaOutline;
this.extendToOutline = options.elements.extendToOutline;
this.rowGuide = options.elements.rowGuide;
this.columnGuide = options.elements.columnGuide;
this.columnHeaders = options.elements.columnHeaders;
this.onColumnWidthChanges = options.onColumnWidthChanges;
this.onRowHeightChanges = options.onRowHeightChanges;
this.resetHeaders();
}
setScrollPosition(scrollPosition: { left: number; top: number }): void {
// We ony scroll whole rows and whole columns
// left, top are maximized with constraints:
// 1. left <= scrollPosition.left
// 2. top <= scrollPosition.top
// 3. (left, top) are the absolute coordinates of a cell
const { column } = this.getBoundedColumn(scrollPosition.left);
const { row } = this.getBoundedRow(scrollPosition.top);
this.model.setTopLeftVisibleCell(row, column);
}
resetHeaders(): void {
for (const handle of this.columnHeaders.querySelectorAll(
".column-resize-handle",
)) {
handle.remove();
}
for (const columnSeparator of this.columnHeaders.querySelectorAll(
".frozen-column-separator",
)) {
columnSeparator.remove();
}
for (const header of this.columnHeaders.children) {
(header as HTMLDivElement).classList.add("column-header");
}
}
setContext(): CanvasRenderingContext2D {
const { canvas, width, height } = this;
const context = canvas.getContext("2d");
if (!context) {
throw new Error(
"This browser does not support 2-dimensional canvas rendering contexts.",
);
}
// If the devicePixelRatio is 2 then the canvas is twice as large to avoid blurring.
canvas.width = width * devicePixelRatio;
canvas.height = height * devicePixelRatio;
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
context.scale(devicePixelRatio, devicePixelRatio);
return context;
}
setSize(size: { width: number; height: number }): void {
this.width = size.width;
this.height = size.height;
this.ctx = this.setContext();
}
/**
* This is the height of the frozen rows including the width of the separator
* It returns 0 if the are no frozen rows
*/
getFrozenRowsHeight(): number {
const frozenRows = this.model.getFrozenRowsCount(
this.model.getSelectedSheet(),
);
if (frozenRows === 0) {
return 0;
}
let frozenRowsHeight = 0;
for (let row = 1; row <= frozenRows; row += 1) {
frozenRowsHeight += this.getRowHeight(this.model.getSelectedSheet(), row);
}
return frozenRowsHeight + frozenSeparatorWidth;
}
/**
* This is the width of the frozen columns including the width of the separator
* It returns 0 if the are no frozen columns
*/
getFrozenColumnsWidth(): number {
const frozenColumns = this.model.getFrozenColumnsCount(
this.model.getSelectedSheet(),
);
if (frozenColumns === 0) {
return 0;
}
let frozenColumnsWidth = 0;
for (let column = 1; column <= frozenColumns; column += 1) {
frozenColumnsWidth += this.getColumnWidth(
this.model.getSelectedSheet(),
column,
);
}
return frozenColumnsWidth + frozenSeparatorWidth;
}
// Get the visible cells (aside from the frozen rows and columns)
getVisibleCells(): {
topLeftCell: Cell;
bottomRightCell: Cell;
} {
const view = this.model.getSelectedView();
const selectedSheet = view.sheet;
const frozenRows = this.model.getFrozenRowsCount(selectedSheet);
const frozenColumns = this.model.getFrozenColumnsCount(selectedSheet);
const rowTop = Math.max(frozenRows + 1, view.top_row);
let rowBottom = rowTop;
const columnLeft = Math.max(frozenColumns + 1, view.left_column);
let columnRight = columnLeft;
const frozenColumnsWidth = this.getFrozenColumnsWidth();
const frozenRowsHeight = this.getFrozenRowsHeight();
let y = headerRowHeight + frozenRowsHeight;
for (let row = rowTop; row <= LAST_ROW; row += 1) {
const rowHeight = this.getRowHeight(selectedSheet, row);
if (y >= this.height - rowHeight || row === LAST_ROW) {
rowBottom = row;
break;
}
y += rowHeight;
}
let x = headerColumnWidth + frozenColumnsWidth;
for (let column = columnLeft; column <= LAST_COLUMN; column += 1) {
const columnWidth = this.getColumnWidth(selectedSheet, column);
if (x >= this.width - columnWidth || column === LAST_COLUMN) {
columnRight = column;
break;
}
x += columnWidth;
}
const cells = {
topLeftCell: { row: rowTop, column: columnLeft },
bottomRightCell: { row: rowBottom, column: columnRight },
};
return cells;
}
/**
* Returns the {row, top} of the row whose upper y coordinate (top) is maximum and less or equal than maxTop
* Both top and maxTop are absolute coordinates
*/
getBoundedRow(maxTop: number): { row: number; top: number } {
const selectedSheet = this.model.getSelectedSheet();
let top = 0;
let row = 1 + this.model.getFrozenRowsCount(selectedSheet);
while (row <= LAST_ROW && top <= maxTop) {
const height = this.getRowHeight(selectedSheet, row);
if (top + height <= maxTop) {
top += height;
} else {
break;
}
row += 1;
}
return { row, top };
}
private getBoundedColumn(maxLeft: number): { column: number; left: number } {
let left = 0;
const selectedSheet = this.model.getSelectedSheet();
let column = 1 + this.model.getFrozenColumnsCount(selectedSheet);
while (left <= maxLeft && column <= LAST_COLUMN) {
const width = this.getColumnWidth(selectedSheet, column);
if (width + left <= maxLeft) {
left += width;
} else {
break;
}
column += 1;
}
return { column, left };
}
/**
* Returns the minimum we can scroll to the left so that
* targetColumn is fully visible.
* Returns the the first visible column and the scrollLeft position
*/
getMinScrollLeft(targetColumn: number): number {
const columnStart =
1 + this.model.getFrozenColumnsCount(this.model.getSelectedSheet());
/** Distance from the first non frozen cell to the right border of column*/
let distance = 0;
for (let column = columnStart; column <= targetColumn; column += 1) {
const width = this.getColumnWidth(this.model.getSelectedSheet(), column);
distance += width;
}
/** Minimum we need to scroll so that `column` is visible */
const minLeft =
distance - this.width + this.getFrozenColumnsWidth() + headerColumnWidth;
// Because scrolling is quantified, we only scroll whole columns,
// we need to find the minimum quantum that is larger than minLeft
let left = 0;
for (let column = columnStart; column <= LAST_COLUMN; column += 1) {
const width = this.getColumnWidth(this.model.getSelectedSheet(), column);
if (left < minLeft) {
left += width;
} else {
break;
}
}
return left;
}
private renderCell(
row: number,
column: number,
x: number,
y: number,
width: number,
height: number,
): void {
const selectedSheet = this.model.getSelectedSheet();
const style = this.model.getCellStyle(selectedSheet, row, column);
let backgroundColor = "#FFFFFF";
if (style.fill.fg_color) {
backgroundColor = style.fill.fg_color;
}
const cellGridColor = this.model.getShowGridLines(selectedSheet)
? gridColor
: backgroundColor;
const fontSize = 13;
let font = `${fontSize}px ${defaultCellFontFamily}`;
let textColor = defaultTextColor;
if (style.font) {
textColor = style.font.color;
font = style.font.b ? `bold ${font}` : `400 ${font}`;
if (style.font.i) {
font = `italic ${font}`;
}
}
let horizontalAlign = "general";
if (style.alignment?.horizontal) {
horizontalAlign = style.alignment.horizontal;
}
let verticalAlign = "bottom";
if (style.alignment?.vertical) {
verticalAlign = style.alignment.vertical;
}
const context = this.ctx;
context.font = font;
context.fillStyle = backgroundColor;
context.fillRect(x, y, width, height);
context.fillStyle = textColor;
// Let's do the border
// Algorithm:
// * we use the border if present
// * otherwise we use the border of the adjacent cell
// * otherwise we use the color of the background
// * otherwise we use the background color of the adjacent cell
// * if everything else fails we use the default grid color
// We only set the left and top borders (right and bottom are set later)
const border = style.border;
let borderLeftColor = cellGridColor;
let borderLeftWidth = 1;
if (border.left) {
borderLeftColor = border.left.color;
switch (border.left.style) {
case "thin":
break;
case "medium":
borderLeftWidth = 2;
break;
case "thick":
borderLeftWidth = 3;
}
} else {
const leftStyle = this.model.getCellStyle(selectedSheet, row, column - 1);
if (leftStyle.border.right) {
borderLeftColor = leftStyle.border.right.color;
switch (leftStyle.border.right.style) {
case "thin":
break;
case "medium":
borderLeftWidth = 2;
break;
case "thick":
borderLeftWidth = 3;
}
} else if (style.fill.fg_color) {
borderLeftColor = style.fill.fg_color;
} else if (leftStyle.fill.fg_color) {
borderLeftColor = leftStyle.fill.fg_color;
}
}
context.beginPath();
context.strokeStyle = borderLeftColor;
context.lineWidth = borderLeftWidth;
context.moveTo(x, y);
context.lineTo(x, y + height);
context.stroke();
let borderTopColor = cellGridColor;
let borderTopWidth = 1;
if (border.top) {
borderTopColor = border.top.color;
switch (border.top.style) {
case "thin":
break;
case "medium":
borderTopWidth = 2;
break;
case "thick":
borderTopWidth = 3;
}
} else {
const topStyle = this.model.getCellStyle(selectedSheet, row - 1, column);
if (topStyle.border.bottom) {
borderTopColor = topStyle.border.bottom.color;
switch (topStyle.border.bottom.style) {
case "thin":
break;
case "medium":
borderTopWidth = 2;
break;
case "thick":
borderTopWidth = 3;
}
} else if (style.fill.fg_color) {
borderTopColor = style.fill.fg_color;
} else if (topStyle.fill.fg_color) {
borderTopColor = topStyle.fill.fg_color;
}
}
context.beginPath();
context.strokeStyle = borderTopColor;
context.lineWidth = borderTopWidth;
context.moveTo(x, y);
context.lineTo(x + width, y);
context.stroke();
// Number = 1,
// Text = 2,
// LogicalValue = 4,
// ErrorValue = 16,
// Array = 64,
// CompoundData = 128,
const cellType = this.model.getCellType(selectedSheet, row, column);
const fullText = this.model.getFormattedCellValue(
selectedSheet,
row,
column,
);
const padding = 4;
if (horizontalAlign === "general") {
if (cellType === 1) {
horizontalAlign = "right";
} else if (cellType === 4) {
horizontalAlign = "center";
} else {
horizontalAlign = "left";
}
}
// Create a rectangular clipping region
context.save();
context.beginPath();
context.rect(x, y, width, height);
context.clip();
// Is there any better parameter?
const lineHeight = 22;
const lines = fullText.split("\n");
const lineCount = lines.length;
lines.forEach((text, line) => {
const textWidth = context.measureText(text).width;
let textX: number;
let textY: number;
// The idea is that in the present font-size and default row heigh,
// top/bottom and center horizontalAlign coincide
const verticalPadding = 4;
if (horizontalAlign === "right") {
textX = width - padding + x - textWidth / 2;
} else if (horizontalAlign === "center") {
textX = x + width / 2;
} else {
// left aligned
textX = padding + x + textWidth / 2;
}
if (verticalAlign === "bottom") {
textY =
y +
height -
fontSize / 2 -
verticalPadding +
(line - lineCount + 1) * lineHeight;
} else if (verticalAlign === "center") {
textY = y + height / 2 + (line + (1 - lineCount) / 2) * lineHeight;
} else {
// aligned top
textY = y + fontSize / 2 + verticalPadding + line * lineHeight;
}
context.fillText(text, textX, textY);
if (style.font) {
if (style.font.u) {
// There are no text-decoration in canvas. You have to do the underline yourself.
const offset = Math.floor(fontSize / 2);
context.beginPath();
context.strokeStyle = textColor;
context.lineWidth = 1;
context.moveTo(textX - textWidth / 2, textY + offset);
context.lineTo(textX + textWidth / 2, textY + offset);
context.stroke();
}
if (style.font.strike) {
// There are no text-decoration in canvas. You have to do the strikethrough yourself.
context.beginPath();
context.strokeStyle = textColor;
context.lineWidth = 1;
context.moveTo(textX - textWidth / 2, textY);
context.lineTo(textX + textWidth / 2, textY);
context.stroke();
}
}
});
// remove the clipping region
context.restore();
}
// Column and row headers with their handles
private addColumnResizeHandle(
x: number,
column: number,
columnWidth: number,
): void {
const div = document.createElement("div");
div.className = "column-resize-handle";
div.style.left = `${x - 1}px`;
div.style.height = `${headerRowHeight}px`;
this.columnHeaders.insertBefore(div, null);
let initPageX = 0;
const resizeHandleMove = (event: MouseEvent): void => {
if (columnWidth + event.pageX - initPageX > 0) {
div.style.left = `${x + event.pageX - initPageX - 1}px`;
this.columnGuide.style.left = `${
headerColumnWidth + x + event.pageX - initPageX
}px`;
}
};
let resizeHandleUp = (event: MouseEvent): void => {
div.style.opacity = "0";
this.columnGuide.style.display = "none";
document.removeEventListener("mousemove", resizeHandleMove);
document.removeEventListener("mouseup", resizeHandleUp);
const newColumnWidth = columnWidth + event.pageX - initPageX;
this.onColumnWidthChanges(
this.model.getSelectedSheet(),
column,
newColumnWidth,
);
};
resizeHandleUp = resizeHandleUp.bind(this);
div.addEventListener("mousedown", (event) => {
div.style.opacity = "1";
this.columnGuide.style.display = "block";
this.columnGuide.style.left = `${headerColumnWidth + x}px`;
initPageX = event.pageX;
document.addEventListener("mousemove", resizeHandleMove);
document.addEventListener("mouseup", resizeHandleUp);
});
}
private addRowResizeHandle(y: number, row: number, rowHeight: number): void {
const div = document.createElement("div");
div.className = "row-resize-handle";
div.style.top = `${y - 1}px`;
div.style.width = `${headerColumnWidth}px`;
const sheet = this.model.getSelectedSheet();
this.canvas.parentElement?.insertBefore(div, null);
let initPageY = 0;
/* istanbul ignore next */
const resizeHandleMove = (event: MouseEvent): void => {
if (rowHeight + event.pageY - initPageY > 0) {
div.style.top = `${y + event.pageY - initPageY - 1}px`;
this.rowGuide.style.top = `${y + event.pageY - initPageY}px`;
}
};
let resizeHandleUp = (event: MouseEvent): void => {
div.style.opacity = "0";
this.rowGuide.style.display = "none";
document.removeEventListener("mousemove", resizeHandleMove);
document.removeEventListener("mouseup", resizeHandleUp);
const newRowHeight = rowHeight + event.pageY - initPageY - 1;
this.onRowHeightChanges(sheet, row, newRowHeight);
};
resizeHandleUp = resizeHandleUp.bind(this);
/* istanbul ignore next */
div.addEventListener("mousedown", (event) => {
div.style.opacity = "1";
this.rowGuide.style.display = "block";
this.rowGuide.style.top = `${y}px`;
initPageY = event.pageY;
document.addEventListener("mousemove", resizeHandleMove);
document.addEventListener("mouseup", resizeHandleUp);
});
}
private styleColumnHeader(
width: number,
div: HTMLDivElement,
selected: boolean,
): void {
div.style.boxSizing = "border-box";
div.style.width = `${width}px`;
div.style.height = `${headerRowHeight}px`;
div.style.backgroundColor = selected
? headerSelectedBackground
: headerBackground;
div.style.color = selected ? headerSelectedColor : headerTextColor;
div.style.fontWeight = "bold";
div.style.borderLeft = `1px solid ${headerBorderColor}`;
div.style.borderTop = `1px solid ${headerBorderColor}`;
if (selected) {
div.style.borderBottom = `1px solid ${outlineColor}`;
div.classList.add("selected");
} else {
div.classList.remove("selected");
}
}
private removeHandles(): void {
const root = this.canvas.parentElement;
if (root) {
for (const handle of root.querySelectorAll(".row-resize-handle"))
handle.remove();
}
}
private renderRowHeaders(
frozenRows: number,
topLeftCell: Cell,
bottomRightCell: Cell,
): void {
const { sheet: selectedSheet, range } = this.model.getSelectedView();
let rowStart = range[0];
let rowEnd = range[2];
if (rowStart > rowEnd) {
[rowStart, rowEnd] = [rowEnd, rowStart];
}
const context = this.ctx;
let topLeftCornerY = headerRowHeight + 0.5;
const firstRow = frozenRows === 0 ? topLeftCell.row : 1;
for (let row = firstRow; row <= bottomRightCell.row; row += 1) {
const rowHeight = this.getRowHeight(selectedSheet, row);
const selected = row >= rowStart && row <= rowEnd;
context.fillStyle = headerBorderColor;
context.fillRect(0.5, topLeftCornerY, headerColumnWidth, rowHeight);
context.fillStyle = selected
? headerSelectedBackground
: headerBackground;
context.fillRect(
0.5,
topLeftCornerY + 0.5,
headerColumnWidth,
rowHeight - 1,
);
if (selected) {
context.fillStyle = outlineColor;
context.fillRect(headerColumnWidth - 1, topLeftCornerY, 1, rowHeight);
}
context.fillStyle = selected ? headerSelectedColor : headerTextColor;
context.font = `bold 12px ${defaultCellFontFamily}`;
context.fillText(
`${row}`,
headerColumnWidth / 2,
topLeftCornerY + rowHeight / 2,
headerColumnWidth,
);
topLeftCornerY += rowHeight;
this.addRowResizeHandle(topLeftCornerY, row, rowHeight);
if (row === frozenRows) {
topLeftCornerY += frozenSeparatorWidth;
row = topLeftCell.row - 1;
}
}
}
private renderColumnHeaders(
frozenColumns: number,
firstColumn: number,
lastColumn: number,
): void {
const { columnHeaders } = this;
let deltaX = 0;
const { range } = this.model.getSelectedView();
let columnStart = range[1];
let columnEnd = range[3];
if (columnStart > columnEnd) {
[columnStart, columnEnd] = [columnEnd, columnStart];
}
for (const header of columnHeaders.querySelectorAll(".column-header"))
header.remove();
for (const handle of columnHeaders.querySelectorAll(
".column-resize-handle",
))
handle.remove();
for (const separator of columnHeaders.querySelectorAll(
".frozen-column-separator",
))
separator.remove();
columnHeaders.style.fontFamily = headerFontFamily;
columnHeaders.style.fontSize = "12px";
columnHeaders.style.height = `${headerRowHeight}px`;
columnHeaders.style.lineHeight = `${headerRowHeight}px`;
columnHeaders.style.left = `${headerColumnWidth}px`;
// Frozen headers
for (let column = 1; column <= frozenColumns; column += 1) {
const selected = column >= columnStart && column <= columnEnd;
deltaX += this.addColumnHeader(deltaX, column, selected);
}
if (frozenColumns !== 0) {
const div = document.createElement("div");
div.className = "frozen-column-separator";
div.style.width = `${frozenSeparatorWidth}px`;
div.style.height = `${headerRowHeight}`;
div.style.display = "inline-block";
div.style.backgroundColor = gridSeparatorColor;
this.columnHeaders.insertBefore(div, null);
deltaX += frozenSeparatorWidth;
}
for (let column = firstColumn; column <= lastColumn; column += 1) {
const selected = column >= columnStart && column <= columnEnd;
deltaX += this.addColumnHeader(deltaX, column, selected);
}
columnHeaders.style.width = `${deltaX}px`;
}
private addColumnHeader(
deltaX: number,
column: number,
selected: boolean,
): number {
const columnWidth = this.getColumnWidth(
this.model.getSelectedSheet(),
column,
);
const div = document.createElement("div");
div.className = "column-header";
div.textContent = columnNameFromNumber(column);
this.columnHeaders.insertBefore(div, null);
this.styleColumnHeader(columnWidth, div, selected);
this.addColumnResizeHandle(deltaX + columnWidth, column, columnWidth);
return columnWidth;
}
getSheetDimensions(): [number, number] {
let x = headerColumnWidth;
for (let column = 1; column < LAST_COLUMN + 1; column += 1) {
x += this.getColumnWidth(this.model.getSelectedSheet(), column);
}
let y = headerRowHeight;
for (let row = 1; row < LAST_ROW + 1; row += 1) {
y += this.getRowHeight(this.model.getSelectedSheet(), row);
}
this.sheetWidth = Math.floor(
x + this.getColumnWidth(this.model.getSelectedSheet(), LAST_COLUMN),
);
this.sheetHeight = Math.floor(
y + 2 * this.getRowHeight(this.model.getSelectedSheet(), LAST_ROW),
);
return [this.sheetWidth, this.sheetHeight];
}
/**
* Returns the css clip in the canvas of an html element
* This is used so we do not see the outlines in the row and column headers
* NB: A _different_ (better!) approach would be to have separate canvases for the headers
* Then the sheet canvas would have it's own bounding box.
* That's tomorrows problem.
* PS: Please, do not use this function. If at all we can use the clip-path property
*/
private getClipCSS(
x: number,
y: number,
width: number,
height: number,
includeFrozenRows: boolean,
includeFrozenColumns: boolean,
): string {
if (!includeFrozenRows && !includeFrozenColumns) {
return "";
}
const frozenColumnsWidth = includeFrozenColumns
? this.getFrozenColumnsWidth()
: 0;
const frozenRowsHeight = includeFrozenRows ? this.getFrozenRowsHeight() : 0;
const yMinCanvas = headerRowHeight + frozenRowsHeight;
const xMinCanvas = headerColumnWidth + frozenColumnsWidth;
const xMaxCanvas =
xMinCanvas + this.width - headerColumnWidth - frozenColumnsWidth;
const yMaxCanvas =
yMinCanvas + this.height - headerRowHeight - frozenRowsHeight;
const topClip = y < yMinCanvas ? yMinCanvas - y : 0;
const leftClip = x < xMinCanvas ? xMinCanvas - x : 0;
// We don't strictly need to clip on the right and bottom edges because
// text is hidden anyway
const rightClip = x + width > xMaxCanvas ? xMaxCanvas - x : width + 4;
const bottomClip = y + height > yMaxCanvas ? yMaxCanvas - y : height + 4;
return `rect(${topClip}px ${rightClip}px ${bottomClip}px ${leftClip}px)`;
}
private getAreaDimensions(
startRow: number,
startColumn: number,
endRow: number,
endColumn: number,
): [number, number] {
const [xStart, yStart] = this.getCoordinatesByCell(startRow, startColumn);
let [xEnd, yEnd] = this.getCoordinatesByCell(endRow, endColumn);
xEnd += this.getColumnWidth(this.model.getSelectedSheet(), endColumn);
yEnd += this.getRowHeight(this.model.getSelectedSheet(), endRow);
const frozenRows = this.model.getFrozenRowsCount(
this.model.getSelectedSheet(),
);
const frozenColumns = this.model.getFrozenColumnsCount(
this.model.getSelectedSheet(),
);
if (frozenRows !== 0 || frozenColumns !== 0) {
let [xFrozenEnd, yFrozenEnd] = this.getCoordinatesByCell(
frozenRows,
frozenColumns,
);
xFrozenEnd += this.getColumnWidth(
this.model.getSelectedSheet(),
frozenColumns,
);
yFrozenEnd += this.getRowHeight(
this.model.getSelectedSheet(),
frozenRows,
);
if (startRow <= frozenRows && endRow > frozenRows) {
yEnd = Math.max(yEnd, yFrozenEnd);
}
if (startColumn <= frozenColumns && endColumn > frozenColumns) {
xEnd = Math.max(xEnd, xFrozenEnd);
}
}
return [Math.abs(xEnd - xStart), Math.abs(yEnd - yStart)];
}
/**
* Returns the coordinates relative to the canvas.
* (headerColumnWidth, headerRowHeight) being the coordinates
* for the top left corner of the first visible cell
*/
getCoordinatesByCell(row: number, column: number): [number, number] {
const selectedSheet = this.model.getSelectedSheet();
const frozenColumns = this.model.getFrozenColumnsCount(selectedSheet);
const frozenColumnsWidth = this.getFrozenColumnsWidth();
const frozenRows = this.model.getFrozenRowsCount(selectedSheet);
const frozenRowsHeight = this.getFrozenRowsHeight();
const { topLeftCell } = this.getVisibleCells();
let x: number;
let y: number;
if (row <= frozenRows) {
// row is one of the frozen rows
y = headerRowHeight;
for (let r = 1; r < row; r += 1) {
y += this.getRowHeight(selectedSheet, r);
}
} else if (row >= topLeftCell.row) {
// row is bellow the frozen rows
y = headerRowHeight + frozenRowsHeight;
for (let r = topLeftCell.row; r < row; r += 1) {
y += this.getRowHeight(selectedSheet, r);
}
} else {
// row is _above_ the frozen rows
y = headerRowHeight + frozenRowsHeight;
for (let r = topLeftCell.row; r > row; r -= 1) {
y -= this.getRowHeight(selectedSheet, r - 1);
}
}
if (column <= frozenColumns) {
// It is one of the frozen columns
x = headerColumnWidth;
for (let c = 1; c < column; c += 1) {
x += this.getColumnWidth(selectedSheet, c);
}
} else if (column >= topLeftCell.column) {
// column is to the right of the frozen columns
x = headerColumnWidth + frozenColumnsWidth;
for (let c = topLeftCell.column; c < column; c += 1) {
x += this.getColumnWidth(selectedSheet, c);
}
} else {
// column is to the left of the frozen columns
x = headerColumnWidth + frozenColumnsWidth;
for (let c = topLeftCell.column; c > column; c -= 1) {
x -= this.getColumnWidth(selectedSheet, c - 1);
}
}
return [Math.floor(x), Math.floor(y)];
}
/**
* (x, y) are the relative coordinates of a cell WRT the canvas
* getCellByCoordinates(headerColumnWidth, headerRowHeight) will return the first visible cell.
* Note: If there are frozen rows/columns for some particular coordinates (x, y)
* there might be two cells. This method returns the visible one.
*/
getCellByCoordinates(
x: number,
y: number,
): { row: number; column: number } | null {
const frozenColumns = this.model.getFrozenColumnsCount(
this.model.getSelectedSheet(),
);
const frozenColumnsWidth = this.getFrozenColumnsWidth();
const frozenRows = this.model.getFrozenRowsCount(
this.model.getSelectedSheet(),
);
const frozenRowsHeight = this.getFrozenRowsHeight();
let column = 0;
let cellX = headerColumnWidth;
const { topLeftCell } = this.getVisibleCells();
if (x < headerColumnWidth) {
column = topLeftCell.column;
while (cellX >= x) {
column -= 1;
if (column < 1) {
column = 1;
break;
}
cellX -= this.getColumnWidth(this.model.getSelectedSheet(), column);
}
} else if (x < headerColumnWidth + frozenColumnsWidth) {
while (cellX <= x) {
column += 1;
cellX += this.getColumnWidth(this.model.getSelectedSheet(), column);
// This cannot happen (would mean cellX > headerColumnWidth + frozenColumnsWidth)
if (column > frozenColumns) {
/* istanbul ignore next */
return null;
}
}
} else {
cellX = headerColumnWidth + frozenColumnsWidth;
column = topLeftCell.column - 1;
while (cellX <= x) {
column += 1;
if (column > LAST_COLUMN) {
return null;
}
cellX += this.getColumnWidth(this.model.getSelectedSheet(), column);
}
}
let cellY = headerRowHeight;
let row = 0;
if (y < headerRowHeight) {
row = topLeftCell.row;
while (cellY >= y) {
row -= 1;
if (row < 1) {
row = 1;
break;
}
cellY -= this.getRowHeight(this.model.getSelectedSheet(), row);
}
} else if (y < headerRowHeight + frozenRowsHeight) {
while (cellY <= y) {
row += 1;
cellY += this.getRowHeight(this.model.getSelectedSheet(), row);
// This cannot happen (would mean cellY > headerRowHeight + frozenRowsHeight)
if (row > frozenRows) {
/* istanbul ignore next */
return null;
}
}
} else {
cellY = headerRowHeight + frozenRowsHeight;
row = topLeftCell.row - 1;
while (cellY <= y) {
row += 1;
if (row > LAST_ROW) {
row = LAST_ROW;
break;
}
cellY += this.getRowHeight(this.model.getSelectedSheet(), row);
}
}
if (row < 1) row = 1;
if (column < 1) column = 1;
return { row, column };
}
private drawExtendToArea(): void {
const { extendToOutline } = this;
const extendToArea = this.workbookState.getExtendToArea();
if (extendToArea === null) {
extendToOutline.style.visibility = "hidden";
return;
}
extendToOutline.style.visibility = "visible";
let { rowStart, rowEnd, columnStart, columnEnd } = extendToArea;
if (rowStart > rowEnd) {
[rowStart, rowEnd] = [rowEnd, rowStart];
}
if (columnStart > columnEnd) {
[columnStart, columnEnd] = [columnEnd, columnStart];
}
const [areaX, areaY] = this.getCoordinatesByCell(rowStart, columnStart);
const [areaWidth, areaHeight] = this.getAreaDimensions(
rowStart,
columnStart,
rowEnd,
columnEnd,
);
// const { border } = extendToArea;
extendToOutline.style.border = `1px dashed ${outlineColor}`;
extendToOutline.style.borderRadius = "3px";
// switch (border) {
// case 'left': {
// extendToOutline.style.borderLeft = 'none';
// extendToOutline.style.borderTopLeftRadius = '0px';
// extendToOutline.style.borderBottomLeftRadius = '0px';
// break;
// }
// case 'right': {
// extendToOutline.style.borderRight = 'none';
// extendToOutline.style.borderTopRightRadius = '0px';
// extendToOutline.style.borderBottomRightRadius = '0px';
// break;
// }
// case 'top': {
// extendToOutline.style.borderTop = 'none';
// extendToOutline.style.borderTopRightRadius = '0px';
// extendToOutline.style.borderTopLeftRadius = '0px';
// break;
// }
// case 'bottom': {
// extendToOutline.style.borderBottom = 'none';
// extendToOutline.style.borderBottomRightRadius = '0px';
// extendToOutline.style.borderBottomLeftRadius = '0px';
// break;
// }
// default:
// break;
// }
const padding = 1;
extendToOutline.style.left = `${areaX - padding}px`;
extendToOutline.style.top = `${areaY - padding}px`;
extendToOutline.style.width = `${areaWidth + 2 * padding}px`;
extendToOutline.style.height = `${areaHeight + 2 * padding}px`;
}
private getColumnWidth(sheet: number, column: number): number {
return Math.round(
this.model.getColumnWidth(sheet, column) * COLUMN_WIDTH_SCALE,
);
}
private getRowHeight(sheet: number, row: number): number {
return Math.round(this.model.getRowHeight(sheet, row) * ROW_HEIGH_SCALE);
}
private drawCellEditor(): void {
const cell = this.workbookState.getEditingCell();
const selectedSheet = this.model.getSelectedSheet();
const { editor } = this;
if (!cell || cell.sheet !== selectedSheet) {
// If the editing cell is not in the same sheet as the selected sheet
// we take the editor out of view
editor.style.left = "-9999px";
editor.style.top = "-9999px";
return;
}
const { row, column } = cell;
// const style = this.model.getCellStyle(
// selectedSheet,
// selectedRow,
// selectedColumn
// );
// cellOutline.style.fontWeight = style.font.b ? "bold" : "normal";
// cellOutline.style.fontStyle = style.font.i ? "italic" : "normal";
// cellOutline.style.backgroundColor = style.fill.fg_color;
// TODO: Should we add the same color as the text?
// Only if it is not a formula?
// cellOutline.style.color = style.font.color;
const [x, y] = this.getCoordinatesByCell(row, column);
const padding = -1;
const width = cell.editorWidth + 2 * padding;
const height = cell.editorHeight + 2 * padding;
// const width =
// this.getColumnWidth(sheet, column) + 2 * padding;
// const height = this.getRowHeight(sheet, row) + 2 * padding;
editor.style.left = `${x}px`;
editor.style.top = `${y}px`;
editor.style.width = `${width - 1}px`;
editor.style.height = `${height - 1}px`;
}
private drawCellOutline(): void {
const { cellOutline, areaOutline, cellOutlineHandle } = this;
if (this.workbookState.getEditingCell()) {
cellOutline.style.visibility = "hidden";
cellOutlineHandle.style.visibility = "hidden";
areaOutline.style.visibility = "hidden";
return;
}
cellOutline.style.visibility = "visible";
cellOutlineHandle.style.visibility = "visible";
areaOutline.style.visibility = "visible";
const [selectedSheet, selectedRow, selectedColumn] =
this.model.getSelectedCell();
const { topLeftCell } = this.getVisibleCells();
const frozenRows = this.model.getFrozenRowsCount(selectedSheet);
const frozenColumns = this.model.getFrozenColumnsCount(selectedSheet);
const [x, y] = this.getCoordinatesByCell(selectedRow, selectedColumn);
const padding = -1;
const width =
this.getColumnWidth(selectedSheet, selectedColumn) + 2 * padding;
const height = this.getRowHeight(selectedSheet, selectedRow) + 2 * padding;
if (
(selectedRow < topLeftCell.row && selectedRow > frozenRows) ||
(selectedColumn < topLeftCell.column && selectedColumn > frozenColumns)
) {
cellOutline.style.visibility = "hidden";
cellOutlineHandle.style.visibility = "hidden";
}
// Position the cell outline and clip it
cellOutline.style.left = `${x - padding - 1}px`;
cellOutline.style.top = `${y - padding - 1}px`;
// Reset CSS properties
cellOutline.style.minWidth = "";
cellOutline.style.minHeight = "";
cellOutline.style.maxWidth = "";
cellOutline.style.maxHeight = "";
cellOutline.style.overflow = "hidden";
// New properties
cellOutline.style.width = `${width}px`;
cellOutline.style.height = `${height}px`;
cellOutline.style.background = "none";
// border is 2px so line-height must be height - 4
cellOutline.style.lineHeight = `${height - 4}px`;
let {
range: [rowStart, columnStart, rowEnd, columnEnd],
} = this.model.getSelectedView();
if (rowStart > rowEnd) {
[rowStart, rowEnd] = [rowEnd, rowStart];
}
if (columnStart > columnEnd) {
[columnStart, columnEnd] = [columnEnd, columnStart];
}
let handleX: number;
let handleY: number;
// Position the selected area outline
if (columnStart === columnEnd && rowStart === rowEnd) {
areaOutline.style.visibility = "hidden";
[handleX, handleY] = this.getCoordinatesByCell(rowStart, columnStart);
handleX += this.getColumnWidth(selectedSheet, columnStart);
handleY += this.getRowHeight(selectedSheet, rowStart);
} else {
areaOutline.style.visibility = "visible";
cellOutlineHandle.style.visibility = "visible";
const [areaX, areaY] = this.getCoordinatesByCell(rowStart, columnStart);
const [areaWidth, areaHeight] = this.getAreaDimensions(
rowStart,
columnStart,
rowEnd,
columnEnd,
);
handleX = areaX + areaWidth;
handleY = areaY + areaHeight;
areaOutline.style.left = `${areaX - padding}px`;
areaOutline.style.top = `${areaY - padding}px`;
areaOutline.style.width = `${areaWidth + 2 * padding}px`;
areaOutline.style.height = `${areaHeight + 2 * padding}px`;
const clipLeft = rowStart < topLeftCell.row && rowStart > frozenRows;
const clipTop =
columnStart < topLeftCell.column && columnStart > frozenColumns;
areaOutline.style.clip = this.getClipCSS(
areaX,
areaY,
areaWidth + 2 * padding,
areaHeight + 2 * padding,
clipLeft,
clipTop,
);
areaOutline.style.border = `1px solid ${outlineColor}`;
// hide the handle if it is out of the visible area
if (
(rowEnd > frozenRows && rowEnd < topLeftCell.row - 1) ||
(columnEnd > frozenColumns && columnEnd < topLeftCell.column - 1)
) {
cellOutlineHandle.style.visibility = "hidden";
}
// This is in case the selection starts in the frozen area and ends outside of the frozen area
// but we have scrolled out the selection.
if (
rowStart <= frozenRows &&
rowEnd > frozenRows &&
rowEnd < topLeftCell.row - 1
) {
areaOutline.style.borderBottom = "None";
cellOutlineHandle.style.visibility = "hidden";
}
if (
columnStart <= frozenColumns &&
columnEnd > frozenColumns &&
columnEnd < topLeftCell.column - 1
) {
areaOutline.style.borderRight = "None";
cellOutlineHandle.style.visibility = "hidden";
}
}
const handleBBox = cellOutlineHandle.getBoundingClientRect();
const handleWidth = handleBBox.width;
const handleHeight = handleBBox.height;
cellOutlineHandle.style.left = `${handleX - handleWidth / 2 - 1}px`;
cellOutlineHandle.style.top = `${handleY - handleHeight / 2 - 1}px`;
}
private drawActiveRanges(topLeftCell: Cell, bottomRightCell: Cell): void {
let activeRanges = this.workbookState.getActiveRanges();
const ctx = this.ctx;
ctx.setLineDash([2, 2]);
const referencedRange =
this.workbookState.getEditingCell()?.referencedRange || null;
if (referencedRange) {
activeRanges = activeRanges.concat([
{
...referencedRange.range,
color: getColor(activeRanges.length),
},
]);
}
const activeRangesCount = activeRanges.length;
for (let rangeIndex = 0; rangeIndex < activeRangesCount; rangeIndex += 1) {
const range = activeRanges[rangeIndex];
const allowedOffset = 1; // to make borders look nicer
const minRow = topLeftCell.row - allowedOffset;
const maxRow = bottomRightCell.row + allowedOffset;
const minColumn = topLeftCell.column - allowedOffset;
const maxColumn = bottomRightCell.column + allowedOffset;
if (
minRow <= range.rowEnd &&
range.rowStart <= maxRow &&
minColumn <= range.columnEnd &&
range.columnStart < maxColumn
) {
// Range in the viewport.
const displayRange: typeof range = {
...range,
rowStart: Math.max(minRow, range.rowStart),
rowEnd: Math.min(maxRow, range.rowEnd),
columnStart: Math.max(minColumn, range.columnStart),
columnEnd: Math.min(maxColumn, range.columnEnd),
};
const [xStart, yStart] = this.getCoordinatesByCell(
displayRange.rowStart,
displayRange.columnStart,
);
const [xEnd, yEnd] = this.getCoordinatesByCell(
displayRange.rowEnd + 1,
displayRange.columnEnd + 1,
);
ctx.strokeStyle = range.color;
ctx.lineWidth = 1;
ctx.strokeRect(xStart, yStart, xEnd - xStart, yEnd - yStart);
ctx.fillStyle = hexToRGBA10Percent(range.color);
ctx.fillRect(xStart, yStart, xEnd - xStart, yEnd - yStart);
}
}
ctx.setLineDash([]);
}
renderSheet(): void {
console.time("renderSheet");
this._renderSheet();
console.timeEnd("renderSheet");
}
private _renderSheet(): void {
const context = this.ctx;
const { canvas } = this;
const selectedSheet = this.model.getSelectedSheet();
context.lineWidth = 1;
context.textAlign = "center";
context.textBaseline = "middle";
// Clear the canvas
context.clearRect(0, 0, canvas.width, canvas.height);
this.removeHandles();
const { topLeftCell, bottomRightCell } = this.getVisibleCells();
const frozenColumns = this.model.getFrozenColumnsCount(selectedSheet);
const frozenRows = this.model.getFrozenRowsCount(selectedSheet);
// Draw frozen rows and columns (top-left-pane)
let x = headerColumnWidth + 0.5;
let y = headerRowHeight + 0.5;
for (let row = 1; row <= frozenRows; row += 1) {
const rowHeight = this.getRowHeight(selectedSheet, row);
x = headerColumnWidth;
for (let column = 1; column <= frozenColumns; column += 1) {
const columnWidth = this.getColumnWidth(selectedSheet, column);
this.renderCell(row, column, x, y, columnWidth, rowHeight);
x += columnWidth;
}
y += rowHeight;
}
if (frozenRows === 0 && frozenColumns !== 0) {
x = headerColumnWidth;
for (let column = 1; column <= frozenColumns; column += 1) {
x += this.getColumnWidth(selectedSheet, column);
}
}
const frozenOffset = frozenSeparatorWidth / 2;
// If there are frozen rows draw a separator
if (frozenRows) {
context.beginPath();
context.lineWidth = frozenSeparatorWidth;
context.strokeStyle = gridSeparatorColor;
context.moveTo(0, y + frozenOffset);
context.lineTo(this.width, y + frozenOffset);
y += frozenSeparatorWidth;
context.stroke();
context.lineWidth = 1;
}
// If there are frozen columns draw a separator
if (frozenColumns) {
context.beginPath();
context.lineWidth = frozenSeparatorWidth;
context.strokeStyle = gridSeparatorColor;
context.moveTo(x + frozenOffset, 0);
context.lineTo(x + frozenOffset, this.height);
x += frozenSeparatorWidth;
context.stroke();
context.lineWidth = 1;
}
const frozenX = x;
const frozenY = y;
// Draw frozen rows (top-right pane)
y = headerRowHeight;
for (let row = 1; row <= frozenRows; row += 1) {
x = frozenX;
const rowHeight = this.getRowHeight(selectedSheet, row);
for (
let { column } = topLeftCell;
column <= bottomRightCell.column;
column += 1
) {
const columnWidth = this.getColumnWidth(selectedSheet, column);
this.renderCell(row, column, x, y, columnWidth, rowHeight);
x += columnWidth;
}
y += rowHeight;
}
// Draw frozen columns (bottom-left pane)
y = frozenY;
for (let { row } = topLeftCell; row <= bottomRightCell.row; row += 1) {
x = headerColumnWidth;
const rowHeight = this.getRowHeight(selectedSheet, row);
for (let column = 1; column <= frozenColumns; column += 1) {
const columnWidth = this.getColumnWidth(selectedSheet, column);
this.renderCell(row, column, x, y, columnWidth, rowHeight);
x += columnWidth;
}
y += rowHeight;
}
// Render all remaining cells (bottom-right pane)
y = frozenY;
for (let { row } = topLeftCell; row <= bottomRightCell.row; row += 1) {
x = frozenX;
const rowHeight = this.getRowHeight(selectedSheet, row);
for (
let { column } = topLeftCell;
column <= bottomRightCell.column;
column += 1
) {
const columnWidth = this.getColumnWidth(selectedSheet, column);
this.renderCell(row, column, x, y, columnWidth, rowHeight);
x += columnWidth;
}
y += rowHeight;
}
// Draw column headers
this.renderColumnHeaders(
frozenColumns,
topLeftCell.column,
bottomRightCell.column,
);
// Draw row headers
this.renderRowHeaders(frozenRows, topLeftCell, bottomRightCell);
// square in the top left corner
context.fillStyle = headerBorderColor;
context.fillRect(0, 0, headerColumnWidth, headerRowHeight);
this.drawCellOutline();
this.drawCellEditor();
this.drawExtendToArea();
this.drawActiveRanges(topLeftCell, bottomRightCell);
}
}