Initial commit

This commit is contained in:
Ellina Sohnenko 2025-12-22 14:43:20 +03:00
parent 0232756c2a
commit d1c289ec68
23 changed files with 18565 additions and 0 deletions

23
.gitignore vendored Normal file
View File

@ -0,0 +1,23 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

17829
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

41
package.json Normal file
View File

@ -0,0 +1,41 @@
{
"name": "fbd-editor",
"version": "0.1.0",
"private": true,
"dependencies": {
"@use-gesture/react": "^10.3.1",
"cra-template": "1.3.0",
"d3": "^7.9.0",
"react": "^19.2.1",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "^19.2.1",
"react-draggable": "^4.5.0",
"react-scripts": "5.0.1",
"web-vitals": "^5.1.0"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

43
public/index.html Normal file
View File

@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

BIN
public/logo192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
public/logo512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

25
public/manifest.json Normal file
View File

@ -0,0 +1,25 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

3
public/robots.txt Normal file
View File

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

83
src/App.css Normal file
View File

@ -0,0 +1,83 @@
.App {
text-align: center;
font-family: Arial, sans-serif;
background-color: #edeef0;
position: relative;
user-select: none;
}
.controlPanel {
width: calc(100% - 20px);
height: 10px;
}
.toolbar {
float: left;
width: 204px;
margin: 4.5px 4px 0px 4px;
min-height: calc(100vh - 55px);
padding: 5px;
border: 2px solid #e3e8e6;
background-color: white;
position: sticky;
}
.toolbarElements {
width: 204px;
min-height: calc(100vh - 93px);
border: 1px solid #ccc;
}
.tabButtons {
display: flex;
flex-wrap: wrap; /* перенос строк */
}
.tabButtons [data-row='1'] { width: 51px; }
.tabButtons [data-row="2"] { width: 68px; border-bottom: 1px solid #ccc; }
.tabButtons button {
font-size: 12px;
padding: 2px 0px 2px 1px;
border: 1px solid #eee;
background-color: white;
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis; /* троеточие */
}
.tabButtons button.activeTab {
background-color: #e0e0e0;
font-weight: bold;
}
.toolbarBlocks {
width: calc(100% - 21px);
height: calc(100% - 58px);
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
padding: 10px;
position: sticky;
overflow-y: auto;
}
.canvas {
left: 224px;
top: 35px;
border: 2px solid #e3e8e6;
width: calc(100% - 233px);
height: calc(100% - 45px);
position: fixed;
overflow: hidden;
}
.dragging {
opacity: 0.7;
}

53
src/App.js Normal file
View File

@ -0,0 +1,53 @@
import React, { useState } from 'react';
import ControlPanel from './components/ControlPanel';
import Canvas from './components/Canvas';
import Toolbar from './components/Toolbar';
import './App.css';
function App() {
const [blocks, setBlocks] = useState([]); //блоки
const [connections, setConnections] = useState([]); //соединения
const addBlock = (block, x = 100, y = 100) => { //добавить блок на холст
const newBlock = {...block, id: Date.now(), x, y};
setBlocks(prevBlocks => [...prevBlocks, newBlock]);
};
const addConnection = (from, to) => { //добавить соединение
setConnections([...connections, {from, to}]);
}
// Функция экспорта: создаёт JSON и скачивает файл
const handleExport = () => {
const data = { blocks, connections };
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'fbd-diagram.json';
a.click();
URL.revokeObjectURL(url);
};
// Функция импорта: загружает данные из файла
const handleImport = (data) => {
setBlocks(data.blocks || []);
setConnections(data.connections || []);
};
// Функция очистки: сбрасывает холст
const handleClear = () => {
setBlocks([]);
setConnections([]);
};
return (
<div className="App">
<ControlPanel onExport = {handleExport} onImport = {handleImport} onClear = {handleClear} />
<Toolbar onAddBlock={addBlock} />
<Canvas blocks={blocks} setBlocks={setBlocks} connections = {connections} addConnection = {addConnection} addBlock = {addBlock} />
</div>
);
};
export default App;

8
src/App.test.js Normal file
View File

@ -0,0 +1,8 @@
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});

79
src/components/Block.js Normal file
View File

@ -0,0 +1,79 @@
import React from 'react';
const Block = ({ type, label, addConnection, blockId }) => {
const handleOutputClick = () => {
// Сохраняем ID блока как "from" для соединения (в реальности нужно состояние для выбора)
// Для простоты: при клике на выход устанавливаем "from", при клике на вход — "to"
window.selectedFrom = blockId; // глобальная переменная для примера, лучше использовать state
};
const handleInputClick = () => {
if (window.selectedFrom && window.selectedFrom != blockId) {
addConnection(window.selectedFrom, blockId); // создание соединения
window.selectedFrom = null;
}
};
//Условная логика для разных типов блоков
const renderBlock = () => {
const centerX = 4; // width/2
const centerY = 0; // height/2
switch (type) {
case 'NOT':
return (
<svg width = "68" height = "84" alight = "center">
<g transform={`translate(${centerX}, ${centerY})`}>
<rect width="60" height = "100%" fill = "lightgray" stroke = "black" />
<text x = "24" y = "20" textAnchor = "left" fontSize = "14" style={{ pointerEvents: 'none' }}>{label}</text>
{/* вход */}
<circle cx = "0" cy = "42" r = "4" fill = "#1A1ADB" onClick = {handleInputClick} style = {{ cursor: 'pointer' }} />
{/* выход */}
<circle cx = "60" cy = "42" r = "4" fill = "#E00010" onClick = {handleOutputClick} style = {{ cursor: 'pointer' }} />
</g>
</svg>
);
case 'OR':
return (
<svg width = "68" height = "84" alight = "center">
<g transform={`translate(${centerX}, ${centerY})`}>
<rect width="60" height = "100%" fill = "lightgray" stroke = "black" />
<text x = "32" y = "20" textAnchor = "left" fontSize = "14" style={{ pointerEvents: 'none' }}>{label}</text>
{/* вход */}
<circle cx = "0" cy = "25" r = "4" fill = "#1A1ADB" onClick = {handleInputClick} style = {{ cursor: 'pointer' }} />
{/* вход 2*/}
<circle cx = "0" cy = "59" r = "4" fill = "#1A1ADB" onClick = {handleInputClick} style = {{ cursor: 'pointer' }} />
{/* выход */}
<circle cx = "60" cy = "42" r = "4" fill = "#E00010" onClick = {handleOutputClick} style = {{ cursor: 'pointer' }} />
</g>
</svg>
);
default:
return (
<svg width = "68" height = "84" alight = "center">
<g transform={`translate(${centerX}, ${centerY})`}>
<rect width="60" height = "100%" fill = "lightgray" stroke = "black" />
<text x = "24" y = "20" textAnchor = "left" fontSize = "14" style={{ pointerEvents: 'none' }}>{label}</text>
{/* вход */}
<circle cx = "0" cy = "25" r = "4" fill = "#1A1ADB" onClick = {handleInputClick} style = {{ cursor: 'pointer' }} />
{/* вход 2*/}
<circle cx = "0" cy = "59" r = "4" fill = "#1A1ADB" onClick = {handleInputClick} style = {{ cursor: 'pointer' }} />
{/* выход */}
<circle cx = "60" cy = "42" r = "4" fill = "#E00010" onClick = {handleOutputClick} style = {{ cursor: 'pointer' }} />
</g>
</svg>
);
}
}
return renderBlock();
};
export default Block;
/* треугольник
<polygon points="60,35 60,49 70,42" fill = "none" stroke="black" />*/

167
src/components/Canvas.js Normal file
View File

@ -0,0 +1,167 @@
import React, { useRef, useState, useEffect } from 'react';
import { useDrop } from 'react-dnd';
import { useDrag } from '@use-gesture/react';
import Block from './Block';
import Connection from './Connection';
const Canvas = ({ blocks, setBlocks, connections, addConnection, addBlock }) => {
const safeBlocks = blocks || [];
const safeConnections = connections || [];
//состояния для маштабирования и перемещения
const [zoom, setZoom] = useState(1); //масштаб 100%
const [pan, setPan] = useState({ x: 0, y: 0 }); //смещение
const [isPanning, setIsPanning] = useState(false); //флаг перемещения
const [panStart, setPanStart] = useState({ x: 0, y: 0}); //начальная точка pan
const [canvasSize, setCanvasSize] = useState({ width: 800, height: 500 });
const canvasRef = useRef (null);
useEffect(() => {
const updateSize = () => {
if (canvasRef.current) {
const rect = canvasRef.current.getBoundingClientRect();
setCanvasSize({ width: rect.width, height: rect.height });
}
};
updateSize(); //первичное обновление
window.addEventListener('resize', updateSize); //обновление при изменении размера окна
return () => window.removeEventListener('resize', updateSize); //очистка
}, []);
const [{ isOver }, drop] = useDrop(() => ({
accept: 'block',
drop: (item, monitor) => {
const offset = monitor.getClientOffset(); //позиция курсора
//console.log('Offset: ', offset);
if (offset && canvasRef.current) {
const canvasRect = canvasRef.current.getBoundingClientRect(); // границы svg
//console.log('CanvasRect: ', canvasRect);
const x = offset.x - canvasRect.left;
const y = offset.y - canvasRect.top;
//console.log('Calculated x/y: ', x, y);
if (item.id) {
console.log('moveBlock');
//перемещение
moveBlock(item.id, x, y);
} else {
console.log('addBlock');
//добавление нового
addBlock(item, x, y);
}
}
},
collect: (monitor) => ({
isOver: monitor.isOver(), //курсор с блоком над canvas
}),
}));
const moveBlock = (id, x, y) => {
setBlocks(safeBlocks.map(block => block.id === id ? { ...block, x, y} : block));
}
// обработчик масштабирования
const handleWheel = (e) => {
e.preventDefault();
const stepZoom = 0.1;
const newZoom = zoom + (e.deltaY > 0 ? -stepZoom : stepZoom);
setZoom (Math.max(0.7, Math.min(2, newZoom)));
};
//обработчик начала перемещения
const handleMouseDown = (e) => {
if (e.button === 0) { //лкм
setIsPanning(true);
setPanStart({ x: e.clientX - pan.x, y: e.clientY - pan.y });
}
};
//обработчик перемещения
const handleMouseMove = (e) => {
if (isPanning) {
setPan ({
x: e.clientX - panStart.x,
y: e.clientY - panStart.y,
});
}
};
//обработчик окончания перемещения
const handleMouseUp = () => {
setIsPanning(false);
}
//сетка
const renderGrid = () => {
const gridSize = 7 * zoom; //размер ячейки
const numLinesX = Math.ceil(canvasSize.width / gridSize) + 10;
const numLinesY = Math.ceil(canvasSize.height / gridSize) + 10;
const lines = [];
for (let i = 0; i < numLinesX; i++) {
lines.push(<line key={`v${i}`} x1={i * gridSize} y1="0" x2={i * gridSize} y2={canvasSize.height} stroke="#eee" strokeWidth="1" />);
}
for (let i = 0; i < numLinesY; i++) {
lines.push(<line key={`h${i}`} x1="0" y1={i * gridSize} x2={canvasSize.width} y2={i * gridSize} stroke="#eee" strokeWidth="1" />);
} //`
return lines;
};
return (
<div
ref = {drop}
className="canvas"
onWheel = {handleWheel} // маштабирование
onMouseDown = {handleMouseDown} // начало перемещения
onMouseMove = {handleMouseMove} // перемещение
onMouseUp = {handleMouseUp} // конец перемещения
>
<svg
width = '100vw'
height = '100vh'
ref = {canvasRef}
style = {{
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom}))`,
transformOrigin: '0 0',
}}
>
{renderGrid()}
{/* Перебираем соединения и рендерим каждое */}
{safeConnections.map((connect, index) => (
<Connection key = {index} connection = {connect} blocks = {safeBlocks} />
))}
{/* Перебираем блоки и рендерим каждый блок в SVG */}
{safeBlocks.map((block) => (
<DraggableBlock key = {block.id} block = {block} moveBlock = {moveBlock} addConnection = {addConnection} />
))}
</svg>
</div>
);
};
const DraggableBlock = ({ block, moveBlock, addConnection }) => {
const [isDragging, setIsDragging] = useState(false); // Состояние для drag
const bind = useDrag(({ active, movement: [mx, my] }) => {
setIsDragging(active); // active = true во время drag
if (!active) {
// При отпускании обновляем позицию
const newX = block.x + mx;
const newY = block.y + my;
moveBlock(block.id, newX, newY);
}
});
return (
<g {...bind()} transform={`translate(${block.x}, ${block.y})`} className={isDragging ? 'dragging' : ''} style={{ cursor: 'move' }}>
<Block type={block.type} label={block.label} addConnection={addConnection} blockId={block.id} />
</g>
);
};
export default Canvas;

View File

@ -0,0 +1,21 @@
import React from 'react';
const Connection = ({ connection, blocks }) => {
//находим блоки по id
const fromBlock = blocks.find (block => block.id === connection.from);
const toBlock = blocks.find (block => block.id === connection.to);
if (!fromBlock || !toBlock) return null;
//координаты
const xFrom = fromBlock.x + 100;
const yFrom = fromBlock.y + 25;
const xTo = toBlock.x;
const y2 = toBlock.y + 25;
return (
<line xFrom = {xFrom} yFrom = {yFrom} xTo = {xTo} yTo = {yTo} stroke = "black" strokeWidth = "2" />
);
};
export default Connection;

View File

@ -0,0 +1,31 @@
import React from 'react';
const ControlPanel = ({ onExport, onImport, onClear }) => {
// Обработчик импорта файла (читает JSON и передаёт в App)
const handleImport = (event) => {
const file = event.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = JSON.parse(e.target.result); // Парсим JSON
onImport(data); // Вызываем функцию из App
} catch (error) {
alert('Ошибка при чтении файла: ' + error.message);
}
};
reader.readAsText(file);
}
};
return (
<div className="controlPanel" style={{ padding: '10px', borderBottom: '1px solid #ccc', backgroundColor: '#f0f0f0', display: 'flex', gap: '10px' }}>
<button onClick={onExport}>Экспорт в JSON</button>
<input type="file" accept=".json" onChange={handleImport} /> {/* Поле для выбора файла импорта */}
<button onClick={onClear}>Очистить холст</button>
{/* Можно добавить: <button>Зум +</button> <button>Зум -</button> */}
</div>
);
};
export default ControlPanel;

106
src/components/Toolbar.js Normal file
View File

@ -0,0 +1,106 @@
import React, { useState } from 'react';
import { useDrag } from 'react-dnd';
import Block from './Block';
import Connection from './Connection';
const Toolbar = ({ onAddBlock }) => {
//состояние для активной вкладки
const [activeTab, setActiveTab] = useState('logical');
const blockCategories = {
logical: [
{ type: 'AND', label: 'AND' },
{ type: 'OR', label: 'OR' },
{ type: 'NOT', label: 'NOT' },
{ type: 'XOR', label: 'XOR' } ],
arithmetic: [
{ type: 'ADD', label: 'ADD' },
{ type: 'SUB', label: 'SUB' },
{ type: 'MUL', label: 'MUL' },
{ type: 'DIV', label: 'DIV' } ],
timers: [
{ type: 'TON', label: 'TON' },
{ type: 'TOF', label: 'TOF' },
{ type: 'TP', label: 'TP' } ],
counters: [
{ type: 'CTU', label: 'CTU' },
{ type: 'CTD', label: 'CTD' },
{ type: 'CTUD', label: 'CTUD' } ],
comparisons: [
{ type: 'GT', label: 'GT' },
{ type: 'LT', label: 'LT' },
{ type: 'EQ', label: 'EQ' } ],
other: [
{ type: 'SR', label: 'SR' },
{ type: 'RS', label: 'RS' },
{ type: 'MOVE', label: 'MOVE' } ],
customized: []
};
const activeBlocks = blockCategories[activeTab] || [];
return (
<div className = "toolbar">
<h4 style = {{ marginTop: '3px', marginBottom: '15px' }}>Блоки</h4>
<div className="toolbarElements">
{/*//Вкладки*/}
<div className = "tabButtons">
{Object.keys (blockCategories).map((tab) => (
<button
key = {tab}
className = {activeTab === tab ? 'activeTab' : ''}
data-row = { tab === 'customized' || tab === 'other' || tab === 'comparisons' ? '2' : '1' }
onClick = { () => setActiveTab(tab) }
title = {
tab === 'logical' ? 'Логические' :
tab === 'arithmetic' ? 'Арифметические' :
tab === 'timers' ? 'Таймеры' :
tab === 'counters' ? 'Счётчики' :
tab === 'comparisons' ? 'Сравнения' :
tab === 'other' ? 'Другие' :
'Пользовательские'
}
> {
tab === 'logical' ? 'Логические' :
tab === 'arithmetic' ? 'Арифметические' :
tab === 'timers' ? 'Таймеры' :
tab === 'counters' ? 'Счётчики' :
tab === 'comparisons' ? 'Сравнения' :
tab === 'other' ? 'Другие' :
'Пользовательские'
} </button>
))}
</div>
{/*Блоки*/}
<div className = "toolbarBlocks">
{activeBlocks.map((block) => (
<DraggableBlock key = {block.type} block = {block} onAddBlock = {onAddBlock} />
))}
</div>
</div>
</div>
);
};
const DraggableBlock = ({ block, onAddBlock }) => {
const [{ isDragging }, drag] = useDrag(() => ({
type: 'block',
item: block,
end: (item, monitor) => {
if (monitor.didDrop()) {}
},
collect: (monitor) => ({ //состояние
isDragging: monitor.isDragging(),
}),
}));
return (
<div ref={drag} style={{ opacity: isDragging ? 0.7 : 1 }}>
<Block type={block.type} label={block.label} />
</div>
);
};
export default Toolbar;

13
src/index.css Normal file
View File

@ -0,0 +1,13 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

21
src/index.js Normal file
View File

@ -0,0 +1,21 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<DndProvider backend={HTML5Backend}>
<App />
</DndProvider>
</React.StrictMode>
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

1
src/logo.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

13
src/reportWebVitals.js Normal file
View File

@ -0,0 +1,13 @@
const reportWebVitals = onPerfEntry => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;

5
src/setupTests.js Normal file
View File

@ -0,0 +1,5 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';

0
src/utils/validation.js Normal file
View File