├── .gitignore ├── README.md ├── package.json ├── public ├── favicon.ico ├── index.html ├── logo192.png ├── logo512.png ├── manifest.json └── robots.txt ├── src ├── App.tsx ├── TextEditor │ ├── Link │ │ ├── Link.tsx │ │ └── index.ts │ ├── TextEditor.scss │ ├── TextEditor.tsx │ ├── config.ts │ ├── context.tsx │ ├── convert.tsx │ ├── index.ts │ └── useEditor.tsx ├── ToolPanel │ ├── ToolPanel.scss │ ├── ToolPanel.tsx │ └── index.ts ├── index.css ├── index.tsx ├── react-app-env.d.ts ├── reportWebVitals.ts └── setupTests.ts ├── tsconfig.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Текстовый редактор на React.js 2 | ## Запуск проекта 3 | ### `yarn start` 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "editor-ts", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^5.11.4", 7 | "@testing-library/react": "^11.1.0", 8 | "@testing-library/user-event": "^12.1.10", 9 | "@types/jest": "^26.0.15", 10 | "@types/node": "^12.0.0", 11 | "@types/react": "^17.0.0", 12 | "@types/react-dom": "^17.0.0", 13 | "classnames": "^2.3.1", 14 | "draft-convert": "^2.1.11", 15 | "draft-js": "^0.11.7", 16 | "react": "^17.0.2", 17 | "react-dom": "^17.0.2", 18 | "react-scripts": "4.0.3", 19 | "typescript": "^4.1.2", 20 | "web-vitals": "^1.0.1" 21 | }, 22 | "scripts": { 23 | "start": "react-scripts start", 24 | "build": "react-scripts build", 25 | "prettier": "prettier --write .", 26 | "test": "react-scripts test", 27 | "eject": "react-scripts eject" 28 | }, 29 | "eslintConfig": { 30 | "extends": [ 31 | "react-app", 32 | "react-app/jest" 33 | ] 34 | }, 35 | "browserslist": { 36 | "production": [ 37 | ">0.2%", 38 | "not dead", 39 | "not op_mini all" 40 | ], 41 | "development": [ 42 | "last 1 chrome version", 43 | "last 1 firefox version", 44 | "last 1 safari version" 45 | ] 46 | }, 47 | "devDependencies": { 48 | "@types/draft-convert": "^2.1.3", 49 | "@types/draft-js": "^0.11.3", 50 | "node-sass": "^4.14.1", 51 | "prettier": "^2.3.2" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ktsstudio/editor-react/3628025612461c473c239289e7bc6db1c1ffa5a8/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ktsstudio/editor-react/3628025612461c473c239289e7bc6db1c1ffa5a8/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ktsstudio/editor-react/3628025612461c473c239289e7bc6db1c1ffa5a8/public/logo512.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import TextEditor, { TextEditorProvider } from "./TextEditor"; 3 | import ToolPanel from "./ToolPanel"; 4 | 5 | function App() { 6 | return ( 7 | 8 | 9 | 10 | 11 | ); 12 | } 13 | 14 | export default App; 15 | -------------------------------------------------------------------------------- /src/TextEditor/Link/Link.tsx: -------------------------------------------------------------------------------- 1 | import { ContentState } from "draft-js"; 2 | import * as React from "react"; 3 | import { useEditorApi } from "../context"; 4 | 5 | type LinkProps = { 6 | children: React.ReactNode; 7 | contentState: ContentState; 8 | entityKey: string; 9 | }; 10 | 11 | const Link: React.FC = ({ contentState, entityKey, children }) => { 12 | const { setEntityData } = useEditorApi(); 13 | const { url, className } = contentState.getEntity(entityKey).getData(); 14 | 15 | const handlerClick = () => { 16 | const newUrl = prompt("URL:", url); 17 | if (newUrl) { 18 | setEntityData(entityKey, { url: newUrl }); 19 | } 20 | }; 21 | 22 | return ( 23 | 24 | {children} 25 | 26 | ); 27 | }; 28 | 29 | export default Link; 30 | -------------------------------------------------------------------------------- /src/TextEditor/Link/index.ts: -------------------------------------------------------------------------------- 1 | import Link from "./Link"; 2 | import { EntityType } from "../config"; 3 | import { ContentBlock, ContentState, DraftDecorator } from "draft-js"; 4 | 5 | function findLinkEntities( 6 | contentBlock: ContentBlock, 7 | callback: (start: number, end: number) => void, 8 | contentState: ContentState 9 | ): void { 10 | contentBlock.findEntityRanges((character) => { 11 | const entityKey = character.getEntity(); 12 | return ( 13 | entityKey !== null && 14 | contentState.getEntity(entityKey).getType() === EntityType.link 15 | ); 16 | }, callback); 17 | } 18 | 19 | const decorator: DraftDecorator = { 20 | strategy: findLinkEntities, 21 | component: Link, 22 | }; 23 | 24 | export default decorator; 25 | -------------------------------------------------------------------------------- /src/TextEditor/TextEditor.scss: -------------------------------------------------------------------------------- 1 | .text-editor { 2 | border: 1px solid black; 3 | padding: 15px; 4 | cursor: pointer; 5 | } 6 | 7 | .public-DraftEditor-content { 8 | min-height: 100px; 9 | } 10 | -------------------------------------------------------------------------------- /src/TextEditor/TextEditor.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Editor } from "draft-js"; 3 | import "./TextEditor.scss"; 4 | import { BLOCK_RENDER_MAP, CUSTOM_STYLE_MAP } from "./config"; 5 | import { useEditorApi } from "./context"; 6 | import cn from "classnames"; 7 | 8 | export type TextEditorProps = { 9 | className?: string; 10 | }; 11 | 12 | const TextEditor: React.FC = ({ className }) => { 13 | const editorApi = useEditorApi(); 14 | 15 | return ( 16 |
17 | 26 |
27 | ); 28 | }; 29 | 30 | export default TextEditor; 31 | -------------------------------------------------------------------------------- /src/TextEditor/config.ts: -------------------------------------------------------------------------------- 1 | import Immutable from "immutable"; 2 | import { DraftEditorCommand, DefaultDraftBlockRenderMap } from "draft-js"; 3 | 4 | export enum EntityType { 5 | link = "link", 6 | } 7 | 8 | export enum BlockType { 9 | h1 = "header-one", 10 | h2 = "header-two", 11 | h3 = "header-three", 12 | h4 = "header-four", 13 | h5 = "header-five", 14 | h6 = "header-six", 15 | blockquote = "blockquote", 16 | code = "code-block", 17 | list = "unordered-list-item", 18 | orderList = "ordered-list-item", 19 | cite = "cite", 20 | default = "unstyled", 21 | } 22 | 23 | export enum InlineStyle { 24 | BOLD = "BOLD", 25 | ITALIC = "ITALIC", 26 | UNDERLINE = "UNDERLINE", 27 | ACCENT = "ACCENT", 28 | } 29 | 30 | export const BLOCK_LABELS = { 31 | [BlockType.h1]: "Заголовок 1", 32 | [BlockType.h2]: "Заголовок 2", 33 | [BlockType.h3]: "Заголовок 3", 34 | [BlockType.h4]: "Заголовок 4", 35 | [BlockType.h5]: "Заголовок 5", 36 | [BlockType.h6]: "Заголовок 6", 37 | [BlockType.blockquote]: "Цитата", 38 | [BlockType.code]: "Блок с кодом", 39 | [BlockType.list]: "Маркированный список", 40 | [BlockType.orderList]: "Нумерованный список", 41 | [BlockType.cite]: "Сноска", 42 | [BlockType.default]: "Обычный текст", 43 | }; 44 | 45 | export type KeyCommand = DraftEditorCommand | "accent"; 46 | 47 | const CUSTOM_BLOCK_RENDER_MAP = Immutable.Map({ 48 | [BlockType.cite]: { 49 | element: "cite", 50 | }, 51 | }); 52 | 53 | export const BLOCK_RENDER_MAP = DefaultDraftBlockRenderMap.merge( 54 | CUSTOM_BLOCK_RENDER_MAP 55 | ); 56 | 57 | export const CUSTOM_STYLE_MAP = { 58 | [InlineStyle.ACCENT]: { 59 | backgroundColor: "#F7F6F3", 60 | color: "#A41E68", 61 | }, 62 | }; 63 | -------------------------------------------------------------------------------- /src/TextEditor/context.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { EditorApi, useEditor } from "./useEditor"; 3 | 4 | const TextEditorContext = React.createContext(undefined); 5 | 6 | export const TextEditorProvider: React.FC = ({ children }) => { 7 | const editorApi = useEditor(); 8 | 9 | return ( 10 | 11 | {children} 12 | 13 | ); 14 | }; 15 | 16 | export const useEditorApi = () => { 17 | const context = React.useContext(TextEditorContext); 18 | if (context === undefined) { 19 | throw new Error("useEditorApi must be used within TextEditorProvider"); 20 | } 21 | 22 | return context; 23 | }; 24 | -------------------------------------------------------------------------------- /src/TextEditor/convert.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable jsx-a11y/heading-has-content */ 2 | import { convertFromHTML, convertToHTML } from "draft-convert"; 3 | import { CUSTOM_STYLE_MAP, BlockType, EntityType, InlineStyle } from "./config"; 4 | 5 | export const stateToHTML = convertToHTML({ 6 | styleToHTML: (style) => { 7 | switch (style) { 8 | case InlineStyle.BOLD: 9 | return ; 10 | case InlineStyle.ITALIC: 11 | return ; 12 | case InlineStyle.UNDERLINE: 13 | return ( 14 | 15 | ); 16 | case InlineStyle.ACCENT: 17 | return ( 18 | 22 | ); 23 | default: 24 | return null; 25 | } 26 | }, 27 | blockToHTML: (block) => { 28 | switch (block.type) { 29 | case BlockType.cite: 30 | return ; 31 | case BlockType.h1: 32 | return

; 33 | case BlockType.h2: 34 | return

; 35 | case BlockType.h3: 36 | return

; 37 | case BlockType.h4: 38 | return

; 39 | case BlockType.h5: 40 | return

; 41 | case BlockType.h6: 42 | return
; 43 | case BlockType.orderList: 44 | return { 45 | element:
  • , 46 | nest:
      , 47 | }; 48 | case BlockType.list: 49 | return { 50 | element:
    1. , 51 | nest:
        , 52 | }; 53 | case BlockType.blockquote: 54 | return
        ; 55 | case BlockType.default: 56 | return

        ; 57 | default: 58 | return null; 59 | } 60 | }, 61 | entityToHTML: (entity, originalText) => { 62 | if (entity.type === EntityType.link) { 63 | return {originalText}; 64 | } 65 | return originalText; 66 | }, 67 | }); 68 | 69 | export const HTMLtoState = convertFromHTML({ 70 | htmlToStyle: (nodeName, node, currentStyle) => { 71 | if (nodeName === "strong") { 72 | return currentStyle.add(InlineStyle.BOLD); 73 | } 74 | 75 | if (nodeName === "em") { 76 | return currentStyle.add(InlineStyle.ITALIC); 77 | } 78 | 79 | if (nodeName === "span" && node.classList.contains("underline")) { 80 | return currentStyle.add(InlineStyle.UNDERLINE); 81 | } 82 | 83 | if (nodeName === "span" && node.classList.contains("accent")) { 84 | return currentStyle.add(InlineStyle.ACCENT); 85 | } 86 | 87 | return currentStyle; 88 | }, 89 | /** Типизация пакета не предусматривает параметр last, но он есть */ 90 | // @ts-ignore 91 | htmlToBlock(nodeName, node, last) { 92 | switch (nodeName) { 93 | case "h1": 94 | return BlockType.h1; 95 | case "h2": 96 | return BlockType.h2; 97 | case "h3": 98 | return BlockType.h3; 99 | case "h4": 100 | return BlockType.h4; 101 | case "li": 102 | if (last === "ol") { 103 | return BlockType.orderList; 104 | } 105 | return BlockType.list; 106 | case "blockquote": 107 | return BlockType.blockquote; 108 | case "cite": 109 | return BlockType.cite; 110 | case "div": 111 | case "p": 112 | return BlockType.default; 113 | default: 114 | return null; 115 | } 116 | }, 117 | htmlToEntity: (nodeName, node, createEntity) => { 118 | if (nodeName === "a" && node.href) { 119 | return createEntity(EntityType.link, "MUTABLE", { url: node.href }); 120 | } 121 | 122 | return undefined; 123 | }, 124 | }); 125 | -------------------------------------------------------------------------------- /src/TextEditor/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./TextEditor"; 2 | 3 | export * from "./TextEditor"; 4 | export * from "./useEditor"; 5 | export * from "./context"; 6 | -------------------------------------------------------------------------------- /src/TextEditor/useEditor.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | KeyBindingUtil, 3 | getDefaultKeyBinding, 4 | DraftHandleValue, 5 | CompositeDecorator, 6 | DraftEntityMutability, 7 | EditorState, 8 | RichUtils, 9 | } from "draft-js"; 10 | import * as React from "react"; 11 | import { BlockType, EntityType, InlineStyle, KeyCommand } from "./config"; 12 | import { HTMLtoState, stateToHTML } from "./convert"; 13 | import LinkDecorator from "./Link"; 14 | 15 | export type EditorApi = { 16 | state: EditorState; 17 | onChange: (state: EditorState) => void; 18 | toggleBlockType: (blockType: BlockType) => void; 19 | currentBlockType: BlockType; 20 | toHtml: () => string; 21 | toggleInlineStyle: (inlineStyle: InlineStyle) => void; 22 | hasInlineStyle: (inlineStyle: InlineStyle) => boolean; 23 | addLink: (url: string) => void; 24 | setEntityData: (entityKey: string, data: Record) => void; 25 | handleKeyCommand: ( 26 | command: KeyCommand, 27 | editorState: EditorState 28 | ) => DraftHandleValue; 29 | handlerKeyBinding: (e: React.KeyboardEvent) => KeyCommand | null; 30 | }; 31 | 32 | const decorator = new CompositeDecorator([LinkDecorator]); 33 | 34 | export const useEditor = (html?: string): EditorApi => { 35 | const [state, setState] = React.useState(() => 36 | html 37 | ? EditorState.createWithContent(HTMLtoState(html), decorator) 38 | : EditorState.createEmpty(decorator) 39 | ); 40 | 41 | const toggleBlockType = React.useCallback((blockType: BlockType) => { 42 | setState((currentState) => 43 | RichUtils.toggleBlockType(currentState, blockType) 44 | ); 45 | }, []); 46 | 47 | const currentBlockType = React.useMemo(() => { 48 | const selection = state.getSelection(); 49 | const content = state.getCurrentContent(); 50 | const block = content.getBlockForKey(selection.getStartKey()); 51 | console.log(block.toJS()); 52 | return block.getType() as BlockType; 53 | }, [state]); 54 | 55 | const toggleInlineStyle = React.useCallback((inlineStyle: InlineStyle) => { 56 | setState((currentState) => 57 | RichUtils.toggleInlineStyle(currentState, inlineStyle) 58 | ); 59 | }, []); 60 | 61 | const hasInlineStyle = React.useCallback( 62 | (inlineStyle: InlineStyle) => { 63 | const currentStyle = state.getCurrentInlineStyle(); 64 | return currentStyle.has(inlineStyle); 65 | }, 66 | [state] 67 | ); 68 | 69 | const setEntityData = React.useCallback((entityKey, data) => { 70 | setState((currentState) => { 71 | const content = currentState.getCurrentContent(); 72 | const contentStateUpdated = content.mergeEntityData(entityKey, data); 73 | return EditorState.push( 74 | currentState, 75 | contentStateUpdated, 76 | "apply-entity" 77 | ); 78 | }); 79 | }, []); 80 | 81 | const addEntity = React.useCallback( 82 | ( 83 | entityType: EntityType, 84 | data: Record, 85 | mutability: DraftEntityMutability 86 | ) => { 87 | setState((currentState) => { 88 | const contentState = currentState.getCurrentContent(); 89 | const contentStateWithEntity = contentState.createEntity( 90 | entityType, 91 | mutability, 92 | data 93 | ); 94 | const entityKey = contentStateWithEntity.getLastCreatedEntityKey(); 95 | const newState = EditorState.set(currentState, { 96 | currentContent: contentStateWithEntity, 97 | }); 98 | return RichUtils.toggleLink( 99 | newState, 100 | newState.getSelection(), 101 | entityKey 102 | ); 103 | }); 104 | }, 105 | [] 106 | ); 107 | 108 | const addLink = React.useCallback( 109 | (url) => { 110 | addEntity(EntityType.link, { url }, "MUTABLE"); 111 | }, 112 | [addEntity] 113 | ); 114 | 115 | const handleKeyCommand = React.useCallback( 116 | (command: KeyCommand, editorState: EditorState) => { 117 | if (command === "accent") { 118 | toggleInlineStyle(InlineStyle.ACCENT); 119 | return "handled"; 120 | } 121 | 122 | const newState = RichUtils.handleKeyCommand(editorState, command); 123 | 124 | if (newState) { 125 | setState(newState); 126 | return "handled"; 127 | } 128 | 129 | return "not-handled"; 130 | }, 131 | [toggleInlineStyle] 132 | ); 133 | 134 | const handlerKeyBinding = React.useCallback((e: React.KeyboardEvent) => { 135 | if (e.keyCode === 81 && KeyBindingUtil.hasCommandModifier(e)) { 136 | return "accent"; 137 | } 138 | 139 | return getDefaultKeyBinding(e); 140 | }, []); 141 | 142 | const toHtml = React.useCallback( 143 | () => stateToHTML(state.getCurrentContent()), 144 | [state] 145 | ); 146 | 147 | return React.useMemo( 148 | () => ({ 149 | state, 150 | onChange: setState, 151 | toggleBlockType, 152 | currentBlockType, 153 | toggleInlineStyle, 154 | hasInlineStyle, 155 | toHtml, 156 | addLink, 157 | setEntityData, 158 | handleKeyCommand, 159 | handlerKeyBinding, 160 | }), 161 | [ 162 | state, 163 | toggleBlockType, 164 | currentBlockType, 165 | toggleInlineStyle, 166 | hasInlineStyle, 167 | toHtml, 168 | addLink, 169 | setEntityData, 170 | handleKeyCommand, 171 | handlerKeyBinding, 172 | ] 173 | ); 174 | }; 175 | -------------------------------------------------------------------------------- /src/ToolPanel/ToolPanel.scss: -------------------------------------------------------------------------------- 1 | .tool-panel { 2 | display: flex; 3 | flex-wrap: wrap; 4 | align-items: center; 5 | margin-left: -5px; 6 | margin-right: -5px; 7 | 8 | &__item { 9 | flex-shrink: 0; 10 | margin: 0 5px 10px; 11 | 12 | &_active { 13 | color: red; 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/ToolPanel/ToolPanel.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useEditorApi } from "../TextEditor"; 3 | import cn from "classnames"; 4 | import { BlockType, InlineStyle } from "../TextEditor/config"; 5 | import "./ToolPanel.scss"; 6 | 7 | const ToolPanel: React.FC = () => { 8 | const { 9 | toHtml, 10 | addLink, 11 | toggleBlockType, 12 | currentBlockType, 13 | toggleInlineStyle, 14 | hasInlineStyle, 15 | } = useEditorApi(); 16 | 17 | return ( 18 |

        19 | 31 | 43 | 55 | 67 | 68 | {Object.values(InlineStyle).map((v) => ( 69 | 82 | ))} 83 | 84 | 95 | 96 | 104 |
        105 | ); 106 | }; 107 | 108 | export default ToolPanel; 109 | -------------------------------------------------------------------------------- /src/ToolPanel/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./ToolPanel"; 2 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 10px; 4 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", 5 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", 6 | sans-serif; 7 | -webkit-font-smoothing: antialiased; 8 | -moz-osx-font-smoothing: grayscale; 9 | } 10 | 11 | * { 12 | box-sizing: border-box; 13 | } 14 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import "./index.css"; 4 | import App from "./App"; 5 | import reportWebVitals from "./reportWebVitals"; 6 | 7 | ReactDOM.render( 8 | 9 | 10 | , 11 | document.getElementById("root") 12 | ); 13 | 14 | // If you want to start measuring performance in your app, pass a function 15 | // to log results (for example: reportWebVitals(console.log)) 16 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 17 | reportWebVitals(); 18 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportHandler } from "web-vitals"; 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import("web-vitals").then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 6 | getCLS(onPerfEntry); 7 | getFID(onPerfEntry); 8 | getFCP(onPerfEntry); 9 | getLCP(onPerfEntry); 10 | getTTFB(onPerfEntry); 11 | }); 12 | } 13 | }; 14 | 15 | export default reportWebVitals; 16 | -------------------------------------------------------------------------------- /src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import "@testing-library/jest-dom"; 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx" 18 | }, 19 | "include": ["src"] 20 | } 21 | --------------------------------------------------------------------------------