Compare commits

..

39 Commits

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
Eugene 322378904c Added "Cell change" event 2023-07-26 15:08:47 +03:00
Eugene 8596220e85 Added click on cell event
Added selection change event
2023-07-26 14:29:51 +03:00
Eugene 9dd64b9a77 Little styles fixes for columns and rows bars 2023-07-26 11:05:43 +03:00
Eugene 50b3c3986f Updated package version 2023-07-25 23:59:34 +03:00
Eugene 35cb54e23e dist update 2023-07-25 23:58:11 +03:00
Eugene 06dd0a0cdf Linted code 2023-07-25 23:57:19 +03:00
Eugene 0f277badb9 Fixed view of col&row bars 2023-07-25 23:54:25 +03:00
Eugene 22e238087c Fixed rects 2023-07-25 23:54:25 +03:00
Eugene 53e081435b Added rows bar 2023-07-25 23:54:25 +03:00
Eugene 1ef3ae3de4 Added new component ColumnsBar
Added render of ColumnsBar methods
Added selection condition render styling
2023-07-25 23:54:24 +03:00
Eugene c34d913619 Readme update 2023-07-25 17:29:27 +03:00
Eugene 55e4eb0f70 Added eslint & prettier config
Formatted files in project
2023-07-25 16:59:49 +03:00
Eugene 9e25b2869c Updated readme 2023-07-25 15:38:47 +03:00
Eugene 3edf35415b Readme update 2023-07-25 15:28:46 +03:00
Eugene f49a836045 dist update 2023-07-25 15:27:58 +03:00
Eugene a37401a28d Added CSS prefixes
Added common styles for cells and single cells styles
2023-07-25 15:26:43 +03:00
Eugene e4c09a517d dist update 2023-07-25 13:20:48 +03:00
Eugene d4e3dcc687 Added 'destroy' method
Added MIT licence
2023-07-25 13:20:25 +03:00
Eugene 68542c816c Dist update 2023-07-25 12:41:20 +03:00
Eugene ea068ac133 Removed unused variable 2023-07-25 12:40:55 +03:00
Eugene 17a67c4ece Update Readme 2023-07-25 12:40:20 +03:00
Eugene 11376f13ac New features: serialize and load data
Fixed imports in modules
2023-07-25 12:33:25 +03:00
61 changed files with 4243 additions and 1245 deletions

30
.eslintrc.cjs Normal file
View File

@ -0,0 +1,30 @@
module.exports = {
env: {
browser: true,
es2021: true,
node: true,
},
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"prettier",
],
overrides: [
{
env: {
node: true,
},
files: [".eslintrc.{js,cjs}"],
parserOptions: {
sourceType: "script",
},
},
],
parser: "@typescript-eslint/parser",
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
},
plugins: ["@typescript-eslint"],
rules: {},
};

2
.prettierignore Normal file
View File

@ -0,0 +1,2 @@
# Ignore artifacts:
dist

1
.prettierrc.json Normal file
View File

@ -0,0 +1 @@
{}

21
LICENCE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 typeguard, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -1,17 +1,78 @@
# Modern Spreadsheet # Modern Spreadsheet
- <span>High performance spreadsheet based on CanvasAPI.</span> <img src="https://raw.githubusercontent.com/yazmeyaa/modern_spreadsheet/6dc20f92e769210c076600c7fcfacd4ed528f085/repo_assets/spreadsheet_preview.png?raw=true" alt="spreadsheet_preview">
- <span>TypeScript supported</span>
<div> ## Features:
<span>Basic usage</span> - High performance spreadsheet based on CanvasAPI.
- TypeScript supported
- Native scrolling
- Customizable
- Copy & Paste support
```js ### Basic usage
import { Spreadsheet } from 'modern_spreadsheet'
import 'modern_spreadsheet/style.css' // <= this is required
const target = document.getElementById('spreadsheet') ```ts
import Spreadsheet from "modern_spreadsheet";
import "modern_spreadsheet/style.css"; // <= this is required
const sheet = new Spreadsheet(target) const target = document.getElementById("spreadsheet");
const sheet = new Spreadsheet(target);
//...
``` ```
</div>
### Save and load data
```ts
function saveData() {
const serialized = sheet.serializeData();
localStorage.setItem("sheet_data", JSON.stringify(serialized));
}
function loadData() {
const data = localStorage.getItem("sheet_data");
const json = JSON.parse(data);
if (!json) return;
sheet.loadData(json);
}
```
#### Supported events
- onCellClick
- onSelectionChange
- onCellChange
- onCopy
### Using events examples
```ts
import Spreadsheet, { SpreadsheetConstructorProperties } from "./main";
const options: SpreadsheetConstructorProperties = {
onCellClick: (event, cell) => {
console.log("Cell click", event, cell);
},
onSelectionChange: (selection) => {
console.log("Changed selection: ", selection);
},
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
- ~~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
- Formulas support
- Selected cell depends cells highlight
- Async formulas support
- Mutlisheets (?)

16
dist/components/columnsBar.d.ts vendored Normal file
View File

@ -0,0 +1,16 @@
import Spreadsheet from "../main";
export declare class ColumnsBar {
element: HTMLCanvasElement;
private root;
height: number;
width: number;
ctx: CanvasRenderingContext2D;
constructor(root: Spreadsheet);
private createElement;
setElementPosition(top: number, left: number): void;
private isColumnSelected;
private renderText;
private renderRect;
private renderSingleColumn;
renderBar(): void;
}

View File

@ -1,11 +1,11 @@
import { Spreadsheet } from "../main"; import Spreadsheet from "../main";
import { Position } from "../modules/cell"; import { Position } from "../modules/cell";
export declare class Editor { export declare class Editor {
element: HTMLInputElement; element: HTMLInputElement;
root: Spreadsheet; root: Spreadsheet;
constructor(root: Spreadsheet); constructor(root: Spreadsheet);
hide(): void; hide(): void;
show(position: Position): void; show(position: Position, initialString?: string): void;
handleKeydown: (event: KeyboardEvent) => void; handleKeydown: (event: KeyboardEvent) => void;
handleClickOutside: (event: MouseEvent) => void; handleClickOutside: (event: MouseEvent) => void;
} }

View File

@ -1,6 +0,0 @@
import { Spreadsheet } from "../main";
export declare class Header {
element: HTMLHeadElement;
root: Spreadsheet;
constructor(root: Spreadsheet);
}

17
dist/components/rowsBar.d.ts vendored Normal file
View File

@ -0,0 +1,17 @@
import Spreadsheet from "../main";
export declare class RowsBar {
element: HTMLCanvasElement;
ctx: CanvasRenderingContext2D;
root: Spreadsheet;
width: number;
height: number;
resizerHeight: number;
constructor(root: Spreadsheet);
private createElement;
setElementPosition(top: number, left: number): void;
private isRowSelected;
private renderText;
private renderRect;
private renderSingleRow;
renderBar(): void;
}

View File

@ -1,4 +1,4 @@
import { Spreadsheet } from "../main"; import Spreadsheet from "../main";
export interface ViewportRect { export interface ViewportRect {
top: number; top: number;
left: number; left: number;
@ -12,6 +12,7 @@ export declare class Scroller {
private root; private root;
private isSelecting; private isSelecting;
constructor(root: Spreadsheet); constructor(root: Spreadsheet);
setSelectingMode(mode: boolean): void;
private handleMouseMove; private handleMouseMove;
private handleMouseUp; private handleMouseUp;
private handleDoubleClick; private handleDoubleClick;

View File

@ -1,4 +1,4 @@
import { Spreadsheet } from "../main"; import Spreadsheet from "../main";
import { Position } from "../modules/cell"; import { Position } from "../modules/cell";
/** /**
* Display (CANVAS) element where cells render * Display (CANVAS) element where cells render
@ -10,5 +10,8 @@ export declare class Sheet {
constructor(root: Spreadsheet); constructor(root: Spreadsheet);
getCellByCoords(x: number, y: number): Position; getCellByCoords(x: number, y: number): Position;
renderCell(position: Position): void; renderCell(position: Position): void;
private getSelectionRange;
private renderSelectionRange;
renderSelection(): void;
renderSheet(): void; renderSheet(): void;
} }

View File

@ -1,4 +1,4 @@
import { Spreadsheet } from "../main"; import Spreadsheet from "../main";
import { ViewProperties } from "../modules/config"; import { ViewProperties } from "../modules/config";
/** Base (root) component */ /** Base (root) component */
export declare class Table { export declare class Table {

View File

@ -1,6 +1,7 @@
import { Spreadsheet } from "../main"; import Spreadsheet from "../main";
export declare class Toolbar { export declare class Toolbar {
element: HTMLDivElement; element: HTMLDivElement;
root: Spreadsheet; root: Spreadsheet;
height: number;
constructor(root: Spreadsheet); constructor(root: Spreadsheet);
} }

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

63
dist/main.d.ts vendored
View File

@ -1,19 +1,26 @@
import { Cell, CellConstructorProps, Position } from "./modules/cell"; 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 { RangeSelectionType, Selection } from "./modules/selection";
import { Styles } from "./modules/styles"; import { Styles } from "./modules/styles";
import { Viewport } from "./modules/viewport"; import { Viewport } from "./modules/viewport";
import './scss/main.scss'; import "./scss/main.scss";
import { Cache } from "./modules/cache"; import { Cache } from "./modules/cache";
interface SpreadsheetConstructorProperties { import { Events } from "./modules/events";
config?: Omit<Config, 'view'>; import { Clipboard } from "./modules/clipboard";
export interface SpreadsheetConstructorProperties {
view?: ViewProperties; view?: ViewProperties;
onCellClick?: CellClickEvent | null;
onSelectionChange?: SelectionChangeEvent | null;
onCellChange?: CellChangeEvent | null;
onCopy?: CopyEvent | null;
} }
export declare class Spreadsheet { export declare const CSS_PREFIX = "modern_sc_";
export default class Spreadsheet {
private table; private table;
private scroller; private scroller;
private toolbar; private toolbar;
private header; private rowsBar;
private columnsBar;
private sheet; private sheet;
private editor; private editor;
styles: Styles; styles: Styles;
@ -22,22 +29,54 @@ export declare class Spreadsheet {
viewport: Viewport; viewport: Viewport;
selection: Selection; selection: Selection;
cache: Cache; cache: Cache;
events: Events;
clipboard: Clipboard;
constructor(target: string | HTMLElement, props?: SpreadsheetConstructorProperties); constructor(target: string | HTMLElement, props?: SpreadsheetConstructorProperties);
private setRowsBarPosition;
private setColumnsBarPosition;
private setElementsPositions;
private getInitialCache; private getInitialCache;
private buildComponent; private buildComponent;
/**Destroy spreadsheet DOM element.
*
* May be usefull when need to rerender component.
*/
destroy(): void;
private appendTableToTarget; private appendTableToTarget;
/** Canvas rendering context 2D.
*
* Abble to draw on canvas with default CanvasAPI methods
*/
get ctx(): CanvasRenderingContext2D; get ctx(): CanvasRenderingContext2D;
get viewProps(): ViewProperties; get viewProps(): ViewProperties;
get columnsBarHeight(): number;
get rowsBarWidth(): number;
get toolbarHeight(): number;
/** Focusing on interactive part of spreadsheet */
focusTable(): void; focusTable(): void;
getCellByCoords(x: number, y: number): Position; getCellByCoords(x: number, y: number): Position;
getCell(position: Position): Cell; getCell(position: Position): Cell;
changeCellValues(position: Position, values: Partial<Omit<CellConstructorProps, 'position'>>): void; changeCellValues(position: Position, values: Partial<Omit<CellConstructorProps, "position">>, enableCallback?: boolean): void;
applyActionToRange(range: RangeSelectionType, callback: (cell: Cell) => any): void; changeCellStyles(position: Position, styles: CellStyles): void;
applyActionToRange(range: RangeSelectionType, callback: (cell: Cell) => void): void;
deleteSelectedCellsValues(): void; deleteSelectedCellsValues(): void;
showEditor(position: Position): void; showEditor(position: Position, initialString?: string): void;
renderSheet(): void; renderSheet(): void;
renderSelection(): void;
renderColumnsBar(): void;
renderRowsBar(): void;
renderCell(row: number, col: number): void; renderCell(row: number, col: number): void;
loadData(data: Cell[][]): void; loadData(data: Cell[][] | SerializableCell[][]): Spreadsheet;
private makeConfigFromData; private makeConfigFromData;
serializeData(): SerializableCell[][];
} }
export {}; 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";

962
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

@ -1,9 +1,10 @@
import { Spreadsheet } from "../main"; import Spreadsheet from "../main";
export type CellConstructorProps = { export type CellConstructorProps = {
value: string; value: string;
displayValue: string; displayValue: string;
resultValue: string; resultValue: string;
position: Position; position: Position;
style: CellStyles | null;
}; };
interface CellStylesConstructorProps { interface CellStylesConstructorProps {
fontSize: number; fontSize: number;
@ -27,16 +28,27 @@ export declare class Position {
column: number; column: number;
constructor(row: number, column: number); constructor(row: number, column: number);
} }
export declare class Cell { export declare class SerializableCell {
value: string; value: string;
displayValue: string; displayValue: string;
/** This refers to the values that were obtained by calculations, for example, after calculating the formula */
resultValue: string; resultValue: string;
position: Position; position: Position;
style: CellStyles; style: CellStyles | null;
constructor(props: SerializableCell | SerializableCell);
}
export declare class Cell {
/** True value (data) */
value: string;
/** Value to render */
displayValue: string;
/** This refers to the values that were obtained by calculations, for example, after calculating the formula */
resultValue: string;
position: Position;
style: CellStyles | null;
constructor(props: CellConstructorProps); constructor(props: CellConstructorProps);
changeValues(values: Partial<Omit<CellConstructorProps, 'position'>>): void; getSerializableCell(): SerializableCell;
private isCellInRange; changeStyles(styles: CellStyles): void;
changeValues(values: Partial<Omit<CellConstructorProps, "position">>): void;
render(root: Spreadsheet): void; render(root: Spreadsheet): void;
} }
export {}; 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 { Column } from "./column";
import { Row } from "./row"; import { Row } from "./row";
import { RangeSelectionType, Selection } from "./selection";
export interface ViewProperties { export interface ViewProperties {
width: number; width: number;
height: 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 = { export type ConfigProperties = {
/** Please, end it with '_' symbol. /** Please, end it with '_' symbol.
* *
@ -14,6 +20,10 @@ export type ConfigProperties = {
rows: Row[]; rows: Row[];
columns: Column[]; columns: Column[];
view: ViewProperties; view: ViewProperties;
onCellClick?: CellClickEvent | null;
onSelectionChange?: SelectionChangeEvent | null;
onCellChange?: CellChangeEvent | null;
onCopy?: CopyEvent | null;
}; };
export type SheetConfigConstructorProps = { export type SheetConfigConstructorProps = {
rows: Row[]; rows: Row[];
@ -23,5 +33,9 @@ export declare class Config {
rows: Row[]; rows: Row[];
columns: Column[]; columns: Column[];
view: ViewProperties; view: ViewProperties;
onCellClick: CellClickEvent | null;
onSelectonChange: SelectionChangeEvent | null;
onCellChange: CellChangeEvent | null;
onCopy: CopyEvent | null;
constructor(props: ConfigProperties); 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;
}

View File

@ -1,2 +1,5 @@
import { CellStyles } from "./cell";
export declare class Styles { export declare class Styles {
cells: CellStyles;
constructor();
} }

View File

@ -1,4 +1,4 @@
import { Spreadsheet } from "../main"; import Spreadsheet from "../main";
export type ViewportConstructorProps = { export type ViewportConstructorProps = {
top: number; top: number;
left: number; left: number;

2
dist/style.css vendored
View File

@ -1 +1 @@
body{padding:0;margin:0}.content{position:absolute;top:0;left:0}.spreadsheet_container{position:relative;isolation:isolate;border:2px solid black}.sheet{display:block;contain:strict}.scroller{overflow:scroll;box-sizing:border-box;transform:translateZ(0)}.scroller:focus{outline:none}.editor{position:absolute;box-sizing:border-box;font-size:16px;font-family:Arial,Helvetica,sans-serif}.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}

View File

@ -2,9 +2,8 @@ import { Cell } from "../modules/cell";
import { Config } from "../modules/config"; import { Config } from "../modules/config";
export declare function createSampleData(rows: number, columns: number, fillCellsByCoords?: boolean): Cell[][]; export declare function createSampleData(rows: number, columns: number, fillCellsByCoords?: boolean): Cell[][];
export declare function createSampleConfig(rows: number, columns: number): Config; export declare function createSampleConfig(rows: number, columns: number): Config;
type SpreadsheetConfigAndDataReturnType = { export type SpreadsheetConfigAndDataReturnType = {
config: Config; config: Config;
data: Cell[][]; data: Cell[][];
}; };
export declare function makeSpreadsheetConfigAndData(rows: number, columns: number): SpreadsheetConfigAndDataReturnType; export declare function makeSpreadsheetConfigAndData(rows: number, columns: number): SpreadsheetConfigAndDataReturnType;
export {};

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,4 +1,4 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
@ -6,8 +6,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Spreadsheet example</title> <title>Spreadsheet example</title>
</head> </head>
<body> <body style="padding: 2rem;">
<div id="spreadsheet"></div> <div id="spreadsheet"></div>
<button id="save_button">Save sheet</button>
<button id="load_button">Load sheet</button>
<script type="module" src="/src/index.ts"></script> <script type="module" src="/src/index.ts"></script>
</body> </body>
</html> </html>

View File

@ -1,7 +1,7 @@
{ {
"name": "modern_spreadsheet", "name": "modern_spreadsheet",
"private": false, "private": false,
"version": "0.0.19", "version": "0.0.33",
"exports": { "exports": {
".": { ".": {
"import": "./dist/main.js", "import": "./dist/main.js",
@ -21,6 +21,7 @@
], ],
"type": "module", "type": "module",
"homepage": "https://github.com/yazmeyaa/modern_spreadsheet", "homepage": "https://github.com/yazmeyaa/modern_spreadsheet",
"license": "MIT",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/yazmeyaa/modern_spreadsheet" "url": "https://github.com/yazmeyaa/modern_spreadsheet"
@ -32,19 +33,33 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
"build:HTML": "tsc && cross-env BUILD_BROWSER=true vite build",
"build:watch": "tsc && vite build --watch", "build:watch": "tsc && vite build --watch",
"preview": "vite preview", "preview": "vite preview",
"predeploy": "npm run dist", "predeploy": "npm run dist",
"deploy": "gh-pages -d dist" "deploy": "gh-pages -d dist",
"lint": "eslint src --ext .ts",
"lint:fix": "eslint src --ext .ts --fix",
"format": "prettier --write ./src/**/*.ts"
}, },
"devDependencies": { "devDependencies": {
"@rollup/plugin-typescript": "^11.1.2", "@rollup/plugin-typescript": "^11.1.2",
"@types/node": "^20.4.4", "@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", "gh-pages": "^5.0.0",
"prettier": "3.0.0",
"rollup-plugin-typescript-paths": "^1.4.0", "rollup-plugin-typescript-paths": "^1.4.0",
"sass": "^1.63.6", "sass": "^1.63.6",
"tslib": "^2.6.0", "tslib": "^2.6.0",
"typescript": "^5.0.2", "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

@ -0,0 +1,122 @@
import Spreadsheet, { RenderBox } from "../main";
export class ColumnsBar {
public element: HTMLCanvasElement;
private root: Spreadsheet;
public height: number = 35;
public width: number;
// private resizerWidth = 1;
ctx: CanvasRenderingContext2D;
constructor(root: Spreadsheet) {
this.root = root;
this.element = this.createElement();
const ctx = this.element.getContext("2d");
if (!ctx) throw new Error("Enable hardware acceleration");
this.ctx = ctx;
this.width = this.root.viewProps.width;
}
private createElement(): HTMLCanvasElement {
const element = document.createElement("canvas");
element.style.position = "absolute";
element.style.height = this.height + "px";
element.style.width = this.root.viewProps.width + "px";
element.style.display = "block";
element.style.borderLeft = "1px solid black";
// element.style.boxSizing = 'border-box'
element.width = this.root.viewProps.width;
element.height = this.height;
return element;
}
public setElementPosition(top: number, left: number) {
this.element.style.top = top + "px";
this.element.style.left = left + "px";
}
private isColumnSelected(column: number): boolean {
const { selectedCell, selectedRange } = this.root.selection;
if (selectedCell && selectedCell.column === column) return true;
if (selectedRange) {
const inRange =
column >=
Math.min(selectedRange.from.column, selectedRange.to.column) &&
column <= Math.max(selectedRange.from.column, selectedRange.to.column);
return inRange;
}
return false;
}
// private getYCoordWithOffset(renderBox: RenderBox): number {
// const {y} = renderBox
// return y + this.root.toolbarHeight
// }
// private getXCoordWithOffset(renderBox: RenderBox): number {
// const {x} = renderBox
// return x
// }
private renderText(column: number, renderBox: RenderBox) {
const { width, x } = renderBox;
this.ctx.fillStyle = "black";
this.ctx.textAlign = "center";
this.ctx.textBaseline = "middle";
this.ctx.font = "12px Arial";
this.ctx.fillText(
this.root.config.columns[column].title,
x + width / 2 - this.root.viewport.left,
0 + this.height / 2,
);
}
private renderRect(column: number, renderBox: RenderBox) {
const { width, x } = renderBox;
const isColSelected = this.isColumnSelected(column);
this.ctx.fillStyle = isColSelected ? "#c7ebff" : "white";
this.ctx.strokeStyle = "black";
this.ctx.lineWidth = 1;
const specialX = x - this.root.viewport.left;
this.ctx.fillRect(specialX - 1, 0, width, this.height);
this.ctx.strokeRect(specialX - 1, 0, width, this.height);
}
private renderSingleColumn(column: number) {
const renderBox = new RenderBox(this.root.config, {
row: 0,
column: column,
});
this.renderRect(column, renderBox);
this.renderText(column, renderBox);
}
public renderBar() {
const lastColIdx = this.root.viewport.lastCol + 3;
const firstColIdx = this.root.viewport.firstCol;
this.ctx.beginPath();
this.ctx.strokeStyle = "black";
this.ctx.lineWidth = 1;
this.ctx.moveTo(0, 0);
this.ctx.lineTo(0, this.height);
this.ctx.closePath();
this.ctx.stroke();
for (let col = firstColIdx; col <= lastColIdx; col++) {
if (!this.root.config.columns[col]) break;
this.renderSingleColumn(col);
}
}
}

View File

@ -1,68 +1,80 @@
import { Spreadsheet } from "../main"; import Spreadsheet, { CSS_PREFIX } from "../main";
import { Position } from "../modules/cell"; import { Position } from "../modules/cell";
import { EventTypes } from "../modules/events";
import { RenderBox } from "../modules/renderBox"; import { RenderBox } from "../modules/renderBox";
export class Editor { export class Editor {
element: HTMLInputElement element: HTMLInputElement;
root: Spreadsheet root: Spreadsheet;
constructor(root: Spreadsheet) { constructor(root: Spreadsheet) {
this.root = root this.root = root;
const element = document.createElement('input') const element = document.createElement("input");
element.classList.add('editor') element.classList.add(CSS_PREFIX + "editor");
this.element = element this.element = element;
this.hide() this.hide();
}
hide() {
this.element.style.display = "none";
this.element.classList.add("hide");
this.element.blur();
window.removeEventListener("click", this.handleClickOutside);
this.element.removeEventListener("keydown", this.handleKeydown);
this.root.focusTable();
}
show(position: Position, initialString?: string) {
const { height, width, x, y } = new RenderBox(this.root.config, position);
const cell = this.root.getCell(position);
this.element.classList.remove("hide");
this.element.style.top =
y - this.root.viewport.top + this.root.columnsBarHeight + "px";
this.element.style.left =
x - this.root.viewport.left + this.root.rowsBarWidth + "px";
this.element.style.width = width + "px";
this.element.style.height = height + "px";
this.element.style.display = "block";
window.addEventListener("click", this.handleClickOutside);
this.element.addEventListener("keydown", this.handleKeydown);
this.element.value = initialString ? initialString : cell.value;
this.element.focus();
if (!initialString) this.element.select();
}
handleKeydown = (event: KeyboardEvent) => {
const { key } = event;
switch (key) {
case "Escape": {
this.hide();
break;
}
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),
});
this.hide();
this.root.renderSelection();
}
} }
};
hide() { handleClickOutside = (event: MouseEvent) => {
this.element.style.display = 'none' const target = event.target as HTMLElement;
this.element.classList.add('hide') if (!this.element.contains(target)) {
this.element.blur() this.hide();
window.removeEventListener('click', this.handleClickOutside)
this.element.removeEventListener('keydown', this.handleKeydown)
this.root.focusTable()
}
show(position: Position) {
const { height, width, x, y } = new RenderBox(this.root.config, position);
const cell = this.root.getCell(position)
this.element.classList.remove('hide')
this.element.style.top = (y - this.root.viewport.top) + 'px'
this.element.style.left = (x - this.root.viewport.left) + 'px'
this.element.style.width = width + 'px'
this.element.style.height = height + 'px'
this.element.style.display = 'block'
window.addEventListener('click', this.handleClickOutside)
this.element.addEventListener('keydown', this.handleKeydown)
this.element.value = cell.value
this.element.focus()
this.element.select()
}
handleKeydown = (event: KeyboardEvent) => {
const {key} = event
switch(key) {
case 'Escape': {
this.hide();
break;
}
case 'Enter': {
this.root.changeCellValues(this.root.selection.selectedCell!, {
value: this.element.value,
displayValue: this.element.value
})
this.hide();
}
}
}
handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement
if (!this.element.contains(target)) {
this.hide()
}
} }
};
} }

View File

@ -1,12 +0,0 @@
import { Spreadsheet } from "../main"
export class Header {
element: HTMLHeadElement
root: Spreadsheet
constructor(root: Spreadsheet) {
this.root = root
const headerElement = document.createElement('header')
headerElement.classList.add()
this.element = headerElement
}
}

109
src/components/rowsBar.ts Normal file
View File

@ -0,0 +1,109 @@
import Spreadsheet, { RenderBox } from "../main";
export class RowsBar {
element: HTMLCanvasElement;
ctx: CanvasRenderingContext2D;
root: Spreadsheet;
width: number = 35;
height: number;
resizerHeight = 1;
constructor(root: Spreadsheet) {
this.root = root;
this.element = this.createElement();
const ctx = this.element.getContext("2d");
if (!ctx) throw new Error("Enable hardware acceleration");
this.ctx = ctx;
this.height = this.root.viewProps.height;
}
private createElement() {
const element = document.createElement("canvas");
element.style.position = "absolute";
element.style.height = this.root.viewProps.height + "px";
element.style.width = this.width + "px";
element.style.display = "block";
element.style.borderTop = "1px solid black";
// element.style.boxSizing = 'border-box'
element.width = this.width;
element.height = this.root.viewProps.height;
return element;
}
public setElementPosition(top: number, left: number) {
this.element.style.top = top + "px";
this.element.style.left = left + "px";
}
private isRowSelected(row: number): boolean {
const { selectedCell, selectedRange } = this.root.selection;
if (selectedCell && selectedCell.row === row) return true;
if (selectedRange) {
const inRange =
row >= Math.min(selectedRange.from.row, selectedRange.to.row) &&
row <= Math.max(selectedRange.from.row, selectedRange.to.row);
return inRange;
}
return false;
}
private renderText(row: number, renderBox: RenderBox) {
const { y, height } = renderBox;
this.ctx.fillStyle = "black";
this.ctx.textAlign = "center";
this.ctx.textBaseline = "middle";
this.ctx.font = "12px Arial";
this.ctx.fillText(
this.root.config.rows[row].title,
this.width / 2,
y - this.root.viewport.top + height / 2,
);
}
private renderRect(column: number, renderBox: RenderBox) {
const { y, height } = renderBox;
const isRowSeleted = this.isRowSelected(column);
this.ctx.fillStyle = isRowSeleted ? "#c7ebff" : "white";
this.ctx.strokeStyle = "black";
this.ctx.lineWidth = this.resizerHeight;
const specialY = y - this.root.viewport.top;
this.ctx.fillRect(0, specialY - 1, this.width, height);
this.ctx.strokeRect(0, specialY - 1, this.width, height);
}
private renderSingleRow(row: number) {
const renderBox = new RenderBox(this.root.config, {
column: 0,
row: row,
});
this.renderRect(row, renderBox);
this.renderText(row, renderBox);
}
public renderBar() {
const lastRowIdx = this.root.viewport.lastRow + 3;
const firstRowIdx = this.root.viewport.firstRow;
this.ctx.beginPath();
this.ctx.moveTo(0, 0);
this.ctx.strokeStyle = "black";
this.ctx.lineWidth = 16;
this.ctx.lineTo(35, 0);
this.ctx.closePath();
this.ctx.stroke();
for (let row = firstRowIdx; row <= lastRowIdx; row++) {
if (!this.root.config.rows[row]) break;
this.renderSingleRow(row);
}
}
}

View File

@ -1,214 +1,320 @@
import { Spreadsheet } from "../main" import Spreadsheet, { CSS_PREFIX, Cell, Selection } from "../main";
import { EventTypes } from "../modules/events";
import { checkEqualCellSelections } from "../utils/position";
export interface ViewportRect { export interface ViewportRect {
top: number top: number;
left: number left: number;
right: number right: number;
bottom: number bottom: number;
} }
export class Scroller { export class Scroller {
element: HTMLDivElement element: HTMLDivElement;
private verticalScroller: HTMLDivElement private verticalScroller: HTMLDivElement;
private horizontalScroller: HTMLDivElement private horizontalScroller: HTMLDivElement;
private root: Spreadsheet private root: Spreadsheet;
private isSelecting = false;
private isSelecting = false constructor(root: Spreadsheet) {
this.root = root;
const { horizontalScroller, scroller, verticalScroller } =
this.buildComponent();
this.element = scroller;
this.verticalScroller = verticalScroller;
this.horizontalScroller = horizontalScroller;
constructor(root: Spreadsheet) { this.element.style.height = this.root.config.view.height + "px";
this.root = root this.element.style.width = this.root.config.view.width + "px";
const { horizontalScroller, scroller, verticalScroller } = this.buildComponent() this.element.style.top = this.root.columnsBarHeight + "px";
this.element = scroller this.element.style.left = this.root.rowsBarWidth + "px";
this.verticalScroller = verticalScroller this.element.tabIndex = -1;
this.horizontalScroller = horizontalScroller
this.element.style.height = this.root.config.view.height + 'px' this.updateScrollerSize(); //* Init size set
this.element.style.width = this.root.config.view.width + 'px'
this.element.tabIndex = -1
this.updateScrollerSize() //* Init size set this.element.addEventListener("scroll", this.handleScroll);
this.element.addEventListener('scroll', this.handleScroll) this.element.addEventListener("mousedown", this.handleClick);
this.element.addEventListener("mousemove", this.handleMouseMove);
this.element.addEventListener("mouseup", this.handleMouseUp);
this.element.addEventListener("dblclick", this.handleDoubleClick);
this.element.addEventListener('mousedown', this.handleClick) this.element.addEventListener("keydown", this.handleKeydown);
this.element.addEventListener('mousemove', this.handleMouseMove) this.element.addEventListener("paste", (event) => {
this.element.addEventListener('mouseup', this.handleMouseUp) if (!this.root.selection.selectedCell) return;
this.element.addEventListener('dblclick', this.handleDoubleClick) this.root.clipboard.paste(
this.root,
this.root.selection.selectedCell,
event,
);
});
}
this.element.addEventListener('keydown', this.handleKeydown) public setSelectingMode(mode: boolean) {
this.isSelecting = mode;
}
private handleMouseMove = (event: MouseEvent) => {
if (!this.isSelecting) return;
const { offsetX, offsetY } = event;
const lastSelectedCell = this.root.getCellByCoords(offsetX, offsetY);
let isRangeChanged = false;
if (this.root.selection.selectedRange) {
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,
});
}
}
};
private handleMouseUp = () => {
this.isSelecting = false;
const newSelection = { ...this.root.selection };
if (this.root.selection.selectedRange) {
if (
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,
});
}
} }
private handleMouseMove = (event: MouseEvent) => { this.root.renderSheet();
if (!this.isSelecting) return; this.root.renderColumnsBar();
const { offsetX, offsetY } = event this.root.renderRowsBar();
const lastSelectedCell = this.root.getCellByCoords(offsetX, offsetY) };
if (this.root.selection.selectedRange) {
this.root.selection.selectedRange.to = lastSelectedCell private handleDoubleClick = (event: MouseEvent) => {
event.preventDefault();
const position = this.root.getCellByCoords(event.offsetX, event.offsetY);
this.root.showEditor(position);
};
private handleKeydown = (event: KeyboardEvent) => {
//* Navigation
if (
["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)
) {
event.preventDefault();
this.root.selection.selectedRange = null;
switch (event.key) {
case "ArrowLeft": {
if (
this.root.selection.selectedCell &&
this.root.selection.selectedCell.column > 0
) {
this.root.selection.selectedCell.column -= 1;
// this.root.renderSheet();
}
break;
} }
this.root.renderSheet() case "ArrowRight": {
} if (
this.root.selection.selectedCell &&
private handleMouseUp = () => { this.root.selection.selectedCell.column <
this.isSelecting = false this.root.config.columns.length - 1
) {
if (this.root.selection.selectedRange) { this.root.selection.selectedCell.column += 1;
if ( // this.root.renderSheet();
(this.root.selection.selectedRange.from.row === this.root.selection.selectedRange.to.row) && }
(this.root.selection.selectedRange.from.column === this.root.selection.selectedRange.to.column) break;
) {
this.root.selection.selectedRange = null
}
} }
case "ArrowUp": {
this.root.renderSheet() if (
this.root.selection.selectedCell &&
this.root.selection.selectedCell.row > 0
) {
this.root.selection.selectedCell.row -= 1;
// this.root.renderSheet();
}
break;
}
case "ArrowDown": {
if (
this.root.selection.selectedCell &&
this.root.selection.selectedCell.row <
this.root.config.rows.length - 1
) {
this.root.selection.selectedCell.row += 1;
// this.root.renderSheet();
}
break;
}
}
this.root.events.dispatch({
type: EventTypes.SELECTION_CHANGE,
selection: this.root.selection,
enableCallback: true,
});
} }
private handleDoubleClick = (event: MouseEvent) => { //* Start typings
const keysRegex = /^([a-z]|[а-я]|[0-9]|=)$/;
if (!event.metaKey && !event.ctrlKey) {
//* Prevent handle shortcutrs
const isPressedLetterKey = keysRegex.test(event.key.toLowerCase());
if (event.key === "F2" || isPressedLetterKey) {
//* English and Russian keyboard. Or F2 button
event.preventDefault(); event.preventDefault();
const position = this.root.getCellByCoords(event.offsetX, event.offsetY) if (!this.root.selection.selectedCell) return;
this.root.showEditor(position)
this.root.showEditor(
this.root.selection.selectedCell,
isPressedLetterKey ? event.key : undefined,
);
}
} }
private handleKeydown = (event: KeyboardEvent) => { if (event.key === "Delete") {
console.log(event) event.preventDefault();
//* Navigation this.root.deleteSelectedCellsValues();
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)) { this.root.renderSheet();
event.preventDefault() }
this.root.selection.selectedRange = null
switch (event.key) { if (event.metaKey || event.ctrlKey) {
case 'ArrowLeft': { if (event.code === "KeyC") {
if (this.root.selection.selectedCell && this.root.selection.selectedCell.column > 0) { let cells: Cell[][] = undefined!;
console.log('tick') const selection = new Selection();
this.root.selection.selectedCell.column -= 1
this.root.renderSheet() if (this.root.selection.selectedRange) {
} const { from, to } = this.root.selection.selectedRange;
break;
} selection.selectedRange = this.root.selection.selectedRange;
case 'ArrowRight': {
if (this.root.selection.selectedCell && this.root.selection.selectedCell.column < this.root.config.columns.length - 1) { const subArrByRows = this.root.data.slice(from.row, to.row + 1);
this.root.selection.selectedCell.column += 1
this.root.renderSheet() const subArrByCols = subArrByRows.map((row) => {
} return row.slice(from.column, to.column + 1);
break; });
}
case 'ArrowUp': { cells = [...subArrByCols];
if (this.root.selection.selectedCell && this.root.selection.selectedCell.row > 0) { } else if (this.root.selection.selectedCell) {
this.root.selection.selectedCell.row -= 1 const { column, row } = this.root.selection.selectedCell;
this.root.renderSheet() cells = [[this.root.data[row][column]]];
} selection.selectedRange = {
break; from: this.root.selection.selectedCell,
} to: this.root.selection.selectedCell,
case 'ArrowDown': { };
if (this.root.selection.selectedCell && this.root.selection.selectedCell.row < this.root.config.rows.length - 1) { } else {
this.root.selection.selectedCell.row += 1 return;
this.root.renderSheet()
}
break;
}
}
}
if (!event.metaKey && !event.ctrlKey) { //* Prevent handle shortcutrs
if (event.key === 'F2' || /^([a-z]|[а-я])$/.test(event.key.toLowerCase())) { //* English and Russian keyboard. Or F2 button
event.preventDefault()
if (!this.root.selection.selectedCell) return;
this.root.showEditor(this.root.selection.selectedCell)
}
} }
if (event.key === 'Delete') { this.root.clipboard.copy(cells, selection.selectedRange);
event.preventDefault() return;
this.root.deleteSelectedCellsValues() }
this.root.renderSheet() if (event.code === "KeyV") {
} // if (!this.root.selection.selectedCell) return;
// this.root.clipboard.paste(this.root, this.root.selection.selectedCell);
}
} }
};
private handleClick = (event: MouseEvent) => { private handleClick = (event: MouseEvent) => {
if (event.button !== 0) return; // Left mouse button this.root.events.dispatch({
const { offsetX, offsetY } = event type: EventTypes.CELL_CLICK,
const clickedCell = this.root.getCellByCoords(offsetX, offsetY) event,
this.isSelecting = true scroller: this,
this.root.selection.selectedRange = { });
from: clickedCell, };
to: clickedCell
}
this.root.selection.selectedCell = clickedCell
this.root.renderSheet() private handleScroll = () => {
} const rect = this.getViewportBoundlingRect();
this.root.viewport.updateValues(rect);
private handleScroll = () => { this.root.renderSheet();
const rect = this.getViewportBoundlingRect() this.root.renderColumnsBar();
this.root.viewport.updateValues(rect)
this.root.renderSheet() this.root.renderRowsBar();
} };
public getViewportBoundlingRect(): ViewportRect { public getViewportBoundlingRect(): ViewportRect {
const { scrollTop, scrollLeft } = this.element const { scrollTop, scrollLeft } = this.element;
const { height, width } = this.element.getBoundingClientRect() const { height, width } = this.element.getBoundingClientRect();
const bottom = scrollTop + height const bottom = scrollTop + height;
const right = scrollLeft + width const right = scrollLeft + width;
return { return {
top: scrollTop, top: scrollTop,
left: scrollLeft, left: scrollLeft,
bottom, bottom,
right right,
} };
} }
private buildComponent() { private buildComponent() {
const scroller = document.createElement('div') const scroller = document.createElement("div");
const verticalScroller = document.createElement('div') const verticalScroller = document.createElement("div");
const horizontalScroller = document.createElement('div') const horizontalScroller = document.createElement("div");
const groupScrollers = document.createElement('div') const groupScrollers = document.createElement("div");
const stack = document.createElement('div') const stack = document.createElement("div");
verticalScroller.style.width = '0px' verticalScroller.style.width = "0px";
verticalScroller.style.pointerEvents = 'none' verticalScroller.style.pointerEvents = "none";
horizontalScroller.style.pointerEvents = 'none' horizontalScroller.style.pointerEvents = "none";
groupScrollers.style.display = 'flex' groupScrollers.style.display = "flex";
stack.appendChild(verticalScroller) stack.appendChild(verticalScroller);
stack.appendChild(horizontalScroller) stack.appendChild(horizontalScroller);
groupScrollers.appendChild(stack) groupScrollers.appendChild(stack);
this.verticalScroller = verticalScroller this.verticalScroller = verticalScroller;
this.horizontalScroller = horizontalScroller this.horizontalScroller = horizontalScroller;
scroller.appendChild(groupScrollers) scroller.appendChild(groupScrollers);
scroller.classList.add('scroller') scroller.contentEditable = "false";
scroller.classList.add(CSS_PREFIX + "scroller");
return { scroller, verticalScroller, horizontalScroller } return { scroller, verticalScroller, horizontalScroller };
} }
private getActualHeight() { private getActualHeight() {
return this.root.config.rows.reduce((acc, curr) => { return this.root.config.rows.reduce((acc, curr) => {
acc += curr.height acc += curr.height;
return acc return acc;
}, 0) }, 0);
} }
private getActualWidth() { private getActualWidth() {
return this.root.config.columns.reduce((acc, curr) => { return this.root.config.columns.reduce((acc, curr) => {
acc += curr.width acc += curr.width;
return acc return acc;
}, 0) }, 0);
} }
updateScrollerSize() { updateScrollerSize() {
const totalHeight = this.getActualHeight() const totalHeight = this.getActualHeight();
const totalWidth = this.getActualWidth() const totalWidth = this.getActualWidth();
this.setScrollerHeight(totalHeight) this.setScrollerHeight(totalHeight);
this.setScrollerWidth(totalWidth) this.setScrollerWidth(totalWidth);
} }
private setScrollerHeight(height: number) { private setScrollerHeight(height: number) {
this.verticalScroller.style.height = height + 'px' this.verticalScroller.style.height = height + "px";
} }
private setScrollerWidth(width: number) { private setScrollerWidth(width: number) {
this.horizontalScroller.style.width = width + 'px' this.horizontalScroller.style.width = width + "px";
} }
} }

View File

@ -1,71 +1,138 @@
import { Spreadsheet } from "../main" import Spreadsheet, { CSS_PREFIX, RenderBox } from "../main";
import { Position } from "../modules/cell" import { Position } from "../modules/cell";
/** /**
* Display (CANVAS) element where cells render * Display (CANVAS) element where cells render
*/ */
export class Sheet { export class Sheet {
element: HTMLCanvasElement element: HTMLCanvasElement;
ctx: CanvasRenderingContext2D ctx: CanvasRenderingContext2D;
root: Spreadsheet root: Spreadsheet;
constructor(root: Spreadsheet) { constructor(root: Spreadsheet) {
this.root = root this.root = root;
const canvas = document.createElement('canvas') const canvas = document.createElement("canvas");
canvas.classList.add('sheet') canvas.classList.add(CSS_PREFIX + "sheet");
//* Set up canvas sizes based on provided root config //* Set up canvas sizes based on provided root config
canvas.height = this.root.config.view.height canvas.height = this.root.config.view.height;
canvas.width = this.root.config.view.width canvas.width = this.root.config.view.width;
canvas.style.width = this.root.config.view.width + 'px' canvas.style.width = this.root.config.view.width + "px";
canvas.style.height = this.root.config.view.height + 'px' canvas.style.height = this.root.config.view.height + "px";
canvas.style.left = "0px";
this.element = canvas this.element = canvas;
const ctx = this.element.getContext('2d') const ctx = this.element.getContext("2d");
if (!ctx) throw new Error('Enable hardware acceleration') if (!ctx) throw new Error("Enable hardware acceleration");
this.ctx = ctx this.ctx = ctx;
}
getCellByCoords(x: number, y: number): Position {
let row = 0;
let height = 0;
while (height <= y) {
height += this.root.config.rows[row].height;
if (height >= y) break;
row++;
} }
getCellByCoords(x: number, y: number): Position { let col = 0;
let row = 0; let width = 0;
let height = 0 while (width <= x) {
while (height <= y) { width += this.root.config.columns[col].width;
height += this.root.config.rows[row].height if (width >= x) break;
if (height >= y) break; col++;
row++;
}
let col = 0;
let width = 0;
while (width <= x) {
width += this.root.config.columns[col].width
if (width >= x) break;
col++;
}
return new Position(row, col)
} }
renderCell(position: Position) { return new Position(row, col);
const { column, row } = position }
this.root.data[row][column].render(this.root)
renderCell(position: Position) {
const { column, row } = position;
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) {
renderSheet() { const box = new RenderBox(this.root.config, selectedCell);
const firstRowIdx = this.root.viewport.firstRow box.x -= this.root.viewport.left;
const lastColIdx = this.root.viewport.lastCol + 3 box.y -= this.root.viewport.top;
const lastRowIdx = this.root.viewport.lastRow + 3 return box;
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]) break; //* Prevent read undefined
this.renderCell({ column: col, row })
}
}
} }
}
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])
break; //* Prevent read undefined
this.renderCell({ column: col, row });
}
}
this.renderSelection();
}
} }

View File

@ -1,22 +1,22 @@
import { Spreadsheet } from "../main" import Spreadsheet, { CSS_PREFIX } from "../main";
import { ViewProperties } from "../modules/config" import { ViewProperties } from "../modules/config";
/** Base (root) component */ /** Base (root) component */
export class Table { export class Table {
element: HTMLDivElement element: HTMLDivElement;
root: Spreadsheet root: Spreadsheet;
constructor(root: Spreadsheet) { constructor(root: Spreadsheet) {
this.root = root this.root = root;
const container = document.createElement('div') const container = document.createElement("div");
container.classList.add('spreadsheet_container') container.classList.add(CSS_PREFIX + "spreadsheet_container");
this.element = container this.element = container;
this.changeElementSizes(this.root.viewProps) this.changeElementSizes(this.root.viewProps);
} }
changeElementSizes(sizes: ViewProperties) { changeElementSizes(sizes: ViewProperties) {
const { height, width } = sizes const { height, width } = sizes;
this.element.style.width = width + 'px' this.element.style.width = width + this.root.rowsBarWidth + "px";
this.element.style.height = height + 'px' this.element.style.height = height + this.root.columnsBarHeight + "px";
} }
} }

View File

@ -1,12 +1,13 @@
import { Spreadsheet } from "../main" import Spreadsheet, { CSS_PREFIX } from "../main";
export class Toolbar { export class Toolbar {
element: HTMLDivElement element: HTMLDivElement;
root: Spreadsheet root: Spreadsheet;
constructor(root: Spreadsheet) { height: number = 0;
this.root = root constructor(root: Spreadsheet) {
const toolbarElement = document.createElement('div') this.root = root;
toolbarElement.classList.add('toolbar') const toolbarElement = document.createElement("div");
this.element = toolbarElement toolbarElement.classList.add(CSS_PREFIX + "toolbar");
} this.element = toolbarElement;
}
} }

View File

@ -1,5 +1,38 @@
import Spreadsheet, { createSampleData, } from './main' import Spreadsheet, { SpreadsheetConstructorProperties } from "./main";
const sheet = new Spreadsheet('#spreadsheet').loadData(createSampleData(20, 20, true)) const options: SpreadsheetConstructorProperties = {
onCellClick: (event, cell) => {
console.log("Cell click", event, cell);
},
onSelectionChange: (selection) => {
console.log("Changed selection: ", selection);
},
onCellChange(cell) {
console.log("Cell changed: ", cell);
},
onCopy: (range, data, dataAsString) => {
console.log("Copy event: ", range, data, dataAsString)
}
};
console.log(sheet) const sheet = new Spreadsheet("#spreadsheet", options);
function saveDataToLS() {
const serializableData = sheet.serializeData();
localStorage.setItem("sheet", JSON.stringify(serializableData));
}
function loadDataFromLS() {
const data = localStorage.getItem("sheet");
if (!data) return;
const json = JSON.parse(data);
sheet.loadData(json);
}
const saveButton = document.querySelector("#save_button");
const loadButton = document.querySelector("#load_button");
if (!saveButton || !loadButton) throw new Error("LOST");
saveButton.addEventListener("click", saveDataToLS);
loadButton.addEventListener("click", loadDataFromLS);

View File

@ -1,19 +1,36 @@
import { Editor } from "./components/editor"; import { Editor } from "./components/editor";
import { Header } from "./components/header";
import { Scroller } from "./components/scroller"; import { Scroller } from "./components/scroller";
import { Sheet } from "./components/sheet"; import { Sheet } from "./components/sheet";
import { Table } from "./components/table"; import { Table } from "./components/table";
import { Toolbar } from "./components/toolbar"; import { Toolbar } from "./components/toolbar";
import { Cell, CellConstructorProps, Position } from "./modules/cell"; import {
import { Config, ViewProperties } from "./modules/config"; 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 { RangeSelectionType, Selection } from "./modules/selection";
import { Styles } from "./modules/styles"; import { Styles } from "./modules/styles";
import { Viewport } from "./modules/viewport"; import { Viewport } from "./modules/viewport";
import './scss/main.scss' import "./scss/main.scss";
import { createSampleConfig, createSampleData } from "./utils/createData"; import { createSampleData } from "./utils/createData";
import { Cache, CachedColumn, CachedRow } from "./modules/cache"; import { Cache, CachedColumn, CachedRow } from "./modules/cache";
import { Row } from "./modules/row"; import { Row } from "./modules/row";
import { Column } from "./modules/column"; 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";
import { FormulaParser } from "./modules/formulaParser";
/* /*
! Component structure ! Component structure
@ -27,238 +44,400 @@ import { Column } from "./modules/column";
</Table> </Table>
*/ */
interface SpreadsheetConstructorProperties { export interface SpreadsheetConstructorProperties {
config?: Omit<Config, 'view'> // Not optional. view?: ViewProperties;
view?: ViewProperties onCellClick?: CellClickEvent | null;
onSelectionChange?: SelectionChangeEvent | null;
onCellChange?: CellChangeEvent | null;
onCopy?: CopyEvent | null
} }
export const CSS_PREFIX = "modern_sc_";
export default class Spreadsheet { export default class Spreadsheet {
private table: Table private table: Table;
private scroller: Scroller private scroller: Scroller;
private toolbar: Toolbar private toolbar: Toolbar;
private header: Header private rowsBar: RowsBar;
private sheet: Sheet private columnsBar: ColumnsBar;
private editor: Editor private sheet: Sheet;
public styles: Styles private editor: Editor;
public config: Config public styles: Styles;
public data: Cell[][] public config: Config;
public viewport: Viewport public data: Cell[][];
public selection: Selection public viewport: Viewport;
public cache: Cache public selection: Selection;
public cache: Cache;
public events: Events;
public clipboard: Clipboard;
public formulaParser: FormulaParser
constructor(target: string | HTMLElement, props?: SpreadsheetConstructorProperties) { constructor(
const config = createSampleConfig(500, 500) target: string | HTMLElement,
if (props?.view) { props?: SpreadsheetConstructorProperties,
config.view = props.view ) {
} const data = createSampleData(40, 40);
const config = this.makeConfigFromData(
this.config = new Config(config) data,
this.sheet = new Sheet(this) props?.view ?? { height: 600, width: 800 },
const data = createSampleData(500, 500) );
this.table = new Table(this) if (props?.view) {
this.scroller = new Scroller(this) config.view = props.view;
this.toolbar = new Toolbar(this)
this.header = new Header(this)
this.editor = new Editor(this)
this.cache = this.getInitialCache()
this.viewport = new Viewport(this, this.scroller.getViewportBoundlingRect())
this.selection = new Selection()
this.data = data
this.styles = new Styles()
this.buildComponent()
this.appendTableToTarget(target)
this.renderSheet()
} }
private getInitialCache(): Cache { this.config = new Config(config);
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[] = [] this.config.onCellClick = props?.onCellClick ?? null;
let currentHeight = 0 this.config.onSelectonChange = props?.onSelectionChange ?? null;
for (let i = 0; i <= this.config.rows.length - 1; i++) { this.config.onCellChange = props?.onCellChange ?? null;
const row = this.config.rows[i] this.config.onCopy = props?.onCopy ?? null
currentHeight += row.height
const cacheRow = new CachedRow({
yPos: currentHeight,
rowIdx: i
})
cachedRows.push(cacheRow)
}
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.formulaParser = new FormulaParser(this)
const cache = new Cache({ this.data = data;
columns: cachedCols, this.styles = new Styles();
rows: cachedRows this.buildComponent();
}) this.setElementsPositions();
this.appendTableToTarget(target);
this.renderSheet();
this.renderColumnsBar();
this.renderRowsBar();
}
console.log("CACHE: ", cache) private setRowsBarPosition() {
console.log("CONFIG: ", this.config) const top = this.columnsBar.height + this.toolbar.height;
return cache const left = 0;
this.rowsBar.setElementPosition(top, left);
}
private setColumnsBarPosition() {
const top = this.toolbar.height;
const left = this.rowsBar.width;
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);
} }
private buildComponent(): void { const cachedRows: CachedRow[] = [];
let currentHeight = 0;
const content = document.createElement('div') //* Abstract for (let i = 0; i <= this.config.rows.length - 1; i++) {
content.appendChild(this.header.element) const row = this.config.rows[i];
content.appendChild(this.sheet.element) currentHeight += row.height;
const cacheRow = new CachedRow({
content.classList.add('content') yPos: currentHeight,
rowIdx: i,
this.table.element.appendChild(this.toolbar.element) });
this.table.element.appendChild(content) cachedRows.push(cacheRow);
this.table.element.appendChild(this.scroller.element)
this.table.element.append(this.editor.element)
} }
private appendTableToTarget(target: string | HTMLElement) { const cache = new Cache({
if (typeof target === 'string') { columns: cachedCols,
const element = document.querySelector(target) rows: cachedRows,
if (!element) throw new Error(`Element with selector ${target} is not finded in DOM.\n Make sure it exists.`) });
element?.appendChild(this.table.element)
} return cache;
if (target instanceof HTMLElement) { }
target.append(this.table.element)
} 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();
}
renderSelection() {
this.sheet.renderSelection();
}
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] ? 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++) {
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);
} }
get ctx() { const config = this.makeConfigFromData(formattedData, this.config.view);
return this.sheet.ctx 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 = config
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),
}),
);
} }
get viewProps() { const columns: Column[] = [];
return this.config.view
for (let col = 0; col < lastColIdx; col++) {
columns.push(
new Column({
width: 150,
title: String(col),
}),
);
} }
focusTable() { const config = new Config({
this.scroller.element.focus() 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);
} }
getCellByCoords(x: number, y: number) { return cellsArray;
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'>>) {
const { column, row } = position
this.data[row][column].changeValues(values)
this.renderCell(row, column)
}
applyActionToRange(range: RangeSelectionType, callback: (cell: Cell) => any): 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) {
this.editor.show(position)
}
renderSheet() {
this.sheet.renderSheet()
}
renderCell(row: number, col: number) {
this.data[row][col].render(this)
}
public loadData(data: Cell[][]): Spreadsheet {
this.data = data
this.config = this.makeConfigFromData(data, 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
})
return config
}
} }
export * from './modules/cache' export * from "./modules/cache";
export * from './modules/cell' export * from "./modules/cell";
export * from './modules/column' export * from "./modules/column";
export * from './modules/config' export * from "./modules/config";
export * from './modules/renderBox' export * from "./modules/renderBox";
export * from './modules/row' export * from "./modules/row";
export * from './modules/selection' export * from "./modules/selection";
export * from './modules/styles' export * from "./modules/styles";
export * from './modules/viewport' export * from "./modules/viewport";
export * from './utils/createData' export * from "./utils/createData";

View File

@ -1,67 +1,67 @@
export interface CachedColumnProperties { export interface CachedColumnProperties {
xPos: number xPos: number;
colIdx: number colIdx: number;
} }
export class CachedColumn { export class CachedColumn {
xPos: number xPos: number;
colIdx: number colIdx: number;
constructor(props: CachedColumnProperties) { constructor(props: CachedColumnProperties) {
this.xPos = props.xPos this.xPos = props.xPos;
this.colIdx = props.colIdx this.colIdx = props.colIdx;
} }
} }
export interface CachedRowProperties { export interface CachedRowProperties {
yPos: number yPos: number;
rowIdx: number rowIdx: number;
} }
export class CachedRow { export class CachedRow {
yPos: number yPos: number;
rowIdx: number rowIdx: number;
constructor(props: CachedRowProperties) { constructor(props: CachedRowProperties) {
this.yPos = props.yPos this.yPos = props.yPos;
this.rowIdx = props.rowIdx this.rowIdx = props.rowIdx;
} }
} }
export interface CacheConstructorProps { export interface CacheConstructorProps {
columns: CachedColumn[] columns: CachedColumn[];
rows: CachedRow[] rows: CachedRow[];
} }
export class Cache { export class Cache {
public columns: CachedColumn[] public columns: CachedColumn[];
public rows: CachedRow[] public rows: CachedRow[];
constructor(initial: CacheConstructorProps) { constructor(initial: CacheConstructorProps) {
this.columns = initial.columns this.columns = initial.columns;
this.rows = initial.rows this.rows = initial.rows;
}
public getRowByYCoord(y: number): number {
let rowIdx = 0;
for (let i = 0; i < this.rows.length; i++) {
rowIdx = i
if (y <= this.rows[i].yPos) { //* Intersection detect
break;
}
} }
public getRowByYCoord(y: number): number { return rowIdx;
let rowIdx = 0; }
for (let i = 0; i < this.rows.length; i++) {
if (y <= this.rows[i].yPos) { //* Intersection detect public getColumnByXCoord(x: number): number {
rowIdx = i; let colIdx = 0;
break; for (let i = 0; i < this.columns.length; i++) {
} colIdx = i
} if (x <= this.columns[i].xPos) { //* Intersection detect
return rowIdx; break;
}
} }
return colIdx;
}
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;
break;
}
}
return colIdx;
}
} }

View File

@ -1,98 +1,155 @@
import { Spreadsheet } from "../main" import Spreadsheet from "../main";
import { RenderBox } from "./renderBox" import { FormulaParser } from "./formulaParser";
import { RenderBox } from "./renderBox";
export type CellConstructorProps = { export type CellConstructorProps = {
value: string value: string;
displayValue: string displayValue: string;
resultValue: string resultValue: string;
position: Position position: Position;
} style: CellStyles | null;
};
interface CellStylesConstructorProps { interface CellStylesConstructorProps {
fontSize: number fontSize: number;
fontColor: string fontColor: string;
background: string background: string;
borderColor: string borderColor: string;
selectedBackground: string selectedBackground: string;
selectedFontColor: string selectedFontColor: string;
} }
export class CellStyles { export class CellStyles {
fontSize: number = 16 fontSize: number = 16;
fontColor: string = 'black' fontColor: string = "black";
background: string = 'white' background: string = "white";
borderColor: string = 'black' borderColor: string = "black";
selectedBackground = '#4287f5' selectedBackground = "#4287f5";
selectedFontColor = '#ffffff' selectedFontColor = "#ffffff";
constructor(props?: CellStylesConstructorProps) { constructor(props?: CellStylesConstructorProps) {
if (props) { if (props) {
Object.assign(this, props) // Override default styles Object.assign(this, props); // Override default styles
}
} }
}
} }
export class Position { export class Position {
row: number row: number;
column: number column: number;
constructor(row: number, column: number) { constructor(row: number, column: number) {
this.row = row this.row = row;
this.column = column this.column = column;
} }
}
export class SerializableCell {
value: string;
displayValue: string;
resultValue: string;
position: Position;
style: CellStyles | null;
constructor(props: SerializableCell | SerializableCell) {
this.value = props.value;
this.displayValue = props.displayValue;
this.resultValue = props.resultValue;
this.position = props.position;
this.style = props.style;
}
} }
export class Cell { export class Cell {
value: string /** True value (data) */
displayValue: string value: string;
/** This refers to the values that were obtained by calculations, for example, after calculating the formula */ /** Value to render */
resultValue: string displayValue: string;
position: Position /** This refers to the values that were obtained by calculations, for example, after calculating the formula */
style: CellStyles = new CellStyles() resultValue: string;
position: Position;
style: CellStyles | null = null;
constructor(props: CellConstructorProps) { cellsDependsOnThisCell: Position[] = []
this.value = props.value dependedFromCells: Position[] = []
this.displayValue = props.displayValue
this.resultValue = props.resultValue
this.position = props.position
}
changeValues(values: Partial<Omit<CellConstructorProps, 'position'>>) { constructor(props: CellConstructorProps) {
Object.assign(this, values) this.value = props.value;
} this.displayValue = props.displayValue;
this.resultValue = props.resultValue;
this.position = props.position;
this.style = props.style;
}
private isCellInRange(root: Spreadsheet): boolean { public getSerializableCell(): SerializableCell {
const { column, row } = this.position const cell: SerializableCell = new SerializableCell({
const { selectedRange } = root.selection displayValue: this.displayValue,
position: this.position,
resultValue: this.resultValue,
style: this.style,
value: this.value,
});
return cell;
}
if (!selectedRange) return false; changeStyles(styles: CellStyles) {
this.style = styles;
}
const isCellInRow = row >= Math.min(selectedRange.from.row, selectedRange.to.row) && row <= Math.max(selectedRange.to.row, selectedRange.from.row) changeValues(values: Partial<Omit<CellConstructorProps, "position">>) {
const isCellInCol = column >= Math.min(selectedRange.from.column, selectedRange.to.column) && column <= Math.max(selectedRange.to.column, selectedRange.from.column) Object.assign(this, values);
}
return isCellInCol && isCellInRow evalFormula(parser: FormulaParser) {
} if (this.value.substring(0, 1) !== '=') return;
render(root: Spreadsheet) { this.resultValue = parser.parser.parse(this.value.slice(1), {
let { height, width, x, y } = new RenderBox(root.config, this.position) col: this.position.column,
const { ctx } = root row: this.position.row
})
}
const isCellSelected = (root.selection.selectedCell?.row === this.position.row && root.selection.selectedCell.column === this.position.column) // private isCellInRange(root: Spreadsheet): boolean {
const isCellInRange = this.isCellInRange(root) // const { column, row } = this.position;
y -= root.viewport.top // const { selectedRange } = root.selection;
x -= root.viewport.left
ctx.clearRect(x, y, width, height) // if (!selectedRange) return false;
ctx.fillStyle = isCellSelected || isCellInRange ? this.style.selectedBackground : this.style.background
ctx.strokeStyle = 'black'
ctx.fillRect(x, y, width - 1, height - 1)
ctx.strokeRect(x, y, width, height)
ctx.fillStyle = isCellSelected || isCellInRange ? this.style.selectedFontColor : this.style.fontColor // const isCellInRow =
ctx.textAlign = 'left' // row >= Math.min(selectedRange.from.row, selectedRange.to.row) &&
ctx.font = `${this.style.fontSize}px Arial` // row <= Math.max(selectedRange.to.row, selectedRange.from.row);
ctx.textBaseline = 'middle' // const isCellInCol =
ctx.fillText(this.displayValue, x + 2, y + height / 2) // 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);
y -= root.viewport.top;
x -= root.viewport.left;
const styles = this.style ?? root.styles.cells;
ctx.clearRect(x, y, width, height);
ctx.fillStyle = styles.background;
ctx.strokeStyle = "black";
ctx.fillRect(x, y, width - 1, height - 1);
ctx.strokeRect(x, y, width, height);
ctx.fillStyle = styles.fontColor;
ctx.textAlign = "left";
ctx.font = `${styles.fontSize}px Arial`;
ctx.textBaseline = "middle";
ctx.fillText(this.displayValue, x + 2, y + height / 2);
}
} }

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,14 +1,14 @@
export type ColumnConstructorProperties = { export type ColumnConstructorProperties = {
width: number width: number;
title: string title: string;
} };
export class Column { export class Column {
width: number width: number;
title: string title: string;
constructor(props: ColumnConstructorProperties) { constructor(props: ColumnConstructorProperties) {
this.width = props.width this.width = props.width;
this.title = props.title this.title = props.title;
} }
} }

View File

@ -1,39 +1,63 @@
import { Column } from "./column" import { Cell } from "./cell";
import { Row } from "./row" import { Column } from "./column";
import { Row } from "./row";
import { RangeSelectionType, Selection } from "./selection";
export interface ViewProperties { export interface ViewProperties {
width: number width: number;
height: 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 = { export type ConfigProperties = {
/** Please, end it with '_' symbol. /** Please, end it with '_' symbol.
* *
* *Example:* * *Example:*
* *
* 'test_' * 'test_'
* 'google_' */ * 'google_' */
rows: Row[] rows: Row[];
columns: Column[] columns: Column[];
view: ViewProperties view: ViewProperties;
} onCellClick?: CellClickEvent | null;
onSelectionChange?: SelectionChangeEvent | null;
onCellChange?: CellChangeEvent | null;
onCopy?: CopyEvent | null;
};
export type SheetConfigConstructorProps = { export type SheetConfigConstructorProps = {
rows: Row[] rows: Row[];
columns: Column[] columns: Column[];
} };
export class Config { export class Config {
rows: Row[] rows: Row[];
columns: Column[] columns: Column[];
view: ViewProperties = { view: ViewProperties = {
width: 800, width: 800,
height: 600, height: 600,
} };
constructor(props: ConfigProperties) {
this.columns = props.columns onCellClick: CellClickEvent | null = null;
this.rows = props.rows onSelectonChange: SelectionChangeEvent | null = null;
this.view = props.view 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.onCopy = props.onCopy ?? null;
}
} }

142
src/modules/events.ts Normal file
View File

@ -0,0 +1,142 @@
import { Scroller } from "../components/scroller";
import Spreadsheet, { Cell, RangeSelectionType, Selection } from "../main";
export 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 class Events {
root: Spreadsheet;
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;
}
case EventTypes.SELECTION_CHANGE: {
const { selection, enableCallback } = action;
//
//* Here may be side effects
//
this.changeSelection(selection, enableCallback);
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);
const selection = new Selection();
selection.selectedCell = clickedCell;
selection.selectedRange = {
from: clickedCell,
to: clickedCell,
};
scroller.setSelectingMode(true);
this.changeSelection(selection, true);
this.root.config.onCellClick?.(event, cell);
};
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();
};
private changeCellValues(cell: Cell, enableCallback: boolean = true) {
if (enableCallback) 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
}
}

View File

@ -2,33 +2,33 @@ import { Position } from "./cell";
import { Config } from "./config"; import { Config } from "./config";
export class RenderBox { export class RenderBox {
x: number x: number;
y: number y: number;
width: number width: number;
height: number height: number;
constructor(config: Config, cellPosition: Position) {
this.x = this.getXCoord(cellPosition.column, config) constructor(config: Config, cellPosition: Position) {
this.y = this.getYCoord(cellPosition.row, config) this.x = this.getXCoord(cellPosition.column, config);
this.width = config.columns[cellPosition.column].width this.y = this.getYCoord(cellPosition.row, config);
this.height = config.rows[cellPosition.row].height this.width = config.columns[cellPosition.column].width;
this.height = config.rows[cellPosition.row].height;
}
private getXCoord(column: number, config: Config): number {
let x = 0;
for (let i = 0; i < column; i++) {
x += config.columns[i].width;
} }
private getXCoord(column: number, config: Config): number { return x;
let x = 0; }
for (let i = 0; i < column; i++) { private getYCoord(row: number, config: Config): number {
x += config.columns[i].width let y = 0;
} for (let i = 0; i < row; i++) {
y += config.rows[i].height;
return x
}
private getYCoord(row: number, config: Config): number {
let y = 0
for (let i = 0; i < row; i++) {
y += config.rows[i].height
}
return y
} }
return y;
}
} }

View File

@ -1,13 +1,13 @@
export type RowConstructorProps = { export type RowConstructorProps = {
height: number height: number;
title: string title: string;
} };
export class Row { export class Row {
height: number height: number;
title: string title: string;
constructor(props: RowConstructorProps) { constructor(props: RowConstructorProps) {
this.height = props.height this.height = props.height;
this.title = props.title this.title = props.title;
} }
} }

View File

@ -1,14 +1,14 @@
export type BaseSelectionType = { export type BaseSelectionType = {
row: number row: number;
column: number column: number;
} };
export type RangeSelectionType = { export type RangeSelectionType = {
from: BaseSelectionType from: BaseSelectionType;
to: BaseSelectionType to: BaseSelectionType;
} };
export class Selection { export class Selection {
selectedCell: BaseSelectionType | null = null selectedCell: BaseSelectionType | null = null;
selectedRange: RangeSelectionType | null = null selectedRange: RangeSelectionType | null = null;
} }

View File

@ -1,4 +1,8 @@
import { CellStyles } from "./cell";
export class Styles { export class Styles {
cells: CellStyles;
constructor() {
this.cells = new CellStyles();
}
} }

View File

@ -1,79 +1,78 @@
import { Spreadsheet } from "../main" import Spreadsheet from "../main";
export type ViewportConstructorProps = { export type ViewportConstructorProps = {
top: number top: number;
left: number left: number;
right: number right: number;
bottom: number bottom: number;
} };
export class Viewport { export class Viewport {
root: Spreadsheet root: Spreadsheet;
top: number top: number;
left: number left: number;
right: number right: number;
bottom: number bottom: number;
firstRow: number firstRow: number;
lastRow: number lastRow: number;
firstCol: number firstCol: number;
lastCol: number lastCol: number;
constructor(root: Spreadsheet, props: ViewportConstructorProps) { constructor(root: Spreadsheet, props: ViewportConstructorProps) {
this.root = root this.root = root;
this.top = props.top this.top = props.top;
this.left = props.left this.left = props.left;
this.right = props.right this.right = props.right;
this.bottom = props.bottom this.bottom = props.bottom;
this.firstRow = this.getFirstRow() this.firstRow = this.getFirstRow();
this.lastCol = this.getFirstRow() //!Temp this.lastCol = this.getFirstRow(); //!Temp
this.firstCol = this.getFirstRow() //!Temp this.firstCol = this.getFirstRow(); //!Temp
this.lastRow = this.getLastRow() this.lastRow = this.getLastRow();
this.updateValues({ this.updateValues({
top: 0, top: 0,
left: 0, left: 0,
right: this.root.viewProps.width, right: this.root.viewProps.width,
bottom: this.root.viewProps.height bottom: this.root.viewProps.height,
}) });
} }
updateValues(props: ViewportConstructorProps) { updateValues(props: ViewportConstructorProps) {
this.top = props.top this.top = props.top;
this.left = props.left this.left = props.left;
this.right = props.right this.right = props.right;
this.bottom = props.bottom this.bottom = props.bottom;
this.firstRow = this.getFirstRow() this.firstRow = this.getFirstRow();
this.lastRow = this.getLastRow() this.lastRow = this.getLastRow();
this.firstCol = this.getFirstCol() this.firstCol = this.getFirstCol();
this.lastCol = this.getLastCol() this.lastCol = this.getLastCol();
} }
/** Get index of first row in viewport */ /** Get index of first row in viewport */
private getFirstRow(): number { private getFirstRow(): number {
let rowIdx = this.root.cache.getRowByYCoord(this.top) const rowIdx = this.root.cache.getRowByYCoord(this.top);
return rowIdx return rowIdx;
} }
private getLastRow(): number { private getLastRow(): number {
let rowIdx = this.root.cache.getRowByYCoord(this.bottom) const rowIdx = this.root.cache.getRowByYCoord(this.bottom);
return rowIdx return rowIdx;
} }
private getFirstCol(): number { private getFirstCol(): number {
let colIdx = this.root.cache.getColumnByXCoord(this.left) const colIdx = this.root.cache.getColumnByXCoord(this.left);
return colIdx return colIdx;
} }
private getLastCol(): number { private getLastCol(): number {
let colIdx = this.root.cache.getColumnByXCoord(this.right) const colIdx = this.root.cache.getColumnByXCoord(this.right);
return colIdx
}
return colIdx;
}
} }

View File

@ -1,4 +1,4 @@
body { body {
padding: 0px; padding: 0px;
margin: 0px; margin: 0px;
} }

View File

@ -1,40 +1,38 @@
$css_prefix: "modern_sc_";
.#{$css_prefix}spreadsheet_container {
.content { position: relative;
position: absolute; isolation: isolate;
top: 0; border: 2px solid black;
left: 0;
} }
.spreadsheet_container { .#{$css_prefix}content {
position: relative; position: absolute;
isolation: isolate;
border: 2px solid black;
} }
.sheet{ .#{$css_prefix}sheet {
display: block; display: block;
contain: strict; contain: strict;
} }
.scroller { .#{$css_prefix}scroller {
overflow: scroll; position: absolute;
box-sizing: border-box; overflow: scroll;
transform: translateZ(0); box-sizing: border-box;
&:focus { transform: translateZ(0);
outline: none;
} &:focus {
outline: none;
}
} }
.editor { .#{$css_prefix}editor {
position: absolute; position: absolute;
box-sizing: border-box; box-sizing: border-box;
font-size: 16px; font-size: 16px;
font-family: Arial, Helvetica, sans-serif; font-family: Arial, Helvetica, sans-serif;
} }
.hide { .#{$css_prefix}hide {
visibility: hidden; visibility: hidden;
} }

View File

@ -1,2 +1,2 @@
@import 'global.scss'; @import "global.scss";
@import 'spreadsheet.scss'; @import "spreadsheet.scss";

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

@ -3,71 +3,78 @@ import { Column } from "../modules/column";
import { Config } from "../modules/config"; import { Config } from "../modules/config";
import { Row } from "../modules/row"; import { Row } from "../modules/row";
export function createSampleData(rows: number, columns: number, fillCellsByCoords: boolean = false): Cell[][] { export function createSampleData(
const data: Cell[][] = [] rows: number,
columns: number,
fillCellsByCoords: boolean = false,
): Cell[][] {
const data: Cell[][] = [];
for (let row = 0; row <= rows; row++) { for (let row = 0; row <= rows; row++) {
const innerRow: Cell[] = [] const innerRow: Cell[] = [];
for (let col = 0; col <= columns; col++) { for (let col = 0; col <= columns; col++) {
const value = fillCellsByCoords ? `${row}:${col}` : '' const value = fillCellsByCoords ? `${row}:${col}` : "";
const cell = new Cell({ const cell = new Cell({
displayValue: value, displayValue: value,
resultValue: value, resultValue: value,
value, value,
position: { position: {
column: col, column: col,
row: row row: row,
} },
}) style: null,
});
innerRow.push(cell) innerRow.push(cell);
}
data.push(innerRow)
} }
return data data.push(innerRow);
}
return data;
} }
export function createSampleConfig(rows: number, columns: number): Config { export function createSampleConfig(rows: number, columns: number): Config {
const rowsArr: Row[] = [];
for (let i = 0; i <= rows; i++) {
const rowItem = new Row({
height: 40,
title: String(i),
});
rowsArr.push(rowItem);
}
const rowsArr: Row[] = [] const colsArr: Column[] = [];
for (let i = 0; i <= rows; i++) { for (let i = 0; i <= columns; i++) {
const rowItem = new Row({ const colItem = new Column({
height: 40, title: String(i),
title: String(i) width: 150,
}) });
rowsArr.push(rowItem) colsArr.push(colItem);
} }
const colsArr: Column[] = [] const config = new Config({
for (let i = 0; i <= columns; i++) { columns: colsArr,
const colItem = new Column({ rows: rowsArr,
title: String(i), view: {
width: 150 height: 600,
}) width: 800,
colsArr.push(colItem) },
} });
const config = new Config({ return config;
columns: colsArr,
rows: rowsArr,
view: {
height: 600,
width: 800
}
})
return config
} }
export type SpreadsheetConfigAndDataReturnType = { export type SpreadsheetConfigAndDataReturnType = {
config: Config, config: Config;
data: Cell[][] data: Cell[][];
} };
export function makeSpreadsheetConfigAndData(rows: number, columns: number): SpreadsheetConfigAndDataReturnType { export function makeSpreadsheetConfigAndData(
const data = createSampleData(rows, columns) rows: number,
const config = createSampleConfig(rows, columns) columns: number,
): SpreadsheetConfigAndDataReturnType {
const data = createSampleData(rows, columns);
const config = createSampleConfig(rows, columns);
return { data, config } return { data, config };
} }

20
src/utils/position.ts Normal file
View File

@ -0,0 +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;
return equalRows && equalColumns;
}
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"], "types": ["vite/client", "node"],
"allowJs": false, "allowJs": false,
"skipLibCheck": true, "skipLibCheck": true,
"esModuleInterop": false, "esModuleInterop": true,
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"strict": true, "strict": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,

View File

@ -1,39 +1,61 @@
import { defineConfig } from 'vite' import { defineConfig } from "vite";
import path from 'path' import path from "path";
import typescript from "@rollup/plugin-typescript"; import typescript from "@rollup/plugin-typescript";
import { typescriptPaths } from "rollup-plugin-typescript-paths"; import { typescriptPaths } from "rollup-plugin-typescript-paths";
import { fileURLToPath } from 'node:url';
export default defineConfig({ const BROWSER_MODE = process.env.BUILD_BROWSER === 'true';
base: '/modern_spreadsheet/', console.log({ BROWSER_MODE });
plugins: [],
resolve: {}, const libConfig = defineConfig({
server: { base: "/modern_spreadsheet/",
port: 3000, plugins: [],
open: true, resolve: {},
server: {
port: 5179,
open: true,
},
build: {
manifest: true,
minify: true,
reportCompressedSize: true,
sourcemap: true,
lib: {
entry: path.resolve(__dirname, "src/main.ts"),
fileName: "main",
formats: ["es", "cjs"],
}, },
build: { rollupOptions: {
manifest: true, external: ["./src/index.ts"],
minify: true, plugins: [
reportCompressedSize: true, typescriptPaths({
sourcemap: true, preserveExtensions: true,
lib: { }),
entry: path.resolve(__dirname, "src/main.ts"), typescript({
fileName: "main", sourceMap: false,
formats: ["es", "cjs"], declaration: true,
}, outDir: "dist",
rollupOptions: { }),
external: ["./src/index.ts"], ],
plugins: [ },
typescriptPaths({ },
preserveExtensions: true });
}),
typescript({ const browserConfig = defineConfig({
sourceMap: false, base: "/modern_spreadsheet/",
declaration: true, resolve: {},
outDir: 'dist' 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;