├── .eslintignore ├── .eslintrc ├── .gitignore ├── babel.config.js ├── package-lock.json ├── package.json ├── requirements.txt ├── src ├── assets │ ├── dashboard.html │ └── excel.html ├── components │ ├── Loader.js │ ├── excel │ │ └── Excel.js │ ├── formula │ │ └── Formula.js │ ├── header │ │ └── Header.js │ ├── table │ │ ├── Table.js │ │ ├── TableSelection.js │ │ ├── table.functions.js │ │ ├── table.resize.js │ │ └── table.template.js │ └── toolbar │ │ ├── Toolbar.js │ │ ├── toolbar.template.js │ │ └── toolbare.template.js ├── constant.js ├── constants.js ├── core │ ├── DomListener.js │ ├── Emitter.js │ ├── ExcelComponent.js │ ├── ExcelStateComponent.js │ ├── StoreSubscriber.js │ ├── dom.js │ ├── page │ │ ├── Page.js │ │ └── StateProcessor.js │ ├── parse.js │ ├── routes │ │ ├── ActiveRoute.js │ │ ├── Router.js │ │ └── Router.spec.js │ ├── store │ │ ├── createStore.js │ │ └── createStore.spec.js │ └── utils.js ├── favicon.ico ├── index.html ├── index.js ├── pages │ ├── DashboardPage.js │ └── ExcelPage.js ├── redux │ ├── actions.js │ ├── initialState.js │ ├── rootReducer.js │ └── types.js ├── scss │ ├── _mixins.scss │ ├── _variables.scss │ ├── dashboard.scss │ ├── formula.scss │ ├── header.scss │ ├── index.scss │ ├── loader.scss │ ├── table.scss │ └── toolbar.scss └── shared │ ├── LocalStorageClient.js │ └── dashboard.functions.js └── webpack.config.js /.eslintignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frexxx-7/excel-cource/f04fd3bc1993cd4d8b6ca4af0367cc6b6fdc7e8c/.eslintignore -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser":"@babel/eslint-parser", 3 | "parserOptions": { 4 | "requireConfigFile":false 5 | }, 6 | "env":{ 7 | "es6":true, 8 | "browser":true, 9 | "node":true, 10 | "jest/globals": true 11 | }, 12 | "rules":{ 13 | "semi":"off", 14 | "arrow-parens":"off", 15 | "comma-dangle":"off", 16 | "linebreak-style": "off", 17 | "require-jsdoc":"off", 18 | "operator-linebreak":"off" 19 | }, 20 | "plugins": ["jest"], 21 | "extends": ["eslint:recommended", "google"] 22 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | [ 4 | '@babel/preset-env', 5 | { 6 | targets: { 7 | node: 'current' 8 | } 9 | } 10 | ] 11 | ], 12 | plugins: [] 13 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "excel-course", 3 | "version": "1.0.0", 4 | "description": "Pure JavaScript Excel Application", 5 | "main": "webpack.config.js", 6 | "scripts": { 7 | "test": "jest", 8 | "test:watch": "jest --watch", 9 | "dev": "cross-env NODE_ENV=development webpack --mode development", 10 | "start": "cross-env NODE_ENV=development webpack serve", 11 | "build": "cross-env NODE_ENV=production webpack --mode production", 12 | "watch": "cross-env NODE_ENV=development webpack --mode development --watch" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/frexxx-7/excel-cource.git" 17 | }, 18 | "keywords": [ 19 | "js", 20 | "javascript", 21 | "excel" 22 | ], 23 | "author": "Borzenko Kirill ", 24 | "license": "ISC", 25 | "bugs": { 26 | "url": "https://github.com/frexxx-7/excel-cource/issues" 27 | }, 28 | "homepage": "https://github.com/frexxx-7/excel-cource#readme", 29 | "private": true, 30 | "browserslist": "> 0.25%, not dead", 31 | "devDependencies": { 32 | "@babel/cli": "^7.20.7", 33 | "@babel/core": "^7.20.5", 34 | "@babel/eslint-parser": "^7.19.1", 35 | "@babel/preset-env": "^7.20.2", 36 | "@types/jest": "^29.2.4", 37 | "babel-loader": "^9.1.0", 38 | "clean-webpack-plugin": "^4.0.0", 39 | "copy-webpack-plugin": "^11.0.0", 40 | "cross-env": "^7.0.3", 41 | "css-loader": "^6.7.3", 42 | "eslint": "^7.32.0", 43 | "eslint-config-google": "^0.14.0", 44 | "eslint-webpack-plugin": "^3.2.0", 45 | "html-webpack-plugin": "^5.5.0", 46 | "jest": "^29.3.1", 47 | "jest-environment-jsdom": "^29.3.1", 48 | "jsdom": "^20.0.3", 49 | "lit-html": "^2.5.0", 50 | "mini-css-extract-plugin": "^2.7.2", 51 | "sass": "^1.57.0", 52 | "sass-loader": "^13.2.0", 53 | "webpack": "^5.75.0", 54 | "webpack-cli": "^5.0.0", 55 | "webpack-dev-server": "^4.11.1" 56 | }, 57 | "dependencies": { 58 | "eslint-plugin-jest": "^27.1.7", 59 | "normalize.css": "^8.0.1" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Webpack (imports/exports) 2 | Babel 3 | Scss 4 | Eslint 5 | 2 Modes 6 | - Dev 7 | Dev Server, SourceMaps, Eslint, Not Minified JS & CSS 8 | - Prod 9 | Minified Code 10 | Git 11 | 12 | Production 13 | 2.0.0 14 | 0 --------------- 15 | Development 16 | 2.0.2 17 | 1 ----------------- 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/assets/dashboard.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 7 | 8 | 9 | 10 | Pure JavaScript Excel 11 | 12 | 13 | 14 |
15 | 16 |
17 | 18 |
19 |

Excel Dashboard

20 |
21 | 22 | 29 | 30 |
31 | 32 |
33 | Название 34 | Дата открытия 35 |
36 | 37 | 50 | 51 |
52 | 53 |
54 | 55 |
56 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /src/assets/excel.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 7 | 8 | 9 | 10 | Pure JavaScript Excel 11 | 12 | 13 | 14 |
15 | 16 |
17 | 18 |
19 | 20 | 21 | 22 |
23 | 24 |
25 | delete 26 |
27 | 28 |
29 | exit_to_app 30 |
31 | 32 |
33 | 34 |
35 | 36 |
37 | 38 |
39 | format_align_left 40 |
41 | 42 |
43 | format_align_center 44 |
45 | 46 |
47 | format_align_right 48 |
49 | 50 |
51 | format_bold 52 |
53 | 54 |
55 | format_italic 56 |
57 | 58 |
59 | format_underlined 60 |
61 | 62 |
63 | 64 |
65 |
fx
66 |
67 |
68 | 69 |
70 | 71 |
72 | 73 |
74 | 75 |
76 |
77 | A 78 |
79 |
80 | B 81 |
82 |
83 | C 84 |
85 |
86 | A 87 |
88 |
89 | B 90 |
91 |
92 | C 93 |
94 |
95 | A 96 |
97 |
98 | B 99 |
100 |
101 | C 102 |
103 |
104 | A 105 |
106 |
107 | B 108 |
109 |
110 | C 111 |
112 |
113 | A 114 |
115 |
116 | B 117 |
118 |
119 | C 120 |
121 |
122 | A 123 |
124 |
125 | B 126 |
127 |
128 | C 129 |
130 |
131 | A 132 |
133 |
134 | B 135 |
136 |
137 | C 138 |
139 |
140 | A 141 |
142 |
143 | B 144 |
145 |
146 | C 147 |
148 |
149 | A 150 |
151 |
152 | B 153 |
154 |
155 | C 156 |
157 |
158 | A 159 |
160 |
161 | B 162 |
163 |
164 | C 165 |
166 | 167 |
168 | 169 |
170 | 171 |
172 |
173 | 1 174 |
175 | 176 |
177 |
A1
178 |
B2
179 |
C3
180 |
181 |
182 | 183 |
184 |
185 | 2 186 |
187 | 188 |
189 |
A1
190 |
B2
191 |
C3
192 |
193 |
194 | 195 |
196 | 197 |
198 | 199 |
200 | 201 | 202 | 203 | -------------------------------------------------------------------------------- /src/components/Loader.js: -------------------------------------------------------------------------------- 1 | import {$} from '../core/dom' 2 | 3 | export function Loader() { 4 | return $.create('div','loader').html(` 5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | `) 20 | } -------------------------------------------------------------------------------- /src/components/excel/Excel.js: -------------------------------------------------------------------------------- 1 | import {$} from '@core/dom' 2 | import {Emitter} from '@core/Emitter' 3 | import {StoreSubscriber} from '@core/StoreSubscriber' 4 | import {preventDefault} from '../../core/utils' 5 | import {updateDate} from '../../redux/actions' 6 | 7 | export class Excel { 8 | constructor(options) { 9 | this.components = options.components || [] 10 | this.store = options.store 11 | this.emitter = new Emitter() 12 | this.subscriber = new StoreSubscriber(this.store) 13 | } 14 | 15 | getRoot() { 16 | const $root = $.create('div', 'excel') 17 | 18 | const componentOptions = { 19 | emitter: this.emitter, 20 | store: this.store 21 | } 22 | 23 | this.components = this.components.map(Component => { 24 | const $el = $.create('div', Component.className) 25 | const component = new Component($el, componentOptions) 26 | $el.html(component.toHTML()) 27 | $root.append($el) 28 | return component 29 | }) 30 | 31 | return $root 32 | } 33 | 34 | init() { 35 | if (process.env.NODE_ENV === 'production') { 36 | document.addEventListener('contextmenu', preventDefault()) 37 | } 38 | this.store.dispatch(updateDate()) 39 | this.subscriber.subscribeComponents(this.components) 40 | this.components.forEach(component => component.init()) 41 | } 42 | 43 | destroy() { 44 | this.subscriber.unsubscribeFromStore() 45 | this.components.forEach(component => component.destroy()) 46 | document.removeEventListener('contextmenu', preventDefault) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/components/formula/Formula.js: -------------------------------------------------------------------------------- 1 | import {ExcelComponent} from '@core/ExcelComponent' 2 | import {$} from '@core/dom' 3 | 4 | export class Formula extends ExcelComponent { 5 | static className = 'excel__formula' 6 | 7 | constructor($root, options) { 8 | super($root, { 9 | name: 'Formula', 10 | listeners: ['input', 'keydown'], 11 | subscribe: ['currentText'], 12 | ...options 13 | }) 14 | } 15 | 16 | toHTML() { 17 | return ` 18 |
fx
19 |
20 | ` 21 | } 22 | 23 | init() { 24 | super.init() 25 | 26 | this.$formula = this.$root.find('#formula') 27 | 28 | this.$on('table:select', $cell => { 29 | this.$formula.text($cell.data.value) 30 | }) 31 | } 32 | 33 | storeChanged({currentText}) { 34 | this.$formula.text(currentText) 35 | } 36 | 37 | onInput(event) { 38 | const text = $(event.target).text() 39 | this.$emit('formula:input', text) 40 | } 41 | 42 | onKeydown(event) { 43 | const keys = ['Enter', 'Tab'] 44 | if (keys.includes(event.key)) { 45 | event.preventDefault() 46 | this.$emit('formula:done') 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/components/header/Header.js: -------------------------------------------------------------------------------- 1 | import {ExcelComponent} from '@core/ExcelComponent' 2 | import {$} from '@core/dom' 3 | import {changeTitle} from '@/redux/actions' 4 | import {defaultTitle} from '@/constants' 5 | import {debounce} from '@core/utils' 6 | import {ActiveRoute} from '../../core/routes/ActiveRoute' 7 | 8 | export class Header extends ExcelComponent { 9 | static className = 'excel__header' 10 | 11 | constructor($root, options) { 12 | super($root, { 13 | name: 'Header', 14 | listeners: ['input', 'click'], 15 | ...options, 16 | }) 17 | } 18 | 19 | prepare() { 20 | this.onInput = debounce(this.onInput, 300) 21 | } 22 | 23 | toHTML() { 24 | const title = this.store.getState().title || defaultTitle 25 | return ` 26 | 27 | 28 |
29 | 30 |
31 | delete 32 |
33 | 34 |
35 | exit_to_app 36 |
37 | 38 |
39 | ` 40 | } 41 | 42 | onClick(event) { 43 | const $target = $(event.target) 44 | 45 | if ($target.data.button === 'remove') { 46 | const decision = confirm('Вы действительно хотите удалить эту таблицу?') 47 | if (decision) { 48 | localStorage.removeItem('excel:' + ActiveRoute.param) 49 | ActiveRoute.navigate('') 50 | } 51 | } else if ($target.data.button === 'exit') { 52 | ActiveRoute.navigate('') 53 | } 54 | } 55 | 56 | onInput(event) { 57 | const $target = $(event.target) 58 | this.$dispatch(changeTitle($target.text())) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/components/table/Table.js: -------------------------------------------------------------------------------- 1 | import {ExcelComponent} from '@core/ExcelComponent' 2 | import {$} from '@core/dom' 3 | import {createTable} from '@/components/table/table.template' 4 | import {resizeHandler} from '@/components/table/table.resize' 5 | import {isCell, matrix, nextSelector, shouldResize} from './table.functions' 6 | import {TableSelection} from '@/components/table/TableSelection' 7 | import * as actions from '@/redux/actions' 8 | import {defaultStyles} from '@/constants' 9 | import {parse} from '@core/parse' 10 | 11 | export class Table extends ExcelComponent { 12 | static className = 'excel__table' 13 | 14 | constructor($root, options) { 15 | super($root, { 16 | name: 'Table', 17 | listeners: ['mousedown', 'keydown', 'input'], 18 | ...options 19 | }) 20 | } 21 | 22 | toHTML() { 23 | return createTable(20, this.store.getState()) 24 | } 25 | 26 | prepare() { 27 | this.selection = new TableSelection() 28 | } 29 | 30 | init() { 31 | super.init() 32 | 33 | this.selectCell(this.$root.find('[data-id="0:0"]')) 34 | 35 | this.$on('formula:input', value => { 36 | this.selection.current 37 | .attr('data-value', value) 38 | .text(parse(value)) 39 | this.updateTextInStore(value) 40 | }) 41 | 42 | this.$on('formula:done', () => { 43 | this.selection.current.focus() 44 | }) 45 | 46 | this.$on('toolbar:applyStyle', value => { 47 | this.selection.applyStyle(value) 48 | this.$dispatch(actions.applyStyle({ 49 | value, 50 | ids: this.selection.selectedIds 51 | })) 52 | }) 53 | } 54 | 55 | selectCell($cell) { 56 | this.selection.select($cell) 57 | this.$emit('table:select', $cell) 58 | const styles = $cell.getStyles(Object.keys(defaultStyles)) 59 | this.$dispatch(actions.changeStyles(styles)) 60 | } 61 | 62 | async resizeTable(event) { 63 | try { 64 | const data = await resizeHandler(this.$root, event) 65 | this.$dispatch(actions.tableResize(data)) 66 | } catch (e) { 67 | console.warn('Resize error', e.message) 68 | } 69 | } 70 | 71 | onMousedown(event) { 72 | if (shouldResize(event)) { 73 | this.resizeTable(event) 74 | } else if (isCell(event)) { 75 | const $target = $(event.target) 76 | if (event.shiftKey) { 77 | const $cells = matrix($target, this.selection.current) 78 | .map(id => this.$root.find(`[data-id="${id}"]`)) 79 | this.selection.selectGroup($cells) 80 | } else { 81 | this.selectCell($target) 82 | } 83 | } 84 | } 85 | 86 | onKeydown(event) { 87 | const keys = [ 88 | 'Enter', 89 | 'Tab', 90 | 'ArrowLeft', 91 | 'ArrowRight', 92 | 'ArrowDown', 93 | 'ArrowUp' 94 | ] 95 | 96 | const {key} = event 97 | 98 | if (keys.includes(key) && !event.shiftKey) { 99 | event.preventDefault() 100 | const id = this.selection.current.id(true) 101 | const $next = this.$root.find(nextSelector(key, id)) 102 | this.selectCell($next) 103 | } 104 | } 105 | 106 | updateTextInStore(value) { 107 | this.$dispatch(actions.changeText({ 108 | id: this.selection.current.id(), 109 | value 110 | })) 111 | } 112 | 113 | onInput(event) { 114 | this.updateTextInStore($(event.target).text()) 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/components/table/TableSelection.js: -------------------------------------------------------------------------------- 1 | export class TableSelection { 2 | static className = 'selected' 3 | 4 | constructor() { 5 | this.group = [] 6 | this.current = null 7 | } 8 | 9 | select($el) { 10 | this.clear() 11 | $el.focus().addClass(TableSelection.className) 12 | this.group.push($el) 13 | this.current = $el 14 | } 15 | 16 | clear() { 17 | this.group.forEach($el => $el.removeClass(TableSelection.className)) 18 | this.group = [] 19 | } 20 | 21 | get selectedIds() { 22 | return this.group.map($el => $el.id()) 23 | } 24 | 25 | selectGroup($group = []) { 26 | this.clear() 27 | 28 | this.group = $group 29 | this.group.forEach($el => $el.addClass(TableSelection.className)) 30 | } 31 | 32 | applyStyle(style) { 33 | this.group.forEach($el => $el.css(style)) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/components/table/table.functions.js: -------------------------------------------------------------------------------- 1 | import {range} from '@core/utils' 2 | 3 | export function shouldResize(event) { 4 | return event.target.dataset.resize 5 | } 6 | 7 | export function isCell(event) { 8 | return event.target.dataset.type === 'cell' 9 | } 10 | 11 | export function matrix($target, $current) { 12 | const target = $target.id(true) 13 | const current = $current.id(true) 14 | const cols = range(current.col, target.col) 15 | const rows = range(current.row, target.row) 16 | 17 | return cols.reduce((acc, col) => { 18 | rows.forEach(row => acc.push(`${row}:${col}`)) 19 | return acc 20 | }, []) 21 | } 22 | 23 | export function nextSelector(key, {col, row}) { 24 | const MIN_VALUE = 0 25 | switch (key) { 26 | case 'Enter': 27 | case 'ArrowDown': 28 | row++ 29 | break 30 | case 'Tab': 31 | case 'ArrowRight': 32 | col++ 33 | break 34 | case 'ArrowLeft': 35 | col = col - 1 < MIN_VALUE ? MIN_VALUE : col - 1 36 | break 37 | case 'ArrowUp': 38 | row = row - 1 < MIN_VALUE ? MIN_VALUE : row - 1 39 | break 40 | } 41 | 42 | return `[data-id="${row}:${col}"]` 43 | } 44 | -------------------------------------------------------------------------------- /src/components/table/table.resize.js: -------------------------------------------------------------------------------- 1 | import {$} from '@core/dom' 2 | 3 | export function resizeHandler($root, event) { 4 | return new Promise(resolve => { 5 | const $resizer = $(event.target) 6 | const $parent = $resizer.closest('[data-type="resizable"]') 7 | const coords = $parent.getCoords() 8 | const type = $resizer.data.resize 9 | const sideProp = type === 'col' ? 'bottom' : 'right' 10 | let value 11 | 12 | $resizer.css({ 13 | opacity: 1, 14 | [sideProp]: '-5000px' 15 | }) 16 | 17 | document.onmousemove = e => { 18 | if (type === 'col') { 19 | const delta = e.pageX - coords.right 20 | value = coords.width + delta 21 | $resizer.css({right: -delta + 'px'}) 22 | } else { 23 | const delta = e.pageY - coords.bottom 24 | value = coords.height + delta 25 | $resizer.css({bottom: -delta + 'px'}) 26 | } 27 | } 28 | 29 | document.onmouseup = () => { 30 | document.onmousemove = null 31 | document.onmouseup = null 32 | 33 | if (type === 'col') { 34 | $parent.css({width: value + 'px'}) 35 | $root.findAll(`[data-col="${$parent.data.col}"]`) 36 | .forEach(el => el.style.width = value + 'px') 37 | } else { 38 | $parent.css({height: value + 'px'}) 39 | } 40 | 41 | resolve({ 42 | value, 43 | type, 44 | id: $parent.data[type] 45 | }) 46 | 47 | $resizer.css({ 48 | opacity: 0, 49 | bottom: 0, 50 | right: 0 51 | }) 52 | } 53 | }) 54 | } 55 | -------------------------------------------------------------------------------- /src/components/table/table.template.js: -------------------------------------------------------------------------------- 1 | import {toInlineStyles} from '@core/utils' 2 | import {defaultStyles} from '@/constants' 3 | import {parse} from '@core/parse' 4 | 5 | const CODES = { 6 | A: 65, 7 | Z: 90 8 | } 9 | 10 | const DEFAULT_WIDTH = 120 11 | const DEFAULT_HEIGHT = 24 12 | 13 | function getWidth(state, index) { 14 | return (state[index] || DEFAULT_WIDTH) + 'px' 15 | } 16 | 17 | function getHeight(state, index) { 18 | return (state[index] || DEFAULT_HEIGHT) + 'px' 19 | } 20 | 21 | function toCell(state, row) { 22 | return function(_, col) { 23 | const id = `${row}:${col}` 24 | const width = getWidth(state.colState, col) 25 | const data = state.dataState[id] 26 | const styles = toInlineStyles({ 27 | ...defaultStyles, 28 | ...state.stylesState[id] 29 | }) 30 | return ` 31 |
${parse(data) || ''}
40 | ` 41 | } 42 | } 43 | 44 | function toColumn({col, index, width}) { 45 | return ` 46 |
52 | ${col} 53 |
54 |
55 | ` 56 | } 57 | 58 | function createRow(index, content, state = {}) { 59 | const resize = index ? '
' : '' 60 | const height = getHeight(state, index) 61 | return ` 62 |
68 |
69 | ${index ? index : ''} 70 | ${resize} 71 |
72 |
${content}
73 |
74 | ` 75 | } 76 | 77 | function toChar(_, index) { 78 | return String.fromCharCode(CODES.A + index) 79 | } 80 | 81 | function withWidthFrom(state) { 82 | return function(col, index) { 83 | return { 84 | col, index, width: getWidth(state.colState, index) 85 | } 86 | } 87 | } 88 | 89 | export function createTable(rowsCount = 15, state = {}) { 90 | const colsCount = CODES.Z - CODES.A + 1 // Compute cols count 91 | const rows = [] 92 | 93 | const cols = new Array(colsCount) 94 | .fill('') 95 | .map(toChar) 96 | .map(withWidthFrom(state)) 97 | .map(toColumn) 98 | .join('') 99 | 100 | rows.push(createRow(null, cols)) 101 | 102 | for (let row = 0; row < rowsCount; row++) { 103 | const cells = new Array(colsCount) 104 | .fill('') 105 | .map(toCell(state, row)) 106 | .join('') 107 | 108 | rows.push(createRow(row + 1, cells, state.rowState)) 109 | } 110 | 111 | return rows.join('') 112 | } 113 | -------------------------------------------------------------------------------- /src/components/toolbar/Toolbar.js: -------------------------------------------------------------------------------- 1 | import {createToolbar} from '@/components/toolbar/toolbar.template' 2 | import {$} from '@core/dom' 3 | import {ExcelStateComponent} from '@core/ExcelStateComponent' 4 | import {defaultStyles} from '@/constants' 5 | 6 | export class Toolbar extends ExcelStateComponent { 7 | static className = 'excel__toolbar' 8 | 9 | constructor($root, options) { 10 | super($root, { 11 | name: 'Toolbar', 12 | listeners: ['click'], 13 | subscribe: ['currentStyles'], 14 | ...options 15 | }) 16 | } 17 | 18 | prepare() { 19 | this.initState(defaultStyles) 20 | } 21 | 22 | get template() { 23 | return createToolbar(this.state) 24 | } 25 | 26 | toHTML() { 27 | return this.template 28 | } 29 | 30 | storeChanged(changes) { 31 | this.setState(changes.currentStyles) 32 | } 33 | 34 | onClick(event) { 35 | const $target = $(event.target) 36 | if ($target.data.type === 'button') { 37 | const value = JSON.parse($target.data.value) 38 | this.$emit('toolbar:applyStyle', value) 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/components/toolbar/toolbar.template.js: -------------------------------------------------------------------------------- 1 | function toButton(button) { 2 | const meta = ` 3 | data-type="button" 4 | data-value='${JSON.stringify(button.value)}' 5 | ` 6 | return ` 7 |
11 | ${button.icon} 15 |
16 | ` 17 | } 18 | 19 | export function createToolbar(s) { 20 | const buttons = [ 21 | { 22 | value: {textAlign: 'left'}, 23 | icon: 'format_align_left', 24 | active: s['textAlign'] === 'left' 25 | }, 26 | { 27 | value: {textAlign: 'center'}, 28 | icon: 'format_align_justify', 29 | active: s['textAlign'] === 'center' 30 | }, 31 | { 32 | value: {textAlign: 'right'}, 33 | icon: 'format_align_right', 34 | active: s['textAlign'] === 'right' 35 | }, 36 | { 37 | value: {fontWeight: s['fontWeight'] === 'bold' ? 'normal' : 'bold'}, 38 | icon: 'format_bold', 39 | active: s['fontWeight'] === 'bold' 40 | }, 41 | { 42 | value: { 43 | textDecoration: s['textDecoration'] === 'underline' 44 | ? 'none' 45 | : 'underline' 46 | }, 47 | icon: 'format_underlined', 48 | active: s['textDecoration'] === 'underline' 49 | }, 50 | { 51 | value: {fontStyle: s['fontStyle'] === 'italic' ? 'normal' : 'italic'}, 52 | icon: 'format_italic', 53 | active: s['fontStyle'] === 'italic' 54 | } 55 | ] 56 | return buttons.map(toButton).join('') 57 | } 58 | -------------------------------------------------------------------------------- /src/components/toolbar/toolbare.template.js: -------------------------------------------------------------------------------- 1 | function toButton(button) { 2 | const meta = ` 3 | data-type="button" 4 | data-value='${JSON.stringify(button.value)}' 5 | ` 6 | return ` 7 |
11 | ${button.icon} 15 |
16 | ` 17 | } 18 | 19 | export function createToolbar(s) { 20 | const buttons = [ 21 | { 22 | icon: 'format_align_left', 23 | active: s['textAlign'] === 'left', 24 | value: {textAlign: 'left'} 25 | }, 26 | { 27 | icon: 'format_align_center', 28 | active: s['textAlign'] === 'center', 29 | value: {textAlign: 'center'} 30 | }, 31 | { 32 | icon: 'format_align_right', 33 | active: s['textAlign'] === 'right', 34 | value: {textAlign: 'right'} 35 | }, 36 | { 37 | icon: 'format_bold', 38 | active: s['fontWeight'] === 'bold', 39 | value: {fontWeight: s['fontWeight'] === 'bold' ? 'normal' : 'bold'} 40 | }, 41 | { 42 | icon: 'format_underline', 43 | active: s['textDecoration'] === 'underline', 44 | value: {textDecoration: s['textDecoration'] === 'underline' 45 | ? 'none' 46 | : 'underline'} 47 | }, 48 | { 49 | icon: 'format_italic', 50 | active: s['fontStyle']==='italic', 51 | value: {fontStyle: s['fontStyle'] === 'italic' ? 'normal' : 'italic'} 52 | } 53 | ] 54 | return buttons.map(toButton).join('') 55 | } 56 | -------------------------------------------------------------------------------- /src/constant.js: -------------------------------------------------------------------------------- 1 | export const defaultStyles = { 2 | textAlign: 'left', 3 | fontWeight: 'normal', 4 | textDecoration: 'none', 5 | fontStyle: 'normal' 6 | } 7 | 8 | export const defaultTitle ='Новая таблица' 9 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | export const defaultStyles = { 2 | textAlign: 'left', 3 | fontWeight: 'normal', 4 | textDecoration: 'none', 5 | fontStyle: 'normal' 6 | } 7 | 8 | export const defaultTitle = 'Новая таблица' 9 | -------------------------------------------------------------------------------- /src/core/DomListener.js: -------------------------------------------------------------------------------- 1 | import {capitalize} from '@core/utils' 2 | 3 | export class DomListener { 4 | constructor($root, listeners = []) { 5 | if (!$root) { 6 | throw new Error(`No $root provided for DomListener!`) 7 | } 8 | this.$root = $root 9 | this.listeners = listeners 10 | } 11 | 12 | initDOMListeners() { 13 | this.listeners.forEach(listener => { 14 | const method = getMethodName(listener) 15 | if (!this[method]) { 16 | const name = this.name || '' 17 | throw new Error( 18 | `Method ${method} is not implemented in ${name} Component` 19 | ) 20 | } 21 | this[method] = this[method].bind(this) 22 | // Тоже самое что и addEventListener 23 | this.$root.on(listener, this[method]) 24 | }) 25 | } 26 | 27 | removeDOMListeners() { 28 | this.listeners.forEach(listener => { 29 | const method = getMethodName(listener) 30 | this.$root.off(listener, this[method]) 31 | }) 32 | } 33 | } 34 | 35 | // input => onInput 36 | function getMethodName(eventName) { 37 | return 'on' + capitalize(eventName) 38 | } 39 | 40 | 41 | -------------------------------------------------------------------------------- /src/core/Emitter.js: -------------------------------------------------------------------------------- 1 | export class Emitter { 2 | constructor() { 3 | this.listeners = {} 4 | } 5 | 6 | // dispatch, fire, trigger 7 | // Уведомляем слушателе если они есть 8 | // table.emit('table:select', {a: 1}) 9 | emit(event, ...args) { 10 | if (!Array.isArray(this.listeners[event])) { 11 | return false 12 | } 13 | this.listeners[event].forEach(listener => { 14 | listener(...args) 15 | }) 16 | return true 17 | } 18 | 19 | // on, listen 20 | // Подписываемся на уведомление 21 | // Добавляем нового слушателя 22 | // formula.subscribe('table:select', () => {}) 23 | subscribe(event, fn) { 24 | this.listeners[event] = this.listeners[event] || [] 25 | this.listeners[event].push(fn) 26 | return () => { 27 | this.listeners[event] = 28 | this.listeners[event].filter(listener => listener !== fn) 29 | } 30 | } 31 | } 32 | 33 | // Example 34 | // const emitter = new Emitter() 35 | // 36 | // const unsub = emitter.subscribe('vladilen', data => console.log(data)) 37 | // emitter.emit('1231231', 42) 38 | // 39 | // setTimeout(() => { 40 | // emitter.emit('vladilen', 'After 2 seconds') 41 | // }, 2000) 42 | // 43 | // setTimeout(() => { 44 | // unsub() 45 | // }, 3000) 46 | // 47 | // setTimeout(() => { 48 | // emitter.emit('vladilen', 'After 4 seconds') 49 | // }, 4000) 50 | -------------------------------------------------------------------------------- /src/core/ExcelComponent.js: -------------------------------------------------------------------------------- 1 | import {DomListener} from '@core/DomListener' 2 | 3 | export class ExcelComponent extends DomListener { 4 | constructor($root, options = {}) { 5 | super($root, options.listeners) 6 | this.name = options.name || '' 7 | this.emitter = options.emitter 8 | this.subscribe = options.subscribe || [] 9 | this.store = options.store 10 | this.unsubscribers = [] 11 | 12 | this.prepare() 13 | } 14 | 15 | // Настраивааем наш компонент до init 16 | prepare() {} 17 | 18 | // Возвращает шаблон компонента 19 | toHTML() { 20 | return '' 21 | } 22 | 23 | // Уведомляем слушателей про событие event 24 | $emit(event, ...args) { 25 | this.emitter.emit(event, ...args) 26 | } 27 | 28 | // Подписываемся на событие event 29 | $on(event, fn) { 30 | const unsub = this.emitter.subscribe(event, fn) 31 | this.unsubscribers.push(unsub) 32 | } 33 | 34 | $dispatch(action) { 35 | this.store.dispatch(action) 36 | } 37 | 38 | // Сюда приходят только изменения по тем полям, на которые мы подписались 39 | storeChanged() {} 40 | 41 | isWatching(key) { 42 | return this.subscribe.includes(key) 43 | } 44 | 45 | // Инициализируем компонент 46 | // Добавляем DOM слушателей 47 | init() { 48 | this.initDOMListeners() 49 | } 50 | 51 | // Удаляем компонент 52 | // Чистим слушатели 53 | destroy() { 54 | this.removeDOMListeners() 55 | this.unsubscribers.forEach(unsub => unsub()) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/core/ExcelStateComponent.js: -------------------------------------------------------------------------------- 1 | import {ExcelComponent} from '@core/ExcelComponent' 2 | 3 | export class ExcelStateComponent extends ExcelComponent { 4 | constructor(...args) { 5 | super(...args) 6 | } 7 | 8 | get template() { 9 | return JSON.stringify(this.state, null, 2) 10 | } 11 | 12 | initState(initialState = {}) { 13 | this.state = {...initialState} 14 | } 15 | 16 | setState(newState) { 17 | this.state = {...this.state, ...newState} 18 | this.$root.html(this.template) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/core/StoreSubscriber.js: -------------------------------------------------------------------------------- 1 | import {isEqual} from '@core/utils' 2 | 3 | export class StoreSubscriber { 4 | constructor(store) { 5 | this.store = store 6 | this.sub = null 7 | this.prevState = {} 8 | } 9 | 10 | subscribeComponents(components) { 11 | this.prevState = this.store.getState() 12 | 13 | this.sub = this.store.subscribe(state => { 14 | Object.keys(state).forEach(key => { 15 | if (!isEqual(this.prevState[key], state[key])) { 16 | components.forEach(component => { 17 | if (component.isWatching(key)) { 18 | const changes = {[key]: state[key]} 19 | component.storeChanged(changes) 20 | } 21 | }) 22 | } 23 | }) 24 | 25 | this.prevState = this.store.getState() 26 | 27 | if (process.env.NODE_ENV === 'development') { 28 | window['redux'] = this.prevState 29 | } 30 | }) 31 | } 32 | 33 | unsubscribeFromStore() { 34 | this.sub.unsubscribe() 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/core/dom.js: -------------------------------------------------------------------------------- 1 | class Dom { 2 | constructor(selector) { 3 | this.$el = typeof selector === 'string' 4 | ? document.querySelector(selector) 5 | : selector 6 | } 7 | 8 | html(html) { 9 | if (typeof html === 'string') { 10 | this.$el.innerHTML = html 11 | return this 12 | } 13 | return this.$el.outerHTML.trim() 14 | } 15 | 16 | text(text) { 17 | if (typeof text !== 'undefined') { 18 | this.$el.textContent = text 19 | return this 20 | } 21 | if (this.$el.tagName.toLowerCase() === 'input') { 22 | return this.$el.value.trim() 23 | } 24 | return this.$el.textContent.trim() 25 | } 26 | 27 | clear() { 28 | this.html('') 29 | return this 30 | } 31 | 32 | on(eventType, callback) { 33 | this.$el.addEventListener(eventType, callback) 34 | } 35 | 36 | off(eventType, callback) { 37 | this.$el.removeEventListener(eventType, callback) 38 | } 39 | 40 | find(selector) { 41 | return $(this.$el.querySelector(selector)) 42 | } 43 | 44 | append(node) { 45 | if (node instanceof Dom) { 46 | node = node.$el 47 | } 48 | 49 | if (Element.prototype.append) { 50 | this.$el.append(node) 51 | } else { 52 | this.$el.appendChild(node) 53 | } 54 | 55 | return this 56 | } 57 | 58 | get data() { 59 | return this.$el.dataset 60 | } 61 | 62 | closest(selector) { 63 | return $(this.$el.closest(selector)) 64 | } 65 | 66 | getCoords() { 67 | return this.$el.getBoundingClientRect() 68 | } 69 | 70 | findAll(selector) { 71 | return this.$el.querySelectorAll(selector) 72 | } 73 | 74 | css(styles = {}) { 75 | Object 76 | .keys(styles) 77 | .forEach(key => { 78 | this.$el.style[key] = styles[key] 79 | }) 80 | } 81 | 82 | getStyles(styles = []) { 83 | return styles.reduce((res, s) => { 84 | res[s] = this.$el.style[s] 85 | return res 86 | }, {}) 87 | } 88 | 89 | id(parse) { 90 | if (parse) { 91 | const parsed = this.id().split(':') 92 | return { 93 | row: +parsed[0], 94 | col: +parsed[1] 95 | } 96 | } 97 | return this.data.id 98 | } 99 | 100 | focus() { 101 | this.$el.focus() 102 | return this 103 | } 104 | 105 | attr(name, value) { 106 | if (value) { 107 | this.$el.setAttribute(name, value) 108 | return this 109 | } 110 | return this.$el.getAttribute(name) 111 | } 112 | 113 | addClass(className) { 114 | this.$el.classList.add(className) 115 | return this 116 | } 117 | 118 | removeClass(className) { 119 | this.$el.classList.remove(className) 120 | return this 121 | } 122 | } 123 | 124 | export function $(selector) { 125 | return new Dom(selector) 126 | } 127 | 128 | $.create = (tagName, classes = '') => { 129 | const el = document.createElement(tagName) 130 | if (classes) { 131 | el.classList.add(classes) 132 | } 133 | return $(el) 134 | } 135 | -------------------------------------------------------------------------------- /src/core/page/Page.js: -------------------------------------------------------------------------------- 1 | export class Page { 2 | constructor(params) { 3 | this.params = params || Date.now().toString() 4 | } 5 | 6 | getRoot() { 7 | throw new Error('Method "getRoot" should be implemented') 8 | } 9 | 10 | afterRender() {} 11 | 12 | destroy() {} 13 | } 14 | -------------------------------------------------------------------------------- /src/core/page/StateProcessor.js: -------------------------------------------------------------------------------- 1 | import {debounce} from '../utils' 2 | 3 | export class StateProcessor { 4 | constructor(client, delay = 300) { 5 | this.client = client 6 | this.listen = debounce(this.listen.bind(this), delay) 7 | } 8 | 9 | listen(state) { 10 | this.client.save(state) 11 | } 12 | 13 | get() { 14 | return this.client.get() 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/core/parse.js: -------------------------------------------------------------------------------- 1 | export function parse(value = '') { 2 | if (value.startsWith('=')) { 3 | try { 4 | return eval(value.slice(1)) 5 | } catch (e) { 6 | return value 7 | } 8 | } 9 | return value 10 | } 11 | 12 | -------------------------------------------------------------------------------- /src/core/routes/ActiveRoute.js: -------------------------------------------------------------------------------- 1 | export class ActiveRoute { 2 | static get path() { 3 | return window.location.hash.slice(1) 4 | } 5 | 6 | static get param() { 7 | return ActiveRoute.path.split('/')[1] 8 | } 9 | 10 | static navigate(path) { 11 | window.location.hash = path 12 | } 13 | } 14 | 15 | -------------------------------------------------------------------------------- /src/core/routes/Router.js: -------------------------------------------------------------------------------- 1 | import {Loader} from '../../components/Loader' 2 | import {$} from '../dom' 3 | import {ActiveRoute} from './ActiveRoute' 4 | 5 | export class Router { 6 | constructor(selector, routes) { 7 | if (!selector) { 8 | throw new Error('Selector is not provided in Router') 9 | } 10 | 11 | this.$placeholder = $(selector) 12 | this.routes = routes 13 | 14 | this.loader = new Loader() 15 | 16 | this.page = null 17 | 18 | this.changePageHandler = this.changePageHandler.bind(this) 19 | 20 | this.init() 21 | } 22 | 23 | init() { 24 | window.addEventListener('hashchange', this.changePageHandler) 25 | this.changePageHandler() 26 | } 27 | 28 | async changePageHandler() { 29 | if (this.page) { 30 | this.page.destroy() 31 | } 32 | this.$placeholder.clear().append(this.loader) 33 | 34 | const Page = ActiveRoute.path.includes('excel') 35 | ? this.routes.excel 36 | : this.routes.dashboard 37 | 38 | this.page = new Page(ActiveRoute.param) 39 | const root = await this.page.getRoot() 40 | 41 | this.$placeholder.clear().append(root) 42 | 43 | this.page.afterRender() 44 | } 45 | 46 | destroy() { 47 | window.removeEventListener('hashchange', this.changePageHandler) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/core/routes/Router.spec.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | import {Page} from '../Page' 5 | import {Router} from './Router' 6 | 7 | class DashboardPage extends Page { 8 | getRoot() { 9 | const root = document.createElement('div') 10 | root.innerHTML = 'dashboard' 11 | return root 12 | } 13 | } 14 | class ExcelPage extends Page{} 15 | 16 | describe('Router:', () => { 17 | let router 18 | let $root 19 | 20 | beforeEach(() => { 21 | $root = document.createElement('div') 22 | router = new Router($root, { 23 | dashboard: DashboardPage, 24 | excel: ExcelPage 25 | }) 26 | }) 27 | 28 | test('should be defined', () => { 29 | expect(router).toBeDefined() 30 | }) 31 | 32 | test('should render Dashboard Page', () => { 33 | router.changePageHandler() 34 | expect($root.innerHTML).toBe('
dashboard
') 35 | }) 36 | }) -------------------------------------------------------------------------------- /src/core/store/createStore.js: -------------------------------------------------------------------------------- 1 | export function createStore(rootReducer, initialState = {}) { 2 | let state = rootReducer({...initialState}, {type: '__INIT__'}) 3 | let listeners = [] 4 | 5 | return { 6 | subscribe(fn) { 7 | listeners.push(fn) 8 | return { 9 | unsubscribe() { 10 | listeners = listeners.filter(l => l !== fn) 11 | } 12 | } 13 | }, 14 | dispatch(action) { 15 | state = rootReducer(state, action) 16 | listeners.forEach(listener => listener(state)) 17 | }, 18 | getState() { 19 | return JSON.parse(JSON.stringify(state)) 20 | } 21 | } 22 | } 23 | 24 | // Extra Task - Переписать на класс 25 | -------------------------------------------------------------------------------- /src/core/store/createStore.spec.js: -------------------------------------------------------------------------------- 1 | import {createStore} from './createStore' 2 | 3 | const initialState = { 4 | count: 0 5 | } 6 | 7 | const reducer = (state=initialState, action) => { 8 | if (action.type === 'ADD') { 9 | return {...state, count: state.count+1} 10 | } 11 | return state 12 | } 13 | 14 | describe('createStore:', () => { 15 | let store 16 | let handler 17 | 18 | beforeEach(()=> { 19 | store = createStore(reducer, initialState) 20 | handler = jest.fn() 21 | }) 22 | 23 | test('should return store object', () => { 24 | expect(store).toBeDefined() 25 | expect(store.dispatch).toBeDefined() 26 | expect(store.subscribe).toBeDefined() 27 | expect(store.getState).not.toBeUndefined() 28 | }) 29 | 30 | test('should return object as a state', () => { 31 | expect(store.getState()).toBeInstanceOf(Object) 32 | }) 33 | 34 | test('should return default state', () => { 35 | expect(store.getState()).toEqual(initialState) 36 | }) 37 | 38 | test('should change state if action exists', () => { 39 | store.dispatch({type:'ADD'}) 40 | expect(store.getState().count).toBe(1) 41 | }) 42 | 43 | test('should NOT change state if action don\'t exists', () => { 44 | store.dispatch({type:'NOT_EXISTING_ACTION'}) 45 | expect(store.getState().count).toBe(0) 46 | }) 47 | 48 | test('should call subscriber function', () => { 49 | store.subscribe(handler) 50 | store.dispatch({type:'ADD'}) 51 | 52 | expect(handler).toHaveBeenCalled() 53 | expect(handler).toHaveBeenCalledWith(store.getState()) 54 | }) 55 | 56 | test('should NOT call sub if unsubscribe', () => { 57 | const sub = store.subscribe(handler) 58 | 59 | sub.unsubscribe() 60 | 61 | store.dispatch({type:'ADD'}) 62 | 63 | expect(handler).not.toHaveBeenCalled() 64 | }) 65 | 66 | test('should dispatch in async way', () => { 67 | return new Promise(resolve => { 68 | setTimeout(() => { 69 | store.dispatch({type:'ADD'}) 70 | }, 500) 71 | setTimeout(() => { 72 | expect(store.getState().count).toBe(1) 73 | resolve() 74 | }, 1000); 75 | }) 76 | }) 77 | }) 78 | -------------------------------------------------------------------------------- /src/core/utils.js: -------------------------------------------------------------------------------- 1 | // Pure functions 2 | export function capitalize(string) { 3 | if (typeof string !== 'string') { 4 | return '' 5 | } 6 | return string.charAt(0).toUpperCase() + string.slice(1) 7 | } 8 | 9 | export function range(start, end) { 10 | if (start > end) { 11 | [end, start] = [start, end] 12 | } 13 | return new Array(end - start + 1) 14 | .fill('') 15 | .map((_, index) => start + index) 16 | } 17 | 18 | export function storage(key, data = null) { 19 | if (!data) { 20 | return JSON.parse(localStorage.getItem(key)) 21 | } 22 | localStorage.setItem(key, JSON.stringify(data)) 23 | } 24 | 25 | export function isEqual(a, b) { 26 | if (typeof a === 'object' && typeof b === 'object') { 27 | return JSON.stringify(a) === JSON.stringify(b) 28 | } 29 | return a === b 30 | } 31 | 32 | export function camelToDashCase(str) { 33 | return str.replace(/([A-Z])/g, g => `-${g[0].toLowerCase()}`) 34 | } 35 | 36 | export function toInlineStyles(styles = {}) { 37 | return Object.keys(styles) 38 | .map(key => `${camelToDashCase(key)}: ${styles[key]}`) 39 | .join(';') 40 | } 41 | 42 | export function debounce(fn, wait) { 43 | let timeout 44 | return function(...args) { 45 | const later = () => { 46 | clearTimeout(timeout) 47 | // eslint-disable-next-line 48 | fn.apply(this, args) 49 | } 50 | clearTimeout(timeout) 51 | timeout = setTimeout(later, wait) 52 | } 53 | } 54 | 55 | export function clone(obj) { 56 | return JSON.parse(JSON.stringify(obj)) 57 | } 58 | 59 | export function preventDefault(event) { 60 | event.preventDefault() 61 | } 62 | -------------------------------------------------------------------------------- /src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frexxx-7/excel-cource/f04fd3bc1993cd4d8b6ca4af0367cc6b6fdc7e8c/src/favicon.ico -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 7 | 8 | 9 | 10 | Pure JavaScript Excel 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import {Router} from './core/routes/router' 2 | import {DashboardPage} from './pages/DashboardPage' 3 | import {ExcelPage} from './pages/ExcelPage' 4 | import './scss/index.scss' 5 | 6 | new Router('#app', { 7 | dashboard: DashboardPage, 8 | excel: ExcelPage 9 | }) 10 | -------------------------------------------------------------------------------- /src/pages/DashboardPage.js: -------------------------------------------------------------------------------- 1 | import {$} from '../core/dom' 2 | import {Page} from '../core/page/Page' 3 | import {createRecordsTable} from '../shared/dashboard.functions' 4 | 5 | export class DashboardPage extends Page { 6 | 7 | getRoot() { 8 | const now = Date.now().toString() 9 | return $.create('div', 'db').html(` 10 |
11 |

Excel Панель Упраления

12 |
13 | 14 |
15 | 20 |
21 | 22 |
23 | ${createRecordsTable()} 24 |
25 | `) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/pages/ExcelPage.js: -------------------------------------------------------------------------------- 1 | import {Excel} from '@/components/excel/Excel' 2 | import {Header} from '@/components/header/Header' 3 | import {Toolbar} from '@/components/toolbar/Toolbar' 4 | import {Formula} from '@/components/formula/Formula' 5 | import {Table} from '@/components/table/Table' 6 | import {createStore} from '@core/store/createStore' 7 | import {rootReducer} from '@/redux/rootReducer' 8 | import {Page} from '../core/page/Page' 9 | import {normalizeInitialState} from '../redux/initialState' 10 | import {StateProcessor} from '../core/page/StateProcessor' 11 | import {LocalStorageClient} from '../shared/LocalStorageClient' 12 | 13 | export class ExcelPage extends Page { 14 | constructor(param) { 15 | super(param) 16 | 17 | this.storeSub = null 18 | this.processor = new StateProcessor( 19 | new LocalStorageClient(this.params) 20 | ) 21 | } 22 | 23 | async getRoot() { 24 | const state = await this.processor.get() 25 | const initialState = normalizeInitialState(state) 26 | const store = createStore(rootReducer, initialState) 27 | 28 | this.storeSub = store.subscribe(this.processor.listen) 29 | 30 | this.excel = new Excel({ 31 | components: [Header, Toolbar, Formula, Table], 32 | store 33 | }) 34 | 35 | return this.excel.getRoot() 36 | } 37 | 38 | afterRender() { 39 | this.excel.init() 40 | } 41 | 42 | destroy() { 43 | this.excel.destroy() 44 | this.storeSub.unsubscribe() 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/redux/actions.js: -------------------------------------------------------------------------------- 1 | import { 2 | CHANGE_TEXT, 3 | CHANGE_STYLES, 4 | TABLE_RESIZE, 5 | APPLY_STYLE, 6 | CHANGE_TITLE, 7 | UPDATE_DATE 8 | } from './types' 9 | 10 | // Action Creator 11 | export function tableResize(data) { 12 | return { 13 | type: TABLE_RESIZE, 14 | data 15 | } 16 | } 17 | 18 | export function changeText(data) { 19 | return { 20 | type: CHANGE_TEXT, 21 | data 22 | } 23 | } 24 | 25 | export function updateDate() { 26 | return { 27 | type: UPDATE_DATE 28 | } 29 | } 30 | 31 | export function changeStyles(data) { 32 | return { 33 | type: CHANGE_STYLES, 34 | data 35 | } 36 | } 37 | 38 | // value, ids 39 | export function applyStyle(data) { 40 | return { 41 | type: APPLY_STYLE, 42 | data 43 | } 44 | } 45 | 46 | export function changeTitle(data) { 47 | return { 48 | type: CHANGE_TITLE, 49 | data 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/redux/initialState.js: -------------------------------------------------------------------------------- 1 | import {defaultStyles, defaultTitle} from '@/constants' 2 | import {clone} from '../core/utils' 3 | 4 | const defaultState = { 5 | title: defaultTitle, 6 | rowState: {}, 7 | colState: {}, 8 | dataState: {}, 9 | stylesState: {}, 10 | currentText: '', 11 | currentStyles: defaultStyles, 12 | openedDate: new Date().toJSON() 13 | } 14 | 15 | const normalize = state => ({ 16 | ...state, 17 | currentStyles: defaultStyles, 18 | currentText: '' 19 | }) 20 | 21 | export function normalizeInitialState(state) { 22 | return state ? normalize(state) : clone(defaultState) 23 | } 24 | -------------------------------------------------------------------------------- /src/redux/rootReducer.js: -------------------------------------------------------------------------------- 1 | import { 2 | CHANGE_TEXT, 3 | CHANGE_STYLES, 4 | TABLE_RESIZE, 5 | APPLY_STYLE, 6 | CHANGE_TITLE, 7 | UPDATE_DATE 8 | } from './types' 9 | 10 | export function rootReducer(state, action) { 11 | let field 12 | let val 13 | switch (action.type) { 14 | case TABLE_RESIZE: 15 | field = action.data.type === 'col' ? 'colState' : 'rowState' 16 | return {...state, [field]: value(state, field, action)} 17 | case CHANGE_TEXT: 18 | field = 'dataState' 19 | return { 20 | ...state, 21 | currentText: action.data.value, 22 | [field]: value(state, field, action) 23 | } 24 | case CHANGE_STYLES: 25 | return {...state, currentStyles: action.data} 26 | case APPLY_STYLE: 27 | field = 'stylesState' 28 | val = state[field] || {} 29 | action.data.ids.forEach(id => { 30 | val[id] = {...val[id], ...action.data.value} 31 | }) 32 | return { 33 | ...state, 34 | [field]: val, 35 | currentStyles: {...state.currentStyles, ...action.data.value} 36 | } 37 | case CHANGE_TITLE: 38 | return {...state, title: action.data} 39 | case UPDATE_DATE: 40 | return {...state, openedDate: new Date().toJSON()} 41 | default: return state 42 | } 43 | } 44 | 45 | 46 | function value(state, field, action) { 47 | const val = state[field] || {} 48 | val[action.data.id] = action.data.value 49 | return val 50 | } 51 | -------------------------------------------------------------------------------- /src/redux/types.js: -------------------------------------------------------------------------------- 1 | export const TABLE_RESIZE = 'TABLE_RESIZE' 2 | export const CHANGE_TEXT = 'CHANGE_TEXT' 3 | export const APPLY_STYLE = 'APPLY_STYLE' 4 | export const CHANGE_STYLES = 'CHANGE_STYLES' 5 | export const CHANGE_TITLE = 'CHANGE_TITLE' 6 | export const UPDATE_DATE = 'UPDATE_DATE' 7 | -------------------------------------------------------------------------------- /src/scss/_mixins.scss: -------------------------------------------------------------------------------- 1 | @mixin button($color: green) { 2 | height: 24px; 3 | min-width: 24px; 4 | padding: 3px; 5 | text-align: center; 6 | position: relative; 7 | display: inline-block; 8 | color: rgba(0, 0, 0, .7); 9 | 10 | & i { 11 | font-size: 18px; 12 | } 13 | 14 | &:active, &.active { 15 | color: $color; 16 | } 17 | 18 | &:hover { 19 | background: #eee; 20 | cursor: pointer; 21 | } 22 | } 23 | 24 | 25 | @mixin clear-list() { 26 | list-style: none; 27 | margin: 0; 28 | padding: 0; 29 | } 30 | -------------------------------------------------------------------------------- /src/scss/_variables.scss: -------------------------------------------------------------------------------- 1 | $header-height: 34px; 2 | $toolbar-height: 40px; 3 | $formula-height: 24px; 4 | $info-cell-width: 40px; 5 | $cell-width: 120px; 6 | $row-height: 24px; 7 | $db-header-height: 64px; 8 | $border-color: #c0c0c0; 9 | $primary-color: #3c74ff; 10 | -------------------------------------------------------------------------------- /src/scss/dashboard.scss: -------------------------------------------------------------------------------- 1 | @import "variables"; 2 | @import "mixins"; 3 | 4 | .db { 5 | 6 | &__header { 7 | position: fixed; 8 | left: 0; 9 | right: 0; 10 | top: 0; 11 | height: $db-header-height; 12 | display: flex; 13 | align-items: center; 14 | justify-content: center; 15 | background: #fff; 16 | box-shadow: 0 2px 5px 2px rgba(60, 64, 67, 0.15); 17 | z-index: 1000; 18 | } 19 | 20 | &__new { 21 | margin-top: $db-header-height; 22 | padding: 1.5rem; 23 | background: #dadce0; 24 | } 25 | 26 | &__create { 27 | width: 160px; 28 | height: 160px; 29 | background: #fff; 30 | border-radius: 4px; 31 | border: 1px solid #dadce0; 32 | display: flex; 33 | justify-content: center; 34 | align-items: center; 35 | font-size: 1.5rem; 36 | text-decoration: none; 37 | color: #000; 38 | text-align: center; 39 | 40 | &:hover { 41 | cursor: pointer; 42 | color: green; 43 | border-color: green; 44 | } 45 | } 46 | 47 | &__view { 48 | max-width: 1000px; 49 | margin: 0 auto; 50 | } 51 | 52 | &__table { 53 | padding: 1rem; 54 | } 55 | 56 | &__list-header { 57 | display: flex; 58 | justify-content: space-between; 59 | color: #202124; 60 | font-weight: 500; 61 | font-size: 16px; 62 | margin-bottom: 10px; 63 | padding: 0 12px; 64 | } 65 | 66 | &__list { 67 | @include clear-list; 68 | } 69 | 70 | &__record { 71 | display: flex; 72 | justify-content: space-between; 73 | align-items: center; 74 | padding: 12px 14px 12px 16px; 75 | margin-bottom: 10px; 76 | 77 | &:hover { 78 | background: #e6f4ea; 79 | border-radius: 25px; 80 | } 81 | 82 | a { 83 | text-decoration: none; 84 | cursor: pointer; 85 | color: #202124; 86 | font-size: 14px; 87 | 88 | &:hover { 89 | text-decoration: underline; 90 | } 91 | } 92 | } 93 | 94 | } 95 | -------------------------------------------------------------------------------- /src/scss/formula.scss: -------------------------------------------------------------------------------- 1 | @import "variables"; 2 | @import "mixins"; 3 | 4 | 5 | .excel__formula { 6 | position: absolute; 7 | left: 0; 8 | right: 0; 9 | top: $header-height + $toolbar-height; 10 | height: $formula-height; 11 | display: flex; 12 | align-items: center; 13 | border-bottom: 1px solid $border-color; 14 | 15 | .info { 16 | font-size: 18px; 17 | font-style: italic; 18 | text-align: center; 19 | border-right: 1px solid $border-color; 20 | min-width: $info-cell-width; 21 | } 22 | 23 | .input { 24 | padding: 4px; 25 | font-size: 12px; 26 | outline: none; 27 | width: 100%; 28 | height: 100%; 29 | color: #000; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/scss/header.scss: -------------------------------------------------------------------------------- 1 | @import "variables"; 2 | @import "mixins"; 3 | 4 | .excel__header { 5 | position: absolute; 6 | top: 0; 7 | right: 0; 8 | left: 0; 9 | height: $header-height; 10 | padding: 8px 4px 0; 11 | display: flex; 12 | justify-content: space-between; 13 | 14 | & .input { 15 | margin: 0; 16 | padding: 2px 7px; 17 | min-width: 1px; 18 | color: #000; 19 | border: 1px solid transparent; 20 | border-radius: 2px; 21 | height: 20px; 22 | font-size: 18px; 23 | line-height: 22px; 24 | outline: none; 25 | 26 | &:hover { 27 | border: 1px solid $border-color; 28 | } 29 | 30 | &:focus { 31 | border: 2px solid #1a73e8; 32 | } 33 | } 34 | 35 | .button { 36 | @include button(red); 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /src/scss/index.scss: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css?family=Roboto&display=swap'); 2 | @import "~normalize.css"; 3 | 4 | @import "header"; 5 | @import "toolbar"; 6 | @import "formula"; 7 | @import "table"; 8 | @import "dashboard"; 9 | @import "loader"; 10 | 11 | * { 12 | margin: 0; 13 | padding: 0; 14 | box-sizing: border-box; 15 | } 16 | 17 | body { 18 | font-family: 'Roboto', sans-serif; 19 | font-size: 12px; 20 | } 21 | 22 | .excel { 23 | position: relative; 24 | color: #888; 25 | height: 100%; 26 | max-width: 100%; 27 | font-size: .8rem; 28 | } 29 | 30 | 31 | -------------------------------------------------------------------------------- /src/scss/loader.scss: -------------------------------------------------------------------------------- 1 | .loader { 2 | width: 100vw; 3 | height: 100vh; 4 | display: flex; 5 | justify-content: center; 6 | align-items: center; 7 | } 8 | .lds-spinner { 9 | color: official; 10 | display: inline-block; 11 | position: relative; 12 | width: 80px; 13 | height: 80px; 14 | } 15 | .lds-spinner div { 16 | transform-origin: 40px 40px; 17 | animation: lds-spinner 1.2s linear infinite; 18 | } 19 | .lds-spinner div:after { 20 | content: " "; 21 | display: block; 22 | position: absolute; 23 | top: 3px; 24 | left: 37px; 25 | width: 6px; 26 | height: 18px; 27 | border-radius: 20%; 28 | background: green; 29 | } 30 | .lds-spinner div:nth-child(1) { 31 | transform: rotate(0deg); 32 | animation-delay: -1.1s; 33 | } 34 | .lds-spinner div:nth-child(2) { 35 | transform: rotate(30deg); 36 | animation-delay: -1s; 37 | } 38 | .lds-spinner div:nth-child(3) { 39 | transform: rotate(60deg); 40 | animation-delay: -0.9s; 41 | } 42 | .lds-spinner div:nth-child(4) { 43 | transform: rotate(90deg); 44 | animation-delay: -0.8s; 45 | } 46 | .lds-spinner div:nth-child(5) { 47 | transform: rotate(120deg); 48 | animation-delay: -0.7s; 49 | } 50 | .lds-spinner div:nth-child(6) { 51 | transform: rotate(150deg); 52 | animation-delay: -0.6s; 53 | } 54 | .lds-spinner div:nth-child(7) { 55 | transform: rotate(180deg); 56 | animation-delay: -0.5s; 57 | } 58 | .lds-spinner div:nth-child(8) { 59 | transform: rotate(210deg); 60 | animation-delay: -0.4s; 61 | } 62 | .lds-spinner div:nth-child(9) { 63 | transform: rotate(240deg); 64 | animation-delay: -0.3s; 65 | } 66 | .lds-spinner div:nth-child(10) { 67 | transform: rotate(270deg); 68 | animation-delay: -0.2s; 69 | } 70 | .lds-spinner div:nth-child(11) { 71 | transform: rotate(300deg); 72 | animation-delay: -0.1s; 73 | } 74 | .lds-spinner div:nth-child(12) { 75 | transform: rotate(330deg); 76 | animation-delay: 0s; 77 | } 78 | @keyframes lds-spinner { 79 | 0% { 80 | opacity: 1; 81 | } 82 | 100% { 83 | opacity: 0; 84 | } 85 | } -------------------------------------------------------------------------------- /src/scss/table.scss: -------------------------------------------------------------------------------- 1 | @import "variables"; 2 | @import "mixins"; 3 | 4 | .excel__table { 5 | position: absolute; 6 | left: 0; 7 | right: 0; 8 | top: $header-height + $toolbar-height + $formula-height; 9 | overflow-x: auto; 10 | padding-bottom: 2px; 11 | 12 | .row { 13 | display: flex; 14 | flex-direction: row; 15 | min-height: 20px; 16 | height: $row-height; 17 | } 18 | 19 | .row-info { 20 | position: relative; 21 | display: flex; 22 | justify-content: center; 23 | align-items: center; 24 | min-width: $info-cell-width; 25 | height: 100%; 26 | border: 1px solid $border-color; 27 | background: #f8f9fa; 28 | border-top: none; 29 | } 30 | 31 | .row-data { 32 | display: flex; 33 | } 34 | 35 | .column { 36 | position: relative; 37 | display: flex; 38 | justify-content: center; 39 | align-items: center; 40 | background: #f8f9fa; 41 | min-width: 40px; 42 | width: $cell-width; 43 | border: 1px solid $border-color; 44 | border-top: 0; 45 | border-left: 0; 46 | height: 100%; 47 | } 48 | 49 | .cell { 50 | min-width: 40px; 51 | padding: 5px; 52 | width: $cell-width; 53 | height: 100%; 54 | border: 1px solid #e2e3e3; 55 | border-top: 0; 56 | border-left: 0; 57 | color: #111; 58 | white-space: nowrap; 59 | outline: none; 60 | 61 | 62 | &.selected { 63 | border: none; 64 | outline: 2px solid $primary-color; 65 | z-index: 2; 66 | } 67 | } 68 | 69 | .col-resize, .row-resize { 70 | position: absolute; 71 | bottom: 0; 72 | right: 0; 73 | background: $primary-color; 74 | opacity: 0; 75 | z-index: 1000; 76 | 77 | &:hover { 78 | opacity: 1!important; 79 | } 80 | } 81 | 82 | .col-resize { 83 | top: 0; 84 | width: 4px; 85 | 86 | &:hover { 87 | cursor: col-resize; 88 | } 89 | } 90 | 91 | .row-resize { 92 | left: 0; 93 | height: 4px; 94 | 95 | &:hover { 96 | cursor: row-resize; 97 | } 98 | } 99 | 100 | } 101 | -------------------------------------------------------------------------------- /src/scss/toolbar.scss: -------------------------------------------------------------------------------- 1 | @import "variables"; 2 | @import "mixins"; 3 | 4 | .excel__toolbar { 5 | position: absolute; 6 | left: 0; 7 | right: 0; 8 | top: $header-height; 9 | height: $toolbar-height; 10 | border-top: 1px solid $border-color; 11 | border-bottom: 1px solid $border-color; 12 | padding: 7px 10px; 13 | display: flex; 14 | 15 | .button { 16 | @include button(green); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/shared/LocalStorageClient.js: -------------------------------------------------------------------------------- 1 | import {storage} from "../core/utils" 2 | 3 | function storageName(param) { 4 | return 'excel:' + param 5 | } 6 | 7 | export class LocalStorageClient { 8 | constructor(name) { 9 | this.name= storageName(name) 10 | } 11 | 12 | save(state) { 13 | storage(this.name, state) 14 | return Promise.resolve() 15 | } 16 | 17 | get() { 18 | return new Promise (resolve => { 19 | const state = storage(this.name) 20 | 21 | setTimeout(() => { 22 | resolve(state) 23 | }, 2500); 24 | }) 25 | } 26 | } -------------------------------------------------------------------------------- /src/shared/dashboard.functions.js: -------------------------------------------------------------------------------- 1 | import {storage} from '../core/utils' 2 | 3 | export function toHTML(key) { 4 | const model = storage(key) 5 | const id = key.split(':')[1] 6 | return ` 7 |
  • 8 | ${model.title} 9 | 10 | ${new Date(model.openedDate).toLocaleDateString()} 11 | ${new Date(model.openedDate).toLocaleTimeString()} 12 | 13 |
  • 14 | ` 15 | } 16 | 17 | function getAllKeys() { 18 | const keys = [] 19 | for (let i=0; i < localStorage.length; i++) { 20 | const key = localStorage.key(i) 21 | if (!key.includes('excel')) { 22 | continue 23 | } 24 | keys.push(key) 25 | } 26 | 27 | return keys 28 | } 29 | 30 | export function createRecordsTable() { 31 | const keys = getAllKeys() 32 | 33 | console.log('keys', keys); 34 | 35 | if (!keys.length) { 36 | return `

    Вы пока не создали ни одной таблицы

    ` 37 | } 38 | 39 | return ` 40 |
    41 | Название 42 | Дата открытия 43 |
    44 | 45 | 48 | ` 49 | } 50 | 51 | 52 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const webpack = require('webpack') 3 | const {CleanWebpackPlugin} = require('clean-webpack-plugin'); 4 | const HTMLWebpackPlugin = require('html-webpack-plugin') 5 | const CopyPlugin = require('copy-webpack-plugin'); 6 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 7 | const EslintWebpackPlugin = require('eslint-webpack-plugin'); 8 | 9 | const isProd = process.env.NODE_ENV === 'production' 10 | const isDev = !isProd 11 | 12 | const filename = ext => isDev ? `bundle.${ext}` : `bundle.[hash].${ext}` 13 | 14 | console.log('IS PROD', isProd); 15 | 16 | const jsLoaders = () => { 17 | if (isDev) { 18 | return new EslintWebpackPlugin() 19 | } 20 | } 21 | 22 | module.exports = { 23 | context: path.resolve(__dirname, 'src'), 24 | mode: 'development', 25 | entry: './index.js', 26 | output: { 27 | filename: filename('js'), 28 | path: path.resolve(__dirname, 'dist') 29 | }, 30 | resolve: { 31 | extensions: ['.js'], 32 | alias: { 33 | '@': path.resolve(__dirname, 'src'), 34 | '@core': path.resolve(__dirname, 'src/core') 35 | } 36 | }, 37 | devtool: isDev ? 'source-map' : false, 38 | devServer: { 39 | port: 3000, 40 | hot: isDev 41 | }, 42 | plugins: [ 43 | new CleanWebpackPlugin(), 44 | new HTMLWebpackPlugin({ 45 | template: 'index.html', 46 | minify: { 47 | removeComments: isProd, 48 | collapseWhitespace: isProd 49 | } 50 | }), 51 | new CopyPlugin({ 52 | patterns: [ 53 | { 54 | from: path.resolve(__dirname, 'src/favicon.ico'), 55 | to: path.resolve(__dirname, 'dist') 56 | }, 57 | ], 58 | }), 59 | new MiniCssExtractPlugin({ 60 | filename: filename('css') 61 | }), 62 | new webpack.DefinePlugin({ 63 | 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV), 64 | }) 65 | ], 66 | module: { 67 | rules: [ 68 | { 69 | test: /\.s[ac]ss$/i, 70 | use: [ 71 | MiniCssExtractPlugin.loader, 72 | 'css-loader', 73 | 'sass-loader', 74 | ], 75 | }, 76 | { 77 | test: /\.m?js$/, 78 | exclude: /node_modules/, 79 | use: { 80 | loader: 'babel-loader', 81 | options: { 82 | presets: ['@babel/preset-env'] 83 | } 84 | } 85 | } 86 | ] 87 | } 88 | } 89 | --------------------------------------------------------------------------------