Compare commits

...

17 Commits
events ... main

Author SHA1 Message Date
Eugene 41bae423c3 add config 2023-11-10 17:48:19 +03:00
Eugene 47f2163124 Added multiply config for component DEMO and lib mode. 2023-11-10 17:48:15 +03:00
Eugene ae0f8b49e6 Added excel formula support
Not reactive now.
2023-07-28 19:30:59 +03:00
Eugene c26c166295 Fixed bugs in Chrome browser
Fixed on load data config listeners reassigning
2023-07-27 15:54:36 +03:00
Eugene cddfc134f8 Formatted & Linted
Dist update
Updated version of spreadsheet
2023-07-27 12:45:53 +03:00
Eugene 1e2847f7f2 Removed unused console logs 2023-07-27 12:45:53 +03:00
Eugene Antonenkov d8c5e4343f
Update README.md 2023-07-27 12:24:46 +03:00
Eugene Antonenkov 997e8f086b
Update README.md 2023-07-27 12:23:59 +03:00
Eugene 6dc20f92e7 Updated styles
Updated README
2023-07-27 12:22:15 +03:00
Eugene 8aed4c81b9 Fixed some render bugs 2023-07-27 11:54:06 +03:00
Eugene ca67d409d5 Added selection highlight with alpha 2023-07-27 11:42:51 +03:00
Eugene Antonenkov 8bdaff0521
Merge pull request #1 from yazmeyaa/clipboard
Clipboard
2023-07-27 00:31:52 +03:00
Eugene 651fea95e4 Dist update 2023-07-26 21:43:46 +03:00
Eugene 954b3b8260 Linted&fromatted 2023-07-26 21:43:22 +03:00
Eugene 022435103b Added copy&paste support
Added copy event
2023-07-26 21:42:27 +03:00
Eugene 3a1367a901 Dist update 2023-07-26 15:19:26 +03:00
Eugene fc9a0df38d Linted & Formatted project 2023-07-26 15:18:56 +03:00
34 changed files with 1979 additions and 1494 deletions

View File

@ -1,9 +1,15 @@
# Modern Spreadsheet
<img src="https://raw.githubusercontent.com/yazmeyaa/modern_spreadsheet/6dc20f92e769210c076600c7fcfacd4ed528f085/repo_assets/spreadsheet_preview.png?raw=true" alt="spreadsheet_preview">
## Features:
- High performance spreadsheet based on CanvasAPI.
- TypeScript supported
- Native scrolling
- Customizable
- Copy & Paste support
## Basic usage
### Basic usage
```ts
import Spreadsheet from "modern_spreadsheet";
@ -14,7 +20,7 @@ const sheet = new Spreadsheet(target);
//...
```
## Save and load data
### Save and load data
```ts
function saveData() {
@ -30,10 +36,11 @@ function loadData() {
}
```
## Supported events
#### Supported events
- onCellClick
- onSelectionChange
- onCellChange
- onCopy
### Using events examples
```ts
@ -41,23 +48,27 @@ import Spreadsheet, { SpreadsheetConstructorProperties } from "./main";
const options: SpreadsheetConstructorProperties = {
onCellClick: (event, cell) => {
console.log('Cell click', event, cell)
console.log("Cell click", event, cell);
},
onSelectionChange: (selection) => {
console.log("Changed selection: ", selection)
console.log("Changed selection: ", selection);
},
onCellChange(cell) {
console.log("Cell changed: ", cell)
onCellChange = (cell) => {
console.log("Cell changed: ", cell);
},
}
onCopy: (range, data, dataAsString) => {
console.log("Copy event: ", range, data, dataAsString)
}
};
const sheet = new Spreadsheet("#spreadsheet", options);
```
## Roadmap
### Roadmap
- ~~Rows number and columns heading render~~
- ~~Custom event functions (ex.: onSelectionChange, onCellEdit...). Full list of supported events will available on this page~~
- ~~Copy & Paste support~~
- Rows and columns resizing
- Toolbar
- Context menu
@ -65,4 +76,3 @@ const sheet = new Spreadsheet("#spreadsheet", options);
- Selected cell depends cells highlight
- Async formulas support
- Mutlisheets (?)
- Copy & Paste support

View File

@ -12,6 +12,7 @@ export declare class Scroller {
private root;
private isSelecting;
constructor(root: Spreadsheet);
setSelectingMode(mode: boolean): void;
private handleMouseMove;
private handleMouseUp;
private handleDoubleClick;

View File

@ -10,5 +10,8 @@ export declare class Sheet {
constructor(root: Spreadsheet);
getCellByCoords(x: number, y: number): Position;
renderCell(position: Position): void;
private getSelectionRange;
private renderSelectionRange;
renderSelection(): void;
renderSheet(): void;
}

8
dist/main.cjs vendored

File diff suppressed because one or more lines are too long

2
dist/main.cjs.map vendored

File diff suppressed because one or more lines are too long

16
dist/main.d.ts vendored
View File

@ -1,13 +1,18 @@
import { Cell, CellConstructorProps, CellStyles, Position, SerializableCell } from "./modules/cell";
import { Config, ViewProperties } from "./modules/config";
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 { Cache } from "./modules/cache";
interface SpreadsheetConstructorProperties {
config?: Omit<Config, "view">;
import { Events } from "./modules/events";
import { Clipboard } from "./modules/clipboard";
export interface SpreadsheetConstructorProperties {
view?: ViewProperties;
onCellClick?: CellClickEvent | null;
onSelectionChange?: SelectionChangeEvent | null;
onCellChange?: CellChangeEvent | null;
onCopy?: CopyEvent | null;
}
export declare const CSS_PREFIX = "modern_sc_";
export default class Spreadsheet {
@ -24,6 +29,8 @@ export default class Spreadsheet {
viewport: Viewport;
selection: Selection;
cache: Cache;
events: Events;
clipboard: Clipboard;
constructor(target: string | HTMLElement, props?: SpreadsheetConstructorProperties);
private setRowsBarPosition;
private setColumnsBarPosition;
@ -49,12 +56,13 @@ export default class Spreadsheet {
focusTable(): void;
getCellByCoords(x: number, y: number): Position;
getCell(position: Position): Cell;
changeCellValues(position: Position, values: Partial<Omit<CellConstructorProps, "position">>): void;
changeCellValues(position: Position, values: Partial<Omit<CellConstructorProps, "position">>, enableCallback?: boolean): void;
changeCellStyles(position: Position, styles: CellStyles): void;
applyActionToRange(range: RangeSelectionType, callback: (cell: Cell) => void): void;
deleteSelectedCellsValues(): void;
showEditor(position: Position, initialString?: string): void;
renderSheet(): void;
renderSelection(): void;
renderColumnsBar(): void;
renderRowsBar(): void;
renderCell(row: number, col: number): void;

637
dist/main.js vendored

File diff suppressed because it is too large Load Diff

2
dist/main.js.map vendored

File diff suppressed because one or more lines are too long

View File

@ -49,7 +49,6 @@ export declare class Cell {
getSerializableCell(): SerializableCell;
changeStyles(styles: CellStyles): void;
changeValues(values: Partial<Omit<CellConstructorProps, "position">>): void;
private isCellInRange;
render(root: Spreadsheet): void;
}
export {};

9
dist/modules/clipboard.d.ts vendored Normal file
View File

@ -0,0 +1,9 @@
import Spreadsheet, { RangeSelectionType } from "../main";
import { Cell, Position } from "./cell";
export declare class Clipboard {
saved: Cell[][] | null;
root: Spreadsheet;
constructor(root: Spreadsheet);
copy(data: Cell[][], range: RangeSelectionType): void;
paste(root: Spreadsheet, { column, row }: Position, event: ClipboardEvent): void;
}

View File

@ -1,9 +1,15 @@
import { Cell } from "./cell";
import { Column } from "./column";
import { Row } from "./row";
import { RangeSelectionType, Selection } from "./selection";
export interface ViewProperties {
width: number;
height: number;
}
export type CellClickEvent = (event: MouseEvent, cell: Cell) => void;
export type SelectionChangeEvent = (selection: Selection) => void;
export type CellChangeEvent = (cell: Cell) => void;
export type CopyEvent = (range: RangeSelectionType, data: Cell[][], dataAsString: string) => void;
export type ConfigProperties = {
/** Please, end it with '_' symbol.
*
@ -14,6 +20,10 @@ export type ConfigProperties = {
rows: Row[];
columns: Column[];
view: ViewProperties;
onCellClick?: CellClickEvent | null;
onSelectionChange?: SelectionChangeEvent | null;
onCellChange?: CellChangeEvent | null;
onCopy?: CopyEvent | null;
};
export type SheetConfigConstructorProps = {
rows: Row[];
@ -23,5 +33,9 @@ export declare class Config {
rows: Row[];
columns: Column[];
view: ViewProperties;
onCellClick: CellClickEvent | null;
onSelectonChange: SelectionChangeEvent | null;
onCellChange: CellChangeEvent | null;
onCopy: CopyEvent | null;
constructor(props: ConfigProperties);
}

39
dist/modules/events.d.ts vendored Normal file
View File

@ -0,0 +1,39 @@
import { Scroller } from "../components/scroller";
import Spreadsheet, { Cell, RangeSelectionType, Selection } from "../main";
export declare enum EventTypes {
CELL_CLICK = "CELL_CLICK",
SELECTION_CHANGE = "CHANGE_SELECTION",
CELL_CHANGE = "CELL_CHANGE",
COPY_CELLS = "COPY_CELLS"
}
export type CellClickEvent = {
type: EventTypes.CELL_CLICK;
event: MouseEvent;
scroller: Scroller;
};
export type ChangeSelectionEvent = {
type: EventTypes.SELECTION_CHANGE;
selection: Selection;
enableCallback?: boolean;
};
export type ChangeCellEvent = {
type: EventTypes.CELL_CHANGE;
cell: Cell;
enableCallback?: boolean;
};
export type CopyAction = {
type: EventTypes.COPY_CELLS;
range: RangeSelectionType;
data: Cell[][];
dataAsString: string;
};
export type ActionTypes = CellClickEvent | ChangeSelectionEvent | ChangeCellEvent | CopyAction;
export declare class Events {
root: Spreadsheet;
constructor(root: Spreadsheet);
dispatch(action: ActionTypes): void;
private cellClick;
private changeSelection;
private changeCellValues;
private copy;
}

2
dist/style.css vendored
View File

@ -1 +1 @@
body{padding:0;margin:0}.modern_sc_content{position:absolute}.modern_sc_spreadsheet_container{position:relative;isolation:isolate;border:2px solid black}.modern_sc_sheet{display:block;contain:strict}.modern_sc_scroller{position:absolute;overflow:scroll;box-sizing:border-box;transform:translateZ(0)}.modern_sc_scroller:focus{outline:none}.modern_sc_editor{position:absolute;box-sizing:border-box;font-size:16px;font-family:Arial,Helvetica,sans-serif}.modern_sc_hide{visibility:hidden}
body{padding:0;margin:0}.modern_sc_spreadsheet_container{position:relative;isolation:isolate;border:2px solid black}.modern_sc_content{position:absolute}.modern_sc_sheet{display:block;contain:strict}.modern_sc_scroller{position:absolute;overflow:scroll;box-sizing:border-box;transform:translateZ(0)}.modern_sc_scroller:focus{outline:none}.modern_sc_editor{position:absolute;box-sizing:border-box;font-size:16px;font-family:Arial,Helvetica,sans-serif}.modern_sc_hide{visibility:hidden}

3
dist/utils/position.d.ts vendored Normal file
View File

@ -0,0 +1,3 @@
import { BaseSelectionType, RangeSelectionType } from "../main";
export declare function checkEqualRanges(range1: RangeSelectionType, range2: RangeSelectionType): boolean;
export declare function checkEqualCellSelections(selection1: BaseSelectionType, selection2: BaseSelectionType): boolean;

View File

@ -1,7 +1,7 @@
{
"name": "modern_spreadsheet",
"private": false,
"version": "0.0.29",
"version": "0.0.33",
"exports": {
".": {
"import": "./dist/main.js",
@ -33,18 +33,21 @@
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"build:HTML": "tsc && cross-env BUILD_BROWSER=true vite build",
"build:watch": "tsc && vite build --watch",
"preview": "vite preview",
"predeploy": "npm run dist",
"deploy": "gh-pages -d dist",
"lint": "eslint src --ext .ts",
"lint:fix": "eslint src --ext .ts --fix"
"lint:fix": "eslint src --ext .ts --fix",
"format": "prettier --write ./src/**/*.ts"
},
"devDependencies": {
"@rollup/plugin-typescript": "^11.1.2",
"@types/node": "^20.4.4",
"@typescript-eslint/eslint-plugin": "^6.2.0",
"@typescript-eslint/parser": "^6.2.0",
"cross-env": "^7.0.3",
"eslint": "^8.45.0",
"eslint-config-prettier": "^8.8.0",
"gh-pages": "^5.0.0",
@ -53,6 +56,10 @@
"sass": "^1.63.6",
"tslib": "^2.6.0",
"typescript": "^5.0.2",
"vite": "^4.4.0"
"vite": "^4.4.0",
"vite-plugin-html": "^3.2.0"
},
"dependencies": {
"fast-formula-parser": "^1.0.19"
}
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View File

@ -82,9 +82,7 @@ export class ColumnsBar {
const isColSelected = this.isColumnSelected(column);
this.ctx.fillStyle = isColSelected
? this.root.styles.cells.selectedBackground
: "white";
this.ctx.fillStyle = isColSelected ? "#c7ebff" : "white";
this.ctx.strokeStyle = "black";
this.ctx.lineWidth = 1;

View File

@ -55,16 +55,18 @@ export class Editor {
case "Enter": {
if (!this.root.selection.selectedCell) return;
this.root.changeCellValues(this.root.selection.selectedCell, {
value: this.element.value,
displayValue: this.element.value,
});
this.root.events.dispatch({
type: EventTypes.CELL_CHANGE,
cell: this.root.getCell(this.root.selection.selectedCell),
values: {
value: this.element.value,
displayValue: this.element.value,
}
})
});
this.hide();
this.root.renderSelection();
}
}
};

View File

@ -69,9 +69,7 @@ export class RowsBar {
const isRowSeleted = this.isRowSelected(column);
this.ctx.fillStyle = isRowSeleted
? this.root.styles.cells.selectedBackground
: "white";
this.ctx.fillStyle = isRowSeleted ? "#c7ebff" : "white";
this.ctx.strokeStyle = "black";
this.ctx.lineWidth = this.resizerHeight;

View File

@ -1,4 +1,4 @@
import Spreadsheet, { CSS_PREFIX } from "../main";
import Spreadsheet, { CSS_PREFIX, Cell, Selection } from "../main";
import { EventTypes } from "../modules/events";
import { checkEqualCellSelections } from "../utils/position";
@ -40,10 +40,18 @@ export class Scroller {
this.element.addEventListener("dblclick", this.handleDoubleClick);
this.element.addEventListener("keydown", this.handleKeydown);
this.element.addEventListener("paste", (event) => {
if (!this.root.selection.selectedCell) return;
this.root.clipboard.paste(
this.root,
this.root.selection.selectedCell,
event,
);
});
}
public setSelectingMode(mode: boolean) {
this.isSelecting = mode
this.isSelecting = mode;
}
private handleMouseMove = (event: MouseEvent) => {
@ -51,37 +59,42 @@ export class Scroller {
const { offsetX, offsetY } = event;
const lastSelectedCell = this.root.getCellByCoords(offsetX, offsetY);
let isRangeChanged = false
let isRangeChanged = false;
if (this.root.selection.selectedRange) {
isRangeChanged = !checkEqualCellSelections(this.root.selection.selectedRange.to, lastSelectedCell)
isRangeChanged = !checkEqualCellSelections(
this.root.selection.selectedRange.to,
lastSelectedCell,
);
if (isRangeChanged) {
this.root.selection.selectedRange.to = lastSelectedCell;
this.root.events.dispatch({
type: EventTypes.SELECTION_CHANGE,
selection: this.root.selection,
enableCallback: true
})
enableCallback: true,
});
}
}
};
private handleMouseUp = () => {
this.isSelecting = false;
const newSelection = {...this.root.selection}
const newSelection = { ...this.root.selection };
if (this.root.selection.selectedRange) {
if (
checkEqualCellSelections(this.root.selection.selectedRange.from, this.root.selection.selectedRange.to)
checkEqualCellSelections(
this.root.selection.selectedRange.from,
this.root.selection.selectedRange.to,
)
) {
newSelection.selectedRange = null;
this.root.events.dispatch({
type: EventTypes.SELECTION_CHANGE,
selection: newSelection,
enableCallback: false
})
enableCallback: false,
});
}
}
@ -98,6 +111,7 @@ export class Scroller {
private handleKeydown = (event: KeyboardEvent) => {
//* Navigation
if (
["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)
) {
@ -118,7 +132,7 @@ export class Scroller {
if (
this.root.selection.selectedCell &&
this.root.selection.selectedCell.column <
this.root.config.columns.length - 1
this.root.config.columns.length - 1
) {
this.root.selection.selectedCell.column += 1;
// this.root.renderSheet();
@ -139,7 +153,7 @@ export class Scroller {
if (
this.root.selection.selectedCell &&
this.root.selection.selectedCell.row <
this.root.config.rows.length - 1
this.root.config.rows.length - 1
) {
this.root.selection.selectedCell.row += 1;
// this.root.renderSheet();
@ -150,12 +164,12 @@ export class Scroller {
this.root.events.dispatch({
type: EventTypes.SELECTION_CHANGE,
selection: this.root.selection,
enableCallback: true
})
enableCallback: true,
});
}
//* Start typings
const keysRegex = /^([a-z]|[а-я])$/;
const keysRegex = /^([a-z]|[а-я]|[0-9]|=)$/;
if (!event.metaKey && !event.ctrlKey) {
//* Prevent handle shortcutrs
const isPressedLetterKey = keysRegex.test(event.key.toLowerCase());
@ -177,14 +191,51 @@ export class Scroller {
this.root.deleteSelectedCellsValues();
this.root.renderSheet();
}
if (event.metaKey || event.ctrlKey) {
if (event.code === "KeyC") {
let cells: Cell[][] = undefined!;
const selection = new Selection();
if (this.root.selection.selectedRange) {
const { from, to } = this.root.selection.selectedRange;
selection.selectedRange = this.root.selection.selectedRange;
const subArrByRows = this.root.data.slice(from.row, to.row + 1);
const subArrByCols = subArrByRows.map((row) => {
return row.slice(from.column, to.column + 1);
});
cells = [...subArrByCols];
} else if (this.root.selection.selectedCell) {
const { column, row } = this.root.selection.selectedCell;
cells = [[this.root.data[row][column]]];
selection.selectedRange = {
from: this.root.selection.selectedCell,
to: this.root.selection.selectedCell,
};
} else {
return;
}
this.root.clipboard.copy(cells, selection.selectedRange);
return;
}
if (event.code === "KeyV") {
// if (!this.root.selection.selectedCell) return;
// this.root.clipboard.paste(this.root, this.root.selection.selectedCell);
}
}
};
private handleClick = (event: MouseEvent) => {
this.root.events.dispatch({
type: EventTypes.CELL_CLICK,
event,
scroller: this
})
scroller: this,
});
};
private handleScroll = () => {
@ -231,6 +282,7 @@ export class Scroller {
this.verticalScroller = verticalScroller;
this.horizontalScroller = horizontalScroller;
scroller.appendChild(groupScrollers);
scroller.contentEditable = "false";
scroller.classList.add(CSS_PREFIX + "scroller");
return { scroller, verticalScroller, horizontalScroller };

View File

@ -1,4 +1,4 @@
import Spreadsheet, { CSS_PREFIX } from "../main";
import Spreadsheet, { CSS_PREFIX, RenderBox } from "../main";
import { Position } from "../modules/cell";
/**
@ -52,12 +52,79 @@ export class Sheet {
this.root.data[row][column].render(this.root);
}
private getSelectionRange() {
const { selectedCell, selectedRange } = this.root.selection;
if (!selectedCell && !selectedRange) return;
if (selectedRange) {
const startRow = Math.min(selectedRange.from.row, selectedRange.to.row);
const startCol = Math.min(
selectedRange.from.column,
selectedRange.to.column,
);
const lastRow = Math.max(selectedRange.from.row, selectedRange.to.row);
const lastCol = Math.max(
selectedRange.from.column,
selectedRange.to.column,
);
const startCellBox = new RenderBox(this.root.config, {
row: startRow,
column: startCol,
});
let width = 0;
for (let col = startCol; col <= lastCol; col++) {
width += this.root.config.columns[col].width;
}
let height = 0;
for (let row = startRow; row <= lastRow; row++) {
height += this.root.config.rows[row].height;
}
const x = startCellBox.x - this.root.viewport.left;
const y = startCellBox.y - this.root.viewport.top;
return { x, y, height, width };
}
if (!selectedRange && selectedCell) {
const box = new RenderBox(this.root.config, selectedCell);
box.x -= this.root.viewport.left;
box.y -= this.root.viewport.top;
return box;
}
}
private renderSelectionRange(
x: number,
y: number,
width: number,
height: number,
) {
this.ctx.save();
this.ctx.strokeStyle = "#7da8ff";
this.ctx.lineWidth = 3;
this.ctx.strokeRect(x, y, width, height);
this.ctx.fillStyle = "#7da8ff35";
this.ctx.fillRect(x, y, width, height);
this.ctx.restore();
}
renderSelection() {
const box = this.getSelectionRange();
if (!box) return;
const { height, width, x, y } = box;
this.renderSelectionRange(x, y, width, height);
}
renderSheet() {
const firstRowIdx = this.root.viewport.firstRow;
const lastColIdx = this.root.viewport.lastCol + 3;
const lastRowIdx = this.root.viewport.lastRow + 3;
const firstColIdx = this.root.viewport.firstCol;
for (let row = firstRowIdx; row <= lastRowIdx; row++) {
for (let col = firstColIdx; col <= lastColIdx; col++) {
if (!this.root.config.columns[col] || !this.root.config.rows[row])
@ -66,5 +133,6 @@ export class Sheet {
this.renderCell({ column: col, row });
}
}
this.renderSelection();
}
}

View File

@ -2,19 +2,21 @@ import Spreadsheet, { SpreadsheetConstructorProperties } from "./main";
const options: SpreadsheetConstructorProperties = {
onCellClick: (event, cell) => {
console.log('Cell click', event, cell)
console.log("Cell click", event, cell);
},
onSelectionChange: (selection) => {
console.log("Changed selection: ", selection)
console.log("Changed selection: ", selection);
},
onCellChange(cell) {
console.log("Cell changed: ", cell)
console.log("Cell changed: ", cell);
},
}
onCopy: (range, data, dataAsString) => {
console.log("Copy event: ", range, data, dataAsString)
}
};
const sheet = new Spreadsheet("#spreadsheet", options);
function saveDataToLS() {
const serializableData = sheet.serializeData();
localStorage.setItem("sheet", JSON.stringify(serializableData));

View File

@ -10,7 +10,14 @@ import {
Position,
SerializableCell,
} from "./modules/cell";
import { CellChangeEvent, CellClickEvent, Config, SelectionChangeEvent, ViewProperties } from "./modules/config";
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";
@ -21,7 +28,9 @@ import { Row } from "./modules/row";
import { Column } from "./modules/column";
import { ColumnsBar } from "./components/columnsBar";
import { RowsBar } from "./components/rowsBar";
import { Events } from "./modules/events";
import { EventTypes, Events } from "./modules/events";
import { Clipboard } from "./modules/clipboard";
import { FormulaParser } from "./modules/formulaParser";
/*
! Component structure
@ -37,9 +46,10 @@ import { Events } from "./modules/events";
export interface SpreadsheetConstructorProperties {
view?: ViewProperties;
onCellClick?: CellClickEvent | null
onSelectionChange?: SelectionChangeEvent | null
onCellChange?: CellChangeEvent | null
onCellClick?: CellClickEvent | null;
onSelectionChange?: SelectionChangeEvent | null;
onCellChange?: CellChangeEvent | null;
onCopy?: CopyEvent | null
}
export const CSS_PREFIX = "modern_sc_";
@ -58,7 +68,9 @@ export default class Spreadsheet {
public viewport: Viewport;
public selection: Selection;
public cache: Cache;
public events: Events
public events: Events;
public clipboard: Clipboard;
public formulaParser: FormulaParser
constructor(
target: string | HTMLElement,
@ -75,9 +87,10 @@ export default class Spreadsheet {
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.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);
@ -92,8 +105,9 @@ export default class Spreadsheet {
this.scroller.getViewportBoundlingRect(),
);
this.selection = new Selection();
this.events = new Events(this)
this.events = new Events(this);
this.clipboard = new Clipboard(this);
this.formulaParser = new FormulaParser(this)
this.data = data;
this.styles = new Styles();
@ -113,7 +127,6 @@ export default class Spreadsheet {
private setColumnsBarPosition() {
const top = this.toolbar.height;
const left = this.rowsBar.width;
console.log(top, left);
this.columnsBar.setElementPosition(top, left);
}
@ -152,8 +165,6 @@ export default class Spreadsheet {
rows: cachedRows,
});
console.log("CACHE: ", cache);
console.log("CONFIG: ", this.config);
return cache;
}
@ -237,10 +248,19 @@ export default class Spreadsheet {
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);
}
@ -295,6 +315,10 @@ export default class Spreadsheet {
this.sheet.renderSheet();
}
renderSelection() {
this.sheet.renderSelection();
}
renderColumnsBar() {
this.columnsBar.renderBar();
}
@ -309,11 +333,11 @@ export default class Spreadsheet {
public loadData(data: Cell[][] | SerializableCell[][]): Spreadsheet {
const rowsLength = data.length;
const colsLength = data[0] ? this.data[0].length : 0;
const colsLength = data[0] ? data[0].length : 0;
this.data = [];
const formattedData: Cell[][] = [];
// Transform serialized objects to Cells
for (let row = 0; row < rowsLength; row++) {
const innerRow: Cell[] = [];
for (let col = 0; col < colsLength; col++) {
@ -331,11 +355,17 @@ export default class Spreadsheet {
formattedData.push(innerRow);
}
this.data = formattedData;
const config = this.makeConfigFromData(formattedData, this.config.view);
config.onCellChange = this.config.onCellChange
config.onCellClick = this.config.onCellClick
config.onCopy = this.config.onCopy
config.onSelectonChange = this.config.onSelectonChange
this.data = formattedData;
this.selection.selectedCell = null;
this.selection.selectedRange = null;
this.config = this.makeConfigFromData(formattedData, this.config.view);
this.config = config
this.cache = this.getInitialCache();
this.scroller.updateScrollerSize();
this.viewport = new Viewport(
@ -376,7 +406,7 @@ export default class Spreadsheet {
view,
rows,
columns,
onCellClick: null
onCellClick: null,
});
return config;

View File

@ -44,21 +44,21 @@ export class Cache {
public getRowByYCoord(y: number): number {
let rowIdx = 0;
for (let i = 0; i < this.rows.length; i++) {
if (y <= this.rows[i].yPos) {
//* Intersection detect
rowIdx = i;
rowIdx = i
if (y <= this.rows[i].yPos) { //* Intersection detect
break;
}
}
return rowIdx;
}
public getColumnByXCoord(x: number): number {
let colIdx = 0;
for (let i = 0; i < this.columns.length; i++) {
if (x <= this.columns[i].xPos) {
//* Intersection detect
colIdx = i;
colIdx = i
if (x <= this.columns[i].xPos) { //* Intersection detect
break;
}
}

View File

@ -1,4 +1,5 @@
import Spreadsheet from "../main";
import { FormulaParser } from "./formulaParser";
import { RenderBox } from "./renderBox";
export type CellConstructorProps = {
@ -69,6 +70,9 @@ export class Cell {
position: Position;
style: CellStyles | null = null;
cellsDependsOnThisCell: Position[] = []
dependedFromCells: Position[] = []
constructor(props: CellConstructorProps) {
this.value = props.value;
this.displayValue = props.displayValue;
@ -96,50 +100,53 @@ export class Cell {
Object.assign(this, values);
}
private isCellInRange(root: Spreadsheet): boolean {
const { column, row } = this.position;
const { selectedRange } = root.selection;
evalFormula(parser: FormulaParser) {
if (this.value.substring(0, 1) !== '=') return;
if (!selectedRange) return false;
const isCellInRow =
row >= Math.min(selectedRange.from.row, selectedRange.to.row) &&
row <= Math.max(selectedRange.to.row, selectedRange.from.row);
const isCellInCol =
column >= Math.min(selectedRange.from.column, selectedRange.to.column) &&
column <= Math.max(selectedRange.to.column, selectedRange.from.column);
return isCellInCol && isCellInRow;
this.resultValue = parser.parser.parse(this.value.slice(1), {
col: this.position.column,
row: this.position.row
})
}
// private isCellInRange(root: Spreadsheet): boolean {
// const { column, row } = this.position;
// const { selectedRange } = root.selection;
// if (!selectedRange) return false;
// const isCellInRow =
// row >= Math.min(selectedRange.from.row, selectedRange.to.row) &&
// row <= Math.max(selectedRange.to.row, selectedRange.from.row);
// const isCellInCol =
// column >= Math.min(selectedRange.from.column, selectedRange.to.column) &&
// column <= Math.max(selectedRange.to.column, selectedRange.from.column);
// return isCellInCol && isCellInRow;
// }
render(root: Spreadsheet) {
const renderBox = new RenderBox(root.config, this.position);
let { x, y } = renderBox;
const { height, width } = renderBox;
const { ctx } = root;
const isCellSelected =
root.selection.selectedCell?.row === this.position.row &&
root.selection.selectedCell.column === this.position.column;
const isCellInRange = this.isCellInRange(root);
// const isCellSelected =
// root.selection.selectedCell?.row === this.position.row &&
// root.selection.selectedCell.column === this.position.column;
// const isCellInRange = this.isCellInRange(root);
y -= root.viewport.top;
x -= root.viewport.left;
const styles = this.style ?? root.styles.cells;
ctx.clearRect(x, y, width, height);
ctx.fillStyle =
isCellSelected || isCellInRange
? styles.selectedBackground
: styles.background;
ctx.fillStyle = styles.background;
ctx.strokeStyle = "black";
ctx.fillRect(x, y, width - 1, height - 1);
ctx.strokeRect(x, y, width, height);
ctx.fillStyle =
isCellSelected || isCellInRange
? styles.selectedFontColor
: styles.fontColor;
ctx.fillStyle = styles.fontColor;
ctx.textAlign = "left";
ctx.font = `${styles.fontSize}px Arial`;
ctx.textBaseline = "middle";

106
src/modules/clipboard.ts Normal file
View File

@ -0,0 +1,106 @@
import Spreadsheet, { RangeSelectionType } from "../main";
import { Cell, CellConstructorProps, CellStyles, Position } from "./cell";
import { EventTypes } from "./events";
export class Clipboard {
saved: Cell[][] | null = null;
root: Spreadsheet;
constructor(root: Spreadsheet) {
this.root = root;
}
copy(data: Cell[][], range: RangeSelectionType) {
const mapedData = data
.map((row) => {
return row
.map((item) => {
return item.displayValue;
})
.join("\t");
})
.join("\n");
this.saved = data;
navigator.clipboard.writeText(mapedData);
this.root.events.dispatch({
type: EventTypes.COPY_CELLS,
data,
dataAsString: mapedData,
range,
});
}
paste(root: Spreadsheet, { column, row }: Position, event: ClipboardEvent) {
if (!this.saved) {
if (!event.clipboardData) return;
const data = event.clipboardData.getData("text");
try {
const arr = data.split("\n").map((item) => item.split("\t"));
const arrayOfCells = arr.map((innerRow) => {
return innerRow.map((item) => {
const cellProps: CellConstructorProps = {
displayValue: item,
position: {
column,
row,
},
resultValue: item,
style: new CellStyles(),
value: item,
};
return new Cell(cellProps);
});
});
const rowsLength = arrayOfCells.length;
const colsLength = arrayOfCells[0] ? arrayOfCells[0].length : 0;
for (let i = 0; i < rowsLength; i++) {
for (let j = 0; j < colsLength; j++) {
const savedCell = arrayOfCells[i][j];
const position = {
column: column + j,
row: row + i,
};
const values = {
displayValue: savedCell.displayValue,
value: savedCell.value,
style: savedCell.style,
};
root.changeCellValues(position, values, false);
}
}
} catch (err) {
console.error("Cannot read clipboard. ", err);
}
root.renderSheet();
return;
}
const rowsLength = this.saved.length;
const colsLength = this.saved[0] ? this.saved[0].length : 0;
for (let i = 0; i < rowsLength; i++) {
for (let j = 0; j < colsLength; j++) {
const savedCell = this.saved[i][j];
const position = {
column: column + j,
row: row + i,
};
const values = {
displayValue: savedCell.displayValue,
value: savedCell.value,
style: savedCell.style,
};
root.changeCellValues(position, values, false);
}
}
}
}

View File

@ -1,15 +1,20 @@
import { Cell } from "./cell";
import { Column } from "./column";
import { Row } from "./row";
import { Selection } from "./selection";
import { RangeSelectionType, Selection } from "./selection";
export interface ViewProperties {
width: number;
height: number;
}
export type CellClickEvent = (event: MouseEvent, cell: Cell) => void
export type SelectionChangeEvent = (selection: Selection) => void
export type CellChangeEvent = (cell: Cell) => void
export type CellClickEvent = (event: MouseEvent, cell: Cell) => void;
export type SelectionChangeEvent = (selection: Selection) => void;
export type CellChangeEvent = (cell: Cell) => void;
export type CopyEvent = (
range: RangeSelectionType,
data: Cell[][],
dataAsString: string,
) => void;
export type ConfigProperties = {
/** Please, end it with '_' symbol.
@ -21,9 +26,10 @@ export type ConfigProperties = {
rows: Row[];
columns: Column[];
view: ViewProperties;
onCellClick?: CellClickEvent | null
onSelectionChange?: SelectionChangeEvent | null
onCellChange?: CellChangeEvent | null
onCellClick?: CellClickEvent | null;
onSelectionChange?: SelectionChangeEvent | null;
onCellChange?: CellChangeEvent | null;
onCopy?: CopyEvent | null;
};
export type SheetConfigConstructorProps = {
@ -39,17 +45,19 @@ export class Config {
height: 600,
};
onCellClick: ((event: MouseEvent, cell: Cell) => void) | null = null
onSelectonChange: SelectionChangeEvent | null = null
onCellChange: CellChangeEvent | null = null
onCellClick: CellClickEvent | null = null;
onSelectonChange: SelectionChangeEvent | null = null;
onCellChange: CellChangeEvent | null = null;
onCopy: CopyEvent | null;
constructor(props: ConfigProperties) {
this.columns = props.columns;
this.rows = props.rows;
this.view = props.view;
this.onCellClick = props.onCellClick ?? null
this.onSelectonChange = props.onSelectionChange ?? null
this.onCellChange = props.onCellChange ?? null
this.onCellClick = props.onCellClick ?? null;
this.onSelectonChange = props.onSelectionChange ?? null;
this.onCellChange = props.onCellChange ?? null;
this.onCopy = props.onCopy ?? null;
}
}

View File

@ -1,107 +1,142 @@
import { Scroller } from "../components/scroller";
import Spreadsheet, { Cell, CellConstructorProps, Selection } from "../main";
import Spreadsheet, { Cell, RangeSelectionType, Selection } from "../main";
export enum EventTypes {
CELL_CLICK = "CELL_CLICK",
SELECTION_CHANGE = "CHANGE_SELECTION",
CELL_CHANGE = "CELL_CHANGE"
CELL_CLICK = "CELL_CLICK",
SELECTION_CHANGE = "CHANGE_SELECTION",
CELL_CHANGE = "CELL_CHANGE",
COPY_CELLS = "COPY_CELLS",
}
export type CellClickEvent = {
type: EventTypes.CELL_CLICK
event: MouseEvent
scroller: Scroller
}
type: EventTypes.CELL_CLICK;
event: MouseEvent;
scroller: Scroller;
};
export type ChangeSelectionEvent = {
type: EventTypes.SELECTION_CHANGE,
selection: Selection
enableCallback?: boolean
}
type: EventTypes.SELECTION_CHANGE;
selection: Selection;
enableCallback?: boolean;
};
export type ChangeCellEvent = {
type: EventTypes.CELL_CHANGE,
cell: Cell,
values: Partial<Omit<CellConstructorProps, "position">>
}
type: EventTypes.CELL_CHANGE;
cell: Cell;
enableCallback?: boolean;
};
export type CopyAction = {
type: EventTypes.COPY_CELLS;
range: RangeSelectionType;
data: Cell[][];
dataAsString: string;
};
export type ActionTypes =
| CellClickEvent
| ChangeSelectionEvent
| ChangeCellEvent
| CopyAction;
export type ActionTypes = CellClickEvent | ChangeSelectionEvent | ChangeCellEvent
export class Events {
root: Spreadsheet;
root: Spreadsheet
constructor(root: Spreadsheet) {
this.root = root;
}
constructor(root: Spreadsheet) {
this.root = root
}
async dispatch(action: ActionTypes) {
switch (action.type) {
case EventTypes.CELL_CLICK: {
const { event, scroller } = action;
//
//* Here may be side effects
//
this.cellClick(event, scroller);
break;
}
dispatch(action: ActionTypes) {
switch (action.type) {
case EventTypes.CELL_CLICK: {
const { event, scroller } = action
//
//* Here may be side effects
//
this.cellClick(event, scroller)
break;
}
case EventTypes.SELECTION_CHANGE: {
const { selection, enableCallback } = action;
//
//* Here may be side effects
//
this.changeSelection(selection, enableCallback);
break;
}
case EventTypes.SELECTION_CHANGE: {
const { selection, enableCallback } = action
//
//* Here may be side effects
//
this.changeSelection(selection, enableCallback)
break;
}
case EventTypes.CELL_CHANGE: {
const { cell, values } = action
//
//* Here may be side effects
//
this.changeCellValues(cell, values)
break;
}
default: {
break;
}
case EventTypes.CELL_CHANGE: {
const { cell, enableCallback } = action;
if (cell.value.substring(0, 1).startsWith('=')) {
try {
await cell.evalFormula(this.root.formulaParser)
cell.displayValue = cell.resultValue
this.root.renderCell(cell.position.row, cell.position.column)
this.changeCellValues(cell, enableCallback);
return;
}
catch (err) {
console.error(err)
}
}
this.root.renderCell(cell.position.row, cell.position.column)
this.changeCellValues(cell, enableCallback);
break;
}
case EventTypes.COPY_CELLS: {
const { data, dataAsString, range } = action;
this.copy(range, data, dataAsString);
break;
}
default: {
break;
}
}
}
private cellClick = (event: MouseEvent, scroller: Scroller) => {
if (event.button !== 0) return; // Left mouse button
const { offsetX, offsetY } = event;
const clickedCell = this.root.getCellByCoords(offsetX, offsetY);
const cell = this.root.getCell(clickedCell)
private cellClick = (event: MouseEvent, scroller: Scroller) => {
if (event.button !== 0) return; // Left mouse button
const { offsetX, offsetY } = event;
const clickedCell = this.root.getCellByCoords(offsetX, offsetY);
const cell = this.root.getCell(clickedCell);
const selection = new Selection()
selection.selectedCell = clickedCell
selection.selectedRange = {
from: clickedCell,
to: clickedCell,
};
const selection = new Selection();
selection.selectedCell = clickedCell;
selection.selectedRange = {
from: clickedCell,
to: clickedCell,
};
scroller.setSelectingMode(true);
scroller.setSelectingMode(true);
this.changeSelection(selection, true)
this.changeSelection(selection, true);
this.root.config.onCellClick?.(event, cell)
}
this.root.config.onCellClick?.(event, cell);
};
private changeSelection = (selection: Selection, enableCallback = false) => {
this.root.selection = selection
private changeSelection = (selection: Selection, enableCallback = false) => {
this.root.selection = selection;
if (enableCallback) this.root.config.onSelectonChange?.(selection)
this.root.renderSheet();
this.root.renderColumnsBar();
this.root.renderRowsBar();
}
if (enableCallback) this.root.config.onSelectonChange?.(selection);
this.root.renderSheet();
this.root.renderColumnsBar();
this.root.renderRowsBar();
};
private changeCellValues(cell: Cell, values: Partial<Omit<CellConstructorProps, "position">>) {
this.root.changeCellValues(cell.position, values)
private changeCellValues(cell: Cell, enableCallback: boolean = true) {
if (enableCallback) this.root.config.onCellChange?.(cell);
}
this.root.config.onCellChange?.(cell)
}
private copy = (
range: RangeSelectionType,
data: Cell[][],
dataAsString: string,
) => {
this.root.config.onCopy?.(range, data, dataAsString);
};
}

View File

@ -0,0 +1,23 @@
import Parser, { DepParser } from 'fast-formula-parser'
import Spreadsheet from '../main'
export class FormulaParser {
parser: Parser
depParser: DepParser
root: Spreadsheet
constructor(root: Spreadsheet) {
this.root = root
this.parser = new Parser({
onCell: ({col, row}) => {
const cell = this.root.data[row - 1][col - 1]
const cellValue = cell.resultValue.length > 0 ? cell.resultValue : cell.value
if( cellValue && isNaN(Number(cellValue)) === false) return Number(cellValue)
return this.root.data[row - 1][col - 1].resultValue ?? ''
},
})
this.depParser = new DepParser({})
this.depParser
}
}

68
src/types/fast-formula-parser.d.ts vendored Normal file
View File

@ -0,0 +1,68 @@
declare module 'fast-formula-parser' {
export type PositionWithSheet = {
sheet?: string
row: number
col: number
}
export type FunctionArgument = {
isArray: boolean
isCellRef: boolean
isRangeRef: boolean
value: string | number
}
export type Position = {
col: number
row: number
}
export type RangeReference = {
sheet?: string
from: Position,
to: Position
}
export type Config = {
functions?: Record<string, (...args: FunctionArgument[]) => string>
functionsNeedContext?: (context: Parser, ...args: FunctionArgument[]) => string
onCell?: (position: PositionWithSheet) => number | string
onRange?: (ref) => Array<string|number>[]
onVariable?: (name: string, sheetName: string) => RangeReference
}
export const Types = {
NUMBER: 0,
ARRAY: 1,
BOOLEAN: 2,
STRING: 3,
RANGE_REF: 4, // can be 'A:C' or '1:4', not only 'A1:C3'
CELL_REF: 5,
COLLECTIONS: 6, // Unions of references
NUMBER_NO_BOOLEAN: 10,
};
export const Factorials: number[]
export default class Parser {
constructor(config: Config)
parse: (expression: string, position: PositionWithSheet) => string
parseAsync: (expression: string, position: PositionWithSheet) => Promise<string>
}
type FormulaHelpersType = {
accept: (param: FunctionArgument, type?: number, defValue?: number | string, flat?: boolean, allowSingleValue?: boolean) => number | string
type: (variable) => number
isRangeRef: (param) => boolean
isCellRef: (param) => boolean
}
export class DepParser {
constructor(config?: {onVariable?: (name: string, sheetName: string) => RangeReference})
parse(expression: string, position: PositionWithSheet): PositionWithSheet[]
}
export const FormulaHelpers: FormulaHelpersType
}

View File

@ -1,12 +1,20 @@
import { BaseSelectionType, RangeSelectionType } from "../main";
export function checkEqualRanges(range1: RangeSelectionType, range2: RangeSelectionType) {
const equalRows = range1.from.row === range2.to.row
const equalColumns = range1.from.column === range2.to.column
export function checkEqualRanges(
range1: RangeSelectionType,
range2: RangeSelectionType,
) {
const equalRows = range1.from.row === range2.to.row;
const equalColumns = range1.from.column === range2.to.column;
return equalRows && equalColumns
return equalRows && equalColumns;
}
export function checkEqualCellSelections(selection1: BaseSelectionType, selection2: BaseSelectionType) {
return selection1.column === selection2.column && selection1.row === selection2.row
export function checkEqualCellSelections(
selection1: BaseSelectionType,
selection2: BaseSelectionType,
) {
return (
selection1.column === selection2.column && selection1.row === selection2.row
);
}

View File

@ -5,7 +5,7 @@
"types": ["vite/client", "node"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": false,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,

View File

@ -2,14 +2,16 @@ import { defineConfig } from "vite";
import path from "path";
import typescript from "@rollup/plugin-typescript";
import { typescriptPaths } from "rollup-plugin-typescript-paths";
import { fileURLToPath } from "node:url";
export default defineConfig({
const BROWSER_MODE = process.env.BUILD_BROWSER === 'true';
console.log({ BROWSER_MODE });
const libConfig = defineConfig({
base: "/modern_spreadsheet/",
plugins: [],
resolve: {},
server: {
port: 3000,
port: 5179,
open: true,
},
build: {
@ -37,3 +39,23 @@ export default defineConfig({
},
},
});
const browserConfig = defineConfig({
base: "/modern_spreadsheet/",
resolve: {},
build: {
manifest: true,
minify: true,
reportCompressedSize: true,
sourcemap: true,
lib: {
entry: path.resolve(__dirname, 'index.html'),
fileName: 'demo',
formats: ['es']
}
}
})
const config = BROWSER_MODE ? browserConfig : libConfig;
export default config;