diff --git a/src/components/ContextMenu/ContextMenu.ts b/src/components/ContextMenu/ContextMenu.ts new file mode 100644 index 0000000..cced60e --- /dev/null +++ b/src/components/ContextMenu/ContextMenu.ts @@ -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 ` +
+
Добавить строку сверху
+
Удалить строку
+
Добавить строку снизу
+
+ `; + } + + return ` +
+
Добавить столбец слева
+
Удалить столбец
+
Добавить столбец справа
+
+ `; + } + + onClick(e: MouseEvent) { + const $target = $(e.target); + if ($target.closest('#excel__contextmenu').isExist) { + this.emitSelectItem($target); + return; + } + + this.hideContextMenu(); + } + + onMouseup() { + this.hideContextMenu(); + } +} diff --git a/src/components/excel/Excel.ts b/src/components/excel/Excel.ts index 2e72cd2..31b6c42 100644 --- a/src/components/excel/Excel.ts +++ b/src/components/excel/Excel.ts @@ -1,19 +1,26 @@ -import { $ } from 'core/Dom'; +import { ContextMenu } from 'components/ContextMenu/ContextMenu'; +import { Formula } from 'components/formula/Formula'; +import { Header } from 'components/header/Header'; +import { Table } from 'components/table/Table'; +import { Toolbar } from 'components/toolbar/Toolbar'; +import { ComponentManager } from 'core/ComponentManager'; import { Observer } from 'core/Observer'; -import { ExcelComponent } from 'core/ExcelComponent'; import { Store } from 'core/store/Store'; import { StoreSubscriber } from 'core/StoreSubscriber'; import { updateOpenDate } from 'redux/action-creators'; // TODO type for components array interface ExcelOptionsType { - components: any[], + components: ComponentType[], store: Store, } -type BaseComponentOption = { +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 { @@ -21,44 +28,38 @@ export class Excel { observer: Observer; store: Store; subscriber: StoreSubscriber; + componentManage: ComponentManager; constructor(options: ExcelOptionsType) { - this.components = options.components; - this.observer = new Observer(); this.store = options.store; + this.components = options.components; + this.subscriber = new StoreSubscriber(this.store); + this.observer = new Observer(); + this.componentManage = new ComponentManager(); } getRoot() { - const $root = $.create('div', 'excel'); - const componentOptions: BaseComponentOption = { observer: this.observer, 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 => { - 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; + return this.componentManage.$rootExcelElement; } - init() { + afterRender() { this.subscriber.subscribeComponents(this.components); - this.components.forEach(component => component.init()); + this.components.forEach(component => component.afterRender()); this.store.dispatchToStore(updateOpenDate(Date.now().toString())); } destroy() { this.subscriber.unsubscribeFromStore(); - this.components.forEach(component => component.destroy()); + this.componentManage.destroyComponents(); } } diff --git a/src/components/formula/Formula.ts b/src/components/formula/Formula.ts index b58d628..8349c2c 100644 --- a/src/components/formula/Formula.ts +++ b/src/components/formula/Formula.ts @@ -1,12 +1,13 @@ +import { BaseComponentOption } from 'components/excel/Excel'; import { Dom } from 'core/Dom'; -import { ExcelComponent, ComponentOptionsType } from 'core/ExcelComponent'; +import { ExcelComponent } from 'core/ExcelComponent'; export class Formula extends ExcelComponent { static className = 'excel__formula'; private formulaInput: Dom; - constructor($root: Dom, options: ComponentOptionsType) { + constructor($root: Dom, options: BaseComponentOption) { super($root, { ...options, eventListeners: ['input', 'keydown'], @@ -22,8 +23,8 @@ export class Formula extends ExcelComponent { `; } - init() { - super.init(); + afterRender() { + super.afterRender(); this.formulaInput = this.$root.find('#formula-input'); diff --git a/src/components/header/Header.ts b/src/components/header/Header.ts index 0cdb016..d02a98f 100644 --- a/src/components/header/Header.ts +++ b/src/components/header/Header.ts @@ -1,4 +1,5 @@ -import { ComponentOptionsType, ExcelComponent } from 'core/ExcelComponent'; +import { BaseComponentOption } from 'components/excel/Excel'; +import { ExcelComponent } from 'core/ExcelComponent'; import * as actions from 'redux/action-creators'; import { $, Dom } from 'core/Dom'; import { ActiveRoute } from 'core/routes/ActiveRoute'; @@ -7,7 +8,7 @@ import { deleteTable } from 'redux/action-creators'; export class Header extends ExcelComponent { static className = 'excel__header'; - constructor($root: Dom, options: ComponentOptionsType) { + constructor($root: Dom, options: BaseComponentOption) { super($root, { ...options, name: 'Header', diff --git a/src/components/table/Table.ts b/src/components/table/Table.ts index e2d86f1..0123ea9 100644 --- a/src/components/table/Table.ts +++ b/src/components/table/Table.ts @@ -1,11 +1,21 @@ +import { ContextSelectType } from 'components/ContextMenu/ContextMenu'; +import { BaseComponentOption } from 'components/excel/Excel'; import { startCellId } from 'components/table/table.functions'; import * as actions from 'redux/action-creators'; import { $, Dom } from 'core/Dom'; -import { ComponentOptionsType, ExcelComponent } from 'core/ExcelComponent'; +import { ExcelComponent } from 'core/ExcelComponent'; import { TableSelection } from 'components/table/TableSelection'; -import { changeCurrentStyles, changeCurrentText, changeTableSize, removeRowFromTable } from 'redux/action-creators'; -import { createTable, getNewRowHTML } from 'components/table/table.template'; -import { initialState, initialStyleState } from 'src/constants'; +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 { initialStyleState } from 'src/constants'; import { getCellId, parse } from 'core/utils'; import { resizeHandler } from 'components/table/handlers/table.resize'; import { selectHandler } from 'components/table/handlers/table.select.handler'; @@ -15,28 +25,39 @@ export class Table extends ExcelComponent { private selection: TableSelection; private isMouseDowned: boolean; - private tableResizing = false; - public tableSize = { row: initialState.tableSize.row, col: initialState.tableSize.col }; + private tableResizing: boolean; + public tableSize: TableSizeType; - constructor($root: Dom, options: ComponentOptionsType) { + constructor($root: Dom, options: BaseComponentOption) { super($root, { ...options, name: 'Table', - eventListeners: ['mousedown', 'keydown', 'input', 'mouseover', 'mouseup'], + eventListeners: ['mousedown', 'keydown', 'input', 'mouseover', 'mouseup', 'contextmenu'], }); - - this.isMouseDowned = false; - const { col, row } = this.getTableSize(); - this.tableSize = { col, row }; } toHTML(): string { - console.log('Table size', this.tableSize); return createTable(this.tableSize.row, this.tableSize.col); } - prepare() { + beforeRender() { this.selection = new TableSelection(this); + this.tableResizing = false; + this.isMouseDowned = false; + this.tableSize = this.getTableSize(); + } + + afterRender() { + super.afterRender(); + + this.initTable(); + + this.$onEventFromObserver('formula:input', this.updateTextInCell); + this.$onEventFromObserver('formula:enter-press', () => this.selection.$currentCell.focus()); + 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() { @@ -49,25 +70,13 @@ export class Table extends ExcelComponent { col: Math.max(maxColFromState, col), }; - if ((maxColFromState !== this.tableSize.col) || (maxRowFromState !== this.tableSize.row)) { + if ((maxColFromState !== this.tableSize?.col) || (maxRowFromState !== this.tableSize?.row)) { this.dispatchToStore(changeTableSize(normalTableSize)); } return normalTableSize; } - init() { - super.init(); - - this.initTable(); - - this.$onEventFromObserver('formula:input', this.updateTextInCell); - this.$onEventFromObserver('formula:enter-press', () => this.selection.$currentCell.focus()); - this.$onEventFromObserver('toolbar:applyStyle', this.updateCurrentStyles); - this.$onEventFromObserver('toolbar:add-row', this.addNewRowHandler); - this.$onEventFromObserver('toolbar:remove-row', this.removeRowHandler); - } - initTable() { this.initTableSize(); this.initTableContentAndStyles(); @@ -156,7 +165,6 @@ export class Table extends ExcelComponent { }; updateTextInCell = (text: string, $cell = this.selection.$focusedCell) => { - // eslint-disable-next-line no-param-reassign $cell.attr('data-value', text); // eslint-disable-next-line no-param-reassign $cell.text = parse(text); @@ -165,15 +173,91 @@ export class Table extends ExcelComponent { text, id: $cell.data.id || startCellId, })); + this.selection.focusToCell($cell); }; addNewRowHandler = () => { - this.tableSize.row++; - this.$root.$el.insertAdjacentHTML('beforeend', getNewRowHTML(this.tableSize.row, this.tableSize.col)); - const $newRow = $(`[data-row="${this.tableSize.row - 1}"]`); - this.initColSizes($newRow); + this.addNewColRow('row', 'after', this.tableSize.row); + }; - this.dispatchToStore(changeTableSize(this.tableSize)); + 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); + // this.selection.selectByCellId({ col: 2, row: 2 }); + 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) { @@ -198,16 +282,12 @@ export class Table extends ExcelComponent { this.isMouseDowned = false; } - removeRowHandler = () => { - const cell = this.selection.$focusedCell; - const cellId = getCellId(cell); - if (!cellId) return; + onContextmenu(event: MouseEvent) { + const $target = $(event.target); - const { row } = cellId; - const nodeToRemove = this.$root.find(`[data-row='${row}']`); - this.$root.removeChild(nodeToRemove); this.selection.clearSelection(); + this.selection.selectHeadRowCol($target); - this.dispatchToStore(removeRowFromTable(+row)); - }; + this.$emitEventToObserver('table:contextmenu', event); + } } diff --git a/src/components/table/TableSelection.ts b/src/components/table/TableSelection.ts index 987044d..1e1bc98 100644 --- a/src/components/table/TableSelection.ts +++ b/src/components/table/TableSelection.ts @@ -30,7 +30,7 @@ export class TableSelection { const range = new Range(); const node = $cell.$el; - range.setStartAfter(node.childNodes[0]); + range.setStartAfter(node.childNodes[node.childNodes.length - 1]); window.getSelection()?.removeAllRanges(); window.getSelection()?.addRange(range); @@ -143,4 +143,23 @@ export class TableSelection { isCellInSelection($cell: Dom) { return this.selectedCellsGroup.includes($cell); } + + selectHeadRowCol($target: Dom) { + const row = $target.closest('[data-header="row"]'); + const col = $target.closest('[data-header="col"]'); + const resizer = $target.closest('[data-resize]'); + + if (row.$el && !resizer.$el) { + const cells = row.closest('[data-row]').findAll('[data-type="cell"]'); + const $cells = Array.from(cells).map(cell => $(cell as HTMLElement)); + + this.selectGroupies($cells); + } else if (col.$el && !resizer.$el) { + const colNumber = col.data.col; + const columns = this.rootTable.$root.findAll(`[data-col="${colNumber}"]`); + const $cells = Array.from(columns).filter(el => el !== col.$el).map(el => $(el as HTMLElement)); + + this.selectGroupies($cells); + } + } } diff --git a/src/components/table/handlers/table.select.handler.ts b/src/components/table/handlers/table.select.handler.ts index d30c55c..2777afd 100644 --- a/src/components/table/handlers/table.select.handler.ts +++ b/src/components/table/handlers/table.select.handler.ts @@ -33,22 +33,7 @@ export function selectHandler(event: MouseEvent | KeyboardEvent, selection: Tabl else if (event.ctrlKey) selection.addCellToSelection(target); else selection.select(target); } else { - const row = target.closest('[data-header="row"]'); - const col = target.closest('[data-header="col"]'); - const resizer = target.closest('[data-resize]'); - - if (row.$el && !resizer.$el) { - const cells = row.closest('[data-row]').findAll('[data-type="cell"]'); - const $cells = Array.from(cells).map(cell => $(cell as HTMLElement)); - - selection.selectGroupies($cells); - } else if (col.$el && !resizer.$el) { - const colNumber = col.data.col; - const columns = selection.rootTable.$root.findAll(`[data-col="${colNumber}"]`); - const $cells = Array.from(columns).filter(el => el !== col.$el).map(el => $(el as HTMLElement)); - - selection.selectGroupies($cells); - } + selection.selectHeadRowCol(target); } } diff --git a/src/components/table/table.template.ts b/src/components/table/table.template.ts index 5c8569f..a603e9a 100644 --- a/src/components/table/table.template.ts +++ b/src/components/table/table.template.ts @@ -62,12 +62,3 @@ export function createTable(rowsCount = 10, columnCount = 10) { return rows.join(''); } - -export function getNewRowHTML(rowIndex: number, colCount: number): string { - const cells = new Array(colCount) - .fill('') - .map((el, colIndex) => createCell('', colIndex, rowIndex - 1)) - .join(''); - - return createRow(cells, rowIndex.toString(), true, rowIndex - 1); -} diff --git a/src/components/toolbar/Toolbar.ts b/src/components/toolbar/Toolbar.ts index b8d06c8..7656aa2 100644 --- a/src/components/toolbar/Toolbar.ts +++ b/src/components/toolbar/Toolbar.ts @@ -1,7 +1,7 @@ +import { BaseComponentOption } from 'components/excel/Excel'; import { startCellId } from 'components/table/table.functions'; import { $, Dom } from 'core/Dom'; import { ExcelComponentState } from 'core/ExcelComponentState'; -import { ComponentOptionsType } from 'core/ExcelComponent'; import { createToolbar } from 'components/toolbar/toolbar.template'; import { StateType } from 'redux/types'; import { fontSizes, initialStyleState } from 'src/constants'; @@ -9,7 +9,7 @@ import { fontSizes, initialStyleState } from 'src/constants'; export class Toolbar extends ExcelComponentState { static className = 'excel__toolbar'; - constructor($root: Dom, options: ComponentOptionsType) { + constructor($root: Dom, options: BaseComponentOption) { super($root, { ...options, eventListeners: ['click', 'change'], @@ -18,7 +18,7 @@ export class Toolbar extends ExcelComponentState { }); } - prepare() { + beforeRender() { const currentToolbarState = this.toolbarState; this.initComponentState(currentToolbarState); diff --git a/src/constants.ts b/src/constants.ts index f4ddbe3..9f1ffaf 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,8 +1,7 @@ import { startCellId } from 'components/table/table.functions'; import { ToolbarStateType } from 'components/toolbar/toolbar-types'; -import { storageName } from 'pages/ExcelPage'; import { StateType } from 'redux/types'; -import { storage } from 'core/utils'; +import { storage, storageName } from 'core/utils'; export const fontSizes = [ '12px', diff --git a/src/core/Clients.ts b/src/core/Clients.ts index ca9a73c..2b377af 100644 --- a/src/core/Clients.ts +++ b/src/core/Clients.ts @@ -1,5 +1,4 @@ -import { storage } from 'core/utils'; -import { storageName } from 'pages/ExcelPage'; +import { storage, storageName } from 'core/utils'; import { StateType } from 'redux/types'; import { getNormalizeInitialState } from 'src/constants'; diff --git a/src/core/ComponentManager.ts b/src/core/ComponentManager.ts new file mode 100644 index 0000000..8e07419 --- /dev/null +++ b/src/core/ComponentManager.ts @@ -0,0 +1,58 @@ +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); + 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()); + } +} diff --git a/src/core/Dom.ts b/src/core/Dom.ts index f6969b1..dcb370b 100644 --- a/src/core/Dom.ts +++ b/src/core/Dom.ts @@ -32,8 +32,8 @@ export class Dom implements DomClass { set text(text: string) { if (!this.$el) return; - if (!text) this.$el.textContent = ''; - this.$el.textContent = text; + if (!text) this.$el.innerText = ''; + this.$el.innerText = text; } get text() { @@ -47,7 +47,6 @@ export class Dom implements DomClass { return this; } - // FIXME: any append(node: HTMLElement | Dom) { let child = node; @@ -126,7 +125,7 @@ export class Dom implements DomClass { } attr(name: string, value: string) { - if (value) { + if (value !== undefined) { this.$el.setAttribute(name, value); return this; } @@ -138,6 +137,10 @@ export class Dom implements DomClass { this.$el.removeChild($child.$el); } + replaceChild($newChild: Dom, $oldChild: Dom) { + this.$el.replaceChild($newChild.$el, $oldChild.$el); + } + get isExist(): boolean { return !!this.$el; } diff --git a/src/core/DomListener.ts b/src/core/DomListener.ts index 78f6dc4..221225a 100644 --- a/src/core/DomListener.ts +++ b/src/core/DomListener.ts @@ -6,7 +6,7 @@ import { getMethodNameByEventName } from 'core/utils'; export class DomListener { $root: Dom; eventListeners: string[]; - protected name: string; + name: string; constructor($root: Dom, eventNames: string[]) { if (!$root) throw new Error('Не передали корневой элемент'); diff --git a/src/core/ExcelComponent.ts b/src/core/ExcelComponent.ts index 70f3912..364528b 100644 --- a/src/core/ExcelComponent.ts +++ b/src/core/ExcelComponent.ts @@ -1,35 +1,36 @@ +import { BaseComponentOption } from 'components/excel/Excel'; +import { ComponentManager } from 'core/ComponentManager'; import { ActionType, CallbackType, StateType } from 'redux/types'; import { Dom } from 'core/Dom'; import { DomListener } from 'core/DomListener'; import { Observer } from 'core/Observer'; import { Store } from 'core/store/Store'; -export type ComponentOptionsType = { +export type ComponentOptionsType = BaseComponentOption & { eventListeners: string[]; name: string; - observer: Observer; - store: Store; - subscribe: (keyof StateType)[], + subscribe?: (keyof StateType)[], }; export abstract class ExcelComponent extends DomListener { - private observer: Observer; + observer: Observer; public store: Store; private subscribe: (keyof StateType)[]; private unsubscribers: CallbackType[]; + private componentManager: ComponentManager; protected constructor($root: Dom, options: ComponentOptionsType) { super($root, options.eventListeners); this.name = options.name; this.observer = options.observer; this.store = options.store; - this.subscribe = options.subscribe; - + this.subscribe = options?.subscribe || []; + this.componentManager = options.componentManager; this.unsubscribers = []; - this.prepare(); + this.beforeRender(); } - prepare() { + beforeRender() { } @@ -58,7 +59,7 @@ export abstract class ExcelComponent extends DomListener { return this.subscribe?.includes(key); } - init() { + afterRender() { this.initDOMListeners(); } @@ -66,4 +67,8 @@ export abstract class ExcelComponent extends DomListener { this.removeDOMListeners(); this.unsubscribers.forEach(unsub => unsub()); } + + rerender() { + this.componentManager.rerenderComponent(this); + } } diff --git a/src/core/utils.ts b/src/core/utils.ts index ab4f05e..e9d73d7 100644 --- a/src/core/utils.ts +++ b/src/core/utils.ts @@ -68,10 +68,14 @@ export function getMethodNameByEventName(eventName: string): string { return `on${capitalize(eventName)}`; } -export function getCellId($cell: Dom): { row: string, col:string } | false { +export function getCellId($cell: Dom): { row: string, col: string } | false { if (!$cell.isExist) return false; const id = $cell.data.id?.split(':'); if (!Array.isArray(id)) return false; const [row, col] = id; return { row, col }; } + +export function storageName(param: string) { + return `excel:${param}`; +} diff --git a/src/pages/ExcelPage.ts b/src/pages/ExcelPage.ts index c59964f..a4af80c 100644 --- a/src/pages/ExcelPage.ts +++ b/src/pages/ExcelPage.ts @@ -1,3 +1,4 @@ +import { ContextMenu } from 'components/ContextMenu/ContextMenu'; import { AbstractPage } from 'pages/AbstractPage'; import { Excel } from 'components/excel/Excel'; import { Formula } from 'components/formula/Formula'; @@ -10,10 +11,6 @@ import { Table } from 'components/table/Table'; import { Toolbar } from 'components/toolbar/Toolbar'; import { rootReducer } from 'redux/rootReducer'; -export function storageName(param: string) { - return `excel:${param}`; -} - export class ExcelPage extends AbstractPage { private excel: Excel; private storeSub: SubscribeType | null; @@ -35,7 +32,7 @@ export class ExcelPage extends AbstractPage { this.storeSub = store.subscribeToStore(this.processor.listen); this.excel = new Excel({ - components: [Header, Toolbar, Formula, Table], + components: [Header, Toolbar, Formula, Table, ContextMenu], store, }); @@ -43,7 +40,7 @@ export class ExcelPage extends AbstractPage { } afterRender() { - this.excel.init(); + this.excel.afterRender(); } destroy() { diff --git a/src/redux/action-constants.ts b/src/redux/action-constants.ts index 6c853b8..11fbbc7 100644 --- a/src/redux/action-constants.ts +++ b/src/redux/action-constants.ts @@ -8,3 +8,6 @@ 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'; diff --git a/src/redux/action-creators.ts b/src/redux/action-creators.ts index 13a1d7f..f743fa7 100644 --- a/src/redux/action-creators.ts +++ b/src/redux/action-creators.ts @@ -7,7 +7,13 @@ import { APPLY_STYLES, CHANGE_TITLE, DELETE_TABLE, - UPDATE_DATE, CHANGE_CURRENT_TEXT, CHANGE_TABLE_SIZE, REMOVE_ROW_FROM_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 { @@ -73,9 +79,30 @@ export function changeTableSize(data: { col: number, row: number }): ActionType }; } +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, + }; +} diff --git a/src/redux/rootReducer.ts b/src/redux/rootReducer.ts index 86564a7..8dfc532 100644 --- a/src/redux/rootReducer.ts +++ b/src/redux/rootReducer.ts @@ -1,7 +1,7 @@ import { ToolbarStateType } from 'components/toolbar/toolbar-types'; +import { storageName } from 'core/utils'; import { ActionType, StateType } from 'redux/types'; import { ActiveRoute } from 'core/routes/ActiveRoute'; -import { storageName } from 'pages/ExcelPage'; import { CHANGE_TEXT, CHANGE_STYLES, @@ -9,7 +9,13 @@ import { APPLY_STYLES, CHANGE_TITLE, DELETE_TABLE, - UPDATE_DATE, CHANGE_CURRENT_TEXT, CHANGE_TABLE_SIZE, REMOVE_ROW_FROM_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 rootReducer(state: StateType, action: ActionType): StateType { @@ -75,6 +81,142 @@ export function rootReducer(state: StateType, action: ActionType): StateType { 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 }; @@ -135,10 +277,72 @@ export function rootReducer(state: StateType, action: ActionType): StateType { // tableSize const newTableSize = { col: state.tableSize.col, row: state.tableSize.row - 1 }; - // const newRowState = Object.keys(state.rowState).filter(rowIndex => rowIndex.toString() !== action.data.toString()). 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; } } diff --git a/src/redux/types.d.ts b/src/redux/types.d.ts index 750f76a..466539b 100644 --- a/src/redux/types.d.ts +++ b/src/redux/types.d.ts @@ -5,6 +5,11 @@ export type ActionType = { [k: string]: any }; +export type TableSizeType = { + col: number; + row: number; +}; + export type StateType = { colState: { [k: number]: number }; rowState: { [k: number]: number }; @@ -15,10 +20,7 @@ export type StateType = { stylesState: { [k: string]: ToolbarStateType }; title: string; currentText: string; - tableSize: { - col: number; - row: number; - }; + tableSize: TableSizeType; }; export type ReducerType = (state: StateType, action: ActionType) => StateType | null; diff --git a/src/styles/components/contextmenu.scss b/src/styles/components/contextmenu.scss new file mode 100644 index 0000000..d20b321 --- /dev/null +++ b/src/styles/components/contextmenu.scss @@ -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); + } + } +} diff --git a/src/styles/components/table.scss b/src/styles/components/table.scss index 28ed6a2..d8c8b35 100644 --- a/src/styles/components/table.scss +++ b/src/styles/components/table.scss @@ -9,6 +9,7 @@ top: $header-height + $toolbar-height + $formula-height; overflow: auto; font-size: $default-cell-font-size; + padding-bottom: 5px; .row{ display: flex; @@ -49,7 +50,7 @@ border: 1px solid #e1e2e3; border-top: 0; border-left: 0; - white-space: nowrap; + white-space: normal; overflow: hidden; outline: none; display: flex; @@ -93,7 +94,7 @@ } } [data-header="row"] { - cursor: e-resize; + cursor: pointer; &.selected { border-right: 2px solid #3c74ff; @@ -101,15 +102,21 @@ } [data-header="col"] { - cursor: n-resize; + cursor: pointer; &.selected { border-bottom: 2px solid #3c74ff; } } - [data-header="col"].selected, [data-header="row"].selected { - font-weight: bold; - background: rgba(60, 116, 255, 0.4); + [data-header="col"], [data-header="row"] { + &.selected { + font-weight: bold; + background: rgba(60, 116, 255, 0.4); + } + + &:hover { + background: #ccc; + } } } diff --git a/src/styles/index.scss b/src/styles/index.scss index 27e87bf..782d726 100644 --- a/src/styles/index.scss +++ b/src/styles/index.scss @@ -5,6 +5,7 @@ @import './components/toolbar'; @import './components/formula'; @import './components/table'; +@import './components/contextmenu'; @import './components/dashboard'; @import './components/loader';