import type { Model } from "@ironcalc/wasm"; import { columnNameFromNumber } from "@ironcalc/wasm"; import { getColor } from "../editor/util"; 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"; 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 = 28; 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, ); extendToOutline.style.border = `1px dashed ${outlineColor}`; extendToOutline.style.borderRadius = "3px"; extendToOutline.style.left = `${areaX}px`; extendToOutline.style.top = `${areaY}px`; extendToOutline.style.width = `${areaWidth - 1}px`; extendToOutline.style.height = `${areaHeight - 1}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 - 2}px`; cellOutline.style.top = `${y - padding - 2}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 + 1}px`; cellOutline.style.height = `${height + 1}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 - 1}px`; areaOutline.style.top = `${areaY - padding - 1}px`; areaOutline.style.width = `${areaWidth + 2 * padding + 1}px`; areaOutline.style.height = `${areaHeight + 2 * padding + 1}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 drawCutRange(): void { const range = this.workbookState.getCutRange() || null; if (!range) { return; } const selectedSheet = this.model.getSelectedSheet(); if (range.sheet !== selectedSheet) { return; } const ctx = this.ctx; ctx.setLineDash([2, 2]); const [xStart, yStart] = this.getCoordinatesByCell( range.rowStart, range.columnStart, ); const [xEnd, yEnd] = this.getCoordinatesByCell( range.rowEnd + 1, range.columnEnd + 1, ); ctx.strokeStyle = "red"; ctx.lineWidth = 1; ctx.strokeRect(xStart, yStart, xEnd - xStart, yEnd - yStart); ctx.setLineDash([]); } 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 selectedSheet = this.model.getSelectedSheet(); const activeRangesCount = activeRanges.length; for (let rangeIndex = 0; rangeIndex < activeRangesCount; rangeIndex += 1) { const range = activeRanges[rangeIndex]; if (range.sheet !== selectedSheet) { continue; } 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 { 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.beginPath(); context.strokeStyle = gridSeparatorColor; context.moveTo(0, 0.5); context.lineTo(x + headerColumnWidth, 0.5); context.stroke(); this.drawCellOutline(); this.drawCellEditor(); this.drawExtendToArea(); this.drawActiveRanges(topLeftCell, bottomRightCell); this.drawCutRange(); } }