├── public ├── robots.txt └── manifest.json ├── src ├── react-app-env.d.ts ├── setupTests.ts ├── components │ ├── AreaSelectRect.module.css │ ├── Grid.module.css │ ├── StrokeCenterLine.module.css │ ├── XorMasks.tsx │ ├── SelectionControl.module.css │ ├── PartsList.module.css │ ├── GlyphArea.module.css │ ├── SelectionInfo.module.css │ ├── Glyph.module.css │ ├── Stroke.tsx │ ├── EditorControls.module.css │ ├── ControlPoint.module.css │ ├── PartsSearch.module.css │ ├── AreaSelectRect.tsx │ ├── Grid.tsx │ ├── OptionModal.module.css │ ├── ControlPoint.tsx │ ├── SubmitPreview.tsx │ ├── PartsList.tsx │ ├── SubmitForm.tsx │ ├── Glyph.tsx │ ├── StrokeCenterLine.tsx │ ├── GlyphArea.tsx │ ├── EditorControls.tsx │ ├── PartsSearch.tsx │ ├── OptionModal.tsx │ ├── SelectionControl.tsx │ └── SelectionInfo.tsx ├── selectors │ ├── util.ts │ ├── submitGlyph.ts │ └── draggedGlyph.ts ├── actions │ ├── undo.ts │ ├── select.ts │ ├── drag.ts │ ├── display.ts │ └── editor.ts ├── hooks.ts ├── store.ts ├── index.css ├── reportWebVitals.ts ├── App.module.css ├── kageUtils │ ├── reflectrotate.ts │ ├── bbx.ts │ ├── stretchparam.ts │ ├── match.ts │ ├── glyph.ts │ ├── transform.ts │ ├── decompose.ts │ ├── connection.ts │ ├── stroketype.ts │ └── freehand.ts ├── index.tsx ├── App.tsx ├── xorMask.ts ├── App.test.tsx ├── args.ts ├── callapi.ts ├── i18n.ts ├── reducers │ ├── display.ts │ ├── select.ts │ ├── undo.ts │ ├── index.ts │ ├── drag.ts │ └── editor.ts ├── shortcuts.ts ├── kage.ts └── locales │ ├── zh-Hans.json │ ├── zh-Hant.json │ ├── ja.json │ ├── ko.json │ └── en.json ├── tsconfig.json ├── vitest.config.ts ├── vite.config.ts ├── .gitignore ├── index.html ├── tsconfig.node.json ├── tsconfig.app.json ├── .github └── workflows │ └── node.js.yml ├── package.json ├── README.md └── eslint.config.mjs /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-only 2 | // Copyright 2020 kurgm 3 | 4 | /// 5 | -------------------------------------------------------------------------------- /src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-only 2 | // Copyright 2020, 2023 kurgm 3 | 4 | import '@testing-library/jest-dom/vitest'; 5 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Glyph Editor", 3 | "name": "Kage Glyph Editor", 4 | "icons": [], 5 | "start_url": ".", 6 | "display": "standalone", 7 | "theme_color": "#000000", 8 | "background_color": "#ffffff" 9 | } 10 | -------------------------------------------------------------------------------- /src/components/AreaSelectRect.module.css: -------------------------------------------------------------------------------- 1 | /* SPDX-License-Identifier: GPL-3.0-only */ 2 | /* Copyright 2020, 2025 kurgm */ 3 | 4 | .areaSelectRect { 5 | fill: none; 6 | stroke: #f57900; 7 | stroke-width: 1px; 8 | pointer-events: none; 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-only 2 | // Copyright 2020, 2023, 2025 kurgm 3 | 4 | { 5 | "files": [], 6 | "references": [ 7 | { "path": "./tsconfig.app.json" }, 8 | { "path": "./tsconfig.node.json" } 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /src/components/Grid.module.css: -------------------------------------------------------------------------------- 1 | /* SPDX-License-Identifier: GPL-3.0-only */ 2 | /* Copyright 2020, 2025 kurgm */ 3 | 4 | .gridLines { 5 | pointer-events: none; 6 | } 7 | .gridLines path { 8 | fill: none; 9 | stroke: #eeeeee; 10 | stroke-width: 1px; 11 | } 12 | -------------------------------------------------------------------------------- /src/selectors/util.ts: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-only 2 | // Copyright 2025 kurgm 3 | 4 | import { createSelector } from "@reduxjs/toolkit"; 5 | 6 | import { AppState } from "../reducers"; 7 | 8 | export const createAppSelector = createSelector.withTypes(); 9 | -------------------------------------------------------------------------------- /src/components/StrokeCenterLine.module.css: -------------------------------------------------------------------------------- 1 | /* SPDX-License-Identifier: GPL-3.0-only */ 2 | /* Copyright 2020, 2021, 2025 kurgm and graphemecluster */ 3 | 4 | .strokeCenterLine { 5 | fill: none; 6 | stroke: #e3e3e3; 7 | stroke-width: 1px; 8 | pointer-events: none; 9 | } 10 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-only 2 | // Copyright 2023 kurgm 3 | 4 | import { defineConfig } from 'vitest/config'; 5 | 6 | export default defineConfig({ 7 | test: { 8 | environment: 'jsdom', 9 | globals: true, 10 | setupFiles: ['./src/setupTests.ts'], 11 | }, 12 | }); 13 | -------------------------------------------------------------------------------- /src/actions/undo.ts: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-only 2 | // Copyright 2020 kurgm 3 | 4 | import actionCreatorFactory from 'typescript-fsa'; 5 | 6 | const actionCreator = actionCreatorFactory('UNDO'); 7 | 8 | export const undoActions = { 9 | undo: actionCreator('UNDO'), 10 | redo: actionCreator('REDO'), 11 | }; 12 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-only 2 | // Copyright 2023, 2025 kurgm 3 | 4 | import { defineConfig } from 'vite' 5 | import react from '@vitejs/plugin-react' 6 | 7 | // https://vite.dev/config/ 8 | export default defineConfig({ 9 | plugins: [react()], 10 | build: { 11 | outDir: 'build', 12 | }, 13 | base: './', 14 | }); 15 | -------------------------------------------------------------------------------- /src/hooks.ts: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-only 2 | // Copyright 2025 kurgm 3 | 4 | import { useDispatch, useSelector } from 'react-redux' 5 | import type { AppState } from './reducers' 6 | import type { AppDispatch } from './store' 7 | 8 | export const useAppDispatch = useDispatch.withTypes() 9 | export const useAppSelector = useSelector.withTypes() 10 | -------------------------------------------------------------------------------- /src/components/XorMasks.tsx: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-only 2 | // Copyright 2020, 2023 kurgm 3 | 4 | import { xorMaskTypes, xorMaskShapeMap } from "../xorMask"; 5 | 6 | const XorMasks = () => <> 7 | {xorMaskTypes.map((maskType) => ( 8 | 9 | ))} 10 | 11 | 12 | export default XorMasks; 13 | -------------------------------------------------------------------------------- /src/components/SelectionControl.module.css: -------------------------------------------------------------------------------- 1 | /* SPDX-License-Identifier: GPL-3.0-only */ 2 | /* Copyright 2020, 2021, 2025 kurgm and graphemecluster */ 3 | 4 | .selectionRect { 5 | fill: none; 6 | stroke: #f57900; 7 | stroke-width: 1px; 8 | pointer-events: none; 9 | } 10 | 11 | .auxiliaryLines { 12 | fill: none; 13 | stroke: #729fcf; 14 | stroke-width: 1px; 15 | pointer-events: none; 16 | } 17 | -------------------------------------------------------------------------------- /src/components/PartsList.module.css: -------------------------------------------------------------------------------- 1 | /* SPDX-License-Identifier: GPL-3.0-only */ 2 | /* Copyright 2020, 2023, 2025 kurgm */ 3 | 4 | .partsList { 5 | display: grid; 6 | grid-template-columns: repeat(4, 60px); 7 | } 8 | 9 | .partsList img { 10 | border: 1px solid #aaaaaa; 11 | margin: 4px; 12 | cursor: pointer; 13 | } 14 | 15 | .partsList img:hover { 16 | border: 5px solid #cc0000; 17 | margin: 0; 18 | } 19 | -------------------------------------------------------------------------------- /src/store.ts: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-only 2 | // Copyright 2020, 2023, 2025 kurgm 3 | 4 | import { configureStore } from '@reduxjs/toolkit'; 5 | 6 | import reducer from './reducers'; 7 | 8 | const store = configureStore({ 9 | reducer, 10 | middleware: (getDefaultMiddleware) => getDefaultMiddleware({ 11 | serializableCheck: false, 12 | }), 13 | }); 14 | 15 | export type AppDispatch = typeof store.dispatch; 16 | 17 | export default store; 18 | -------------------------------------------------------------------------------- /src/components/GlyphArea.module.css: -------------------------------------------------------------------------------- 1 | /* SPDX-License-Identifier: GPL-3.0-only */ 2 | /* Copyright 2020, 2025 kurgm */ 3 | 4 | .glyphBoundary { 5 | fill: none; 6 | stroke: #333333; 7 | stroke-width: 2; 8 | } 9 | 10 | .glyphGuide { 11 | fill: none; 12 | stroke: #999999; 13 | stroke-width: 1; 14 | } 15 | 16 | .glyphArea svg.freehand * { 17 | pointer-events: none; 18 | } 19 | 20 | .glyphArea svg { 21 | stroke-linecap: round; 22 | stroke-linejoin: round; 23 | } 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: GPL-3.0-only 2 | # Copyright 2020 kurgm 3 | 4 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 5 | 6 | # dependencies 7 | /node_modules 8 | /.pnp 9 | .pnp.js 10 | 11 | # testing 12 | /coverage 13 | 14 | # production 15 | /build 16 | 17 | # misc 18 | .DS_Store 19 | .env.local 20 | .env.development.local 21 | .env.test.local 22 | .env.production.local 23 | 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | /* SPDX-License-Identifier: GPL-3.0-only */ 2 | /* Copyright 2020 kurgm */ 3 | 4 | body { 5 | margin: 0; 6 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 7 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 8 | sans-serif; 9 | -webkit-font-smoothing: antialiased; 10 | -moz-osx-font-smoothing: grayscale; 11 | } 12 | 13 | code { 14 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 15 | monospace; 16 | } 17 | -------------------------------------------------------------------------------- /src/selectors/submitGlyph.ts: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-only 2 | // Copyright 2020, 2023, 2025 kurgm 3 | 4 | import { Glyph } from '../kageUtils/glyph'; 5 | import { getGlyphLineBBX } from '../kageUtils/bbx'; 6 | 7 | import { draggedGlyphSelector } from './draggedGlyph'; 8 | import { createAppSelector } from './util'; 9 | 10 | export const submitGlyphSelector = createAppSelector([ 11 | draggedGlyphSelector, 12 | ], (glyph: Glyph): Glyph => { 13 | return glyph.filter((glyphLine) => getGlyphLineBBX(glyphLine)[0] < 200); 14 | }); 15 | -------------------------------------------------------------------------------- /src/components/SelectionInfo.module.css: -------------------------------------------------------------------------------- 1 | /* SPDX-License-Identifier: GPL-3.0-only */ 2 | /* Copyright 2020, 2025 kurgm */ 3 | 4 | .selectControl { 5 | display: flex; 6 | flex-flow: column; 7 | } 8 | 9 | .selectedInfo { 10 | flex: 1; 11 | } 12 | 13 | .alert { 14 | color: red; 15 | } 16 | 17 | .selectionControl { 18 | display: flex; 19 | justify-content: center; 20 | align-items: center; 21 | } 22 | 23 | .selectPrevnextButton { 24 | height: 2.5em; 25 | } 26 | 27 | .selectionNum { 28 | margin: 0 1em; 29 | width: 5em; 30 | text-align: center; 31 | align-self: center; 32 | } 33 | -------------------------------------------------------------------------------- /src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-only 2 | // Copyright 2020, 2021, 2025 kurgm 3 | 4 | import { MetricType } from 'web-vitals'; 5 | 6 | const reportWebVitals = (onPerfEntry?: (metric: MetricType) => void) => { 7 | if (onPerfEntry && onPerfEntry instanceof Function) { 8 | import('web-vitals').then(({ onCLS, onFCP, onLCP, onTTFB, onINP }) => { 9 | onCLS(onPerfEntry); 10 | onFCP(onPerfEntry); 11 | onLCP(onPerfEntry); 12 | onTTFB(onPerfEntry); 13 | onINP(onPerfEntry); 14 | }); 15 | } 16 | }; 17 | 18 | export default reportWebVitals; 19 | -------------------------------------------------------------------------------- /src/App.module.css: -------------------------------------------------------------------------------- 1 | /* SPDX-License-Identifier: GPL-3.0-only */ 2 | /* Copyright 2020, 2021, 2025 kurgm and graphemecluster */ 3 | 4 | .App { 5 | display: grid; 6 | grid-template-columns: minmax(500px, 1fr) auto; 7 | grid-template-rows: minmax(240px, 1fr) auto; 8 | position: absolute; 9 | top: 0; 10 | left: 0; 11 | bottom: 0; 12 | right: 0; 13 | } 14 | 15 | .partsSearchArea { 16 | grid-column: 2/3; 17 | grid-row: 1/3; 18 | } 19 | 20 | .glyphArea { 21 | grid-column: 1/2; 22 | grid-row: 1/2; 23 | border: 1px solid #ccc; 24 | } 25 | 26 | .editorControls { 27 | grid-column: 1/2; 28 | grid-row: 2/3; 29 | } 30 | -------------------------------------------------------------------------------- /src/components/Glyph.module.css: -------------------------------------------------------------------------------- 1 | /* SPDX-License-Identifier: GPL-3.0-only */ 2 | /* Copyright 2020, 2021, 2023, 2025 kurgm and graphemecluster */ 3 | 4 | .strokesDeselected polygon { 5 | fill: #000000; 6 | stroke: none; 7 | } 8 | 9 | .xormaskFill { 10 | fill: #000000; 11 | pointer-events: none; 12 | } 13 | 14 | .xormaskFill.translucent { 15 | fill-opacity: 0.7; 16 | } 17 | 18 | .strokesInvert polygon { 19 | fill: #ffffff; 20 | stroke: none; 21 | pointer-events: none; 22 | } 23 | 24 | .strokesSelected polygon { 25 | fill: #cc0000; 26 | stroke: none; 27 | } 28 | 29 | .movableStroke { 30 | cursor: move; 31 | } 32 | -------------------------------------------------------------------------------- /src/components/Stroke.tsx: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-only 2 | // Copyright 2020, 2023 kurgm 3 | 4 | import { Polygons } from '@kurgm/kage-engine'; 5 | 6 | export interface StrokeComponentProps { 7 | polygons: Polygons; 8 | className?: string; 9 | } 10 | 11 | const StrokeComponent = (props: StrokeComponentProps) => ( 12 | <> 13 | {props.polygons.array.map((polygon, i) => ( 14 | `${x},${y} `).join("")} 18 | /> 19 | ))} 20 | 21 | ); 22 | 23 | export default StrokeComponent; 24 | -------------------------------------------------------------------------------- /src/components/EditorControls.module.css: -------------------------------------------------------------------------------- 1 | /* SPDX-License-Identifier: GPL-3.0-only */ 2 | /* Copyright 2020, 2021, 2025 kurgm and graphemecluster */ 3 | 4 | .editorControls { 5 | display: flex; 6 | padding: 10px; 7 | } 8 | 9 | .selectControl { 10 | flex: 1; 11 | } 12 | 13 | .controlButtons { 14 | margin: 0 1em; 15 | display: grid; 16 | grid-template-columns: repeat(2, 7.5em); 17 | gap: 3px; 18 | } 19 | 20 | .preview { 21 | display: flex; 22 | flex-flow: column; 23 | justify-content: space-evenly; 24 | width: 7em; 25 | } 26 | 27 | .previewThumbnail { 28 | align-self: center; 29 | border: 1px solid #aaaaaa; 30 | } 31 | 32 | .preview button { 33 | font-weight: bold; 34 | } 35 | -------------------------------------------------------------------------------- /src/actions/select.ts: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-only 2 | // Copyright 2020 kurgm 3 | 4 | import actionCreatorFactory from 'typescript-fsa'; 5 | 6 | const actionCreator = actionCreatorFactory('SELECT'); 7 | 8 | export const selectActions = { 9 | selectSingle: actionCreator('SELECT_SINGLE'), 10 | selectAddSingle: actionCreator('SELECT_ADD_SINGLE'), 11 | selectRemoveSingle: actionCreator('SELECT_REMOVE_SINGLE'), 12 | selectNone: actionCreator('SELECT_NONE'), 13 | selectAll: actionCreator('SELECT_ALL'), 14 | selectDeselected: actionCreator('SELECT_TOGGLE_ALL'), 15 | selectPrev: actionCreator('SELECT_PREV'), 16 | selectNext: actionCreator('SELECT_NEXT'), 17 | }; 18 | -------------------------------------------------------------------------------- /src/components/ControlPoint.module.css: -------------------------------------------------------------------------------- 1 | /* SPDX-License-Identifier: GPL-3.0-only */ 2 | /* Copyright 2020, 2021, 2025 kurgm and graphemecluster */ 3 | 4 | .controlpointRect { 5 | fill: #f57900; 6 | stroke: #f57900; 7 | stroke-width: 1px; 8 | } 9 | 10 | .controlpointRect.online { 11 | fill: #73d216; 12 | stroke: #73d216; 13 | } 14 | 15 | .controlpointRect.match { 16 | fill: #729fcf; 17 | stroke: #729fcf; 18 | } 19 | 20 | .controlpointRect.move { 21 | cursor: move; 22 | } 23 | .controlpointRect.nsResize { 24 | cursor: ns-resize; 25 | } 26 | .controlpointRect.ewResize { 27 | cursor: ew-resize; 28 | } 29 | .controlpointRect.nwseResize { 30 | cursor: nwse-resize; 31 | } 32 | .controlpointRect.neswResize { 33 | cursor: nesw-resize; 34 | } 35 | -------------------------------------------------------------------------------- /src/components/PartsSearch.module.css: -------------------------------------------------------------------------------- 1 | /* SPDX-License-Identifier: GPL-3.0-only */ 2 | /* Copyright 2020, 2021, 2023, 2025 kurgm and graphemecluster */ 3 | 4 | .partsSearchArea { 5 | display: flex; 6 | flex-flow: column; 7 | } 8 | 9 | .partsSearchBox { 10 | display: flex; 11 | padding: 10px; 12 | } 13 | 14 | .partsSearchBox input { 15 | flex: 1; 16 | } 17 | 18 | .partsListArea { 19 | border: 1px solid #666; 20 | flex: 1 1; 21 | text-align: center; 22 | overflow-y: scroll; 23 | } 24 | 25 | .partsListArea div.message { 26 | margin: 3em 0; 27 | width: 240px; 28 | } 29 | 30 | .partsHoverName { 31 | width: 240px; 32 | height: 1.3em; 33 | line-height: 1.3; 34 | overflow: hidden; 35 | white-space: nowrap; 36 | text-overflow: ellipsis; 37 | } 38 | -------------------------------------------------------------------------------- /src/kageUtils/reflectrotate.ts: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-only 2 | // Copyright 2020 kurgm 3 | 4 | export enum ReflectRotateType { 5 | reflectX, 6 | reflectY, 7 | rotate90, 8 | rotate180, 9 | rotate270, 10 | } 11 | 12 | export const reflectRotateTypes = [ 13 | ReflectRotateType.reflectX, 14 | ReflectRotateType.reflectY, 15 | ReflectRotateType.rotate90, 16 | ReflectRotateType.rotate180, 17 | ReflectRotateType.rotate270, 18 | ]; 19 | 20 | export const reflectRotateTypeParamsMap: Record = { 21 | [ReflectRotateType.reflectX]: [98, 0], 22 | [ReflectRotateType.reflectY]: [97, 0], 23 | [ReflectRotateType.rotate90]: [99, 1], 24 | [ReflectRotateType.rotate180]: [99, 2], 25 | [ReflectRotateType.rotate270]: [99, 3], 26 | }; 27 | -------------------------------------------------------------------------------- /src/components/AreaSelectRect.tsx: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-only 2 | // Copyright 2020, 2023, 2025 kurgm 3 | 4 | import styles from './AreaSelectRect.module.css'; 5 | 6 | interface AreaSelectRectProps { 7 | rect: [number, number, number, number] | null; 8 | } 9 | 10 | const AreaSelectRect = (props: AreaSelectRectProps) => { 11 | if (!props.rect) { 12 | return null; 13 | } 14 | let [x1, y1, x2, y2] = props.rect; 15 | if (x1 > x2) { 16 | // swap 17 | const temp = x1; 18 | x1 = x2; 19 | x2 = temp; 20 | } 21 | if (y1 > y2) { 22 | // swap 23 | const temp = y1; 24 | y1 = y2; 25 | y2 = temp; 26 | } 27 | 28 | return ; 29 | }; 30 | 31 | export default AreaSelectRect; 32 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | KAGE Glyph Editor 14 | 15 | 16 | 17 |
18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-only 2 | // Copyright 2020, 2022 kurgm 3 | 4 | import React from 'react'; 5 | import ReactDOM from 'react-dom/client'; 6 | 7 | import { Provider } from 'react-redux'; 8 | 9 | import store from './store'; 10 | import App from './App'; 11 | import reportWebVitals from './reportWebVitals'; 12 | import './i18n'; 13 | 14 | import './index.css'; 15 | 16 | const root = ReactDOM.createRoot( 17 | document.getElementById('root') as HTMLElement 18 | ); 19 | root.render( 20 | 21 | 22 | 23 | 24 | 25 | ); 26 | 27 | // If you want to start measuring performance in your app, pass a function 28 | // to log results (for example: reportWebVitals(console.log)) 29 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 30 | reportWebVitals(); 31 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-only 2 | // Copyright 2025 kurgm 3 | 4 | { 5 | "compilerOptions": { 6 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 7 | "target": "ES2023", 8 | "lib": ["ES2023"], 9 | "module": "ESNext", 10 | "types": ["node"], 11 | "skipLibCheck": true, 12 | 13 | /* Bundler mode */ 14 | "moduleResolution": "bundler", 15 | "allowImportingTsExtensions": true, 16 | "verbatimModuleSyntax": true, 17 | "moduleDetection": "force", 18 | "noEmit": true, 19 | 20 | /* Linting */ 21 | "strict": true, 22 | "noUnusedLocals": true, 23 | "noUnusedParameters": true, 24 | "erasableSyntaxOnly": true, 25 | "noFallthroughCasesInSwitch": true, 26 | "noUncheckedSideEffectImports": true, 27 | "forceConsistentCasingInFileNames": true 28 | }, 29 | "include": ["vite.config.ts", "vitest.config.ts"], 30 | } 31 | -------------------------------------------------------------------------------- /src/components/Grid.tsx: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-only 2 | // Copyright 2020, 2023, 2025 kurgm 3 | 4 | import { useAppSelector } from '../hooks'; 5 | 6 | import styles from './Grid.module.css'; 7 | 8 | const Grid = () => { 9 | const grid = useAppSelector((state) => state.grid); 10 | if (!grid.display) { 11 | return <>; 12 | } 13 | const xs = []; 14 | for (let x = grid.originX; x < 200; x += grid.spacingX) { 15 | xs.push(x); 16 | } 17 | const ys = []; 18 | for (let y = grid.originY; y < 200; y += grid.spacingY) { 19 | ys.push(y); 20 | } 21 | return ( 22 | 23 | {xs.map((x) => ( 24 | 28 | ))} 29 | {ys.map((y) => ( 30 | 34 | ))} 35 | 36 | ); 37 | }; 38 | 39 | export default Grid; 40 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-only 2 | // Copyright 2020, 2023, 2025 kurgm 3 | 4 | import { useTranslation } from 'react-i18next'; 5 | 6 | import GlyphArea from './components/GlyphArea'; 7 | import EditorControls from './components/EditorControls'; 8 | import PartsSearch from './components/PartsSearch' 9 | import SubmitForm from './components/SubmitForm'; 10 | import OptionModal from './components/OptionModal'; 11 | 12 | import { useShortcuts } from './shortcuts'; 13 | 14 | import styles from './App.module.css'; 15 | 16 | function App() { 17 | const { i18n } = useTranslation(); 18 | useShortcuts(); 19 | return ( 20 |
21 | 22 | 23 | 24 | 25 | 26 |
27 | ); 28 | } 29 | 30 | export default App; 31 | -------------------------------------------------------------------------------- /src/components/OptionModal.module.css: -------------------------------------------------------------------------------- 1 | /* SPDX-License-Identifier: GPL-3.0-only */ 2 | /* Copyright 2020, 2021, 2023, 2025 kurgm and graphemecluster */ 3 | 4 | .modalContent { 5 | position: absolute; 6 | left: 50%; 7 | transform: translate(-50%, 0); 8 | bottom: 40px; 9 | border: 1px solid #ccc; 10 | background: #ffffff; 11 | overflow: auto; 12 | -webkit-overflow-scrolling: touch; 13 | border-radius: 4px; 14 | outline: none; 15 | padding: 20px; 16 | } 17 | 18 | .modalContent input[type="number"] { 19 | width: 4em; 20 | } 21 | 22 | .gridOption { 23 | display: grid; 24 | grid-template-columns: auto auto auto auto; 25 | grid-gap: 4px; 26 | margin-top: 4px; 27 | text-align: right; 28 | } 29 | 30 | .generalOption { 31 | display: grid; 32 | grid-template-columns: auto auto; 33 | grid-gap: 4px; 34 | text-align: right; 35 | width: intrinsic; 36 | width: max-content; 37 | margin: 10px auto; 38 | } 39 | 40 | .closeOption { 41 | display: grid; 42 | width: 80%; 43 | margin: auto; 44 | } 45 | -------------------------------------------------------------------------------- /src/actions/drag.ts: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-only 2 | // Copyright 2020, 2021 kurgm and graphemecluster 3 | 4 | import actionCreatorFactory from 'typescript-fsa'; 5 | 6 | export type CTMInv = (x: number, y: number) => [number, number]; 7 | export enum RectPointPosition { 8 | north, 9 | south, 10 | east, 11 | west, 12 | southeast, 13 | southwest, 14 | northeast, 15 | northwest, 16 | } 17 | 18 | const actionCreator = actionCreatorFactory('EDITOR'); 19 | 20 | export const dragActions = { 21 | startBackgroundDrag: actionCreator('BACKGROUND_DRAG_START'), 22 | startSelectionDrag: actionCreator('SELECTION_DRAG_START'), 23 | startPointDrag: actionCreator<[React.MouseEvent, number]>('MOVE_POINT_START'), 24 | startResize: actionCreator<[React.MouseEvent, RectPointPosition]>('RESIZE_START'), 25 | 26 | mouseMove: actionCreator('MOUSE_MOVE'), 27 | mouseUp: actionCreator('MOUSE_UP'), 28 | 29 | updateCTMInv: actionCreator('UPDATE_CTMINV'), 30 | } 31 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-only 2 | // Copyright 2020, 2023, 2025 kurgm 3 | 4 | { 5 | "compilerOptions": { 6 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 7 | "target": "ES2022", 8 | "useDefineForClassFields": true, 9 | "lib": ["ES2022", "DOM", "DOM.Iterable"], 10 | "module": "ESNext", 11 | "types": ["vite/client"], 12 | "skipLibCheck": true, 13 | 14 | /* Bundler mode */ 15 | "moduleResolution": "bundler", 16 | "allowImportingTsExtensions": true, 17 | "isolatedModules": true, 18 | // "verbatimModuleSyntax": true, 19 | "moduleDetection": "force", 20 | "noEmit": true, 21 | "jsx": "react-jsx", 22 | 23 | /* Linting */ 24 | "strict": true, 25 | "noUnusedLocals": true, 26 | "noUnusedParameters": true, 27 | // "erasableSyntaxOnly": true, 28 | "noFallthroughCasesInSwitch": true, 29 | "noUncheckedSideEffectImports": true, 30 | "forceConsistentCasingInFileNames": true 31 | }, 32 | "include": [ 33 | "src" 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /src/components/ControlPoint.tsx: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-only 2 | // Copyright 2020, 2025 kurgm 3 | 4 | import clsx from 'clsx/lite'; 5 | import React from 'react'; 6 | 7 | import { MatchType } from '../kageUtils/match'; 8 | 9 | import styles from './ControlPoint.module.css'; 10 | 11 | interface ControlPointProps { 12 | x: number; 13 | y: number; 14 | matchType?: MatchType; 15 | cursorType?: 'nsResize' | 'ewResize' | 'nwseResize' | 'neswResize' | 'move'; 16 | handleMouseDown: (evt: React.MouseEvent) => void; 17 | } 18 | 19 | const ControlPoint = (props: ControlPointProps) => ( 20 | 34 | ); 35 | 36 | export default ControlPoint; 37 | -------------------------------------------------------------------------------- /src/actions/display.ts: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-only 2 | // Copyright 2020, 2021, 2023 kurgm and graphemecluster 3 | 4 | import actionCreatorFactory from 'typescript-fsa'; 5 | 6 | import { KShotai } from '../kage'; 7 | import { XorMaskType } from '../xorMask'; 8 | 9 | export enum ShowCenterLine { 10 | none, 11 | selection, 12 | always 13 | } 14 | 15 | const actionCreator = actionCreatorFactory('DISPLAY'); 16 | 17 | export const displayActions = { 18 | openOptionModal: actionCreator('OPEN_OPTION_MODAL'), 19 | closeOptionModal: actionCreator('CLOSE_OPTION_MODAL'), 20 | 21 | setGridDisplay: actionCreator('SET_GRID_DISPLAY'), 22 | setGridOriginX: actionCreator('SET_GRID_ORIGIN_X'), 23 | setGridOriginY: actionCreator('SET_GRID_ORIGIN_Y'), 24 | setGridSpacingX: actionCreator('SET_GRID_SPACING_X'), 25 | setGridSpacingY: actionCreator('SET_GRID_SPACING_Y'), 26 | 27 | setShotai: actionCreator('SET_SHOTAI'), 28 | setStrokeCenterLineDisplay: actionCreator('SET_STROKE_CENTER_LINE_DISPLAY'), 29 | 30 | setXorMaskType: actionCreator('SET_XOR_MASK_TYPE'), 31 | }; 32 | -------------------------------------------------------------------------------- /src/xorMask.ts: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-only 2 | // Copyright 2020 kurgm 3 | 4 | export const xorMaskShapeMap = { 5 | none: "", 6 | circle: "M 123.2 13.4 137.9 18.8 151.5 26.6 163.5 36.7 173.5 48.7 181.3 62.2 186.7 76.9 189.4 92.4 189.4 108 186.7 123.4 181.3 138.1 173.5 151.7 163.5 163.7 151.5 173.7 137.9 181.6 123.2 186.9 107.8 189.6 92.1 189.6 76.7 186.9 62 181.6 48.4 173.7 36.4 163.7 26.4 151.7 18.6 138.1 13.2 123.4 10.5 108 10.5 92.4 13.2 76.9 18.6 62.2 26.4 48.7 36.4 36.7 48.4 26.6 62 18.8 76.7 13.4 92.1 10.7 107.8 10.7 Z", 7 | squareWithRoundedCorners: "M 176.9 15.1 178.8 15.7 180.5 16.6 182 17.9 183.3 19.4 184.2 21.1 184.8 23 185 25 185 175 184.8 176.9 184.2 178.8 183.3 180.5 182 182 180.5 183.3 178.8 184.2 176.9 184.8 175 185 25 185 23 184.8 21.1 184.2 19.4 183.3 17.9 182 16.6 180.5 15.7 178.8 15.1 176.9 15 175 15 25 15.1 23 15.7 21.1 16.6 19.4 17.9 17.9 19.4 16.6 21.1 15.7 23 15.1 25 15 175 15 Z", 8 | square: "M 185 185 15 185 15 15 185 15 Z", 9 | diamond: "M 190 100 100 190 10 100 100 10 Z" 10 | } 11 | 12 | export type XorMaskType = keyof typeof xorMaskShapeMap; 13 | export const xorMaskTypes = Object.keys(xorMaskShapeMap) as XorMaskType[]; 14 | -------------------------------------------------------------------------------- /src/components/SubmitPreview.tsx: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-only 2 | // Copyright 2020, 2023, 2025 kurgm 3 | 4 | import { useAppSelector } from '../hooks'; 5 | import { submitGlyphSelector } from '../selectors/submitGlyph'; 6 | import { makeGlyphSeparatedForSubmit } from '../kage'; 7 | 8 | import GlyphComponent from './Glyph'; 9 | 10 | type SubmitPreviewProps = { 11 | className?: string; 12 | }; 13 | 14 | const SubmitPreview = (props: SubmitPreviewProps) => { 15 | const submitGlyph = useAppSelector(submitGlyphSelector); 16 | const buhinMap = useAppSelector((state) => state.buhinMap); 17 | const shotai = useAppSelector((state) => state.shotai); 18 | const xorMaskType = useAppSelector((state) => state.xorMaskType); 19 | 20 | return ( 21 | 22 | 30 | 31 | ); 32 | }; 33 | 34 | export default SubmitPreview; 35 | -------------------------------------------------------------------------------- /src/App.test.tsx: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-only 2 | // Copyright 2020, 2023, 2025 kurgm 3 | 4 | import { render, RenderOptions, screen } from '@testing-library/react'; 5 | import userEvent from '@testing-library/user-event'; 6 | import ReactModal from 'react-modal'; 7 | import { Provider } from 'react-redux'; 8 | import { test, expect } from 'vitest'; 9 | 10 | import App from './App'; 11 | import store from './store'; 12 | import './i18n'; 13 | 14 | const customRender = ( 15 | ui: React.ReactNode, 16 | options?: Omit, 17 | ) => { 18 | const rootDiv = document.body.appendChild(document.createElement('div')); 19 | rootDiv.id = 'root'; 20 | 21 | ReactModal.setAppElement(rootDiv); 22 | 23 | return render( 24 | 25 | {ui} 26 | , 27 | { 28 | ...options, 29 | container: rootDiv, 30 | }, 31 | ); 32 | } 33 | 34 | test('clicking option button opens a dialog', async () => { 35 | customRender(); 36 | 37 | expect(screen.queryByRole('dialog')).toBeNull(); 38 | 39 | await userEvent.click(screen.getByText('設定…')); 40 | 41 | const modalElement = screen.getByRole('dialog'); 42 | expect(modalElement).toBeVisible(); 43 | }); 44 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: GPL-3.0-only 2 | # Copyright 2020, 2022, 2023 kurgm 3 | 4 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 5 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 6 | 7 | name: Node.js CI 8 | 9 | on: 10 | push: 11 | branches: [ master ] 12 | pull_request: 13 | branches: [ master ] 14 | 15 | jobs: 16 | build: 17 | 18 | runs-on: ubuntu-latest 19 | 20 | strategy: 21 | matrix: 22 | node-version: [20.x] 23 | 24 | steps: 25 | - uses: actions/checkout@v3 26 | - name: Use Node.js ${{ matrix.node-version }} 27 | uses: actions/setup-node@v3 28 | with: 29 | node-version: ${{ matrix.node-version }} 30 | - run: npm ci 31 | - run: npm run lint --if-present 32 | - run: npm run build --if-present 33 | - run: npm test 34 | 35 | - if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }} 36 | name: Deploy 37 | uses: peaceiris/actions-gh-pages@v3 38 | with: 39 | github_token: ${{ secrets.GITHUB_TOKEN }} 40 | publish_dir: ./build 41 | -------------------------------------------------------------------------------- /src/args.ts: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-only 2 | // Copyright 2020, 2022, 2025 kurgm 3 | 4 | const args = new URLSearchParams(window.location.hash.slice(1)); 5 | 6 | let host = args.get('host'); 7 | let ssl = args.get('ssl') !== 'false'; 8 | const name = args.get('name'); 9 | const related = args.get('related') || 'u3013'; 10 | const edittime = args.get('edittime'); 11 | const data = args.get('data') || ''; 12 | const summary = args.get('summary') || ''; 13 | 14 | export const gwHosts = [ 15 | 'glyphwiki.org', 16 | 'en.glyphwiki.org', 17 | 'ko.glyphwiki.org', 18 | 'zhs.glyphwiki.org', 19 | 'zht.glyphwiki.org', 20 | 'non-ssl.glyphwiki.org', 21 | ]; 22 | 23 | if (!host && document.referrer) { 24 | try { 25 | const referrerUrl = new URL(document.referrer); 26 | if (gwHosts.includes(referrerUrl.host)) { 27 | host = referrerUrl.host; 28 | ssl = referrerUrl.protocol === 'https:'; 29 | } 30 | } catch { 31 | // ignore invalid referrer 32 | } 33 | } 34 | 35 | if (!host && gwHosts.includes(window.location.host)) { 36 | host = window.location.host; 37 | ssl = window.location.protocol === 'https:'; 38 | } 39 | 40 | if (!host || !gwHosts.includes(host)) { 41 | host = 'glyphwiki.org'; 42 | } 43 | 44 | const sanitizedArgs = { 45 | host, 46 | ssl, 47 | name, 48 | related, 49 | edittime, 50 | data, 51 | summary, 52 | }; 53 | 54 | export default sanitizedArgs; 55 | -------------------------------------------------------------------------------- /src/callapi.ts: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-only 2 | // Copyright 2020, 2022 kurgm 3 | 4 | import { gwHosts } from "./args"; 5 | 6 | // Responses from GlyphWiki's API for glyphEditor lack headers required for cross-origin requests. 7 | // We call GlyphWiki's API directly if it is same-origin (i.e. this app is deployed to GlyphWiki site), 8 | // otherwise we call it via the reverse proxy that adds the 'Access-Control-Allow-Origin: *' header in the response. 9 | 10 | const isSameOriginAPI = ( 11 | ["http:", "https:"].includes(window.location.protocol) && 12 | gwHosts.includes(window.location.host) 13 | ); 14 | 15 | const apiUrlPrefix = isSameOriginAPI 16 | ? '' 17 | : 'https://asia-northeast1-ku6goma.cloudfunctions.net/gwproxy'; 18 | 19 | const callApi = async (path: string) => { 20 | const response = await fetch(apiUrlPrefix + path); 21 | if (!response.ok) { 22 | throw new Error('API error occurred'); 23 | } 24 | return new URLSearchParams(await response.text()); 25 | }; 26 | 27 | export const getSource = async (name: string) => { 28 | const result = await callApi(`/get_source.cgi?name=${encodeURIComponent(name)}`); 29 | return result.get('data'); 30 | }; 31 | 32 | export const search = async (query: string) => { 33 | const result = await callApi(`/search4ge.cgi?query=${encodeURIComponent(query)}`); 34 | return result.get('data')!; 35 | }; 36 | 37 | export const getCandidate = async (name: string) => { 38 | const result = await callApi(`/get_candidate.cgi?name=${encodeURIComponent(name)}`); 39 | return result.get('data')!; 40 | }; 41 | -------------------------------------------------------------------------------- /src/actions/editor.ts: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-only 2 | // Copyright 2020 kurgm 3 | 4 | import actionCreatorFactory from 'typescript-fsa'; 5 | 6 | import { StretchParam } from '../kageUtils/stretchparam'; 7 | import { ReflectRotateType } from '../kageUtils/reflectrotate'; 8 | 9 | const actionCreator = actionCreatorFactory('EDITOR'); 10 | 11 | export const editorActions = { 12 | loadedBuhin: actionCreator<[string, string]>('LOAD_BUHIN_DATA'), 13 | loadedStretchParam: actionCreator<[string, StretchParam]>('LOAD_STRETCH_PARAM'), 14 | 15 | changeStrokeType: actionCreator('CHANGE_STROKE_TYPE'), 16 | changeHeadShapeType: actionCreator('CHANGE_HEAD_SHAPE_TYPE'), 17 | changeTailShapeType: actionCreator('CHANGE_TAIL_SHAPE_TYPE'), 18 | changeStretchCoeff: actionCreator('CHANGE_STRETCH_COEFF'), 19 | changeReflectRotateOpType: actionCreator('CHANGE_REFLECT_ROTATE_OPTYPE'), 20 | 21 | swapWithPrev: actionCreator('SWAP_WITH_PREV'), 22 | swapWithNext: actionCreator('SWAP_WITH_NEXT'), 23 | 24 | insertPart: actionCreator('ADD_PART'), 25 | 26 | paste: actionCreator('PASTE'), 27 | copy: actionCreator('COPY_SELECTION'), 28 | cut: actionCreator('CUT_SELECTION'), 29 | delete: actionCreator('DELETE_SELECTION'), 30 | decomposeSelected: actionCreator('DECOMPOSE_SELECTION'), 31 | moveSelected: actionCreator<[number, number]>('MOVE_SELECTED'), 32 | 33 | toggleFreehand: actionCreator('TOGGLE_FREEHAND_MODE'), 34 | 35 | escape: actionCreator('PRESS_ESC_KEY'), 36 | 37 | finishEdit: actionCreator('FINISH_EDIT'), 38 | }; 39 | -------------------------------------------------------------------------------- /src/components/PartsList.tsx: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-only 2 | // Copyright 2020, 2025 kurgm 3 | 4 | import React, { useCallback } from 'react'; 5 | 6 | import styles from './PartsList.module.css'; 7 | 8 | const getImageURL = (name: string) => ( 9 | `https://glyphwiki.org/glyph/${name}.50px.png` 10 | ); 11 | 12 | interface PartsListProps { 13 | names: string[]; 14 | handleItemClick: (partName: string, evt: React.MouseEvent) => void; 15 | handleItemMouseEnter: (partName: string, evt: React.MouseEvent) => void; 16 | } 17 | 18 | const PartsList = (props: PartsListProps) => { 19 | const { handleItemClick, handleItemMouseEnter } = props; 20 | const handleImageClick = useCallback((evt: React.MouseEvent) => { 21 | const partName = evt.currentTarget.dataset.name!; 22 | handleItemClick(partName, evt); 23 | }, [handleItemClick]); 24 | const handleImageMouseEnter = useCallback((evt: React.MouseEvent) => { 25 | const partName = evt.currentTarget.dataset.name!; 26 | handleItemMouseEnter(partName, evt); 27 | }, [handleItemMouseEnter]); 28 | 29 | return ( 30 |
31 | {props.names.map((name) => ( 32 | {name} 41 | ))} 42 |
43 | ); 44 | }; 45 | 46 | export default PartsList; 47 | -------------------------------------------------------------------------------- /src/i18n.ts: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-only 2 | // Copyright 2020, 2023, 2025 kurgm 3 | 4 | import i18n from 'i18next'; 5 | import { initReactI18next } from 'react-i18next'; 6 | 7 | import args from "./args"; 8 | 9 | import jaTranslation from './locales/ja.json'; 10 | import enTranslation from './locales/en.json'; 11 | import koTranslation from './locales/ko.json'; 12 | import zhHansTranslation from './locales/zh-Hans.json'; 13 | import zhHantTranslation from './locales/zh-Hant.json'; 14 | 15 | const resources = { 16 | "ja": { 17 | translation: jaTranslation, 18 | }, 19 | "en": { 20 | translation: enTranslation, 21 | }, 22 | "ko": { 23 | translation: koTranslation, 24 | }, 25 | "zh-Hans": { 26 | translation: zhHansTranslation, 27 | }, 28 | "zh-Hant": { 29 | translation: zhHantTranslation, 30 | }, 31 | }; 32 | 33 | let lng: keyof typeof resources; 34 | switch (args.host.split('.')[0]) { 35 | case 'en': 36 | lng = 'en'; 37 | break; 38 | case 'ko': 39 | lng = 'ko'; 40 | break; 41 | case 'zhs': 42 | lng = 'zh-Hans'; 43 | break; 44 | case 'zht': 45 | lng = 'zh-Hant'; 46 | break; 47 | default: 48 | lng = 'ja'; 49 | break; 50 | } 51 | 52 | i18n 53 | .use(initReactI18next) // passes i18n down to react-i18next 54 | .init({ 55 | resources, 56 | lng, 57 | fallbackLng: { 58 | 'zh-Hans': ['zh-Hant', 'ja'], 59 | 'zh-Hant': ['zh-Hans', 'ja'], 60 | 'default': ['ja'], 61 | }, 62 | 63 | returnObjects: true, 64 | interpolation: { 65 | escapeValue: false, // react already safes from xss 66 | }, 67 | }); 68 | 69 | export default i18n; 70 | -------------------------------------------------------------------------------- /src/kageUtils/bbx.ts: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-only 2 | // Copyright 2020 kurgm 3 | 4 | import memoizeOne from 'memoize-one'; 5 | 6 | import { GlyphLine } from './glyph'; 7 | 8 | export type BBX = [number, number, number, number]; 9 | 10 | export const bbxOfPoints = (points: [number, number][]): BBX => { 11 | const xs = points.map(([x]) => x); 12 | const ys = points.map(([, y]) => y); 13 | return [ 14 | Math.min(...xs), 15 | Math.min(...ys), 16 | Math.max(...xs), 17 | Math.max(...ys), 18 | ]; 19 | }; 20 | 21 | export const getGlyphLineBBX = (glyphLine: GlyphLine): BBX => { 22 | switch (glyphLine.value[0]) { 23 | case 99: 24 | return bbxOfPoints([ 25 | [glyphLine.value[3], glyphLine.value[4]], 26 | [glyphLine.value[5], glyphLine.value[6]], 27 | ]); 28 | case 0: 29 | case 1: 30 | case 2: 31 | case 3: 32 | case 4: 33 | case 6: 34 | case 7: 35 | case 9: { 36 | const points: [number, number][] = []; 37 | for (let i = 3; i + 2 <= glyphLine.value.length; i += 2) { 38 | points.push([glyphLine.value[i], glyphLine.value[i + 1]]); 39 | } 40 | return bbxOfPoints(points); 41 | } 42 | default: 43 | return bbxOfPoints([]); 44 | } 45 | } 46 | 47 | export const mergeBBX = ([x11, y11, x12, y12]: BBX, [x21, y21, x22, y22]: BBX): BBX => [ 48 | Math.min(x11, x21), 49 | Math.min(y11, y21), 50 | Math.max(x12, x22), 51 | Math.max(y12, y22), 52 | ]; 53 | 54 | export const getGlyphLinesBBX = memoizeOne((glyphLines: GlyphLine[]): BBX => { 55 | return glyphLines.map(getGlyphLineBBX).reduce(mergeBBX, bbxOfPoints([])); 56 | }, ([gLines1], [gLines2]) => ( 57 | gLines1.length === gLines2.length && 58 | gLines1.every((gLine1, index) => gLine1 === gLines2[index]) 59 | )); 60 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "kage-editor", 3 | "version": "0.1.0", 4 | "private": true, 5 | "license": "GPL-3.0-only", 6 | "type": "module", 7 | "dependencies": { 8 | "@kurgm/kage-engine": "^0.6.0", 9 | "@reduxjs/toolkit": "^2.9.1", 10 | "clsx": "^2.1.1", 11 | "geometric": "^2.5.5", 12 | "i18next": "^25.6.0", 13 | "memoize-one": "^6.0.0", 14 | "react": "^19.2.0", 15 | "react-dom": "^19.2.0", 16 | "react-hotkeys-hook": "^5.2.1", 17 | "react-i18next": "^16.1.0", 18 | "react-modal": "^3.16.3", 19 | "react-redux": "^9.2.0", 20 | "typescript-fsa": "^3.0.0", 21 | "typescript-fsa-reducers": "^1.2.2", 22 | "web-vitals": "^5.1.0" 23 | }, 24 | "devDependencies": { 25 | "@eslint/js": "^9.38.0", 26 | "@stylistic/eslint-plugin": "^5.5.0", 27 | "@testing-library/jest-dom": "^6.9.1", 28 | "@testing-library/react": "^16.3.0", 29 | "@testing-library/user-event": "^14.6.1", 30 | "@types/geometric": "^2.5.3", 31 | "@types/node": "^20.19.22", 32 | "@types/react": "^19.2.2", 33 | "@types/react-dom": "^19.2.2", 34 | "@types/react-modal": "^3.16.3", 35 | "@vitejs/plugin-react": "^5.0.4", 36 | "@vitest/eslint-plugin": "^1.3.23", 37 | "eslint": "^9.38.0", 38 | "eslint-plugin-import": "^2.32.0", 39 | "eslint-plugin-jsx-a11y": "^6.10.2", 40 | "eslint-plugin-react": "^7.37.5", 41 | "eslint-plugin-react-hooks": "^7.0.0", 42 | "eslint-plugin-react-refresh": "^0.4.24", 43 | "eslint-plugin-testing-library": "^7.13.3", 44 | "jsdom": "^27.0.1", 45 | "typescript": "^5.9.3", 46 | "typescript-eslint": "^8.46.1", 47 | "vite": "^7.1.11", 48 | "vitest": "^3.2.4" 49 | }, 50 | "scripts": { 51 | "dev": "vite", 52 | "build": "tsc -b && vite build", 53 | "lint": "eslint .", 54 | "preview": "vite preview", 55 | "test": "vitest" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/reducers/display.ts: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-only 2 | // Copyright 2020, 2023 kurgm 3 | 4 | import { ReducerBuilder } from 'typescript-fsa-reducers'; 5 | 6 | import { displayActions } from '../actions/display'; 7 | 8 | import { AppState } from '.'; 9 | 10 | export interface GridState { 11 | display: boolean; 12 | originX: number; 13 | originY: number; 14 | spacingX: number; 15 | spacingY: number; 16 | } 17 | 18 | const updateBuilder = (builder: ReducerBuilder) => builder 19 | .case(displayActions.openOptionModal, (state) => ({ 20 | ...state, 21 | showOptionModal: true, 22 | })) 23 | .case(displayActions.closeOptionModal, (state) => ({ 24 | ...state, 25 | showOptionModal: false, 26 | })) 27 | 28 | .case(displayActions.setGridDisplay, (state, value) => ({ 29 | ...state, 30 | grid: { 31 | ...state.grid, 32 | display: value, 33 | }, 34 | })) 35 | .case(displayActions.setGridOriginX, (state, value) => ({ 36 | ...state, 37 | grid: { 38 | ...state.grid, 39 | originX: value, 40 | }, 41 | })) 42 | .case(displayActions.setGridOriginY, (state, value) => ({ 43 | ...state, 44 | grid: { 45 | ...state.grid, 46 | originY: value, 47 | }, 48 | })) 49 | .case(displayActions.setGridSpacingX, (state, value) => ({ 50 | ...state, 51 | grid: { 52 | ...state.grid, 53 | spacingX: Math.max(2, value), 54 | }, 55 | })) 56 | .case(displayActions.setGridSpacingY, (state, value) => ({ 57 | ...state, 58 | grid: { 59 | ...state.grid, 60 | spacingY: Math.max(2, value), 61 | }, 62 | })) 63 | 64 | .case(displayActions.setStrokeCenterLineDisplay, (state, value) => ({ 65 | ...state, 66 | showStrokeCenterLine: value, 67 | })) 68 | .case(displayActions.setShotai, (state, shotai) => ({ 69 | ...state, 70 | shotai, 71 | })) 72 | 73 | .case(displayActions.setXorMaskType, (state, xorMaskType) => ({ 74 | ...state, 75 | xorMaskType, 76 | })); 77 | 78 | 79 | export default updateBuilder; 80 | -------------------------------------------------------------------------------- /src/reducers/select.ts: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-only 2 | // Copyright 2020 kurgm 3 | 4 | import { ReducerBuilder } from 'typescript-fsa-reducers'; 5 | 6 | import { selectActions } from '../actions/select'; 7 | 8 | import { AppState } from '.'; 9 | 10 | const updateBuilder = (builder: ReducerBuilder) => builder 11 | .case(selectActions.selectSingle, (state, index) => ({ 12 | ...state, 13 | selection: [index], 14 | })) 15 | .case(selectActions.selectAddSingle, (state, index) => ({ 16 | ...state, 17 | selection: state.selection.includes(index) ? state.selection : state.selection.concat([index]), 18 | })) 19 | .case(selectActions.selectRemoveSingle, (state, index) => ({ 20 | ...state, 21 | selection: state.selection.filter((index2) => index !== index2), 22 | })) 23 | .case(selectActions.selectAll, (state) => ({ 24 | ...state, 25 | selection: state.glyph.map((_gLine, index) => index), 26 | })) 27 | .case(selectActions.selectDeselected, (state) => ({ 28 | ...state, 29 | selection: state.glyph.map((_gLine, index) => index).filter((index) => !state.selection.includes(index)), 30 | })) 31 | .case(selectActions.selectNone, (state) => ({ 32 | ...state, 33 | selection: [], 34 | })) 35 | .case(selectActions.selectPrev, (state) => { 36 | if (state.glyph.length === 0) { 37 | return { ...state, selection: [] }; 38 | } 39 | const firstSelected = state.selection.length === 0 ? 0 : Math.min(...state.selection); 40 | return { 41 | ...state, 42 | selection: [(firstSelected - 1 + state.glyph.length) % state.glyph.length], 43 | }; 44 | }) 45 | .case(selectActions.selectNext, (state) => { 46 | if (state.glyph.length === 0) { 47 | return { ...state, selection: [] }; 48 | } 49 | const firstSelected = state.selection.length === 0 ? -1 : Math.max(...state.selection); 50 | return { 51 | ...state, 52 | selection: [(firstSelected + 1 + state.glyph.length) % state.glyph.length], 53 | }; 54 | }); 55 | 56 | 57 | export default updateBuilder; 58 | -------------------------------------------------------------------------------- /src/components/SubmitForm.tsx: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-only 2 | // Copyright 2020, 2022, 2023, 2025 kurgm 3 | 4 | import React, { useRef, useEffect } from 'react'; 5 | 6 | import args, { gwHosts } from '../args'; 7 | 8 | import { useAppSelector } from '../hooks'; 9 | import { submitGlyphSelector } from '../selectors/submitGlyph'; 10 | import { unparseGlyph } from '../kageUtils/glyph'; 11 | 12 | 13 | // Recent browsers do not send cookies (without SameSite attribute) in cross-site POST requests 14 | // for security reasons, which breaks GlyphWiki's session management. 15 | // If the submission will be cross-site request, submit as a GET request; otherwise submit as 16 | // a POST request to prevent the data loss due to a long URL (see issue #16). 17 | const isGlyphWikiHost = (host: string) => gwHosts.includes(host); 18 | const submitAsPost = isGlyphWikiHost(window.location.host) && isGlyphWikiHost(args.host); 19 | 20 | const glyphName = args.name || 'sandbox'; 21 | 22 | const formAction = `${args.ssl ? 'https' : 'http'}://${args.host}/wiki/${encodeURIComponent(glyphName)}${submitAsPost ? '?action=preview' : ''}`; 23 | 24 | const formStyle: React.CSSProperties = { 25 | visibility: 'hidden', 26 | position: 'absolute', 27 | }; 28 | 29 | const SubmitForm = () => { 30 | const exitEvent = useAppSelector((state) => state.exitEvent); 31 | const formRef = useRef(null); 32 | useEffect(() => { 33 | if (exitEvent) { 34 | formRef.current?.submit(); 35 | } 36 | }, [exitEvent]); 37 | const glyph = useAppSelector(submitGlyphSelector); 38 | return ( 39 |
45 | 46 | 47 | 48 | 49 | 50 | {args.edittime && } 51 |
52 | ); 53 | }; 54 | 55 | export default SubmitForm; 56 | -------------------------------------------------------------------------------- /src/reducers/undo.ts: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-only 2 | // Copyright 2020, 2023, 2025 kurgm 3 | 4 | import { AnyAction, Reducer } from '@reduxjs/toolkit'; 5 | import { reducerWithInitialState } from 'typescript-fsa-reducers'; 6 | 7 | import { isGlyphDeepEqual } from '../kageUtils/glyph'; 8 | 9 | import { undoActions } from '../actions/undo'; 10 | 11 | import { AppState } from '.'; 12 | 13 | const UNDO_MAX_TIMES = 30; 14 | 15 | const pushUndo = (oldState: AppState, newState: AppState): AppState => { 16 | if (isGlyphDeepEqual(oldState.glyph, newState.glyph)) { 17 | return newState; 18 | } 19 | return { 20 | ...newState, 21 | undoStacks: { 22 | undo: oldState.undoStacks.undo.concat([oldState.glyph]).slice(-UNDO_MAX_TIMES), 23 | redo: [], 24 | }, 25 | }; 26 | }; 27 | 28 | export const undoable = (reducer: Reducer): Reducer => { 29 | const initialState = reducer(undefined, { type: '_INIT' }); 30 | return reducerWithInitialState(initialState) 31 | .case(undoActions.undo, (state) => { 32 | if (state.undoStacks.undo.length === 0) { 33 | return state; 34 | } 35 | return { 36 | ...state, 37 | glyph: state.undoStacks.undo[state.undoStacks.undo.length - 1], 38 | selection: [], // TODO: select changed lines? 39 | undoStacks: { 40 | undo: state.undoStacks.undo.slice(0, -1), 41 | redo: state.undoStacks.redo.concat([state.glyph]), 42 | }, 43 | }; 44 | }) 45 | .case(undoActions.redo, (state) => { 46 | if (state.undoStacks.redo.length === 0) { 47 | return state; 48 | } 49 | return { 50 | ...state, 51 | glyph: state.undoStacks.redo[state.undoStacks.redo.length - 1], 52 | selection: [], // TODO: select changed lines? 53 | undoStacks: { 54 | undo: state.undoStacks.undo.concat([state.glyph]), 55 | redo: state.undoStacks.redo.slice(0, -1), 56 | }, 57 | }; 58 | }) 59 | .default((oldState, action) => { 60 | const newState = reducer(oldState, action); 61 | if (!oldState) { 62 | return newState; 63 | } 64 | return pushUndo(oldState, newState); 65 | }); 66 | }; 67 | -------------------------------------------------------------------------------- /src/kageUtils/stretchparam.ts: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-only 2 | // Copyright 2020, 2025 kurgm 3 | 4 | import { GlyphLine } from './glyph'; 5 | 6 | export type StretchParam = [number, number, number, number]; 7 | export type StretchPositions = [number, number, number, number]; 8 | 9 | export const getStretchPositions = (glyphLine: GlyphLine): StretchPositions | null => { 10 | if (glyphLine.value[0] !== 99) { 11 | return null; 12 | } 13 | const sx = glyphLine.value[9]; 14 | const sy = glyphLine.value[10]; 15 | const tx = glyphLine.value[1]; 16 | const ty = glyphLine.value[2]; 17 | return [sx, sy, tx, ty]; 18 | }; 19 | 20 | export const setStretchPositions = (glyphLine: GlyphLine, positions: StretchPositions): GlyphLine => { 21 | if (glyphLine.value[0] !== 99) { 22 | return glyphLine; 23 | } 24 | const [sx, sy, tx, ty] = positions; 25 | 26 | const newValue = glyphLine.value.slice(); 27 | newValue[9] = Math.round(sx); 28 | newValue[10] = Math.round(sy); 29 | newValue[1] = Math.round(tx); 30 | newValue[2] = Math.round(ty); 31 | return { value: newValue, partName: glyphLine.partName }; 32 | }; 33 | 34 | export const normalizeStretchPositions = (positions: StretchPositions): StretchPositions => { 35 | const [sx, sy, tx, ty] = positions; 36 | if (tx <= 100) { 37 | return [0, 0, tx + 200, ty]; 38 | } 39 | return [sx, sy, tx, ty]; 40 | }; 41 | 42 | export const calcStretchPositions = (param: StretchParam, k: number): StretchPositions => { 43 | const [x0, y0, x1, y1] = param; 44 | return [ 45 | x0 - 100, 46 | y0 - 100, 47 | x0 + (x1 - x0) * k / 20 + 100, 48 | y0 + (y1 - y0) * k / 20 - 100, 49 | ]; 50 | }; 51 | 52 | const clampStretchScalar = (k: number): number => Math.max(-10, Math.min(10, k)); 53 | 54 | export const calcStretchScalar = (param: StretchParam, positions: StretchPositions): number => { 55 | const [x0, y0, x1, y1] = param; 56 | if (x0 === x1 && y0 === y1) { 57 | return 0; 58 | } 59 | const [sx, sy, tx, ty] = normalizeStretchPositions(positions); 60 | if (sx === tx - 200 && sy === ty) { 61 | return 0; 62 | } 63 | return clampStretchScalar(Math.round( 64 | Math.abs(x0 - x1) > Math.abs(y0 - y1) 65 | ? (tx - 100 - x0) / (x1 - x0) * 20 66 | : (ty + 100 - y0) / (y1 - y0) * 20 67 | )) || 0; 68 | } 69 | -------------------------------------------------------------------------------- /src/kageUtils/match.ts: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-only 2 | // Copyright 2020, 2025 kurgm 3 | 4 | import { Glyph, PointDescriptor, getNumColumns } from './glyph'; 5 | 6 | export enum MatchType { 7 | none, 8 | online, 9 | match, 10 | } 11 | 12 | export const getMatchType = (glyph: Glyph, point: PointDescriptor): MatchType => { 13 | const glyphLine = glyph[point.lineIndex]; 14 | 15 | const endPointIndex = (getNumColumns(glyphLine.value[0]) - 3) / 2 - 1; 16 | if (point.pointIndex !== 0 && point.pointIndex !== endPointIndex) { 17 | return MatchType.none; 18 | } 19 | 20 | const x = glyphLine.value[3 + point.pointIndex * 2]; 21 | const y = glyphLine.value[3 + point.pointIndex * 2 + 1]; 22 | const isOnline = (x1: number, y1: number, x2: number, y2: number): boolean => ( 23 | ( 24 | x1 === x2 && // 垂直 25 | x1 === x && y1 <= y && y <= y2 26 | ) || ( 27 | y1 === y2 && // 水平 28 | y1 === y && x1 <= x && x <= x2 29 | ) 30 | ); 31 | 32 | let result = MatchType.none; 33 | for (let lineIndex = 0; lineIndex < glyph.length; lineIndex++) { 34 | if (point.lineIndex === lineIndex) { 35 | continue; 36 | } 37 | const glyphLine = glyph[lineIndex]; 38 | if ([0, 9, 99].includes(glyphLine.value[0])) { 39 | continue; 40 | } 41 | 42 | if (glyphLine.value[3] === x && glyphLine.value[4] === y) { 43 | return MatchType.match; 44 | } 45 | const endPointIndex = (getNumColumns(glyphLine.value[0]) - 3) / 2 - 1; 46 | if ( 47 | glyphLine.value[3 + endPointIndex * 2] === x && 48 | glyphLine.value[3 + endPointIndex * 2 + 1] === y 49 | ) { 50 | return MatchType.match; 51 | } 52 | 53 | switch (glyphLine.value[0]) { 54 | case 3: 55 | case 4: 56 | if ( 57 | isOnline(glyphLine.value[3], glyphLine.value[4], glyphLine.value[5], glyphLine.value[6]) || 58 | isOnline(glyphLine.value[5], glyphLine.value[6], glyphLine.value[7], glyphLine.value[8]) 59 | ) { 60 | result = MatchType.online; 61 | } 62 | break; 63 | case 1: 64 | case 7: 65 | if (isOnline(glyphLine.value[3], glyphLine.value[4], glyphLine.value[5], glyphLine.value[6])) { 66 | result = MatchType.online; 67 | } 68 | break; 69 | default: 70 | break; 71 | } 72 | } 73 | return result; 74 | }; 75 | -------------------------------------------------------------------------------- /src/kageUtils/glyph.ts: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-only 2 | // Copyright 2020 kurgm 3 | 4 | export interface GlyphLine { 5 | value: number[]; 6 | partName?: string; 7 | } 8 | 9 | export const getNumColumns = (strokeType: number): number => { 10 | switch (strokeType) { 11 | case 0: 12 | case 1: 13 | case 9: 14 | return 7; 15 | case 2: 16 | case 3: 17 | case 4: 18 | return 9; 19 | case 6: 20 | case 7: 21 | case 99: 22 | return 11; 23 | default: 24 | return 0; 25 | } 26 | }; 27 | 28 | export const parseGlyphLine = (glyphLineStr: string): GlyphLine => { 29 | const splitLine = glyphLineStr.split(':'); 30 | const strokeType = +splitLine[0]; 31 | const numColumns = getNumColumns(strokeType); 32 | const value = []; 33 | for (let i = 0; i < numColumns; i++) { 34 | value.push(Math.floor(+splitLine[i] || 0)); 35 | } 36 | if (value[0] === 99) { 37 | const partName = splitLine[7] || ''; 38 | return { value, partName } 39 | } 40 | return { value }; 41 | }; 42 | 43 | export const unparseGlyphLine = (glyphLine: GlyphLine): string => { 44 | const values: (number | string)[] = glyphLine.value.map((num) => Math.round(num)); 45 | if (values[0] === 99) { 46 | values[7] = glyphLine.partName || ''; 47 | } 48 | return values.join(':'); 49 | }; 50 | 51 | export const isValidGlyphLine = (glyphLine: GlyphLine): boolean => ( 52 | glyphLine.value.length !== 0 && 53 | ( 54 | glyphLine.value[0] !== 0 || 55 | glyphLine.value[1] === 97 || glyphLine.value[1] === 98 || glyphLine.value[1] === 99 56 | ) 57 | ); 58 | 59 | 60 | export type Glyph = GlyphLine[]; 61 | 62 | export const parseGlyph = (glyphStr: string): Glyph => ( 63 | glyphStr.split(/[$\r\n]+/) 64 | .map((line) => parseGlyphLine(line)) 65 | .filter((gLine) => isValidGlyphLine(gLine)) 66 | ); 67 | 68 | export const unparseGlyph = (glyph: Glyph): string => ( 69 | glyph 70 | .map((gLine) => unparseGlyphLine(gLine)) 71 | .join('$') 72 | ); 73 | 74 | export const isGlyphDeepEqual = (glyph1: Glyph, glyph2: Glyph) => ( 75 | glyph1 === glyph2 || ( 76 | glyph1.length === glyph2.length && 77 | glyph1.every((glyphLine1, index) => { 78 | const glyphLine2 = glyph2[index]; 79 | return glyphLine1 === glyphLine2 || ( 80 | glyphLine1.partName === glyphLine2.partName && 81 | glyphLine1.value.length === glyphLine2.value.length && 82 | glyphLine1.value.every((value1, index) => value1 === glyphLine2.value[index]) 83 | ); 84 | }) 85 | ) 86 | ); 87 | 88 | 89 | export interface PointDescriptor { 90 | lineIndex: number; 91 | pointIndex: number; 92 | } 93 | -------------------------------------------------------------------------------- /src/reducers/index.ts: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-only 2 | // Copyright 2020, 2021, 2023 kurgm and graphemecluster 3 | 4 | import { reducerWithInitialState } from 'typescript-fsa-reducers'; 5 | 6 | import { ShowCenterLine } from '../actions/display'; 7 | import { RectPointPosition, CTMInv } from '../actions/drag'; 8 | 9 | import { GlyphLine, Glyph, parseGlyph } from '../kageUtils/glyph'; 10 | import { StretchParam } from '../kageUtils/stretchparam'; 11 | import args from '../args'; 12 | import type { KShotai } from '../kage'; 13 | 14 | import select from './select'; 15 | import drag from './drag'; 16 | import editor from './editor'; 17 | import display, { GridState } from './display'; 18 | 19 | import { undoable } from './undo'; 20 | import { XorMaskType } from '../xorMask'; 21 | 22 | export interface AppState { 23 | glyph: Glyph; 24 | selection: number[]; 25 | 26 | areaSelectRect: [number, number, number, number] | null; 27 | dragSelection: [number, number, number, number] | null; 28 | dragPoint: [number, [number, number, number, number]] | null; 29 | resizeSelection: [RectPointPosition, [number, number, number, number]] | null; 30 | freehandStroke: [number, number][] | null; 31 | ctmInv: CTMInv | null; 32 | freehandMode: boolean; 33 | 34 | buhinMap: Map; 35 | stretchParamMap: Map; 36 | clipboard: GlyphLine[]; 37 | undoStacks: { 38 | undo: Glyph[]; 39 | redo: Glyph[]; 40 | }; 41 | exitEvent: Event | null; 42 | 43 | showOptionModal: boolean; 44 | grid: GridState; 45 | showStrokeCenterLine: ShowCenterLine; 46 | shotai: KShotai; 47 | xorMaskType: XorMaskType; 48 | } 49 | 50 | const initialState: AppState = { 51 | glyph: parseGlyph(args.data), 52 | selection: [], 53 | 54 | areaSelectRect: null, 55 | dragSelection: null, 56 | dragPoint: null, 57 | resizeSelection: null, 58 | freehandStroke: null, 59 | ctmInv: null, 60 | freehandMode: false, 61 | 62 | buhinMap: new Map(), 63 | stretchParamMap: new Map(), 64 | clipboard: [], 65 | undoStacks: { undo: [], redo: [] }, 66 | exitEvent: null, 67 | 68 | showOptionModal: false, 69 | grid: { 70 | display: true, 71 | originX: 0, 72 | originY: 0, 73 | spacingX: 20, 74 | spacingY: 20, 75 | }, 76 | showStrokeCenterLine: ShowCenterLine.selection, 77 | shotai: 0, // KShotai.kMincho 78 | xorMaskType: "none", 79 | }; 80 | 81 | const reducer = undoable( 82 | reducerWithInitialState(initialState) 83 | .withHandling(select) 84 | .withHandling(drag) 85 | .withHandling(editor) 86 | .withHandling(display) 87 | ); 88 | 89 | export default reducer; 90 | -------------------------------------------------------------------------------- /src/components/Glyph.tsx: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-only 2 | // Copyright 2020, 2023, 2025 kurgm 3 | 4 | import clsx from 'clsx/lite'; 5 | import React from 'react'; 6 | 7 | import { Glyph } from '../kageUtils/glyph'; 8 | import { makeGlyphSeparated, KShotai } from '../kage'; 9 | import { XorMaskType } from '../xorMask'; 10 | 11 | import Stroke from './Stroke'; 12 | 13 | import styles from './Glyph.module.css' 14 | 15 | export interface GlyphComponentProps { 16 | glyph: Glyph; 17 | buhinMap: Map; 18 | selection: number[]; 19 | shotai: KShotai; 20 | xorMaskType: XorMaskType; 21 | translucentXorMask?: boolean; 22 | handleMouseDownDeselectedStroke?: (evt: React.MouseEvent, index: number) => void; 23 | handleMouseDownSelectedStroke?: (evt: React.MouseEvent, index: number) => void; 24 | makeGlyphSeparated?: typeof makeGlyphSeparated; 25 | } 26 | 27 | const GlyphComponent = (props: GlyphComponentProps) => { 28 | const polygonsSep = (props.makeGlyphSeparated || makeGlyphSeparated)(props.glyph, props.buhinMap, props.shotai); 29 | 30 | const { selection } = props; 31 | const nonSelection = polygonsSep.map((_polygons, index) => index) 32 | .filter((index) => !selection.includes(index)); 33 | 34 | return ( 35 | <> 36 | 37 | {nonSelection.map((index) => ( 38 | props.handleMouseDownDeselectedStroke?.(evt, index)}> 39 | 43 | 44 | ))} 45 | 46 | {props.xorMaskType !== "none" && <> 47 | 51 | 52 | 53 | 54 | 55 | {nonSelection.map((index) => ( 56 | 57 | 58 | 59 | ))} 60 | 61 | } 62 | 63 | {selection.map((index) => ( 64 | props.handleMouseDownSelectedStroke?.(evt, index)}> 65 | 69 | 70 | ))} 71 | 72 | 73 | ); 74 | }; 75 | 76 | export default GlyphComponent; 77 | -------------------------------------------------------------------------------- /src/shortcuts.ts: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-only 2 | // Copyright 2020, 2023, 2025 kurgm 3 | 4 | import { useHotkeys } from 'react-hotkeys-hook'; 5 | 6 | import { useAppDispatch } from './hooks'; 7 | import { selectActions } from './actions/select'; 8 | import { editorActions } from './actions/editor'; 9 | import { undoActions } from './actions/undo'; 10 | 11 | export const useShortcuts = () => { 12 | const dispatch = useAppDispatch(); 13 | useHotkeys('mod+a', (evt) => { 14 | dispatch(selectActions.selectAll()); 15 | evt.preventDefault(); 16 | }, {}, [dispatch]); 17 | useHotkeys('mod+i', (evt) => { 18 | dispatch(selectActions.selectDeselected()); 19 | evt.preventDefault(); 20 | }, {}, [dispatch]); 21 | 22 | useHotkeys('mod+z', (evt) => { 23 | dispatch(undoActions.undo()); 24 | evt.preventDefault(); 25 | }, {}, [dispatch]); 26 | useHotkeys('mod+y, mod+shift+z', (evt) => { 27 | dispatch(undoActions.redo()); 28 | evt.preventDefault(); 29 | }, {}, [dispatch]); 30 | useHotkeys('mod+x', (evt) => { 31 | dispatch(editorActions.cut()); 32 | evt.preventDefault(); 33 | }, {}, [dispatch]); 34 | useHotkeys('mod+c', (evt) => { 35 | dispatch(editorActions.copy()); 36 | evt.preventDefault(); 37 | }, {}, [dispatch]); 38 | useHotkeys('mod+v', (evt) => { 39 | dispatch(editorActions.paste()); 40 | evt.preventDefault(); 41 | }, {}, [dispatch]); 42 | useHotkeys('delete', (evt) => { 43 | dispatch(editorActions.delete()); 44 | evt.preventDefault(); 45 | }, {}, [dispatch]); 46 | 47 | useHotkeys('esc', () => { 48 | dispatch(editorActions.escape()); 49 | }, {}, [dispatch]); 50 | 51 | useHotkeys('ctrl+h, left', (evt) => { 52 | dispatch(editorActions.moveSelected([-1, 0])); 53 | evt.preventDefault(); 54 | }, {}, [dispatch]); 55 | useHotkeys('ctrl+j, down', (evt) => { 56 | dispatch(editorActions.moveSelected([0, 1])); 57 | evt.preventDefault(); 58 | }, {}, [dispatch]); 59 | useHotkeys('ctrl+k, up', (evt) => { 60 | dispatch(editorActions.moveSelected([0, -1])); 61 | evt.preventDefault(); 62 | }, {}, [dispatch]); 63 | useHotkeys('ctrl+l, right', (evt) => { 64 | dispatch(editorActions.moveSelected([1, 0])); 65 | evt.preventDefault(); 66 | }, {}, [dispatch]); 67 | useHotkeys('ctrl+shift+h, shift+left', (evt) => { 68 | dispatch(editorActions.moveSelected([-5, 0])); 69 | evt.preventDefault(); 70 | }, {}, [dispatch]); 71 | useHotkeys('ctrl+shift+j, shift+down', (evt) => { 72 | dispatch(editorActions.moveSelected([0, 5])); 73 | evt.preventDefault(); 74 | }, {}, [dispatch]); 75 | useHotkeys('ctrl+shift+k, shift+up', (evt) => { 76 | dispatch(editorActions.moveSelected([0, -5])); 77 | evt.preventDefault(); 78 | }, {}, [dispatch]); 79 | useHotkeys('ctrl+shift+l, shift+right', (evt) => { 80 | dispatch(editorActions.moveSelected([5, 0])); 81 | evt.preventDefault(); 82 | }, {}, [dispatch]); 83 | 84 | useHotkeys('mod+s', (evt) => { 85 | dispatch(editorActions.finishEdit(evt)); 86 | evt.preventDefault(); 87 | }, {}, [dispatch]); 88 | }; 89 | -------------------------------------------------------------------------------- /src/components/StrokeCenterLine.tsx: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-only 2 | // Copyright 2020, 2023, 2025 kurgm 3 | 4 | 5 | import { ShowCenterLine } from '../actions/display'; 6 | import { useAppSelector } from '../hooks'; 7 | import { createAppSelector } from '../selectors/util'; 8 | import { draggedGlyphSelector } from '../selectors/draggedGlyph'; 9 | import { decomposeDeep } from '../kageUtils/decompose'; 10 | import { GlyphLine } from '../kageUtils/glyph'; 11 | 12 | import styles from './StrokeCenterLine.module.css'; 13 | 14 | const strokeCenterLineShownNumbersSelector = createAppSelector( 15 | [ 16 | draggedGlyphSelector, 17 | (state) => state.showStrokeCenterLine, 18 | (state) => state.selection, 19 | ], 20 | (glyph, showStrokeCenterLine, selection): number[] => { 21 | switch (showStrokeCenterLine) { 22 | case ShowCenterLine.none: 23 | return []; 24 | case ShowCenterLine.selection: { 25 | if (selection.length !== 1) { 26 | return []; 27 | } 28 | const selectedGlyphLine = glyph[selection[0]]; 29 | switch (selectedGlyphLine.value[0]) { 30 | case 0: 31 | case 9: 32 | case 99: 33 | return []; 34 | default: 35 | return selection; 36 | } 37 | } 38 | case ShowCenterLine.always: 39 | return glyph.map((_gLine, index) => index); 40 | } 41 | } 42 | ); 43 | 44 | const strokeCenterLineStrokesPerLinesSelector = createAppSelector( 45 | [ 46 | draggedGlyphSelector, 47 | (state) => state.buhinMap, 48 | strokeCenterLineShownNumbersSelector, 49 | ], 50 | (glyph, buhinMap, glyphLineNumbers): GlyphLine[][] => ( 51 | glyph.map((gLine, index) => glyphLineNumbers.includes(index) ? decomposeDeep(gLine, buhinMap) : []) 52 | ) 53 | ); 54 | 55 | const StrokeCenterLine = () => { 56 | const strokesPerLines = useAppSelector(strokeCenterLineStrokesPerLinesSelector); 57 | return ( 58 | 59 | {strokesPerLines.map((strokesPerLine, lineIndex) => ( 60 | 61 | {strokesPerLine.map((stroke, strokeIndex) => { 62 | const v = stroke.value; 63 | switch (v[0]) { 64 | case 1: 65 | return 66 | case 2: 67 | return 68 | case 3: 69 | case 4: 70 | return 71 | case 6: 72 | return 73 | case 7: 74 | return 75 | default: 76 | return null; 77 | } 78 | })} 79 | 80 | ))} 81 | 82 | ); 83 | }; 84 | 85 | export default StrokeCenterLine; 86 | -------------------------------------------------------------------------------- /src/kage.ts: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-only 2 | // Copyright 2020, 2022, 2025 kurgm 3 | 4 | import memoizeOne from 'memoize-one'; 5 | 6 | import { Kage, Polygons, KShotai } from '@kurgm/kage-engine'; 7 | import { Glyph, unparseGlyphLine } from './kageUtils/glyph'; 8 | import { StretchParam } from './kageUtils/stretchparam'; 9 | 10 | import store from './store'; 11 | import { editorActions } from './actions/editor'; 12 | 13 | import { getSource } from './callapi'; 14 | 15 | export { KShotai }; 16 | 17 | const kage_ = new Kage(); 18 | 19 | export const getKage = (buhinMap: Map, fallback?: (name: string) => string | undefined | void, shotai?: KShotai): Kage => { 20 | kage_.kBuhin.search = (name) => { 21 | let result = buhinMap.get(name); 22 | if (typeof result === 'undefined') { 23 | result = fallback?.(name) || ''; 24 | } 25 | return result; 26 | }; 27 | if (typeof shotai !== 'undefined') { 28 | kage_.kShotai = shotai; 29 | } 30 | return kage_; 31 | }; 32 | 33 | const waiting = new Set(); 34 | const loadAbsentBuhin = (name: string) => { 35 | if (waiting.has(name)) { 36 | return; 37 | } 38 | waiting.add(name); 39 | getSource(name) 40 | .then((source) => { 41 | if (typeof source !== 'string') { 42 | throw new Error(`failed to get buhin source of ${name}`); 43 | } 44 | const stretchMatch = /^0:1:0:(-?\d+):(-?\d+):(-?\d+):(-?\d+)(?=$|\$)/.exec(source); 45 | if (stretchMatch) { 46 | const params: StretchParam = [ 47 | +stretchMatch[1] || 0, 48 | +stretchMatch[2] || 0, 49 | +stretchMatch[3] || 0, 50 | +stretchMatch[4] || 0, 51 | ]; 52 | store.dispatch(editorActions.loadedStretchParam([name, params])); 53 | } 54 | store.dispatch(editorActions.loadedBuhin([name, source])); 55 | waiting.delete(name); 56 | }) 57 | .catch((err) => console.error(err)); 58 | }; 59 | 60 | const filteredGlyphIsEqual = (glyph1: Glyph, glyph2: Glyph) => ( 61 | glyph1.length === glyph2.length && 62 | glyph1.every((gLine1, index) => ( 63 | gLine1 === glyph2[index] 64 | )) 65 | ); 66 | 67 | const makeGlyphSeparated_ = memoizeOne((glyph: Glyph, map: Map, shotai: KShotai): Polygons[] => { 68 | const data = glyph.map(unparseGlyphLine); 69 | const result = getKage(map, loadAbsentBuhin, shotai).makeGlyphSeparated(data); 70 | return result; 71 | }, ([glyph1, map1, shotai1], [glyph2, map2, shotai2]) => ( 72 | map1 === map2 && 73 | shotai1 === shotai2 && 74 | filteredGlyphIsEqual(glyph1, glyph2) 75 | )); 76 | 77 | const makeGlyphSeparatedFactory = ( 78 | isEqual?: (newArgs: Parameters, lastArgs: Parameters) => boolean 79 | ) => memoizeOne((glyph: Glyph, map: Map, shotai: KShotai): Polygons[] => { 80 | return makeGlyphSeparated_(glyph, map, shotai); 81 | }, isEqual); 82 | 83 | export const makeGlyphSeparated = makeGlyphSeparatedFactory(); 84 | export const makeGlyphSeparatedForSubmit = makeGlyphSeparatedFactory( 85 | ([glyph1, map1, shotai1], [glyph2, map2, shotai2]) => ( 86 | map1 === map2 && 87 | shotai1 === shotai2 && 88 | filteredGlyphIsEqual(glyph1, glyph2) 89 | ) 90 | ); 91 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # kage-editor 2 | 3 | The glyph editor used on [GlyphWiki](https://glyphwiki.org/) 4 | 5 | [GlyphWiki](https://glyphwiki.org/) で使用されている字形エディタ 6 | 7 | [![Screen shot of kage-editor](https://user-images.githubusercontent.com/14951262/147846286-5eec550d-5a20-48a6-ab67-0b37d8674d2d.png)](https://kurgm.github.io/kage-editor/#data=2%3A7%3A8%3A66%3A13%3A102%3A23%3A120%3A43%241%3A0%3A2%3A34%3A60%3A100%3A60%241%3A22%3A4%3A100%3A60%3A100%3A183%241%3A0%3A2%3A16%3A93%3A71%3A93%242%3A22%3A7%3A71%3A93%3A61%3A145%3A13%3A174%242%3A0%3A7%3A171%3A64%3A152%3A81%3A119%3A104%242%3A7%3A0%3A105%3A67%3A121%3A135%3A180%3A166) 8 | 9 | This HTML5 / JavaScript app is the ported version from the previous glyph editor which was implemented as a Flash app. 10 | 11 | この HTML5 / JavaScript アプリは、Flash で実装されていた以前のグリフエディタから移植されたものです。 12 | 13 | macOS 上の最新版の Chrome / Firefox / Safari で動作確認しています。 14 | 15 | ## 導入方法 16 | 17 | ソースコードは直接ウェブサイトに設置できる状態にはなっておらず、事前にビルド作業が必要になります。 18 | 19 | ビルド作業を行う環境には Node.js がインストールされていることが必要ですが、生成されるのは静的サイトですから設置先のウェブサーバには Node.js などが無くても構いません。 20 | 21 | (また、[このリポジトリの gh-pages ブランチ](https://github.com/kurgm/kage-editor/tree/gh-pages)にビルド済みの(`./build` ディレクトリ下の)ファイルがあります。) 22 | 23 | ### ビルド手順 24 | Node.js (version 20 以上) をインストールしておいてください。 25 | 26 | この git リポジトリをクローンします: 27 | ```bash 28 | git clone https://github.com/kurgm/kage-editor.git 29 | cd kage-editor 30 | ``` 31 | 32 | ビルドに必要なツール・ライブラリ等を `./node_modules` ディレクトリに取得します: 33 | ```bash 34 | npm install 35 | ``` 36 | 37 | ビルドを行います: 38 | ```bash 39 | npm run build 40 | ``` 41 | 42 | ここまでの手順が成功すれば、ビルド結果は `./build` ディレクトリに生成されています。 `./build` ディレクトリをウェブサーバにコピー・配置してください。(他のディレクトリ(`src` や `node_modules` など)をコピーする必要はありません。) 43 | 44 | ## ブックマークレット 45 | (最新の kage-editor を利用したいグリフウィキ利用者向け) 46 | 47 | グリフウィキの編集中画面からジャンプできるブックマークレットです。 48 | 49 | ```js 50 | javascript:(function(l,f){l.href='https://kurgm.github.io/kage-editor/#ssl='+(l.protocol!='http:')+'&host='+l.host+'&name:page&edittime&related&data:textbox&summary'.replace(/(\w+):?(\w*)/g,function(e,k,n){return k+'='+encodeURIComponent(f[1].elements[n||k].value).replace(/%3A/g,':')})})(location,document.forms) 51 | ``` 52 | 53 | ## 機能一覧 54 | 55 | - 筆画・部品をクリックで選択 56 | - 筆画・部品をCtrl+クリック or Shift+クリックで選択対象に追加/削除 57 | - 筆画・部品をドラッグで範囲選択 58 | - 背景をクリックで全選択解除 59 | - 選択筆画・部品の制御点の表示 60 | - 接続有無による制御点の色分け 61 | - 制御点ドラッグによる編集 62 | - 複数筆画・部品の拡大縮小 63 | - 筆画・部品をドラッグで移動 64 | - 選択筆画・部品の制御点の座標を表示 65 | - 線種・頭/尾形状の編集 66 | - 線種形状のエラー表示 67 | - ストレッチ係数の表示/編集 68 | - 部品分解 69 | - コピー/カット/ペースト 70 | - 1つ前/後を選択 71 | - 筆画・部品の並べ替え 72 | - 部品検索・挿入 73 | - 部品一覧から孫検索 (Shift+クリック) 74 | - サムネイル表示 75 | - アンドゥ/リドゥ 76 | - 手書き 77 | - グリッド 78 | - UI表示言語切替 79 | - 書体切替 80 | - 中心線表示 81 | - 白抜き表示 82 | - キーボードショートカット 83 | + Ctrl+A: 全選択 84 | + Ctrl+I: 選択反転 85 | + Ctrl+Z, Ctrl+Y: 元に戻す/やり直し 86 | + Ctrl+X, Ctrl+C, Ctrl+V: カット, コピー, ペースト 87 | + Del: 選択分を削除 88 | + 矢印キー / Ctrl+{H,J,K,L}: 選択分を1px移動 (Shift+ で5px移動) 89 | + Esc: 手書きモード終了, 選択解除 90 | + Ctrl+S: 編集終了 91 | 92 | ## 未対応の機能 93 | - 部品自動配置(不要?) 94 | - 回転・反転の追加 95 | 96 | ## 翻訳 / Translation 97 | 98 | 翻訳データは [src/locales/](src/locales/) フォルダ内のJSONファイルで管理されています。 99 | 翻訳に誤りを発見した場合は報告・修正にご協力いただけると非常に助かります。 100 | 101 | Localized messages are maintained as JSON files under [src/locales/](src/locales/) folder. Feedback or correction of mistranslations is greatly appreciated if you find any. 102 | 103 | ## ライセンス 104 | 105 | [GPL-3.0-only](COPYING) 106 | -------------------------------------------------------------------------------- /src/locales/zh-Hans.json: -------------------------------------------------------------------------------- 1 | { 2 | "stroke type": "笔画种类", 3 | "stroke type 1": "直线", 4 | "stroke type 2": "曲线", 5 | "stroke type 3": "折", 6 | "stroke type 4": "弯", 7 | "stroke type 6": "三次曲线", 8 | "stroke type 7": "竖撇", 9 | "head type": "起笔形状", 10 | "head type 1-0": "开放", 11 | "head type 1-2": "连接(横)", 12 | "head type 1-32": "连接(纵)", 13 | "head type 1-12": "左上角", 14 | "head type 1-22": "右上角", 15 | "head type 2-0": "开放", 16 | "head type 2-32": "连接", 17 | "head type 2-12": "左上角", 18 | "head type 2-22": "右上角", 19 | "head type 2-7": "尖锐", 20 | "head type 2-27": "横捺角", 21 | "head type 3-0": "开放", 22 | "head type 3-32": "连接(纵)", 23 | "head type 3-12": "左上角", 24 | "head type 3-22": "右上角", 25 | "head type 4-0": "开放", 26 | "head type 4-22": "右上角", 27 | "head type 6-0": "开放", 28 | "head type 6-32": "连接", 29 | "head type 6-12": "左上角", 30 | "head type 6-22": "右上角", 31 | "head type 6-7": "尖锐", 32 | "head type 6-27": "横捺角", 33 | "head type 7-0": "开放", 34 | "head type 7-32": "连接", 35 | "head type 7-12": "左上角", 36 | "head type 7-22": "右上角", 37 | "tail type": "收笔形状", 38 | "tail type 1-0": "开放", 39 | "tail type 1-2": "连接(横)", 40 | "tail type 1-32": "连接(纵)", 41 | "tail type 1-13": "左下角", 42 | "tail type 1-23": "右下角", 43 | "tail type 1-4": "左钩", 44 | "tail type 1-313": "竖折(旧字形)", 45 | "tail type 1-413": "竖折(新字形)", 46 | "tail type 1-24": "右下角(港台字形)", 47 | "tail type 2-7": "撇", 48 | "tail type 2-0": "捺", 49 | "tail type 2-8": "圆尾", 50 | "tail type 2-4": "左钩", 51 | "tail type 2-5": "右钩", 52 | "tail type 3-0": "开放", 53 | "tail type 3-5": "上钩", 54 | "tail type 3-32": "连接", 55 | "tail type 4-0": "开放", 56 | "tail type 4-5": "上钩", 57 | "tail type 6-7": "撇", 58 | "tail type 6-0": "捺", 59 | "tail type 6-8": "圆尾", 60 | "tail type 6-4": "左钩", 61 | "tail type 6-5": "右钩", 62 | "tail type 7-7": "撇", 63 | "invalid stroke shape types": "错误:非法笔形组合", 64 | "part": "部件", 65 | "alias of": "({{entity}} 的别名)", 66 | "stretch": "伸缩:", 67 | "operation type reflectX": "左右反转", 68 | "operation type reflectY": "上下反转", 69 | "operation type rotate90": "顺时针旋转90度", 70 | "operation type rotate180": "顺时针旋转180度", 71 | "operation type rotate270": "顺时针旋转270度", 72 | "selecting multiple strokes": "已选择多个项目", 73 | "select prev": "←", 74 | "swap with prev": "调换←", 75 | "select next": "→", 76 | "swap with next": "→调换", 77 | "undo": "撤销", 78 | "redo": "重做", 79 | "select all": "全选", 80 | "invert selection": "反选", 81 | "copy": "复制", 82 | "paste": "粘贴", 83 | "cut": "剪切", 84 | "start freehand": "开始手写", 85 | "end freehand": "结束手写", 86 | "decompose": "拆解部件", 87 | "options": "设置…", 88 | "finish edit": "编辑完毕", 89 | "search": "搜索", 90 | "searching": "搜索中", 91 | "search error": "错误:{{message}}", 92 | "search query too short": "错误:关键词过短", 93 | "no search result": "查无结果", 94 | "grid option": "网格", 95 | "enable grid": "显示网格", 96 | "grid origin x": "X轴初始值", 97 | "grid origin y": "Y轴初始值", 98 | "grid spacing x": "X轴间隔", 99 | "grid spacing y": "Y轴间隔", 100 | "glyph font style": "字体风格", 101 | "mincho style": "宋体", 102 | "gothic style": "黑体", 103 | "show stroke center line": "笔画中线", 104 | "show stroke center line none": "隐藏", 105 | "show stroke center line selection": "只显示选取笔画", 106 | "show stroke center line always": "始终显示", 107 | "negative mask type": "蒙板", 108 | "negative mask type none": "无", 109 | "negative mask type circle": "圆形", 110 | "negative mask type squareWithRoundedCorners": "圆角矩形", 111 | "negative mask type square": "矩形", 112 | "negative mask type diamond": "菱形", 113 | "display language": "界面语言", 114 | "close modal": "关闭" 115 | } 116 | -------------------------------------------------------------------------------- /src/locales/zh-Hant.json: -------------------------------------------------------------------------------- 1 | { 2 | "stroke type": "筆劃種類", 3 | "stroke type 1": "直線", 4 | "stroke type 2": "曲線", 5 | "stroke type 3": "折", 6 | "stroke type 4": "彎", 7 | "stroke type 6": "三次曲線", 8 | "stroke type 7": "豎撇", 9 | "head type": "起筆形狀", 10 | "head type 1-0": "開放", 11 | "head type 1-2": "連接(橫)", 12 | "head type 1-32": "連接(縱)", 13 | "head type 1-12": "左上角", 14 | "head type 1-22": "右上角", 15 | "head type 2-0": "開放", 16 | "head type 2-32": "連接", 17 | "head type 2-12": "左上角", 18 | "head type 2-22": "右上角", 19 | "head type 2-7": "尖銳", 20 | "head type 2-27": "橫捺角", 21 | "head type 3-0": "開放", 22 | "head type 3-32": "連接(縱)", 23 | "head type 3-12": "左上角", 24 | "head type 3-22": "右上角", 25 | "head type 4-0": "開放", 26 | "head type 4-22": "右上角", 27 | "head type 6-0": "開放", 28 | "head type 6-32": "連接", 29 | "head type 6-12": "左上角", 30 | "head type 6-22": "右上角", 31 | "head type 6-7": "尖銳", 32 | "head type 6-27": "橫捺角", 33 | "head type 7-0": "開放", 34 | "head type 7-32": "連接", 35 | "head type 7-12": "左上角", 36 | "head type 7-22": "右上角", 37 | "tail type": "收筆形狀", 38 | "tail type 1-0": "開放", 39 | "tail type 1-2": "連接(橫)", 40 | "tail type 1-32": "連接(縱)", 41 | "tail type 1-13": "左下角", 42 | "tail type 1-23": "右下角", 43 | "tail type 1-4": "左鉤", 44 | "tail type 1-313": "豎折(大陸舊字形)", 45 | "tail type 1-413": "豎折(大陸新字形)", 46 | "tail type 1-24": "右下角(港台字形)", 47 | "tail type 2-7": "撇", 48 | "tail type 2-0": "捺", 49 | "tail type 2-8": "圓尾", 50 | "tail type 2-4": "左鉤", 51 | "tail type 2-5": "右鉤", 52 | "tail type 3-0": "開放", 53 | "tail type 3-5": "上鉤", 54 | "tail type 3-32": "連接", 55 | "tail type 4-0": "開放", 56 | "tail type 4-5": "上鉤", 57 | "tail type 6-7": "撇", 58 | "tail type 6-0": "捺", 59 | "tail type 6-8": "圓尾", 60 | "tail type 6-4": "左鉤", 61 | "tail type 6-5": "右鉤", 62 | "tail type 7-7": "撇", 63 | "invalid stroke shape types": "錯誤:非法筆形組合", 64 | "part": "部件", 65 | "alias of": "({{entity}} 的別名)", 66 | "stretch": "伸縮:", 67 | "operation type reflectX": "左右反轉", 68 | "operation type reflectY": "上下反轉", 69 | "operation type rotate90": "順時針旋轉90度", 70 | "operation type rotate180": "順時針旋轉180度", 71 | "operation type rotate270": "順時針旋轉270度", 72 | "selecting multiple strokes": "已選取多個項目", 73 | "select prev": "←", 74 | "swap with prev": "調換←", 75 | "select next": "→", 76 | "swap with next": "→調換", 77 | "undo": "復原", 78 | "redo": "重做", 79 | "select all": "全部選取", 80 | "invert selection": "反向選取", 81 | "copy": "拷貝", 82 | "paste": "貼上", 83 | "cut": "剪下", 84 | "start freehand": "開始手寫", 85 | "end freehand": "結束手寫", 86 | "decompose": "拆解部件", 87 | "options": "設定…", 88 | "finish edit": "編輯完成", 89 | "search": "檢索", 90 | "searching": "檢索中", 91 | "search error": "錯誤:{{message}}", 92 | "search query too short": "錯誤:關鍵字過短", 93 | "no search result": "查無結果", 94 | "grid option": "格線", 95 | "enable grid": "顯示格線", 96 | "grid origin x": "X軸初始值", 97 | "grid origin y": "Y軸初始值", 98 | "grid spacing x": "X軸間隔", 99 | "grid spacing y": "Y軸間隔", 100 | "glyph font style": "字型風格", 101 | "mincho style": "明體", 102 | "gothic style": "黑體", 103 | "show stroke center line": "筆劃中線", 104 | "show stroke center line none": "隱藏", 105 | "show stroke center line selection": "只顯示選取筆劃", 106 | "show stroke center line always": "始終顯示", 107 | "negative mask type": "遮罩", 108 | "negative mask type none": "無", 109 | "negative mask type circle": "圓形", 110 | "negative mask type squareWithRoundedCorners": "圓角矩形", 111 | "negative mask type square": "矩形", 112 | "negative mask type diamond": "菱形", 113 | "display language": "介面語言", 114 | "close modal": "關閉" 115 | } 116 | -------------------------------------------------------------------------------- /src/locales/ja.json: -------------------------------------------------------------------------------- 1 | { 2 | "stroke type": "線種", 3 | "stroke type 1": "直線", 4 | "stroke type 2": "曲線", 5 | "stroke type 3": "折れ", 6 | "stroke type 4": "乙線", 7 | "stroke type 6": "複曲線", 8 | "stroke type 7": "縦払い", 9 | "head type": "頭形状", 10 | "head type 1-0": "開放", 11 | "head type 1-2": "接続(横)", 12 | "head type 1-32": "接続(縦)", 13 | "head type 1-12": "左上カド", 14 | "head type 1-22": "右上カド", 15 | "head type 2-0": "開放", 16 | "head type 2-32": "接続", 17 | "head type 2-12": "左上カド", 18 | "head type 2-22": "右上カド", 19 | "head type 2-7": "細入り", 20 | "head type 2-27": "屋根付き細入り", 21 | "head type 3-0": "開放", 22 | "head type 3-32": "接続(縦)", 23 | "head type 3-12": "左上カド", 24 | "head type 3-22": "右上カド", 25 | "head type 4-0": "開放", 26 | "head type 4-22": "右上カド", 27 | "head type 6-0": "開放", 28 | "head type 6-32": "接続", 29 | "head type 6-12": "左上カド", 30 | "head type 6-22": "右上カド", 31 | "head type 6-7": "細入り", 32 | "head type 6-27": "屋根付き細入り", 33 | "head type 7-0": "開放", 34 | "head type 7-32": "接続", 35 | "head type 7-12": "左上カド", 36 | "head type 7-22": "右上カド", 37 | "tail type": "尾形状", 38 | "tail type 1-0": "開放", 39 | "tail type 1-2": "接続(横)", 40 | "tail type 1-32": "接続(縦)", 41 | "tail type 1-13": "左下カド", 42 | "tail type 1-23": "右下カド", 43 | "tail type 1-4": "左ハネ", 44 | "tail type 1-313": "左下zh用旧", 45 | "tail type 1-413": "左下zh用新", 46 | "tail type 1-24": "右下H/T", 47 | "tail type 2-7": "左払い", 48 | "tail type 2-0": "右払い", 49 | "tail type 2-8": "止め", 50 | "tail type 2-4": "左ハネ", 51 | "tail type 2-5": "右ハネ", 52 | "tail type 3-0": "開放", 53 | "tail type 3-5": "上ハネ", 54 | "tail type 3-32": "接続", 55 | "tail type 4-0": "開放", 56 | "tail type 4-5": "上ハネ", 57 | "tail type 6-7": "左払い", 58 | "tail type 6-0": "右払い", 59 | "tail type 6-8": "止め", 60 | "tail type 6-4": "左ハネ", 61 | "tail type 6-5": "右ハネ", 62 | "tail type 7-7": "左払い", 63 | "invalid stroke shape types": "エラー:ありえない筆画・形状の組み合わせです", 64 | "part": "部品", 65 | "alias of": "({{entity}} のエイリアス)", 66 | "stretch": "ストレッチ:", 67 | "operation type reflectX": "左右反転", 68 | "operation type reflectY": "上下反転", 69 | "operation type rotate90": "回転(90度)", 70 | "operation type rotate180": "回転(180度)", 71 | "operation type rotate270": "回転(270度)", 72 | "selecting multiple strokes": "複数選択中", 73 | "select prev": "←", 74 | "swap with prev": "入替←", 75 | "select next": "→", 76 | "swap with next": "→入替", 77 | "undo": "元に戻す", 78 | "redo": "やり直す", 79 | "select all": "すべてを選択", 80 | "invert selection": "選択範囲を反転", 81 | "copy": "コピー", 82 | "paste": "貼り付け", 83 | "cut": "切り取り", 84 | "start freehand": "手書き開始", 85 | "end freehand": "手書き終了", 86 | "decompose": "部品分解", 87 | "options": "設定…", 88 | "finish edit": "編集終了", 89 | "search": "検索", 90 | "searching": "検索中", 91 | "search error": "エラー:{{message}}", 92 | "search query too short": "エラー:キーワードが短すぎます", 93 | "no search result": "該当部品なし", 94 | "grid option": "グリッド", 95 | "enable grid": "有効にする", 96 | "grid origin x": "開始X", 97 | "grid origin y": "開始Y", 98 | "grid spacing x": "間隔X", 99 | "grid spacing y": "間隔Y", 100 | "glyph font style": "書体", 101 | "mincho style": "明朝", 102 | "gothic style": "ゴシック", 103 | "show stroke center line": "中心線", 104 | "show stroke center line none": "表示しない", 105 | "show stroke center line selection": "選択のみ表示", 106 | "show stroke center line always": "常に表示", 107 | "negative mask type": "白抜き", 108 | "negative mask type none": "なし", 109 | "negative mask type circle": "丸", 110 | "negative mask type squareWithRoundedCorners": "角丸四角", 111 | "negative mask type square": "四角", 112 | "negative mask type diamond": "菱形", 113 | "display language": "表示言語", 114 | "close modal": "閉じる" 115 | } 116 | -------------------------------------------------------------------------------- /src/locales/ko.json: -------------------------------------------------------------------------------- 1 | { 2 | "stroke type": "선종", 3 | "stroke type 1": "직선", 4 | "stroke type 2": "곡선", 5 | "stroke type 3": "꺾은선", 6 | "stroke type 4": "굽힘선", 7 | "stroke type 6": "복곡선", 8 | "stroke type 7": "세로획 왼삐침", 9 | "head type": "머리 모양", 10 | "head type 1-0": "개방", 11 | "head type 1-2": "접속(가로)", 12 | "head type 1-32": "접속(세로)", 13 | "head type 1-12": "왼쪽 위 모서리", 14 | "head type 1-22": "오른쪽 위 모서리", 15 | "head type 2-0": "개방", 16 | "head type 2-32": "접속", 17 | "head type 2-12": "왼쪽 위 모서리", 18 | "head type 2-22": "오른쪽 위 모서리", 19 | "head type 2-7": "오른삐침", 20 | "head type 2-27": "지붕 첨부 오른삐침", 21 | "head type 3-0": "개방", 22 | "head type 3-32": "접속(세로)", 23 | "head type 3-12": "왼쪽 위 모서리", 24 | "head type 3-22": "오른쪽 위 모서리", 25 | "head type 4-0": "개방", 26 | "head type 4-22": "오른쪽 위 모서리", 27 | "head type 6-0": "개방", 28 | "head type 6-32": "접속", 29 | "head type 6-12": "왼쪽 위 모서리", 30 | "head type 6-22": "오른쪽 위 모서리", 31 | "head type 6-7": "오른삐침", 32 | "head type 6-27": "지붕 첨부 오른삐침", 33 | "head type 7-0": "개방", 34 | "head type 7-32": "접속", 35 | "head type 7-12": "왼쪽 위 모서리", 36 | "head type 7-22": "오른쪽 위 모서리", 37 | "tail type": "꼬리 모양", 38 | "tail type 1-0": "개방", 39 | "tail type 1-2": "접속(가로)", 40 | "tail type 1-32": "접속(세로)", 41 | "tail type 1-13": "왼쪽 아래 모서리", 42 | "tail type 1-23": "오른쪽 아래 모서리", 43 | "tail type 1-4": "왼쪽 갈고리", 44 | "tail type 1-313": "왼쪽 아래 중국용 낡음", 45 | "tail type 1-413": "왼쪽 아래 중국용 새로움", 46 | "tail type 1-24": "오른쪽 아래 홍콩/대만용", 47 | "tail type 2-7": "왼삐침", 48 | "tail type 2-0": "오른삐침", 49 | "tail type 2-8": "마침", 50 | "tail type 2-4": "왼쪽 갈고리", 51 | "tail type 2-5": "오른쪽 갈고리", 52 | "tail type 3-0": "개방", 53 | "tail type 3-5": "위쪽 갈고리", 54 | "tail type 3-32": "접속", 55 | "tail type 4-0": "개방", 56 | "tail type 4-5": "위쪽 갈고리", 57 | "tail type 6-7": "왼삐침", 58 | "tail type 6-0": "오른삐침", 59 | "tail type 6-8": "마침", 60 | "tail type 6-4": "왼쪽 갈고리", 61 | "tail type 6-5": "오른쪽 갈고리", 62 | "tail type 7-7": "왼삐침", 63 | "invalid stroke shape types": "오류: 존재할 수 없는 필획·모양의 조합입니다", 64 | "part": "부품", 65 | "alias of": "({{entity}}의 에일리어스)", 66 | "stretch": "스트레치:", 67 | "operation type reflectX": "좌우 반전", 68 | "operation type reflectY": "상하 반전", 69 | "operation type rotate90": "회전 (90도)", 70 | "operation type rotate180": "회전 (180도)", 71 | "operation type rotate270": "회전 (270도)", 72 | "selecting multiple strokes": "복수 선택 중", 73 | "select prev": "←", 74 | "swap with prev": "교체←", 75 | "select next": "→", 76 | "swap with next": "→교체", 77 | "undo": "실행 취소", 78 | "redo": "다시 실행", 79 | "select all": "모두 선택", 80 | "invert selection": "선택 범위를 반전", 81 | "copy": "복사", 82 | "paste": "붙여넣기", 83 | "cut": "잘라내기", 84 | "start freehand": "필기 시작", 85 | "end freehand": "필기 종료", 86 | "decompose": "부품 분해", 87 | "options": "설정…", 88 | "finish edit": "편집 종료", 89 | "search": "검색", 90 | "searching": "검색중", 91 | "search error": "오류: {{message}}", 92 | "search query too short": "오류: 키워드가 너무 짧습니다", 93 | "no search result": "해당 부품 없음", 94 | "grid option": "그리드", 95 | "enable grid": "유효시키기", 96 | "grid origin x": "시작X", 97 | "grid origin y": "시작Y", 98 | "grid spacing x": "간격X", 99 | "grid spacing y": "간격Y", 100 | "glyph font style": "글꼴", 101 | "mincho style": "명조", 102 | "gothic style": "고딕", 103 | "show stroke center line": "중심선", 104 | "show stroke center line none": "표시하지 않음", 105 | "show stroke center line selection": "선택만 표시", 106 | "show stroke center line always": "항상 표시", 107 | "negative mask type": "마스크", 108 | "negative mask type none": "없음", 109 | "negative mask type circle": "동그라미", 110 | "negative mask type squareWithRoundedCorners": "둥근 사각형", 111 | "negative mask type square": "사각형", 112 | "negative mask type diamond": "마름모", 113 | "display language": "표시 언어", 114 | "close modal": "닫기" 115 | } 116 | -------------------------------------------------------------------------------- /src/kageUtils/transform.ts: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-only 2 | // Copyright 2020 kurgm 3 | 4 | import { GlyphLine, Glyph, PointDescriptor } from './glyph'; 5 | 6 | import { BBX } from './bbx'; 7 | import { listupConnectedPoints, listupConnectedPointsOfSelection } from './connection'; 8 | 9 | export const applyGlyphLineOperation = (glyphLine: GlyphLine, tX: (x: number) => number, tY: (y: number) => number): GlyphLine => { 10 | switch (glyphLine.value[0]) { 11 | case 99: { 12 | const value = glyphLine.value.slice(); 13 | value[3] = tX(value[3]); 14 | value[4] = tY(value[4]); 15 | value[5] = tX(value[5]); 16 | value[6] = tY(value[6]); 17 | return { value, partName: glyphLine.partName }; 18 | } 19 | case 0: 20 | case 1: 21 | case 2: 22 | case 3: 23 | case 4: 24 | case 6: 25 | case 7: 26 | case 9: { 27 | const value = glyphLine.value.slice(); 28 | for (let i = 3; i + 2 <= value.length; i += 2) { 29 | value[i] = tX(value[i]); 30 | value[i + 1] = tY(value[i + 1]); 31 | } 32 | return { value }; 33 | } 34 | default: 35 | return glyphLine; 36 | } 37 | } 38 | 39 | export const applyGlyphPointOperation = (glyph: Glyph, pdescs: PointDescriptor[], tX: (x: number) => number, tY: (y: number) => number): Glyph => { 40 | if (pdescs.length === 0) { 41 | return glyph; 42 | } 43 | const newGlyph = glyph.slice(); 44 | for (const { lineIndex, pointIndex } of pdescs) { 45 | const glyphLine = newGlyph[lineIndex]; 46 | const newValue = glyphLine.value.slice(); 47 | newValue[3 + pointIndex * 2] = tX(newValue[3 + pointIndex * 2]); 48 | newValue[3 + pointIndex * 2 + 1] = tY(newValue[3 + pointIndex * 2 + 1]); 49 | newGlyph[lineIndex] = { 50 | ...glyphLine, 51 | value: newValue, 52 | }; 53 | } 54 | return newGlyph; 55 | }; 56 | 57 | 58 | export const moveSelectedGlyphLines = (glyph: Glyph, selection: number[], dx: number, dy: number): Glyph => { 59 | if (selection.length === 0) { 60 | return glyph; 61 | } 62 | const targetDescs = listupConnectedPointsOfSelection(glyph, selection); 63 | 64 | const tX = (x: number) => Math.round(x + dx); 65 | const tY = (y: number) => Math.round(y + dy); 66 | glyph = glyph.map((glyphLine, index) => selection.includes(index) 67 | ? applyGlyphLineOperation(glyphLine, tX, tY) 68 | : glyphLine 69 | ); 70 | glyph = applyGlyphPointOperation(glyph, targetDescs, tX, tY); 71 | return glyph; 72 | }; 73 | 74 | export const moveSelectedPoint = (glyph: Glyph, selection: number[], pointIndex: number, dx: number, dy: number): Glyph => { 75 | if (selection.length === 0) { 76 | return glyph; 77 | } 78 | const lineIndex = selection[0]; 79 | const selectedDesc: PointDescriptor = { lineIndex, pointIndex }; 80 | const targetDescs = listupConnectedPoints(glyph, [selectedDesc]) 81 | .filter((targetDesc) => targetDesc.lineIndex !== lineIndex); 82 | targetDescs.push(selectedDesc); 83 | 84 | const tX = (x: number) => Math.round(x + dx); 85 | const tY = (y: number) => Math.round(y + dy); 86 | glyph = applyGlyphPointOperation(glyph, targetDescs, tX, tY); 87 | return glyph; 88 | }; 89 | 90 | export const resizeGlyphLine = (glyphLine: GlyphLine, oldBBX: BBX, newBBX: BBX): GlyphLine => { 91 | const [x11, y11, x12, y12] = oldBBX; 92 | const [x21, y21, x22, y22] = newBBX; 93 | const tX = (x: number) => Math.round(x21 + (x - x11) * (x22 - x21) / (x12 - x11)); 94 | const tY = (y: number) => Math.round(y21 + (y - y11) * (y22 - y21) / (y12 - y11)); 95 | return applyGlyphLineOperation(glyphLine, tX, tY); 96 | }; 97 | 98 | export const resizeSelectedGlyphLines = (glyph: Glyph, selection: number[], oldBBX: BBX, newBBX: BBX): Glyph => { 99 | return glyph.map((glyphLine, index) => selection.includes(index) 100 | ? resizeGlyphLine(glyphLine, oldBBX, newBBX) 101 | : glyphLine 102 | ); 103 | }; 104 | -------------------------------------------------------------------------------- /src/kageUtils/decompose.ts: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-only 2 | // Copyright 2020, 2022 kurgm 3 | 4 | import { getKage } from '../kage'; 5 | 6 | import { GlyphLine, parseGlyph, unparseGlyph, unparseGlyphLine, getNumColumns, Glyph } from './glyph'; 7 | import { getStretchPositions, normalizeStretchPositions, setStretchPositions } from './stretchparam'; 8 | import { applyGlyphLineOperation } from './transform'; 9 | 10 | export const decompose = (glyphLine: GlyphLine, buhinMap: Map): GlyphLine[] => { 11 | if (glyphLine.value[0] !== 99) { 12 | return [glyphLine]; 13 | } 14 | const buhinSource = buhinMap.get(glyphLine.partName!); 15 | if (!buhinSource) { 16 | return [glyphLine]; 17 | } 18 | 19 | let failedBuhin = false; 20 | const kage = getKage(buhinMap, () => { 21 | failedBuhin = true; 22 | }); 23 | 24 | const glyph = parseGlyph(buhinSource); 25 | 26 | const strokesArray = 27 | // @ts-expect-error 2445 28 | kage.getEachStrokes( 29 | unparseGlyph(glyph) 30 | ); 31 | const box = 32 | // @ts-expect-error 2445 33 | kage.getBox( 34 | strokesArray 35 | ); 36 | 37 | const x1 = glyphLine.value[3]; 38 | const y1 = glyphLine.value[4]; 39 | const x2 = glyphLine.value[5]; 40 | const y2 = glyphLine.value[6]; 41 | const [sx, sy, tx, ty] = normalizeStretchPositions(getStretchPositions(glyphLine)!); 42 | const isStretchEnabled = !(sx === tx - 200 && sy === ty); 43 | 44 | if (isStretchEnabled && failedBuhin) { 45 | // box may be incorrect 46 | return [glyphLine]; 47 | } 48 | 49 | return glyph.map((oldGlyphLine) => { 50 | const tX = (x: number) => { 51 | const stretchedX = isStretchEnabled 52 | ? kage.stretch(tx - 200, sx, x, box.minX, box.maxX) 53 | : x; 54 | return Math.round(stretchedX / 200 * (x2 - x1) + x1); 55 | }; 56 | const tY = (y: number) => { 57 | const stretchedY = isStretchEnabled 58 | ? kage.stretch(ty, sy, y, box.minY, box.maxY) 59 | : y; 60 | return Math.round(stretchedY / 200 * (y2 - y1) + y1); 61 | }; 62 | const newGlyphLine = applyGlyphLineOperation(oldGlyphLine, tX, tY); 63 | 64 | if (!(isStretchEnabled && newGlyphLine.value[0] === 99)) { 65 | return newGlyphLine; 66 | } 67 | 68 | const [sx2, sy2, tx2, ty2] = normalizeStretchPositions(getStretchPositions(newGlyphLine)!); 69 | if (!(sx2 === tx2 - 200 && sy2 === ty2)) { 70 | // Cannot compose two stretches... 71 | return newGlyphLine; 72 | } 73 | 74 | const px1 = newGlyphLine.value[3]; 75 | const py1 = newGlyphLine.value[4]; 76 | const px2 = newGlyphLine.value[5]; 77 | const py2 = newGlyphLine.value[6]; 78 | 79 | if (px1 === px2 || py1 === py2) { 80 | return newGlyphLine; 81 | } 82 | 83 | const revX = (x: number) => (x - px1) / (px2 - px1) * 200; 84 | const revY = (y: number) => (y - py1) / (py2 - py1) * 200; 85 | return setStretchPositions(newGlyphLine, [ 86 | revX(sx + 100) - 100, 87 | revY(sy + 100) - 100, 88 | revX(tx - 100) + 100, 89 | revY(ty + 100) - 100, 90 | ]); 91 | }); 92 | }; 93 | 94 | export const decomposeDeep = (glyphLine: GlyphLine, buhinMap: Map): GlyphLine[] => { 95 | const kage = getKage(buhinMap); 96 | const strokesArray = 97 | // @ts-expect-error 2445 98 | kage.getEachStrokes( 99 | unparseGlyphLine(glyphLine) 100 | ); 101 | return strokesArray.map((stroke): GlyphLine => { 102 | const columns = getNumColumns(stroke.a1_100); 103 | return { 104 | value: [ 105 | stroke.a1_100 + stroke.a1_opt * 100, 106 | stroke.a2_100 + stroke.a2_opt * 100, 107 | stroke.a3_100 + stroke.a3_opt * 100, 108 | Math.round(stroke.x1) || 0, 109 | Math.round(stroke.y1) || 0, 110 | Math.round(stroke.x2) || 0, 111 | Math.round(stroke.y2) || 0, 112 | Math.round(stroke.x3) || 0, 113 | Math.round(stroke.y3) || 0, 114 | Math.round(stroke.x4) || 0, 115 | Math.round(stroke.y4) || 0, 116 | ].slice(0, columns), 117 | }; 118 | }); 119 | }; 120 | 121 | export const decomposeDeepGlyph = (glyph: Glyph, buhinMap: Map): GlyphLine[] => { 122 | return glyph.map((glyphLine) => decomposeDeep(glyphLine, buhinMap)).reduce((a, b) => a.concat(b), []); 123 | }; 124 | -------------------------------------------------------------------------------- /src/locales/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "stroke type": "Type", 3 | "stroke type 1": "straight line", 4 | "stroke type 2": "curve", 5 | "stroke type 3": "bend line", 6 | "stroke type 4": "bend curve", 7 | "stroke type 6": "cmplx. curve", 8 | "stroke type 7": "vert slash", 9 | "head type": "Head", 10 | "head type 1-0": "free", 11 | "head type 1-2": "connect (h)", 12 | "head type 1-32": "connect (v)", 13 | "head type 1-12": "top-L corner", 14 | "head type 1-22": "top-R corner", 15 | "head type 2-0": "free", 16 | "head type 2-32": "connect", 17 | "head type 2-12": "top-L corner", 18 | "head type 2-22": "top-R corner", 19 | "head type 2-7": "narrow", 20 | "head type 2-27": "roofed narrow", 21 | "head type 3-0": "free", 22 | "head type 3-32": "connect (v)", 23 | "head type 3-12": "top-L corner", 24 | "head type 3-22": "top-R corner", 25 | "head type 4-0": "free", 26 | "head type 4-22": "top-R corner", 27 | "head type 6-0": "free", 28 | "head type 6-32": "connect", 29 | "head type 6-12": "top-L corner", 30 | "head type 6-22": "top-R corner", 31 | "head type 6-7": "narrow", 32 | "head type 6-27": "roofed narrow", 33 | "head type 7-0": "free", 34 | "head type 7-32": "connect", 35 | "head type 7-12": "top-L corner", 36 | "head type 7-22": "top-R corner", 37 | "tail type": "Tail", 38 | "tail type 1-0": "free", 39 | "tail type 1-2": "connect (h)", 40 | "tail type 1-32": "connect (v)", 41 | "tail type 1-13": "btm-L corner", 42 | "tail type 1-23": "btm-R corner", 43 | "tail type 1-4": "slash left", 44 | "tail type 1-313": "btm-L (zh: old)", 45 | "tail type 1-413": "btm-L (zh: new)", 46 | "tail type 1-24": "btm-R (H/T)", 47 | "tail type 2-7": "slash left", 48 | "tail type 2-0": "slash right", 49 | "tail type 2-8": "stop for dot", 50 | "tail type 2-4": "hook left", 51 | "tail type 2-5": "hook right", 52 | "tail type 3-0": "free", 53 | "tail type 3-5": "hook up", 54 | "tail type 3-32": "connect", 55 | "tail type 4-0": "free", 56 | "tail type 4-5": "hook up", 57 | "tail type 6-7": "slash left", 58 | "tail type 6-0": "slash right", 59 | "tail type 6-8": "stop for dot", 60 | "tail type 6-4": "hook left", 61 | "tail type 6-5": "hook right", 62 | "tail type 7-7": "slash left", 63 | "invalid stroke shape types": "Error: illegal combination of strokes/shape types", 64 | "part": "Part", 65 | "alias of": "(alias of {{entity}})", 66 | "stretch": "Stretch:", 67 | "operation type reflectX": "Flip horizontally", 68 | "operation type reflectY": "Flip vertically", 69 | "operation type rotate90": "Rotate (90 degrees)", 70 | "operation type rotate180": "Rotate (180 degrees)", 71 | "operation type rotate270": "Rotate (270 degrees)", 72 | "selecting multiple strokes": "Selecting multiple items", 73 | "select prev": "←", 74 | "swap with prev": "Swap←", 75 | "select next": "→", 76 | "swap with next": "→Swap", 77 | "undo": "Undo", 78 | "redo": "Redo", 79 | "select all": "Select All", 80 | "invert selection": "Invert Selection", 81 | "copy": "Copy", 82 | "paste": "Paste", 83 | "cut": "Cut", 84 | "start freehand": "Freedraw", 85 | "end freehand": "Stop freedraw", 86 | "decompose": "Decompose", 87 | "options": "Options…", 88 | "finish edit": "Finish editing", 89 | "search": "Search", 90 | "searching": "Searching", 91 | "search error": "Error: {{message}}", 92 | "search query too short": "Error: keyword is too short", 93 | "no search result": "No Results Found", 94 | "grid option": "Grid", 95 | "enable grid": "Enable", 96 | "grid origin x": "Origin X", 97 | "grid origin y": "Origin Y", 98 | "grid spacing x": "Spacing X", 99 | "grid spacing y": "Spacing Y", 100 | "glyph font style": "Font", 101 | "mincho style": "Mincho", 102 | "gothic style": "Gothic", 103 | "show stroke center line": "Center Line", 104 | "show stroke center line none": "Hide", 105 | "show stroke center line selection": "Show Only Selection", 106 | "show stroke center line always": "Always Show", 107 | "negative mask type": "Mask", 108 | "negative mask type none": "None", 109 | "negative mask type circle": "Circle", 110 | "negative mask type squareWithRoundedCorners": "Rounded Square", 111 | "negative mask type square": "Square", 112 | "negative mask type diamond": "Diamond", 113 | "display language": "Language", 114 | "close modal": "Close" 115 | } 116 | -------------------------------------------------------------------------------- /src/components/GlyphArea.tsx: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-only 2 | // Copyright 2020, 2021, 2023, 2025 kurgm and graphemecluster 3 | 4 | import clsx from 'clsx/lite'; 5 | import React, { useEffect, useCallback } from 'react'; 6 | 7 | import { useAppDispatch, useAppSelector } from '../hooks'; 8 | 9 | import { selectActions } from '../actions/select'; 10 | import { dragActions, CTMInv } from '../actions/drag'; 11 | import { draggedGlyphSelector } from '../selectors/draggedGlyph'; 12 | 13 | import XorMasks from './XorMasks'; 14 | import Grid from './Grid'; 15 | import Glyph from './Glyph'; 16 | import StrokeCenterLine from './StrokeCenterLine'; 17 | import SelectionControl from './SelectionControl'; 18 | import AreaSelectRect from './AreaSelectRect'; 19 | 20 | import styles from './GlyphArea.module.css'; 21 | 22 | interface GlyphAreaProps { 23 | className?: string; 24 | } 25 | 26 | const GlyphArea = (props: GlyphAreaProps) => { 27 | const glyph = useAppSelector(draggedGlyphSelector); 28 | const buhinMap = useAppSelector((state) => state.buhinMap); 29 | const shotai = useAppSelector((state) => state.shotai); 30 | const xorMaskType = useAppSelector((state) => state.xorMaskType); 31 | const selection = useAppSelector((state) => state.selection); 32 | const areaSelectRect = useAppSelector((state) => state.areaSelectRect); 33 | const freehandMode = useAppSelector((state) => state.freehandMode); 34 | 35 | const dispatch = useAppDispatch(); 36 | const handleMouseDownCapture = useCallback((evt: React.MouseEvent) => { 37 | const ctm = evt.currentTarget.getScreenCTM(); 38 | if (!ctm) { 39 | return; 40 | } 41 | const pt = evt.currentTarget.createSVGPoint(); 42 | const ictm = ctm.inverse(); 43 | const ctmInv: CTMInv = (evtx, evty) => { 44 | pt.x = evtx; 45 | pt.y = evty; 46 | const { x, y } = pt.matrixTransform(ictm); 47 | return [x, y]; 48 | }; 49 | dispatch(dragActions.updateCTMInv(ctmInv)); 50 | }, [dispatch]); 51 | 52 | const handleMouseDownBackground = useCallback((evt: React.MouseEvent) => { 53 | if (!(evt.shiftKey || evt.ctrlKey)) { 54 | dispatch(selectActions.selectNone()); 55 | } 56 | dispatch(dragActions.startBackgroundDrag(evt)); 57 | evt.preventDefault(); 58 | }, [dispatch]); 59 | const handleMouseDownDeselectedStroke = useCallback((evt: React.MouseEvent, index: number) => { 60 | if (evt.shiftKey || evt.ctrlKey) { 61 | dispatch(selectActions.selectAddSingle(index)); 62 | } else { 63 | dispatch(selectActions.selectSingle(index)); 64 | } 65 | dispatch(dragActions.startSelectionDrag(evt)); 66 | evt.preventDefault(); 67 | evt.stopPropagation(); 68 | }, [dispatch]); 69 | const handleMouseDownSelectedStroke = useCallback((evt: React.MouseEvent, index: number) => { 70 | if (evt.shiftKey || evt.ctrlKey) { 71 | dispatch(selectActions.selectRemoveSingle(index)); 72 | } 73 | dispatch(dragActions.startSelectionDrag(evt)); 74 | evt.preventDefault(); 75 | evt.stopPropagation(); 76 | }, [dispatch]); 77 | 78 | useEffect(() => { 79 | const handleMouseMove = (evt: MouseEvent) => { 80 | dispatch(dragActions.mouseMove(evt)); 81 | }; 82 | const handleMouseUp = (evt: MouseEvent) => { 83 | dispatch(dragActions.mouseUp(evt)); 84 | }; 85 | document.addEventListener('mousemove', handleMouseMove) 86 | document.addEventListener('mouseup', handleMouseUp); 87 | return () => { 88 | document.removeEventListener('mousemove', handleMouseMove); 89 | document.removeEventListener('mouseup', handleMouseUp); 90 | }; 91 | }, [dispatch]); 92 | 93 | return ( 94 |
95 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 117 | 118 | 119 | 120 | 121 |
122 | ); 123 | }; 124 | 125 | export default GlyphArea; 126 | -------------------------------------------------------------------------------- /src/components/EditorControls.tsx: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-only 2 | // Copyright 2020, 2025 kurgm 3 | 4 | import clsx from 'clsx/lite'; 5 | import React, { useCallback } from 'react'; 6 | import { useTranslation } from 'react-i18next'; 7 | 8 | import { useAppDispatch, useAppSelector } from '../hooks'; 9 | import { editorActions } from '../actions/editor'; 10 | import { selectActions } from '../actions/select'; 11 | import { undoActions } from '../actions/undo'; 12 | import { displayActions } from '../actions/display'; 13 | 14 | import SelectionInfo from './SelectionInfo'; 15 | import SubmitPreview from './SubmitPreview'; 16 | 17 | import styles from './EditorControls.module.css'; 18 | 19 | interface EditorControlsProps { 20 | className?: string; 21 | } 22 | 23 | const EditorControls = (props: EditorControlsProps) => { 24 | const glyph = useAppSelector((state) => state.glyph); 25 | const selection = useAppSelector((state) => state.selection); 26 | const clipboard = useAppSelector((state) => state.clipboard); 27 | const freehandMode = useAppSelector((state) => state.freehandMode); 28 | const undoLength = useAppSelector((state) => state.undoStacks.undo.length); 29 | const redoLength = useAppSelector((state) => state.undoStacks.redo.length); 30 | 31 | const undoDisabled = undoLength === 0; 32 | const redoDisabled = redoLength === 0; 33 | const pasteDisabled = clipboard.length === 0; 34 | const decomposeDisabled = !selection.some((index) => glyph[index].value[0] === 99); 35 | 36 | const dispatch = useAppDispatch(); 37 | const undo = useCallback(() => { 38 | dispatch(undoActions.undo()); 39 | }, [dispatch]); 40 | const redo = useCallback(() => { 41 | dispatch(undoActions.redo()); 42 | }, [dispatch]); 43 | const selectAll = useCallback(() => { 44 | dispatch(selectActions.selectAll()); 45 | }, [dispatch]); 46 | const selectDeselected = useCallback(() => { 47 | dispatch(selectActions.selectDeselected()); 48 | }, [dispatch]); 49 | const copy = useCallback(() => { 50 | dispatch(editorActions.copy()); 51 | }, [dispatch]); 52 | const paste = useCallback(() => { 53 | dispatch(editorActions.paste()); 54 | }, [dispatch]); 55 | const cut = useCallback(() => { 56 | dispatch(editorActions.cut()); 57 | }, [dispatch]); 58 | const toggleFreehand = useCallback(() => { 59 | dispatch(editorActions.toggleFreehand()); 60 | }, [dispatch]); 61 | const decompose = useCallback(() => { 62 | dispatch(editorActions.decomposeSelected()); 63 | }, [dispatch]); 64 | const options = useCallback(() => { 65 | dispatch(displayActions.openOptionModal()); 66 | }, [dispatch]); 67 | const finishEdit = useCallback((evt: React.MouseEvent) => { 68 | dispatch(editorActions.finishEdit(evt.nativeEvent)); 69 | }, [dispatch]); 70 | 71 | const { t } = useTranslation(); 72 | return ( 73 |
74 | 75 |
76 | 82 | 88 | 94 | 100 | 106 | 112 | 118 | 123 | 129 | 134 |
135 |
136 | 137 | 140 |
141 |
142 | ); 143 | }; 144 | 145 | export default EditorControls; 146 | -------------------------------------------------------------------------------- /src/kageUtils/connection.ts: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-only 2 | // Copyright 2020, 2025 kurgm 3 | 4 | import memoizeOne from 'memoize-one'; 5 | 6 | import { GlyphLine, Glyph, PointDescriptor, getNumColumns } from './glyph'; 7 | 8 | interface ConnectablePoint { 9 | position: 'start' | 'end'; 10 | strokeType: number; 11 | shapeType: number; 12 | coord: [number, number]; 13 | pointIndex: number; 14 | } 15 | 16 | interface ConnectablePointFilter { 17 | position: 'start' | 'end'; 18 | strokeTypes: number[]; 19 | shapeTypes: number[]; 20 | } 21 | 22 | const connectablePairs: [ConnectablePointFilter, ConnectablePointFilter][] = [ 23 | [ 24 | // 左上 25 | { 26 | position: 'start', 27 | strokeTypes: [1], 28 | shapeTypes: [2], 29 | }, 30 | { 31 | position: 'start', 32 | strokeTypes: [1, 2, 3, 4, 6, 7], 33 | shapeTypes: [12], 34 | }, 35 | ], 36 | [ 37 | // 左下 38 | { 39 | position: 'start', 40 | strokeTypes: [1], 41 | shapeTypes: [2], 42 | }, 43 | { 44 | position: 'end', 45 | strokeTypes: [1], 46 | shapeTypes: [13, 313, 413], 47 | }, 48 | ], 49 | [ 50 | // 右上 51 | { 52 | position: 'end', 53 | strokeTypes: [1], 54 | shapeTypes: [2], 55 | }, 56 | { 57 | position: 'start', 58 | strokeTypes: [1, 2, 3, 4, 6, 7], 59 | shapeTypes: [22, 27], 60 | }, 61 | ], 62 | [ 63 | // 右下 64 | { 65 | position: 'end', 66 | strokeTypes: [1], 67 | shapeTypes: [2], 68 | }, 69 | { 70 | position: 'end', 71 | strokeTypes: [1], 72 | shapeTypes: [23, 24], 73 | }, 74 | ], 75 | ]; 76 | 77 | const matchConnectablePoint = (filter: ConnectablePointFilter, point: ConnectablePoint) => ( 78 | filter.position === point.position && 79 | filter.strokeTypes.includes(point.strokeType) && 80 | filter.shapeTypes.includes(point.shapeType) 81 | ) 82 | 83 | const isConnectable = (point1: ConnectablePoint, point2: ConnectablePoint) => ( 84 | point1.coord[0] === point2.coord[0] && 85 | point1.coord[1] === point2.coord[1] && 86 | connectablePairs.some(([filter1, filter2]) => ( 87 | (matchConnectablePoint(filter1, point1) && matchConnectablePoint(filter2, point2)) || 88 | (matchConnectablePoint(filter2, point1) && matchConnectablePoint(filter1, point2)) 89 | )) 90 | ); 91 | 92 | const getConnectablePoints = (glyphLine: GlyphLine): ConnectablePoint[] => { 93 | const result: ConnectablePoint[] = []; 94 | switch (glyphLine.value[0]) { 95 | case 1: 96 | case 2: 97 | case 3: 98 | case 4: 99 | case 6: 100 | case 7: { 101 | result.push({ 102 | position: 'start', 103 | strokeType: glyphLine.value[0], 104 | shapeType: glyphLine.value[1], 105 | coord: [ 106 | glyphLine.value[3], 107 | glyphLine.value[4], 108 | ], 109 | pointIndex: 0, 110 | }); 111 | const endPointIndex = (getNumColumns(glyphLine.value[0]) - 3) / 2 - 1; 112 | result.push({ 113 | position: 'end', 114 | strokeType: glyphLine.value[0], 115 | shapeType: glyphLine.value[2], 116 | coord: [ 117 | glyphLine.value[3 + endPointIndex * 2], 118 | glyphLine.value[3 + endPointIndex * 2 + 1], 119 | ], 120 | pointIndex: endPointIndex, 121 | }); 122 | break; 123 | } 124 | default: 125 | break; 126 | } 127 | return result; 128 | }; 129 | 130 | export const listupConnectedPoints = memoizeOne((glyph: Glyph, points: PointDescriptor[]): PointDescriptor[] => { 131 | const sPoints: ConnectablePoint[] = []; 132 | for (const { lineIndex, pointIndex } of points) { 133 | for (const sPoint of getConnectablePoints(glyph[lineIndex])) { 134 | if (sPoint.pointIndex === pointIndex) { 135 | sPoints.push(sPoint); 136 | } 137 | } 138 | } 139 | const result: PointDescriptor[] = []; 140 | glyph.forEach((glyphLine, lineIndex) => { 141 | for (const dPoint of getConnectablePoints(glyphLine)) { 142 | if (sPoints.some((sPoint) => isConnectable(sPoint, dPoint))) { 143 | result.push({ 144 | lineIndex, 145 | pointIndex: dPoint.pointIndex, 146 | }); 147 | } 148 | } 149 | }); 150 | return result; 151 | }, ([glyph1, points1], [glyph2, points2]) => ( 152 | glyph1 === glyph2 && 153 | points1.length === points2.length && 154 | points1.every((point1, index) => ( 155 | point1.lineIndex === points2[index].lineIndex && 156 | point1.pointIndex === points2[index].pointIndex 157 | )) 158 | )); 159 | 160 | export const listupConnectedPointsOfSelection = memoizeOne((glyph: Glyph, selection: number[]): PointDescriptor[] => { 161 | const selectedDescs: PointDescriptor[] = []; 162 | for (const lineIndex of selection) { 163 | const glyphLine = glyph[lineIndex]; 164 | selectedDescs.push({ lineIndex, pointIndex: 0 }); 165 | selectedDescs.push({ lineIndex, pointIndex: (getNumColumns(glyphLine.value[0]) - 3) / 2 - 1 }); 166 | } 167 | return listupConnectedPoints(glyph, selectedDescs) 168 | .filter(({ lineIndex }) => !selection.includes(lineIndex)); 169 | }); 170 | -------------------------------------------------------------------------------- /src/components/PartsSearch.tsx: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-only 2 | // Copyright 2020, 2025 kurgm 3 | 4 | import clsx from 'clsx/lite'; 5 | import React, { useRef, useCallback, useState, useEffect } from 'react'; 6 | import { useTranslation } from 'react-i18next'; 7 | 8 | import { useAppDispatch } from '../hooks'; 9 | import { editorActions } from '../actions/editor'; 10 | import { search } from '../callapi'; 11 | import args from '../args'; 12 | 13 | import PartsList from './PartsList'; 14 | 15 | import styles from './PartsSearch.module.css'; 16 | 17 | const searchSuggestions = [ 18 | 'エディタ部品1', 19 | 'エディタ部品2', 20 | 'エディタ部品3', 21 | 'エディタ部品4', 22 | ]; 23 | 24 | const initialQuery = args.name || ''; 25 | 26 | class QueryTooShortError extends Error { } 27 | 28 | interface SearchState { 29 | query: string; 30 | result: string[] | null; 31 | err: unknown; 32 | } 33 | 34 | const initialSearchState: SearchState = { 35 | query: '', 36 | result: [], 37 | err: null, 38 | }; 39 | 40 | interface PartsSearchProps { 41 | className?: string; 42 | } 43 | 44 | const PartsSearch = (props: PartsSearchProps) => { 45 | const queryInputRef = useRef(null); 46 | const [searchState, setSearchState] = useState(initialSearchState); 47 | 48 | const startSearch = (query: string) => { 49 | setSearchState({ 50 | query, 51 | result: null, 52 | err: null, 53 | }); 54 | search(query) 55 | .then((result) => { 56 | if (result === 'tooshort') { 57 | throw new QueryTooShortError('query too short'); 58 | } 59 | if (result === 'nodata') { 60 | return []; 61 | } 62 | return result.split('\t'); 63 | }) 64 | .then((names): SearchState => ({ 65 | query, 66 | result: names, 67 | err: null, 68 | })) 69 | .catch((reason): SearchState => ({ 70 | query, 71 | result: null, 72 | err: reason, 73 | })) 74 | .then((newSearchState) => { 75 | setSearchState((currentSearchState) => ( 76 | (currentSearchState.query === query) 77 | ? newSearchState 78 | : currentSearchState // query has changed, discard result 79 | )); 80 | }); 81 | }; 82 | 83 | useEffect(() => { 84 | if (initialQuery) { 85 | // eslint-disable-next-line react-hooks/set-state-in-effect 86 | startSearch(initialQuery); 87 | } 88 | }, []); 89 | const handleSearch = useCallback(() => { 90 | if (!queryInputRef.current) { 91 | return; 92 | } 93 | const query = queryInputRef.current.value; 94 | if (!query) { 95 | setSearchState({ 96 | query, 97 | result: [], 98 | err: null, 99 | }); 100 | return; 101 | } 102 | startSearch(query); 103 | }, []); 104 | const handleFormSubmit = useCallback((evt: React.FormEvent) => { 105 | evt.preventDefault(); 106 | handleSearch(); 107 | }, [handleSearch]); 108 | 109 | const hoverNameRef = useRef(null); 110 | const handleItemMouseEnter = useCallback((partName: string) => { 111 | if (!hoverNameRef.current) { 112 | return; 113 | } 114 | hoverNameRef.current.textContent = partName; 115 | }, []); 116 | const dispatch = useAppDispatch(); 117 | const handleItemClick = useCallback((partName: string, evt: React.MouseEvent) => { 118 | if (evt.shiftKey) { 119 | if (!queryInputRef.current) { 120 | return; 121 | } 122 | queryInputRef.current.value = partName; 123 | startSearch(partName); 124 | } else { 125 | dispatch(editorActions.insertPart(partName)); 126 | } 127 | }, [dispatch]); 128 | 129 | const { t } = useTranslation(); 130 | return ( 131 |
132 |
133 | 134 | 137 | 138 | {searchSuggestions.map((suggestion, index) => ( 139 | 142 |
143 |
144 | {searchState.err 145 | ? searchState.err instanceof QueryTooShortError 146 | ?
{t('search query too short')}
147 | :
{t('search error', { message: searchState.err })}
148 | : !searchState.result 149 | ?
{t('searching')}
150 | : searchState.result.length === 0 151 | ?
{t('no search result')}
152 | : } 157 |
158 |
 
159 |
160 | ) 161 | }; 162 | 163 | export default PartsSearch; 164 | -------------------------------------------------------------------------------- /src/selectors/draggedGlyph.ts: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-only 2 | // Copyright 2020, 2021, 2023, 2025 kurgm and graphemecluster 3 | 4 | import { RectPointPosition } from '../actions/drag'; 5 | 6 | import { Glyph, GlyphLine } from '../kageUtils/glyph'; 7 | import { getGlyphLinesBBX } from '../kageUtils/bbx'; 8 | import { moveSelectedGlyphLines, moveSelectedPoint, resizeSelectedGlyphLines } from '../kageUtils/transform'; 9 | import { drawFreehand } from '../kageUtils/freehand'; 10 | 11 | import { createAppSelector } from './util'; 12 | 13 | export const resizeSelected = (glyph: Glyph, selection: number[], position: RectPointPosition, dx: number, dy: number): Glyph => { 14 | if (selection.length === 1) { 15 | const selectedGlyphLine = glyph[selection[0]]; 16 | switch (selectedGlyphLine.value[0]) { 17 | case 0: 18 | case 9: 19 | case 99: { 20 | const newValue = selectedGlyphLine.value.slice(); 21 | switch (position) { 22 | case RectPointPosition.north: 23 | newValue[4] = Math.round(newValue[4] + dy); 24 | break; 25 | case RectPointPosition.west: 26 | newValue[3] = Math.round(newValue[3] + dx); 27 | break; 28 | case RectPointPosition.south: 29 | newValue[6] = Math.round(newValue[6] + dy); 30 | break; 31 | case RectPointPosition.east: 32 | newValue[5] = Math.round(newValue[5] + dx); 33 | break; 34 | case RectPointPosition.southeast: 35 | newValue[5] = Math.round(newValue[5] + dx); 36 | newValue[6] = Math.round(newValue[6] + dy); 37 | break; 38 | case RectPointPosition.southwest: 39 | newValue[3] = Math.round(newValue[3] + dx); 40 | newValue[6] = Math.round(newValue[6] + dy); 41 | break; 42 | case RectPointPosition.northeast: 43 | newValue[5] = Math.round(newValue[5] + dx); 44 | newValue[4] = Math.round(newValue[4] + dy); 45 | break; 46 | case RectPointPosition.northwest: 47 | newValue[3] = Math.round(newValue[3] + dx); 48 | newValue[4] = Math.round(newValue[4] + dy); 49 | break; 50 | } 51 | const newGlyphLine: GlyphLine = selectedGlyphLine.value[0] === 99 52 | ? { value: newValue, partName: selectedGlyphLine.partName } 53 | : { value: newValue }; 54 | return glyph.map((glyphLine, index) => index === selection[0] ? newGlyphLine : glyphLine); 55 | } 56 | default: 57 | // not expected to reach here... 58 | break; 59 | } 60 | } 61 | const minSize = 20; 62 | const oldBBX = getGlyphLinesBBX(selection.map((index) => glyph[index])); 63 | const newBBX = oldBBX.slice() as typeof oldBBX; 64 | switch (position) { 65 | case RectPointPosition.north: 66 | newBBX[1] = Math.min(newBBX[1] + dy, newBBX[3] - minSize); 67 | break; 68 | case RectPointPosition.west: 69 | newBBX[0] = Math.min(newBBX[0] + dx, newBBX[2] - minSize); 70 | break; 71 | case RectPointPosition.south: 72 | newBBX[3] = Math.max(newBBX[3] + dy, newBBX[1] + minSize); 73 | break; 74 | case RectPointPosition.east: 75 | newBBX[2] = Math.max(newBBX[2] + dx, newBBX[0] + minSize); 76 | break; 77 | case RectPointPosition.southeast: 78 | newBBX[2] = Math.max(newBBX[2] + dx, newBBX[0] + minSize); 79 | newBBX[3] = Math.max(newBBX[3] + dy, newBBX[1] + minSize); 80 | break; 81 | case RectPointPosition.southwest: 82 | newBBX[0] = Math.min(newBBX[0] + dx, newBBX[2] - minSize); 83 | newBBX[3] = Math.max(newBBX[3] + dy, newBBX[1] + minSize); 84 | break; 85 | case RectPointPosition.northeast: 86 | newBBX[2] = Math.max(newBBX[2] + dx, newBBX[0] + minSize); 87 | newBBX[1] = Math.min(newBBX[1] + dy, newBBX[3] - minSize); 88 | break; 89 | case RectPointPosition.northwest: 90 | newBBX[0] = Math.min(newBBX[0] + dx, newBBX[2] - minSize); 91 | newBBX[1] = Math.min(newBBX[1] + dy, newBBX[3] - minSize); 92 | break; 93 | } 94 | return resizeSelectedGlyphLines(glyph, selection, oldBBX, newBBX); 95 | }; 96 | 97 | export const draggedGlyphSelector = createAppSelector([ 98 | (state) => state.glyph, 99 | (state) => state.selection, 100 | (state) => state.dragSelection, 101 | (state) => state.dragPoint, 102 | (state) => state.resizeSelection, 103 | (state) => state.freehandStroke, 104 | ], (glyph, selection, dragSelection, dragPoint, resizeSelection, freehandStroke) => { 105 | if (dragSelection) { 106 | const [x1, y1, x2, y2] = dragSelection; 107 | const dx = x2 - x1; 108 | const dy = y2 - y1; 109 | glyph = moveSelectedGlyphLines(glyph, selection, dx, dy); 110 | } else if (dragPoint) { 111 | const [pointIndex, [x1, y1, x2, y2]] = dragPoint; 112 | const dx = x2 - x1; 113 | const dy = y2 - y1; 114 | glyph = moveSelectedPoint(glyph, selection, pointIndex, dx, dy); 115 | } else if (resizeSelection) { 116 | const [position, [x1, y1, x2, y2]] = resizeSelection; 117 | const dx = x2 - x1; 118 | const dy = y2 - y1; 119 | glyph = resizeSelected(glyph, selection, position, dx, dy); 120 | } else if (freehandStroke) { 121 | glyph = drawFreehand(glyph, freehandStroke); 122 | } 123 | return glyph; 124 | }); 125 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-only 2 | // Copyright 2023, 2025 kurgm 3 | 4 | // @ts-check 5 | 6 | import js from '@eslint/js'; 7 | import stylistic from '@stylistic/eslint-plugin'; 8 | import vitest from '@vitest/eslint-plugin'; 9 | import importPlugin from 'eslint-plugin-import'; 10 | import jsxA11y from 'eslint-plugin-jsx-a11y'; 11 | import reactPlugin from 'eslint-plugin-react'; 12 | import reactHooks from 'eslint-plugin-react-hooks'; 13 | import reactRefresh from 'eslint-plugin-react-refresh'; 14 | import testingLibrary from 'eslint-plugin-testing-library'; 15 | import tseslint from 'typescript-eslint'; 16 | import { globalIgnores } from 'eslint/config' 17 | 18 | 19 | export default tseslint.config([ 20 | globalIgnores(['build']), 21 | { 22 | files: ['**/*.{ts,tsx}'], 23 | extends: [ 24 | js.configs.recommended, 25 | tseslint.configs.recommended, 26 | jsxA11y.flatConfigs.recommended, 27 | reactPlugin.configs.flat.recommended, 28 | reactPlugin.configs.flat['jsx-runtime'], 29 | reactHooks.configs.flat.recommended, 30 | reactRefresh.configs.vite, 31 | ], 32 | 33 | languageOptions: { 34 | ecmaVersion: 2020, 35 | parserOptions: { 36 | warnOnUnsupportedTypeScriptVersion: true, 37 | project: ['./tsconfig.node.json', './tsconfig.app.json'], 38 | }, 39 | }, 40 | settings: { 41 | react: { 42 | version: 'detect', 43 | }, 44 | }, 45 | plugins: { 46 | '@stylistic': stylistic, 47 | import: importPlugin, 48 | }, 49 | rules: { 50 | // http://eslint.org/docs/rules/ 51 | 'array-callback-return': 'warn', 52 | '@stylistic/dot-location': ['warn', 'property'], 53 | eqeqeq: ['warn', 'smart'], 54 | '@stylistic/new-parens': 'warn', 55 | 'no-caller': 'warn', 56 | 'no-eval': 'warn', 57 | 'no-extend-native': 'warn', 58 | 'no-extra-bind': 'warn', 59 | 'no-extra-label': 'warn', 60 | '@typescript-eslint/no-implied-eval': 'warn', 61 | 'no-iterator': 'warn', 62 | 'no-label-var': 'warn', 63 | 'no-labels': ['warn', { allowLoop: true, allowSwitch: false }], 64 | 'no-lone-blocks': 'warn', 65 | '@typescript-eslint/no-loop-func': 'warn', 66 | '@stylistic/no-mixed-operators': [ 67 | 'warn', 68 | { 69 | groups: [ 70 | ['&', '|', '^', '~', '<<', '>>', '>>>'], 71 | ['==', '!=', '===', '!==', '>', '>=', '<', '<='], 72 | ['&&', '||'], 73 | ['in', 'instanceof'], 74 | ], 75 | allowSamePrecedence: false, 76 | }, 77 | ], 78 | 'no-multi-str': 'warn', 79 | 'no-new-func': 'warn', 80 | 'no-object-constructor': 'warn', 81 | 'no-new-wrappers': 'warn', 82 | 'no-octal-escape': 'warn', 83 | 'no-restricted-syntax': ['warn', 'WithStatement'], 84 | 'no-script-url': 'warn', 85 | 'no-self-compare': 'warn', 86 | 'no-sequences': 'warn', 87 | 'no-template-curly-in-string': 'warn', 88 | 'no-throw-literal': 'warn', 89 | 'no-useless-computed-key': 'warn', 90 | 'no-useless-concat': 'warn', 91 | 'no-useless-rename': [ 92 | 'warn', 93 | { 94 | ignoreDestructuring: false, 95 | ignoreImport: false, 96 | ignoreExport: false, 97 | }, 98 | ], 99 | '@stylistic/no-whitespace-before-property': 'warn', 100 | '@stylistic/rest-spread-spacing': ['warn', 'never'], 101 | strict: ['warn', 'never'], 102 | 'unicode-bom': ['warn', 'never'], 103 | 'no-restricted-properties': [ 104 | 'error', 105 | { 106 | object: 'require', 107 | property: 'ensure', 108 | message: 109 | 'Please use import() instead. More info: https://facebook.github.io/create-react-app/docs/code-splitting', 110 | }, 111 | { 112 | object: 'System', 113 | property: 'import', 114 | message: 115 | 'Please use import() instead. More info: https://facebook.github.io/create-react-app/docs/code-splitting', 116 | }, 117 | ], 118 | 119 | // https://github.com/benmosher/eslint-plugin-import/tree/master/docs/rules 120 | 'import/first': 'error', 121 | 'import/no-amd': 'error', 122 | 'import/no-anonymous-default-export': 'warn', 123 | 'import/no-webpack-loader-syntax': 'error', 124 | 125 | // https://github.com/yannickcr/eslint-plugin-react/tree/master/docs/rules 126 | 'react/forbid-foreign-prop-types': ['warn', { allowInPropTypes: true }], 127 | 'react/jsx-pascal-case': [ 128 | 'warn', 129 | { 130 | allowAllCaps: true, 131 | ignore: [], 132 | }, 133 | ], 134 | 'react/no-typos': 'error', 135 | 'react/style-prop-object': 'warn', 136 | 137 | // TODO: activate these rules 138 | 'jsx-a11y/click-events-have-key-events': 'off', 139 | 'jsx-a11y/no-noninteractive-element-interactions': 'off', 140 | 141 | // TypeScript's `noFallthroughCasesInSwitch` option is more robust (#6906) 142 | 'default-case': 'off', 143 | 144 | // Add TypeScript specific rules (and turn off ESLint equivalents) 145 | '@typescript-eslint/consistent-type-assertions': 'warn', 146 | '@typescript-eslint/no-redeclare': 'warn', 147 | 'no-use-before-define': 'off', 148 | '@typescript-eslint/no-use-before-define': [ 149 | 'warn', 150 | { 151 | functions: false, 152 | classes: false, 153 | variables: false, 154 | typedefs: false, 155 | }, 156 | ], 157 | 'no-useless-constructor': 'off', 158 | '@typescript-eslint/no-useless-constructor': 'warn', 159 | 160 | '@typescript-eslint/switch-exhaustiveness-check': [ 161 | 'warn', 162 | { requireDefaultForNonUnion: true }, 163 | ], 164 | }, 165 | }, 166 | 167 | { 168 | files: ['**/__tests__/**/*', '**/*.{spec,test}.*'], 169 | 170 | extends: [ 171 | testingLibrary.configs['flat/react'], 172 | vitest.configs.recommended, 173 | ], 174 | 175 | rules: { 176 | 'vitest/no-conditional-expect': 'error', 177 | 'vitest/no-interpolation-in-snapshots': 'error', 178 | 'vitest/no-mocks-import': 'error', 179 | }, 180 | }, 181 | ]); 182 | -------------------------------------------------------------------------------- /src/components/OptionModal.tsx: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-only 2 | // Copyright 2020, 2021, 2023, 2025 kurgm, hulenkius and graphemecluster 3 | 4 | import React, { useCallback } from 'react'; 5 | 6 | import ReactModal from 'react-modal'; 7 | import { useTranslation } from 'react-i18next'; 8 | 9 | import { displayActions, ShowCenterLine } from '../actions/display'; 10 | import { useAppDispatch, useAppSelector } from '../hooks'; 11 | import { KShotai } from '../kage'; 12 | import { XorMaskType, xorMaskTypes } from '../xorMask'; 13 | 14 | import styles from './OptionModal.module.css'; 15 | 16 | const OptionModal = () => { 17 | const showOptionModal = useAppSelector((state) => state.showOptionModal); 18 | const grid = useAppSelector((state) => state.grid); 19 | const shotai = useAppSelector((state) => state.shotai); 20 | const xorMaskType = useAppSelector((state) => state.xorMaskType); 21 | const showStrokeCenterLine = useAppSelector((state) => state.showStrokeCenterLine); 22 | 23 | const dispatch = useAppDispatch(); 24 | const handleRequestClose = useCallback(() => { 25 | dispatch(displayActions.closeOptionModal()); 26 | }, [dispatch]); 27 | 28 | const handleGridDisplayChange = useCallback((evt: React.ChangeEvent) => { 29 | dispatch(displayActions.setGridDisplay(evt.currentTarget.checked)); 30 | }, [dispatch]); 31 | const handleGridOriginXChange = useCallback((evt: React.ChangeEvent) => { 32 | dispatch(displayActions.setGridOriginX(evt.currentTarget.valueAsNumber)); 33 | }, [dispatch]); 34 | const handleGridOriginYChange = useCallback((evt: React.ChangeEvent) => { 35 | dispatch(displayActions.setGridOriginY(evt.currentTarget.valueAsNumber)); 36 | }, [dispatch]); 37 | const handleGridSpacingXChange = useCallback((evt: React.ChangeEvent) => { 38 | dispatch(displayActions.setGridSpacingX(evt.currentTarget.valueAsNumber)); 39 | }, [dispatch]); 40 | const handleGridSpacingYChange = useCallback((evt: React.ChangeEvent) => { 41 | dispatch(displayActions.setGridSpacingY(evt.currentTarget.valueAsNumber)); 42 | }, [dispatch]); 43 | const handleShotaiChange = useCallback((evt: React.ChangeEvent) => { 44 | dispatch(displayActions.setShotai(+evt.currentTarget.value as KShotai)); 45 | }, [dispatch]); 46 | const handleStrokeCenterLineChange = useCallback((evt: React.ChangeEvent) => { 47 | dispatch(displayActions.setStrokeCenterLineDisplay(+evt.currentTarget.value as ShowCenterLine)); 48 | }, [dispatch]); 49 | const handleXorMaskTypeChange = useCallback((evt: React.ChangeEvent) => { 50 | dispatch(displayActions.setXorMaskType(evt.currentTarget.value as XorMaskType)); 51 | }, [dispatch]); 52 | 53 | const { t, i18n } = useTranslation(); 54 | const handleLanguageChange = useCallback((evt: React.ChangeEvent) => { 55 | i18n.changeLanguage(evt.currentTarget.value); 56 | }, [i18n]); 57 | 58 | return ( 59 | 65 |
66 | {t('grid option')} 67 |
68 | 72 |
73 |
74 |
{t('grid origin x')}
75 | 83 | 84 |
{t('grid origin y')}
85 | 93 | 94 |
{t('grid spacing x')}
95 | 103 | 104 |
{t('grid spacing y')}
105 | 113 |
114 |
115 |
116 |
{t('glyph font style')}
117 | 124 | 125 |
{t('show stroke center line')}
126 | 134 | 135 |
{t('negative mask type')}
136 | 144 | 145 |
{t('display language')}
146 | 156 |
157 |
158 | 159 |
160 |
161 | ) 162 | }; 163 | 164 | if (process.env.NODE_ENV !== 'test') { 165 | ReactModal.setAppElement('#root'); 166 | } 167 | 168 | export default OptionModal; 169 | -------------------------------------------------------------------------------- /src/kageUtils/stroketype.ts: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-only 2 | // Copyright 2020, 2022 kurgm 3 | 4 | import memoizeOne from 'memoize-one'; 5 | 6 | import { GlyphLine, getNumColumns } from './glyph'; 7 | 8 | export const strokeTypes = [1, 2, 3, 4, 6, 7]; 9 | 10 | export const headShapeTypes: Record = { 11 | 1: [0, 2, 32, 12, 22], 12 | 2: [0, 32, 12, 22, 7, 27], 13 | 3: [0, 32, 12, 22], 14 | 4: [0, 22], 15 | 6: [0, 32, 12, 22, 7, 27], 16 | 7: [0, 32, 12, 22], 17 | }; 18 | 19 | export const tailShapeTypes: Record = { 20 | 1: [0, 2, 32, 13, 23, 4, 313, 413, 24], 21 | 2: [7, 0, 8, 4, 5], 22 | 3: [0, 5, 32], 23 | 4: [0, 5], 24 | 6: [7, 0, 8, 4, 5], 25 | 7: [7], 26 | }; 27 | 28 | export const changeStrokeType = (glyphLine: GlyphLine, newType: number): GlyphLine => { 29 | const oldType = glyphLine.value[0]; 30 | if (!strokeTypes.includes(oldType) || !strokeTypes.includes(newType)) { 31 | return glyphLine; 32 | } 33 | const newGlyphLine: GlyphLine = { 34 | value: glyphLine.value.slice(), 35 | }; 36 | 37 | newGlyphLine.value[0] = newType; 38 | 39 | if (!headShapeTypes[newType].includes(newGlyphLine.value[1])) { 40 | newGlyphLine.value[1] = headShapeTypes[newType][0]; 41 | } 42 | if (!tailShapeTypes[newType].includes(newGlyphLine.value[2])) { 43 | newGlyphLine.value[2] = tailShapeTypes[newType][0]; 44 | } 45 | 46 | const oldNumPoints = (getNumColumns(oldType) - 3) / 2; 47 | const newNumPoints = (getNumColumns(newType) - 3) / 2; 48 | if (oldNumPoints === newNumPoints) { 49 | return newGlyphLine; 50 | } 51 | if (oldNumPoints === 2 && newNumPoints === 3) { 52 | const x1 = newGlyphLine.value[3]; 53 | const y1 = newGlyphLine.value[4]; 54 | const x3 = newGlyphLine.value[5]; 55 | const y3 = newGlyphLine.value[6]; 56 | 57 | const x2 = Math.round((x1 + x3) / 2); 58 | const y2 = Math.round((y1 + y3) / 2); 59 | newGlyphLine.value = newGlyphLine.value.slice(0, 3).concat( 60 | [x1, y1, x2, y2, x3, y3] 61 | ); 62 | return newGlyphLine; 63 | } 64 | if (oldNumPoints === 2 && newNumPoints === 4) { 65 | const x1 = newGlyphLine.value[3]; 66 | const y1 = newGlyphLine.value[4]; 67 | const x4 = newGlyphLine.value[5]; 68 | const y4 = newGlyphLine.value[6]; 69 | 70 | const x2 = Math.round((2 * x1 + x4) / 3); 71 | const y2 = Math.round((2 * y1 + y4) / 3); 72 | const x3 = Math.round((x1 + 2 * x4) / 3); 73 | const y3 = Math.round((y1 + 2 * y4) / 3); 74 | newGlyphLine.value = newGlyphLine.value.slice(0, 3).concat( 75 | [x1, y1, x2, y2, x3, y3, x4, y4] 76 | ); 77 | return newGlyphLine; 78 | } 79 | if (oldNumPoints === 3 && newNumPoints === 2) { 80 | const x1 = newGlyphLine.value[3]; 81 | const y1 = newGlyphLine.value[4]; 82 | const x2 = newGlyphLine.value[7]; 83 | const y2 = newGlyphLine.value[8]; 84 | newGlyphLine.value = newGlyphLine.value.slice(0, 3).concat( 85 | [x1, y1, x2, y2] 86 | ); 87 | return newGlyphLine; 88 | } 89 | if (oldNumPoints === 3 && newNumPoints === 4) { 90 | const x1 = newGlyphLine.value[3]; 91 | const y1 = newGlyphLine.value[4]; 92 | const xm = newGlyphLine.value[5]; 93 | const ym = newGlyphLine.value[6]; 94 | const x4 = newGlyphLine.value[7]; 95 | const y4 = newGlyphLine.value[8]; 96 | 97 | const x2 = Math.round((x1 + 2 * xm) / 3); 98 | const y2 = Math.round((y1 + 2 * ym) / 3); 99 | const x3 = Math.round((x4 + 2 * xm) / 3); 100 | const y3 = Math.round((y4 + 2 * ym) / 3); 101 | newGlyphLine.value = newGlyphLine.value.slice(0, 3).concat( 102 | [x1, y1, x2, y2, x3, y3, x4, y4] 103 | ); 104 | return newGlyphLine; 105 | } 106 | if (oldNumPoints === 4 && newNumPoints === 2) { 107 | const x1 = newGlyphLine.value[3]; 108 | const y1 = newGlyphLine.value[4]; 109 | const x2 = newGlyphLine.value[9]; 110 | const y2 = newGlyphLine.value[10]; 111 | newGlyphLine.value = newGlyphLine.value.slice(0, 3).concat( 112 | [x1, y1, x2, y2] 113 | ); 114 | return newGlyphLine; 115 | } 116 | if (oldNumPoints === 4 && newNumPoints === 3) { 117 | const x1 = newGlyphLine.value[3]; 118 | const y1 = newGlyphLine.value[4]; 119 | const xm1 = newGlyphLine.value[5]; 120 | const ym1 = newGlyphLine.value[6]; 121 | const xm2 = newGlyphLine.value[7]; 122 | const ym2 = newGlyphLine.value[8]; 123 | const x3 = newGlyphLine.value[9]; 124 | const y3 = newGlyphLine.value[10]; 125 | 126 | const x2 = Math.round((xm1 + xm2) / 2); 127 | const y2 = Math.round((ym1 + ym2) / 2); 128 | newGlyphLine.value = newGlyphLine.value.slice(0, 3).concat( 129 | [x1, y1, x2, y2, x3, y3] 130 | ); 131 | return newGlyphLine; 132 | } 133 | return newGlyphLine; 134 | }; 135 | 136 | const validStrokeShapeTypes: [number, number, number][] = [ 137 | [1, 0, 0], 138 | [1, 0, 2], 139 | [1, 0, 32], 140 | [1, 0, 13], 141 | [1, 0, 23], 142 | [1, 0, 4], 143 | [1, 0, 313], 144 | [1, 0, 413], 145 | [1, 0, 24], 146 | [1, 2, 0], 147 | [1, 2, 2], 148 | [1, 32, 0], 149 | [1, 32, 32], 150 | [1, 32, 13], 151 | [1, 32, 23], 152 | [1, 32, 4], 153 | [1, 32, 313], 154 | [1, 32, 413], 155 | [1, 32, 24], 156 | [1, 12, 0], 157 | [1, 12, 32], 158 | [1, 12, 13], 159 | [1, 12, 23], 160 | [1, 12, 313], 161 | [1, 12, 413], 162 | [1, 12, 24], 163 | [1, 22, 0], 164 | [1, 22, 32], 165 | [1, 22, 13], 166 | [1, 22, 23], 167 | [1, 22, 4], 168 | [1, 22, 313], 169 | [1, 22, 413], 170 | [1, 22, 24], 171 | [2, 0, 7], 172 | [2, 0, 5], 173 | [2, 32, 7], 174 | [2, 32, 4], 175 | [2, 32, 5], 176 | [2, 12, 7], 177 | [2, 22, 7], 178 | [2, 22, 4], 179 | [2, 22, 5], 180 | [2, 7, 0], 181 | [2, 7, 8], 182 | [2, 7, 4], 183 | [2, 27, 0], 184 | [3, 0, 0], 185 | [3, 0, 5], 186 | [3, 0, 32], 187 | [3, 32, 0], 188 | [3, 32, 5], 189 | [3, 32, 32], 190 | [3, 12, 0], 191 | [3, 12, 5], 192 | [3, 12, 32], 193 | [3, 22, 0], 194 | [3, 22, 5], 195 | [3, 22, 32], 196 | [4, 0, 0], 197 | [4, 0, 5], 198 | [4, 22, 0], 199 | [4, 22, 5], 200 | [6, 0, 7], 201 | [6, 0, 5], 202 | [6, 32, 7], 203 | [6, 32, 4], 204 | [6, 32, 5], 205 | [6, 12, 7], 206 | [6, 22, 7], 207 | [6, 22, 4], 208 | [6, 22, 5], 209 | [6, 7, 0], 210 | [6, 7, 8], 211 | [6, 7, 4], 212 | [6, 27, 0], 213 | [7, 0, 7], 214 | [7, 32, 7], 215 | [7, 12, 7], 216 | [7, 22, 7], 217 | ]; 218 | 219 | export const isValidStrokeShapeTypes = memoizeOne((stroke: GlyphLine) => { 220 | if (!strokeTypes.includes(stroke.value[0])) { 221 | return true; 222 | } 223 | 224 | if (!validStrokeShapeTypes.some(([s0, s1, s2]) => ( 225 | s0 === stroke.value[0] && 226 | s1 === stroke.value[1] && 227 | s2 === stroke.value[2] 228 | ))) { 229 | return false; 230 | } 231 | 232 | if (stroke.value[0] === 1) { 233 | const [, s1, s2, x1, y1, x2, y2] = stroke.value; 234 | const isVertical = y1 === y2 ? x1 === x2 : x2 - x1 <= Math.abs(y1 - y2); 235 | 236 | if (isVertical 237 | ? (s1 === 2 || s2 === 2) 238 | : !([0, 2].includes(s1) && [0, 2].includes(s2)) 239 | ) { 240 | return false; 241 | } 242 | } 243 | 244 | return true; 245 | }); 246 | -------------------------------------------------------------------------------- /src/reducers/drag.ts: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-only 2 | // Copyright 2020, 2022, 2025 kurgm 3 | 4 | import { ReducerBuilder } from 'typescript-fsa-reducers'; 5 | 6 | import { polygonInPolygon, polygonIntersectsPolygon, Polygon as GPolygon } from 'geometric'; 7 | 8 | import { dragActions } from '../actions/drag'; 9 | 10 | import { Glyph } from '../kageUtils/glyph'; 11 | import { moveSelectedGlyphLines, moveSelectedPoint } from '../kageUtils/transform'; 12 | import { drawFreehand } from '../kageUtils/freehand'; 13 | import { makeGlyphSeparated, KShotai } from '../kage'; 14 | 15 | import { AppState } from '.'; 16 | import { resizeSelected } from '../selectors/draggedGlyph'; 17 | 18 | const performAreaSelect = (glyph: Glyph, buhinMap: Map, shotai: KShotai, x1: number, y1: number, x2: number, y2: number): number[] => { 19 | const polygonsSep = makeGlyphSeparated(glyph, buhinMap, shotai); 20 | const result = []; 21 | 22 | const gAreaPolygon: GPolygon = [ 23 | [x1, y1], 24 | [x1, y2], 25 | [x2, y2], 26 | [x2, y1], 27 | [x1, y1], 28 | ]; 29 | 30 | for (let index = 0; index < polygonsSep.length; index++) { 31 | const polygons = polygonsSep[index]; 32 | if (polygons.array.some((polygon) => { 33 | const gPolygon: GPolygon = polygon.array.map(({ x, y }) => [x, y]); 34 | gPolygon.push(gPolygon[0]); // close polygon 35 | 36 | return ( 37 | polygonInPolygon(gAreaPolygon, gPolygon) || 38 | polygonInPolygon(gPolygon, gAreaPolygon) || 39 | polygonIntersectsPolygon(gAreaPolygon, gPolygon) 40 | ) as boolean; 41 | })) { 42 | result.push(index); 43 | } 44 | } 45 | return result; 46 | }; 47 | 48 | const updateBuilder = (builder: ReducerBuilder) => builder 49 | .case(dragActions.startBackgroundDrag, (state, evt) => { 50 | if (!state.ctmInv) { 51 | return state; 52 | } 53 | const [x1, y1] = state.ctmInv(evt.clientX, evt.clientY); 54 | if (state.freehandMode) { 55 | return { 56 | ...state, 57 | freehandStroke: [[x1, y1]], 58 | }; 59 | } 60 | return { 61 | ...state, 62 | areaSelectRect: [x1, y1, x1, y1], 63 | }; 64 | }) 65 | .case(dragActions.startSelectionDrag, (state, evt) => { 66 | if (!state.ctmInv) { 67 | return state; 68 | } 69 | const [x1, y1] = state.ctmInv(evt.clientX, evt.clientY); 70 | return { 71 | ...state, 72 | dragSelection: [x1, y1, x1, y1], 73 | }; 74 | }) 75 | .case(dragActions.startPointDrag, (state, [evt, pointIndex]) => { 76 | if (!state.ctmInv) { 77 | return state; 78 | } 79 | const [x1, y1] = state.ctmInv(evt.clientX, evt.clientY); 80 | return { 81 | ...state, 82 | dragPoint: [pointIndex, [x1, y1, x1, y1]], 83 | }; 84 | }) 85 | .case(dragActions.startResize, (state, [evt, position]) => { 86 | if (!state.ctmInv) { 87 | return state; 88 | } 89 | const [x1, y1] = state.ctmInv(evt.clientX, evt.clientY); 90 | return { 91 | ...state, 92 | resizeSelection: [position, [x1, y1, x1, y1]], 93 | }; 94 | }) 95 | 96 | .case(dragActions.mouseMove, (state, evt) => { 97 | if (!state.ctmInv) { 98 | return state; 99 | } 100 | if (state.areaSelectRect) { 101 | const [x1, y1] = state.areaSelectRect; 102 | const [x2, y2] = state.ctmInv(evt.clientX, evt.clientY); 103 | return { 104 | ...state, 105 | areaSelectRect: [x1, y1, x2, y2], 106 | }; 107 | } 108 | if (state.dragSelection) { 109 | const [x1, y1] = state.dragSelection; 110 | const [x2, y2] = state.ctmInv(evt.clientX, evt.clientY); 111 | return { 112 | ...state, 113 | dragSelection: [x1, y1, x2, y2], 114 | }; 115 | } 116 | if (state.dragPoint) { 117 | const [pointIndex, [x1, y1]] = state.dragPoint; 118 | const [x2, y2] = state.ctmInv(evt.clientX, evt.clientY); 119 | return { 120 | ...state, 121 | dragPoint: [pointIndex, [x1, y1, x2, y2]], 122 | }; 123 | } 124 | if (state.resizeSelection) { 125 | const [position, [x1, y1]] = state.resizeSelection; 126 | const [x2, y2] = state.ctmInv(evt.clientX, evt.clientY); 127 | return { 128 | ...state, 129 | resizeSelection: [position, [x1, y1, x2, y2]], 130 | }; 131 | } 132 | if (state.freehandStroke) { 133 | const [x2, y2] = state.ctmInv(evt.clientX, evt.clientY); 134 | const freehandStroke = state.freehandStroke.concat([[x2, y2]]); 135 | if (freehandStroke.length >= 3) { 136 | const [lastX, lastY] = freehandStroke[freehandStroke.length - 2]; 137 | if (Math.abs(x2 - lastX) < 2 && Math.abs(y2 - lastY) < 2) { 138 | freehandStroke.splice(freehandStroke.length - 2, 1); 139 | } 140 | } 141 | return { 142 | ...state, 143 | freehandStroke, 144 | }; 145 | } 146 | return state; 147 | }) 148 | .case(dragActions.mouseUp, (state, evt) => { 149 | if (!state.ctmInv) { 150 | return state; 151 | } 152 | if (state.areaSelectRect) { 153 | const [x1, y1] = state.areaSelectRect; 154 | const [x2, y2] = state.ctmInv(evt.clientX, evt.clientY); 155 | const intersections = performAreaSelect(state.glyph, state.buhinMap, state.shotai, x1, y1, x2, y2); 156 | 157 | const newSelection = Array.from(new Set(state.selection.concat(intersections))); 158 | return { 159 | ...state, 160 | selection: newSelection, 161 | areaSelectRect: null, 162 | }; 163 | } 164 | if (state.dragSelection) { 165 | const [x1, y1] = state.dragSelection; 166 | const [x2, y2] = state.ctmInv(evt.clientX, evt.clientY); 167 | 168 | const newGlyph = moveSelectedGlyphLines(state.glyph, state.selection, x2 - x1, y2 - y1); 169 | return { 170 | ...state, 171 | glyph: newGlyph, 172 | dragSelection: null, 173 | }; 174 | } 175 | if (state.dragPoint) { 176 | const [pointIndex, [x1, y1]] = state.dragPoint; 177 | const [x2, y2] = state.ctmInv(evt.clientX, evt.clientY); 178 | 179 | const newGlyph = moveSelectedPoint(state.glyph, state.selection, pointIndex, x2 - x1, y2 - y1); 180 | return { 181 | ...state, 182 | glyph: newGlyph, 183 | dragPoint: null, 184 | }; 185 | } 186 | if (state.resizeSelection) { 187 | const [position, [x1, y1]] = state.resizeSelection; 188 | const [x2, y2] = state.ctmInv(evt.clientX, evt.clientY); 189 | 190 | const newGlyph = resizeSelected(state.glyph, state.selection, position, x2 - x1, y2 - y1); 191 | return { 192 | ...state, 193 | glyph: newGlyph, 194 | resizeSelection: null, 195 | }; 196 | } 197 | if (state.freehandStroke) { 198 | const [x2, y2] = state.ctmInv(evt.clientX, evt.clientY); 199 | const freehandStroke = state.freehandStroke.concat([[x2, y2]]); 200 | 201 | const newGlyph = drawFreehand(state.glyph, freehandStroke); 202 | return { 203 | ...state, 204 | glyph: newGlyph, 205 | freehandStroke: null, 206 | }; 207 | } 208 | return state; 209 | }) 210 | 211 | .case(dragActions.updateCTMInv, (state, ctmInv) => ({ 212 | ...state, 213 | ctmInv, 214 | })); 215 | 216 | 217 | export default updateBuilder; 218 | -------------------------------------------------------------------------------- /src/reducers/editor.ts: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-only 2 | // Copyright 2020 kurgm 3 | 4 | import { ReducerBuilder } from 'typescript-fsa-reducers'; 5 | 6 | import { editorActions } from '../actions/editor'; 7 | 8 | import { Glyph, GlyphLine } from '../kageUtils/glyph'; 9 | import { getGlyphLinesBBX } from '../kageUtils/bbx'; 10 | import { decompose } from '../kageUtils/decompose'; 11 | import { calcStretchPositions, setStretchPositions } from '../kageUtils/stretchparam'; 12 | import { changeStrokeType } from '../kageUtils/stroketype'; 13 | import { reflectRotateTypeParamsMap } from '../kageUtils/reflectrotate'; 14 | import { applyGlyphLineOperation, moveSelectedGlyphLines } from '../kageUtils/transform'; 15 | 16 | import { AppState } from '.'; 17 | 18 | const setGlyphLine = (glyph: Glyph, index: number, glyphLine: GlyphLine): Glyph => { 19 | const newGlyph = glyph.slice(); 20 | newGlyph[index] = glyphLine; 21 | return newGlyph; 22 | }; 23 | 24 | const updateBuilder = (builder: ReducerBuilder) => builder 25 | .case(editorActions.loadedBuhin, (state, [name, data]) => { 26 | const newMap = new Map(state.buhinMap); 27 | newMap.set(name, data); 28 | return { 29 | ...state, 30 | buhinMap: newMap, 31 | }; 32 | }) 33 | .case(editorActions.loadedStretchParam, (state, [name, param]) => { 34 | const newMap = new Map(state.stretchParamMap); 35 | newMap.set(name, param); 36 | return { 37 | ...state, 38 | stretchParamMap: newMap, 39 | }; 40 | }) 41 | 42 | .case(editorActions.changeStrokeType, (state, newType) => { 43 | if (state.selection.length !== 1) { 44 | return state; 45 | } 46 | const lineIndex = state.selection[0]; 47 | const newGLine = changeStrokeType(state.glyph[lineIndex], newType); 48 | return { 49 | ...state, 50 | glyph: setGlyphLine(state.glyph, lineIndex, newGLine), 51 | }; 52 | }) 53 | .case(editorActions.changeHeadShapeType, (state, newType) => { 54 | if (state.selection.length !== 1) { 55 | return state; 56 | } 57 | const lineIndex = state.selection[0]; 58 | const newGLine: GlyphLine = { 59 | ...state.glyph[lineIndex], 60 | value: state.glyph[lineIndex].value.slice(), 61 | }; 62 | newGLine.value[1] = newType; 63 | return { 64 | ...state, 65 | glyph: setGlyphLine(state.glyph, lineIndex, newGLine), 66 | }; 67 | }) 68 | .case(editorActions.changeTailShapeType, (state, newType) => { 69 | if (state.selection.length !== 1) { 70 | return state; 71 | } 72 | const lineIndex = state.selection[0]; 73 | const newGLine: GlyphLine = { 74 | ...state.glyph[lineIndex], 75 | value: state.glyph[lineIndex].value.slice(), 76 | }; 77 | newGLine.value[2] = newType; 78 | return { 79 | ...state, 80 | glyph: setGlyphLine(state.glyph, lineIndex, newGLine), 81 | }; 82 | }) 83 | .case(editorActions.changeStretchCoeff, (state, value) => { 84 | if (state.selection.length !== 1) { 85 | return state; 86 | } 87 | const lineIndex = state.selection[0]; 88 | const selectedLine = state.glyph[lineIndex]; 89 | if (!selectedLine.partName) { 90 | return state; 91 | } 92 | const stretchParam = state.stretchParamMap.get(selectedLine.partName); 93 | if (!stretchParam) { 94 | return state; 95 | } 96 | const newGLine = setStretchPositions( 97 | state.glyph[lineIndex], 98 | calcStretchPositions(stretchParam, value) 99 | ); 100 | return { 101 | ...state, 102 | glyph: setGlyphLine(state.glyph, lineIndex, newGLine), 103 | }; 104 | }) 105 | .case(editorActions.changeReflectRotateOpType, (state, opType) => { 106 | if (state.selection.length !== 1) { 107 | return state; 108 | } 109 | const lineIndex = state.selection[0]; 110 | const newGLine: GlyphLine = { 111 | ...state.glyph[lineIndex], 112 | value: state.glyph[lineIndex].value.slice(), 113 | }; 114 | const params = reflectRotateTypeParamsMap[opType] 115 | if (!params) { 116 | return state; 117 | } 118 | newGLine.value[1] = params[0]; 119 | newGLine.value[2] = params[1]; 120 | return { 121 | ...state, 122 | glyph: setGlyphLine(state.glyph, lineIndex, newGLine), 123 | }; 124 | }) 125 | 126 | .case(editorActions.swapWithPrev, (state) => { 127 | if (state.selection.length !== 1) { 128 | return state; 129 | } 130 | const lineIndex = state.selection[0]; 131 | if (lineIndex === 0) { 132 | return state; 133 | } 134 | const newGlyph = state.glyph.slice(); 135 | newGlyph[lineIndex - 1] = state.glyph[lineIndex]; 136 | newGlyph[lineIndex] = state.glyph[lineIndex - 1]; 137 | return { 138 | ...state, 139 | glyph: newGlyph, 140 | selection: [lineIndex - 1], 141 | }; 142 | }) 143 | .case(editorActions.swapWithNext, (state) => { 144 | if (state.selection.length !== 1) { 145 | return state; 146 | } 147 | const lineIndex = state.selection[0]; 148 | if (lineIndex === state.glyph.length - 1) { 149 | return state; 150 | } 151 | const newGlyph = state.glyph.slice(); 152 | newGlyph[lineIndex + 1] = state.glyph[lineIndex]; 153 | newGlyph[lineIndex] = state.glyph[lineIndex + 1]; 154 | return { 155 | ...state, 156 | glyph: newGlyph, 157 | selection: [lineIndex + 1], 158 | }; 159 | }) 160 | 161 | .case(editorActions.insertPart, (state, partName) => ({ 162 | ...state, 163 | glyph: state.glyph.concat([{ 164 | value: [99, 0, 0, 0, 0, 200, 200, 0, 0, 0, 0], 165 | partName, 166 | }]), 167 | selection: [state.glyph.length], 168 | })) 169 | 170 | .case(editorActions.paste, (state) => ({ 171 | ...state, 172 | glyph: state.glyph.concat(state.clipboard), 173 | selection: state.clipboard.map((_gLine, index) => state.glyph.length + index), 174 | })) 175 | .case(editorActions.copy, (state) => { 176 | const targetLines = state.selection.map((index) => state.glyph[index]); 177 | const [x1, y1] = getGlyphLinesBBX(targetLines); 178 | const tX = (x: number) => 230 + x - x1; 179 | const tY = (y: number) => 20 + y - y1; 180 | return { 181 | ...state, 182 | clipboard: state.selection.map((index) => ( 183 | applyGlyphLineOperation(state.glyph[index], tX, tY) 184 | )), 185 | }; 186 | }) 187 | .case(editorActions.cut, (state) => ({ 188 | ...state, 189 | glyph: state.glyph.filter((_gLine, index) => !state.selection.includes(index)), 190 | clipboard: state.selection.map((index) => state.glyph[index]), 191 | selection: [], 192 | })) 193 | .case(editorActions.delete, (state) => ({ 194 | ...state, 195 | glyph: state.glyph.filter((_gLine, index) => !state.selection.includes(index)), 196 | selection: [], 197 | })) 198 | 199 | .case(editorActions.decomposeSelected, (state) => { 200 | let newGlyph: Glyph = []; 201 | let newSelection: number[] = []; 202 | state.glyph.forEach((gLine, index) => { 203 | if (!state.selection.includes(index)) { 204 | newGlyph.push(gLine); 205 | return; 206 | } 207 | const newLines = decompose(gLine, state.buhinMap); 208 | newSelection = newSelection.concat( 209 | newLines.map((_gLine, subindex) => newGlyph.length + subindex) 210 | ); 211 | newGlyph = newGlyph.concat(newLines); 212 | }); 213 | return { 214 | ...state, 215 | glyph: newGlyph, 216 | selection: newSelection, 217 | }; 218 | }) 219 | .case(editorActions.moveSelected, (state, [dx, dy]) => ({ 220 | ...state, 221 | glyph: moveSelectedGlyphLines(state.glyph, state.selection, dx, dy), 222 | })) 223 | 224 | .case(editorActions.toggleFreehand, (state) => ({ 225 | ...state, 226 | selection: state.freehandMode ? state.selection : [], 227 | freehandMode: !state.freehandMode, 228 | })) 229 | 230 | .case(editorActions.escape, (state) => { 231 | if (state.showOptionModal) { 232 | return { 233 | ...state, 234 | showOptionModal: false, 235 | } 236 | } 237 | if (state.freehandMode) { 238 | return { 239 | ...state, 240 | freehandMode: false, 241 | }; 242 | } 243 | if (state.selection.length) { 244 | return { 245 | ...state, 246 | selection: [], 247 | }; 248 | } 249 | return state; 250 | }) 251 | 252 | .case(editorActions.finishEdit, (state, evt) => ({ 253 | ...state, 254 | exitEvent: evt, 255 | })); 256 | 257 | 258 | export default updateBuilder; 259 | -------------------------------------------------------------------------------- /src/components/SelectionControl.tsx: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-only 2 | // Copyright 2020, 2021, 2023, 2025 kurgm and graphemecluster 3 | 4 | import React, { useCallback, useMemo } from 'react'; 5 | 6 | import { dragActions, RectPointPosition } from '../actions/drag'; 7 | import { useAppDispatch, useAppSelector } from '../hooks'; 8 | import { createAppSelector } from '../selectors/util'; 9 | import { draggedGlyphSelector } from '../selectors/draggedGlyph'; 10 | import { getGlyphLinesBBX } from '../kageUtils/bbx'; 11 | import { getMatchType, MatchType } from '../kageUtils/match'; 12 | 13 | import ControlPoint from './ControlPoint'; 14 | 15 | import styles from './SelectionControl.module.css'; 16 | 17 | interface RectControl { 18 | multiSelect: boolean; 19 | coords: [number, number, number, number]; 20 | } 21 | interface ControlPointSpec { 22 | x: number; 23 | y: number; 24 | matchType: MatchType; 25 | } 26 | 27 | interface SelectionControlSpec { 28 | rectControl: RectControl | null; 29 | pointControl: ControlPointSpec[]; 30 | auxiliaryLines: [number, number, number, number][]; 31 | } 32 | const selectionControlSelector = createAppSelector( 33 | [ 34 | draggedGlyphSelector, 35 | (state) => state.selection, 36 | ], 37 | (glyph, selection): SelectionControlSpec => { 38 | if (selection.length === 0) { 39 | return { rectControl: null, pointControl: [], auxiliaryLines: [] }; 40 | } 41 | if (selection.length > 1) { 42 | const selectedStrokes = selection.map((index) => glyph[index]); 43 | const bbx = getGlyphLinesBBX(selectedStrokes); 44 | return { 45 | rectControl: { 46 | multiSelect: true, 47 | coords: bbx, 48 | }, 49 | pointControl: [], 50 | auxiliaryLines: [], 51 | }; 52 | } 53 | const selectedStroke = glyph[selection[0]]; 54 | switch (selectedStroke.value[0]) { 55 | case 0: 56 | case 9: 57 | case 99: 58 | return { 59 | rectControl: { 60 | multiSelect: false, 61 | coords: [ 62 | selectedStroke.value[3], 63 | selectedStroke.value[4], 64 | selectedStroke.value[5], 65 | selectedStroke.value[6], 66 | ], 67 | }, 68 | pointControl: [], 69 | auxiliaryLines: [], 70 | }; 71 | case 1: 72 | case 2: 73 | case 3: 74 | case 4: 75 | case 6: 76 | case 7: { 77 | const pointControl: ControlPointSpec[] = []; 78 | for (let i = 3; i + 2 <= selectedStroke.value.length; i += 2) { 79 | const matchType = getMatchType(glyph, { 80 | lineIndex: selection[0], 81 | pointIndex: (i - 3) / 2, 82 | }); 83 | pointControl.push({ 84 | x: selectedStroke.value[i], 85 | y: selectedStroke.value[i + 1], 86 | matchType, 87 | }); 88 | } 89 | 90 | const auxiliaryLines: [number, number, number, number][] = []; 91 | if (selectedStroke.value[0] === 2 || selectedStroke.value[0] === 6) { 92 | auxiliaryLines.push([ 93 | selectedStroke.value[3], 94 | selectedStroke.value[4], 95 | selectedStroke.value[5], 96 | selectedStroke.value[6], 97 | ]); 98 | } 99 | if (selectedStroke.value[0] === 2 || selectedStroke.value[0] === 7) { 100 | auxiliaryLines.push([ 101 | selectedStroke.value[5], 102 | selectedStroke.value[6], 103 | selectedStroke.value[7], 104 | selectedStroke.value[8], 105 | ]); 106 | } 107 | if (selectedStroke.value[0] === 6 || selectedStroke.value[0] === 7) { 108 | auxiliaryLines.push([ 109 | selectedStroke.value[7], 110 | selectedStroke.value[8], 111 | selectedStroke.value[9], 112 | selectedStroke.value[10], 113 | ]); 114 | } 115 | return { rectControl: null, pointControl, auxiliaryLines }; 116 | } 117 | default: 118 | return { rectControl: null, pointControl: [], auxiliaryLines: [] }; 119 | } 120 | } 121 | ); 122 | 123 | const SelectionControl = () => { 124 | const { rectControl, pointControl, auxiliaryLines } = useAppSelector(selectionControlSelector); 125 | 126 | const dispatch = useAppDispatch(); 127 | const handleMouseDownRectControl = useCallback((evt: React.MouseEvent, position: RectPointPosition) => { 128 | dispatch(dragActions.startResize([evt, position])); 129 | evt.stopPropagation(); 130 | }, [dispatch]); 131 | 132 | const handleMouseDownNorthPoint = useCallback( 133 | (evt: React.MouseEvent) => handleMouseDownRectControl(evt, RectPointPosition.north), 134 | [handleMouseDownRectControl] 135 | ); 136 | const handleMouseDownWestPoint = useCallback( 137 | (evt: React.MouseEvent) => handleMouseDownRectControl(evt, RectPointPosition.west), 138 | [handleMouseDownRectControl] 139 | ); 140 | const handleMouseDownSouthPoint = useCallback( 141 | (evt: React.MouseEvent) => handleMouseDownRectControl(evt, RectPointPosition.south), 142 | [handleMouseDownRectControl] 143 | ); 144 | const handleMouseDownEastPoint = useCallback( 145 | (evt: React.MouseEvent) => handleMouseDownRectControl(evt, RectPointPosition.east), 146 | [handleMouseDownRectControl] 147 | ); 148 | const handleMouseDownSoutheastPoint = useCallback( 149 | (evt: React.MouseEvent) => handleMouseDownRectControl(evt, RectPointPosition.southeast), 150 | [handleMouseDownRectControl] 151 | ); 152 | const handleMouseDownSouthwestPoint = useCallback( 153 | (evt: React.MouseEvent) => handleMouseDownRectControl(evt, RectPointPosition.southwest), 154 | [handleMouseDownRectControl] 155 | ); 156 | const handleMouseDownNortheastPoint = useCallback( 157 | (evt: React.MouseEvent) => handleMouseDownRectControl(evt, RectPointPosition.northeast), 158 | [handleMouseDownRectControl] 159 | ); 160 | const handleMouseDownNorthwestPoint = useCallback( 161 | (evt: React.MouseEvent) => handleMouseDownRectControl(evt, RectPointPosition.northwest), 162 | [handleMouseDownRectControl] 163 | ); 164 | 165 | const handleMouseDownPointControls = useMemo(() => { 166 | return pointControl.map((_control, pointIndex) => (evt: React.MouseEvent) => { 167 | dispatch(dragActions.startPointDrag([evt, pointIndex])); 168 | evt.stopPropagation(); 169 | }); 170 | }, [dispatch, pointControl]); 171 | 172 | const verticallyFlipped = !!rectControl && rectControl.coords[0] > rectControl.coords[2]; 173 | const horizontallyFlipped = !!rectControl && rectControl.coords[1] > rectControl.coords[3]; 174 | const rectCornerFlipped = verticallyFlipped !== horizontallyFlipped; 175 | 176 | return <> 177 | {rectControl && <> 178 | 185 | 191 | 197 | 203 | 209 | 215 | 221 | 227 | 233 | } 234 | {auxiliaryLines.map((points, index) => ( 235 | 236 | ))} 237 | {pointControl.map(({ x, y, matchType }, index) => ( 238 | 244 | ))} 245 | ; 246 | }; 247 | 248 | export default SelectionControl; 249 | -------------------------------------------------------------------------------- /src/components/SelectionInfo.tsx: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-only 2 | // Copyright 2020, 2023, 2025 kurgm 3 | 4 | import clsx from 'clsx/lite'; 5 | import React, { useCallback } from 'react'; 6 | import { shallowEqual } from 'react-redux'; 7 | import { useTranslation } from 'react-i18next'; 8 | 9 | import { editorActions } from '../actions/editor'; 10 | import { selectActions } from '../actions/select'; 11 | import { useAppDispatch, useAppSelector } from '../hooks'; 12 | import { GlyphLine } from '../kageUtils/glyph'; 13 | import { calcStretchScalar, getStretchPositions } from '../kageUtils/stretchparam'; 14 | import { strokeTypes, headShapeTypes, tailShapeTypes, isValidStrokeShapeTypes } from '../kageUtils/stroketype'; 15 | import { ReflectRotateType, reflectRotateTypeParamsMap, reflectRotateTypes } from '../kageUtils/reflectrotate'; 16 | import { draggedGlyphSelector } from '../selectors/draggedGlyph'; 17 | import { createAppSelector } from '../selectors/util'; 18 | 19 | import styles from './SelectionInfo.module.css'; 20 | 21 | 22 | const selectedGlyphLineSelector = createAppSelector([ 23 | (state) => state.selection, 24 | draggedGlyphSelector, 25 | ], (selection, draggedGlyph): GlyphLine | null => { 26 | if (selection.length !== 1) { 27 | return null; 28 | } 29 | return draggedGlyph[selection[0]]; 30 | }); 31 | 32 | interface StrokeInfo { 33 | strokeType: number; 34 | headShapeType: number; 35 | tailShapeType: number; 36 | validTypes: boolean; 37 | coordString: string; 38 | } 39 | const strokeInfoSelector = createAppSelector([ 40 | selectedGlyphLineSelector 41 | ], (selectedStroke): StrokeInfo | null => { 42 | if (!selectedStroke) { 43 | return null; 44 | } 45 | if (!strokeTypes.includes(selectedStroke.value[0])) { 46 | return null; 47 | } 48 | 49 | const points = []; 50 | for (let i = 3; i + 2 <= selectedStroke.value.length; i += 2) { 51 | points.push(`(${selectedStroke.value[i]},${selectedStroke.value[i + 1]})`); 52 | } 53 | return { 54 | strokeType: selectedStroke.value[0], 55 | headShapeType: selectedStroke.value[1], 56 | tailShapeType: selectedStroke.value[2], 57 | validTypes: isValidStrokeShapeTypes(selectedStroke), 58 | coordString: points.join(' → '), 59 | }; 60 | }); 61 | 62 | interface PartInfo { 63 | partName: string; 64 | entityName: string | null; 65 | coordString: string; 66 | stretchCoeff: number | null; 67 | } 68 | const partInfoSelector = createAppSelector([ 69 | selectedGlyphLineSelector, 70 | (state) => state.buhinMap, 71 | (state) => state.stretchParamMap, 72 | ], (selectedStroke, buhinMap, stretchParamMap): PartInfo | null => { 73 | if (!selectedStroke) { 74 | return null; 75 | } 76 | if (selectedStroke.value[0] !== 99) { 77 | return null; 78 | } 79 | const partName = selectedStroke.partName!; 80 | const buhinSource = buhinMap.get(partName); 81 | let entityName: string | null = null; 82 | if (buhinSource) { 83 | const aliasMatch = /^(?:0:1:0:[^$]+\$)?99:0:0:0:0:200:200:([^$]+)$/.exec(buhinSource); 84 | if (aliasMatch) { 85 | entityName = aliasMatch[1]; 86 | } 87 | } 88 | const stretchParam = stretchParamMap.get(partName); 89 | const stretchCoeff = stretchParam 90 | ? calcStretchScalar(stretchParam, getStretchPositions(selectedStroke)!) 91 | : null; 92 | 93 | return { 94 | partName, 95 | entityName, 96 | coordString: `(${selectedStroke.value[3]},${selectedStroke.value[4]}) → (${selectedStroke.value[5]},${selectedStroke.value[6]})`, 97 | stretchCoeff, 98 | }; 99 | }); 100 | 101 | interface ReflectRotateInfo { 102 | opType: ReflectRotateType | -1; 103 | coordString: string; 104 | } 105 | const reflectRotateInfoSelector = createAppSelector([ 106 | selectedGlyphLineSelector, 107 | ], (selectedStroke): ReflectRotateInfo | null => { 108 | if (!selectedStroke) { 109 | return null; 110 | } 111 | if (selectedStroke.value[0] !== 0) { 112 | return null; 113 | } 114 | const opType = reflectRotateTypes.find((type) => { 115 | const [param1, param2] = reflectRotateTypeParamsMap[type]; 116 | return param1 === selectedStroke.value[1] && param2 === selectedStroke.value[2]; 117 | }) ?? -1; 118 | 119 | return { 120 | opType, 121 | coordString: `(${selectedStroke.value[3]},${selectedStroke.value[4]}) → (${selectedStroke.value[5]},${selectedStroke.value[6]})`, 122 | }; 123 | }); 124 | 125 | interface OtherInfo { 126 | isMultiple: boolean; 127 | coordString?: string; 128 | } 129 | const otherInfoSelector = createAppSelector([ 130 | (state) => state.selection, 131 | selectedGlyphLineSelector, 132 | ], (selection, selectedStroke_): OtherInfo | null => { 133 | if (selection.length > 1) { 134 | return { isMultiple: true }; 135 | } 136 | if (selection.length === 0) { 137 | return { isMultiple: false }; 138 | } 139 | const selectedStroke = selectedStroke_!; 140 | const strokeType = selectedStroke.value[0]; 141 | if (strokeTypes.includes(strokeType) || strokeType === 99 || strokeType === 0) { 142 | return null; 143 | } 144 | 145 | const points = []; 146 | for (let i = 3; i + 2 <= selectedStroke.value.length; i += 2) { 147 | points.push(`(${selectedStroke.value[i]},${selectedStroke.value[i + 1]})`); 148 | } 149 | return { isMultiple: false, coordString: points.join(' → ') }; 150 | }); 151 | 152 | const selectIndexStringSelector = createAppSelector([ 153 | (state) => state.glyph.length, 154 | (state) => state.selection, 155 | ], (glyphLength, selection) => { 156 | const selectedIndexString = selection 157 | .map((index) => index + 1) 158 | .sort((a, b) => a - b).join(','); 159 | return `${selectedIndexString || '-'} / ${glyphLength || '-'}`; 160 | }); 161 | 162 | const buttonsDisabledSelector = createAppSelector([ 163 | (state) => state.glyph.length, 164 | (state) => state.selection, 165 | ], (glyphLength, selection) => ({ 166 | swapPrevDisabled: selection.length !== 1 || selection[0] === 0, 167 | swapNextDisabled: selection.length !== 1 || selection[0] === glyphLength - 1, 168 | selectPrevDisabled: glyphLength === 0, 169 | selectNextDisabled: glyphLength === 0, 170 | })); 171 | 172 | interface SelectionInfoProps { 173 | className?: string; 174 | } 175 | 176 | const SelectionInfo = (props: SelectionInfoProps) => { 177 | 178 | const strokeInfo = useAppSelector(strokeInfoSelector); 179 | const partInfo = useAppSelector(partInfoSelector); 180 | const reflectRotateInfo = useAppSelector(reflectRotateInfoSelector); 181 | const otherInfo = useAppSelector(otherInfoSelector); 182 | 183 | const selectIndexString = useAppSelector(selectIndexStringSelector); 184 | 185 | const { 186 | swapPrevDisabled, swapNextDisabled, 187 | selectPrevDisabled, selectNextDisabled, 188 | } = useAppSelector(buttonsDisabledSelector, shallowEqual); 189 | 190 | const dispatch = useAppDispatch(); 191 | const changeStrokeType = useCallback((evt: React.ChangeEvent) => { 192 | dispatch(editorActions.changeStrokeType(+evt.currentTarget.value)); 193 | }, [dispatch]); 194 | const changeHeadShapeType = useCallback((evt: React.ChangeEvent) => { 195 | dispatch(editorActions.changeHeadShapeType(+evt.currentTarget.value)); 196 | }, [dispatch]); 197 | const changeTailShapeType = useCallback((evt: React.ChangeEvent) => { 198 | dispatch(editorActions.changeTailShapeType(+evt.currentTarget.value)); 199 | }, [dispatch]); 200 | const changeStretchCoeff = useCallback((evt: React.ChangeEvent) => { 201 | dispatch(editorActions.changeStretchCoeff(+evt.currentTarget.value)); 202 | }, [dispatch]); 203 | const changeReflectRotateOpType = useCallback((evt: React.ChangeEvent) => { 204 | dispatch(editorActions.changeReflectRotateOpType(+evt.currentTarget.value)) 205 | }, [dispatch]); 206 | const selectPrev = useCallback(() => { 207 | dispatch(selectActions.selectPrev()); 208 | }, [dispatch]); 209 | const selectNext = useCallback(() => { 210 | dispatch(selectActions.selectNext()); 211 | }, [dispatch]); 212 | const swapWithPrev = useCallback(() => { 213 | dispatch(editorActions.swapWithPrev()); 214 | }, [dispatch]); 215 | const swapWithNext = useCallback(() => { 216 | dispatch(editorActions.swapWithNext()); 217 | }, [dispatch]); 218 | 219 | const { t } = useTranslation(); 220 | return ( 221 |
222 |
223 | {strokeInfo && <> 224 |
225 | {t('stroke type')} 226 | 236 | {' '} 237 | {t('head type')} 238 | 248 | {' '} 249 | {t('tail type')} 250 | 260 | {' '} 261 | {!strokeInfo.validTypes && ( 262 | 263 | {t('invalid stroke shape types')} 264 | 265 | )} 266 |
267 |
{strokeInfo.coordString}
268 | } 269 | {partInfo && <> 270 |
271 | {t('part')} 272 | {' '} 273 | {partInfo.partName} 274 | {' '} 275 | {partInfo.entityName && t('alias of', { entity: partInfo.entityName })} 276 |
277 |
{partInfo.coordString}
278 | {partInfo.stretchCoeff !== null && ( 279 |
280 | {t('stretch')} 281 | {' '} 282 | 287 | {' '} 288 | {partInfo.stretchCoeff} 289 |
290 | )} 291 | } 292 | {reflectRotateInfo && <> 293 |
294 | 304 |
305 |
{reflectRotateInfo.coordString}
306 | } 307 | {otherInfo && <> 308 | {otherInfo.isMultiple &&
{t('selecting multiple strokes')}
} 309 | {otherInfo.coordString &&
{otherInfo.coordString}
} 310 | } 311 |
312 |
313 | 319 | 326 |
327 | {selectIndexString} 328 |
329 | 336 | 342 |
343 |
344 | ); 345 | }; 346 | 347 | export default SelectionInfo; 348 | -------------------------------------------------------------------------------- /src/kageUtils/freehand.ts: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-only 2 | // Copyright 2020, 2025 kurgm 3 | 4 | import { Glyph, GlyphLine } from './glyph'; 5 | import { applyGlyphLineOperation } from './transform'; 6 | 7 | const sum = (nums: number[]) => nums.reduce((a, b) => a + b, 0); 8 | const avg = (nums: number[]) => sum(nums) / nums.length; 9 | const min: { 10 | (data: number[]): number; 11 | (data: T[], ev: (val: T) => number): number; 12 | } = (data: T[], ev?: (val: T) => number) => { 13 | let result = Infinity; 14 | for (const datum of data) { 15 | const val = ev ? ev(datum) : datum as number; 16 | if (val < result) { 17 | result = val; 18 | } 19 | } 20 | return result; 21 | }; 22 | const minBy = (data: T[], ev: (val: T) => number): T | undefined => { 23 | let result: T | undefined = undefined; 24 | let minVal = Infinity; 25 | for (const datum of data) { 26 | const val = ev(datum); 27 | if (val < minVal) { 28 | result = datum; 29 | minVal = val; 30 | } 31 | } 32 | return result; 33 | }; 34 | 35 | const lerp = (x1: number, x2: number, k: number) => x1 * (1 - k) + x2 * k; 36 | const norm2 = (dx: number, dy: number) => dx * dx + dy * dy; 37 | 38 | export const drawFreehand = (glyph: Glyph, points: [number, number][]): Glyph => { 39 | const [startX, startY] = points[0]; 40 | const [endX, endY] = points[points.length - 1]; 41 | const dx = endX - startX; 42 | const dy = endY - startY; 43 | 44 | if (glyph.length > 0 && norm2(dx, dy) < 25 ** 2) { 45 | const lastStroke = glyph[glyph.length - 1]; 46 | // ハネ部分かどうか? 47 | if ( 48 | [1, 2, 3, 4, 6].includes(lastStroke.value[0]) && 49 | norm2( 50 | startX - lastStroke.value[lastStroke.value.length - 2], 51 | startY - lastStroke.value[lastStroke.value.length - 1] 52 | ) < 10 ** 2 53 | ) { 54 | if ([1, 2, 6].includes(lastStroke.value[0]) && dx < 0) { // 左ハネに変更 55 | const newLastStroke: GlyphLine = { 56 | value: lastStroke.value.slice(), 57 | }; 58 | newLastStroke.value[2] = 4; 59 | if (newLastStroke.value[1] === 27) { 60 | newLastStroke.value[1] = 22; 61 | } 62 | return glyph.slice(0, -1).concat([newLastStroke]); 63 | } 64 | if ([2, 6].includes(lastStroke.value[0]) && dx >= 0 && dy < 0) { // 右ハネに変更 65 | const newLastStroke: GlyphLine = { 66 | value: lastStroke.value.slice(), 67 | }; 68 | newLastStroke.value[2] = 5; 69 | if (newLastStroke.value[1] === 7) { 70 | newLastStroke.value[1] = 0; 71 | } else if (newLastStroke.value[1] === 27) { 72 | newLastStroke.value[1] = 22; 73 | } 74 | return glyph.slice(0, -1).concat([newLastStroke]); 75 | } 76 | if ([3, 4].includes(lastStroke.value[0]) && dy < 0) { // 上ハネに変更 77 | const newLastStroke: GlyphLine = { 78 | value: lastStroke.value.slice(), 79 | }; 80 | newLastStroke.value[2] = 5; 81 | return glyph.slice(0, -1).concat([newLastStroke]); 82 | } 83 | } 84 | } 85 | 86 | const centroidX = avg(points.map(([x]) => x)); 87 | const centroidY = avg(points.map(([, y]) => y)); 88 | 89 | const midLerpRate = 3; 90 | const midX = lerp((startX + endX) / 2, centroidX, midLerpRate); 91 | const midY = lerp((startY + endY) / 2, centroidY, midLerpRate); 92 | 93 | const dis = (dx * midY - dy * midX + startX * endY - startY * endX) / Math.sqrt(norm2(dx, dy)); 94 | if ( 95 | Math.abs(dis) <= 5 && // 曲がっていない 96 | ( 97 | (dx > 0 && Math.abs(dy) <= dx * 0.5) || // 横 98 | (dy > 0 && -dy <= dx && dx <= dy * 0.5) // 縦 99 | ) 100 | ) { // 直線 101 | const newStroke: GlyphLine = { 102 | value: [1, 0, 0, startX, startY, endX, endY], 103 | }; 104 | return correctStroke(glyph, newStroke); 105 | } 106 | if (dx < 0 && dy >= 50 && dis < 0 && dx * -3 < dy) { // 縦払い 107 | const mid1X = startX; 108 | const mid1Y = lerp(startY, endY, 1 / 3); 109 | const mid2X = startX; 110 | const mid2Y = lerp(startY, endY, 2 / 3); 111 | const newStroke: GlyphLine = { 112 | value: [7, 0, 7, startX, startY, mid1X, mid1Y, mid2X, mid2Y, endX, endY], 113 | }; 114 | return correctStroke(glyph, newStroke); 115 | } 116 | // 曲線 117 | let startType = 0; 118 | let endType = 7; 119 | if (dx > 0 && dy > 0 && dis > 0) { // 右払い or 折れ 120 | const [leftBottomX, leftBottomY] = minBy(points, ([x, y]) => x - y)!; 121 | const dx1 = startX - leftBottomX; 122 | const dy1 = startY - leftBottomY; 123 | const dx2 = endX - leftBottomX; 124 | const dy2 = endY - leftBottomY; 125 | const cosAngle = (dx1 * dx2 + dy1 * dy2) / Math.sqrt(norm2(dx1, dy1) * norm2(dx2, dy2)); 126 | if (dx1 < 50 && dx2 > 30 && -20 <= dy2 && dy2 <= 20 && cosAngle > -0.15) { 127 | // 折れ 128 | const midX = min(points, ([x]) => x); 129 | const midY = endY; 130 | const newStroke: GlyphLine = { 131 | value: [3, 0, 0, startX, startY, midX, midY, endX, endY], 132 | }; 133 | return correctStroke(glyph, newStroke); 134 | } 135 | startType = 7; 136 | endType = 0; 137 | } else if (dx > 0 && dy > 0 && dis < 0) { // 止め 138 | startType = 7; 139 | endType = 8; 140 | } 141 | const newStroke: GlyphLine = { 142 | value: [2, startType, endType, startX, startY, midX, midY, endX, endY], 143 | }; 144 | return correctStroke(glyph, newStroke); 145 | }; 146 | 147 | const round = (x: number) => Math.round(x); 148 | 149 | let snapped: boolean[]; 150 | 151 | const correctStroke = (glyph: Glyph, newStroke: GlyphLine): Glyph => { 152 | snapped = newStroke.value.map(() => false); 153 | glyph = snapStrokeStart(glyph, newStroke); 154 | glyph = snapStrokeEnd(glyph, newStroke); 155 | snapStrokeTilt(newStroke); 156 | newStroke = applyGlyphLineOperation(newStroke, round, round) 157 | glyph = snapToNewStroke(glyph, newStroke); 158 | return glyph.concat([newStroke]); 159 | }; 160 | 161 | const setGlyphValue = (glyph: Glyph, lineIndex: number, column: number, value: number): Glyph => { 162 | if (glyph[lineIndex].value[column] === value) { 163 | return glyph; 164 | } 165 | return glyph.map((gLine, index) => { 166 | if (index !== lineIndex) { 167 | return gLine; 168 | } 169 | const newGLine: GlyphLine = { 170 | ...gLine, 171 | value: gLine.value.slice(), 172 | }; 173 | newGLine.value[column] = value; 174 | return newGLine; 175 | }); 176 | }; 177 | 178 | const snapVerticalStroke = ( 179 | glyph: Glyph, vertStroke: GlyphLine, position: 'start' | 'end', 180 | leftType: number, middleType: number, rightType: number 181 | ): Glyph => { 182 | const ti = position === 'start' ? 1 : 2; 183 | const xi = position === 'start' ? 3 : vertStroke.value.length - 2; 184 | const yi = xi + 1; 185 | const nx = vertStroke.value[xi]; 186 | const ny = vertStroke.value[yi]; 187 | const minX = nx - 10; 188 | const maxX = nx + 10; 189 | const minY = ny - 10; 190 | const maxY = ny + 10; 191 | for (let lineIndex = glyph.length - 1; lineIndex >= 0; lineIndex--) { 192 | const horiStroke = glyph[lineIndex]; 193 | if (horiStroke.value[0] !== 1) { 194 | continue; 195 | } 196 | const x1 = horiStroke.value[3]; 197 | const y1 = horiStroke.value[4]; 198 | const x2 = horiStroke.value[5]; 199 | const y2 = horiStroke.value[6]; 200 | if (x2 - x1 < Math.abs(y2 - y1)) { 201 | continue; 202 | } 203 | if ( 204 | [0, 2].includes(horiStroke.value[1]) && 205 | minX <= x1 && x1 <= maxX && 206 | minY <= y1 && y1 <= maxY 207 | ) { 208 | vertStroke.value[xi] = x1; 209 | vertStroke.value[yi] = y1; 210 | snapped[xi] = snapped[yi] = true; 211 | vertStroke.value[ti] = leftType; 212 | return setGlyphValue(glyph, lineIndex, 1, 2); // 接続(横) 213 | } 214 | if ( 215 | [0, 2].includes(horiStroke.value[2]) && 216 | minX <= x2 && x2 <= maxX && 217 | minY <= y2 && y2 <= maxY 218 | ) { 219 | vertStroke.value[xi] = x2; 220 | vertStroke.value[yi] = y2; 221 | snapped[xi] = snapped[yi] = true; 222 | vertStroke.value[ti] = rightType; 223 | return setGlyphValue(glyph, lineIndex, 2, 2); // 接続(横) 224 | } 225 | if (y1 === y2 && minY <= y1 && y1 <= maxY && x1 <= nx && nx <= x2) { 226 | vertStroke.value[yi] = y1; 227 | snapped[yi] = true; 228 | vertStroke.value[ti] = middleType; 229 | return glyph; 230 | } 231 | } 232 | return glyph; 233 | }; 234 | 235 | const snapHorizontalStroke = (glyph: Glyph, horiStroke: GlyphLine, position: 'start' | 'end'): Glyph => { 236 | const ti = position === 'start' ? 1 : 2; 237 | const xi = position === 'start' ? 3 : 5; 238 | const yi = xi + 1; 239 | const nx = horiStroke.value[xi]; 240 | const ny = horiStroke.value[yi]; 241 | const minX = nx - 10; 242 | const maxX = nx + 10; 243 | const minY = ny - 10; 244 | const maxY = ny + 10; 245 | for (let lineIndex = glyph.length - 1; lineIndex >= 0; lineIndex--) { 246 | const vertStroke = glyph[lineIndex]; 247 | if (![1, 2, 3, 4, 6, 7].includes(vertStroke.value[0])) { 248 | continue; 249 | } 250 | const x1 = vertStroke.value[3]; 251 | const y1 = vertStroke.value[4]; 252 | const x2 = vertStroke.value[5]; 253 | const y2 = vertStroke.value[6]; 254 | if (vertStroke.value[0] === 1 && x2 - x1 > Math.abs(y2 - y1)) { 255 | continue; 256 | } 257 | if (position === 'start') { 258 | if ( 259 | [0, 12].includes(vertStroke.value[1]) && 260 | minX <= x1 && x1 <= maxX && 261 | minY <= y1 && y1 <= maxY 262 | ) { 263 | horiStroke.value[xi] = x1; 264 | horiStroke.value[yi] = y1; 265 | snapped[xi] = snapped[yi] = true; 266 | horiStroke.value[ti] = 2; 267 | return setGlyphValue(glyph, lineIndex, 1, 12); // 左上カド 268 | } 269 | if ( 270 | vertStroke.value[0] === 1 && 271 | [0, 13, 313, 413].includes(vertStroke.value[2]) && 272 | minX <= x2 && x2 <= maxX && 273 | minY <= y2 && y2 <= maxY 274 | ) { 275 | horiStroke.value[xi] = x2; 276 | horiStroke.value[yi] = y2; 277 | snapped[xi] = snapped[yi] = true; 278 | horiStroke.value[ti] = 2; 279 | return vertStroke.value[2] === 0 280 | ? setGlyphValue(glyph, lineIndex, 2, 13) // 左下カド 281 | : glyph; 282 | } 283 | } else { 284 | if ( 285 | [0, 22].includes(vertStroke.value[1]) && 286 | minX <= x1 && x1 <= maxX && 287 | minY <= y1 && y1 <= maxY 288 | ) { 289 | horiStroke.value[xi] = x1; 290 | horiStroke.value[yi] = y1; 291 | snapped[xi] = snapped[yi] = true; 292 | horiStroke.value[ti] = 2; 293 | return setGlyphValue(glyph, lineIndex, 1, 22); // 右上カド 294 | } 295 | if ( 296 | vertStroke.value[0] === 1 && 297 | [0, 23, 24].includes(vertStroke.value[2]) && 298 | minX <= x2 && x2 <= maxX && 299 | minY <= y2 && y2 <= maxY 300 | ) { 301 | horiStroke.value[xi] = x2; 302 | horiStroke.value[yi] = y2; 303 | snapped[xi] = snapped[yi] = true; 304 | horiStroke.value[ti] = 2; 305 | return vertStroke.value[2] === 0 306 | ? setGlyphValue(glyph, lineIndex, 2, 23) // 右下カド 307 | : glyph; 308 | } 309 | } 310 | if (x1 === x2 && minX <= x1 && x1 <= maxX && y1 <= ny && ny <= y2) { 311 | horiStroke.value[xi] = x1; 312 | snapped[xi] = true; 313 | horiStroke.value[ti] = 2; 314 | return glyph; 315 | } 316 | } 317 | return glyph; 318 | }; 319 | 320 | const snapStrokeStart = (glyph: Glyph, newStroke: GlyphLine): Glyph => { 321 | if (newStroke.value[0] !== 1) { 322 | const y1 = newStroke.value[4]; 323 | const y2 = newStroke.value[6]; 324 | if (y1 > y2) { 325 | return glyph; 326 | } 327 | const midStartShape = newStroke.value[1] === 7 ? 7 : 32; 328 | const rightStartShape = newStroke.value[1] === 7 ? 27 : 22; 329 | return snapVerticalStroke(glyph, newStroke, 'start', 12, midStartShape, rightStartShape); 330 | } 331 | const x1 = newStroke.value[3]; 332 | const y1 = newStroke.value[4]; 333 | const x2 = newStroke.value[5]; 334 | const y2 = newStroke.value[6]; 335 | if (x2 - x1 > Math.abs(y2 - y1)) { 336 | return snapHorizontalStroke(glyph, newStroke, 'start'); 337 | } 338 | if (y2 - y1 > 0) { 339 | return snapVerticalStroke(glyph, newStroke, 'start', 12, 32, 22); 340 | } 341 | return glyph; 342 | }; 343 | const snapStrokeSegmentTilt = (newStroke: GlyphLine, point1Index: number) => { 344 | const x1i = 3 + point1Index * 2; 345 | const y1i = x1i + 1; 346 | const x2i = x1i + 2; 347 | const y2i = x1i + 3; 348 | 349 | const x1 = newStroke.value[x1i]; 350 | const y1 = newStroke.value[y1i]; 351 | const x2 = newStroke.value[x2i]; 352 | const y2 = newStroke.value[y2i]; 353 | 354 | const dx = x2 - x1; 355 | const dy = y2 - y1; 356 | 357 | if (Math.abs(dx) > Math.abs(dy) * 20) { 358 | if (!snapped[y2i]) { 359 | newStroke.value[y2i] = y1; 360 | return; 361 | } 362 | if (!snapped[y1i]) { 363 | newStroke.value[y1i] = y2; 364 | return; 365 | } 366 | } 367 | if (Math.abs(dy) > Math.abs(dx) * 20) { 368 | if (!snapped[x2i]) { 369 | newStroke.value[x2i] = x1; 370 | return; 371 | } 372 | if (!snapped[x1i]) { 373 | newStroke.value[x1i] = x2; 374 | return; 375 | } 376 | } 377 | }; 378 | const snapStrokeTilt = (newStroke: GlyphLine) => { 379 | switch (newStroke.value[0]) { 380 | case 1: 381 | snapStrokeSegmentTilt(newStroke, 0); 382 | return; 383 | case 3: 384 | snapStrokeSegmentTilt(newStroke, 0); 385 | snapStrokeSegmentTilt(newStroke, 1); 386 | return; 387 | case 4: 388 | snapStrokeSegmentTilt(newStroke, 1); 389 | return; 390 | case 7: 391 | newStroke.value[5] = newStroke.value[7] = newStroke.value[3]; 392 | return; 393 | default: 394 | return; 395 | } 396 | }; 397 | const snapStrokeEnd = (glyph: Glyph, newStroke: GlyphLine): Glyph => { 398 | if (newStroke.value[0] !== 1) { 399 | return glyph; 400 | } 401 | const x1 = newStroke.value[3]; 402 | const y1 = newStroke.value[4]; 403 | const x2 = newStroke.value[5]; 404 | const y2 = newStroke.value[6]; 405 | if (x2 - x1 > Math.abs(y2 - y1)) { 406 | return snapHorizontalStroke(glyph, newStroke, 'end'); 407 | } 408 | if (y2 - y1 > 0) { 409 | return snapVerticalStroke(glyph, newStroke, 'end', 13, 32, 23); 410 | } 411 | return glyph; 412 | }; 413 | const snapToNewStroke = (glyph: Glyph, newStroke: GlyphLine): Glyph => { 414 | if (newStroke.value[0] !== 1) { 415 | return glyph; 416 | } 417 | const x1 = newStroke.value[3]; 418 | const y1 = newStroke.value[4]; 419 | const x2 = newStroke.value[5]; 420 | const y2 = newStroke.value[6]; 421 | if (y1 !== y2) { 422 | return glyph; 423 | } 424 | const minY = y1 - 10; 425 | const maxY = y1 + 10; 426 | glyph.forEach((gLine, lineIndex) => { 427 | if (gLine.value[0] !== 1 || gLine.value[2] !== 0) { 428 | return; 429 | } 430 | const sx1 = gLine.value[3]; 431 | const sy1 = gLine.value[4]; 432 | const sx2 = gLine.value[5]; 433 | const sy2 = gLine.value[6]; 434 | if (sx2 - sx1 > Math.abs(sy2 - sy1)) { 435 | return; 436 | } 437 | if (minY <= sy2 && sy2 <= maxY && x1 <= sx2 && sx2 <= x2) { 438 | glyph = setGlyphValue(glyph, lineIndex, 2, 32); 439 | glyph = setGlyphValue(glyph, lineIndex, 6, y1); 440 | } 441 | }); 442 | return glyph; 443 | }; 444 | --------------------------------------------------------------------------------