modern_spreadsheet/src/main.ts

434 lines
11 KiB
TypeScript

import { Editor } from "./components/editor";
import { Scroller } from "./components/scroller";
import { Sheet } from "./components/sheet";
import { Table } from "./components/table";
import { Toolbar } from "./components/toolbar";
import {
Cell,
CellConstructorProps,
CellStyles,
Position,
SerializableCell,
} from "./modules/cell";
import {
CellChangeEvent,
CellClickEvent,
Config,
CopyEvent,
SelectionChangeEvent,
ViewProperties,
} from "./modules/config";
import { RangeSelectionType, Selection } from "./modules/selection";
import { Styles } from "./modules/styles";
import { Viewport } from "./modules/viewport";
import "./scss/main.scss";
import { createSampleData } from "./utils/createData";
import { Cache, CachedColumn, CachedRow } from "./modules/cache";
import { Row } from "./modules/row";
import { Column } from "./modules/column";
import { ColumnsBar } from "./components/columnsBar";
import { RowsBar } from "./components/rowsBar";
import { EventTypes, Events } from "./modules/events";
import { Clipboard } from "./modules/clipboard";
/*
! Component structure
<Table>
<Toolbar />
<Content> //* Abstract
<Header />
<Sheet />
</Content>
<Scroller />
</Table>
*/
export interface SpreadsheetConstructorProperties {
view?: ViewProperties;
onCellClick?: CellClickEvent | null;
onSelectionChange?: SelectionChangeEvent | null;
onCellChange?: CellChangeEvent | null;
onCopy?: CopyEvent | null
}
export const CSS_PREFIX = "modern_sc_";
export default class Spreadsheet {
private table: Table;
private scroller: Scroller;
private toolbar: Toolbar;
private rowsBar: RowsBar;
private columnsBar: ColumnsBar;
private sheet: Sheet;
private editor: Editor;
public styles: Styles;
public config: Config;
public data: Cell[][];
public viewport: Viewport;
public selection: Selection;
public cache: Cache;
public events: Events;
public clipboard: Clipboard;
constructor(
target: string | HTMLElement,
props?: SpreadsheetConstructorProperties,
) {
const data = createSampleData(40, 40);
const config = this.makeConfigFromData(
data,
props?.view ?? { height: 600, width: 800 },
);
if (props?.view) {
config.view = props.view;
}
this.config = new Config(config);
this.config.onCellClick = props?.onCellClick ?? null;
this.config.onSelectonChange = props?.onSelectionChange ?? null;
this.config.onCellChange = props?.onCellChange ?? null;
this.config.onCopy = props?.onCopy ?? null
this.rowsBar = new RowsBar(this);
this.columnsBar = new ColumnsBar(this);
this.sheet = new Sheet(this);
this.table = new Table(this);
this.scroller = new Scroller(this);
this.toolbar = new Toolbar(this);
this.editor = new Editor(this);
this.cache = this.getInitialCache();
this.viewport = new Viewport(
this,
this.scroller.getViewportBoundlingRect(),
);
this.selection = new Selection();
this.events = new Events(this);
this.clipboard = new Clipboard(this);
this.data = data;
this.styles = new Styles();
this.buildComponent();
this.setElementsPositions();
this.appendTableToTarget(target);
this.renderSheet();
this.renderColumnsBar();
this.renderRowsBar();
}
private setRowsBarPosition() {
const top = this.columnsBar.height + this.toolbar.height;
const left = 0;
this.rowsBar.setElementPosition(top, left);
}
private setColumnsBarPosition() {
const top = this.toolbar.height;
const left = this.rowsBar.width;
console.log(top, left);
this.columnsBar.setElementPosition(top, left);
}
private setElementsPositions() {
this.setRowsBarPosition();
this.setColumnsBarPosition();
}
private getInitialCache(): Cache {
const cachedCols: CachedColumn[] = [];
let currentWidth = 0;
for (let i = 0; i <= this.config.columns.length - 1; i++) {
const col = this.config.columns[i];
currentWidth += col.width;
const cacheCol = new CachedColumn({
xPos: currentWidth,
colIdx: i,
});
cachedCols.push(cacheCol);
}
const cachedRows: CachedRow[] = [];
let currentHeight = 0;
for (let i = 0; i <= this.config.rows.length - 1; i++) {
const row = this.config.rows[i];
currentHeight += row.height;
const cacheRow = new CachedRow({
yPos: currentHeight,
rowIdx: i,
});
cachedRows.push(cacheRow);
}
const cache = new Cache({
columns: cachedCols,
rows: cachedRows,
});
console.log("CACHE: ", cache);
console.log("CONFIG: ", this.config);
return cache;
}
private buildComponent(): void {
const content = document.createElement("div"); //* Abstract
content.style.top = this.columnsBarHeight + "px";
content.style.left = this.rowsBarWidth + "px";
content.appendChild(this.sheet.element);
content.classList.add(CSS_PREFIX + "content");
this.table.element.appendChild(this.toolbar.element);
this.table.element.appendChild(this.rowsBar.element);
this.table.element.appendChild(this.columnsBar.element);
this.table.element.appendChild(content);
this.table.element.appendChild(this.scroller.element);
this.table.element.append(this.editor.element);
}
/**Destroy spreadsheet DOM element.
*
* May be usefull when need to rerender component.
*/
public destroy() {
this.table.element.remove();
}
private appendTableToTarget(target: string | HTMLElement) {
if (typeof target === "string") {
const element = document.querySelector(target);
if (!element)
throw new Error(
`Element with selector ${target} is not finded in DOM.\n Make sure it exists.`,
);
element?.appendChild(this.table.element);
}
if (target instanceof HTMLElement) {
target.append(this.table.element);
}
}
/** Canvas rendering context 2D.
*
* Abble to draw on canvas with default CanvasAPI methods
*/
get ctx() {
return this.sheet.ctx;
}
get viewProps() {
return this.config.view;
}
get columnsBarHeight() {
return this.columnsBar.height;
}
get rowsBarWidth() {
return this.rowsBar.width;
}
get toolbarHeight() {
return this.toolbar.height;
}
/** Focusing on interactive part of spreadsheet */
focusTable() {
this.scroller.element.focus();
}
getCellByCoords(x: number, y: number) {
return this.sheet.getCellByCoords(x, y);
}
getCell(position: Position): Cell {
const { column, row } = position;
return this.data[row][column];
}
changeCellValues(
position: Position,
values: Partial<Omit<CellConstructorProps, "position">>,
enableCallback: boolean = true
) {
const { column, row } = position;
this.data[row][column].changeValues(values);
this.events.dispatch({
type: EventTypes.CELL_CHANGE,
cell: this.data[row][column],
enableCallback: enableCallback
})
this.renderCell(row, column);
}
changeCellStyles(position: Position, styles: CellStyles) {
const { column, row } = position;
this.data[row][column].changeStyles(styles);
this.renderCell(row, column);
}
applyActionToRange(
range: RangeSelectionType,
callback: (cell: Cell) => void,
): void {
const fromRow = Math.min(range.from.row, range.to.row);
const toRow = Math.max(range.from.row, range.to.row);
const fromCol = Math.min(range.from.column, range.to.column);
const toCol = Math.max(range.from.column, range.to.column);
for (let row = fromRow; row <= toRow; row++) {
for (let col = fromCol; col <= toCol; col++) {
const cell = this.data[row][col];
callback(cell);
}
}
}
deleteSelectedCellsValues() {
if (this.selection.selectedRange !== null) {
this.applyActionToRange(this.selection.selectedRange, (cell) => {
this.changeCellValues(cell.position, {
displayValue: "",
resultValue: "",
value: "",
});
});
} else {
if (!this.selection.selectedCell) return;
this.changeCellValues(this.selection.selectedCell, {
displayValue: "",
resultValue: "",
value: "",
});
}
}
showEditor(position: Position, initialString?: string) {
this.editor.show(position, initialString);
}
renderSheet() {
this.sheet.renderSheet();
}
renderColumnsBar() {
this.columnsBar.renderBar();
}
renderRowsBar() {
this.rowsBar.renderBar();
}
renderCell(row: number, col: number) {
this.data[row][col].render(this);
}
public loadData(data: Cell[][] | SerializableCell[][]): Spreadsheet {
const rowsLength = data.length;
const colsLength = data[0] ? this.data[0].length : 0;
this.data = [];
const formattedData: Cell[][] = [];
for (let row = 0; row < rowsLength; row++) {
const innerRow: Cell[] = [];
for (let col = 0; col < colsLength; col++) {
const cell = data[row][col];
innerRow.push(
new Cell({
displayValue: cell.displayValue,
position: cell.position,
resultValue: cell.resultValue,
value: cell.value,
style: cell.style,
}),
);
}
formattedData.push(innerRow);
}
this.data = formattedData;
this.selection.selectedCell = null;
this.selection.selectedRange = null;
this.config = this.makeConfigFromData(formattedData, this.config.view);
this.cache = this.getInitialCache();
this.scroller.updateScrollerSize();
this.viewport = new Viewport(
this,
this.scroller.getViewportBoundlingRect(),
);
this.renderSheet();
return this;
}
private makeConfigFromData(data: Cell[][], view: ViewProperties): Config {
const lastRowIdx = data.length - 1;
const lastColIdx = data[0] ? data[0].length : 0;
const rows: Row[] = [];
for (let row = 0; row < lastRowIdx; row++) {
rows.push(
new Row({
height: 40,
title: String(row),
}),
);
}
const columns: Column[] = [];
for (let col = 0; col < lastColIdx; col++) {
columns.push(
new Column({
width: 150,
title: String(col),
}),
);
}
const config = new Config({
view,
rows,
columns,
onCellClick: null,
});
return config;
}
public serializeData(): SerializableCell[][] {
const rowsLength = this.data.length;
const colsLength = this.data[0] ? this.data[0].length : 0;
const cellsArray: SerializableCell[][] = [];
for (let row = 0; row < rowsLength; row++) {
const innerRow: SerializableCell[] = [];
for (let col = 0; col < colsLength; col++) {
innerRow.push(this.data[row][col].getSerializableCell());
}
cellsArray.push(innerRow);
}
return cellsArray;
}
}
export * from "./modules/cache";
export * from "./modules/cell";
export * from "./modules/column";
export * from "./modules/config";
export * from "./modules/renderBox";
export * from "./modules/row";
export * from "./modules/selection";
export * from "./modules/styles";
export * from "./modules/viewport";
export * from "./utils/createData";