Compare commits

...

21 Commits

Author SHA1 Message Date
19efeee09e change version 2022-07-19 12:55:30 +05:00
5779faa413 add readme, and deploy config example 2022-07-19 12:53:44 +05:00
dab573919e little hot fixes 2022-07-19 12:25:54 +05:00
b634c50a52 little fixes 2022-07-19 11:37:19 +05:00
579694c829 little ux fixes 2022-07-18 16:32:09 +05:00
0d17d35fe8 refactor, new selection/focus logic 2022-07-18 14:19:55 +05:00
3bb80c1ddf add column/row adding, removing 2022-07-17 16:50:22 +05:00
e12efe60b1 little fix selection 2022-07-14 16:59:02 +05:00
497a275537 add remove row function 2022-07-14 16:13:43 +05:00
2f78b7ec22 add cells selection 2022-07-14 11:43:42 +05:00
b282490279 add change table size 2022-07-13 16:27:41 +05:00
d1b83a26a3 add some type 2022-07-12 17:00:27 +05:00
d674203a4d add row button, add row after enter press in last row 2022-07-12 13:27:38 +05:00
09ff3a751d add selection by mouse over 2022-07-11 21:06:28 +05:00
2b053a9d47 add selection all cells in row or col 2022-07-11 17:52:51 +05:00
c92ae3a38d add increase-decrease font size buttons 2022-07-11 15:06:52 +05:00
e1ad90fc6f add fontFamily select, some fixes 2022-07-11 12:57:49 +05:00
f2ee7b8469 fix overflow, space in cell 2022-07-11 11:03:18 +05:00
3bd194816e little refactor 2022-07-10 23:05:25 +05:00
f7e04814a5 add font-size tool, little fixes and refactor 2022-07-10 18:06:43 +05:00
cdd94a5d02 add vertical align buttons 2022-07-10 13:40:50 +05:00
54 changed files with 2156 additions and 647 deletions

View File

@ -34,13 +34,14 @@ module.exports = {
"import/prefer-default-export": "off", "import/prefer-default-export": "off",
"linebreak-style": "off", "linebreak-style": "off",
"max-len": "off", "max-len": "off",
"no-console": "off",
"no-new": "off",
"no-continue": "off",
"no-plusplus": "off",
"object-curly-newline": "off",
"no-alert": "off", "no-alert": "off",
"no-console": "off",
"no-continue": "off",
"no-new": "off",
"no-plusplus": "off",
"no-restricted-globals": "off", "no-restricted-globals": "off",
"object-curly-newline": "off",
"prefer-destructuring": "off",
}, },
env: { env: {
browser: true, browser: true,

76
README.md Normal file
View File

@ -0,0 +1,76 @@
# Mini excel app
## _Small application covering the basic functionality of Excel_
The application is a SPA consisting of two screens
- Dashboard (Create new table or open saved)
- Excel (Excel table editing page)
Technologies used in this project:
- Typescript
- ES6+
- SCSS
- Redux
- Webpack 5
- Jest
## Implemented functionality
- Resize table rows/columns
- Navigation and selection of cells using the function keys (Ctrl, Shift, Arrows), or using the mouse
- Mathematical calculations in a formula
- Ability to add/remove columns via context menu (right mouse button)
- Changing cell style: size, font, alignment
- Saving to LocalStorage, also implemented the ability to delete certain tables
## Installation
Requires [Node.js](https://nodejs.org/) v16+ to run.
Install the dependencies and devDependencies and start the server.
```sh
npm install
```
For development run the command to create local webpack dev server
```sh
npm run start
```
For build use
```sh
npm run build
```
For deploy edit deploy config file `configs/deploy/sftp-configs.js`
```sh
module.exports = {
config: {
host: 'host',
port: 'port',
username: 'username',
password: 'password',
},
directory: 'path to directory'
};
```
and run
```sh
npm run deploy
```
Testing
Run jest test by
```sh
npm run test
```
or
```sh
npm run test-watch
```
## License
MIT

View File

@ -0,0 +1,10 @@
module.exports = {
config: {
host: 'host',
port: 'port',
username: 'username',
password: 'password',
},
directory: 'directory'
};

View File

@ -3,9 +3,9 @@
const path = require('path'); const path = require('path');
const SftpClient = require('ssh2-sftp-client'); const SftpClient = require('ssh2-sftp-client');
const dotenv = require('dotenv'); const dotenv = require('dotenv');
const { config } = require('./configs/deploy/sftp-config'); const { config, directory } = require('./configs/deploy/sftp-config');
const REMOTE_DIRECTORY = '/var/www/mysite/excel'; const REMOTE_DIRECTORY = directory;
const LOCAL_DIRECTORY = path.join(__dirname, 'dist'); const LOCAL_DIRECTORY = path.join(__dirname, 'dist');
const dotenvPath = path.join(__dirname, '..', '.env'); const dotenvPath = path.join(__dirname, '..', '.env');

View File

@ -1,6 +1,6 @@
{ {
"name": "excel-course", "name": "excel-course",
"version": "1.0.0", "version": "1.1.0",
"main": "index.js", "main": "index.js",
"devDependencies": { "devDependencies": {
"@babel/core": "^7.18.6", "@babel/core": "^7.18.6",

View File

@ -0,0 +1,124 @@
import { BaseComponentOption } from 'components/excel/Excel';
import { $, Dom } from 'core/Dom';
import { ExcelComponent } from 'core/ExcelComponent';
export type ContextSelectType = {
event: ContextEventType,
target: Dom,
};
type ContextEventType =
'add-row-before' |
'remove-row' |
'add-row-after' |
'add-col-before' |
'remove-col' |
'add-col-after';
export class ContextMenu extends ExcelComponent {
static className = 'excel__contextmenu_layer';
private contextMenu: Dom;
private contextType: string;
private contextFor: Dom;
constructor($root: Dom, options: BaseComponentOption) {
super($root, {
...options,
name: 'ContextMenu',
eventListeners: ['click', 'mouseup'],
});
this.$onEventFromObserver('table:contextmenu', this.initContext);
this.$onEventFromObserver('table:rerendercontext', this.rerender);
this.contextType = 'row';
}
toHTML(): string {
return this.createContextMenu();
}
afterRender() {
super.afterRender();
this.contextMenu = $('#excel__contextmenu');
}
transformContextMenuCoords(coords: { left: number, top: number }) {
this.contextMenu.css({
left: `${coords.left.toString()}px`,
top: `${coords.top.toString()}px`,
});
}
hideContextMenu() {
this.transformContextMenuCoords({ left: 0, top: -100 });
this.$root.removeClass('active');
}
initContext = (event: MouseEvent) => {
const $target = $(event.target);
if (!$target.closest('[data-header]').isExist) return;
event.preventDefault();
this.contextFor = $target;
if ($target.data.header && $target.data.header !== this.contextType) {
this.contextType = $target.data.header;
this.$root.html(this.createContextMenu());
this.contextMenu = $('#excel__contextmenu');
}
this.transformContextMenuCoords({ top: event.clientY, left: event.clientX });
this.$root.addClass('active');
};
emitSelectItem(contextItem: Dom) {
if (!contextItem.isExist) return;
const contextEvent = contextItem?.data.contextitem;
if (!contextEvent) return;
const select = {
target: this.contextFor,
event: contextEvent,
};
this.$emitEventToObserver('context-menu: select', select);
}
createContextMenu() {
if (this.contextType === 'row') {
return `
<div class="excel__contextmenu" id="excel__contextmenu">
<div class="excel__contextmenu_item" tabindex="-1" data-contextitem="add-row-before">Добавить строку сверху</div>
<div class="excel__contextmenu_item" tabindex="-1" data-contextitem="remove-row">Удалить строку</div>
<div class="excel__contextmenu_item" tabindex="-1" data-contextitem="add-row-after">Добавить строку снизу</div>
</div>
`;
}
return `
<div class="excel__contextmenu" id="excel__contextmenu">
<div class="excel__contextmenu_item" tabindex="-1" data-contextitem="add-col-before">Добавить столбец слева</div>
<div class="excel__contextmenu_item" tabindex="-1" data-contextitem="remove-col">Удалить столбец</div>
<div class="excel__contextmenu_item" tabindex="-1" data-contextitem="add-col-after">Добавить столбец справа</div>
</div>
`;
}
onClick(e: MouseEvent) {
const $target = $(e.target);
if ($target.closest('#excel__contextmenu').isExist) {
this.emitSelectItem($target);
return;
}
this.hideContextMenu();
}
onMouseup() {
this.hideContextMenu();
}
}

View File

@ -1,4 +1,4 @@
import { $, Dom } from 'core/dom'; import { $, Dom } from 'core/Dom';
export function Loader(): Dom { export function Loader(): Dom {
return $.create('div', 'loader') return $.create('div', 'loader')

View File

@ -1,58 +1,65 @@
import { $ } from 'core/dom'; import { ContextMenu } from 'components/ContextMenu/ContextMenu';
import { Emitter } from 'core/Emitter'; import { Formula } from 'components/formula/Formula';
import { ExcelComponent } from 'core/ExcelComponent'; import { Header } from 'components/header/Header';
import { Store } from 'core/store/createStore'; import { Table } from 'components/table/Table';
import { Toolbar } from 'components/toolbar/Toolbar';
import { ComponentManager } from 'core/ComponentManager';
import { Observer } from 'core/Observer';
import { Store } from 'core/store/Store';
import { StoreSubscriber } from 'core/StoreSubscriber'; import { StoreSubscriber } from 'core/StoreSubscriber';
import { updateOpenDate } from 'redux/actions'; import { updateOpenDate } from 'redux/action-creators';
// TODO type for components array
interface ExcelOptionsType { interface ExcelOptionsType {
components: any[], components: ComponentType[],
store: any, store: Store,
} }
export type ComponentType = typeof Header | typeof Toolbar | typeof Formula | typeof Table | typeof ContextMenu;
export type BaseComponentOption = {
observer: Observer;
store: Store;
componentManager: ComponentManager;
};
export class Excel { export class Excel {
components: any[]; components: any[];
emitter: Emitter; observer: Observer;
store: Store; store: Store;
subscriber: StoreSubscriber; subscriber: StoreSubscriber;
componentManage: ComponentManager;
constructor(options: ExcelOptionsType) { constructor(options: ExcelOptionsType) {
this.components = options.components;
this.emitter = new Emitter();
this.store = options.store; this.store = options.store;
this.components = options.components;
this.subscriber = new StoreSubscriber(this.store); this.subscriber = new StoreSubscriber(this.store);
this.observer = new Observer();
this.componentManage = new ComponentManager();
} }
getRoot() { getRoot() {
const $root = $.create('div', 'excel'); const componentOptions: BaseComponentOption = {
observer: this.observer,
const componentOptions = {
emitter: this.emitter,
store: this.store, store: this.store,
componentManager: this.componentManage,
}; };
this.components = this.components.map(Component => this.componentManage.createComponent(Component, componentOptions));
this.componentManage.addComponentsToRoot(this.components);
this.components = this.components.map(Component => { return this.componentManage.$rootExcelElement;
const $el = $.create('div', Component.className);
const component: ExcelComponent = new Component($el, componentOptions);
$el.html(component.toHTML());
$root.append($el.$el);
return component;
});
return $root;
} }
init() { afterRender() {
this.subscriber.subscribeComponents(this.components); this.subscriber.subscribeComponents(this.components);
this.components.forEach(component => component.init()); this.components.forEach(component => component.afterRender());
this.store.dispatch(updateOpenDate(Date.now().toString())); this.store.dispatchToStore(updateOpenDate(Date.now().toString()));
} }
destroy() { destroy() {
this.subscriber.unsubscribeFromStore(); this.subscriber.unsubscribeFromStore();
this.components.forEach(component => component.destroy()); this.componentManage.destroyComponents();
} }
} }

View File

@ -1,4 +1,5 @@
import { Dom } from 'core/dom'; import { BaseComponentOption } from 'components/excel/Excel';
import { Dom } from 'core/Dom';
import { ExcelComponent } from 'core/ExcelComponent'; import { ExcelComponent } from 'core/ExcelComponent';
export class Formula extends ExcelComponent { export class Formula extends ExcelComponent {
@ -6,12 +7,12 @@ export class Formula extends ExcelComponent {
private formulaInput: Dom; private formulaInput: Dom;
constructor($root: Dom, options: any) { constructor($root: Dom, options: BaseComponentOption) {
super($root, { super($root, {
listeners: ['input', 'keydown'], ...options,
eventListeners: ['input', 'keydown'],
name: 'Formula', name: 'Formula',
subscribe: ['currentText'], subscribe: ['currentText'],
...options,
}); });
} }
@ -22,13 +23,13 @@ export class Formula extends ExcelComponent {
`; `;
} }
init() { afterRender() {
super.init(); super.afterRender();
this.formulaInput = this.$root.find('#formula-input'); this.formulaInput = this.$root.find('#formula-input');
this.$on('table:select-cell', text => { this.$onEventFromObserver('table:select-cell', (cell: Dom) => {
this.formulaInput.text = text || ''; this.formulaInput.text = cell.dataValue || '';
}); });
} }
@ -41,7 +42,8 @@ export class Formula extends ExcelComponent {
if (!target) return; if (!target) return;
const text = (target as HTMLElement).innerText.trim(); const text = (target as HTMLElement).innerText.trim();
this.$emit('formula:input', text);
this.$emitEventToObserver('formula:input', text);
} }
onKeydown(event: KeyboardEvent) { onKeydown(event: KeyboardEvent) {
@ -50,7 +52,7 @@ export class Formula extends ExcelComponent {
if (preventedKeys.includes(event.key)) event.preventDefault(); if (preventedKeys.includes(event.key)) event.preventDefault();
if (event.key === 'Enter') { if (event.key === 'Enter') {
this.$emit('formula:enter-press'); this.$emitEventToObserver('formula:enter-press');
} }
} }
} }

View File

@ -1,26 +1,28 @@
import * as actions from 'redux/actions'; import { BaseComponentOption } from 'components/excel/Excel';
import { $, Dom } from 'core/dom'; import { ExcelComponent } from 'core/ExcelComponent';
import * as actions from 'redux/action-creators';
import { $, Dom } from 'core/Dom';
import { ActiveRoute } from 'core/routes/ActiveRoute'; import { ActiveRoute } from 'core/routes/ActiveRoute';
import { ExcelStateComponent } from 'core/ExcelStateComponent'; import { deleteTable } from 'redux/action-creators';
import { deleteTable } from 'redux/actions';
export class Header extends ExcelStateComponent { export class Header extends ExcelComponent {
static className = 'excel__header'; static className = 'excel__header';
constructor($root: Dom, options: any) { constructor($root: Dom, options: BaseComponentOption) {
super($root, { super($root, {
name: 'Header',
listeners: ['input', 'click'],
subscribe: ['title'],
...options, ...options,
name: 'Header',
eventListeners: ['input', 'click'],
subscribe: ['title'],
}); });
} }
toHTML(): string { toHTML(): string {
const { title } = this.store.getState(); const { title, id } = this.store.getState();
return ` return `
<input type="text" class="input" value="${title}"> <input type="text" class="input" value="${title}">
<div>ID: <strong>${id}</strong></div>
<div> <div>
<div class="button" data-button="delete-table"> <div class="button" data-button="delete-table">
@ -33,9 +35,9 @@ export class Header extends ExcelStateComponent {
} }
onInput(event: InputEvent) { onInput(event: InputEvent) {
const $target = $(event.target as HTMLInputElement); const $target = $(event.target);
this.$dispatch(actions.changeTitle($target.text)); this.dispatchToStore(actions.changeTitle($target.text));
} }
onClick(event: MouseEvent) { onClick(event: MouseEvent) {
@ -54,7 +56,7 @@ export class Header extends ExcelStateComponent {
} }
case 'delete-table': { case 'delete-table': {
confirm('Действительно хочешь удалить ?') && this.$dispatch(deleteTable(this.store.getState().id)); confirm('Действительно хочешь удалить ?') && this.dispatchToStore(deleteTable(this.store.getState().id));
break; break;
} }

View File

@ -0,0 +1,59 @@
import { Table } from 'components/table/Table';
import { $, Dom } from 'core/Dom';
import { parse } from 'core/utils';
export class FocusManager {
private rootTable: Table;
public $currentFocusedCell: Dom | null;
constructor(rootTable: Table) {
this.rootTable = rootTable;
this.initManager();
}
private initManager() {
this.$currentFocusedCell = null;
}
resetFocus() {
window.getSelection()?.removeAllRanges();
(document.activeElement as HTMLElement)?.blur();
this.$currentFocusedCell = null;
}
focusOnTable() {
this.rootTable.$root.focus();
}
focusCell($cell: Dom) {
try {
const r = new Range();
const innerText = $cell.$el.childNodes[$cell.$el.childNodes.length - 1];
if (innerText !== undefined) {
r.setEndAfter(innerText);
r.setStartBefore(innerText);
r.collapse(false);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(r);
} else {
$cell.focus();
}
this.$currentFocusedCell = $cell;
} catch (e) {
console.log('FocusManager: ', e.message);
this.rootTable.selectionManager.clearAllSelection();
}
}
onFocusOut(event: FocusEvent) {
const $target = $(event.target);
if (!this.$currentFocusedCell?.isEqual($target)) return;
this.rootTable.updateTextInCell(parse($target.dataValue), $target, true);
}
}

View File

@ -0,0 +1,405 @@
import { Table } from 'components/table/Table';
import {
getCellIdFromParams,
getParamsFromCellId,
isCell2,
isSelectionKey,
startCellId,
} from 'components/table/table.functions';
import { $, Dom } from 'core/Dom';
import { getCellById, getIdByCell } from 'core/utils';
export class SelectionManager {
private rootTable: Table;
private $selection: Dom[];
public $currentSelectedCell: Dom | null;
private $firstSelectedCell: Dom | null;
private isMouseDowned: boolean;
private static selectedClassName = 'selected';
private static addSelectedCellClass($cell: Dom) {
if (!$cell || !$cell.isExist) return;
$cell.addClass('selected');
}
private static removeSelectedCellClass($cell: Dom) {
if (!$cell || !$cell.isExist) return;
$cell.removeClass('selected');
}
private addCurrentCellClass() {
if (!this.$firstSelectedCell?.isExist) this.$currentSelectedCell?.addClass('current');
else this.$firstSelectedCell?.addClass('current');
}
private removeCurrentCellClass() {
if (!this.$firstSelectedCell?.isExist) this.$currentSelectedCell?.removeClass('current');
else this.$firstSelectedCell?.removeClass('current');
}
public static selectionKeys = [
'ArrowDown',
'ArrowLeft',
'ArrowRight',
'ArrowUp',
'Enter',
'Tab',
'Delete',
'Control',
'Shift',
];
constructor(rootTable: Table) {
this.rootTable = rootTable;
this.initManager();
}
private initManager() {
this.clearAllSelection();
this.isMouseDowned = false;
}
get selectedIds() {
return this.$selection.map($cell => getCellIdFromParams(getIdByCell($cell)));
}
addCellToSelection($target: Dom) {
this.$selection.push($target);
this.currentSelectedCell = $target;
this.selectHeader($target);
SelectionManager.addSelectedCellClass($target);
}
private set currentSelectedCell($cell: Dom | null) {
this.removeCurrentCellClass();
if ($cell) {
this.$currentSelectedCell = $cell;
this.addCurrentCellClass();
}
}
selectFromTo($from: Dom, $cell: Dom) {
this.clearAllSelection(false);
const startCellParams = getParamsFromCellId($from.data.id || startCellId);
const selectedCellParams = getParamsFromCellId($cell.data.id || startCellId);
const startCol = Math.min(startCellParams.col, selectedCellParams.col);
const endCol = Math.max(startCellParams.col, selectedCellParams.col);
const startRow = Math.min(startCellParams.row, selectedCellParams.row);
const endRow = Math.max(startCellParams.row, selectedCellParams.row);
for (let row = startRow; row <= endRow; row++) {
for (let col = startCol; col <= endCol; col++) {
const $target = $(`[data-id="${row}:${col}"]`);
this.addCellToSelection($target);
}
}
this.rootTable.focusManager.focusOnTable();
}
selectCell($cell: Dom) {
this.clearAllSelection();
this.rootTable.focusManager.focusOnTable();
this.$selection = [$cell];
this.currentSelectedCell = $cell;
this.$firstSelectedCell = $cell;
this.selectHeader($cell);
this.rootTable.emitSelectCallback($cell);
SelectionManager.addSelectedCellClass($cell);
}
selectCells($cells: Dom[]) {
this.clearAllSelection();
$cells.forEach($cell => this.addCellToSelection($cell));
}
addGroupToSelectionById(cellID: { col: number | string, row: number | string }) {
if (this.$selection.length === 1) {
this.$firstSelectedCell = this.$selection[0];
}
const { col, row } = cellID;
const $cell = $(`[data-id="${row}:${col}"]`);
const $lastCell = this.$selection[this.$selection.length - 1];
if (!$lastCell.isExist || !this.$firstSelectedCell?.isExist) {
this.selectCell($lastCell);
return;
}
this.selectFromTo(this.$firstSelectedCell, $cell);
this.$currentSelectedCell = $cell;
}
clearAllSelection(clearFirstSelectedCell = true) {
this.$selection?.forEach($cell => SelectionManager.removeSelectedCellClass($cell));
this.$selection = [];
this.currentSelectedCell = null;
if (clearFirstSelectedCell) this.$firstSelectedCell = null;
this.clearHeaderSelection();
this.rootTable.focusManager?.resetFocus();
}
selectHeadRowCol($target: Dom) {
const row = $target.closest('[data-header="row"]');
const col = $target.closest('[data-header="col"]');
const resizer = $target.closest('[data-resize]');
if (resizer?.isExist) return;
let $cells: Dom[] = [];
if (row?.isExist) {
const cells = row.closest('[data-row]')?.findAll('[data-type="cell"]');
if (!cells) return;
$cells = Array.from(cells).map(cell => $(cell as HTMLElement));
} else if (col?.isExist) {
const colNumber = col.data.col;
const columns = this.rootTable.$root.findAll(`[data-col="${colNumber}"]`);
$cells = Array.from(columns).filter(el => !col.isEqual(el as HTMLElement)).map(el => $(el as HTMLElement));
}
this.selectCells($cells);
this.currentSelectedCell = $cells[0];
this.$currentSelectedCell?.isExist && this.rootTable.emitSelectCallback(this.$currentSelectedCell);
}
selectHeader($cell: Dom) {
if (!$cell.isExist) return;
const { headerCol, headerRow } = this.findHeadOfCell($cell);
headerRow?.addClass(SelectionManager.selectedClassName);
headerCol?.addClass(SelectionManager.selectedClassName);
}
clearHeaderSelection() {
this.rootTable.$root.findAll('[data-header]').forEach(header => {
header.classList.remove(SelectionManager.selectedClassName);
});
}
getSiblingCellBySide(side: 'left' | 'right' | 'down' | 'up', $cell = this.$currentSelectedCell): Dom | undefined {
if (!$cell?.isExist) {
console.error('Нет стартовой ячейки');
return;
}
let { row, col } = getIdByCell($cell);
if (row === undefined || col === undefined) return;
switch (side) {
case 'down':
row++;
break;
case 'up':
row--;
break;
case 'left':
col--;
break;
case 'right':
col++;
break;
default: break;
}
const $neighbourCell = getCellById({ col, row });
if (!$neighbourCell || !$neighbourCell.isExist) {
console.error('Не получилось найти ячейку');
return;
}
// FIXME
// eslint-disable-next-line consistent-return
return $neighbourCell;
}
// TODO remove
moveSelectionTo(side: 'left' | 'right' | 'down' | 'up') {
const $current = this.$currentSelectedCell || this.rootTable.focusManager.$currentFocusedCell || this.$currentSelectedCell?.[0];
if (!$current?.isExist) return;
const $movingCell = this.getSiblingCellBySide(side, $current);
if (!$movingCell?.isExist || !$movingCell) {
console.error('Ошибка, не найдена ячейка для перемещения');
return;
}
this.selectCell($movingCell);
}
findHeadOfCell($cell: Dom): { headerRow: Dom | undefined, headerCol: Dom | undefined } {
const headerRow = $cell.closest('[data-row]')?.find("[data-header='row']");
const headerCol = this.rootTable.$root.find(`[data-col="${$cell.data.id?.split(':')[1]}"]`);
return { headerRow, headerCol };
}
onMouseDownHandler(event: MouseEvent) {
const target = $(event.target);
const header = target.closest('[data-header]');
const resizer = target.closest('[data-resize]');
if (!this.rootTable.focusManager.$currentFocusedCell?.isExist) event.preventDefault();
if (header?.isExist && !isCell2(target) && !resizer?.isExist) {
this.selectHeadRowCol(header);
return;
}
if (!isCell2(target)) return;
if (this.$currentSelectedCell && target.isEqual(this.$currentSelectedCell)) return;
this.isMouseDowned = true;
switch (true) {
case event.ctrlKey:
this.addCellToSelection(target);
break;
case event.shiftKey:
if (!this.$currentSelectedCell) this.selectCell(target);
else {
const id = getIdByCell(target);
if (!id) return;
const { row, col } = id;
if (row === undefined || col === undefined) return;
this.addGroupToSelectionById({ row, col });
}
break;
default: this.selectCell(target);
}
}
onKeyDownHandler(event: KeyboardEvent) {
if (!isSelectionKey(event.key) || this.rootTable.focusManager.$currentFocusedCell?.isExist) {
// TODO improve check
if (event.key.length === 1 && !this.rootTable.focusManager.$currentFocusedCell?.isExist) {
this.$currentSelectedCell && this.rootTable.focusManager.focusCell(this.$currentSelectedCell);
}
if (event.key === 'Enter' || event.key === 'Tab') {
let side;
if (event.key === 'Enter' && !event.shiftKey) side = 'down';
if (event.key === 'Tab') side = event.shiftKey ? 'left' : 'right';
side && this.moveSelectionTo(side);
}
return;
}
event.preventDefault();
let $cell: Dom | undefined;
let side: 'left' | 'right' | 'down' | 'up' = 'right';
switch (event.key) {
case 'Shift':
case 'Control':
return;
case 'ArrowDown':
side = 'down';
break;
case 'ArrowUp':
side = 'up';
break;
case 'ArrowLeft':
side = 'left';
break;
case 'ArrowRight':
side = 'right';
break;
case 'Enter':
side = event.shiftKey ? 'up' : 'down';
break;
case 'Tab':
side = event.shiftKey ? 'left' : 'right';
break;
case 'Delete': {
this.$selection.forEach($el => {
this.rootTable.updateTextInCell('', $el);
});
return;
}
default: break;
}
if (!side) return;
$cell = this.getSiblingCellBySide(side, this.$currentSelectedCell || this.$firstSelectedCell);
if (event.ctrlKey && $cell?.isExist) {
this.addCellToSelection($cell);
return;
}
if (event.shiftKey && $cell?.isExist && event.key !== 'Enter' && event.key !== 'Tab') {
const id = getIdByCell($cell);
if (!id) return;
const { row, col } = id;
if (row === undefined || col === undefined) return;
this.addGroupToSelectionById({ row, col });
return;
}
$cell = this.getSiblingCellBySide(side, this.$firstSelectedCell || this.$currentSelectedCell);
if (!$cell || !$cell.isExist) return;
this.selectCell($cell);
}
doubleClickHandler(event: MouseEvent) {
const target = $(event.target);
if (!isCell2(target)) return;
this.rootTable.focusManager.focusCell(target);
this.rootTable.updateTextInCell(target.dataValue, target, true);
}
onMouseOverHandler(event: MouseEvent) {
if (!this.isMouseDowned) return;
const $target = $((event as any).toElement);
if (this.$firstSelectedCell?.isExist && isCell2($target)) {
this.selectFromTo(this.$firstSelectedCell, $target);
this.$currentSelectedCell = $target;
}
}
onMouseUpHandler() {
this.isMouseDowned = false;
}
applyStyle(style: Partial<CSSStyleDeclaration>) {
this.$selection.forEach($cell => {
$cell.css(style);
});
}
}

View File

@ -1,69 +1,92 @@
import { ContextSelectType } from 'components/ContextMenu/ContextMenu';
import { BaseComponentOption } from 'components/excel/Excel';
import { FocusManager } from 'components/table/FocusManager';
import { SelectionManager } from 'components/table/SelectionManager';
import { startCellId } from 'components/table/table.functions'; import { startCellId } from 'components/table/table.functions';
import * as actions from 'redux/actions';
import { $, Dom } from 'core/dom';
import { ExcelComponent } from 'core/ExcelComponent';
import { TableSelection } from 'components/table/TableSelection';
import { changeCurrentStyles } from 'redux/actions';
import { createTable } from 'components/table/table.template';
import { initialStyleState } from 'src/constants';
import { parse } from 'core/utils'; import { parse } from 'core/utils';
import * as actions from 'redux/action-creators';
import { $, Dom } from 'core/Dom';
import { ExcelComponent } from 'core/ExcelComponent';
import {
addCol,
addRow, changeCurrentStyles, changeCurrentText,
changeTableSize, removeColFromTable,
removeRowFromTable,
} from 'redux/action-creators';
import { createTable } from 'components/table/table.template';
import { TableSizeType } from 'redux/types';
import { resizeHandler } from 'components/table/handlers/table.resize'; import { resizeHandler } from 'components/table/handlers/table.resize';
import { selectHandler } from 'components/table/handlers/table.select.handler'; import { initialStyleState } from 'src/constants';
export class Table extends ExcelComponent { export class Table extends ExcelComponent {
static className = 'excel__table'; static className = 'excel__table';
private selection: TableSelection; private tableResizing: boolean;
public tableSize: TableSizeType;
selectionManager: SelectionManager;
focusManager: FocusManager;
constructor($root: Dom, options: any) { constructor($root: Dom, options: BaseComponentOption) {
super($root, { super($root, {
name: 'Table',
listeners: ['mousedown', 'keydown', 'input'],
...options, ...options,
name: 'Table',
eventListeners: ['mousedown', 'keydown', 'input', 'mouseover', 'mouseup', 'contextmenu', 'dblclick', 'focusout'],
}); });
} }
toHTML(): string { toHTML(): string {
return createTable(50, 24); return createTable(this.tableSize.row, this.tableSize.col);
} }
prepare() { beforeRender() {
this.selection = new TableSelection(); this.tableResizing = false;
this.tableSize = this.getTableSize();
this.selectionManager = new SelectionManager(this);
this.focusManager = new FocusManager(this);
} }
init() { afterRender() {
super.init(); super.afterRender();
const $cell = this.$root.find(`[data-id="${startCellId}"]`);
this.selection.select($cell);
this.$emit('table:select-cell', $cell.data.value);
this.$on('formula:input', (data) => {
this.selection.current.attr('data-value', data);
this.selection.current.text = parse(data);
this.updateCurrentTextInStore(data);
});
this.$on('formula:enter-press', () => {
this.selection.current.focus();
});
this.$on('toolbar:applyStyle', (value) => {
this.selection.applyStyle(value);
this.$dispatch(actions.applyStyle({
value,
ids: this.selection.selectedIds,
}));
});
this.initTable(); this.initTable();
this.$onEventFromObserver('formula:input', (text) => {
if (!this.focusManager.$currentFocusedCell) this.updateTextInCell(text, this.selectionManager.$currentSelectedCell, true);
else this.updateTextInCell(text, this.focusManager.$currentFocusedCell, true);
});
this.$onEventFromObserver('formula:enter-press', () => {
this.focusManager.focusCell(this.selectionManager.$currentSelectedCell);
this.updateTextInCell(this.selectionManager.$currentSelectedCell?.text, this.focusManager.$currentFocusedCell, false);
});
this.$onEventFromObserver('toolbar:applyStyle', this.updateCurrentStyles);
this.$onEventFromObserver('toolbar:add-row', this.addNewRowHandler);
this.$onEventFromObserver('toolbar:remove-row', this.removeRowHandler);
this.$onEventFromObserver('context-menu: select', this.contextMenuHandler);
}
getTableSize() {
const { tableSize: { col, row }, colState, rowState } = this.store.getState();
const maxRowFromState = Math.max(...Object.keys(rowState).map(el => +el));
const maxColFromState = Math.max(...Object.keys(colState).map(el => +el));
const normalTableSize = {
row: Math.max(maxRowFromState, row),
col: Math.max(maxColFromState, col),
};
if ((maxColFromState !== this.tableSize?.col) || (maxRowFromState !== this.tableSize?.row)) {
this.dispatchToStore(changeTableSize(normalTableSize));
}
return normalTableSize;
} }
initTable() { initTable() {
this.initTableSize(); this.initTableSize();
this.initTableContentAndStyles(); this.initTableContentAndStyles();
this.initStartCellFocus();
} }
initTableSize() { initTableSize() {
@ -72,14 +95,25 @@ export class Table extends ExcelComponent {
row: this.store.getState()?.rowState, row: this.store.getState()?.rowState,
}; };
Object.keys(size.col).forEach(key => { this.initColSizes(this.$root, size.col);
const cols = this.$root.findAll(`[data-col="${key}"]`); this.initRowSizes(this.$root, size.row);
cols.forEach(el => $(el as HTMLElement).css({ width: `${size.col[key]}px` })); }
});
Object.keys(size.row).forEach(key => { initRowSizes(rootElem: Dom, rowState = this.store.getState()?.rowState) {
const rows = this.$root.findAll(`[data-row="${key}"]`); if (!rootElem || !rootElem?.$el) return;
rows.forEach(el => $(el as HTMLElement).css({ height: `${size.row[key]}px` }));
Object.keys(rowState).forEach(key => {
const rows = rootElem.findAll(`[data-row="${key}"]`);
rows.forEach(el => $(el as HTMLElement).css({ height: `${rowState[+key]}px` }));
});
}
initColSizes(rootElem: Dom, colState = this.store.getState()?.colState) {
if (!rootElem || !rootElem?.$el) return;
Object.keys(colState).forEach(key => {
const cols = rootElem.findAll(`[data-col="${key}"]`);
cols.forEach(el => $(el as HTMLElement).css({ width: `${colState[+key]}px` }));
}); });
} }
@ -87,49 +121,192 @@ export class Table extends ExcelComponent {
const tableState = this.store.getState(); const tableState = this.store.getState();
const tableContent = tableState?.dataState; const tableContent = tableState?.dataState;
const tableStyles = tableState?.stylesState; const tableStyles = tableState?.stylesState;
Object.keys(tableContent).forEach(cellId => { Object.keys(tableContent).forEach(cellId => {
const $cell = this.$root.find(`[data-id="${cellId}"]`); const $cell = this.$root.find(`[data-id="${cellId}"]`);
const styles = tableStyles[cellId]; const styles = tableStyles[cellId];
if (!$cell.isExist) return;
$cell.text = parse(tableContent[cellId]) || ''; $cell.text = parse(tableContent[cellId]) || '';
$cell.setData('value', tableContent[cellId]); $cell.setData('value', tableContent[cellId]);
$cell.css(styles); $cell.css(styles);
}); });
} }
emitSelectCallback() { initStartCellFocus() {
this.$emit('table:select-cell', this.selection.current.data.value); // const $cell = this.$root.find(`[data-id="${startCellId}"]`);
// this.selection.select($cell);
//
// this.$emitEventToObserver('table:select-cell', $cell);
}
const styles = this.selection.current?.getStyles(Object.keys(initialStyleState)); emitSelectCallback($cell: Dom) {
this.$dispatch(changeCurrentStyles(styles)); this.$emitEventToObserver('table:select-cell', $cell);
const styles = $cell.getStyles(Object.keys(initialStyleState));
this.dispatchToStore(changeCurrentStyles(styles));
this.dispatchToStore(changeCurrentText($cell.dataValue));
} }
async resizeTable(event: MouseEvent) { async resizeTable(event: MouseEvent) {
try { try {
if (!$(event.target).closest('[data-resize]')?.isExist) return;
this.tableResizing = true;
const resizeData = await resizeHandler(this.$root, event); const resizeData = await resizeHandler(this.$root, event);
this.$dispatch(actions.tableResize({ resizeData })); this.dispatchToStore(actions.tableResize(resizeData));
this.tableResizing = false;
} catch (e) { } catch (e) {
console.warn('Resize error', e.message); console.warn('Resize error', e.message);
} }
} }
updateCurrentTextInStore(text: string) { updateCurrentStyles = (style: Partial<CSSStyleDeclaration>) => {
this.$dispatch(actions.changeText({ this.selectionManager.applyStyle(style);
text, this.dispatchToStore(actions.applyStyle({
id: this.selection.current.data.id || startCellId, value: style,
ids: this.selectionManager.selectedIds,
})); }));
};
updateTextInCell = (text: string, $cell?: Dom, changeVisibleText?: boolean) => {
if (!$cell || !$cell.isExist) return;
if (!changeVisibleText) {
$cell.attr('data-value', text);
this.dispatchToStore(actions.changeText({
text: $cell.dataValue.toString(),
id: $cell.data.id || startCellId,
}));
} else {
// eslint-disable-next-line no-param-reassign
$cell.text = text || '';
}
};
addNewRowHandler = () => {
this.addNewColRow('row', 'after', this.tableSize.row);
};
addNewColRow(type: 'col' | 'row', position: 'after' | 'before', targetIndex: number) {
if (targetIndex === undefined) return;
type === 'col'
? this.dispatchToStore(addCol({ position, targetIndex }))
: this.dispatchToStore(addRow({ position, targetIndex }));
this.rerender();
} }
removeRowHandler = () => {
// const cell = this.selection.$focusedCell;
// const cellId = getCellId(cell);
// if (!cellId) return;
//
// const { row } = cellId;
// this.removeColRow('row', +row);
};
removeColRow = (type: 'col' | 'row', index: number) => {
if (!type || !index) return;
type === 'col'
? this.dispatchToStore(removeColFromTable(index))
: this.dispatchToStore(removeRowFromTable(index));
this.rerender();
};
contextMenuHandler = (select: ContextSelectType) => {
const { event, target } = select;
switch (event) {
case 'add-row-before': {
const idx = target.closest('[data-row]').data.row;
if (!idx) return;
this.addNewColRow('row', 'before', +idx);
break;
}
case 'add-row-after': {
const idx = target.closest('[data-row]').data.row;
if (!idx) return;
this.addNewColRow('row', 'after', +idx);
break;
}
case 'remove-row': {
const idx = target.closest('[data-row]').data.row;
if (!idx) return;
this.removeColRow('row', +idx);
break;
}
case 'add-col-before': {
const idx = target.closest('[data-col]').data.col;
if (!idx) return;
this.addNewColRow('col', 'before', +idx);
break;
}
case 'add-col-after': {
const idx = target.closest('[data-col]').data.col;
if (!idx) return;
this.addNewColRow('col', 'after', +idx);
break;
}
case 'remove-col': {
const idx = target.closest('[data-col]').data.col;
if (!idx) return;
this.removeColRow('col', +idx);
break;
}
default: break;
}
};
onMousedown(event: MouseEvent) { onMousedown(event: MouseEvent) {
selectHandler(event, this.selection, this.emitSelectCallback.bind(this));
this.resizeTable(event); this.resizeTable(event);
this.selectionManager.onMouseDownHandler(event);
} }
onKeydown(event: KeyboardEvent) { onKeydown(event: KeyboardEvent) {
selectHandler(event, this.selection, this.emitSelectCallback.bind(this)); this.selectionManager.onKeyDownHandler(event);
} }
onInput(event: InputEvent) { onInput(event: InputEvent) {
this.updateCurrentTextInStore((event.target as HTMLElement).innerText.trim()); const $target = $(event.target);
if (!this.focusManager.$currentFocusedCell) return;
this.updateTextInCell($target.text, this.focusManager.$currentFocusedCell);
}
onMouseover(event: MouseEvent) {
this.selectionManager.onMouseOverHandler(event);
}
onMouseup() {
this.selectionManager.onMouseUpHandler();
}
onContextmenu(event: MouseEvent) {
const $target = $(event.target);
this.selectionManager.clearAllSelection();
this.selectionManager.selectHeadRowCol($target);
this.$emitEventToObserver('table:contextmenu', event);
}
onDblclick(event: MouseEvent) {
this.selectionManager.doubleClickHandler(event);
}
onFocusout(event: FocusEvent) {
this.focusManager.onFocusOut(event);
} }
} }

View File

@ -1,67 +0,0 @@
import { $, Dom } from 'core/dom';
import { getParamsFromCellId, startCellId } from 'components/table/table.functions';
export class TableSelection {
static selectedClassName = 'selected';
private group: Dom[];
public current: Dom;
constructor() {
this.group = [];
}
get selectedIds() {
return this.group.map(el => el.data.id);
}
select($el: Dom) {
this.clearSelection();
this.group = [$el];
this.current = $el;
$el.addClass(TableSelection.selectedClassName);
$el.focus();
}
selectByCellId(cellID: { col: number, row: number }) {
let { col, row } = cellID;
if (col <= 0) col = 0;
if (row <= 0) row = 0;
this.clearSelection();
this.current = $(`[data-id="${row}:${col}"]`);
this.current.focus();
this.current.addClass(TableSelection.selectedClassName);
}
clearSelection() {
this.group.forEach(el => el?.removeClass(TableSelection.selectedClassName));
this.current?.removeClass(TableSelection.selectedClassName);
this.group = [];
}
selectGroup($el: Dom) {
const startCellParams = getParamsFromCellId(this.current.data.id || startCellId);
const selectedCellParams = getParamsFromCellId($el.data.id || startCellId);
const startCol = Math.min(startCellParams.col, selectedCellParams.col);
const endCol = Math.max(startCellParams.col, selectedCellParams.col);
const startRow = Math.min(startCellParams.row, selectedCellParams.row);
const endRow = Math.max(startCellParams.row, selectedCellParams.row);
this.clearSelection();
for (let row = startRow; row <= endRow; row++) {
for (let col = startCol; col <= endCol; col++) {
const cell = $(`[data-id="${row}:${col}"]`);
this.group.push(cell);
cell.addClass(TableSelection.selectedClassName);
}
}
}
applyStyle(style: CSSStyleRule) {
this.group.forEach(el => el.css(style));
}
}

View File

@ -1,8 +1,7 @@
import { startCellId } from 'components/table/table.functions'; import { startCellId } from 'components/table/table.functions';
import { $, Dom } from 'core/dom'; import { $, Dom } from 'core/Dom';
type CustomElementType = Element & { css: any }; export type ResizeReturnDataType = { value: number, id: string, type: string };
type ResizeReturnDataType = { value: number, id: string, type: string };
export function resizeHandler($root: Dom, event: MouseEvent) { export function resizeHandler($root: Dom, event: MouseEvent) {
return new Promise<ResizeReturnDataType>(res => { return new Promise<ResizeReturnDataType>(res => {
@ -18,13 +17,7 @@ export function resizeHandler($root: Dom, event: MouseEvent) {
let delta: number; let delta: number;
(Element.prototype as CustomElementType).css = function (styles: any) { $resizer.css({ opacity: '1' });
Object.keys(styles).forEach((key: any) => {
this.style[key] = styles[key];
});
};
$resizer.css({ opacity: 1 });
document.onmousemove = e => { document.onmousemove = e => {
document.body.style.userSelect = 'none'; document.body.style.userSelect = 'none';
@ -84,7 +77,7 @@ export function resizeHandler($root: Dom, event: MouseEvent) {
res({ value, id, type }); res({ value, id, type });
$resizer.css({ opacity: 0, bottom: 0, right: 0 }); $resizer.css({ opacity: '0', bottom: '0', right: '0' });
}; };
}); });
} }

View File

@ -1,79 +0,0 @@
import { $ } from 'core/dom';
import { TableSelection } from 'components/table/TableSelection';
import { getParamsFromCellId, isCell, startCellId } from 'components/table/table.functions';
export function selectHandler(event: MouseEvent | KeyboardEvent, selection: TableSelection, callback?: () => void) {
switch (event.type) {
case 'mousedown': {
onMouseDownHandler();
break;
}
case 'keydown': {
onKeyDownHandler();
break;
}
default: break;
}
// Analog callback && callback();
callback?.();
function onMouseDownHandler() {
if (isCell(event)) {
if (event.shiftKey) selection.selectGroup($(event.target as HTMLElement));
else selection.select($(event.target as HTMLElement));
}
}
function onKeyDownHandler() {
const { key } = event as KeyboardEvent;
const handleKeys = [
'ArrowDown',
'ArrowUp',
'ArrowRight',
'ArrowLeft',
'Enter',
'Tab',
];
if (!selection?.current || !handleKeys.includes(key)) return;
// If something goes wrong, go to start line
const currentCellId = selection.current.data.id || startCellId;
let { row, col } = getParamsFromCellId(currentCellId);
switch (key) {
case 'ArrowDown': {
row++;
break;
}
case 'ArrowUp': {
row--;
break;
}
case 'ArrowRight': {
col++;
break;
}
case 'ArrowLeft': {
col--;
break;
}
case 'Enter': {
if (event.shiftKey) return;
event.preventDefault();
row++;
break;
}
case 'Tab': {
event.preventDefault();
col++;
break;
}
default: break;
}
selection.selectByCellId({ row, col });
}
}

View File

@ -1,7 +1,16 @@
import { $ } from 'core/dom'; import { SelectionManager } from 'components/table/SelectionManager';
import { $, Dom } from 'core/Dom';
export function isCell(event: Event): boolean { export function isCell(event: Event): boolean {
return $(event.target as HTMLElement).data.type === 'cell'; return $(event.target).data.type === 'cell';
}
export function isCell2($cell: Dom): boolean {
return $cell.data.type === 'cell';
}
export function isSelectionKey(key: string): boolean {
return SelectionManager.selectionKeys.includes(key);
} }
export function getParamsFromCellId(cellId: string) { export function getParamsFromCellId(cellId: string) {
@ -11,4 +20,8 @@ export function getParamsFromCellId(cellId: string) {
return { col, row }; return { col, row };
} }
export function getCellIdFromParams(params: { row?: string, col?: string }) {
return `${params.row}:${params.col}`;
}
export const startCellId = '0:0'; export const startCellId = '0:0';

View File

@ -7,7 +7,8 @@ function createCell(cellContent = '', colIndex = 1, rowIndex = 1) {
return ` return `
<div <div
class="cell" class="cell"
contenteditable contenteditable
spellcheck="false"
data-col="${colIndex}" data-col="${colIndex}"
data-id="${rowIndex}:${colIndex}" data-id="${rowIndex}:${colIndex}"
data-type="cell" data-type="cell"
@ -20,17 +21,17 @@ function createCell(cellContent = '', colIndex = 1, rowIndex = 1) {
function createCol(columnContent = '', index = 1) { function createCol(columnContent = '', index = 1) {
return ` return `
<div class="column" data-type="resizable" data-col="${index}"> <div class="column" data-type="resizable" data-col="${index}" data-header="col">
${columnContent} ${columnContent}
<div class="col-resize" data-resize="col"></div> <div class="col-resize" data-resize="col"></div>
</div> </div>
`; `;
} }
function createRow(dataContent = '', infoContent = '', needResize = true, rowIndex = -1) { function createRow(dataContent = '', infoContent = '', needResize = true, rowIndex = -1): string {
return ` return `
<div class="row" data-type="resizable" data-row="${rowIndex}"> <div class="row" data-type="resizable" data-row="${rowIndex}">
<div class="row-info"> <div class="row-info" data-header="row">
${infoContent} ${infoContent}
${needResize ? '<div class="row-resize" data-resize="row"></div>' : ''} ${needResize ? '<div class="row-resize" data-resize="row"></div>' : ''}
</div> </div>

View File

@ -1,26 +1,27 @@
import { BaseComponentOption } from 'components/excel/Excel';
import { startCellId } from 'components/table/table.functions'; import { startCellId } from 'components/table/table.functions';
import { $, Dom } from 'core/dom'; import { $, Dom } from 'core/Dom';
import { ExcelStateComponent } from 'core/ExcelStateComponent'; import { ExcelComponentState } from 'core/ExcelComponentState';
import { OptionsType } from 'core/ExcelComponent';
import { createToolbar } from 'components/toolbar/toolbar.template'; import { createToolbar } from 'components/toolbar/toolbar.template';
import { initialStyleState } from 'src/constants'; import { StateType } from 'redux/types';
import { fontSizes, initialStyleState } from 'src/constants';
export class Toolbar extends ExcelStateComponent { export class Toolbar extends ExcelComponentState {
static className = 'excel__toolbar'; static className = 'excel__toolbar';
constructor($root: Dom, options: OptionsType) { constructor($root: Dom, options: BaseComponentOption) {
super($root, { super($root, {
...options, ...options,
listeners: ['click'], eventListeners: ['click', 'change'],
name: 'Toolbar', name: 'Toolbar',
subscribe: ['currentStyles'], subscribe: ['currentStyles'],
}); });
} }
prepare() { beforeRender() {
const currentToolbarState = this.toolbarState; const currentToolbarState = this.toolbarState;
this.initState(currentToolbarState); this.initComponentState(currentToolbarState);
} }
get toolbarState() { get toolbarState() {
@ -31,27 +32,97 @@ export class Toolbar extends ExcelStateComponent {
} }
get template(): string { get template(): string {
return createToolbar(this.state); return createToolbar(this.componentState);
} }
toHTML(): string { toHTML(): string {
return this.template; return this.template;
} }
storeChanged(args?: any) { storeChanged(args: StateType) {
this.setState(args.currentStyles); if (!args) return;
this.setComponentState(args.currentStyles);
} }
onClick(event: MouseEvent) { onClick(event: MouseEvent) {
const target = $(event.target as HTMLElement); // TODO refactor, make style handler
const stringValue = target?.data?.value; const target = $(event.target);
if (!stringValue) return;
const value = JSON.parse(stringValue); if (target.closest('[data-add-row-btn]').isExist) {
const key = Object.keys(value)[0]; this.$emitEventToObserver('toolbar:add-row');
return;
}
this.$emit('toolbar:applyStyle', value); if (target.closest('[data-remove-row-btn]').isExist) {
this.$emitEventToObserver('toolbar:remove-row');
return;
}
this.setState({ [key]: value[key] }); let stringValue;
let value;
let key;
switch (true) {
case !!target.closest('[data-change-size]').$el: {
const el = target.closest('[data-change-size]');
if (el.hasClass('disable')) return;
const currentSize = this.store.getState().currentStyles.fontSize;
let idx = fontSizes.findIndex(font => font === currentSize);
let nextSize;
if (el.data.changeSize === 'increase') {
nextSize = fontSizes[++idx];
} else if (el.data.changeSize === 'decrease') {
nextSize = fontSizes[--idx];
}
value = { fontSize: nextSize };
key = 'fontSize';
break;
}
default: {
stringValue = target?.data?.value;
if (!stringValue) return;
value = JSON.parse(stringValue);
key = Object.keys(value)[0];
}
}
if (value && key) {
this.$emitEventToObserver('toolbar:applyStyle', value);
this.setComponentState({ [key]: value[key] });
}
}
// TODO find EventType
onChange(e: any) {
const target = $(e.target);
let value = '';
let key = '';
switch (true) {
case !!target.closest('#button-size').$el:
value = `${e.target.value.toString()}px`;
key = 'fontSize';
break;
case !!target.closest('#button-font').$el:
value = e.target.value;
key = 'fontFamily';
break;
default: break;
}
if (key && value) {
this.$emitEventToObserver('toolbar:applyStyle', { [key]: value });
this.setComponentState({ [key]: value });
}
} }
} }

View File

@ -2,5 +2,8 @@ export type ToolbarStateType = {
fontWeight?: 'normal' | 'bold'; fontWeight?: 'normal' | 'bold';
fontStyle?: 'normal' | 'italic'; fontStyle?: 'normal' | 'italic';
textDecoration?: 'none' | 'underline'; textDecoration?: 'none' | 'underline';
textAlign?: 'left' | 'center' | 'right'; justifyContent?: 'start' | 'center' | 'end';
alignItems?: 'start' | 'center' | 'end';
fontSize?: string;
fontFamily?: string
}; };

View File

@ -1,5 +1,6 @@
import { ToolbarStateType } from 'components/toolbar/toolbar-types'; import { ToolbarStateType } from 'components/toolbar/toolbar-types';
import { initialStyleState } from 'src/constants'; import { isLargestFontSize, isSmallestFontSize } from 'core/utils';
import { fontFamilies, fontSizes, initialStyleState } from 'src/constants';
type ButtonConfigType = { type ButtonConfigType = {
icon: string; icon: string;
@ -8,66 +9,180 @@ type ButtonConfigType = {
}; };
export function createToolbar(state: ToolbarStateType): string { export function createToolbar(state: ToolbarStateType): string {
const btns: ButtonConfigType[] = [ const btns: (ButtonConfigType | ButtonConfigType[])[] = [
{ [
icon: 'format_bold', {
isActive: state.fontWeight === 'bold', icon: 'format_bold',
value: { isActive: state.fontWeight === 'bold',
fontWeight: state.fontWeight === 'bold' ? initialStyleState.fontWeight : 'bold', value: {
fontWeight: state.fontWeight === 'bold' ? initialStyleState.fontWeight : 'bold',
},
}, },
}, {
{ icon: 'format_italic',
icon: 'format_italic', isActive: state.fontStyle === 'italic',
isActive: state.fontStyle === 'italic', value: {
value: { fontStyle: state.fontStyle === 'italic' ? initialStyleState.fontStyle : 'italic',
fontStyle: state.fontStyle === 'italic' ? initialStyleState.fontStyle : 'italic', },
}, },
}, {
{ icon: 'format_underline',
icon: 'format_underline', isActive: state.textDecoration === 'underline',
isActive: state.textDecoration === 'underline', value: {
value: { textDecoration: state.textDecoration === 'underline' ? initialStyleState.textDecoration : 'underline',
textDecoration: state.textDecoration === 'underline' ? initialStyleState.textDecoration : 'underline', },
}, },
}, ],
{ [
icon: 'format_align_left', {
isActive: state.textAlign === 'left', icon: 'format_align_left',
value: { isActive: state.justifyContent === 'start',
textAlign: 'left', value: {
justifyContent: 'start',
},
}, },
}, {
{ icon: 'format_align_center',
icon: 'format_align_center', isActive: state.justifyContent === 'center',
isActive: state.textAlign === 'center', value: {
value: { justifyContent: state.justifyContent === 'center' ? initialStyleState.justifyContent : 'center',
textAlign: state.textAlign === 'center' ? initialStyleState.textAlign : 'center', },
}, },
}, {
{ icon: 'format_align_right',
icon: 'format_align_right', isActive: state.justifyContent === 'end',
isActive: state.textAlign === 'right', value: {
value: { justifyContent: state.justifyContent === 'end' ? initialStyleState.justifyContent : 'end',
textAlign: state.textAlign === 'right' ? initialStyleState.textAlign : 'right', },
}, },
}, ],
[
{
icon: 'vertical_align_bottom',
isActive: state.alignItems === 'end',
value: {
alignItems: state.alignItems === 'end' ? initialStyleState.alignItems : 'end',
},
},
{
icon: 'vertical_align_center',
isActive: state.alignItems === 'center',
value: {
alignItems: state.alignItems === 'center' ? initialStyleState.alignItems : 'center',
},
},
{
icon: 'vertical_align_top',
isActive: state.alignItems === 'start',
value: {
alignItems: state.alignItems === 'start' ? initialStyleState.alignItems : 'start',
},
},
],
]; ];
return btns.map(btn => toButton(btn)).join(' '); const buttons = btns.map(btn => (Array.isArray(btn) ? createButtonsFromConfigGroup(btn) : createButtonFromConfig(btn)));
const selectGroup = createGroup(createFontSizeButton(state.fontSize), createFontFamilyButton(state.fontFamily));
const increaseDecreaseFontSize = createSizeUpDownButtons(state.fontSize);
buttons.push(increaseDecreaseFontSize);
buttons.push(selectGroup);
// buttons.push(createAddRowButton());
// buttons.push(createRemoveRowButton());
return buttons.join('');
} }
function toButton(button: ButtonConfigType) { function createSizeUpDownButtons(currentSize = initialStyleState.fontSize) {
const meta = ` const buttons = ['increase', 'decrease'];
data-type="button"
data-value='${JSON.stringify(button.value)}' return buttons.map(btn => {
`; const disable = btn === 'increase' ? isLargestFontSize(currentSize) : isSmallestFontSize(currentSize);
return `
<div class="button${disable ? ' disable' : ''}" data-change-size="${btn}">
<i class="material-icons">text_${btn}</i>
</div>
`;
}).join('');
}
function createFontSizeButton(currentSize = initialStyleState.fontSize) {
const options = fontSizes.map(size => {
// remove 'px' part
const sizeValue = size.slice(0, -2);
return size === currentSize
? `<option value="${sizeValue}" selected>${sizeValue}</option>`
: `<option value="${sizeValue}">${sizeValue}</option>`;
});
return ` return `
<div <div class="button">
class="button ${button.isActive && 'active'}" <select class="button__size" id="button-size">
${meta} ${options.join('')}
> </select>
<i class="material-icons" ${meta}>${button.icon}</i>
</div> </div>
`; `;
} }
function createFontFamilyButton(font = initialStyleState.fontFamily) {
const options = fontFamilies.map(fontName => createFontFamilyOption(fontName, fontName === font));
function createFontFamilyOption(name: string, selected: boolean) {
return selected
? `<option value="${name}" style="font-family: ${name}" selected >${name}</option>`
: `<option value="${name}" style="font-family: ${name}">${name}</option>`;
}
return `
<div class="button">
<select class="button__font" id="button-font">
${options.join('')}
</select>
</div>
`;
}
function createGroup(...btns: string[]) {
return `
<div class="button__group">
${btns.join('')}
</div>
`;
}
function createButtonsFromConfigGroup(buttons: ButtonConfigType[]) {
if (buttons.length === 1) return createButtonFromConfig(buttons[0]);
const btns = buttons.map(btn => createButtonFromConfig(btn));
return createGroup(...btns);
}
function createButtonFromConfig(btnConfig: ButtonConfigType) {
const meta = `
data-type="button"
data-value='${JSON.stringify(btnConfig.value)}'
`;
return `
<div class="button ${btnConfig.isActive ? 'active' : ''}"${meta}>
<i class="material-icons" ${meta}>${btnConfig.icon}</i>
</div>
`;
}
// function createAddRowButton() {
// return `
// <div class="button" data-add-row-btn>
// <i class="material-icons" data-add-row-btn>add_circle</i>
// </div>
// `;
// }
//
// function createRemoveRowButton() {
// return `
// <div class="button" data-remove-row-btn>
// <i class="material-icons" data-remove-row-btn>remove_circle</i>
// </div>
// `;
// }

View File

@ -1,26 +1,57 @@
import { startCellId } from 'components/table/table.functions'; import { startCellId } from 'components/table/table.functions';
import { ToolbarStateType } from 'components/toolbar/toolbar-types'; import { ToolbarStateType } from 'components/toolbar/toolbar-types';
import { StateType } from 'redux/types'; import { StateType } from 'redux/types';
import { storage } from 'core/utils'; import { storage, storageName } from 'core/utils';
export const fontSizes = [
'12px',
'14px',
'16px',
'18px',
'20px',
'22px',
'24px',
'26px',
'28px',
'30px',
];
export const fontFamilies = ['Roboto', 'Cormorant SC', 'Kanit', 'Playfair Display'];
export const initialStyleState: ToolbarStateType = { export const initialStyleState: ToolbarStateType = {
textAlign: 'left', justifyContent: 'start',
fontWeight: 'normal', fontWeight: 'normal',
textDecoration: 'none', textDecoration: 'none',
fontStyle: 'normal', fontStyle: 'normal',
fontSize: fontSizes[0],
fontFamily: fontFamilies[0],
alignItems: 'start',
};
export const initialState: StateType = {
colState: {},
rowState: {},
dataState: {},
stylesState: {},
title: 'New excel table',
openDate: Date.now(),
currentStyles: initialStyleState,
currentText: 'initial text',
id: '0',
tableSize: {
col: 20,
row: 30,
},
}; };
export function getNormalizeInitialState(params: string): StateType { export function getNormalizeInitialState(params: string): StateType {
const state = storage(storageName(params));
// if (!state) return initialState;
return { return {
colState: {}, ...initialState,
rowState: {}, ...state,
dataState: {},
currentText: '',
stylesState: {},
title: 'New excel table',
id: params, id: params,
openDate: Date.now(), currentStyles: state?.stylesState?.[startCellId] || initialStyleState,
...storage(`excel:${params}`), currentText: state?.dataState?.[startCellId],
currentStyles: { ...storage(`excel:${params}`)?.stylesState?.[startCellId] },
}; };
} }

View File

@ -1,15 +1,14 @@
import { storage } from 'core/utils'; import { storage, storageName } from 'core/utils';
import { storageName } from 'pages/ExcelPage';
import { StateType } from 'redux/types'; import { StateType } from 'redux/types';
import { getNormalizeInitialState } from 'src/constants'; import { getNormalizeInitialState } from 'src/constants';
export interface ClientDataType { export interface ClientDataType {
save: (state: StateType) => Promise<any>; save: (state: StateType) => Promise<void>;
get: () => Promise<any>; get: () => Promise<StateType>;
} }
export class LocalStorageClient implements ClientDataType { export class LocalStorageClient implements ClientDataType {
private name: string; private readonly name: string;
constructor(name: string) { constructor(name: string) {
this.name = name; this.name = name;
@ -20,12 +19,10 @@ export class LocalStorageClient implements ClientDataType {
return Promise.resolve(); return Promise.resolve();
} }
get() { get(): Promise<StateType> {
const data = storage(storageName(this.name)) || getNormalizeInitialState(this.name);
return new Promise(resolve => { return new Promise(resolve => {
setTimeout(() => { setTimeout(() => {
resolve(data); resolve(getNormalizeInitialState(this.name));
}, 1500); }, 1500);
}); });
} }

View File

@ -0,0 +1,59 @@
import { BaseComponentOption, ComponentType } from 'components/excel/Excel';
import { $, Dom } from 'core/Dom';
import { ExcelComponent } from 'core/ExcelComponent';
export class ComponentManager {
private components: ExcelComponent[];
private componentConstructors: {
[k: string]: ComponentType;
};
public $rootExcelElement: Dom;
constructor() {
this.componentConstructors = {};
this.$rootExcelElement = $.create('div', 'excel');
this.components = [];
// for debug
// @ts-ignore
window.componentRoots = this.componentConstructors;
}
createComponent(Component: ComponentType, componentOptions: BaseComponentOption) {
const $el = $.create('div', Component.className);
$el.attr('tabindex', '-1');
const componentInstance: ExcelComponent = new Component($el, componentOptions);
$el.html(componentInstance.toHTML());
this.componentConstructors[componentInstance.name] = Component;
this.components.push(componentInstance);
return componentInstance;
}
addComponentsToRoot(components: ExcelComponent[]) {
components.forEach(component => this.$rootExcelElement.append(component.$root));
}
rerenderComponent(component: ExcelComponent) {
const componentConstructor = this.findComponentConstructor(component);
const componentOptions: BaseComponentOption = {
observer: component.observer,
store: component.store,
componentManager: this,
};
const $newComponent = this.createComponent(componentConstructor, componentOptions);
this.$rootExcelElement.replaceChild($newComponent.$root, component.$root);
component.destroy();
$newComponent.afterRender();
}
findComponentConstructor(component: ExcelComponent): ComponentType {
return this.componentConstructors[component.name];
}
destroyComponents() {
Object.values(this.components).forEach(component => component.destroy());
}
}

View File

@ -1,7 +1,7 @@
import { ToolbarStateType } from 'components/toolbar/toolbar-types'; import { CallbackType } from 'redux/types';
import { initialStyleState } from 'src/constants'; import { initialStyleState } from 'src/constants';
export type SelectorType = string | HTMLElement; export type SelectorType = string | HTMLElement | EventTarget | null;
export interface DomClass { export interface DomClass {
html(html?: string): string | DomClass; html(html?: string): string | DomClass;
@ -13,13 +13,16 @@ export class Dom implements DomClass {
$el: HTMLElement; $el: HTMLElement;
constructor(selector: SelectorType) { constructor(selector: SelectorType) {
// Could not find element with selector in DOM, need a check try {
if (typeof selector === 'string') { if (typeof selector === 'string') {
const elementFromDOM = document.querySelector(selector); const elementFromDOM = document.querySelector(selector);
if (!elementFromDOM) throw new Error(`Can't find element with "${selector}" selector`); if (!elementFromDOM) throw new Error(`Can't find element with "${selector}" selector`);
else this.$el = elementFromDOM as HTMLElement; else this.$el = elementFromDOM as HTMLElement;
} else { } else {
this.$el = selector; this.$el = selector as HTMLElement;
}
} catch (e) {
console.error(e.message);
} }
} }
@ -30,13 +33,15 @@ export class Dom implements DomClass {
} }
set text(text: string) { set text(text: string) {
if (!text) this.$el.textContent = ''; if (!this.$el) return;
this.$el.textContent = text;
if (!text) this.$el.innerText = '';
this.$el.innerText = text;
} }
get text() { get text() {
if (this.$el.closest('input')) return (this.$el as HTMLInputElement).value; if (this.$el.closest('input')) return (this.$el as HTMLInputElement).value;
return (this.$el as HTMLElement).innerText; return (this.$el as HTMLElement).innerText || '';
} }
clear() { clear() {
@ -45,23 +50,22 @@ export class Dom implements DomClass {
return this; return this;
} }
// FIXME: any append(node: HTMLElement | Dom) {
append(node: any) {
let child = node; let child = node;
if (node instanceof Dom) child = node.$el; if (node instanceof Dom) child = node.$el;
if (this.$el.append) this.$el.append(child); if (this.$el.append) this.$el.append(child as HTMLElement);
else this.$el.appendChild(child); else this.$el.appendChild(child as HTMLElement);
return this; return this;
} }
on(eventType: string, callback: any) { on(eventType: string, callback: CallbackType) {
this.$el.addEventListener(eventType, callback); this.$el.addEventListener(eventType, callback);
} }
off(eventType: string, callback: any) { off(eventType: string, callback: CallbackType) {
this.$el.removeEventListener(eventType, callback); this.$el.removeEventListener(eventType, callback);
} }
@ -69,10 +73,18 @@ export class Dom implements DomClass {
this.$el.focus(); this.$el.focus();
} }
blur() {
this.$el.blur();
}
get data() { get data() {
return this.$el.dataset || ''; return this.$el.dataset || '';
} }
get dataValue(): string {
return this.$el.dataset.value || '';
}
setData(name: string, value: string) { setData(name: string, value: string) {
this.$el.setAttribute(`data-${name}`, value); this.$el.setAttribute(`data-${name}`, value);
} }
@ -93,11 +105,11 @@ export class Dom implements DomClass {
return this.$el.querySelectorAll(selector); return this.$el.querySelectorAll(selector);
} }
css(styles: any) { css(styles: Partial<CSSStyleDeclaration>) {
if (!styles) return; if (!styles) return;
Object.keys(styles)?.forEach((key: any) => { Object.keys(styles)?.forEach((key: any) => {
this.$el.style[key] = styles[key]; this.$el.style[key] = styles[key] as string;
}); });
} }
@ -105,25 +117,50 @@ export class Dom implements DomClass {
this.$el?.classList.add(className); this.$el?.classList.add(className);
} }
hasClass(className: string) {
return Array.from(this.$el.classList).includes(className);
}
removeClass(className: string) { removeClass(className: string) {
this.$el?.classList.remove(className); this.$el?.classList.remove(className);
} }
getStyles(styles: any[]) { getStyles(styles: string[]): Partial<CSSStyleDeclaration> {
return styles.reduce((res, s) => { return styles.reduce((res, s) => {
res[s] = this.$el.style[s] || initialStyleState[s as keyof ToolbarStateType]; // replace all need if case style value have 2 or more word, this.$el.style[s] return ""word value""
// for example font-family
// @ts-ignore
res[s] = this.$el.style[s].replaceAll('"', '') || initialStyleState[s];
return res; return res;
}, {}); }, {});
} }
attr(name: string, value: string) { attr(name: string, value?: string) {
if (value) { if (value !== undefined) {
this.$el.setAttribute(name, value); this.$el.setAttribute(name, value);
return this; return this;
} }
return this.$el.getAttribute(name); return this.$el.getAttribute(name);
} }
removeChild($child: Dom) {
this.$el.removeChild($child.$el);
}
replaceChild($newChild: Dom, $oldChild: Dom) {
this.$el.replaceChild($newChild.$el, $oldChild.$el);
}
get isExist(): boolean {
return !!this.$el;
}
isEqual($target: Dom | HTMLElement): boolean {
if ($target instanceof Dom) return this.$el === $target.$el;
return this.$el === $target;
}
} }
export function $(selector: SelectorType) { export function $(selector: SelectorType) {

View File

@ -1,41 +1,40 @@
import { Dom } from 'core/dom'; import { Dom } from 'core/Dom';
import { capitalize } from 'core/utils'; import { getMethodNameByEventName } from 'core/utils';
// TODO fix types
export class DomListener { export class DomListener {
$root: Dom; $root: Dom;
listeners: string[]; eventListeners: string[];
name: string;
constructor($root: Dom, listeners: string[]) { constructor($root: Dom, eventNames: string[]) {
if (!$root) throw new Error('Не передали корневой элемент'); if (!$root) throw new Error('Не передали корневой элемент');
this.$root = $root; this.$root = $root;
this.listeners = listeners; this.eventListeners = eventNames;
} }
initDOMListeners() { initDOMListeners() {
if (!this.listeners) return; if (!this.eventListeners) return;
this.listeners.forEach((listener: string) => { this.eventListeners.forEach((listener: string) => {
const method: any = getMethodName(listener); const method = getMethodNameByEventName(listener);
// @ts-ignore FIXME: // @ts-ignore
this[method] = this[method]?.bind(this); this[method] = this[method]?.bind(this);
// @ts-ignore FIXME: // @ts-ignore
if (!this[method]) throw new Error(`Отсутствует метод ${method} в компоненте ${this?.name}`); if (!this[method]) throw new Error(`Отсутствует метод ${method} в компоненте ${this?.name}`);
// @ts-ignore FIXME:
// @ts-ignore
this.$root.on(listener, this[method]); this.$root.on(listener, this[method]);
}); });
} }
removeDOMListeners() { removeDOMListeners() {
this.listeners.forEach(listener => { this.eventListeners.forEach(listener => {
// @ts-ignore FIXME: const method = getMethodNameByEventName(listener);
const method: any = getMethodName(listener); // @ts-ignore
// @ts-ignore FIXME:
this.$root.off(listener, this[method]); this.$root.off(listener, this[method]);
}); });
} }
} }
function getMethodName(eventName: string): string {
return `on${capitalize(eventName)}`;
}

View File

@ -1,42 +1,36 @@
import { ActionType } from 'redux/types'; import { BaseComponentOption } from 'components/excel/Excel';
import { Dom } from 'core/dom'; import { ComponentManager } from 'core/ComponentManager';
import { ActionType, CallbackType, StateType } from 'redux/types';
import { Dom } from 'core/Dom';
import { DomListener } from 'core/DomListener'; import { DomListener } from 'core/DomListener';
import { Emitter } from 'core/Emitter'; import { Observer } from 'core/Observer';
import { Store } from 'core/store/createStore'; import { Store } from 'core/store/Store';
interface ExcelComponentClass { export type ComponentOptionsType = BaseComponentOption & {
toHTML: () => string; eventListeners: string[];
prepare: () => void;
storeChanged?: (args: any) => void;
}
export type OptionsType = {
listeners: string[];
name: string; name: string;
emitter: Emitter; subscribe?: (keyof StateType)[],
store: Store;
subscribe: string[],
}; };
export abstract class ExcelComponent extends DomListener implements ExcelComponentClass { export abstract class ExcelComponent extends DomListener {
private name: string; observer: Observer;
private emitter: Emitter;
public store: Store; public store: Store;
private subscribe: string[]; private subscribe: (keyof StateType)[];
private unsubscribers: ((args?: any) => any)[]; private unsubscribers: CallbackType[];
private componentManager: ComponentManager;
constructor($root: Dom, options: OptionsType) { protected constructor($root: Dom, options: ComponentOptionsType) {
super($root, options.listeners); super($root, options.eventListeners);
this.name = options.name; this.name = options.name;
this.emitter = options.emitter; this.observer = options.observer;
this.store = options.store; this.store = options.store;
this.subscribe = options.subscribe; this.subscribe = options?.subscribe || [];
this.componentManager = options.componentManager;
this.unsubscribers = []; this.unsubscribers = [];
this.prepare(); this.beforeRender();
} }
prepare() { beforeRender() {
} }
@ -44,28 +38,29 @@ export abstract class ExcelComponent extends DomListener implements ExcelCompone
return ''; return '';
} }
$emit(event: string, args?: any): void { $emitEventToObserver(event: string, args?: any): boolean {
this.emitter?.emit(event, args); return this.observer?.emit(event, args);
} }
$on(event: string, callback: (args: any) => any) { $onEventFromObserver(event: string, callback: (args: any) => any) {
const unsub = this.emitter?.subscribe(event, callback); const unsub = this.observer?.subscribe(event, callback);
unsub && this.unsubscribers.push(unsub); unsub && this.unsubscribers.push(unsub);
} }
$dispatch(action: ActionType) { dispatchToStore(action: ActionType) {
this.store?.dispatch(action); this.store?.dispatchToStore(action);
} }
storeChanged(args?: any) { // eslint-disable-next-line @typescript-eslint/no-unused-vars
console.log('CHANGE STORE: ', args); storeChanged(args: StateType) {
// console.log('CHANGE STORE: ', args, ' in component ', this.name);
} }
isWatching(key: string) { isWatching(key: keyof StateType) {
return this.subscribe?.includes(key); return this.subscribe?.includes(key);
} }
init() { afterRender() {
this.initDOMListeners(); this.initDOMListeners();
} }
@ -73,4 +68,8 @@ export abstract class ExcelComponent extends DomListener implements ExcelCompone
this.removeDOMListeners(); this.removeDOMListeners();
this.unsubscribers.forEach(unsub => unsub()); this.unsubscribers.forEach(unsub => unsub());
} }
rerender() {
this.componentManager.rerenderComponent(this);
}
} }

View File

@ -0,0 +1,29 @@
import { Dom } from 'core/Dom';
import { ExcelComponent, ComponentOptionsType } from 'core/ExcelComponent';
type ExcelComponentStateType = {
[k: string]: any;
};
export abstract class ExcelComponentState extends ExcelComponent {
componentState: ExcelComponentStateType;
protected constructor(root: Dom, options: ComponentOptionsType) {
super(root, options);
}
get template(): string {
return JSON.stringify(this.componentState, null, 2);
}
initComponentState(initialState = {}) {
this.componentState = { ...initialState };
}
setComponentState(newState: ExcelComponentStateType) {
if (!newState) return;
this.componentState = { ...this.componentState, ...newState };
this.$root.html(this.template);
}
}

View File

@ -1,27 +0,0 @@
import { Dom } from 'core/dom';
import { ExcelComponent, OptionsType } from 'core/ExcelComponent';
type ExcelComponentStateType = {
[k: string]: any;
};
export abstract class ExcelStateComponent extends ExcelComponent {
state: ExcelComponentStateType;
protected constructor(root: Dom, options: OptionsType) {
super(root, options);
}
get template(): string {
return JSON.stringify(this.state, null, 2);
}
initState(initialState = {}) {
this.state = { ...initialState };
}
setState(newState: ExcelComponentStateType) {
this.state = { ...this.state, ...newState };
this.$root.html(this.template);
}
}

View File

@ -1,23 +1,15 @@
export class Emitter { import { CallbackType } from 'redux/types';
private listeners: {
[k: string]: Array<(args?: any) => any> export class Observer {
private readonly listeners: {
[k: string]: Array<CallbackType>
}; };
constructor() { constructor() {
this.listeners = {}; this.listeners = {};
} }
emit(eventName: string, args: any[]) { subscribe(eventName: string, callback: CallbackType) {
if (!Array.isArray(this.listeners[eventName])) return false;
this.listeners[eventName].forEach(listener => {
listener(args);
});
return true;
}
subscribe(eventName: string, callback: (args?: any) => any) {
this.listeners[eventName] = this.listeners[eventName] || []; this.listeners[eventName] = this.listeners[eventName] || [];
this.listeners[eventName].push(callback); this.listeners[eventName].push(callback);
@ -25,4 +17,12 @@ export class Emitter {
this.listeners[eventName] = this.listeners[eventName].filter(listener => listener !== callback); this.listeners[eventName] = this.listeners[eventName].filter(listener => listener !== callback);
}; };
} }
emit(eventName: string, args: any[]) {
if (!Array.isArray(this.listeners[eventName])) return false;
this.listeners[eventName].forEach(listener => listener(args));
return true;
}
} }

View File

@ -5,9 +5,9 @@ import { StateType } from 'redux/types';
export class StateProcessor { export class StateProcessor {
private client: ClientDataType; private client: ClientDataType;
constructor(client: ClientDataType, dalay = 300) { constructor(client: ClientDataType, delay = 300) {
this.client = client; this.client = client;
this.listen = debounce(this.listen.bind(this), dalay); this.listen = debounce(this.listen.bind(this), delay);
} }
listen(state: StateType) { listen(state: StateType) {

View File

@ -1,37 +1,36 @@
import { StateType } from 'redux/types'; import { StateType, SubscribeType } from 'redux/types';
import { Store } from 'core/store/createStore'; import { Store } from 'core/store/Store';
import { isEqual } from 'core/utils'; import { isEqual } from 'core/utils';
export class StoreSubscriber { export class StoreSubscriber {
sub: any; sub: SubscribeType | null;
prevState: StateType; currentState: StateType;
constructor(private store: Store) { constructor(private store: Store) {
this.sub = null; this.sub = null;
this.prevState = {};
} }
subscribeComponents(components: any[]) { subscribeComponents(components: any[]) {
this.prevState = this.store.getState(); this.currentState = this.store.getState();
this.sub = this.store.subscribe((state: StateType) => { this.sub = this.store.subscribeToStore((newState: StateType) => {
if (!state) return; if (!newState) return;
Object.keys(state).forEach(key => { Object.keys(newState).forEach((key) => {
if (!isEqual(this.prevState[key], state[key])) { if (!isEqual(this.currentState[key as keyof StateType], newState[key as keyof StateType])) {
components.forEach(component => { components.forEach(component => {
if (component.isWatching(key)) { if (component.isWatching(key)) {
const changes = { [key]: state[key] }; component.storeChanged({ [key]: newState[key as keyof StateType] });
component.storeChanged(changes);
} }
}); });
} }
}); });
this.prevState = this.store.getState();
this.currentState = this.store.getState();
}); });
} }
unsubscribeFromStore() { unsubscribeFromStore() {
this.sub.unsubscribe(); this.sub?.unsubscribe();
} }
} }

View File

@ -1,4 +1,4 @@
import { $, Dom, SelectorType } from 'core/dom'; import { $, Dom, SelectorType } from 'core/Dom';
import { ActiveRoute } from 'core/routes/ActiveRoute'; import { ActiveRoute } from 'core/routes/ActiveRoute';
import { DashboardPage } from 'pages/DashboardPage'; import { DashboardPage } from 'pages/DashboardPage';
import { ExcelPage } from 'pages/ExcelPage'; import { ExcelPage } from 'pages/ExcelPage';
@ -12,15 +12,14 @@ type RoutesType = {
export class Router { export class Router {
private $placeholder: Dom; private $placeholder: Dom;
private routes: RoutesType; private routes: RoutesType;
private page: DashboardPage | ExcelPage | null; private page: DashboardPage | ExcelPage;
private loader: Dom; private readonly loader: Dom;
constructor(selector: SelectorType, routes: RoutesType) { constructor(selector: SelectorType, routes: RoutesType) {
if (!selector) throw new Error('Selector not provided'); if (!selector) throw new Error('Selector not provided');
this.$placeholder = $(selector); this.$placeholder = $(selector);
this.routes = routes; this.routes = routes;
this.page = null;
this.loader = Loader(); this.loader = Loader();
this.changePageHandler = this.changePageHandler.bind(this); this.changePageHandler = this.changePageHandler.bind(this);
@ -35,8 +34,8 @@ export class Router {
} }
async changePageHandler() { async changePageHandler() {
this.$placeholder.clear().append(this.loader);
this.page?.destroy(); this.page?.destroy();
this.$placeholder.clear().append(this.loader);
let Page; let Page;
@ -50,14 +49,13 @@ export class Router {
Page = this.routes.dashboard; Page = this.routes.dashboard;
break; break;
} }
// @ts-ignore
this.page = new Page(ActiveRoute.param); this.page = new Page(ActiveRoute.param);
const root = await this.page?.getRoot(); const root = await this.page.getRoot();
this.$placeholder.clear().append(root); this.$placeholder.clear().append(root);
this.page?.afterRender(); this.page.afterRender();
} }
destroy() { destroy() {

View File

@ -1,16 +1,17 @@
import { ActionType, ReducerType, StateType, SubscribeType } from 'redux/types'; import { ActionType, CallbackType, ReducerType, StateType, SubscribeType } from 'redux/types';
export class Store { export class Store {
state: StateType; state: StateType | null;
listeners: ((args?: any) => void)[]; listeners: CallbackType[];
constructor(private reducer: ReducerType, initialState: StateType) { constructor(private reducer: ReducerType, initialState: StateType) {
this.state = reducer({ ...initialState }, { type: '__INIT__' }); this.state = reducer({ ...initialState }, { type: '__INIT__' });
this.listeners = []; this.listeners = [];
} }
subscribe(fn: (state: StateType) => void): SubscribeType { subscribeToStore(fn: (state: StateType) => void): SubscribeType {
this.listeners.push(fn); this.listeners.push(fn);
return { return {
unsubscribe: () => { unsubscribe: () => {
this.listeners = this.listeners.filter((l: any) => l !== fn); this.listeners = this.listeners.filter((l: any) => l !== fn);
@ -18,12 +19,14 @@ export class Store {
}; };
} }
dispatch(action: ActionType) { dispatchToStore(action: ActionType) {
if (!this.state || !action.type) return;
this.state = this.reducer(this.state, action); this.state = this.reducer(this.state, action);
this.listeners.forEach(listener => listener(this.state)); this.listeners.forEach(listener => listener(this.state));
} }
getState() { getState(): StateType {
return JSON.parse(JSON.stringify(this.state)); return JSON.parse(JSON.stringify(this.state));
} }
} }

View File

@ -1,10 +1,14 @@
import { $, Dom } from 'core/Dom';
import { CallbackType, StateType } from 'redux/types';
import { fontSizes } from 'src/constants';
export function capitalize(string: string): string { export function capitalize(string: string): string {
if (!string) return ''; if (!string) return '';
return string.charAt(0).toUpperCase() + string.slice(1); return string.charAt(0).toUpperCase() + string.slice(1);
} }
export function storage(key: string, data: any = null): any { export function storage(key: string, data: StateType | null = null): any {
if (!data) { if (!data) {
const localData = localStorage.getItem(key); const localData = localStorage.getItem(key);
return localData ? JSON.parse(localData) : false; return localData ? JSON.parse(localData) : false;
@ -23,7 +27,7 @@ export function isEqual(a: any, b: any) {
return a === b; return a === b;
} }
export function debounce(fn: (fnArgs?: any) => void, wait: number) { export function debounce(fn: CallbackType, wait: number) {
let timeout: NodeJS.Timeout; let timeout: NodeJS.Timeout;
return function (...args: any) { return function (...args: any) {
@ -38,7 +42,7 @@ export function debounce(fn: (fnArgs?: any) => void, wait: number) {
} }
export function parse(value: string) { export function parse(value: string) {
if (value.startsWith('=')) { if (value.toString().startsWith('=')) {
try { try {
// eslint-disable-next-line no-eval // eslint-disable-next-line no-eval
return eval(value.slice(1)); return eval(value.slice(1));
@ -49,3 +53,32 @@ export function parse(value: string) {
return value; return value;
} }
export function isLargestFontSize(fontSize?: string): number | boolean {
if (!fontSize) return false;
return fontSizes.length - 1 === fontSizes.findIndex(el => el === fontSize);
}
export function isSmallestFontSize(fontSize?: string): number | boolean {
if (!fontSize) return false;
return fontSizes.findIndex(el => el === fontSize) === 0;
}
export function getMethodNameByEventName(eventName: string): string {
return `on${capitalize(eventName)}`;
}
export function getIdByCell($cell: Dom): { row?: string, col?: string } {
const id = $cell.data.id?.split(':');
return { row: id?.[0], col: id?.[1] };
}
export function getCellById(id: { row: string | number, col: string | number }): Dom | false {
const $cell = $(`[data-id="${id.row}:${id.col}"]`);
if (!$cell.isExist) return false;
return $cell;
}
export function storageName(param: string) {
return `excel:${param}`;
}

View File

@ -1,5 +1,5 @@
import './styles/index.scss'; import './styles/index.scss';
import { Router } from 'core/routes/router'; import { Router } from 'core/routes/Router';
import { DashboardPage } from 'pages/DashboardPage'; import { DashboardPage } from 'pages/DashboardPage';
import { ExcelPage } from 'pages/ExcelPage'; import { ExcelPage } from 'pages/ExcelPage';

View File

@ -1,7 +1,7 @@
export abstract class AbstractPage { export abstract class AbstractPage {
params: any; params: string[];
constructor(params: any) { constructor(params: string[]) {
this.params = params || Date.now().toString(); this.params = params || Date.now().toString();
} }

View File

@ -1,9 +1,9 @@
import { $ } from 'core/dom'; import { $, Dom } from 'core/Dom';
import { AbstractPage } from 'pages/AbstractPage'; import { AbstractPage } from 'pages/AbstractPage';
import { storage } from 'core/utils'; import { storage } from 'core/utils';
export class DashboardPage extends AbstractPage { export class DashboardPage extends AbstractPage {
getRoot() { getRoot(): Dom {
const id = Date.now().toString(); const id = Date.now().toString();
return $.create('div', 'db').html( return $.create('div', 'db').html(
@ -24,7 +24,7 @@ export class DashboardPage extends AbstractPage {
} }
} }
function toHtml(key: string) { function toHtml(key: string): string {
const params = +key.split(':')[1]; const params = +key.split(':')[1];
const state = storage(key); const state = storage(key);
const link = `#excel/${params}`; const link = `#excel/${params}`;
@ -38,7 +38,7 @@ function toHtml(key: string) {
`; `;
} }
export function createRecordsTable() { export function createRecordsTable(): string {
const keys = getAllKeys(); const keys = getAllKeys();
if (!keys.length) return '<p>Пока не создали ни одной таблицы</p>'; if (!keys.length) return '<p>Пока не создали ни одной таблицы</p>';

View File

@ -1,25 +1,22 @@
import { ContextMenu } from 'components/ContextMenu/ContextMenu';
import { AbstractPage } from 'pages/AbstractPage'; import { AbstractPage } from 'pages/AbstractPage';
import { Excel } from 'components/excel/Excel'; import { Excel } from 'components/excel/Excel';
import { Formula } from 'components/formula/Formula'; import { Formula } from 'components/formula/Formula';
import { Header } from 'components/header/Header'; import { Header } from 'components/header/Header';
import { LocalStorageClient } from 'core/Clients'; import { LocalStorageClient } from 'core/Clients';
import { StateProcessor } from 'core/StateProcessor'; import { StateProcessor } from 'core/StateProcessor';
import { Store } from 'core/store/createStore'; import { Store } from 'core/store/Store';
import { SubscribeType } from 'redux/types'; import { SubscribeType } from 'redux/types';
import { Table } from 'components/table/Table'; import { Table } from 'components/table/Table';
import { Toolbar } from 'components/toolbar/Toolbar'; import { Toolbar } from 'components/toolbar/Toolbar';
import { rootReducer } from 'redux/rootReducer'; import { rootReducer } from 'redux/rootReducer';
export function storageName(param: string) {
return `excel:${param}`;
}
export class ExcelPage extends AbstractPage { export class ExcelPage extends AbstractPage {
private excel: Excel; private excel: Excel;
private storeSub: SubscribeType | null; private storeSub: SubscribeType | null;
private processor: StateProcessor; private processor: StateProcessor;
constructor(props: any) { constructor(props: string[]) {
super(props); super(props);
this.storeSub = null; this.storeSub = null;
@ -29,13 +26,13 @@ export class ExcelPage extends AbstractPage {
} }
async getRoot() { async getRoot() {
const normalizeState = await this.processor.get(); const state = await this.processor.get();
const store = new Store(rootReducer, normalizeState); const store = new Store(rootReducer, state);
this.storeSub = store.subscribe(this.processor.listen); this.storeSub = store.subscribeToStore(this.processor.listen);
this.excel = new Excel({ this.excel = new Excel({
components: [Header, Toolbar, Formula, Table], components: [Header, Toolbar, Formula, Table, ContextMenu],
store, store,
}); });
@ -43,7 +40,7 @@ export class ExcelPage extends AbstractPage {
} }
afterRender() { afterRender() {
this.excel.init(); this.excel.afterRender();
} }
destroy() { destroy() {

View File

@ -0,0 +1,13 @@
export const APPLY_STYLES = 'APPLY_STYLES';
export const CHANGE_STYLES = 'CURRENT_STYLES';
export const CHANGE_TEXT = 'CHANGE_TEXT';
export const CHANGE_TITLE = 'CHANGE_TITLE';
export const DELETE_TABLE = 'DELETE_TABLE';
export const TABLE_RESIZE = 'TABLE_RESIZE';
export const UPDATE_DATE = 'UPDATE_DATE';
export const CHANGE_CURRENT_TEXT = 'CHANGE_CURRENT_TEXT';
export const CHANGE_TABLE_SIZE = 'CHANGE_TABLE_SIZE';
export const REMOVE_ROW_FROM_TABLE = 'REMOVE_ROW_FROM_TABLE';
export const REMOVE_COL_FROM_TABLE = 'REMOVE_COL_FROM_TABLE';
export const ADD_ROW_TO_TABLE = 'ADD_ROW_TO_TABLE';
export const ADD_COL_TO_TABLE = 'ADD_COL_TO_TABLE';

View File

@ -0,0 +1,108 @@
import { ResizeReturnDataType } from 'components/table/handlers/table.resize';
import { ActionType } from 'redux/types';
import {
CHANGE_TEXT,
CHANGE_STYLES,
TABLE_RESIZE,
APPLY_STYLES,
CHANGE_TITLE,
DELETE_TABLE,
UPDATE_DATE,
CHANGE_CURRENT_TEXT,
CHANGE_TABLE_SIZE,
REMOVE_ROW_FROM_TABLE,
ADD_ROW_TO_TABLE,
ADD_COL_TO_TABLE,
REMOVE_COL_FROM_TABLE,
} from 'redux/action-constants';
export function tableResize(resizeData: ResizeReturnDataType): ActionType {
return {
type: TABLE_RESIZE,
resizeData,
};
}
export function changeText(data: { text: string, id: string }): ActionType {
return {
type: CHANGE_TEXT,
data,
};
}
export function changeCurrentStyles(data: Partial<CSSStyleDeclaration>): ActionType {
return {
type: CHANGE_STYLES,
data,
};
}
export function applyStyle(data: { ids: (string | undefined)[], value: Partial<CSSStyleDeclaration> }): ActionType {
return {
type: APPLY_STYLES,
data,
};
}
export function changeTitle(data: string): ActionType {
return {
type: CHANGE_TITLE,
data,
};
}
export function deleteTable(data: string): ActionType {
return {
type: DELETE_TABLE,
data,
};
}
export function updateOpenDate(data: string): ActionType {
return {
type: UPDATE_DATE,
data,
};
}
export function changeCurrentText(data: string): ActionType {
return {
type: CHANGE_CURRENT_TEXT,
data,
};
}
export function changeTableSize(data: { col: number, row: number }): ActionType {
return {
type: CHANGE_TABLE_SIZE,
data,
};
}
export function addRow(data: { position: 'after' | 'before', targetIndex: number }): ActionType {
return {
type: ADD_ROW_TO_TABLE,
data,
};
}
export function addCol(data: { position: 'after' | 'before', targetIndex: number }): ActionType {
return {
type: ADD_COL_TO_TABLE,
data,
};
}
export function removeRowFromTable(removedRowNumber: number) {
return {
type: REMOVE_ROW_FROM_TABLE,
data: removedRowNumber,
};
}
export function removeColFromTable(removedColNumber: number) {
return {
type: REMOVE_COL_FROM_TABLE,
data: removedColNumber,
};
}

View File

@ -1,8 +0,0 @@
export type ResizePayloadType = {
colState: {
[k in number]: number
},
rowState: {
[k in number]: number
},
};

View File

@ -1,59 +0,0 @@
import { ActionType } from 'redux/types';
import {
CHANGE_TEXT,
CHANGE_STYLES,
TABLE_RESIZE,
APPLY_STYLES,
CHANGE_TITLE,
DELETE_TABLE,
UPDATE_DATE,
} from 'redux/constants';
export function tableResize(resizeData: any) {
return {
type: TABLE_RESIZE,
...resizeData,
};
}
export function changeText(data: { text: string, id: string }) {
return {
type: CHANGE_TEXT,
data,
};
}
export function changeCurrentStyles(data: any) {
return {
type: CHANGE_STYLES,
data,
};
}
export function applyStyle(data: any) {
return {
type: APPLY_STYLES,
data,
};
}
export function changeTitle(data: string) {
return {
type: CHANGE_TITLE,
data,
};
}
export function deleteTable(data: string) {
return {
type: DELETE_TABLE,
data,
};
}
export function updateOpenDate(data: string): ActionType {
return {
type: UPDATE_DATE,
data,
};
}

View File

@ -1,7 +0,0 @@
export const APPLY_STYLES = 'APPLY_STYLES';
export const CHANGE_STYLES = 'CURRENT_STYLES';
export const CHANGE_TEXT = 'CHANGE_TEXT';
export const CHANGE_TITLE = 'CHANGE_TITLE';
export const DELETE_TABLE = 'DELETE_TABLE';
export const TABLE_RESIZE = 'TABLE_RESIZE';
export const UPDATE_DATE = 'UPDATE_DATE';

View File

@ -1,6 +1,7 @@
import { ToolbarStateType } from 'components/toolbar/toolbar-types';
import { storageName } from 'core/utils';
import { ActionType, StateType } from 'redux/types'; import { ActionType, StateType } from 'redux/types';
import { ActiveRoute } from 'core/routes/ActiveRoute'; import { ActiveRoute } from 'core/routes/ActiveRoute';
import { storageName } from 'pages/ExcelPage';
import { import {
CHANGE_TEXT, CHANGE_TEXT,
CHANGE_STYLES, CHANGE_STYLES,
@ -9,21 +10,27 @@ import {
CHANGE_TITLE, CHANGE_TITLE,
DELETE_TABLE, DELETE_TABLE,
UPDATE_DATE, UPDATE_DATE,
} from 'redux/constants'; CHANGE_CURRENT_TEXT,
CHANGE_TABLE_SIZE,
REMOVE_ROW_FROM_TABLE,
ADD_ROW_TO_TABLE,
ADD_COL_TO_TABLE,
REMOVE_COL_FROM_TABLE,
} from 'redux/action-constants';
export function rootReducer(state: StateType, action: ActionType) { export function rootReducer(state: StateType, action: ActionType): StateType {
switch (action.type) { switch (action.type) {
case TABLE_RESIZE: { case TABLE_RESIZE: {
const newState: StateType = { ...state }; const newState: StateType = { ...state };
const fieldName = `${action.resizeData?.type}State`; const fieldName = `${action.resizeData?.type}State`;
newState[fieldName][action.resizeData?.id] = action.resizeData?.value; newState[fieldName as 'colState' | 'rowState'][action.resizeData?.id] = action.resizeData?.value;
return { ...state, ...newState }; return { ...state, ...newState };
} }
case CHANGE_TEXT: { case CHANGE_TEXT: {
const newState: StateType = state.dataState || {}; const newState: { [k: string]: string } = state.dataState || {};
const fieldName = action.data.id; const fieldName = action.data.id;
newState[fieldName] = action.data.text; newState[fieldName] = action.data.text;
@ -59,11 +66,281 @@ export function rootReducer(state: StateType, action: ActionType) {
localStorage.removeItem(name); localStorage.removeItem(name);
ActiveRoute.navigateTo = ''; ActiveRoute.navigateTo = '';
return null; return null as any;
} }
case UPDATE_DATE: { case UPDATE_DATE: {
return { ...this.state, openDate: action.data }; return { ...state, openDate: action.data };
}
case CHANGE_CURRENT_TEXT: {
return { ...state, currentText: action.data };
}
case CHANGE_TABLE_SIZE: {
return { ...state, tableSize: action.data };
}
case ADD_ROW_TO_TABLE: {
// needs to be redefined next params:
// rowState: { [k: number]: number };
// dataState: { [k: string]: string };
// stylesState: { [k: string]: ToolbarStateType };
// tableSize: { col: number, row: number }
const newRowStateEntries: [number, number][] = [];
const newDataStateEntries: [string, string][] = [];
const newStylesStateEntries: [string, ToolbarStateType][] = [];
// rowState change
Object.entries(state.rowState).forEach(([key, value]) => {
switch (true) {
case action.data.position === 'before' && +key < action.data.targetIndex:
case action.data.position === 'after' && +key <= action.data.targetIndex:
newRowStateEntries.push([+key, value]);
break;
case action.data.position === 'before' && +key >= action.data.targetIndex:
case action.data.position === 'after' && +key > action.data.targetIndex:
newRowStateEntries.push([+key + 1, value]);
break;
default: break;
}
});
const newRowState = Object.fromEntries(newRowStateEntries);
// dataState change
Object.entries(state.dataState).forEach(([key, value]) => {
const [row, col] = key.split(':');
switch (true) {
case action.data.position === 'before' && +row < action.data.targetIndex:
case action.data.position === 'after' && +row <= action.data.targetIndex:
newDataStateEntries.push([key, value.toString()]);
break;
case action.data.position === 'before' && +row >= action.data.targetIndex:
case action.data.position === 'after' && +row > action.data.targetIndex:
newDataStateEntries.push([`${+row + 1}:${col}`, value.toString()]);
break;
default: break;
}
});
const newDataState = Object.fromEntries(newDataStateEntries);
// stylesState change
Object.entries(state.stylesState).forEach(([key, value]) => {
const [row, col] = key.split(':');
switch (true) {
case action.data.position === 'before' && +row < action.data.targetIndex:
case action.data.position === 'after' && +row <= action.data.targetIndex:
newStylesStateEntries.push([key, value]);
break;
case action.data.position === 'before' && +row >= action.data.targetIndex:
case action.data.position === 'after' && +row > action.data.targetIndex:
newStylesStateEntries.push([`${+row + 1}:${col}`, value]);
break;
default: break;
}
});
const newStylesState = Object.fromEntries(newStylesStateEntries);
// tableSize
const newTableSize = { col: state.tableSize.col, row: state.tableSize.row + 1 };
// debugger
return { ...state, rowState: newRowState, dataState: newDataState, stylesState: newStylesState, tableSize: newTableSize };
}
case ADD_COL_TO_TABLE: {
// needs to be redefined next params:
// colState: { [k: number]: number };
// dataState: { [k: string]: string };
// stylesState: { [k: string]: ToolbarStateType };
// tableSize: { col: number, row: number }
const newColStateEntries: [number, number][] = [];
const newDataStateEntries: [string, string][] = [];
const newStylesStateEntries: [string, ToolbarStateType][] = [];
// colState change
Object.entries(state.colState).forEach(([key, value]) => {
switch (true) {
case action.data.position === 'before' && +key < action.data.targetIndex:
case action.data.position === 'after' && +key <= action.data.targetIndex:
newColStateEntries.push([+key, value]);
break;
case action.data.position === 'before' && +key >= action.data.targetIndex:
case action.data.position === 'after' && +key > action.data.targetIndex:
newColStateEntries.push([+key + 1, value]);
break;
default: break;
}
});
const newColState = Object.fromEntries(newColStateEntries);
// dataState change
Object.entries(state.dataState).forEach(([key, value]) => {
const [row, col] = key.split(':');
switch (true) {
case action.data.position === 'before' && +col < action.data.targetIndex:
case action.data.position === 'after' && +col <= action.data.targetIndex:
newDataStateEntries.push([key, value.toString()]);
break;
case action.data.position === 'before' && +col >= action.data.targetIndex:
case action.data.position === 'after' && +col > action.data.targetIndex:
newDataStateEntries.push([`${row}:${+col + 1}`, value.toString()]);
break;
default: break;
}
});
const newDataState = Object.fromEntries(newDataStateEntries);
// stylesState change
Object.entries(state.stylesState).forEach(([key, value]) => {
const [row, col] = key.split(':');
switch (true) {
case action.data.position === 'before' && +col < action.data.targetIndex:
case action.data.position === 'after' && +col <= action.data.targetIndex:
newStylesStateEntries.push([key, value]);
break;
case action.data.position === 'before' && +col >= action.data.targetIndex:
case action.data.position === 'after' && +col > action.data.targetIndex:
newStylesStateEntries.push([`${row}:${+col + 1}`, value]);
break;
default: break;
}
});
const newStylesState = Object.fromEntries(newStylesStateEntries);
// tableSize
const newTableSize = { col: state.tableSize.col + 1, row: state.tableSize.row };
// debugger
return { ...state, colState: newColState, dataState: newDataState, stylesState: newStylesState, tableSize: newTableSize };
}
case REMOVE_ROW_FROM_TABLE: {
// needs to be redefined next params:
// rowState: { [k: number]: number };
// dataState: { [k: string]: string };
// stylesState: { [k: string]: ToolbarStateType };
// tableSize: { col: number, row: number }
const newRowStateEntries: [number, number][] = [];
const newDataStateEntries: [string, string][] = [];
const newStylesStateEntries: [string, ToolbarStateType][] = [];
// rowState change
Object.entries(state.rowState).forEach(([key, value]) => {
switch (true) {
case key < action.data:
newRowStateEntries.push([+key, value]);
break;
case key > action.data:
newRowStateEntries.push([+key - 1, value]);
break;
default: break;
}
});
const newRowState = Object.fromEntries(newRowStateEntries);
// dataState change
Object.entries(state.dataState).forEach(([key, value]) => {
const [row, col] = key.split(':');
switch (true) {
case row < action.data:
newDataStateEntries.push([key, value.toString()]);
break;
case row > action.data:
newDataStateEntries.push([`${+row - 1}:${col}`, value.toString()]);
break;
default: break;
}
});
const newDataState = Object.fromEntries(newDataStateEntries);
// stylesState change
Object.entries(state.stylesState).forEach(([key, value]) => {
const [row, col] = key.split(':');
switch (true) {
case row < action.data:
newStylesStateEntries.push([key, value]);
break;
case row > action.data:
newStylesStateEntries.push([`${+row - 1}:${col}`, value]);
break;
default: break;
}
});
const newStylesState = Object.fromEntries(newStylesStateEntries);
// tableSize
const newTableSize = { col: state.tableSize.col, row: state.tableSize.row - 1 };
return { ...state, rowState: newRowState, dataState: newDataState, stylesState: newStylesState, tableSize: newTableSize };
}
case REMOVE_COL_FROM_TABLE: {
// needs to be redefined next params:
// colState: { [k: number]: number };
// dataState: { [k: string]: string };
// stylesState: { [k: string]: ToolbarStateType };
// tableSize: { col: number, row: number }
const newColStateEntries: [number, number][] = [];
const newDataStateEntries: [string, string][] = [];
const newStylesStateEntries: [string, ToolbarStateType][] = [];
// colState change
Object.entries(state.colState).forEach(([key, value]) => {
switch (true) {
case key < action.data:
newColStateEntries.push([+key, value]);
break;
case key > action.data:
newColStateEntries.push([+key - 1, value]);
break;
default: break;
}
});
const newColState = Object.fromEntries(newColStateEntries);
// dataState change
Object.entries(state.dataState).forEach(([key, value]) => {
const [row, col] = key.split(':');
switch (true) {
case col < action.data:
newDataStateEntries.push([key, value.toString()]);
break;
case col > action.data:
newDataStateEntries.push([`${row}:${+col - 1}`, value.toString()]);
break;
default: break;
}
});
const newDataState = Object.fromEntries(newDataStateEntries);
// stylesState change
Object.entries(state.stylesState).forEach(([key, value]) => {
const [row, col] = key.split(':');
switch (true) {
case col < action.data:
newStylesStateEntries.push([key, value]);
break;
case col > action.data:
newStylesStateEntries.push([`${row}:${+col - 1}`, value]);
break;
default: break;
}
});
const newStylesState = Object.fromEntries(newStylesStateEntries);
// tableSize
const newTableSize = { col: state.tableSize.col - 1, row: state.tableSize.row };
return { ...state, colState: newColState, dataState: newDataState, stylesState: newStylesState, tableSize: newTableSize };
} }
default: return state; default: return state;

24
src/redux/types.d.ts vendored
View File

@ -1,14 +1,32 @@
import { ToolbarStateType } from 'components/toolbar/toolbar-types';
export type ActionType = { export type ActionType = {
type: string type: string
[k: string]: any [k: string]: any
}; };
export type StateType = { export type TableSizeType = {
[k: string]: any col: number;
row: number;
}; };
export type ReducerType = (state: StateType, action: ActionType) => StateType; export type StateType = {
colState: { [k: number]: number };
rowState: { [k: number]: number };
currentStyles: ToolbarStateType;
dataState: { [k: string]: string };
id: string;
openDate: number;
stylesState: { [k: string]: ToolbarStateType };
title: string;
currentText: string;
tableSize: TableSizeType;
};
export type ReducerType = (state: StateType, action: ActionType) => StateType | null;
export type SubscribeType = { export type SubscribeType = {
unsubscribe: () => void unsubscribe: () => void
}; };
export type CallbackType = (...args: any[]) => void;

View File

@ -7,3 +7,4 @@ $info-cell-width: 40px;
$row-height: 25px; $row-height: 25px;
$toolbar-height: 40px; $toolbar-height: 40px;
$primary-color: #3c74ff; $primary-color: #3c74ff;
$default-cell-font-size: 12px;

View File

@ -0,0 +1,43 @@
@import "../_variables";
@import "../_mixins";
.excel__contextmenu_layer {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
z-index: 10;
user-select: none;
&.active {
height: 100vh;
}
}
.excel__contextmenu {
position: absolute;
z-index: 20;
left: 0;
top: -100px;
width: 170px;
height: 100px;
background: #ffffff;
display: flex;
flex-direction: column;
.excel__contextmenu_item {
flex: 1;
border: 2px solid #cecece;
cursor: pointer;
font-weight: bold;
display: flex;
align-items: center;
padding: 5px;
&:hover {
background: rgba(206, 206, 206, 1);
}
}
}

View File

@ -23,5 +23,6 @@
width: 100%; width: 100%;
height: 100%; height: 100%;
color: #000; color: #000;
overflow: hidden;
} }
} }

View File

@ -5,8 +5,12 @@
position: absolute; position: absolute;
left: 0; left: 0;
right: 0; right: 0;
max-height: calc(100vh - ($header-height + $toolbar-height + $formula-height));
top: $header-height + $toolbar-height + $formula-height; top: $header-height + $toolbar-height + $formula-height;
overflow: auto; overflow: auto;
font-size: $default-cell-font-size;
padding-bottom: 5px;
.row{ .row{
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -46,16 +50,22 @@
border: 1px solid #e1e2e3; border: 1px solid #e1e2e3;
border-top: 0; border-top: 0;
border-left: 0; border-left: 0;
white-space: nowrap; white-space: normal;
overflow: hidden;
outline: none; outline: none;
display: flex;
&:hover:not(.selected) { &:hover:not(.selected) {
cursor: cell; cursor: cell;
} }
&.selected { &.selected {
border: none; border: none;
outline: 2px solid $primary-color; outline: 2px solid $primary-color;
background: #cccccc;
z-index: 2; z-index: 2;
} }
&.current {
background: #ffffff;
}
} }
.col-resize{ .col-resize{
position: absolute; position: absolute;
@ -65,14 +75,13 @@
width: 4px; width: 4px;
background: $primary-color; background: $primary-color;
opacity: 0; opacity: 0;
z-index: 2; z-index: 50;
&:hover{ &:hover{
cursor: col-resize; cursor: col-resize;
opacity: 1 !important; opacity: 1 !important;
} }
} }
.row-resize{ .row-resize{
position: absolute; position: absolute;
bottom: 0; bottom: 0;
@ -81,11 +90,37 @@
height: 4px; height: 4px;
opacity: 0; opacity: 0;
background: $primary-color; background: $primary-color;
z-index: 2; z-index: 50;
&:hover{ &:hover{
cursor: row-resize; cursor: row-resize;
opacity: 1 !important; opacity: 1 !important;
} }
} }
[data-header="row"] {
cursor: pointer;
&.selected {
border-right: 2px solid #3c74ff;
}
}
[data-header="col"] {
cursor: pointer;
&.selected {
border-bottom: 2px solid #3c74ff;
}
}
[data-header="col"], [data-header="row"] {
&.selected {
font-weight: bold;
background: rgba(60, 116, 255, 0.4);
}
&:hover {
background: #ccc;
}
}
} }

View File

@ -15,4 +15,23 @@
.button { .button {
@include button(green) @include button(green)
} }
}
.button.disable {
cursor: not-allowed;
}
.button__group {
border-right: 1px solid #c0c0c0;
&:last-child {
border: none;
}
}
.button__size {
width: 50px;
}
.button__font {
width: 100px;
}
}

View File

@ -1,10 +1,11 @@
@import url('https://fonts.googleapis.com/css2?family=Roboto&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Cormorant+SC&family=Kanit&family=Playfair+Display&family=Roboto&display=swap');;
@import "~normalize.css"; @import "~normalize.css";
@import './components/header'; @import './components/header';
@import './components/toolbar'; @import './components/toolbar';
@import './components/formula'; @import './components/formula';
@import './components/table'; @import './components/table';
@import './components/contextmenu';
@import './components/dashboard'; @import './components/dashboard';
@import './components/loader'; @import './components/loader';

View File

@ -2,7 +2,7 @@
* @jest-environment jsdom * @jest-environment jsdom
*/ */
import { Router } from '../src/core/routes/router'; import { Router } from '../src/core/routes/Router';
import { AbstractPage } from '../src/pages/AbstractPage'; import { AbstractPage } from '../src/pages/AbstractPage';
class DashboardPage extends AbstractPage { class DashboardPage extends AbstractPage {

View File

@ -1,4 +1,4 @@
import { Store } from '../src/core/store/createStore'; import { Store } from '../src/core/store/Store';
const initialState = { const initialState = {
count: 0, count: 0,
@ -23,8 +23,8 @@ describe('Create store', () => {
test('should return store object', () => { test('should return store object', () => {
expect(store).toBeDefined(); expect(store).toBeDefined();
expect(store.dispatch).toBeDefined(); expect(store.dispatchToStore).toBeDefined();
expect(store.subscribe).toBeDefined(); expect(store.subscribeToStore).toBeDefined();
expect(store.getState).not.toBeUndefined(); expect(store.getState).not.toBeUndefined();
}); });
@ -37,27 +37,27 @@ describe('Create store', () => {
}); });
test('should change state if actions exist', () => { test('should change state if actions exist', () => {
store.dispatch({ type: 'ADD' }); store.dispatchToStore({ type: 'ADD' });
expect(store.getState().count).toBe(1); expect(store.getState().count).toBe(1);
}); });
test("should NOT change state if actions don't exist", () => { test("should NOT change state if actions don't exist", () => {
store.dispatch({ type: 'NOT_EXISTING_TYPE' }); store.dispatchToStore({ type: 'NOT_EXISTING_TYPE' });
expect(store.getState().count).toBe(0); expect(store.getState().count).toBe(0);
}); });
test('should call subscriber', () => { test('should call subscriber', () => {
store.subscribe(handler); store.subscribeToStore(handler);
store.dispatch({ type: 'ADD' }); store.dispatchToStore({ type: 'ADD' });
expect(handler).toHaveBeenCalled(); expect(handler).toHaveBeenCalled();
expect(handler).toHaveBeenCalledWith(store.getState()); expect(handler).toHaveBeenCalledWith(store.getState());
}); });
test('should NOT call sub if unsubscribe', () => { test('should NOT call sub if unsubscribe', () => {
const unsub = store.subscribe(handler); const unsub = store.subscribeToStore(handler);
unsub.unsubscribe(); unsub.unsubscribe();
store.dispatch({ type: 'ADD' }); store.dispatchToStore({ type: 'ADD' });
expect(handler).not.toHaveBeenCalled(); expect(handler).not.toHaveBeenCalled();
}); });
@ -65,7 +65,7 @@ describe('Create store', () => {
test('should dispatch in async way', () => { test('should dispatch in async way', () => {
return new Promise(resolve => { return new Promise(resolve => {
setTimeout(() => { setTimeout(() => {
store.dispatch({ type: 'ADD' }); store.dispatchToStore({ type: 'ADD' });
}, 500); }, 500);
setTimeout(() => { setTimeout(() => {