├── .eslintrc.json ├── .gitignore ├── .prettierrc.json ├── LICENSE ├── README.md ├── index.html ├── package-lock.json ├── package.json ├── public └── favicon.ico ├── src ├── assets │ ├── github.svg │ ├── next.svg │ └── previous.svg ├── components │ ├── App │ │ ├── App.module.scss │ │ └── App.tsx │ ├── Canvas │ │ ├── Button │ │ │ ├── Button.module.scss │ │ │ └── Button.tsx │ │ ├── Canvas.module.scss │ │ ├── Canvas.tsx │ │ ├── OptionsMenu │ │ │ └── OptionsMenu.tsx │ │ └── ShapesMenu │ │ │ └── ShapesMenu.tsx │ ├── ConfirmModal │ │ ├── ConfirmModal.module.scss │ │ └── ConfirmModal.tsx │ ├── Glyph │ │ ├── Glyph.module.scss │ │ └── Glyph.tsx │ └── GlyphSet │ │ ├── GlyphSet.module.scss │ │ └── GlyphSet.tsx ├── fonts │ ├── ChicagoFLF.ttf │ └── Karektar.otf ├── index.scss ├── main.tsx ├── types.ts ├── utils │ ├── constants │ │ ├── app.constants.ts │ │ └── canvas.constants.ts │ ├── helpers │ │ ├── app.helpers.ts │ │ └── canvas.helpers.ts │ └── reducers │ │ └── fontReducer.ts ├── variables.scss └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true 5 | }, 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:react/recommended", 9 | "plugin:react-hooks/recommended", 10 | "plugin:@typescript-eslint/recommended", 11 | "plugin:@typescript-eslint/recommended-requiring-type-checking", 12 | "prettier" 13 | ], 14 | "overrides": [], 15 | "parser": "@typescript-eslint/parser", 16 | "parserOptions": { 17 | "ecmaVersion": "latest", 18 | "sourceType": "module", 19 | "project": "./tsconfig.json" 20 | }, 21 | "settings": { 22 | "react": { 23 | "version": "detect" 24 | } 25 | }, 26 | "plugins": [ 27 | "react", 28 | "react-hooks", 29 | "@typescript-eslint", 30 | "etc", 31 | "import", 32 | "prettier" 33 | ], 34 | "rules": { 35 | "@typescript-eslint/ban-types": [ 36 | "error", 37 | { 38 | "types": { 39 | "String": { 40 | "message": "Use string instead", 41 | "fixWith": "string" 42 | }, 43 | "Boolean": { 44 | "message": "Use boolean instead", 45 | "fixWith": "boolean" 46 | }, 47 | "Number": { 48 | "message": "Use number instead", 49 | "fixWith": "number" 50 | }, 51 | "Symbol": { 52 | "message": "Use symbol instead", 53 | "fixWith": "symbol" 54 | }, 55 | "BigInt": { 56 | "message": "Use bigint instead", 57 | "fixWith": "bigint" 58 | }, 59 | "Function": { 60 | "message": "The `Function` type accepts any function-like value. It provides no type safety when calling the function, which can be a common source of bugs." 61 | } 62 | }, 63 | "extendDefaults": false 64 | } 65 | ], 66 | "@typescript-eslint/dot-notation": "off", 67 | "@typescript-eslint/member-delimiter-style": [ 68 | "error", 69 | { 70 | "multiline": { 71 | "delimiter": "none", 72 | "requireLast": true 73 | }, 74 | "singleline": { 75 | "delimiter": "semi", 76 | "requireLast": false 77 | } 78 | } 79 | ], 80 | "@typescript-eslint/naming-convention": [ 81 | "error", 82 | { 83 | "selector": "default", 84 | "format": ["camelCase"] 85 | }, 86 | { 87 | "selector": ["objectLiteralProperty"], 88 | "format": ["UPPER_CASE", "camelCase", "PascalCase", "snake_case"], 89 | "leadingUnderscore": "allow" 90 | }, 91 | { 92 | "selector": ["memberLike"], 93 | "format": ["UPPER_CASE", "camelCase", "PascalCase"], 94 | "leadingUnderscore": "allow" 95 | }, 96 | { 97 | "selector": ["classProperty"], 98 | "format": ["UPPER_CASE", "camelCase"], 99 | "leadingUnderscore": "allow" 100 | }, 101 | { 102 | "selector": ["variable", "parameter"], 103 | "format": ["UPPER_CASE", "camelCase", "PascalCase"], 104 | "leadingUnderscore": "allow" 105 | }, 106 | { 107 | "selector": ["typeLike"], 108 | "format": ["PascalCase", "UPPER_CASE"] 109 | }, 110 | { 111 | "selector": ["function"], 112 | "format": ["PascalCase", "camelCase"] 113 | }, 114 | { 115 | "selector": [ 116 | "classProperty", 117 | "objectLiteralProperty", 118 | "typeProperty", 119 | "classMethod", 120 | "objectLiteralMethod", 121 | "typeMethod", 122 | "accessor", 123 | "enumMember" 124 | ], 125 | "format": null, 126 | "modifiers": ["requiresQuotes"] 127 | }, 128 | // We exclude identifiers with names starting with `__UNSAFE_` so that they may 129 | // be used without breaking linting. 130 | { 131 | "selector": ["variable", "parameter", "memberLike", "objectLiteralProperty"], 132 | "format": null, 133 | "filter": { 134 | "regex": "^__UNSAFE_", 135 | "match": true 136 | } 137 | } 138 | ], 139 | "@typescript-eslint/no-empty-function": "error", 140 | "@typescript-eslint/no-floating-promises": "error", 141 | "@typescript-eslint/no-redeclare": ["error"], 142 | "@typescript-eslint/no-shadow": [ 143 | "error", 144 | { 145 | "hoist": "all" 146 | } 147 | ], 148 | "@typescript-eslint/no-unused-expressions": [ 149 | "error", 150 | { 151 | "allowShortCircuit": true, 152 | "allowTernary": true 153 | } 154 | ], 155 | "@typescript-eslint/semi": ["error", "never"], 156 | "@typescript-eslint/type-annotation-spacing": "error", 157 | "brace-style": ["error", "1tbs"], 158 | "comma-dangle": "off", 159 | "curly": "error", 160 | "default-case": "error", 161 | "dot-notation": "off", 162 | "eol-last": "off", 163 | "eqeqeq": ["error", "smart"], 164 | "guard-for-in": "error", 165 | "id-match": "error", 166 | "import/order": [ 167 | "warn", 168 | { 169 | "alphabetize": {"order": "asc", "caseInsensitive": true}, 170 | "pathGroups": [ 171 | { 172 | "pattern": "~/**", 173 | "group": "internal", 174 | "position": "after" 175 | } 176 | ], 177 | "groups": ["builtin", "external", "internal", ["parent", "sibling"]] 178 | } 179 | ], 180 | "no-bitwise": "error", 181 | "no-caller": "error", 182 | "no-cond-assign": ["error", "always"], 183 | "no-console": [ 184 | "off", 185 | { 186 | "allow": [ 187 | "warn", 188 | "dir", 189 | "timeLog", 190 | "assert", 191 | "clear", 192 | "count", 193 | "countReset", 194 | "group", 195 | "groupEnd", 196 | "table", 197 | "dirxml", 198 | "groupCollapsed", 199 | "Console", 200 | "profile", 201 | "profileEnd", 202 | "timeStamp", 203 | "context" 204 | ] 205 | } 206 | ], 207 | "no-debugger": "error", 208 | "no-empty": [ 209 | "error", 210 | { 211 | "allowEmptyCatch": true 212 | } 213 | ], 214 | // no-empty-function is turned off because we are using the 215 | // incompatible rule provided by @typescript-eslint/no-empty-function 216 | "no-constant-condition": ["error", {"checkLoops": false}], 217 | "no-empty-function": "off", 218 | "no-eval": "error", 219 | "no-multiple-empty-lines": "error", 220 | "no-new-wrappers": "error", 221 | "no-trailing-spaces": "off", 222 | "no-unused-labels": "error", 223 | "radix": "error", 224 | "react/display-name": "off", 225 | "react/jsx-boolean-value": "off", 226 | "react/jsx-curly-brace-presence": ["error", "never"], 227 | "react/jsx-curly-spacing": "off", 228 | "react/jsx-equals-spacing": ["error", "never"], 229 | "react/jsx-key": "error", 230 | "react/no-children-prop": "off", 231 | "react/no-danger": "error", 232 | "react/no-unescaped-entities": "off", 233 | "react/prop-types": "off", 234 | "react/jsx-no-bind": "off", 235 | "react/jsx-wrap-multilines": "off", 236 | "react/self-closing-comp": "error", 237 | "semi": ["error", "never"], 238 | "sort-imports": [ 239 | "warn", 240 | { 241 | "ignoreCase": true, 242 | "ignoreDeclarationSort": true 243 | } 244 | ], 245 | "spaced-comment": [ 246 | "error", 247 | "always", 248 | { 249 | "markers": ["/"] 250 | } 251 | ], 252 | "no-fallthrough": ["error", {"allowEmptyCase": true}], 253 | "etc/no-implicit-any-catch": ["error", {"allowExplicitAny": false}], 254 | "react/react-in-jsx-scope": "off" 255 | } 256 | } 257 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "quoteProps": "consistent", 5 | "printWidth": 85, 6 | "trailingComma": "all", 7 | "bracketSpacing": false, 8 | "endOfLine": "lf", 9 | "arrowParens": "avoid" 10 | } 11 | 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Nishanth Jayram 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Karektar 2 | 3 | > Karektar is a web application for building exportable bitmap fonts from custom glyph sets. 4 | 5 | https://karektar.newtrino.ink/ 6 | 7 | ## Features 8 | * Design fonts for specific use cases, without needing to build all possible glyphs. 9 | * Utilize various tools for drawing on the canvas, from the basic pencil/eraser to shape and fill tools. 10 | * Export fonts to an OTF format (utilizing the [opentype.js](https://github.com/opentypejs/opentype.js) library). 11 | 12 | ## Setup 13 | * Clone the repository: `git clone https://github.com/nishanthjayram/karektar.git`. 14 | * Install project dependencies by running `yarn`. 15 | * Start the development server by running `yarn dev`. (Run `yarn dev --host` to expose to other devices on your local network.) 16 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Karektar 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "karektar", 3 | "private": true, 4 | "homepage": "https://nishanthjayram.github.io/karektar", 5 | "version": "0.0.0", 6 | "type": "module", 7 | "scripts": { 8 | "dev": "vite", 9 | "predeploy": "yarn run build", 10 | "deploy": "gh-pages -d dist", 11 | "build": "tsc && vite build", 12 | "preview": "vite preview", 13 | "lint": "eslint 'src/**/*.{js,jsx,ts,tsx,json}'", 14 | "lint:fix": "eslint --fix 'src/**/*.{js,jsx,ts,tsx,json}'", 15 | "format": "prettier --write 'src/**/*.{js,jsx,ts,tsx,css,md,json}' --config ./.prettierrc.json" 16 | }, 17 | "dependencies": { 18 | "@fortawesome/fontawesome-svg-core": "^6.4.0", 19 | "@fortawesome/free-regular-svg-icons": "^6.4.0", 20 | "@fortawesome/free-solid-svg-icons": "^6.4.0", 21 | "@fortawesome/react-fontawesome": "^0.2.0", 22 | "@tippy.js/react": "^3.1.1", 23 | "@types/opentype": "^0.0.4", 24 | "@types/react-modal": "^3.16.0", 25 | "opentype.js": "^1.3.4", 26 | "react": "^18.2.0", 27 | "react-dom": "^18.2.0", 28 | "react-modal": "^3.16.1", 29 | "react-responsive": "^9.0.2", 30 | "vite-plugin-svgr": "^2.4.0" 31 | }, 32 | "devDependencies": { 33 | "@types/react": "^18.0.26", 34 | "@types/react-dom": "^18.0.9", 35 | "@typescript-eslint/eslint-plugin": "^5.51.0", 36 | "@typescript-eslint/parser": "^5.51.0", 37 | "@vitejs/plugin-react": "^3.0.0", 38 | "classnames": "^2.3.2", 39 | "eslint": "^8.34.0", 40 | "eslint-config-prettier": "^8.6.0", 41 | "eslint-plugin-etc": "^2.0.2", 42 | "eslint-plugin-import": "^2.27.5", 43 | "eslint-plugin-prettier": "^4.2.1", 44 | "eslint-plugin-react": "^7.32.2", 45 | "eslint-plugin-react-hooks": "^4.6.0", 46 | "gh-pages": "^5.0.0", 47 | "prettier": "2.8.4", 48 | "sass": "^1.57.1", 49 | "typescript": "^4.9.3", 50 | "vite": "^4.1.5" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nishanthjayram/karektar/11e9fe13a81eb44790690de3cb474d4f47833ca0/public/favicon.ico -------------------------------------------------------------------------------- /src/assets/github.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/previous.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/App/App.module.scss: -------------------------------------------------------------------------------- 1 | .appRow { 2 | display: flex; 3 | flex-flow: row nowrap; 4 | align-items: center; 5 | margin-top: 30px; 6 | gap: 48px; 7 | } 8 | 9 | .page { 10 | height: 100vh; 11 | position: relative; 12 | display: flex; 13 | justify-content: space-between; 14 | flex-flow: column nowrap; 15 | } 16 | 17 | .controls { 18 | display: flex; 19 | } 20 | 21 | .buttonRow { 22 | display: flex; 23 | flex-flow: row nowrap; 24 | } 25 | 26 | .disabledButton { 27 | pointer-events: none; 28 | color: gray; 29 | } 30 | 31 | .input { 32 | background-color: rgba(1, 1, 1, 0.87); 33 | font-family: ChicagoFLF; 34 | font-size: 16px; 35 | color: white; 36 | border-radius: 2px; 37 | outline: none; 38 | border: none; 39 | padding: 6px; 40 | height: 60px; 41 | } 42 | 43 | .footer { 44 | position: absolute; 45 | display: flex; 46 | flex-flow: row nowrap; 47 | align-items: center; 48 | bottom: 0px; 49 | right: 0px; 50 | } 51 | 52 | .footerIcon { 53 | fill: #000; 54 | transform: scale(110%); 55 | padding: 0 10px 0 10px; 56 | transition: all 0.1s ease-in-out; 57 | &:hover { 58 | fill: #ffa500; 59 | } 60 | } 61 | 62 | @media screen and (max-width: 576px) { 63 | .appRow { 64 | display: flex; 65 | flex-flow: row wrap; 66 | align-items: flex-start; 67 | margin-top: 30px; 68 | } 69 | 70 | .input { 71 | font-size: 14px; 72 | } 73 | 74 | .footer { 75 | position: absolute; 76 | bottom: 0; 77 | align-items: center; 78 | justify-content: center; 79 | text-align: center; 80 | font-size: 11px; 81 | } 82 | 83 | .footerIcon { 84 | transform: scale(90%); 85 | padding: 0px 4px 0px 4px; 86 | } 87 | } 88 | 89 | .noclick { 90 | pointer-events: none; 91 | cursor: none; 92 | overflow: hidden; 93 | } 94 | -------------------------------------------------------------------------------- /src/components/App/App.tsx: -------------------------------------------------------------------------------- 1 | import classnames from 'classnames' 2 | import opentype from 'opentype.js' 3 | import {useEffect, useReducer, useState} from 'react' 4 | import Modal from 'react-modal' 5 | import {useMediaQuery} from 'react-responsive' 6 | import styles from './App.module.scss' 7 | import {ReactComponent as GitHub} from '../../assets/github.svg' 8 | import {TConfirmModal, TFontProps} from '../../types' 9 | import { 10 | DEFAULT_FONT_NAME, 11 | DEFAULT_PROMPT, 12 | EXPORT_ALERT, 13 | EXPORT_PROMPT, 14 | HELP_MESSAGE, 15 | MOBILE_HELP_MESSAGE, 16 | RESET_ALERT, 17 | SUBMIT_ALERT, 18 | UNITS_PER_EM, 19 | WIKI_LINK, 20 | } from '../../utils/constants/app.constants' 21 | import { 22 | assertUnreachable, 23 | getUniqueCharacters, 24 | initializeFont, 25 | isEmptyGlyph, 26 | } from '../../utils/helpers/app.helpers' 27 | import {indexToPos} from '../../utils/helpers/canvas.helpers' 28 | import {fontReducer} from '../../utils/reducers/fontReducer' 29 | import Canvas from '../Canvas/Canvas' 30 | import ConfirmModal from '../ConfirmModal/ConfirmModal' 31 | import GlyphSet from '../GlyphSet/GlyphSet' 32 | 33 | const XS_SCREEN = 576 34 | 35 | const App = ({bitmapSize}: {bitmapSize: number}) => { 36 | Modal.setAppElement('#root') 37 | const screenFlag = useMediaQuery({query: `(max-width: ${XS_SCREEN}px)`}) 38 | 39 | const canvasSize = screenFlag ? Math.floor(window.innerWidth / 32) * 32 : 512 40 | const glyphSize = 48 41 | 42 | const [fontState, fontDispatch] = useReducer( 43 | fontReducer, 44 | initializeFont( 45 | bitmapSize, 46 | canvasSize, 47 | screenFlag ? false : undefined, 48 | glyphSize, 49 | DEFAULT_PROMPT, 50 | screenFlag, 51 | ), 52 | ) 53 | 54 | const fontProps = { 55 | fontState: fontState, 56 | fontDispatch: fontDispatch, 57 | } 58 | 59 | const {confirmModal, glyphSetModal} = fontState 60 | 61 | const pageSize = useWindowSize() 62 | 63 | return ( 64 |
73 |
74 | 75 | {!screenFlag && ( 76 | <> 77 | <InputField {...fontProps} /> 78 | <ButtonMenu {...fontProps} /> 79 | </> 80 | )} 81 | <div className={styles.appRow}> 82 | <Canvas {...fontProps} /> 83 | <GlyphSet {...fontProps} /> 84 | </div> 85 | {screenFlag && ( 86 | <> 87 | <br /> 88 | <InputField {...fontProps} /> 89 | <ButtonMenu {...fontProps} /> 90 | </> 91 | )} 92 | </div> 93 | <div className={styles.footer}> 94 | <p> 95 | © 2023 by{' '} 96 | <a href="https://newtrino.ink" target="_blank" rel="noopener noreferrer"> 97 | Nishanth Jayram 98 | </a> 99 | . All rights reserved. 100 | </p> 101 | <a 102 | href="https://github.com/nishanthjayram/karektar" 103 | target="_blank" 104 | rel="noopener noreferrer" 105 | > 106 | <GitHub className={styles.footerIcon} /> 107 | </a> 108 | </div> 109 | </div> 110 | ) 111 | } 112 | 113 | const useWindowSize = () => { 114 | const [size, setSize] = useState(window.innerHeight) 115 | const onResize = () => setSize(window.innerHeight) 116 | 117 | useEffect(() => { 118 | window.addEventListener('resize', onResize) 119 | return window.removeEventListener('resize', onResize) 120 | }, []) 121 | 122 | return size 123 | } 124 | 125 | const Title = () => ( 126 | <h1> 127 | <a href={WIKI_LINK} target="_blank" rel="noreferrer noopener"> 128 | Kare 129 | </a> 130 | ktar. 131 | </h1> 132 | ) 133 | 134 | const InputField: React.FC<TFontProps> = ({fontState, fontDispatch}) => { 135 | const {canvasSize, inputText} = fontState 136 | 137 | const fieldSize = canvasSize - 12 138 | 139 | return ( 140 | <textarea 141 | id="queryField" 142 | name="queryField" 143 | value={inputText} 144 | placeholder="Enter prompt" 145 | onChange={e => 146 | fontDispatch({ 147 | type: 'GLYPH_SET_ACTION', 148 | op: 'UPDATE_INPUT_TEXT', 149 | newInputText: e.target.value, 150 | }) 151 | } 152 | className={styles.input} 153 | style={{ 154 | width: `${fieldSize}px`, 155 | height: '60px', 156 | maxWidth: `${fieldSize}px`, 157 | minWidth: `${fieldSize}px`, 158 | }} 159 | /> 160 | ) 161 | } 162 | 163 | const ButtonMenu: React.FC<TFontProps> = ({fontState, fontDispatch}) => { 164 | const { 165 | bitmapSize, 166 | canvasSize, 167 | glyphSet, 168 | inputText, 169 | pixelSize, 170 | screenFlag, 171 | symbolSet, 172 | } = fontState 173 | 174 | const updateModal = (type: TConfirmModal) => 175 | fontDispatch({ 176 | type: 'GLYPH_SET_ACTION', 177 | op: 'UPDATE_CONFIRM_MODAL', 178 | newConfirmModal: type, 179 | }) 180 | 181 | const handleSubmit = () => 182 | fontDispatch({ 183 | type: 'GLYPH_SET_ACTION', 184 | op: 'UPDATE_SYMBOL_SET', 185 | newSymbolSet: getUniqueCharacters(inputText), 186 | }) 187 | 188 | const handleReset = () => { 189 | fontDispatch({ 190 | type: 'GLYPH_SET_ACTION', 191 | op: 'RESET_GLYPH_SET', 192 | }) 193 | } 194 | 195 | const handleExport = () => { 196 | const fontName = prompt(EXPORT_PROMPT, DEFAULT_FONT_NAME) 197 | 198 | if (!fontName) { 199 | return 200 | } 201 | 202 | const [fontFamily, fontStyle] = [ 203 | fontName.split(' ')[0], 204 | fontName.split(' ')[1] || 'Regular', 205 | ] 206 | 207 | const glyphs = new Array<opentypejs.Glyph>() 208 | 209 | glyphs.push( 210 | new opentype.Glyph({ 211 | name: '.notdef', 212 | unicode: 0, 213 | advanceWidth: UNITS_PER_EM / 2, 214 | path: new opentype.Path(), 215 | }), 216 | ) 217 | 218 | const scaleFactor = UNITS_PER_EM / canvasSize 219 | let maxAscender = -Infinity 220 | let minDescender = Infinity 221 | 222 | glyphSet.forEach((glyph, symbol) => { 223 | const indices = glyph.reduce((acc, curr, idx) => { 224 | if (curr) { 225 | acc.push(idx) 226 | } 227 | return acc 228 | }, new Array<number>()) 229 | 230 | const path = new opentype.Path() 231 | 232 | indices.forEach(idx => { 233 | const [x, y] = indexToPos(idx, bitmapSize) 234 | const x1 = x * pixelSize 235 | const x2 = x1 + pixelSize 236 | const y1 = (bitmapSize - y - 1) * pixelSize 237 | const y2 = y1 + pixelSize 238 | 239 | path.moveTo(x1 * scaleFactor, y1 * scaleFactor) 240 | path.lineTo(x1 * scaleFactor, y2 * scaleFactor) 241 | path.lineTo(x2 * scaleFactor, y2 * scaleFactor) 242 | path.lineTo(x2 * scaleFactor, y1 * scaleFactor) 243 | path.lineTo(x1 * scaleFactor, y1 * scaleFactor) 244 | }) 245 | 246 | const fontGlyph = new opentype.Glyph({ 247 | name: symbol, 248 | unicode: symbol.charCodeAt(0), 249 | advanceWidth: 0, 250 | path: path, 251 | }) 252 | 253 | const {xMax, yMin, yMax} = fontGlyph.getMetrics() 254 | fontGlyph.advanceWidth = xMax 255 | 256 | minDescender = Math.min(minDescender, yMin) 257 | maxAscender = Math.max(maxAscender, yMax) 258 | 259 | glyphs.push(fontGlyph) 260 | }) 261 | 262 | const font = new opentype.Font({ 263 | familyName: fontFamily, 264 | styleName: fontStyle, 265 | unitsPerEm: UNITS_PER_EM, 266 | ascender: maxAscender, 267 | descender: -minDescender, 268 | glyphs: glyphs, 269 | }) 270 | 271 | font.download() 272 | } 273 | 274 | const handlePointerUp = (type: TConfirmModal) => { 275 | switch (type) { 276 | case 'SUBMIT': { 277 | const newSymbolSet = getUniqueCharacters(inputText) 278 | if (symbolSet.some(symbol => !newSymbolSet.includes(symbol))) { 279 | return updateModal(type) 280 | } 281 | return fontDispatch({ 282 | type: 'GLYPH_SET_ACTION', 283 | op: 'UPDATE_SYMBOL_SET', 284 | newSymbolSet: newSymbolSet, 285 | }) 286 | } 287 | case 'RESET': { 288 | return updateModal(type) 289 | } 290 | case 'EXPORT': { 291 | if ([...glyphSet.values()].some(glyph => isEmptyGlyph(glyph))) { 292 | return updateModal(type) 293 | } 294 | return handleExport() 295 | } 296 | case 'HELP': { 297 | return updateModal(type) 298 | } 299 | default: { 300 | return assertUnreachable(type) 301 | } 302 | } 303 | } 304 | 305 | return ( 306 | <> 307 | <ConfirmModal 308 | fontState={fontState} 309 | fontDispatch={fontDispatch} 310 | type="SUBMIT" 311 | onConfirm={handleSubmit} 312 | message={SUBMIT_ALERT} 313 | /> 314 | <ConfirmModal 315 | fontState={fontState} 316 | fontDispatch={fontDispatch} 317 | type="RESET" 318 | onConfirm={handleReset} 319 | message={RESET_ALERT} 320 | /> 321 | <ConfirmModal 322 | fontState={fontState} 323 | fontDispatch={fontDispatch} 324 | type="EXPORT" 325 | onConfirm={handleExport} 326 | message={EXPORT_ALERT} 327 | /> 328 | <ConfirmModal 329 | fontState={fontState} 330 | fontDispatch={fontDispatch} 331 | cancelFlag={false} 332 | type="HELP" 333 | message={screenFlag ? MOBILE_HELP_MESSAGE : HELP_MESSAGE} 334 | /> 335 | <div className={styles.buttonRow}> 336 | <button 337 | className={classnames( 338 | inputText.length === 0 && styles.disabledButton, 339 | styles.button, 340 | )} 341 | onPointerUp={() => handlePointerUp('SUBMIT')} 342 | > 343 | Submit 344 | </button> 345 | <button 346 | className={classnames( 347 | [...glyphSet.values()].every(glyph => isEmptyGlyph(glyph)) && 348 | styles.disabledButton, 349 | styles.button, 350 | )} 351 | onPointerUp={() => handlePointerUp('RESET')} 352 | > 353 | Reset 354 | </button> 355 | <button 356 | className={classnames( 357 | [...glyphSet.values()].every(glyph => isEmptyGlyph(glyph)) && 358 | styles.disabledButton, 359 | styles.button, 360 | )} 361 | onPointerUp={() => handlePointerUp('EXPORT')} 362 | > 363 | Export 364 | </button> 365 | <button 366 | className={styles.button} 367 | onPointerUp={() => handlePointerUp('HELP')} 368 | > 369 | Help 370 | </button> 371 | </div> 372 | </> 373 | ) 374 | } 375 | 376 | export default App 377 | -------------------------------------------------------------------------------- /src/components/Canvas/Button/Button.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../../variables.scss'; 2 | 3 | .icon { 4 | color: #efefef; 5 | width: 22px; 6 | height: $header-height; 7 | padding: 0 12px; 8 | position: relative; 9 | 10 | @media (hover: hover) { 11 | &:hover { 12 | color: #ffa500; 13 | } 14 | } 15 | } 16 | 17 | .optionIcon { 18 | color: #efefef; 19 | width: 22px; 20 | height: $header-height; 21 | padding: 0 12px; 22 | position: relative; 23 | } 24 | 25 | .activeIcon { 26 | color: #ffa500; 27 | } 28 | 29 | .disabledIcon { 30 | color: gray; 31 | pointer-events: none; 32 | } 33 | -------------------------------------------------------------------------------- /src/components/Canvas/Button/Button.tsx: -------------------------------------------------------------------------------- 1 | import {FontAwesomeIcon} from '@fortawesome/react-fontawesome' 2 | import Tippy from '@tippy.js/react' 3 | import classnames from 'classnames' 4 | import styles from './Button.module.scss' 5 | import { 6 | TActionLabel, 7 | TButtonType, 8 | TFont, 9 | TFontAction, 10 | TOptionLabel, 11 | TToolLabel, 12 | } from '../../../types' 13 | import {assertUnreachable, initializeGlyph} from '../../../utils/helpers/app.helpers' 14 | 15 | type TButtonProps = TButtonType & { 16 | active?: boolean 17 | disabled?: boolean 18 | tooltipPlacement?: 'top' | 'right' 19 | fontState: TFont 20 | fontDispatch: React.Dispatch<TFontAction> 21 | } 22 | 23 | const Button: React.FC<TButtonProps> = ({ 24 | type, 25 | label, 26 | icon, 27 | active, 28 | disabled, 29 | tooltipPlacement = 'top', 30 | fontState, 31 | fontDispatch, 32 | }) => { 33 | const { 34 | activeMenu, 35 | bitmapSize, 36 | captureFlag, 37 | guidelinesFlag, 38 | modelFlag, 39 | screenFlag, 40 | } = fontState 41 | 42 | const handleClick = () => { 43 | if (captureFlag) { 44 | return 45 | } 46 | switch (type) { 47 | case 'tool': { 48 | return handleToolClick(label) 49 | } 50 | case 'action': { 51 | return handleActionClick(label) 52 | } 53 | case 'option': { 54 | return handleOptionClick(label) 55 | } 56 | default: { 57 | return assertUnreachable(type) 58 | } 59 | } 60 | } 61 | 62 | const handleToolClick = (tool: TToolLabel) => { 63 | if (activeMenu) { 64 | fontDispatch({ 65 | type: 'CANVAS_ACTION', 66 | op: 'UPDATE_ACTIVE_MENU', 67 | newActiveMenu: undefined, 68 | }) 69 | } 70 | 71 | switch (tool) { 72 | case 'DRAW': 73 | case 'ERASE': 74 | case 'FILL': 75 | case 'LINE': 76 | case 'RECTANGLE': 77 | case 'ELLIPSE': { 78 | return fontDispatch({ 79 | type: 'CANVAS_ACTION', 80 | op: 'UPDATE_CURRENT_TOOL', 81 | newCurrentTool: tool, 82 | }) 83 | } 84 | default: { 85 | return assertUnreachable(tool) 86 | } 87 | } 88 | } 89 | 90 | const handleActionClick = (action: TActionLabel) => { 91 | switch (action) { 92 | case 'CLEAR': { 93 | const newGlyphCanvas = initializeGlyph(bitmapSize) 94 | fontDispatch({ 95 | type: 'GLYPH_SET_ACTION', 96 | op: 'UPDATE_GLYPH_CANVAS', 97 | newGlyphCanvas: newGlyphCanvas, 98 | }) 99 | return fontDispatch({ 100 | type: 'CANVAS_ACTION', 101 | op: 'UPDATE_CANVAS_HISTORY', 102 | newGlyphCanvas: newGlyphCanvas, 103 | }) 104 | } 105 | case 'UNDO': 106 | case 'REDO': { 107 | return fontDispatch({type: 'CANVAS_ACTION', op: action}) 108 | } 109 | default: { 110 | return assertUnreachable(action) 111 | } 112 | } 113 | } 114 | 115 | const handleOptionClick = (option: TOptionLabel) => { 116 | switch (option) { 117 | case 'GUIDELINES': { 118 | return fontDispatch({ 119 | type: 'CANVAS_ACTION', 120 | op: 'UPDATE_GUIDELINES_FLAG', 121 | newGuidelinesFlag: !guidelinesFlag, 122 | }) 123 | } 124 | case 'MODEL': { 125 | return fontDispatch({ 126 | type: 'CANVAS_ACTION', 127 | op: 'UPDATE_MODEL_FLAG', 128 | newModelFlag: !modelFlag, 129 | }) 130 | } 131 | default: { 132 | return assertUnreachable(option) 133 | } 134 | } 135 | } 136 | 137 | const button = ( 138 | <FontAwesomeIcon 139 | icon={icon} 140 | className={classnames( 141 | active && styles.activeIcon, 142 | disabled && styles.disabledIcon, 143 | type === 'option' && styles.optionIcon, 144 | type !== 'option' && styles.icon, 145 | )} 146 | onPointerUp={handleClick} 147 | /> 148 | ) 149 | 150 | return screenFlag ? ( 151 | button 152 | ) : ( 153 | <Tippy content={label} placement={tooltipPlacement} hideOnClick={false}> 154 | {button} 155 | </Tippy> 156 | ) 157 | } 158 | 159 | export default Button 160 | -------------------------------------------------------------------------------- /src/components/Canvas/Canvas.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../variables.scss'; 2 | 3 | .editor { 4 | position: relative; 5 | 6 | .canvas { 7 | position: absolute; 8 | z-index: 20; 9 | top: 0; 10 | left: 0; 11 | touch-action: none; 12 | } 13 | 14 | .model { 15 | position: absolute; 16 | z-index: 10; 17 | top: 0; 18 | left: 0; 19 | } 20 | } 21 | 22 | .header { 23 | position: relative; 24 | background-color: black; 25 | color: #efefef; 26 | display: flex; 27 | flex-flow: row nowrap; 28 | align-items: center; 29 | height: $header-height; 30 | 31 | .text { 32 | font-size: 20px; 33 | text-align: center; 34 | } 35 | 36 | .separator { 37 | border-right: 1px #ffffff solid; 38 | height: $header-height; 39 | } 40 | 41 | .toolbar { 42 | display: flex; 43 | margin-left: auto; 44 | margin-right: 5px; 45 | 46 | .icon { 47 | color: #efefef; 48 | width: 22px; 49 | height: $header-height; 50 | padding: 0 12px; 51 | position: relative; 52 | 53 | @media (hover: hover) { 54 | &:hover { 55 | color: #ffa500; 56 | } 57 | } 58 | } 59 | 60 | .optionIcon { 61 | color: #efefef; 62 | width: 22px; 63 | height: $header-height; 64 | padding: 0 12px; 65 | position: relative; 66 | } 67 | 68 | .activeIcon { 69 | color: #ffa500; 70 | } 71 | 72 | .disabledIcon { 73 | color: gray; 74 | pointer-events: none; 75 | } 76 | 77 | .menu { 78 | display: flex; 79 | flex-flow: column nowrap; 80 | position: absolute; 81 | visibility: hidden; 82 | } 83 | 84 | .menuOpen { 85 | display: flex; 86 | flex-flow: column nowrap; 87 | position: absolute; 88 | visibility: visible; 89 | border-radius: 2px; 90 | background-color: #6d6d6d; 91 | opacity: 0.7; 92 | z-index: 30; 93 | overflow: visible; 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/components/Canvas/Canvas.tsx: -------------------------------------------------------------------------------- 1 | import {faTrashAlt} from '@fortawesome/free-regular-svg-icons' 2 | import {faA, faRedo, faTextHeight, faUndo} from '@fortawesome/free-solid-svg-icons' 3 | import Button from './Button/Button' 4 | import styles from './Canvas.module.scss' 5 | import OptionsMenu from './OptionsMenu/OptionsMenu' 6 | import ToolsMenu from './ShapesMenu/ShapesMenu' 7 | import {TAction, TFont, TFontAction, TFontProps, TGlyph, TOption} from '../../types' 8 | import { 9 | DRAW_TOOLS, 10 | EMPTY_CELL, 11 | FILLED_CELL, 12 | GLYPH_TEXT_WIDTH, 13 | GRIDLINE_COLOR, 14 | GUIDELINE_COLOR, 15 | OPTIONS_MENU_HEADER, 16 | SHAPE_TOOLS, 17 | SHAPES_MENU_HEADER, 18 | } from '../../utils/constants/canvas.constants' 19 | import {assertUnreachable} from '../../utils/helpers/app.helpers' 20 | import { 21 | fill, 22 | getMousePos, 23 | getRect, 24 | plotShape, 25 | posToIndex, 26 | } from '../../utils/helpers/canvas.helpers' 27 | import 'tippy.js/dist/tippy.css' 28 | 29 | type TCanvasProps = { 30 | fontState: TFont 31 | fontDispatch: React.Dispatch<TFontAction> 32 | } 33 | 34 | const Canvas: React.FC<TCanvasProps> = ({fontState, fontDispatch}) => { 35 | const { 36 | activeMenu, 37 | bitmapSize, 38 | activeGlyph, 39 | canvasSize, 40 | currentTool, 41 | glyphSet, 42 | glyphSetModal, 43 | guidelinesFlag, 44 | modelFlag, 45 | pixelSize, 46 | shapeRange, 47 | captureFlag, 48 | vlinePos, 49 | hlinePos, 50 | } = fontState 51 | 52 | const glyphCanvas = glyphSet.get(activeGlyph) 53 | 54 | if (!glyphCanvas) { 55 | return <div /> 56 | } 57 | 58 | const drawCanvas = (canvas: HTMLCanvasElement | null) => { 59 | const ctx = canvas?.getContext('2d') 60 | if (!ctx) { 61 | return 62 | } 63 | 64 | // Draw the gridlines of the canvas. 65 | ctx.beginPath() 66 | ctx.strokeStyle = GRIDLINE_COLOR 67 | Array.from({length: bitmapSize - 1}, (_, i) => i + 1).forEach(i => { 68 | ctx.moveTo(i * pixelSize + 0.5, 1) 69 | ctx.lineTo(i * pixelSize + 0.5, canvasSize) 70 | ctx.moveTo(1, i * pixelSize + 0.5) 71 | ctx.lineTo(canvasSize, i * pixelSize + 0.5) 72 | }) 73 | ctx.closePath() 74 | ctx.stroke() 75 | 76 | if (guidelinesFlag) { 77 | // Draw the horizontal and vertical guidelines of the canvas. 78 | ctx.beginPath() 79 | ctx.strokeStyle = GUIDELINE_COLOR 80 | ctx.moveTo(vlinePos + 0.5, 1) 81 | ctx.lineTo(vlinePos + 0.5, canvasSize) 82 | ctx.moveTo(1, hlinePos + 0.5) 83 | ctx.lineTo(canvasSize, hlinePos + 0.5) 84 | ctx.closePath() 85 | ctx.stroke() 86 | } 87 | 88 | // Draw the cells of the canvas with either an "empty" or "filled" state. 89 | ctx.beginPath() 90 | glyphCanvas.forEach((filled: boolean, idx: number) => { 91 | ctx.fillStyle = filled ? FILLED_CELL : EMPTY_CELL 92 | ctx.fillRect(...getRect(idx, bitmapSize, pixelSize)) 93 | }) 94 | ctx.closePath() 95 | 96 | const shapeCells = plotShape(currentTool, bitmapSize, shapeRange) 97 | if (shapeCells) { 98 | ctx.fillStyle = FILLED_CELL 99 | ctx.beginPath() 100 | shapeCells.forEach(idx => ctx.fillRect(...getRect(idx, bitmapSize, pixelSize))) 101 | ctx.closePath() 102 | } 103 | } 104 | 105 | const drawModel = (canvas: HTMLCanvasElement | null) => { 106 | const ctx = canvas?.getContext('2d') 107 | if (!ctx || !activeGlyph) { 108 | return 109 | } 110 | 111 | ctx.clearRect(0, 0, canvasSize, canvasSize) 112 | 113 | if (modelFlag) { 114 | ctx.beginPath() 115 | ctx.font = `${canvasSize}px Arial` 116 | 117 | ctx.fillText(activeGlyph, 2 * pixelSize, 12 * pixelSize) 118 | ctx.closePath() 119 | } 120 | } 121 | 122 | const handlePointerUp = () => { 123 | const shapeCells = plotShape(currentTool, bitmapSize, shapeRange) 124 | 125 | if (!shapeCells) { 126 | fontDispatch({ 127 | type: 'CANVAS_ACTION', 128 | op: 'UPDATE_CANVAS_HISTORY', 129 | newGlyphCanvas: glyphCanvas, 130 | }) 131 | } else { 132 | const newGlyphCanvas = [...glyphCanvas] 133 | shapeCells.forEach(idx => (newGlyphCanvas[idx] = true)) 134 | fontDispatch({ 135 | type: 'GLYPH_SET_ACTION', 136 | op: 'UPDATE_GLYPH_CANVAS', 137 | newGlyphCanvas: newGlyphCanvas, 138 | }) 139 | fontDispatch({ 140 | type: 'CANVAS_ACTION', 141 | op: 'UPDATE_SHAPE_RANGE', 142 | newShapeRange: undefined, 143 | }) 144 | fontDispatch({ 145 | type: 'CANVAS_ACTION', 146 | op: 'UPDATE_CANVAS_HISTORY', 147 | newGlyphCanvas: newGlyphCanvas, 148 | }) 149 | } 150 | } 151 | 152 | const handlePointerDown = (evt: React.PointerEvent<HTMLCanvasElement>) => { 153 | if (evt.buttons !== 1) { 154 | return 155 | } 156 | 157 | if (activeMenu) { 158 | fontDispatch({ 159 | type: 'CANVAS_ACTION', 160 | op: 'UPDATE_ACTIVE_MENU', 161 | newActiveMenu: undefined, 162 | }) 163 | return 164 | } 165 | 166 | const mousePos = getMousePos(evt, bitmapSize, pixelSize) 167 | if (mousePos === null) { 168 | return 169 | } 170 | 171 | const currIdx = posToIndex(mousePos, bitmapSize) 172 | evt.currentTarget.setPointerCapture(evt.pointerId) 173 | 174 | switch (currentTool) { 175 | case 'DRAW': 176 | case 'ERASE': { 177 | const newGlyphCanvas = [...glyphCanvas] 178 | newGlyphCanvas[currIdx] = currentTool === 'DRAW' ? true : false 179 | return fontDispatch({ 180 | type: 'GLYPH_SET_ACTION', 181 | op: 'UPDATE_GLYPH_CANVAS', 182 | newGlyphCanvas: newGlyphCanvas, 183 | }) 184 | } 185 | case 'LINE': 186 | case 'RECTANGLE': 187 | case 'ELLIPSE': { 188 | return fontDispatch({ 189 | type: 'CANVAS_ACTION', 190 | op: 'UPDATE_SHAPE_RANGE', 191 | newShapeRange: [mousePos, mousePos], 192 | }) 193 | } 194 | case 'FILL': { 195 | const fillCells = fill(mousePos, glyphCanvas, bitmapSize) 196 | if (!fillCells) { 197 | return 198 | } 199 | const newGlyphCanvas = [...glyphCanvas] 200 | fillCells.forEach(idx => (newGlyphCanvas[idx] = true)) 201 | return fontDispatch({ 202 | type: 'GLYPH_SET_ACTION', 203 | op: 'UPDATE_GLYPH_CANVAS', 204 | newGlyphCanvas: newGlyphCanvas, 205 | }) 206 | } 207 | default: { 208 | return assertUnreachable(currentTool) 209 | } 210 | } 211 | } 212 | 213 | const handlePointerMove = (evt: React.PointerEvent<HTMLCanvasElement>) => { 214 | if (evt.buttons !== 1 || !captureFlag) { 215 | return 216 | } 217 | 218 | const mousePos = getMousePos(evt, bitmapSize, pixelSize) 219 | if (mousePos === null) { 220 | return 221 | } 222 | 223 | const currIdx = posToIndex(mousePos, bitmapSize) 224 | 225 | switch (currentTool) { 226 | case 'DRAW': 227 | case 'ERASE': { 228 | const newGlyphCanvas = [...glyphCanvas] 229 | newGlyphCanvas[currIdx] = currentTool === 'DRAW' ? true : false 230 | return fontDispatch({ 231 | type: 'GLYPH_SET_ACTION', 232 | op: 'UPDATE_GLYPH_CANVAS', 233 | newGlyphCanvas: newGlyphCanvas, 234 | }) 235 | } 236 | case 'LINE': 237 | case 'RECTANGLE': 238 | case 'ELLIPSE': { 239 | if (!shapeRange) { 240 | return 241 | } 242 | return fontDispatch({ 243 | type: 'CANVAS_ACTION', 244 | op: 'UPDATE_SHAPE_RANGE', 245 | newShapeRange: [shapeRange[0], mousePos], 246 | }) 247 | } 248 | case 'FILL': { 249 | return 250 | } 251 | default: { 252 | return assertUnreachable(currentTool) 253 | } 254 | } 255 | } 256 | 257 | return ( 258 | <div> 259 | <div className={styles.header} style={{width: canvasSize}}> 260 | <div 261 | className={styles.text} 262 | style={{ 263 | minWidth: `${GLYPH_TEXT_WIDTH}px`, 264 | padding: `0 ${(2 * pixelSize - GLYPH_TEXT_WIDTH) / 2}px`, 265 | }} 266 | onPointerDown={evt => { 267 | if (glyphSetModal !== undefined) { 268 | fontDispatch({ 269 | type: 'GLYPH_SET_ACTION', 270 | op: 'UPDATE_GLYPH_SET_MODAL', 271 | newGlyphSetModal: true, 272 | }) 273 | } 274 | evt.preventDefault() 275 | }} 276 | > 277 | {activeGlyph} 278 | </div> 279 | <div className={styles.separator} /> 280 | <Toolbar 281 | fontState={fontState} 282 | fontDispatch={fontDispatch} 283 | glyphCanvas={glyphCanvas} 284 | /> 285 | </div> 286 | <div className={styles.editor} style={{width: canvasSize, height: canvasSize}}> 287 | <canvas 288 | ref={drawCanvas} 289 | className={styles.canvas} 290 | style={{opacity: modelFlag ? '0.95' : '1'}} 291 | width={canvasSize} 292 | height={canvasSize} 293 | onGotPointerCapture={() => 294 | fontDispatch({ 295 | type: 'CANVAS_ACTION', 296 | op: 'UPDATE_CAPTURE_FLAG', 297 | newCaptureFlag: true, 298 | }) 299 | } 300 | onLostPointerCapture={() => 301 | fontDispatch({ 302 | type: 'CANVAS_ACTION', 303 | op: 'UPDATE_CAPTURE_FLAG', 304 | newCaptureFlag: false, 305 | }) 306 | } 307 | onPointerUp={handlePointerUp} 308 | onPointerDown={evt => handlePointerDown(evt)} 309 | onPointerMove={evt => handlePointerMove(evt)} 310 | /> 311 | <canvas 312 | ref={drawModel} 313 | className={styles.model} 314 | width={canvasSize} 315 | height={canvasSize} 316 | /> 317 | </div> 318 | </div> 319 | ) 320 | } 321 | 322 | type TToolbarProps = TFontProps & { 323 | glyphCanvas: TGlyph 324 | } 325 | 326 | const Toolbar: React.FC<TToolbarProps> = ({ 327 | fontState, 328 | fontDispatch, 329 | glyphCanvas, 330 | }) => { 331 | const { 332 | canvasHistory, 333 | currentTool, 334 | guidelinesFlag, 335 | historyIndex, 336 | modelFlag, 337 | screenFlag, 338 | } = fontState 339 | 340 | const actions: TAction[] = [ 341 | { 342 | type: 'action', 343 | label: 'CLEAR', 344 | icon: faTrashAlt, 345 | disabled: glyphCanvas.every(v => !v), 346 | }, 347 | { 348 | type: 'action', 349 | label: 'UNDO', 350 | icon: faUndo, 351 | disabled: historyIndex === 0, 352 | }, 353 | { 354 | type: 'action', 355 | label: 'REDO', 356 | icon: faRedo, 357 | disabled: historyIndex === canvasHistory.length - 1, 358 | }, 359 | ] 360 | 361 | const options: TOption[] = [ 362 | { 363 | type: 'option', 364 | label: 'GUIDELINES', 365 | icon: faTextHeight, 366 | active: guidelinesFlag, 367 | }, 368 | { 369 | type: 'option', 370 | label: 'MODEL', 371 | icon: faA, 372 | active: modelFlag, 373 | }, 374 | ] 375 | 376 | return ( 377 | <div className={styles.toolbar}> 378 | {!screenFlag && 379 | DRAW_TOOLS.map((props, index) => ( 380 | <Button 381 | key={index} 382 | {...props} 383 | active={currentTool === props.label} 384 | fontState={fontState} 385 | fontDispatch={fontDispatch} 386 | /> 387 | ))} 388 | <ToolsMenu 389 | defaultLabel={SHAPES_MENU_HEADER.defaultLabel} 390 | defaultIcon={SHAPES_MENU_HEADER.defaultIcon} 391 | tools={screenFlag ? [...DRAW_TOOLS, ...SHAPE_TOOLS] : SHAPE_TOOLS} 392 | fontState={fontState} 393 | fontDispatch={fontDispatch} 394 | /> 395 | {actions.map((props, index) => ( 396 | <Button 397 | key={index} 398 | {...props} 399 | fontState={fontState} 400 | fontDispatch={fontDispatch} 401 | /> 402 | ))} 403 | <OptionsMenu 404 | defaultLabel={OPTIONS_MENU_HEADER.defaultLabel} 405 | defaultIcon={OPTIONS_MENU_HEADER.defaultIcon} 406 | options={options} 407 | fontState={fontState} 408 | fontDispatch={fontDispatch} 409 | /> 410 | </div> 411 | ) 412 | } 413 | 414 | export default Canvas 415 | -------------------------------------------------------------------------------- /src/components/Canvas/OptionsMenu/OptionsMenu.tsx: -------------------------------------------------------------------------------- 1 | import {FontAwesomeIcon} from '@fortawesome/react-fontawesome' 2 | import Tippy from '@tippy.js/react' 3 | import classnames from 'classnames' 4 | import {TFont, TFontAction, TMenuHeader, TOption} from '../../../types' 5 | import Button from '../Button/Button' 6 | import styles from '../Canvas.module.scss' 7 | 8 | type TOptionsMenuProps = TMenuHeader & { 9 | options: TOption[] 10 | fontState: TFont 11 | fontDispatch: React.Dispatch<TFontAction> 12 | } 13 | 14 | const OptionsMenu: React.FC<TOptionsMenuProps> = ({ 15 | defaultLabel, 16 | defaultIcon, 17 | options, 18 | fontState, 19 | fontDispatch, 20 | }) => { 21 | const {activeMenu, screenFlag} = fontState 22 | 23 | const menu = ( 24 | <div> 25 | <FontAwesomeIcon 26 | icon={defaultIcon} 27 | className={classnames( 28 | activeMenu === defaultLabel && styles.activeIcon, 29 | styles.icon, 30 | )} 31 | onPointerUp={() => 32 | fontDispatch({ 33 | type: 'CANVAS_ACTION', 34 | op: 'UPDATE_ACTIVE_MENU', 35 | newActiveMenu: activeMenu === defaultLabel ? undefined : defaultLabel, 36 | }) 37 | } 38 | /> 39 | <div 40 | className={classnames( 41 | activeMenu !== defaultLabel && styles.menu, 42 | activeMenu === defaultLabel && styles.menuOpen, 43 | )} 44 | > 45 | {options.map((props, index) => ( 46 | <Button 47 | key={index} 48 | {...props} 49 | tooltipPlacement="right" 50 | fontState={fontState} 51 | fontDispatch={fontDispatch} 52 | /> 53 | ))} 54 | </div> 55 | </div> 56 | ) 57 | 58 | return screenFlag ? ( 59 | menu 60 | ) : ( 61 | <Tippy placement="top" content={defaultLabel} hideOnClick={false}> 62 | {menu} 63 | </Tippy> 64 | ) 65 | } 66 | 67 | export default OptionsMenu 68 | -------------------------------------------------------------------------------- /src/components/Canvas/ShapesMenu/ShapesMenu.tsx: -------------------------------------------------------------------------------- 1 | import {FontAwesomeIcon} from '@fortawesome/react-fontawesome' 2 | import Tippy from '@tippy.js/react' 3 | import classnames from 'classnames' 4 | import {TFont, TFontAction, TMenuHeader, TTool} from '../../../types' 5 | import Button from '../Button/Button' 6 | import styles from '../Canvas.module.scss' 7 | 8 | type TToolsMenu = TMenuHeader & { 9 | tools: TTool[] 10 | fontState: TFont 11 | fontDispatch: React.Dispatch<TFontAction> 12 | } 13 | 14 | const ToolsMenu: React.FC<TToolsMenu> = ({ 15 | defaultLabel, 16 | defaultIcon, 17 | tools, 18 | fontState, 19 | fontDispatch, 20 | }) => { 21 | const {activeMenu, currentTool, screenFlag} = fontState 22 | 23 | const tool = tools.find(t => currentTool === t.label) 24 | const label = tool ? tool.label : defaultLabel 25 | const icon = tool ? tool.icon : defaultIcon 26 | 27 | const menu = ( 28 | <div> 29 | <FontAwesomeIcon 30 | icon={icon} 31 | className={classnames( 32 | (activeMenu === defaultLabel || currentTool === label) && 33 | styles.activeIcon, 34 | styles.icon, 35 | )} 36 | onPointerUp={() => 37 | fontDispatch({ 38 | type: 'CANVAS_ACTION', 39 | op: 'UPDATE_ACTIVE_MENU', 40 | newActiveMenu: activeMenu === defaultLabel ? undefined : defaultLabel, 41 | }) 42 | } 43 | /> 44 | <div 45 | className={classnames( 46 | activeMenu !== defaultLabel && styles.menu, 47 | activeMenu === defaultLabel && styles.menuOpen, 48 | )} 49 | > 50 | {tools.map((props, index) => ( 51 | <Button 52 | key={index} 53 | {...props} 54 | tooltipPlacement="right" 55 | fontState={fontState} 56 | fontDispatch={fontDispatch} 57 | /> 58 | ))} 59 | </div> 60 | </div> 61 | ) 62 | 63 | return screenFlag ? ( 64 | menu 65 | ) : ( 66 | <Tippy placement="top" content={label} hideOnClick={false}> 67 | {menu} 68 | </Tippy> 69 | ) 70 | } 71 | 72 | export default ToolsMenu 73 | -------------------------------------------------------------------------------- /src/components/ConfirmModal/ConfirmModal.module.scss: -------------------------------------------------------------------------------- 1 | .overlay { 2 | position: fixed; 3 | top: 50%; 4 | left: 50%; 5 | transform: translate(-50%, -50%); 6 | background-color: rgba(128, 125, 125, 0.95); 7 | color: white; 8 | z-index: 9999; 9 | width: 600px; 10 | } 11 | 12 | .modal { 13 | outline: none; 14 | margin: auto; 15 | border-radius: 2px; 16 | padding: 10px 20px; 17 | } 18 | 19 | .button { 20 | background-color: rgba(255, 255, 255, 0.87); 21 | color: black; 22 | } 23 | 24 | @media screen and (max-width: 576px) { 25 | .overlay { 26 | width: 400px; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/components/ConfirmModal/ConfirmModal.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Modal from 'react-modal' 3 | import styles from './ConfirmModal.module.scss' 4 | import {TConfirmModal, TFontProps} from '../../types' 5 | 6 | type TConfirmModalProps = TFontProps & { 7 | type: TConfirmModal 8 | onConfirm?: () => void 9 | message: string 10 | cancelFlag?: boolean 11 | } 12 | 13 | const ConfirmModal: React.FC<TConfirmModalProps> = ({ 14 | fontState, 15 | fontDispatch, 16 | type, 17 | onConfirm = () => void 0, 18 | message, 19 | cancelFlag = true, 20 | }) => { 21 | const {confirmModal} = fontState 22 | 23 | const onRequestClose = () => 24 | fontDispatch({ 25 | type: 'GLYPH_SET_ACTION', 26 | op: 'UPDATE_CONFIRM_MODAL', 27 | newConfirmModal: undefined, 28 | }) 29 | 30 | return ( 31 | <Modal 32 | className={styles.modal} 33 | overlayClassName={styles.overlay} 34 | isOpen={confirmModal === type} 35 | > 36 | <p>{message}</p> 37 | <button 38 | className={styles.button} 39 | onClick={() => { 40 | onConfirm() 41 | onRequestClose() 42 | }} 43 | > 44 | Confirm 45 | </button> 46 | {cancelFlag && ( 47 | <button className={styles.button} onClick={onRequestClose}> 48 | Cancel 49 | </button> 50 | )} 51 | </Modal> 52 | ) 53 | } 54 | 55 | export default ConfirmModal 56 | -------------------------------------------------------------------------------- /src/components/Glyph/Glyph.module.scss: -------------------------------------------------------------------------------- 1 | .glyph { 2 | display: flex; 3 | flex-flow: column nowrap; 4 | } 5 | 6 | .symbol { 7 | display: flex; 8 | flex-flow: row wrap; 9 | font-size: 12px; 10 | justify-content: center; 11 | color: white; 12 | background-color: black; 13 | } 14 | 15 | .activeSymbol { 16 | background-color: orange; 17 | } 18 | 19 | .canvas { 20 | display: flex; 21 | flex-flow: row wrap; 22 | border-color: black; 23 | border-width: 1px; 24 | border-style: solid; 25 | } 26 | -------------------------------------------------------------------------------- /src/components/Glyph/Glyph.tsx: -------------------------------------------------------------------------------- 1 | import classnames from 'classnames' 2 | import {memo} from 'react' 3 | import styles from './Glyph.module.scss' 4 | import {TFont, TFontAction} from '../../types' 5 | import {EMPTY_CELL, FILLED_CELL} from '../../utils/constants/canvas.constants' 6 | 7 | const Glyph = ({ 8 | glyph, 9 | fontState, 10 | fontDispatch, 11 | }: { 12 | glyph: string 13 | fontState: TFont 14 | fontDispatch: React.Dispatch<TFontAction> 15 | }) => { 16 | const {activeGlyph, bitmapSize, glyphSet, glyphSetModal, glyphSize} = fontState 17 | const p = glyphSize / bitmapSize 18 | 19 | const glyphCanvas = glyphSet.get(glyph) 20 | 21 | const updateGlyph = (canvas: HTMLCanvasElement | null) => { 22 | const ctx = canvas?.getContext('2d') 23 | if (!ctx || !glyphCanvas) { 24 | return 25 | } 26 | 27 | ctx.beginPath() 28 | glyphCanvas.forEach((filled: boolean, idx: number) => { 29 | const [x, y] = [idx % bitmapSize, Math.floor(idx / bitmapSize)] 30 | ctx.fillStyle = filled ? FILLED_CELL : EMPTY_CELL 31 | ctx.fillRect(x * p, y * p, p, p) 32 | }) 33 | ctx.closePath() 34 | } 35 | 36 | return ( 37 | <div 38 | className={styles.glyph} 39 | onPointerDown={evt => { 40 | if (evt.buttons === 1) { 41 | fontDispatch({ 42 | type: 'GLYPH_SET_ACTION', 43 | op: 'UPDATE_ACTIVE_GLYPH', 44 | newActiveGlyph: glyph, 45 | }) 46 | if (glyphSetModal) { 47 | fontDispatch({ 48 | type: 'GLYPH_SET_ACTION', 49 | op: 'UPDATE_GLYPH_SET_MODAL', 50 | newGlyphSetModal: false, 51 | }) 52 | } 53 | } 54 | }} 55 | > 56 | <div 57 | className={classnames( 58 | glyph === activeGlyph && styles.activeSymbol, 59 | styles.symbol, 60 | )} 61 | > 62 | {glyph} 63 | </div> 64 | <canvas ref={updateGlyph} width={glyphSize} height={glyphSize} /> 65 | </div> 66 | ) 67 | } 68 | 69 | export default memo( 70 | Glyph, 71 | (prevProps, nextProps) => prevProps.fontState === nextProps.fontState, 72 | ) 73 | -------------------------------------------------------------------------------- /src/components/GlyphSet/GlyphSet.module.scss: -------------------------------------------------------------------------------- 1 | .glyphSet { 2 | display: flex; 3 | flex-flow: column nowrap; 4 | width: 450px; 5 | } 6 | 7 | .overlay { 8 | width: 100%; 9 | position: fixed; 10 | display: flex; 11 | flex-flow: column wrap; 12 | justify-content: flex-start; 13 | align-items: center; 14 | background-color: rgba(128, 125, 125, 1); 15 | z-index: 9999; 16 | } 17 | 18 | .modal { 19 | position: fixed; 20 | z-index: 9999; 21 | top: 0; 22 | left: 0; 23 | right: 0; 24 | bottom: 0; 25 | background: rgba(128, 125, 125, 1); 26 | color: white; 27 | outline: none; 28 | text-align: center; 29 | width: 100vw; 30 | padding: 16px; 31 | } 32 | 33 | .navBar { 34 | display: flex; 35 | flex-flow: row nowrap; 36 | align-items: center; 37 | justify-content: space-between; 38 | height: 48px; 39 | } 40 | 41 | .page { 42 | font-size: 20px; 43 | } 44 | 45 | .navControls { 46 | justify-content: space-between; 47 | margin: 0px 0px; 48 | width: 50px; 49 | display: flex; 50 | } 51 | 52 | .navButton { 53 | fill: #000000; 54 | :hover { 55 | fill: #ffa500; 56 | } 57 | } 58 | 59 | .navButtonDisabled { 60 | fill: #b7b5b5; 61 | pointer-events: none; 62 | } 63 | 64 | .gallery { 65 | display: grid; 66 | grid-gap: 18px; 67 | grid-template-columns: repeat(6, 1fr); 68 | border: none; 69 | user-select: none; 70 | height: 450px; 71 | } 72 | 73 | @media screen and (max-width: 576px) { 74 | .gallery { 75 | display: grid; 76 | grid-gap: 6px; 77 | grid-template-columns: repeat(5, 1fr); 78 | border: none; 79 | } 80 | 81 | .glyphSet { 82 | width: calc(100% - 32px); 83 | } 84 | 85 | .navControls { 86 | justify-content: space-between; 87 | align-items: end; 88 | margin: 0px 0px; 89 | width: 50px; 90 | display: flex; 91 | } 92 | 93 | .navButton { 94 | height: 20px; 95 | width: 20px; 96 | fill: #ffffff; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/components/GlyphSet/GlyphSet.tsx: -------------------------------------------------------------------------------- 1 | import classnames from 'classnames' 2 | import Modal from 'react-modal' 3 | import styles from './GlyphSet.module.scss' 4 | import {ReactComponent as Next} from '../../assets/next.svg' 5 | import {ReactComponent as Previous} from '../../assets/previous.svg' 6 | import {TFontProps} from '../../types' 7 | import Glyph from '../Glyph/Glyph' 8 | 9 | const GlyphSet: React.FC<TFontProps> = ({fontState, fontDispatch}) => { 10 | const {glyphSetModal} = fontState 11 | 12 | if (glyphSetModal === undefined) { 13 | return <Gallery fontState={fontState} fontDispatch={fontDispatch} /> 14 | } 15 | 16 | return ( 17 | <Modal 18 | isOpen={glyphSetModal} 19 | className={styles.modal} 20 | overlayClassName={styles.overlay} 21 | > 22 | <h2>GALLERY</h2> 23 | <Gallery fontState={fontState} fontDispatch={fontDispatch} /> 24 | </Modal> 25 | ) 26 | } 27 | 28 | const Gallery: React.FC<TFontProps> = ({fontState, fontDispatch}) => { 29 | const {galleryPage, glyphSet, screenFlag} = fontState 30 | const pageLength = screenFlag ? 25 : 30 31 | const minPage = 0 32 | const maxPage = Math.ceil(glyphSet.size / pageLength) - 1 33 | 34 | if (glyphSet.size === 0) { 35 | return <div className={styles.glyphSet} /> 36 | } 37 | return ( 38 | <div className={styles.glyphSet}> 39 | <div className={styles.navBar}> 40 | <div className={styles.page}>{`${galleryPage + 1}/${maxPage + 1}`}</div> 41 | <div className={styles.navControls}> 42 | <Previous 43 | className={classnames( 44 | galleryPage === minPage && styles.navButtonDisabled, 45 | styles.navButton, 46 | )} 47 | onPointerUp={() => 48 | fontDispatch({ 49 | type: 'GLYPH_SET_ACTION', 50 | op: 'UPDATE_GALLERY_PAGE', 51 | newGalleryPage: galleryPage - 1, 52 | }) 53 | } 54 | /> 55 | <Next 56 | className={classnames( 57 | galleryPage === maxPage && styles.navButtonDisabled, 58 | styles.navButton, 59 | )} 60 | onPointerUp={() => 61 | fontDispatch({ 62 | type: 'GLYPH_SET_ACTION', 63 | op: 'UPDATE_GALLERY_PAGE', 64 | newGalleryPage: galleryPage + 1, 65 | }) 66 | } 67 | /> 68 | </div> 69 | </div> 70 | <div className={styles.gallery}> 71 | {[...glyphSet.keys()] 72 | .slice(galleryPage * pageLength, galleryPage * pageLength + pageLength) 73 | .map(symbol => ( 74 | <Glyph 75 | key={symbol} 76 | glyph={symbol} 77 | fontState={fontState} 78 | fontDispatch={fontDispatch} 79 | /> 80 | ))} 81 | </div> 82 | </div> 83 | ) 84 | } 85 | 86 | export default GlyphSet 87 | -------------------------------------------------------------------------------- /src/fonts/ChicagoFLF.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nishanthjayram/karektar/11e9fe13a81eb44790690de3cb474d4f47833ca0/src/fonts/ChicagoFLF.ttf -------------------------------------------------------------------------------- /src/fonts/Karektar.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nishanthjayram/karektar/11e9fe13a81eb44790690de3cb474d4f47833ca0/src/fonts/Karektar.otf -------------------------------------------------------------------------------- /src/index.scss: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'ChicagoFLF'; 3 | src: local('ChicagoFLF'), url('./fonts/ChicagoFLF.ttf') format('truetype'); 4 | } 5 | 6 | @font-face { 7 | font-family: 'Karektar'; 8 | src: local('Karketar'), url('./fonts/Karektar.otf') format('opentype'); 9 | } 10 | 11 | :root { 12 | display: flex; 13 | align-items: center; 14 | justify-content: center; 15 | 16 | font-family: ChicagoFLF, Helvetica, Arial, sans-serif; 17 | font-size: 16px; 18 | 19 | color: rgba(1, 1, 1, 0.87); 20 | background-color: #ffffff; 21 | 22 | font-synthesis: none; 23 | text-rendering: optimizeLegibility; 24 | -webkit-font-smoothing: antialiased; 25 | -moz-osx-font-smoothing: grayscale; 26 | -webkit-text-size-adjust: 100%; 27 | } 28 | 29 | button { 30 | font-family: ChicagoFLF; 31 | width: 80px; 32 | background-color: rgba(1, 1, 1, 0.87); 33 | color: white; 34 | border-radius: 2px; 35 | height: 28px; 36 | margin-right: 8px; 37 | font-size: 14px; 38 | 39 | @media (hover: hover) { 40 | &:hover { 41 | color: #ffa500; 42 | } 43 | } 44 | 45 | &:active { 46 | background-color: #ffa500; 47 | color: #ffffff; 48 | } 49 | } 50 | 51 | a { 52 | color: #ffa600; 53 | text-decoration: inherit; 54 | 55 | &:hover { 56 | color: #ffc354; 57 | } 58 | } 59 | 60 | body { 61 | margin: 0; 62 | display: flex; 63 | place-items: center; 64 | min-width: 320px; 65 | min-height: 100vh; 66 | } 67 | 68 | h1 { 69 | font-family: Karektar; 70 | font-size: 4em; 71 | margin-bottom: 10px; 72 | margin: 0; 73 | padding: 10px 0 0 0; 74 | } 75 | 76 | @media screen and (max-width: 576px) { 77 | body { 78 | min-height: 0; 79 | } 80 | 81 | h1 { 82 | font-family: Karektar; 83 | font-size: 42px; 84 | margin-bottom: 10px; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import App from './components/App/App' 4 | import './fonts/ChicagoFLF.ttf' 5 | import './index.scss' 6 | 7 | ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( 8 | <React.StrictMode> 9 | <App bitmapSize={16} /> 10 | </React.StrictMode>, 11 | ) 12 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import {IconProp} from '@fortawesome/fontawesome-svg-core' 2 | 3 | export type TToolLabel = 'DRAW' | 'ERASE' | 'FILL' | 'LINE' | 'RECTANGLE' | 'ELLIPSE' 4 | export type TActionLabel = 'CLEAR' | 'UNDO' | 'REDO' 5 | export type TOptionLabel = 'GUIDELINES' | 'MODEL' 6 | export type TTool = { 7 | type: 'tool' 8 | label: TToolLabel 9 | icon: IconProp 10 | } 11 | export type TAction = { 12 | type: 'action' 13 | label: TActionLabel 14 | icon: IconProp 15 | disabled: boolean 16 | } 17 | export type TOption = { 18 | type: 'option' 19 | label: TOptionLabel 20 | icon: IconProp 21 | active: boolean 22 | } 23 | export type TButtonType = TTool | TAction | TOption 24 | 25 | export type TConfirmModal = 'SUBMIT' | 'RESET' | 'EXPORT' | 'HELP' 26 | 27 | export type TMenuLabel = 'SHAPES' | 'OPTIONS' 28 | export type TMenuHeader = { 29 | defaultLabel: TMenuLabel 30 | defaultIcon: IconProp 31 | } 32 | 33 | export type TPos = [x: number, y: number] 34 | export type TRect = [x: number, y: number, w: number, h: number] 35 | export type TShapeRange = [startPos: TPos, endPos: TPos] | undefined 36 | 37 | export type TSymbol = string 38 | export type TGlyph = boolean[] 39 | export type TGlyphSet = Map<TSymbol, TGlyph> 40 | 41 | export type TFont = { 42 | activeGlyph: string 43 | activeMenu: TMenuLabel | undefined 44 | bitmapSize: number 45 | canvasHistory: boolean[][] 46 | canvasSize: number 47 | captureFlag: boolean 48 | confirmModal: TConfirmModal | undefined 49 | currentTool: TToolLabel 50 | galleryPage: number 51 | glyphSet: TGlyphSet 52 | glyphSetModal: boolean | undefined 53 | glyphSize: number 54 | guidelinesFlag: boolean 55 | historyIndex: number 56 | hlinePos: number 57 | inputText: string 58 | modelFlag: boolean 59 | pixelSize: number 60 | screenFlag: boolean 61 | shapeRange: TShapeRange 62 | symbolSet: string[] 63 | vlinePos: number 64 | } 65 | 66 | export type TCanvasAction = {type: 'CANVAS_ACTION'} & ( 67 | | {op: 'UPDATE_ACTIVE_MENU'; newActiveMenu: TMenuLabel | undefined} 68 | | {op: 'UPDATE_CANVAS_HISTORY'; newGlyphCanvas: boolean[]} 69 | | {op: 'UPDATE_CANVAS_SIZE'; newCanvasSize: number} 70 | | {op: 'UPDATE_CAPTURE_FLAG'; newCaptureFlag: boolean} 71 | | {op: 'UPDATE_CURRENT_TOOL'; newCurrentTool: TToolLabel} 72 | | {op: 'UPDATE_GUIDELINES_FLAG'; newGuidelinesFlag: boolean} 73 | | {op: 'UPDATE_MODEL_FLAG'; newModelFlag: boolean} 74 | | {op: 'UPDATE_SHAPE_RANGE'; newShapeRange: TShapeRange} 75 | | {op: 'UNDO'} 76 | | {op: 'REDO'} 77 | ) 78 | export type TGlyphSetAction = {type: 'GLYPH_SET_ACTION'} & ( 79 | | {op: 'RESET_GLYPH_SET'} 80 | | {op: 'UPDATE_ACTIVE_GLYPH'; newActiveGlyph: string} 81 | | {op: 'UPDATE_CONFIRM_MODAL'; newConfirmModal: TConfirmModal | undefined} 82 | | {op: 'UPDATE_GALLERY_PAGE'; newGalleryPage: number} 83 | | {op: 'UPDATE_GLYPH_CANVAS'; newGlyphCanvas: boolean[]} 84 | | {op: 'UPDATE_GLYPH_SIZE'; newGlyphSize: number} 85 | | {op: 'UPDATE_GLYPH_SET_MODAL'; newGlyphSetModal: boolean | undefined} 86 | | {op: 'UPDATE_INPUT_TEXT'; newInputText: string} 87 | | {op: 'UPDATE_SYMBOL_SET'; newSymbolSet: string[]} 88 | ) 89 | 90 | export type TFontAction = TCanvasAction | TGlyphSetAction 91 | export type TFontProps = { 92 | fontState: TFont 93 | fontDispatch: React.Dispatch<TFontAction> 94 | } 95 | -------------------------------------------------------------------------------- /src/utils/constants/app.constants.ts: -------------------------------------------------------------------------------- 1 | export const SCREEN_BREAKPOINTS = { 2 | xs: 576, 3 | sm: 768, 4 | md: 992, 5 | lg: 1200, 6 | } 7 | export const DEFAULT_FONT_NAME = 'Karektar Regular' 8 | export const DEFAULT_PROMPT = 'sphinx of black quartz judge my vow' 9 | export const DEFAULT_SYMBOLS = 'abcdefghijklmnopqrstuvwxyz'.split('') 10 | export const EXPORT_ALERT = 11 | 'You are about to export with empty glyphs in your glyph set. Are you sure you want to continue?' 12 | export const EXPORT_PROMPT = 13 | 'Enter your font family name and style name, separated by space. (Default style is "Regular".)' 14 | export const RESET_ALERT = 15 | 'You are about to reset all progress from your glyph set. Are you sure you want to continue?' 16 | export const RX_LETTERS = /[A-Za-z]/g 17 | export const RX_NON_ALPHANUMERIC = /[^A-Za-z0-9\s]/g 18 | export const RX_NUMBERS = /[0-9]/g 19 | export const SUBMIT_ALERT = 20 | 'You are about to remove some of the existing glyphs in your set. Are you sure you want to continue?' 21 | export const WIKI_LINK = 'https://en.wikipedia.org/wiki/Susan_Kare' 22 | export const UNITS_PER_EM = 1024 23 | 24 | export const HELP_MESSAGE = 25 | 'Edit the input field and click "Submit" to generate your own glyphset. Drag across ' + 26 | 'the canvas to draw, or perform other operations by selecting one of the given tools. ' + 27 | "Use the gallery on the right to change to editing other glyphs. When you're done, " + 28 | 'click "Export" to save your work to an OTF file!' 29 | 30 | export const MOBILE_HELP_MESSAGE = 31 | 'Edit the input field and click "Submit" to generate your own glyphset. Drag across ' + 32 | 'the canvas to draw, or tap on the pencil icon to select another tool. You can also ' + 33 | 'tap on the icon of the current glyph on the top right to view the gallery, where you ' + 34 | "can switch to editing other glyphs. When you're done, " + 35 | 'click "Export" to save your work to an OTF file!' 36 | -------------------------------------------------------------------------------- /src/utils/constants/canvas.constants.ts: -------------------------------------------------------------------------------- 1 | import {faCircle, faSquare} from '@fortawesome/free-regular-svg-icons' 2 | import { 3 | faEraser, 4 | faFill, 5 | faGear, 6 | faPen, 7 | faShapes, 8 | faSlash, 9 | } from '@fortawesome/free-solid-svg-icons' 10 | import {TMenuHeader, TTool} from '../../types' 11 | 12 | export const GLYPH_TEXT_WIDTH = 30 13 | export const GRIDLINE_COLOR = '#ffffff' 14 | export const GUIDELINE_COLOR = '#ffa500' 15 | export const EMPTY_CELL = '#efefef' 16 | export const FILLED_CELL = '#2b2b2b' 17 | 18 | export const DRAW_TOOLS: TTool[] = [ 19 | { 20 | type: 'tool', 21 | label: 'DRAW', 22 | icon: faPen, 23 | }, 24 | { 25 | type: 'tool', 26 | label: 'ERASE', 27 | icon: faEraser, 28 | }, 29 | { 30 | type: 'tool', 31 | label: 'FILL', 32 | icon: faFill, 33 | }, 34 | ] 35 | 36 | export const SHAPE_TOOLS: TTool[] = [ 37 | { 38 | type: 'tool', 39 | label: 'LINE', 40 | icon: faSlash, 41 | }, 42 | { 43 | type: 'tool', 44 | label: 'RECTANGLE', 45 | icon: faSquare, 46 | }, 47 | { 48 | type: 'tool', 49 | label: 'ELLIPSE', 50 | icon: faCircle, 51 | }, 52 | ] 53 | 54 | export const SHAPES_MENU_HEADER: TMenuHeader = { 55 | defaultLabel: 'SHAPES', 56 | defaultIcon: faShapes, 57 | } 58 | 59 | export const OPTIONS_MENU_HEADER: TMenuHeader = { 60 | defaultLabel: 'OPTIONS', 61 | defaultIcon: faGear, 62 | } 63 | -------------------------------------------------------------------------------- /src/utils/helpers/app.helpers.ts: -------------------------------------------------------------------------------- 1 | import {TFont, TGlyph, TGlyphSet, TSymbol} from '../../types' 2 | import { 3 | RX_LETTERS, 4 | RX_NON_ALPHANUMERIC, 5 | RX_NUMBERS, 6 | } from '../constants/app.constants' 7 | 8 | export const assertUnreachable = (x: never): never => { 9 | // eslint-disable-next-line @typescript-eslint/restrict-template-expressions 10 | throw new Error(`Default case should not be reachable. ${x}`) 11 | } 12 | 13 | export const compareArrays = <T>(a: T[], b: T[]) => 14 | a !== undefined && 15 | b !== undefined && 16 | a.length === b.length && 17 | a.every((v, i) => v === b[i]) 18 | export const getUniqueCharacters = (input: string) => { 19 | const uniqueCharacters = [...new Set<string>(input)] 20 | 21 | const letters = uniqueCharacters.flatMap(c => c.match(RX_LETTERS) ?? []) 22 | const numbers = uniqueCharacters.flatMap(c => c.match(RX_NUMBERS) ?? []) 23 | const nonAlphaNum = uniqueCharacters.flatMap( 24 | c => c.match(RX_NON_ALPHANUMERIC) ?? [], 25 | ) 26 | 27 | return [letters, numbers, nonAlphaNum].flatMap(c => c.sort()) 28 | } 29 | 30 | export const initializeFont = ( 31 | bitmapSize: number, 32 | canvasSize: number, 33 | glyphSetModal: boolean | undefined, 34 | glyphSize: number, 35 | inputText: string, 36 | screenFlag: boolean, 37 | ): TFont => { 38 | const pixelSize = canvasSize / bitmapSize 39 | const symbolSet = getUniqueCharacters(inputText) 40 | 41 | const newGlyphSet: TGlyphSet = new Map<TSymbol, TGlyph>() 42 | symbolSet.forEach(symbol => newGlyphSet.set(symbol, initializeGlyph(bitmapSize))) 43 | return { 44 | activeGlyph: symbolSet[0], 45 | activeMenu: undefined, 46 | bitmapSize: bitmapSize, 47 | canvasHistory: [initializeGlyph(bitmapSize)], 48 | canvasSize: canvasSize, 49 | captureFlag: false, 50 | confirmModal: undefined, 51 | currentTool: 'DRAW', 52 | galleryPage: 0, 53 | glyphSet: newGlyphSet, 54 | glyphSetModal: glyphSetModal, 55 | glyphSize: glyphSize, 56 | guidelinesFlag: true, 57 | historyIndex: 0, 58 | hlinePos: pixelSize * 12, 59 | inputText: inputText, 60 | modelFlag: true, 61 | pixelSize: pixelSize, 62 | screenFlag: screenFlag, 63 | shapeRange: undefined, 64 | symbolSet: symbolSet, 65 | vlinePos: pixelSize * 2, 66 | } 67 | } 68 | 69 | export const initializeGlyph = (bitmapSize: number) => 70 | new Array<boolean>(bitmapSize ** 2).fill(false) 71 | 72 | export const isEmptyGlyph = (glyph: TGlyph) => glyph.every(c => !c) 73 | -------------------------------------------------------------------------------- /src/utils/helpers/canvas.helpers.ts: -------------------------------------------------------------------------------- 1 | import {assertUnreachable} from './app.helpers' 2 | import {TPos, TRect, TShapeRange, TToolLabel} from '../../types' 3 | 4 | export const checkPos = ([x, y]: TPos, bitmapSize: number) => 5 | x >= 0 && y >= 0 && x <= bitmapSize - 1 && y <= bitmapSize - 1 6 | 7 | export const getMousePos = ( 8 | evt: React.PointerEvent<HTMLCanvasElement>, 9 | bitmapSize: number, 10 | pixelSize: number, 11 | ): TPos | null => { 12 | const [x, y] = [ 13 | Math.floor(evt.nativeEvent.offsetX / pixelSize), 14 | Math.floor(evt.nativeEvent.offsetY / pixelSize), 15 | ] 16 | return checkPos([x, y], bitmapSize) ? [x, y] : null 17 | } 18 | 19 | export const posToIndex = (pos: TPos, bitmapSize: number): number => 20 | bitmapSize * pos[1] + pos[0] 21 | export const indexToPos = (idx: number, bitmapSize: number): TPos => [ 22 | idx % bitmapSize, 23 | Math.floor(idx / bitmapSize), 24 | ] 25 | 26 | export const getDistance = ([x0, y0]: TPos, [x1, y1]: TPos): TPos => [ 27 | Math.abs(x1 - x0), 28 | Math.abs(y1 - y0), 29 | ] 30 | export const getRect = ( 31 | idx: number, 32 | bitmapSize: number, 33 | pixelSize: number, 34 | ): TRect => { 35 | const [x, y] = indexToPos(idx, bitmapSize) 36 | return [x * pixelSize + 1, y * pixelSize + 1, pixelSize - 1, pixelSize - 1] 37 | } 38 | 39 | export const plotLine = ([x0, y0]: TPos, [x1, y1]: TPos, bitmapSize: number) => { 40 | const coords = new Array<TPos>() 41 | 42 | const dx = Math.abs(x1 - x0) 43 | const sx = x0 < x1 ? 1 : -1 44 | const dy = -Math.abs(y1 - y0) 45 | const sy = y0 < y1 ? 1 : -1 46 | let error = dx + dy 47 | 48 | while (true) { 49 | coords.push([x0, y0]) 50 | if (x0 === x1 && y0 === y1) { 51 | break 52 | } 53 | 54 | const e2 = 2 * error 55 | 56 | if (e2 >= dy) { 57 | if (x0 === x1) { 58 | break 59 | } 60 | error = error + dy 61 | x0 = x0 + sx 62 | } 63 | if (e2 <= dx) { 64 | if (y0 === y1) { 65 | break 66 | } 67 | error = error + dx 68 | y0 = y0 + sy 69 | } 70 | } 71 | 72 | return coords.map(c => posToIndex(c, bitmapSize)) 73 | } 74 | 75 | export const plotRect = ([x0, y0]: TPos, [x1, y1]: TPos, bitmapSize: number) => { 76 | const [xMin, xMax, yMin, yMax] = [ 77 | Math.min(x0, x1), 78 | Math.max(x0, x1), 79 | Math.min(y0, y1), 80 | Math.max(y0, y1), 81 | ] 82 | 83 | const coords = new Array<TPos>() 84 | for (let i = xMin; i <= xMax; i++) { 85 | coords.push([i, y0]) 86 | coords.push([i, y1]) 87 | } 88 | for (let j = yMin; j <= yMax; j++) { 89 | coords.push([x0, j]) 90 | coords.push([x1, j]) 91 | } 92 | 93 | return coords.map(c => posToIndex(c, bitmapSize)) 94 | } 95 | 96 | export const plotEllipse = ([xc, yc]: TPos, [rx, ry]: TPos, bitmapSize: number) => { 97 | const coords = new Array<TPos>() 98 | let [x, y] = [0, ry] 99 | 100 | let d1 = ry * ry - rx * rx * ry + 0.25 * rx * rx 101 | let dx = 2 * ry * ry * x 102 | let dy = 2 * rx * rx * y 103 | 104 | while (dx < dy) { 105 | coords.push( 106 | [x + xc, y + yc], 107 | [-x + xc, y + yc], 108 | [x + xc, -y + yc], 109 | [-x + xc, -y + yc], 110 | ) 111 | 112 | if (d1 < 0) { 113 | x++ 114 | dx = dx + 2 * ry * ry 115 | d1 = d1 + dx + ry * ry 116 | } else { 117 | x++ 118 | y-- 119 | dx = dx + 2 * ry * ry 120 | dy = dy - 2 * rx * rx 121 | d1 = d1 + dx - dy + ry * ry 122 | } 123 | } 124 | 125 | let d2 = 126 | ry * ry * ((x + 0.5) * (x + 0.5)) + 127 | rx * rx * ((y - 1) * (y - 1)) - 128 | rx * rx * ry * ry 129 | 130 | while (y >= 0) { 131 | coords.push( 132 | [x + xc, y + yc], 133 | [-x + xc, y + yc], 134 | [x + xc, -y + yc], 135 | [-x + xc, -y + yc], 136 | ) 137 | 138 | if (d2 > 0) { 139 | y-- 140 | dy = dy - 2 * rx * rx 141 | d2 = d2 + rx * rx - dy 142 | } else { 143 | y-- 144 | x++ 145 | dx = dx + 2 * ry * ry 146 | dy = dy - 2 * rx * rx 147 | d2 = d2 + dx - dy + rx * rx 148 | } 149 | } 150 | 151 | return coords.map(c => posToIndex(c, bitmapSize)) 152 | } 153 | 154 | export const plotShape = ( 155 | shapeTool: TToolLabel, 156 | bitmapSize: number, 157 | shapeRange: TShapeRange, 158 | ) => { 159 | if (!shapeRange) { 160 | return 161 | } 162 | 163 | const [startPos, endPos] = shapeRange 164 | switch (shapeTool) { 165 | case 'LINE': { 166 | return plotLine(startPos, endPos, bitmapSize) 167 | } 168 | case 'RECTANGLE': { 169 | return plotRect(startPos, endPos, bitmapSize) 170 | } 171 | case 'ELLIPSE': { 172 | return plotEllipse(endPos, getDistance(startPos, endPos), bitmapSize) 173 | } 174 | case 'DRAW': 175 | case 'ERASE': 176 | case 'FILL': { 177 | return 178 | } 179 | default: { 180 | return assertUnreachable(shapeTool) 181 | } 182 | } 183 | } 184 | 185 | export const fill = (start: TPos, glyphCanvas: boolean[], bitmapSize: number) => { 186 | if (glyphCanvas[posToIndex(start, bitmapSize)]) { 187 | return 188 | } 189 | 190 | const queue = [start] 191 | const visited = new Array<TPos>() 192 | 193 | while (queue.length > 0) { 194 | const pos = queue.pop() 195 | 196 | if (pos === undefined) { 197 | break 198 | } 199 | 200 | const [x, y] = pos 201 | const cells: TPos[] = [ 202 | [x, y], 203 | [x + 1, y], 204 | [x - 1, y], 205 | [x, y + 1], 206 | [x, y - 1], 207 | ] 208 | cells.forEach(c => { 209 | if ( 210 | checkPos(c, bitmapSize) && 211 | !visited.some(e => e.join() === c.join()) && 212 | !glyphCanvas[posToIndex(c, bitmapSize)] 213 | ) { 214 | queue.push(c) 215 | visited.push(c) 216 | } 217 | }) 218 | } 219 | 220 | return visited.map(c => posToIndex(c, bitmapSize)) 221 | } 222 | -------------------------------------------------------------------------------- /src/utils/reducers/fontReducer.ts: -------------------------------------------------------------------------------- 1 | import { 2 | TCanvasAction, 3 | TFont, 4 | TFontAction, 5 | TGlyph, 6 | TGlyphSet, 7 | TGlyphSetAction, 8 | TSymbol, 9 | } from '../../types' 10 | import {compareArrays, initializeGlyph} from '../helpers/app.helpers' 11 | 12 | export const fontReducer = (state: TFont, action: TFontAction): TFont => { 13 | switch (action.type) { 14 | case 'CANVAS_ACTION': { 15 | return {...state, ...canvasReducer(state, action)} 16 | } 17 | case 'GLYPH_SET_ACTION': { 18 | return {...state, ...glyphSetReducer(state, action)} 19 | } 20 | default: { 21 | return state 22 | } 23 | } 24 | } 25 | 26 | export const glyphSetReducer = (state: TFont, action: TGlyphSetAction): TFont => { 27 | switch (action.op) { 28 | case 'UPDATE_ACTIVE_GLYPH': { 29 | if (state.activeGlyph === action.newActiveGlyph) { 30 | return state 31 | } 32 | const newGlyphCanvas = state.glyphSet.get(action.newActiveGlyph) 33 | return { 34 | ...state, 35 | activeGlyph: action.newActiveGlyph, 36 | canvasHistory: [newGlyphCanvas ?? initializeGlyph(state.bitmapSize)], 37 | historyIndex: 0, 38 | } 39 | } 40 | case 'UPDATE_CONFIRM_MODAL': { 41 | return {...state, confirmModal: action.newConfirmModal} 42 | } 43 | case 'UPDATE_GALLERY_PAGE': { 44 | return {...state, galleryPage: action.newGalleryPage} 45 | } 46 | case 'UPDATE_GLYPH_CANVAS': { 47 | const newGlyphSet = new Map(state.glyphSet) 48 | newGlyphSet.set(state.activeGlyph, action.newGlyphCanvas) 49 | return {...state, glyphSet: newGlyphSet} 50 | } 51 | case 'UPDATE_GLYPH_SET_MODAL': { 52 | return {...state, glyphSetModal: action.newGlyphSetModal} 53 | } 54 | case 'UPDATE_INPUT_TEXT': { 55 | return {...state, inputText: action.newInputText} 56 | } 57 | case 'UPDATE_SYMBOL_SET': { 58 | const newGlyphSet: TGlyphSet = new Map<TSymbol, TGlyph>() 59 | action.newSymbolSet.forEach(symbol => 60 | newGlyphSet.set( 61 | symbol, 62 | state.glyphSet.get(symbol) ?? initializeGlyph(state.bitmapSize), 63 | ), 64 | ) 65 | 66 | const activeGlyphFlag = action.newSymbolSet.includes(state.activeGlyph) 67 | return { 68 | ...state, 69 | activeGlyph: activeGlyphFlag ? state.activeGlyph : action.newSymbolSet[0], 70 | canvasHistory: activeGlyphFlag 71 | ? state.canvasHistory 72 | : [initializeGlyph(state.bitmapSize)], 73 | glyphSet: newGlyphSet, 74 | historyIndex: activeGlyphFlag ? state.historyIndex : 0, 75 | symbolSet: action.newSymbolSet, 76 | } 77 | } 78 | case 'RESET_GLYPH_SET': { 79 | const newGlyphSet = new Map(state.glyphSet) 80 | newGlyphSet.forEach((_, symbol) => 81 | newGlyphSet.set(symbol, initializeGlyph(state.bitmapSize)), 82 | ) 83 | return { 84 | ...state, 85 | canvasHistory: [initializeGlyph(state.bitmapSize)], 86 | glyphSet: newGlyphSet, 87 | historyIndex: 0, 88 | } 89 | } 90 | default: { 91 | return state 92 | } 93 | } 94 | } 95 | 96 | export const canvasReducer = (state: TFont, action: TCanvasAction): TFont => { 97 | switch (action.op) { 98 | case 'UPDATE_ACTIVE_MENU': { 99 | return {...state, activeMenu: action.newActiveMenu} 100 | } 101 | case 'UPDATE_CANVAS_HISTORY': { 102 | return compareArrays( 103 | state.canvasHistory[state.historyIndex], 104 | action.newGlyphCanvas, 105 | ) 106 | ? state 107 | : { 108 | ...state, 109 | canvasHistory: [ 110 | ...state.canvasHistory.slice(0, state.historyIndex + 1), 111 | action.newGlyphCanvas, 112 | ], 113 | historyIndex: state.historyIndex + 1, 114 | } 115 | } 116 | case 'UPDATE_CANVAS_SIZE': { 117 | return {...state, canvasSize: action.newCanvasSize} 118 | } 119 | case 'UPDATE_CAPTURE_FLAG': { 120 | return {...state, captureFlag: action.newCaptureFlag} 121 | } 122 | case 'UPDATE_CURRENT_TOOL': { 123 | return {...state, currentTool: action.newCurrentTool} 124 | } 125 | case 'UPDATE_GUIDELINES_FLAG': { 126 | return {...state, guidelinesFlag: action.newGuidelinesFlag} 127 | } 128 | case 'UPDATE_MODEL_FLAG': { 129 | return {...state, modelFlag: action.newModelFlag} 130 | } 131 | case 'UPDATE_SHAPE_RANGE': { 132 | return {...state, shapeRange: action.newShapeRange} 133 | } 134 | 135 | case 'UNDO': 136 | case 'REDO': { 137 | const newHistoryIndex = 138 | action.op === 'UNDO' 139 | ? Math.max(0, state.historyIndex - 1) 140 | : Math.min(state.historyIndex + 1, state.canvasHistory.length - 1) 141 | const newGlyphSet = new Map(state.glyphSet) 142 | newGlyphSet.set(state.activeGlyph, state.canvasHistory[newHistoryIndex]) 143 | return { 144 | ...state, 145 | glyphSet: newGlyphSet, 146 | historyIndex: newHistoryIndex, 147 | } 148 | } 149 | default: { 150 | return state 151 | } 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/variables.scss: -------------------------------------------------------------------------------- 1 | $header-height: 48px; 2 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// <reference types="vite/client" /> 2 | /// <reference types="vite-plugin-svgr/client" /> 3 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": false, 4 | "allowSyntheticDefaultImports": true, 5 | "esModuleInterop": false, 6 | "forceConsistentCasingInFileNames": true, 7 | "isolatedModules": true, 8 | "jsx": "react-jsx", 9 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 10 | "module": "ESNext", 11 | "moduleResolution": "Node", 12 | "noEmit": true, 13 | "noImplicitAny": true, 14 | "resolveJsonModule": true, 15 | "skipLibCheck": true, 16 | "strict": true, 17 | "strictBindCallApply": true, 18 | "strictNullChecks": true, 19 | "strictPropertyInitialization": true, 20 | "target": "ESNext", 21 | "useDefineForClassFields": true, 22 | "useUnknownInCatchVariables": true 23 | }, 24 | "include": ["src", "vite.config.ts"], 25 | "references": [{"path": "./tsconfig.node.json"}] 26 | } 27 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "allowSyntheticDefaultImports": true 7 | }, 8 | "include": ["vite.config.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import react from '@vitejs/plugin-react' 2 | import {defineConfig} from 'vite' 3 | import svgr from 'vite-plugin-svgr' 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | base: './', 8 | build: { 9 | sourcemap: true, 10 | }, 11 | plugins: [svgr(), react()], 12 | }) 13 | --------------------------------------------------------------------------------