diff --git a/package-lock.json b/package-lock.json index 1389a32..202c062 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2194,6 +2194,11 @@ } } }, + "classnames": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.2.6.tgz", + "integrity": "sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q==" + }, "clean-css": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-4.2.1.tgz", @@ -3058,6 +3063,14 @@ "utila": "~0.4" } }, + "dom-helpers": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-3.4.0.tgz", + "integrity": "sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA==", + "requires": { + "@babel/runtime": "^7.1.2" + } + }, "dom-serializer": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.1.1.tgz", @@ -7715,6 +7728,11 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.8.6.tgz", "integrity": "sha512-aUk3bHfZ2bRSVFFbbeVS4i+lNPZr3/WM5jT2J5omUVV1zzcs1nAaf3l51ctA5FFvCRbhrH0bdAsRRQddFJZPtA==" }, + "react-lifecycles-compat": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", + "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==" + }, "react-router": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.0.1.tgz", @@ -7761,6 +7779,28 @@ "tiny-warning": "^1.0.0" } }, + "react-toastify": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-5.3.2.tgz", + "integrity": "sha512-YHTTey7JWqXVkkBIeJ34PAvQELmGfLEGCx9bu68aIZYd+kRU2u9k/nG3AydgbX/uevIb4QNpeeE98DjkooMs5w==", + "requires": { + "@babel/runtime": "^7.4.2", + "classnames": "^2.2.6", + "prop-types": "^15.7.2", + "react-transition-group": "^2.6.1" + } + }, + "react-transition-group": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-2.9.0.tgz", + "integrity": "sha512-+HzNTCHpeQyl4MJ/bdE0u6XRMe9+XG/+aL4mCxVN4DnPBQ0/5bfHWPDuOZUzYdMj94daZaZdCCc1Dzt9R/xSSg==", + "requires": { + "dom-helpers": "^3.4.0", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2", + "react-lifecycles-compat": "^3.0.4" + } + }, "read-pkg": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-1.1.0.tgz", diff --git a/package.json b/package.json index caedf60..95c66c0 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "react": "^16.8.6", "react-dom": "^16.8.6", "react-router-dom": "^5.0.1", + "react-toastify": "^5.3.2", "rxjs": "^6.5.2", "rxjs-hooks": "^0.5.1" } diff --git a/src/main.tsx b/src/main.tsx index e76f69d..812be3d 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,5 +2,8 @@ import React from 'react' import App from './modules/core/components/App' import { render } from 'react-dom' +import { handleErrors } from './modules/errors/helpers/handlErrors' render(, document.getElementById('app')) + +handleErrors() diff --git a/src/modules/core/components/App.scss b/src/modules/core/components/App.scss index 0dbfb1a..e8f96f4 100644 --- a/src/modules/core/components/App.scss +++ b/src/modules/core/components/App.scss @@ -8,4 +8,5 @@ body { canvas { background-color: #222222; + z-index: -1; } diff --git a/src/modules/core/components/App.tsx b/src/modules/core/components/App.tsx index 75544d2..301cb28 100644 --- a/src/modules/core/components/App.tsx +++ b/src/modules/core/components/App.tsx @@ -1,10 +1,26 @@ import React from 'react' import '../styles/reset' import './App.scss' +import 'react-toastify/dist/ReactToastify.css' import Canvas from './Canvas' +import { ToastContainer } from 'react-toastify' const App = () => { - return + return ( + <> + + + + ) } export default App diff --git a/src/modules/errors/classes/SimulationError.ts b/src/modules/errors/classes/SimulationError.ts new file mode 100644 index 0000000..1285904 --- /dev/null +++ b/src/modules/errors/classes/SimulationError.ts @@ -0,0 +1,9 @@ +export class SimulationError extends Error { + public constructor(public mesagge: string = '') { + super() + } + + public toString() { + return `SimulationError: ${this.mesagge}` + } +} diff --git a/src/modules/errors/helpers/handlErrors.ts b/src/modules/errors/helpers/handlErrors.ts new file mode 100644 index 0000000..8d3f0f5 --- /dev/null +++ b/src/modules/errors/helpers/handlErrors.ts @@ -0,0 +1,14 @@ +import { toast } from 'react-toastify' +import { createToastArguments } from '../../toasts/helpers/createToastArguments' + +export const handleErrors = () => { + window.onerror = (a, b, c, d, error) => { + if (error) { + const args = createToastArguments(error.toString()) + + toast.error(...args) + } + + console.log(a) + } +} diff --git a/src/modules/saving/helpers/getState.ts b/src/modules/saving/helpers/getState.ts index 83550a5..582f2d0 100644 --- a/src/modules/saving/helpers/getState.ts +++ b/src/modules/saving/helpers/getState.ts @@ -1,15 +1,18 @@ import { SimulationRenderer } from '../../simulationRenderer/classes/SimulationRenderer' -import { Gate } from '../../simulation/classes/Gate' +import { Gate, PinWrapper } from '../../simulation/classes/Gate' import { GateState, TransformState, RendererState, CameraState, - SimulationState + SimulationState, + WireState, + WireLimit } from '../types/SimulationSave' import { Transform } from '../../../common/math/classes/Transform' import { Camera } from '../../simulationRenderer/classes/Camera' import { Simulation } from '../../simulation/classes/Simulation' +import { Wire } from '../../simulation/classes/Wire' export const getTransformState = (transform: Transform): TransformState => { return { @@ -25,9 +28,26 @@ export const getCameraState = (camera: Camera): CameraState => { } } +export const getWireLimit = (pin: PinWrapper): WireLimit => { + return { + id: pin.value.gate.id, + index: pin.index + } +} + +export const getWireState = (wire: Wire): WireState => { + return { + from: getWireLimit(wire.start), + to: getWireLimit(wire.end), + id: wire.id + } +} + export const getSimulationState = (simulation: Simulation): SimulationState => { return { - gates: Array.from(simulation.gates).map(getGateState) + gates: Array.from(simulation.gates).map(getGateState), + wires: simulation.wires.map(getWireState), + mode: simulation.mode } } diff --git a/src/modules/saving/types/SimulationSave.ts b/src/modules/saving/types/SimulationSave.ts index cfa9ef9..2d88e5f 100644 --- a/src/modules/saving/types/SimulationSave.ts +++ b/src/modules/saving/types/SimulationSave.ts @@ -1,5 +1,7 @@ import { vector2 } from '../../../common/math/classes/Transform' +export type simulationMode = 'ic' | 'project' + export interface TransformState { position: vector2 scale: vector2 @@ -16,8 +18,22 @@ export interface CameraState { transform: TransformState } +export interface WireLimit { + id: number + index: number +} + +export interface WireState { + from: WireLimit + to: WireLimit + id: number +} + export interface SimulationState { gates: GateState[] + wires: WireState[] + + mode: simulationMode } export interface RendererState { diff --git a/src/modules/simulation/classes/Gate.ts b/src/modules/simulation/classes/Gate.ts index 3c0663d..f23d4dc 100644 --- a/src/modules/simulation/classes/Gate.ts +++ b/src/modules/simulation/classes/Gate.ts @@ -3,6 +3,7 @@ import { Pin } from './Pin' import merge from 'deepmerge' import { GateTemplate, PinCount } from '../types/GateTemplate' import { DefaultGateTemplate } from '../constants' +import { idStore } from '../stores/idStore' export interface GatePins { inputs: Pin[] @@ -16,22 +17,30 @@ export interface PinWrapper { } export class Gate { - public static lastId = 0 - public transform = new Transform() - public id = Gate.lastId++ public _pins: GatePins = { inputs: [], outputs: [] } + public id: number public template: GateTemplate - public constructor(template: DeepPartial = {}) { + public constructor(template: DeepPartial = {}, id?: number) { this.template = merge(DefaultGateTemplate, template) as GateTemplate - this._pins.inputs = Gate.generatePins(this.template.pins.inputs, 1) - this._pins.outputs = Gate.generatePins(this.template.pins.outputs, 2) + this._pins.inputs = Gate.generatePins( + this.template.pins.inputs, + 1, + this + ) + this._pins.outputs = Gate.generatePins( + this.template.pins.outputs, + 2, + this + ) + + this.id = id !== undefined ? id : idStore.generate() } private wrapPins(pins: Pin[]) { @@ -58,7 +67,9 @@ export class Gate { return result } - private static generatePins(options: PinCount, type: number) { - return [...Array(options.count)].fill(true).map(() => new Pin(type)) + private static generatePins(options: PinCount, type: number, gate: Gate) { + return [...Array(options.count)] + .fill(true) + .map(() => new Pin(type, gate)) } } diff --git a/src/modules/simulation/classes/Pin.ts b/src/modules/simulation/classes/Pin.ts index e097b78..7d61728 100644 --- a/src/modules/simulation/classes/Pin.ts +++ b/src/modules/simulation/classes/Pin.ts @@ -1,5 +1,6 @@ import { SubscriptionData } from '../types/SubscriptionData' import { BehaviorSubject } from 'rxjs' +import { Gate } from './Gate' /* Types: @@ -9,24 +10,25 @@ Second bit = output */ export class Pin { public state = new BehaviorSubject(false) - public connectedTo = new Set() + public pairs = new Set() - private pairs = new Set() private subscriptions: SubscriptionData[] = [] - public constructor(public type = 0b01) {} + public constructor(public type = 0b01, public gate: Gate) {} - public addPair(pin: Pin) { + public addPair(pin: Pin, subscribe = false) { this.pairs.add(pin) - const rawSubscription = pin.state.subscribe(state => { - this.state.next(state) - }) + if (subscribe) { + const rawSubscription = pin.state.subscribe(state => { + this.state.next(state) + }) - this.subscriptions.push({ - data: pin, - subscription: rawSubscription - }) + this.subscriptions.push({ + data: pin, + subscription: rawSubscription + }) + } } public removePair(pin: Pin) { diff --git a/src/modules/simulation/classes/Simulation.ts b/src/modules/simulation/classes/Simulation.ts index 8da8de7..543d6c1 100644 --- a/src/modules/simulation/classes/Simulation.ts +++ b/src/modules/simulation/classes/Simulation.ts @@ -1,9 +1,14 @@ import { Gate } from './Gate' import { GateStorage } from './GateStorage' import { LruCacheNode } from '@eix-js/utils' +import { Wire } from './Wire' +import { simulationMode } from '../../saving/types/SimulationSave' export class Simulation { public gates = new GateStorage() + public wires: Wire[] = [] + + public constructor(public mode: simulationMode = 'project') {} public push(...gates: Gate[]) { for (const gate of gates) { diff --git a/src/modules/simulation/classes/Wire.ts b/src/modules/simulation/classes/Wire.ts new file mode 100644 index 0000000..5a008fb --- /dev/null +++ b/src/modules/simulation/classes/Wire.ts @@ -0,0 +1,27 @@ +import { idStore } from '../stores/idStore' +import { PinWrapper } from './Gate' +import { SimulationError } from '../../errors/classes/SimulationError' + +export class Wire { + public id: number + + public constructor( + public start: PinWrapper, + public end: PinWrapper, + id?: number + ) { + if (end.value.pairs.size !== 0) { + throw new SimulationError('An input pin can only have 1 pair') + } + + end.value.addPair(start.value, true) + start.value.addPair(end.value) + + this.id = id !== undefined ? id : idStore.generate() + } + + public dispose() { + this.end.value.removePair(this.start.value) + this.start.value.removePair(this.end.value) + } +} diff --git a/src/modules/simulation/stores/idStore.ts b/src/modules/simulation/stores/idStore.ts new file mode 100644 index 0000000..354db10 --- /dev/null +++ b/src/modules/simulation/stores/idStore.ts @@ -0,0 +1,12 @@ +import { LocalStore } from '../../storage/classes/LocalStore' + +const store = new LocalStore('id') + +export const idStore = { + generate() { + const current = store.get() + store.set(current + 1) + + return current + 1 + } +} diff --git a/src/modules/simulationRenderer/classes/SimulationRenderer.ts b/src/modules/simulationRenderer/classes/SimulationRenderer.ts index 00a80aa..088a83d 100644 --- a/src/modules/simulationRenderer/classes/SimulationRenderer.ts +++ b/src/modules/simulationRenderer/classes/SimulationRenderer.ts @@ -14,6 +14,7 @@ import { getPinPosition } from '../helpers/pinPosition' import { pointInCircle } from '../../../common/math/helpers/pointInCircle' import { SelectedPins } from '../types/SelectedPins' import { getRendererState } from '../../saving/helpers/getState' +import { Wire } from '../../simulation/classes/Wire' export class SimulationRenderer { public mouseDownOutput = new Subject() @@ -104,11 +105,21 @@ export class SimulationRenderer { } } - if (this.selectedPins.start && this.selectedPins.end) { - console.log('Connecting!') - console.log(getRendererState(this)) + if ( + this.selectedPins.start && + this.selectedPins.end && + this.selectedPins.end.wrapper.value.pairs.size === 0 + ) { + this.simulation.wires.push( + new Wire( + this.selectedPins.start.wrapper, + this.selectedPins.end.wrapper + ) + ) this.selectedPins.start = null this.selectedPins.end = null + + console.log(getRendererState(this)) } } } diff --git a/src/modules/simulationRenderer/constants.ts b/src/modules/simulationRenderer/constants.ts index 7098fa4..cc1359b 100644 --- a/src/modules/simulationRenderer/constants.ts +++ b/src/modules/simulationRenderer/constants.ts @@ -8,9 +8,14 @@ export const defaultSimulationRendererOptions: SimulationRendererOptions = { connectionLength: 30, pinRadius: 10, pinStrokeColor: '#888888', - pinStrokeWidth: 3 + pinStrokeWidth: 3, + pinFill: { + open: 'rgb(255,216,20)', + closed: 'rgb(90,90,90)' + } }, wires: { - temporaryWireColor: `rgba(128,128,128,0.5)` + temporaryWireColor: `rgba(128,128,128,0.5)`, + curvePointOffset: 100 } } diff --git a/src/modules/simulationRenderer/helpers/pinFill.ts b/src/modules/simulationRenderer/helpers/pinFill.ts index 0134344..52d5677 100644 --- a/src/modules/simulationRenderer/helpers/pinFill.ts +++ b/src/modules/simulationRenderer/helpers/pinFill.ts @@ -1,13 +1,14 @@ import { Pin } from '../../simulation/classes/Pin' +import { SimulationRenderer } from '../classes/SimulationRenderer' -export const pinFill = (pin: Pin) => { +export const pinFill = (renderer: SimulationRenderer, pin: Pin) => { let color = 'rgba(0,0,0,0)' - if (pin.connectedTo.size) { - if (pin.state) { - color = 'yellow' + if (pin.pairs.size) { + if (pin.state.value) { + color = renderer.options.gates.pinFill.open } else { - color = 'grey' + color = renderer.options.gates.pinFill.closed } } diff --git a/src/modules/simulationRenderer/helpers/renderPins.ts b/src/modules/simulationRenderer/helpers/renderPins.ts index 8bff60b..c872069 100644 --- a/src/modules/simulationRenderer/helpers/renderPins.ts +++ b/src/modules/simulationRenderer/helpers/renderPins.ts @@ -23,7 +23,7 @@ export const renderPins = ( ctx.lineWidth = pinStrokeWidth for (const pin of gate.pins) { - ctx.fillStyle = pinFill(pin.value) + ctx.fillStyle = pinFill(renderer, pin.value) // render little connection const start = calculatePinStart( diff --git a/src/modules/simulationRenderer/helpers/renderSimulation.ts b/src/modules/simulationRenderer/helpers/renderSimulation.ts index 3d74aef..f73c092 100644 --- a/src/modules/simulationRenderer/helpers/renderSimulation.ts +++ b/src/modules/simulationRenderer/helpers/renderSimulation.ts @@ -3,6 +3,7 @@ import { invert } from '../../vector2/helpers/basic' import { renderGate } from './renderGate' import { clearCanvas } from '../../../common/canvas/helpers/clearCanvas' import { renderClickedPins } from './renderClickedPins' +import { renderWires } from './renderWires' export const renderSimulation = ( ctx: CanvasRenderingContext2D, @@ -12,7 +13,10 @@ export const renderSimulation = ( ctx.translate(...renderer.camera.transform.position) - // render gates + for (const wire of renderer.simulation.wires) { + renderWires(ctx, renderer, wire) + } + for (const gate of renderer.simulation.gates) { renderGate(ctx, renderer, gate) } diff --git a/src/modules/simulationRenderer/helpers/renderWires.ts b/src/modules/simulationRenderer/helpers/renderWires.ts new file mode 100644 index 0000000..d1f91a5 --- /dev/null +++ b/src/modules/simulationRenderer/helpers/renderWires.ts @@ -0,0 +1,94 @@ +import { SimulationRenderer } from '../classes/SimulationRenderer' +import { pinFill } from './pinFill' +import { getPinPosition } from './pinPosition' +import { Wire } from '../../simulation/classes/Wire' +import { wireRadius } from './wireRadius' +import { clamp } from '../../simulation/helpers/clamp' + +export const renderWires = ( + ctx: CanvasRenderingContext2D, + renderer: SimulationRenderer, + wire: Wire +) => { + const { start, end } = wire + const startPosition = getPinPosition( + renderer, + start.value.gate.transform, + start + ) + const endPosition = getPinPosition(renderer, end.value.gate.transform, end) + const length = renderer.options.wires.curvePointOffset + const centerY = (startPosition[1] + endPosition[1]) / 2 + const controlPostions = [startPosition[0] + length, endPosition[0] - length] + + ctx.strokeStyle = pinFill(renderer, start.value) + ctx.lineWidth = wireRadius(renderer) + ctx.lineCap = 'round' + + ctx.beginPath() + ctx.moveTo(...startPosition) + + if (startPosition[0] < endPosition[0]) { + ctx.bezierCurveTo( + controlPostions[0], + startPosition[1], + controlPostions[1], + endPosition[1], + ...endPosition + ) + } else { + const { abs, PI } = Math + + const baseFactor = startPosition[1] < endPosition[1] ? 1 : -1 + const factors = [baseFactor, baseFactor] + + const radiuses = [...Array(2)].fill( + abs((centerY - startPosition[1]) / 2) + ) + + const limit = 70 + if (radiuses[0] < limit) { + factors[0] *= -1 + radiuses[0] = limit + radiuses[1] = abs( + (startPosition[1] + + factors[0] * 2 * radiuses[0] - + endPosition[1]) / + 2 + ) + // radiuses[0] = + } + + const centerPosition = [ + startPosition[1] + factors[0] * radiuses[0], + endPosition[1] - factors[1] * radiuses[1] + ] + + ctx.arc( + controlPostions[0], + centerPosition[0], + radiuses[0], + (-factors[0] * PI) / 2, + (factors[0] * PI) / 2, + factors[0] !== 1 + ) + + ctx.lineTo( + controlPostions[1], + endPosition[1] - factors[1] * 2 * radiuses[1] + ) + + ctx.arc( + controlPostions[1], + centerPosition[1], + radiuses[1], + (-factors[1] * PI) / 2, + (factors[1] * PI) / 2, + factors[1] === 1 + ) + + ctx.lineTo(...endPosition) + } + + ctx.stroke() +} diff --git a/src/modules/simulationRenderer/helpers/wireRadius.ts b/src/modules/simulationRenderer/helpers/wireRadius.ts new file mode 100644 index 0000000..cbaa565 --- /dev/null +++ b/src/modules/simulationRenderer/helpers/wireRadius.ts @@ -0,0 +1,9 @@ +import { SimulationRenderer } from '../classes/SimulationRenderer' + +export const wireRadius = (renderer: SimulationRenderer) => { + return ( + 2 * + (renderer.options.gates.pinRadius - + renderer.options.gates.pinStrokeWidth) + ) +} diff --git a/src/modules/simulationRenderer/types/SimulationRendererOptions.ts b/src/modules/simulationRenderer/types/SimulationRendererOptions.ts index e013453..1df581a 100644 --- a/src/modules/simulationRenderer/types/SimulationRendererOptions.ts +++ b/src/modules/simulationRenderer/types/SimulationRendererOptions.ts @@ -7,8 +7,13 @@ export interface SimulationRendererOptions { pinRadius: number pinStrokeColor: string pinStrokeWidth: number + pinFill: { + open: string + closed: string + } } wires: { temporaryWireColor: string + curvePointOffset: number } } diff --git a/src/modules/storage/classes/LocalStore.ts b/src/modules/storage/classes/LocalStore.ts new file mode 100644 index 0000000..942ad60 --- /dev/null +++ b/src/modules/storage/classes/LocalStore.ts @@ -0,0 +1,56 @@ +import { CacheInstancesByKey } from '@eix-js/utils' + +@CacheInstancesByKey(Infinity) +export class LocalStore { + public constructor(public name: string) { + if (!localStorage.getItem(name)) { + localStorage.setItem(name, '{}') + } + } + + public getAll(): Record { + const raw = localStorage.getItem(this.name) + + if (!raw) + throw new Error( + `An error occured when accesing ${ + this.name + } in the local storage!` + ) + else { + return JSON.parse(raw) + } + } + + public ls(): string[] { + return Object.keys(this.getAll()) + } + + public *[Symbol.iterator](): Iterable { + for (const item of this.ls()) { + return this.get(item) + } + } + + public get(key = 'index') { + return this.getAll()[key] + } + + public set(key: string | T = 'index', value?: T) { + if (typeof key !== 'string' || value === undefined) { + localStorage.setItem( + this.name, + JSON.stringify({ + index: key + }) + ) + } else { + localStorage.setItem( + this.name, + JSON.stringify({ + [key]: value + }) + ) + } + } +} diff --git a/src/modules/toasts/helpers/createToastArguments.ts b/src/modules/toasts/helpers/createToastArguments.ts new file mode 100644 index 0000000..0b6592c --- /dev/null +++ b/src/modules/toasts/helpers/createToastArguments.ts @@ -0,0 +1,15 @@ +import { ToastOptions } from 'react-toastify' + +export const createToastArguments = ( + message: string +): [string, ToastOptions] => [ + message, + { + position: 'top-left', + autoClose: 5000, + hideProgressBar: false, + closeOnClick: true, + pauseOnHover: true, + draggable: true + } +] diff --git a/webpack.config.js b/webpack.config.js index 970293f..33d3c29 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -17,18 +17,27 @@ const babelRule = { use: 'babel-loader' } +const cssAndSass = [ + isProduction + ? MiniCssExtractPlugin.loader + : { + loader: 'style-loader', + options: { + singleton: true + } + }, + 'css-loader' +] + +const cssRule = { + test: /\.css$/, + use: cssAndSass +} + const sassRule = { test: /\.scss$/, use: [ - isProduction - ? MiniCssExtractPlugin.loader - : { - loader: 'style-loader', - options: { - singleton: true - } - }, - { loader: 'css-loader' }, + ...cssAndSass, { loader: 'sass-loader', options: { @@ -47,7 +56,7 @@ const baseConfig = { publicPath: '/' }, module: { - rules: [babelRule, sassRule] + rules: [babelRule, sassRule, cssRule] }, resolve: { extensions: ['.js', '.ts', '.tsx', '.scss']