├── .cargo └── config.toml ├── .eslintrc.json ├── .gitignore ├── .husky └── pre-commit ├── .nvmrc ├── .prettierignore ├── .taurignore ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── apps ├── browser │ └── README.md └── desktop │ ├── app │ ├── App.css │ ├── App.tsx │ └── routes.ts │ ├── assets │ ├── fonts │ │ └── Inter-VariableFont.ttf │ └── toolbar │ │ ├── grid.svg │ │ ├── hand.svg │ │ ├── info.svg │ │ ├── pen.svg │ │ ├── select.svg │ │ └── shape.svg │ ├── components.json │ ├── components │ ├── EditorView.tsx │ ├── GlyphGrid.tsx │ ├── InteractiveScene.tsx │ ├── Layout.tsx │ ├── Metrics.tsx │ ├── NavigationPane.tsx │ ├── SidePane.tsx │ ├── StaticScene.tsx │ ├── Titlebar.tsx │ ├── Toolbar.tsx │ ├── ToolsPane.tsx │ └── ui │ │ └── tooltip.tsx │ ├── context │ └── CanvasContext.tsx │ ├── data │ └── charsets │ │ ├── adobe-latin-1.ts │ │ └── index.ts │ ├── index.css │ ├── index.html │ ├── lib │ ├── core │ │ ├── Contour.test.ts │ │ ├── Contour.ts │ │ ├── EditActions.test.ts │ │ ├── EditActions.ts │ │ ├── EditEngine.test.ts │ │ ├── EditEngine.ts │ │ ├── EntityId.ts │ │ ├── EventEmitter.test.ts │ │ ├── EventEmitter.ts │ │ ├── PatternMatcher.test.ts │ │ ├── PatternMatcher.ts │ │ ├── PatternParser.test.ts │ │ ├── PatternParser.ts │ │ ├── RuleTable.ts │ │ ├── UndoManager.ts │ │ └── common.ts │ ├── editor │ │ ├── ContourManager.ts │ │ ├── Editor.ts │ │ ├── FrameHandler.ts │ │ ├── Painter.ts │ │ ├── Scene.ts │ │ └── Viewport.ts │ ├── graphics │ │ ├── Path.ts │ │ └── backends │ │ │ ├── CanvasKitRenderer.ts │ │ │ └── errors.ts │ ├── math │ │ ├── circle.ts │ │ ├── line.ts │ │ ├── point.ts │ │ ├── rect.test.ts │ │ ├── rect.ts │ │ ├── shape.ts │ │ └── vector.ts │ ├── styles │ │ └── style.ts │ ├── tools │ │ ├── Hand.ts │ │ ├── Pen.ts │ │ ├── Select.ts │ │ ├── Shape.ts │ │ └── tools.ts │ ├── utils.ts │ └── utils │ │ └── utils.ts │ ├── main.tsx │ ├── package.json │ ├── postcss.config.js │ ├── public │ ├── canvaskit.wasm │ └── cursors │ │ ├── pen@1.svg │ │ └── pen@2.svg │ ├── store │ └── store.ts │ ├── tailwind.config.js │ ├── tsconfig.json │ ├── tsconfig.node.json │ ├── types │ ├── common.ts │ ├── edit.ts │ ├── events.ts │ ├── graphics.ts │ ├── handle.ts │ ├── math.ts │ ├── nudge.ts │ ├── segments.ts │ ├── svg.d.ts │ └── tool.ts │ ├── views │ ├── Editor.tsx │ ├── FontInfo.tsx │ └── Home.tsx │ ├── vite-env.d.ts │ └── vite.config.ts ├── crates ├── shift-editor │ ├── Cargo.toml │ └── src │ │ ├── editor.rs │ │ └── lib.rs ├── shift-events │ ├── Cargo.toml │ └── src │ │ ├── events.rs │ │ └── lib.rs ├── shift-font │ ├── Cargo.toml │ └── src │ │ ├── contour.rs │ │ ├── entity.rs │ │ ├── font.rs │ │ ├── font_service.rs │ │ ├── glyph.rs │ │ ├── lib.rs │ │ ├── otf_ttf.rs │ │ ├── path.rs │ │ └── ufo.rs ├── shift-tauri │ ├── .gitignore │ ├── Cargo.lock │ ├── Cargo.toml │ ├── build.rs │ ├── capabilities │ │ └── default.json │ ├── icons │ │ ├── 128x128.png │ │ ├── 128x128@2x.png │ │ ├── 32x32.png │ │ ├── Square107x107Logo.png │ │ ├── Square142x142Logo.png │ │ ├── Square150x150Logo.png │ │ ├── Square284x284Logo.png │ │ ├── Square30x30Logo.png │ │ ├── Square310x310Logo.png │ │ ├── Square44x44Logo.png │ │ ├── Square71x71Logo.png │ │ ├── Square89x89Logo.png │ │ ├── StoreLogo.png │ │ ├── android │ │ │ ├── mipmap-hdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ ├── ic_launcher_foreground.png │ │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-mdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ ├── ic_launcher_foreground.png │ │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-xhdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ ├── ic_launcher_foreground.png │ │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-xxhdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ ├── ic_launcher_foreground.png │ │ │ │ └── ic_launcher_round.png │ │ │ └── mipmap-xxxhdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ ├── ic_launcher_foreground.png │ │ │ │ └── ic_launcher_round.png │ │ ├── icon.icns │ │ ├── icon.ico │ │ ├── icon.png │ │ └── ios │ │ │ ├── AppIcon-20x20@1x.png │ │ │ ├── AppIcon-20x20@2x-1.png │ │ │ ├── AppIcon-20x20@2x.png │ │ │ ├── AppIcon-20x20@3x.png │ │ │ ├── AppIcon-29x29@1x.png │ │ │ ├── AppIcon-29x29@2x-1.png │ │ │ ├── AppIcon-29x29@2x.png │ │ │ ├── AppIcon-29x29@3x.png │ │ │ ├── AppIcon-40x40@1x.png │ │ │ ├── AppIcon-40x40@2x-1.png │ │ │ ├── AppIcon-40x40@2x.png │ │ │ ├── AppIcon-40x40@3x.png │ │ │ ├── AppIcon-512@2x.png │ │ │ ├── AppIcon-60x60@2x.png │ │ │ ├── AppIcon-60x60@3x.png │ │ │ ├── AppIcon-76x76@1x.png │ │ │ ├── AppIcon-76x76@2x.png │ │ │ └── AppIcon-83.5x83.5@2x.png │ ├── src │ │ ├── commands.rs │ │ ├── core.rs │ │ ├── lib.rs │ │ ├── main.rs │ │ ├── menu.rs │ │ └── shortcuts.rs │ └── tauri.conf.json └── shift-unicode │ ├── Cargo.toml │ ├── build.rs │ └── src │ ├── lib.rs │ └── unicode.rs ├── gen ├── README.md └── charsets │ ├── .gitignore │ ├── .python-version │ ├── README.md │ ├── charsets.py │ ├── main.py │ ├── pyproject.toml │ └── uv.lock ├── package.json ├── packages └── shared │ ├── README.md │ ├── package.json │ ├── scripts │ └── exports-ts-rs.js │ ├── src │ ├── index.ts │ └── types │ │ ├── EntityId.ts │ │ ├── Font.ts │ │ ├── FontCompiledEvent.ts │ │ ├── FontLoadedEvent.ts │ │ ├── FontMetadata.ts │ │ ├── Glyph.ts │ │ ├── IContour.ts │ │ ├── IContourPoint.ts │ │ ├── Metrics.ts │ │ ├── MovedPoint.ts │ │ ├── PathCommand.ts │ │ ├── PointType.ts │ │ ├── PointsAddedEvent.ts │ │ └── PointsMovedEvent.ts │ └── tsconfig.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── scripts └── rebuild_types.sh └── turbo.json /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [env] 2 | TS_RS_EXPORT_DIR = { value = "./packages/shared/src/types", relative = true } 3 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "plugins": [ 4 | "@typescript-eslint", 5 | "import", 6 | "react", 7 | "react-hooks" 8 | ], 9 | "extends": [ 10 | "eslint:recommended", 11 | "plugin:@typescript-eslint/recommended", 12 | "plugin:import/recommended", 13 | "plugin:import/typescript", 14 | "plugin:react/recommended", 15 | "plugin:react-hooks/recommended" 16 | ], 17 | "settings": { 18 | "react": { 19 | "version": "detect" 20 | }, 21 | "import/resolver": { 22 | "typescript": { 23 | "alwaysTryTypes": true, 24 | "project": "./apps/desktop/tsconfig.json" 25 | }, 26 | "alias": { 27 | "map": [ 28 | ["@", "./apps/desktop"], 29 | ["@/components", "./apps/desktop/components"], 30 | ["@/lib", "./apps/desktop/lib"], 31 | ["@/store", "./apps/desktop/store"], 32 | ["@/types", "./apps/desktop/types"], 33 | ["@/context", "./apps/desktop/context"], 34 | ["@/assets", "./apps/desktop/assets"], 35 | ["@/hooks", "./apps/desktop/hooks"] 36 | ], 37 | "extensions": [".ts", ".tsx", ".js", ".jsx", ".json"] 38 | } 39 | } 40 | }, 41 | "rules": { 42 | // Import sorting 43 | "import/order": [ 44 | "error", 45 | { 46 | "groups": [ 47 | "builtin", 48 | "external", 49 | "internal", 50 | ["parent", "sibling"], 51 | "index", 52 | "object", 53 | "type" 54 | ], 55 | "pathGroups": [ 56 | { 57 | "pattern": "react", 58 | "group": "builtin", 59 | "position": "before" 60 | }, 61 | { 62 | "pattern": "@tauri-uis/**", 63 | "group": "external", 64 | "position": "after" 65 | } 66 | ], 67 | "pathGroupsExcludedImportTypes": ["react"], 68 | "newlines-between": "always", 69 | "alphabetize": { 70 | "order": "asc", 71 | "caseInsensitive": true 72 | } 73 | } 74 | ], 75 | // TypeScript specific rules 76 | "@typescript-eslint/no-explicit-any": "warn", 77 | "@typescript-eslint/explicit-function-return-type": "off", 78 | "@typescript-eslint/explicit-module-boundary-types": "off", 79 | "@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }], 80 | "@typescript-eslint/naming-convention": [ 81 | "error", 82 | { 83 | "selector": "interface", 84 | "format": ["PascalCase"] 85 | }, 86 | { 87 | "selector": "typeAlias", 88 | "format": ["PascalCase"] 89 | }, 90 | { 91 | "selector": "enum", 92 | "format": ["PascalCase"] 93 | } 94 | ], 95 | // React specific rules 96 | "react/react-in-jsx-scope": "off", 97 | "react/prop-types": "warn", 98 | "react-hooks/rules-of-hooks": "error", 99 | "react-hooks/exhaustive-deps": "off" 100 | } 101 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | !.vscode/extensions.json 17 | .idea 18 | .DS_Store 19 | *.suo 20 | *.ntvs* 21 | *.njsproj 22 | *.sln 23 | *.sw? 24 | .cursorrules 25 | .vscode 26 | 27 | target* 28 | .turbo 29 | .bin -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | pnpm format 2 | pnpm lint 3 | pnpm test 4 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | lts/jod -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | build 4 | src-tauri -------------------------------------------------------------------------------- /.taurignore: -------------------------------------------------------------------------------- 1 | !crates/* -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | resolver = "2" 3 | members = ["crates/*"] 4 | 5 | 6 | [workspace.dependencies] 7 | shift-events = { path = "crates/shift-events" } 8 | shift-font = { path = "crates/shift-font" } 9 | shift-editor = { path = "crates/shift-editor" } 10 | shift-unicode = { path = "crates/shift-unicode" } 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

3 | 4 |

Shift

6 |

A modern, cross-platform font editor built with TypeScript and Rust, focused on bringing contemporary technologies and design principles to type design.

7 | 8 | ![image](https://github.com/user-attachments/assets/ff850488-3413-4b46-a4c8-c2344db0dc0e) 9 | 10 |

11 |
12 | 13 | ## Why Shift? 14 | 15 | Shift aims to redefine font editing by combining the power of Rust for performance-critical tasks with the flexibility of web-based UI technologies. Whether you're a type designer or a developer, Shift offers a fresh approach to creating and editing fonts with a focus on speed, precision, and extensibility. 16 | 17 | > [!IMPORTANT] 18 | > Shift is in a pre-alpha state and is currently only suitable for developers interested in contributing to the project 19 | 20 | ## Architecture 21 | 22 | Shift uses the Tauri framework: 23 | 24 | - **UI**: Uses React and Vite for components around the canvas 25 | - **Rendering**: Webview based UI utilising the HTML canvas and rendered with CanvasKit (Skia) for high-quality graphics 26 | - **State management**: Zustland for global React state mangement 27 | - **Backend**: Rust for high-intensive operations and font related processing 28 | 29 | ## Getting Started 30 | 31 | ### Prerequisites 32 | 33 | - **Rust** (1.70 or later): [Install Rust](https://www.rust-lang.org/tools/install) 34 | - **pnpm** (1.0 or later): [Install pnpm](https://pnpm.io/installation) 35 | - **typescript** (5.8 or later, also known as tsc): [Install typescript](https://www.typescriptlang.org/download) 36 | - **node.js** (23.10 or later): [Install node.js](https://nodejs.org/en/download) 37 | - **System Dependencies**: 38 | - **Windows**: Microsoft Visual C++ Build Tools, WebView2 39 | - **macOS**: Xcode Command Line Tools (`xcode-select --install`) 40 | - **Linux**: WebKit2GTK (`libwebkit2gtk-4.0-dev`) and build essentials 41 | 42 | ### Quick Start 43 | 44 | 1. **Clone the repository**: 45 | 46 | ```bash 47 | git clone https://github.com/shift-editor/shift.git 48 | cd shift 49 | ``` 50 | 51 | 2. **Install dependencies**: 52 | 53 | ```bash 54 | pnpm install 55 | ``` 56 | 57 | 3. **Run the development server**: 58 | 59 | ```bash 60 | pnpm dev:app 61 | ``` 62 | 63 | ### Build for Production 64 | 65 | ```bash 66 | pnpm build:app 67 | 68 | ``` 69 | 70 | ### Common Issues 71 | 72 | - If you encounter build errors, ensure you have all system dependencies installed 73 | - For Linux users, make sure WebKit2GTK development libraries are installed 74 | - For detailed troubleshooting, check the [Tauri docs](https://v1.tauri.app/v1/guides/getting-started/prerequisites/) 75 | 76 | ## Development Roadmap 77 | 78 | We aim to implement the typical features present in font editors such as FontForge, Glyphs, RobotFont etc. 79 | 80 | ## License 81 | 82 | [GNU General Public License (GPL) v3.0](https://www.gnu.org/licenses/gpl-3.0.en.html) 83 | 84 | Copyright © 2025 Kostya Farber. All rights reserved. 85 | -------------------------------------------------------------------------------- /apps/browser/README.md: -------------------------------------------------------------------------------- 1 | future potential to ship this in the browser 2 | -------------------------------------------------------------------------------- /apps/desktop/app/App.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: var(--color-secondary); 3 | overflow: hidden; 4 | } 5 | 6 | @font-face { 7 | font-family: 'Inter'; 8 | src: url('/assets/fonts/Inter-VariableFont.ttf') format('truetype'); 9 | font-weight: normal; 10 | font-style: normal; 11 | } 12 | 13 | .cursor-pen { 14 | cursor: 15 | url('/cursors/pen@1.svg') 15 8, 16 | pointer; 17 | cursor: 18 | -webkit-image-set(url('/cursors/pen@2.svg') 2x) 15 8, 19 | pointer; 20 | } 21 | 22 | .cursor-hand { 23 | cursor: move; 24 | cursor: grab; 25 | cursor: -moz-grab; 26 | cursor: -webkit-grab; 27 | } 28 | 29 | .cursor-hand:active { 30 | cursor: grabbing; 31 | cursor: -moz-grabbing; 32 | cursor: -webkit-grabbing; 33 | } 34 | -------------------------------------------------------------------------------- /apps/desktop/app/App.tsx: -------------------------------------------------------------------------------- 1 | import './App.css'; 2 | import { HashRouter, Route, Routes } from 'react-router-dom'; 3 | 4 | import { routes } from './routes'; 5 | 6 | export const App = () => { 7 | return ( 8 | 9 | 10 | {routes.map((route) => ( 11 | } /> 12 | ))} 13 | 14 | 15 | ); 16 | }; 17 | 18 | export default App; 19 | -------------------------------------------------------------------------------- /apps/desktop/app/routes.ts: -------------------------------------------------------------------------------- 1 | import GridSvg from '@/assets/toolbar/grid.svg'; 2 | import InfoSvg from '@/assets/toolbar/info.svg'; 3 | import { Svg } from '@/types/common'; 4 | import { Editor } from '@/views/Editor'; 5 | import { FontInfo } from '@/views/FontInfo'; 6 | import { Home } from '@/views/Home'; 7 | 8 | interface Route { 9 | id: string; 10 | path: string; 11 | component: React.ComponentType; 12 | description: string; 13 | icon?: Svg; 14 | } 15 | 16 | export const routes: Route[] = [ 17 | { 18 | id: 'home', 19 | path: '/', 20 | icon: GridSvg, 21 | component: Home, 22 | description: 'Display all glyphs', 23 | }, 24 | { 25 | id: 'info', 26 | path: '/info', 27 | icon: InfoSvg, 28 | component: FontInfo, 29 | description: 'Display and edit font information, such as family name, weight, style, etc.', 30 | }, 31 | { 32 | id: 'editor', 33 | path: '/editor/:glyphId', 34 | component: Editor, 35 | description: 'Display and edit a glyph', 36 | }, 37 | ]; 38 | -------------------------------------------------------------------------------- /apps/desktop/assets/fonts/Inter-VariableFont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shift-editor/shift/ed519408f54119ea83678ee2390cea7e145148be/apps/desktop/assets/fonts/Inter-VariableFont.ttf -------------------------------------------------------------------------------- /apps/desktop/assets/toolbar/grid.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /apps/desktop/assets/toolbar/hand.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /apps/desktop/assets/toolbar/info.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /apps/desktop/assets/toolbar/pen.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /apps/desktop/assets/toolbar/select.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /apps/desktop/assets/toolbar/shape.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /apps/desktop/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "index.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } 22 | -------------------------------------------------------------------------------- /apps/desktop/components/EditorView.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useEffect } from 'react'; 2 | 3 | import { Glyph, Metrics } from '@shift/shared'; 4 | import { invoke } from '@tauri-apps/api/core'; 5 | 6 | import { CanvasContextProvider } from '@/context/CanvasContext'; 7 | import AppState, { getEditor } from '@/store/store'; 8 | 9 | import { InteractiveScene } from './InteractiveScene'; 10 | import { Metrics as MetricsComponent } from './Metrics'; 11 | import { StaticScene } from './StaticScene'; 12 | 13 | interface EditorViewProps { 14 | glyphId: string; 15 | } 16 | 17 | export const EditorView: FC = ({ glyphId }) => { 18 | const activeTool = AppState((state) => state.activeTool); 19 | const editor = getEditor(); 20 | 21 | useEffect(() => { 22 | const fetchFontData = async () => { 23 | const [metrics, glyph] = await Promise.all([ 24 | invoke('get_font_metrics'), 25 | invoke('get_glyph', { 26 | unicode: parseInt(glyphId, 16), 27 | }), 28 | ]); 29 | 30 | const guides = { 31 | ascender: { y: metrics.ascender }, 32 | capHeight: { y: metrics.capHeight }, 33 | xHeight: { y: metrics.xHeight }, 34 | descender: { y: metrics.descender }, 35 | baseline: { y: 0 }, 36 | xAdvance: glyph.x_advance, 37 | }; 38 | 39 | editor.constructGuidesPath(guides); 40 | editor.setViewportUpm(metrics.unitsPerEm); 41 | 42 | editor.loadContours(glyph.contours); 43 | editor.redrawGlyph(); 44 | }; 45 | 46 | fetchFontData(); 47 | 48 | editor.activeTool().setReady(); 49 | 50 | return () => { 51 | editor.activeTool().setIdle(); 52 | editor.clearContours(); 53 | }; 54 | }, [glyphId]); 55 | 56 | const onWheel = (e: React.WheelEvent) => { 57 | e.preventDefault(); 58 | 59 | const pan = editor.getPan(); 60 | 61 | const dx = e.deltaX; 62 | const dy = e.deltaY; 63 | 64 | editor.pan(pan.x - dx, pan.y - dy); 65 | editor.requestRedraw(); 66 | }; 67 | 68 | return ( 69 |
{ 73 | const position = editor.getMousePosition(e.clientX, e.clientY); 74 | const { x, y } = editor.projectScreenToUpm(position.x, position.y); 75 | editor.setUpmMousePosition(x, y); 76 | }} 77 | > 78 | 79 | 80 | 81 | 82 | 83 |
84 | ); 85 | }; 86 | -------------------------------------------------------------------------------- /apps/desktop/components/GlyphGrid.tsx: -------------------------------------------------------------------------------- 1 | import { useNavigate } from 'react-router-dom'; 2 | 3 | import { ADOBE_LATIN_1 } from '@/data/charsets'; 4 | 5 | export const GlyphGrid = () => { 6 | const navigate = useNavigate(); 7 | 8 | return ( 9 |
10 |
11 |

Adobe Latin 1

12 |
13 |
14 | {Object.values(ADOBE_LATIN_1).map((glyph) => { 15 | return ( 16 |
17 | 23 |
24 | ); 25 | })} 26 |
27 |
28 | ); 29 | }; 30 | -------------------------------------------------------------------------------- /apps/desktop/components/InteractiveScene.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | 3 | import { CanvasContext } from '@/context/CanvasContext'; 4 | import AppState from '@/store/store'; 5 | 6 | export const InteractiveScene = () => { 7 | const { interactiveCanvasRef } = useContext(CanvasContext); 8 | const editor = AppState.getState().editor; 9 | const activeTool = editor.activeTool(); 10 | 11 | return ( 12 | { 17 | activeTool.onMouseDown(e); 18 | }} 19 | onMouseUp={(e) => { 20 | activeTool.onMouseUp(e); 21 | }} 22 | onMouseMove={(e) => { 23 | activeTool.onMouseMove(e); 24 | }} 25 | onDoubleClick={(e) => { 26 | if (!activeTool.onDoubleClick) return; 27 | 28 | activeTool.onDoubleClick(e); 29 | }} 30 | /> 31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /apps/desktop/components/Layout.tsx: -------------------------------------------------------------------------------- 1 | export const Layout = () => {}; 2 | -------------------------------------------------------------------------------- /apps/desktop/components/Metrics.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | 3 | import { getEditor } from '@/store/store'; 4 | 5 | export const Metrics = () => { 6 | const xRef = useRef(null); 7 | const yRef = useRef(null); 8 | const editor = getEditor(); 9 | 10 | useEffect(() => { 11 | const updateMouseMetrics = () => { 12 | if (!xRef.current || !yRef.current) return; 13 | 14 | const { x, y } = editor.getUpmMousePosition(); 15 | 16 | xRef.current.textContent = Math.round(x).toString(); 17 | yRef.current.textContent = Math.round(y).toString(); 18 | }; 19 | 20 | window.addEventListener('mousemove', updateMouseMetrics); 21 | 22 | return () => { 23 | window.removeEventListener('mousemove', updateMouseMetrics); 24 | }; 25 | }, []); 26 | 27 | return ( 28 | <> 29 |
30 |
x
31 |
32 |
y
33 |
34 |
35 | 36 | ); 37 | }; 38 | -------------------------------------------------------------------------------- /apps/desktop/components/NavigationPane.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react'; 2 | 3 | import { useNavigate } from 'react-router-dom'; 4 | 5 | import { routes } from '@/app/routes'; 6 | import { Svg } from '@/types/common'; 7 | 8 | export interface NavigationItemProps { 9 | Icon: Svg; 10 | onClick: () => void; 11 | } 12 | export const NavigationItem: FC = ({ Icon, onClick }) => { 13 | return ( 14 |
15 | 16 |
17 | ); 18 | }; 19 | export const NavigationPane = () => { 20 | const navigate = useNavigate(); 21 | 22 | return ( 23 |
24 |
25 | {routes.map((route) => { 26 | if (route.icon) { 27 | return ( 28 | navigate(route.path)} 32 | /> 33 | ); 34 | } 35 | return null; 36 | })} 37 |
38 |
39 | ); 40 | }; 41 | -------------------------------------------------------------------------------- /apps/desktop/components/SidePane.tsx: -------------------------------------------------------------------------------- 1 | export const SidePane = () => { 2 | return ( 3 |
4 |
5 |

Position

6 |
7 |
8 | ); 9 | }; 10 | -------------------------------------------------------------------------------- /apps/desktop/components/StaticScene.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | 3 | import { CanvasContext } from '@/context/CanvasContext'; 4 | 5 | export const StaticScene = () => { 6 | const { staticCanvasRef } = useContext(CanvasContext); 7 | 8 | return ( 9 | 14 | ); 15 | }; 16 | -------------------------------------------------------------------------------- /apps/desktop/components/Titlebar.tsx: -------------------------------------------------------------------------------- 1 | export const Titlebar = () => {}; 2 | -------------------------------------------------------------------------------- /apps/desktop/components/Toolbar.tsx: -------------------------------------------------------------------------------- 1 | import { NavigationPane } from './NavigationPane'; 2 | import { ToolsPane } from './ToolsPane'; 3 | 4 | export const Toolbar = () => { 5 | return ( 6 |
7 | 8 | 9 |
10 |
11 | ); 12 | }; 13 | -------------------------------------------------------------------------------- /apps/desktop/components/ToolsPane.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useEffect } from 'react'; 2 | 3 | import { FontLoadedEvent } from '@shift/shared'; 4 | import { listen } from '@tauri-apps/api/event'; 5 | 6 | import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; 7 | import { tools } from '@/lib/tools/tools'; 8 | import AppState, { getEditor } from '@/store/store'; 9 | import { Svg } from '@/types/common'; 10 | import { ToolName } from '@/types/tool'; 11 | 12 | interface ToolbarIconProps { 13 | Icon: Svg; 14 | name: ToolName; 15 | tooltip: string; 16 | activeTool: ToolName; 17 | onClick: () => void; 18 | } 19 | export const ToolbarIcon: FC = ({ Icon, name, tooltip, activeTool, onClick }) => { 20 | return ( 21 | 22 | 23 |
29 | 30 |
31 |
32 | 33 |

{tooltip}

34 |
35 |
36 | ); 37 | }; 38 | 39 | export const ToolsPane: FC = () => { 40 | const activeTool = AppState((state) => state.activeTool); 41 | const setActiveTool = AppState((state) => state.setActiveTool); 42 | const editor = getEditor(); 43 | 44 | const fileName = AppState((state) => state.fileName); 45 | 46 | useEffect(() => { 47 | const unsubscribe = listen('font:loaded', (event) => { 48 | AppState.setState({ fileName: event.payload.fileName }); 49 | }); 50 | 51 | return () => { 52 | unsubscribe.then((fn) => fn()); 53 | }; 54 | }, []); 55 | 56 | return ( 57 |
58 |

{fileName}

59 | 60 |
61 | {Array.from(tools.entries()).map(([name, { icon, tooltip }]) => ( 62 | { 69 | setActiveTool(name); 70 | editor.activeTool().setReady(); 71 | }} 72 | /> 73 | ))} 74 |
75 |
76 |
77 | ); 78 | }; 79 | -------------------------------------------------------------------------------- /apps/desktop/components/ui/tooltip.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import * as TooltipPrimitive from '@radix-ui/react-tooltip'; 4 | 5 | import { cn } from '@/lib/utils'; 6 | 7 | function TooltipProvider({ 8 | delayDuration = 0, 9 | ...props 10 | }: React.ComponentProps) { 11 | return ( 12 | 17 | ); 18 | } 19 | 20 | function Tooltip({ ...props }: React.ComponentProps) { 21 | return ( 22 | 23 | 24 | 25 | ); 26 | } 27 | 28 | function TooltipTrigger({ ...props }: React.ComponentProps) { 29 | return ; 30 | } 31 | 32 | function TooltipContent({ 33 | className, 34 | sideOffset = 0, 35 | children, 36 | ...props 37 | }: React.ComponentProps) { 38 | return ( 39 | 40 | 49 | {children} 50 | 51 | 52 | ); 53 | } 54 | 55 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; 56 | -------------------------------------------------------------------------------- /apps/desktop/context/CanvasContext.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useEffect, useRef, useState } from 'react'; 2 | 3 | import { CanvasKit } from 'canvaskit-wasm'; 4 | 5 | import { CanvasKitContext, initCanvasKit } from '@/lib/graphics/backends/CanvasKitRenderer'; 6 | import AppState from '@/store/store'; 7 | import { CanvasRef } from '@/types/graphics'; 8 | 9 | interface CanvasContext { 10 | interactiveCanvasRef: CanvasRef; 11 | staticCanvasRef: CanvasRef; 12 | } 13 | 14 | export const CanvasContext = createContext({ 15 | interactiveCanvasRef: { current: null }, 16 | staticCanvasRef: { current: null }, 17 | }); 18 | 19 | export const CanvasContextProvider = ({ children }: { children: React.ReactNode }) => { 20 | const interactiveCanvasRef = useRef(null); 21 | const staticCanvasRef = useRef(null); 22 | 23 | useEffect(() => { 24 | const initCanvas = (canvasKit: CanvasKit, canvas: HTMLCanvasElement) => { 25 | const ctx = new CanvasKitContext(canvasKit); 26 | 27 | ctx.resizeCanvas(canvas); 28 | 29 | return ctx; 30 | }; 31 | 32 | const editor = AppState.getState().editor; 33 | 34 | const setUpContexts = async ({ 35 | interactiveCanvas, 36 | staticCanvas, 37 | }: { 38 | interactiveCanvas: HTMLCanvasElement; 39 | staticCanvas: HTMLCanvasElement; 40 | }) => { 41 | const canvasKit = await initCanvasKit(); 42 | const interactiveContext = initCanvas(canvasKit, interactiveCanvas); 43 | const staticContext = initCanvas(canvasKit, staticCanvas); 44 | 45 | editor.setInteractiveContext(interactiveContext); 46 | editor.setStaticContext(staticContext); 47 | 48 | const resizeCanvas = (entries: ResizeObserverEntry[]) => { 49 | const [interactiveCanvas, staticCanvas] = entries; 50 | 51 | interactiveContext.resizeCanvas(interactiveCanvas.target as HTMLCanvasElement); 52 | staticContext.resizeCanvas(staticCanvas.target as HTMLCanvasElement); 53 | 54 | editor.requestImmediateRedraw(); 55 | }; 56 | 57 | const observer = new ResizeObserver(resizeCanvas); 58 | 59 | observer.observe(interactiveCanvas); 60 | observer.observe(staticCanvas); 61 | 62 | return () => { 63 | observer.disconnect(); 64 | }; 65 | }; 66 | 67 | if (!interactiveCanvasRef.current || !staticCanvasRef.current) return; 68 | 69 | setUpContexts({ 70 | interactiveCanvas: interactiveCanvasRef.current, 71 | staticCanvas: staticCanvasRef.current, 72 | }); 73 | }, []); 74 | 75 | return ( 76 | 82 | {children} 83 | 84 | ); 85 | }; 86 | -------------------------------------------------------------------------------- /apps/desktop/data/charsets/index.ts: -------------------------------------------------------------------------------- 1 | import { ADOBE_LATIN_1 } from './adobe-latin-1'; 2 | 3 | export { ADOBE_LATIN_1 }; 4 | 5 | export const CHARSETS = { 6 | ADOBE_LATIN_1, 7 | }; 8 | -------------------------------------------------------------------------------- /apps/desktop/index.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss'; 2 | @import 'tw-animate-css'; 3 | 4 | @theme { 5 | --font-ui: 'Inter'; 6 | --text-ui: 0.85rem; 7 | --color-secondary: #3a3a3a; 8 | } 9 | -------------------------------------------------------------------------------- /apps/desktop/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Shift 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /apps/desktop/lib/core/EditActions.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'vitest'; 2 | 3 | describe('maintainTangency', () => { 4 | it('should maintain tangency when moving a selected point', () => {}); 5 | }); 6 | -------------------------------------------------------------------------------- /apps/desktop/lib/core/EditActions.ts: -------------------------------------------------------------------------------- 1 | import { Vector2D } from '@/lib/math/vector'; 2 | import { AppliedEdit, Edit } from '@/types/edit'; 3 | 4 | import { ContourPoint } from './Contour'; 5 | import { EditContext } from './EditEngine'; 6 | 7 | export const MaintainTangency = ( 8 | ctx: EditContext, 9 | anchor: ContourPoint, 10 | selected: ContourPoint, 11 | opposite: ContourPoint, 12 | dx: number, 13 | dy: number 14 | ): AppliedEdit => { 15 | // Get the original magnitude of the opposite handle - this must be preserved 16 | const oppositeMagnitude = new Vector2D(opposite.x - anchor.x, opposite.y - anchor.y).length(); 17 | const newSelectedVector = new Vector2D(selected.x - anchor.x, selected.y - anchor.y); 18 | 19 | // Create the opposite vector: opposite direction, preserving original magnitude 20 | const vectorLength = newSelectedVector.length(); 21 | let newOppositeVector: Vector2D; 22 | 23 | if (vectorLength < 1e-10) { 24 | // Handle edge case: if the selected vector is essentially zero 25 | const originalOppositeVector = new Vector2D(opposite.x - anchor.x, opposite.y - anchor.y); 26 | newOppositeVector = originalOppositeVector; // Keep original direction 27 | } else { 28 | const normalizedDirection = newSelectedVector.normalize(); 29 | newOppositeVector = normalizedDirection.multiply(-oppositeMagnitude); 30 | } 31 | 32 | // Calculate final position for opposite handle 33 | const newOppositePos = new Vector2D( 34 | anchor.x + newOppositeVector.x, 35 | anchor.y + newOppositeVector.y 36 | ); 37 | 38 | // Create edits - selected point is already moved, so just record the edit 39 | const selectedEdit: Edit = { 40 | point: selected, 41 | from: { x: selected.x - dx, y: selected.y - dy }, // Original position before the first move 42 | to: { x: selected.x, y: selected.y }, // Current position (already moved) 43 | }; 44 | 45 | const oppositeEdit: Edit = { 46 | point: opposite, 47 | from: { x: opposite.x, y: opposite.y }, 48 | to: { x: newOppositePos.x, y: newOppositePos.y }, 49 | }; 50 | 51 | // Only move the opposite handle (selected is already moved) 52 | ctx.movePointTo(opposite, newOppositePos.x, newOppositePos.y); 53 | 54 | return { 55 | point: anchor, 56 | edits: [selectedEdit, oppositeEdit], 57 | affectedPoints: [selected, opposite], 58 | }; 59 | }; 60 | -------------------------------------------------------------------------------- /apps/desktop/lib/core/EditEngine.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, expect, it } from 'vitest'; 2 | 3 | import { Contour, ContourPoint } from './Contour'; 4 | import { EditEngine } from './EditEngine'; 5 | 6 | describe('EditEngine', () => { 7 | let editEngine: EditEngine; 8 | let mockContext: { 9 | getSelectedPoints: () => Set; 10 | movePointTo: (point: ContourPoint, dx: number, dy: number) => void; 11 | }; 12 | let contour: Contour; 13 | 14 | beforeEach(() => { 15 | contour = new Contour(); 16 | mockContext = { 17 | getSelectedPoints: () => new Set(), 18 | movePointTo: (point: ContourPoint, x: number, y: number) => { 19 | point.set_x(x); 20 | point.set_y(y); 21 | }, 22 | }; 23 | editEngine = new EditEngine(mockContext); 24 | }); 25 | 26 | describe('applyEdits', () => { 27 | it('should move selected points directly', () => { 28 | // Create a simple contour with 3 points 29 | contour.addPoint(0, 0, 'onCurve'); 30 | contour.addPoint(100, 0, 'onCurve'); 31 | contour.addPoint(200, 0, 'onCurve'); 32 | contour.close(); 33 | 34 | // Select the middle point 35 | const selectedPoints = new Set([contour.points[1]]); 36 | mockContext.getSelectedPoints = () => selectedPoints; 37 | 38 | // Apply edit 39 | const edits = editEngine.applyEdits(10, 20); 40 | 41 | // Verify the point was moved 42 | expect(contour.points[1].x).toBe(110); 43 | expect(contour.points[1].y).toBe(20); 44 | expect(edits.length).toBe(1); 45 | expect(edits[0].point).toBe(contour.points[1]); 46 | expect(edits[0].edits.length).toBe(1); 47 | expect(edits[0].edits[0].from).toEqual({ x: 100, y: 0 }); 48 | expect(edits[0].edits[0].to).toEqual({ x: 110, y: 20 }); 49 | }); 50 | 51 | it('should apply handle movement rules', () => { 52 | // Create a contour with a handle point 53 | contour.addPoint(0, 0, 'offCurve'); // Handle 54 | contour.addPoint(100, 0, 'onCurve'); // Anchor 55 | contour.addPoint(200, 0, 'offCurve'); // Handle 56 | contour.close(); 57 | 58 | // Select the anchor point 59 | const selectedPoints = new Set([contour.points[1]]); 60 | mockContext.getSelectedPoints = () => selectedPoints; 61 | 62 | // Apply edit 63 | const edits = editEngine.applyEdits(10, 20); 64 | 65 | // Verify both handles were moved 66 | expect(contour.points[0].x).toBe(10); 67 | expect(contour.points[0].y).toBe(20); 68 | expect(contour.points[2].x).toBe(210); 69 | expect(contour.points[2].y).toBe(20); 70 | expect(edits.length).toBe(2); // One for direct move, one for rule application 71 | }); 72 | 73 | it('should handle multiple selected points', () => { 74 | // Create a contour with multiple points 75 | contour.addPoint(0, 0, 'onCurve'); 76 | contour.addPoint(100, 0, 'onCurve'); 77 | contour.addPoint(200, 0, 'onCurve'); 78 | contour.close(); 79 | 80 | // Select multiple points 81 | const selectedPoints = new Set([contour.points[0], contour.points[2]]); 82 | mockContext.getSelectedPoints = () => selectedPoints; 83 | 84 | // Apply edit 85 | const edits = editEngine.applyEdits(10, 20); 86 | 87 | // Verify both points were moved 88 | expect(contour.points[0].x).toBe(10); 89 | expect(contour.points[0].y).toBe(20); 90 | expect(contour.points[2].x).toBe(210); 91 | expect(contour.points[2].y).toBe(20); 92 | expect(edits.length).toBe(2); 93 | }); 94 | 95 | it('should handle points without neighbors', () => { 96 | // Create a single point 97 | contour.addPoint(0, 0, 'onCurve'); 98 | const selectedPoints = new Set([contour.points[0]]); 99 | mockContext.getSelectedPoints = () => selectedPoints; 100 | 101 | // Apply edit 102 | const edits = editEngine.applyEdits(10, 20); 103 | 104 | // Verify the point was moved 105 | expect(contour.points[0].x).toBe(10); 106 | expect(contour.points[0].y).toBe(20); 107 | expect(edits.length).toBe(1); 108 | }); 109 | }); 110 | }); 111 | -------------------------------------------------------------------------------- /apps/desktop/lib/core/EditEngine.ts: -------------------------------------------------------------------------------- 1 | import { AppliedEdit, Edit } from '@/types/edit'; 2 | 3 | import { ContourPoint } from './Contour'; 4 | import { PatternMatcher } from './PatternMatcher'; 5 | 6 | export interface EditContext { 7 | getSelectedPoints(): Set; 8 | movePointTo(point: ContourPoint, x: number, y: number): void; 9 | } 10 | 11 | export class EditEngine { 12 | #context: EditContext; 13 | #patternMatcher: PatternMatcher; 14 | 15 | public constructor(context: EditContext) { 16 | this.#context = context; 17 | this.#patternMatcher = new PatternMatcher(); 18 | } 19 | 20 | public applyEdits(dx: number, dy: number): AppliedEdit[] { 21 | const selectedPoints = this.#context.getSelectedPoints(); 22 | const edits: AppliedEdit[] = []; 23 | 24 | // move selected points 25 | for (const point of selectedPoints) { 26 | const edit: Edit = { 27 | point: point, 28 | from: { x: point.x, y: point.y }, 29 | to: { x: point.x + dx, y: point.y + dy }, 30 | }; 31 | 32 | this.#context.movePointTo(point, point.x + dx, point.y + dy); 33 | edits.push({ 34 | point: point, 35 | edits: [edit], 36 | affectedPoints: [], 37 | }); 38 | } 39 | 40 | // apply rules for affected points 41 | for (const point of selectedPoints) { 42 | const rule = this.#patternMatcher.match(point, selectedPoints); 43 | if (rule) { 44 | const edit = rule.action(this.#context, point, dx, dy); 45 | edits.push(edit); 46 | } 47 | } 48 | 49 | return edits; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /apps/desktop/lib/core/EntityId.ts: -------------------------------------------------------------------------------- 1 | export type Ident = number; 2 | const NO_PARENT_ID = 0; 3 | 4 | class Id { 5 | static #id: number = 0; 6 | 7 | static next(): Ident { 8 | this.#id++; 9 | return this.#id; 10 | } 11 | } 12 | 13 | export class EntityId { 14 | readonly parentId: Ident = NO_PARENT_ID; 15 | readonly id: Ident; 16 | 17 | constructor(parentId?: Ident) { 18 | if (parentId) { 19 | this.parentId = parentId; 20 | } 21 | this.id = Id.next(); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /apps/desktop/lib/core/EventEmitter.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach, vi } from 'vitest'; 2 | 3 | import { EventEmitter } from '@/lib/core/EventEmitter'; 4 | 5 | import { EntityId } from './EntityId'; 6 | 7 | describe('EventEmitter', () => { 8 | let emitter: EventEmitter; 9 | 10 | beforeEach(() => { 11 | emitter = new EventEmitter(); 12 | }); 13 | 14 | it('should emit events', () => { 15 | const handler = vi.fn(); 16 | 17 | emitter.on('points:added', handler); 18 | 19 | emitter.emit('points:added', [new EntityId(1)]); 20 | 21 | expect(handler).toHaveBeenCalled(); 22 | }); 23 | 24 | it('should call multiple handlers', () => { 25 | const handler1 = vi.fn(); 26 | const handler2 = vi.fn(); 27 | 28 | emitter.on('points:added', handler1); 29 | emitter.on('points:added', handler2); 30 | 31 | const pointId = new EntityId(1); 32 | 33 | emitter.emit('points:added', [pointId]); 34 | 35 | expect(handler1).toHaveBeenCalledWith([pointId]); 36 | expect(handler2).toHaveBeenCalledWith([pointId]); 37 | }); 38 | 39 | it('should remove event handlers', () => { 40 | const handler = vi.fn(); 41 | 42 | emitter.on('points:added', handler); 43 | emitter.off('points:added', handler); 44 | 45 | emitter.emit('points:added', [new EntityId(1)]); 46 | 47 | expect(handler).not.toHaveBeenCalled(); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /apps/desktop/lib/core/EventEmitter.ts: -------------------------------------------------------------------------------- 1 | import { emit, listen, UnlistenFn } from '@tauri-apps/api/event'; 2 | 3 | import { EventHandler, EventName, IEventEmitter } from '@/types/events'; 4 | 5 | export class EventEmitter implements IEventEmitter { 6 | #eventHandlers: Map[]>; 7 | 8 | constructor() { 9 | this.#eventHandlers = new Map(); 10 | } 11 | 12 | on(event: EventName, handler: EventHandler) { 13 | const handlers = this.#eventHandlers.get(event) || []; 14 | handlers.push(handler); 15 | this.#eventHandlers.set(event, handlers); 16 | } 17 | 18 | emit(event: EventName, data: T) { 19 | const handlers = this.#eventHandlers.get(event); 20 | 21 | if (!handlers) { 22 | return; 23 | } 24 | 25 | handlers.forEach((handler) => handler(data)); 26 | } 27 | 28 | off(event: EventName, handler: EventHandler) { 29 | const handlers = this.#eventHandlers.get(event); 30 | 31 | if (!handlers) { 32 | return; 33 | } 34 | 35 | this.#eventHandlers.set( 36 | event, 37 | handlers.filter((h) => h !== handler) 38 | ); 39 | } 40 | } 41 | 42 | export class TauriEventEmitter implements IEventEmitter { 43 | #unlistenFns: Map; 44 | 45 | constructor() { 46 | this.#unlistenFns = new Map(); 47 | } 48 | 49 | on(event: EventName, handler: EventHandler) { 50 | listen(event, (event) => { 51 | handler(event.payload); 52 | }).then((unlistenFn) => { 53 | this.#unlistenFns.set(event, [...(this.#unlistenFns.get(event) || []), unlistenFn]); 54 | }); 55 | } 56 | 57 | emit(event: EventName, data: T) { 58 | emit(event, data); 59 | } 60 | 61 | off(event: EventName) { 62 | const unlistenFns = this.#unlistenFns.get(event); 63 | 64 | if (!unlistenFns) { 65 | return; 66 | } 67 | 68 | unlistenFns.forEach((unlistenFn) => unlistenFn()); 69 | this.#unlistenFns.delete(event); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /apps/desktop/lib/core/PatternMatcher.ts: -------------------------------------------------------------------------------- 1 | import { ContourPoint } from './Contour'; 2 | import { TOKENS } from './PatternParser'; 3 | import { BuildRuleTable, Pattern, Rule, RuleTable } from './RuleTable'; 4 | 5 | const WINDOW_SIZES = [3, 5]; 6 | 7 | export class PatternMatcher { 8 | #ruleTable: RuleTable; 9 | 10 | public constructor() { 11 | this.#ruleTable = BuildRuleTable(); 12 | } 13 | 14 | #pointPattern( 15 | point: ContourPoint | null, 16 | selectedPoints: Set, 17 | centralPoint: boolean 18 | ): Pattern { 19 | if (!point) { 20 | return TOKENS.NO_POINT; 21 | } 22 | 23 | if (selectedPoints.has(point) && !centralPoint) { 24 | return TOKENS.SELECTED_POINT; 25 | } 26 | 27 | switch (point.pointType) { 28 | case 'onCurve': 29 | return point.smooth ? TOKENS.SMOOTH : TOKENS.CORNER; 30 | case 'offCurve': 31 | return TOKENS.HANDLE; 32 | } 33 | } 34 | 35 | /** 36 | * Gets a point at a specific distance from the central point 37 | * @param centralPoint The central point to expand from 38 | * @param distance Distance from center (positive = next, negative = prev, 0 = center) 39 | * @returns The point at the specified distance, or null if not reachable 40 | */ 41 | #getPointAtDistance(centralPoint: ContourPoint, distance: number): ContourPoint | null { 42 | if (distance === 0) { 43 | return centralPoint; 44 | } 45 | 46 | let currentPoint: ContourPoint | null = centralPoint; 47 | const steps = Math.abs(distance); 48 | const direction = distance > 0 ? 'next' : 'prev'; 49 | 50 | for (let i = 0; i < steps; i++) { 51 | if (!currentPoint) { 52 | return null; 53 | } 54 | currentPoint = direction === 'next' ? currentPoint.nextPoint : currentPoint.prevPoint; 55 | } 56 | 57 | return currentPoint; 58 | } 59 | 60 | #buildPattern( 61 | point: ContourPoint, 62 | selectedPoints: Set, 63 | windowSize: number 64 | ): Pattern { 65 | if (windowSize % 2 === 0) { 66 | throw new Error('Window size must be odd to have a central point'); 67 | } 68 | 69 | const halfWindow = Math.floor(windowSize / 2); 70 | let pattern = ''; 71 | 72 | // Build pattern from left to right: [-halfWindow, ..., -1, 0, 1, ..., halfWindow] 73 | for (let i = -halfWindow; i <= halfWindow; i++) { 74 | const targetPoint = this.#getPointAtDistance(point, i); 75 | const isCentral = i === 0; 76 | pattern += this.#pointPattern(targetPoint, selectedPoints, isCentral); 77 | } 78 | 79 | return pattern; 80 | } 81 | 82 | public match(point: ContourPoint, selectedPoints: Set): Rule | null { 83 | for (const windowSize of WINDOW_SIZES) { 84 | const pattern = this.#buildPattern(point, selectedPoints, windowSize); 85 | const rule = this.#ruleTable.get(pattern); 86 | if (rule) { 87 | return rule; 88 | } 89 | } 90 | 91 | return null; 92 | } 93 | 94 | /** 95 | * Public method to build patterns for testing purposes 96 | * @param point The central point 97 | * @param selectedPoints Set of selected points 98 | * @returns Array of patterns for different window sizes 99 | */ 100 | public buildPatterns(point: ContourPoint, selectedPoints: Set): Pattern[] { 101 | return WINDOW_SIZES.map((windowSize) => this.#buildPattern(point, selectedPoints, windowSize)); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /apps/desktop/lib/core/PatternParser.ts: -------------------------------------------------------------------------------- 1 | import { ContourPoint } from './Contour'; 2 | import { PatternMatcher } from './PatternMatcher'; 3 | import { Pattern } from './RuleTable'; 4 | 5 | // Token types 6 | export const TOKENS = { 7 | NO_POINT: 'N', 8 | CORNER: 'C', 9 | HANDLE: 'H', 10 | SMOOTH: 'S', 11 | SELECTED_POINT: '@', 12 | ALL: 'X', 13 | SET_START: '[', 14 | SET_END: ']', 15 | } as const; 16 | 17 | type Token = (typeof TOKENS)[keyof typeof TOKENS]; 18 | 19 | const ALL_POINT_TOKENS = [TOKENS.NO_POINT, TOKENS.CORNER, TOKENS.SMOOTH, TOKENS.HANDLE]; 20 | 21 | /** 22 | * Builds patterns for a given point using PatternMatcher 23 | * @deprecated Use PatternMatcher.buildPatterns() directly for better performance and configurability 24 | * @param point The central point 25 | * @param selectedPoints Set of selected points 26 | * @returns The first pattern (3-point window) for backward compatibility 27 | */ 28 | export const BuildRules = (point: ContourPoint, selectedPoints: Set): Pattern => { 29 | const matcher = new PatternMatcher(); 30 | const patterns = matcher.buildPatterns(point, selectedPoints); 31 | return patterns[0]; // Return first pattern for backward compatibility 32 | }; 33 | 34 | export class PatternParser { 35 | /** 36 | * Generates the cartesian product of multiple arrays 37 | * @param sets Arrays to combine 38 | * @returns Array of all possible combinations 39 | */ 40 | #cartesianProduct(...sets: T[][]): T[][] { 41 | return sets.reduce( 42 | (acc, current) => 43 | acc.flatMap((accElement) => 44 | current.map((currentElement) => [...accElement, currentElement]) 45 | ), 46 | [[]] 47 | ); 48 | } 49 | 50 | /** 51 | * Parses a character set (e.g., [NH]) 52 | * @param pattern The pattern string 53 | * @param startIndex Start index of the set 54 | * @returns Tuple of [parsed set, new index] 55 | */ 56 | #parseSet(pattern: string, startIndex: number): [Token[], number] { 57 | const set: Token[] = []; 58 | let i = startIndex + 1; // Skip the opening bracket 59 | 60 | while (i < pattern.length && pattern[i] !== TOKENS.SET_END) { 61 | if (pattern[i] === TOKENS.ALL) { 62 | set.push(...ALL_POINT_TOKENS); 63 | } else { 64 | set.push(pattern[i] as Token); 65 | } 66 | i++; 67 | } 68 | 69 | // If set is empty, return empty array 70 | if (set.length === 0) { 71 | return [[], i + 1]; 72 | } 73 | 74 | return [set, i + 1]; // +1 to skip the closing bracket 75 | } 76 | 77 | /** 78 | * Expands a pattern string into all possible combinations 79 | * @param pattern The pattern string to expand 80 | * @returns Array of expanded patterns 81 | */ 82 | expand(pattern: string): Pattern[] { 83 | const sets: Token[][] = []; 84 | let i = 0; 85 | 86 | while (i < pattern.length) { 87 | const token = pattern[i] as Token; 88 | 89 | switch (token) { 90 | case TOKENS.SET_START: { 91 | const [set, newIndex] = this.#parseSet(pattern, i); 92 | if (set.length > 0) { 93 | sets.push(set); 94 | } else { 95 | // For empty sets, return empty array 96 | return []; 97 | } 98 | i = newIndex; 99 | break; 100 | } 101 | case TOKENS.ALL: { 102 | sets.push([...ALL_POINT_TOKENS]); 103 | i++; 104 | break; 105 | } 106 | default: 107 | sets.push([token]); 108 | i++; 109 | break; 110 | } 111 | } 112 | 113 | const combinations = this.#cartesianProduct(...sets); 114 | return combinations.map((pattern) => pattern.join('')).filter(Boolean); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /apps/desktop/lib/core/RuleTable.ts: -------------------------------------------------------------------------------- 1 | import { AppliedEdit, Edit } from '@/types/edit'; 2 | 3 | import { ContourPoint } from './Contour'; 4 | import { MaintainTangency } from './EditActions'; 5 | import { EditContext } from './EditEngine'; 6 | import { PatternParser } from './PatternParser'; 7 | 8 | export type Pattern = string; 9 | 10 | export interface Rule { 11 | pattern: Pattern; 12 | description: string; 13 | action(ctx: EditContext, point: ContourPoint, dx: number, dy: number): AppliedEdit; 14 | } 15 | 16 | export type RuleTemplate = Map; 17 | export const RULE_TEMPLATES: RuleTemplate = new Map([ 18 | [ 19 | '[X@][CS]H', 20 | { 21 | pattern: '[X@][CS]H', 22 | description: 'move the right neighbour handle of an anchor point', 23 | action: (ctx, point, dx, dy) => { 24 | if (!point.nextPoint) { 25 | console.warn('expected next point'); 26 | return { 27 | point: point, 28 | edits: [], 29 | affectedPoints: [], 30 | }; 31 | } 32 | 33 | const nextPoint = point.nextPoint; 34 | const edit: Edit = { 35 | point: nextPoint, 36 | from: { x: nextPoint.x, y: nextPoint.y }, 37 | to: { x: nextPoint.x + dx, y: nextPoint.y + dy }, 38 | }; 39 | ctx.movePointTo(nextPoint, nextPoint.x + dx, nextPoint.y + dy); 40 | 41 | return { 42 | point: point, 43 | edits: [edit], 44 | affectedPoints: [nextPoint], 45 | }; 46 | }, 47 | }, 48 | ], 49 | [ 50 | 'H[CS][X@]', 51 | { 52 | pattern: 'H[CS][X@]', 53 | description: 'move the left neighbour handle of an anchor point', 54 | action: (ctx, point, dx, dy) => { 55 | if (!point.prevPoint) { 56 | console.warn('expected prev point'); 57 | return { 58 | point: point, 59 | edits: [], 60 | affectedPoints: [], 61 | }; 62 | } 63 | 64 | const prevPoint = point.prevPoint; 65 | const edit: Edit = { 66 | point: prevPoint, 67 | from: { x: prevPoint.x, y: prevPoint.y }, 68 | to: { x: prevPoint.x + dx, y: prevPoint.y + dy }, 69 | }; 70 | ctx.movePointTo(prevPoint, prevPoint.x + dx, prevPoint.y + dy); 71 | 72 | return { 73 | point: point, 74 | edits: [edit], 75 | affectedPoints: [prevPoint], 76 | }; 77 | }, 78 | }, 79 | ], 80 | [ 81 | 'H[CS]H', 82 | { 83 | pattern: 'H[CS]H', 84 | description: 'move the neighbour handles of an anchor point', 85 | action: (ctx, point, dx, dy) => { 86 | if (!point.prevPoint || !point.nextPoint) { 87 | console.warn('expected prev and next point'); 88 | return { 89 | point: point, 90 | edits: [], 91 | affectedPoints: [], 92 | }; 93 | } 94 | 95 | const prevPoint = point.prevPoint; 96 | const nextPoint = point.nextPoint; 97 | const edit1: Edit = { 98 | point: prevPoint, 99 | from: { x: prevPoint.x, y: prevPoint.y }, 100 | to: { x: prevPoint.x + dx, y: prevPoint.y + dy }, 101 | }; 102 | 103 | const edit2: Edit = { 104 | point: nextPoint, 105 | from: { x: nextPoint.x, y: nextPoint.y }, 106 | to: { x: nextPoint.x + dx, y: nextPoint.y + dy }, 107 | }; 108 | 109 | ctx.movePointTo(prevPoint, prevPoint.x + dx, prevPoint.y + dy); 110 | ctx.movePointTo(nextPoint, nextPoint.x + dx, nextPoint.y + dy); 111 | 112 | return { 113 | point: point, 114 | edits: [edit1, edit2], 115 | affectedPoints: [prevPoint, nextPoint], 116 | }; 117 | }, 118 | }, 119 | ], 120 | [ 121 | 'HS[HC][@X][@X]', 122 | { 123 | pattern: 'HSH[@X][@X]', 124 | description: 125 | 'move the handle and maintain tangency through the anchor point with the opposite handle', 126 | action: (ctx, point, dx, dy) => { 127 | if (!point.prevPoint) { 128 | console.warn('expected an anchor point'); 129 | return { 130 | point: point, 131 | edits: [], 132 | affectedPoints: [], 133 | }; 134 | } 135 | 136 | if (!point.prevPoint.prevPoint) { 137 | console.warn('expected opposite point'); 138 | return { 139 | point: point, 140 | edits: [], 141 | affectedPoints: [], 142 | }; 143 | } 144 | 145 | const edit = MaintainTangency( 146 | ctx, 147 | point.prevPoint, 148 | point, 149 | point.prevPoint.prevPoint, 150 | dx, 151 | dy 152 | ); 153 | 154 | return edit; 155 | }, 156 | }, 157 | ], 158 | [ 159 | '[@X]HS', 160 | { 161 | pattern: '[@X]HS', 162 | description: 'move the handle of a smooth point', 163 | action: (ctx, point, dx, dy) => { 164 | if (!point.nextPoint) { 165 | console.warn('expected an anchor point'); 166 | return { 167 | point: point, 168 | edits: [], 169 | affectedPoints: [], 170 | }; 171 | } 172 | 173 | if (!point.nextPoint.nextPoint) { 174 | console.warn('expected opposite point'); 175 | return { 176 | point: point, 177 | edits: [], 178 | affectedPoints: [], 179 | }; 180 | } 181 | 182 | const edit = MaintainTangency( 183 | ctx, 184 | point.nextPoint, 185 | point, 186 | point.nextPoint.nextPoint, 187 | dx, 188 | dy 189 | ); 190 | 191 | return edit; 192 | }, 193 | }, 194 | ], 195 | ]); 196 | 197 | export type RuleTable = Map; 198 | const buildRuleTable = (ruleTemplates: RuleTemplate): RuleTable => { 199 | const ruleTable: RuleTable = new Map(); 200 | const parser = new PatternParser(); 201 | 202 | for (const [pattern, rule] of ruleTemplates) { 203 | const patterns = parser.expand(pattern); 204 | 205 | for (const pattern of patterns) { 206 | ruleTable.set(pattern, rule); 207 | } 208 | } 209 | 210 | return ruleTable; 211 | }; 212 | 213 | export const BuildRuleTable = (): RuleTable => { 214 | const ruleTable = buildRuleTable(RULE_TEMPLATES); 215 | return ruleTable; 216 | }; 217 | -------------------------------------------------------------------------------- /apps/desktop/lib/core/UndoManager.ts: -------------------------------------------------------------------------------- 1 | interface Command { 2 | undo(): void; 3 | } 4 | 5 | export class UndoManager { 6 | #undoStack: Command[] = []; 7 | 8 | push(command: Command) { 9 | this.#undoStack.push(command); 10 | } 11 | 12 | peek() { 13 | return this.#undoStack[this.#undoStack.length - 1]; 14 | } 15 | 16 | undo() { 17 | const command = this.#undoStack.pop(); 18 | if (command) { 19 | command.undo(); 20 | } 21 | } 22 | 23 | clear() { 24 | this.#undoStack = []; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /apps/desktop/lib/core/common.ts: -------------------------------------------------------------------------------- 1 | import { mod } from '../utils/utils'; 2 | 3 | /** 4 | * A cycling collection that wraps an array 5 | */ 6 | export class CyclingCollection { 7 | readonly #array: T[]; 8 | #currentIndex: number = 0; 9 | 10 | private constructor(items: T[]) { 11 | this.#array = items; 12 | } 13 | 14 | /** 15 | * Creates a new CyclingCollection 16 | */ 17 | static create(items: T[]): CyclingCollection { 18 | return new CyclingCollection(items); 19 | } 20 | 21 | get length(): number { 22 | return this.#array.length; 23 | } 24 | 25 | get items(): readonly T[] { 26 | return this.#array; 27 | } 28 | 29 | current(): T { 30 | return this.#array[this.#currentIndex]; 31 | } 32 | 33 | moveTo(index: number): T { 34 | this.#currentIndex = mod(index, this.length); 35 | return this.#array[this.#currentIndex]; 36 | } 37 | 38 | next(): T { 39 | this.#currentIndex = mod(this.#currentIndex + 1, this.length); 40 | return this.#array[this.#currentIndex]; 41 | } 42 | 43 | prev(): T { 44 | this.#currentIndex = mod(this.#currentIndex - 1, this.length); 45 | return this.#array[this.#currentIndex]; 46 | } 47 | 48 | peekNext(): T { 49 | return this.#array[mod(this.#currentIndex + 1, this.length)]; 50 | } 51 | 52 | peekPrev(): T { 53 | return this.#array[mod(this.#currentIndex - 1, this.length)]; 54 | } 55 | 56 | reset(): void { 57 | this.#currentIndex = 0; 58 | } 59 | 60 | /** Iterator support */ 61 | *[Symbol.iterator]() { 62 | yield* this.#array; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /apps/desktop/lib/editor/ContourManager.ts: -------------------------------------------------------------------------------- 1 | import { PointType } from '@shift/shared'; 2 | 3 | import { Contour, ContourPoint } from '@/lib/core/Contour'; 4 | import { EntityId, Ident } from '@/lib/core/EntityId'; 5 | import { Segment } from '@/types/segments'; 6 | 7 | export class ContourManager { 8 | #activeContourId: EntityId | null; 9 | #contours: Map = new Map(); 10 | 11 | constructor() { 12 | this.#activeContourId = null; 13 | } 14 | 15 | public getContour(id: Ident): Contour | undefined { 16 | return this.#contours.get(id); 17 | } 18 | 19 | public setActiveContour(id: EntityId) { 20 | this.#activeContourId = id; 21 | } 22 | 23 | public loadContours(contours: Contour[]) { 24 | for (const contour of contours) { 25 | this.#contours.set(contour.entityId.id, contour); 26 | } 27 | } 28 | 29 | public clearContours() { 30 | this.#contours.clear(); 31 | } 32 | 33 | getActiveContour(): Contour { 34 | if (!this.#activeContourId) { 35 | const c = new Contour(); 36 | this.#contours.set(c.entityId.id, c); 37 | this.#activeContourId = c.entityId; 38 | return c; 39 | } 40 | 41 | const c = this.#contours.get(this.#activeContourId.id); 42 | if (!c) { 43 | throw new Error('Current contour not found'); 44 | } 45 | 46 | return c; 47 | } 48 | 49 | addPoint(x: number, y: number, pointType: PointType): EntityId { 50 | if (this.pointClosesPath(x, y)) { 51 | return this.closeContour(); 52 | } 53 | 54 | return this.getActiveContour().addPoint(x, y, pointType); 55 | } 56 | 57 | getPoint(id: EntityId): ContourPoint | undefined { 58 | const c = this.#contours.get(id.parentId); 59 | if (!c) { 60 | console.error('No parentId for point'); 61 | return undefined; 62 | } 63 | 64 | const point = c.points.find((p) => p.entityId.id === id.id); 65 | if (!point) { 66 | console.error('No point found'); 67 | return undefined; 68 | } 69 | 70 | return point; 71 | } 72 | 73 | removePoint(id: EntityId): ContourPoint | undefined { 74 | const c = this.#contours.get(id.parentId); 75 | if (!c) { 76 | console.error('No parentId for point'); 77 | return; 78 | } 79 | 80 | c.removePoint(id); 81 | } 82 | 83 | getNeighborPoints(p: ContourPoint): ContourPoint[] { 84 | // Use the direct point relationships if available, otherwise fall back to cursor navigation 85 | if (p.prevPoint !== null || p.nextPoint !== null) { 86 | return p.getNeighbors(); 87 | } 88 | 89 | // Fallback to the original cursor-based approach 90 | const c = this.#contours.get(p.entityId.parentId); 91 | if (!c) { 92 | console.error('No parentId for point'); 93 | return []; 94 | } 95 | 96 | const pointCursor = c.pointCursor(); 97 | const index = c.points.findIndex((point) => point.entityId.id === p.entityId.id); 98 | 99 | if (index === -1) { 100 | console.error('No index for point'); 101 | return []; 102 | } 103 | 104 | pointCursor.moveTo(index); 105 | const neighbors = []; 106 | 107 | neighbors.push(pointCursor.peekPrev()); 108 | neighbors.push(pointCursor.peekNext()); 109 | 110 | return neighbors; 111 | } 112 | 113 | pointClosesPath(x: number, y: number): boolean { 114 | if (this.getActiveContour().points.length > 1) { 115 | const firstPoint = this.getActiveContour().firstPoint(); 116 | return firstPoint.distance(x, y) < 6; 117 | } 118 | 119 | return false; 120 | } 121 | 122 | closeContour(): EntityId { 123 | const firstPoint = this.getActiveContour().firstPoint(); 124 | 125 | this.getActiveContour().close(); 126 | this.#activeContourId = this.addContour(); 127 | 128 | return firstPoint.entityId; 129 | } 130 | 131 | movePointTo(id: EntityId, x: number, y: number) { 132 | const c = this.#contours.get(id.parentId); 133 | if (!c) { 134 | console.error('No parentId for point'); 135 | return; 136 | } 137 | 138 | const p = c.points.find((p) => p.entityId.id === id.id); 139 | 140 | if (!p) { 141 | console.error('point not found'); 142 | return; 143 | } 144 | 145 | p.movePointTo(x, y); 146 | } 147 | 148 | movePointBy(id: EntityId, dx: number, dy: number) { 149 | const c = this.#contours.get(id.parentId); 150 | if (!c) { 151 | console.error('No parentId for point'); 152 | return; 153 | } 154 | 155 | const p = c.points.find((p) => p.entityId.id === id.id); 156 | 157 | if (!p) { 158 | console.error('point not found'); 159 | return; 160 | } 161 | 162 | p.movePointBy(dx, dy); 163 | } 164 | 165 | addContour(contour?: Contour): EntityId { 166 | const c = contour ?? new Contour(); 167 | this.#contours.set(c.entityId.id, c); 168 | 169 | return c.entityId; 170 | } 171 | 172 | duplicateContour(id: EntityId): EntityId { 173 | const c = this.#contours.get(id.id); 174 | if (!c) { 175 | console.error('No parentId for point'); 176 | return id; 177 | } 178 | 179 | const newContour = c.clone(); 180 | this.#contours.set(newContour.entityId.id, newContour); 181 | 182 | return newContour.entityId; 183 | } 184 | 185 | upgradeLineSegment(id: EntityId): EntityId { 186 | const c = this.#contours.get(id.parentId); 187 | if (!c) { 188 | console.error('No parentId for point'); 189 | return id; 190 | } 191 | 192 | return c.upgradeLineSegment(id); 193 | } 194 | 195 | // TODO: Add tests 196 | getSegment(id: EntityId): Segment | undefined { 197 | const c = this.#contours.get(id.parentId); 198 | if (!c) { 199 | console.error('No parentId for point'); 200 | return undefined; 201 | } 202 | 203 | for (const segment of c.segments()) { 204 | if (segment.points.anchor1.entityId === id) { 205 | return segment; 206 | } 207 | } 208 | 209 | return undefined; 210 | } 211 | 212 | contours(): Contour[] { 213 | return Array.from(this.#contours.values()); 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /apps/desktop/lib/editor/FrameHandler.ts: -------------------------------------------------------------------------------- 1 | type FrameHandlerCallback = (...args: unknown[]) => void; 2 | 3 | export class FrameHandler { 4 | #id: number | null = null; 5 | #callback: FrameHandlerCallback | null = null; 6 | 7 | public requestUpdate(callback: FrameHandlerCallback): void { 8 | if (this.#id) return; 9 | this.#callback = callback; 10 | 11 | this.#id = window.requestAnimationFrame(this.#update); 12 | } 13 | 14 | #update = () => { 15 | if (!this.#callback) return; 16 | this.#callback(); 17 | this.#callback = null; 18 | this.#id = null; 19 | }; 20 | 21 | cleanup(): void { 22 | if (!this.#id) return; 23 | window.cancelAnimationFrame(this.#id); 24 | this.#id = null; 25 | this.#callback = null; 26 | } 27 | 28 | public cancelUpdate(): void { 29 | this.cleanup(); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /apps/desktop/lib/editor/Painter.ts: -------------------------------------------------------------------------------- 1 | import { Path2D } from '@/lib/graphics/Path'; 2 | import { Line } from '@/lib/math/line'; 3 | import { HANDLE_STYLES } from '@/lib/styles/style'; 4 | import { IRenderer } from '@/types/graphics'; 5 | import { HandleState } from '@/types/handle'; 6 | 7 | export class Painter { 8 | public drawGuides(ctx: IRenderer, path: Path2D) { 9 | ctx.stroke(path); 10 | } 11 | 12 | public drawFirstHandle(ctx: IRenderer, x: number, y: number, handleState: HandleState) { 13 | const style = HANDLE_STYLES.first[handleState]; 14 | 15 | ctx.setStyle(style); 16 | 17 | if (handleState == 'selected') { 18 | ctx.fillCircle(x, y, style.size); 19 | } else { 20 | ctx.strokeCircle(x, y, style.size); 21 | } 22 | } 23 | 24 | public drawLastHandle( 25 | ctx: IRenderer, 26 | x0: number, 27 | y0: number, 28 | x1: number, 29 | y1: number, 30 | handleState: HandleState 31 | ) { 32 | const style = HANDLE_STYLES.last[handleState]; 33 | ctx.setStyle(style); 34 | 35 | const theta = Math.atan2(y1 - y0, x1 - x0); 36 | 37 | const arrowSize = style.size; 38 | const arrowAngle = Math.PI / 4; 39 | 40 | const { x, y } = Line.lerp({ x: x0, y: y0 }, { x: x1, y: y1 }, 0.025); 41 | const distance = Math.hypot(x - x0, y - y0); 42 | 43 | // maintain equal distance between the arrow points 44 | const lerp = 0.25 / distance; 45 | 46 | for (const p of [0, lerp]) { 47 | const { x, y } = Line.lerp({ x: x0, y: y0 }, { x: x1, y: y1 }, p); 48 | 49 | ctx.beginPath(); 50 | ctx.moveTo(x, y); 51 | ctx.lineTo( 52 | x - arrowSize * -Math.cos(theta + arrowAngle), 53 | y - arrowSize * -Math.sin(theta + arrowAngle) 54 | ); 55 | 56 | ctx.moveTo(x, y); 57 | ctx.lineTo( 58 | x - arrowSize * -Math.cos(theta - arrowAngle), 59 | y - arrowSize * -Math.sin(theta - arrowAngle) 60 | ); 61 | ctx.stroke(); 62 | } 63 | } 64 | 65 | public drawCornerHandle(ctx: IRenderer, x: number, y: number, handleState: HandleState): void { 66 | const style = HANDLE_STYLES.corner[handleState]; 67 | ctx.setStyle(style); 68 | 69 | if (handleState == 'selected') { 70 | ctx.fillRect(x - style.size / 2, y - style.size / 2, style.size, style.size); 71 | return; 72 | } 73 | ctx.strokeRect(x - style.size / 2, y - style.size / 2, style.size, style.size); 74 | } 75 | 76 | public drawControlHandle(ctx: IRenderer, x: number, y: number, handleState: HandleState): void { 77 | const style = HANDLE_STYLES.control[handleState]; 78 | 79 | ctx.setStyle(style); 80 | ctx.strokeCircle(x, y, style.size); 81 | ctx.fillCircle(x, y, style.size); 82 | } 83 | 84 | public drawDirectionHandle( 85 | ctx: IRenderer, 86 | x: number, 87 | y: number, 88 | handleState: HandleState, 89 | isCounterClockWise?: boolean 90 | ): void { 91 | const style = HANDLE_STYLES.direction[handleState]; 92 | ctx.setStyle(style); 93 | 94 | if (handleState == 'selected') { 95 | ctx.fillCircle(x, y, style.size - 1); 96 | } else { 97 | ctx.strokeCircle(x, y, style.size - 1); 98 | } 99 | 100 | ctx.beginPath(); 101 | 102 | const radius = style.size + 6; 103 | const startAngle = 0; 104 | const endAngle = Math.PI; 105 | 106 | ctx.arcTo(x, y, radius, startAngle, endAngle, true); 107 | ctx.stroke(); 108 | 109 | // this is a bit janky as we are back in screen space here 110 | // need to revisit how we achieve keeping the handles the same 111 | // size no matter the zoom in the painting phase, but whilst keeping 112 | // everything in UPM always would be ideal 113 | // const onX = isCounterClockWise ? x + radius : x - radius; 114 | const onX = isCounterClockWise ? x - radius : x + radius; 115 | 116 | const arrowSize = 8; 117 | const arrowAngle = Math.PI / 4; 118 | 119 | ctx.beginPath(); 120 | 121 | if (isCounterClockWise) { 122 | ctx.moveTo(onX, y); 123 | ctx.lineTo(onX - arrowSize * Math.cos(arrowAngle), y - arrowSize * Math.sin(arrowAngle)); 124 | 125 | ctx.moveTo(onX, y); 126 | ctx.lineTo(onX + arrowSize * Math.cos(arrowAngle), y - arrowSize * Math.sin(arrowAngle)); 127 | } else { 128 | ctx.moveTo(onX, y); 129 | ctx.lineTo(onX - arrowSize * Math.cos(arrowAngle), y - arrowSize * Math.sin(arrowAngle)); 130 | 131 | ctx.moveTo(onX, y); 132 | ctx.lineTo(onX + arrowSize * Math.cos(arrowAngle), y - arrowSize * Math.sin(arrowAngle)); 133 | } 134 | 135 | ctx.stroke(); 136 | } 137 | 138 | public drawSmoothHandle(ctx: IRenderer, x: number, y: number, handleState: HandleState): void { 139 | const style = HANDLE_STYLES.smooth[handleState]; 140 | ctx.setStyle(style); 141 | 142 | ctx.strokeCircle(x, y, style.size); 143 | ctx.fillCircle(x, y, style.size); 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /apps/desktop/lib/editor/Scene.ts: -------------------------------------------------------------------------------- 1 | import { PointType } from '@shift/shared'; 2 | 3 | import { EntityId } from '@/lib/core/EntityId'; 4 | import { Segment } from '@/types/segments'; 5 | 6 | import { ContourManager } from './ContourManager'; 7 | import { Contour, ContourPoint } from '../core/Contour'; 8 | import { Path2D } from '../graphics/Path'; 9 | 10 | export interface Guides { 11 | xAdvance: number; 12 | ascender: { y: number }; 13 | capHeight: { y: number }; 14 | xHeight: { y: number }; 15 | baseline: { y: number }; 16 | descender: { y: number }; 17 | } 18 | 19 | export class Scene { 20 | #contourManager: ContourManager; 21 | #staticGuides: Path2D; 22 | #glyphPath: Path2D; 23 | 24 | public constructor() { 25 | this.#contourManager = new ContourManager(); 26 | this.#staticGuides = new Path2D(); 27 | this.#glyphPath = new Path2D(); 28 | } 29 | 30 | public constructGuidesPath(guides: Guides): Path2D { 31 | this.#staticGuides.clear(); 32 | 33 | // Draw horizontal guide lines 34 | this.#staticGuides.moveTo(0, guides.ascender.y); 35 | this.#staticGuides.lineTo(guides.xAdvance, guides.ascender.y); 36 | 37 | this.#staticGuides.moveTo(0, guides.capHeight.y); 38 | this.#staticGuides.lineTo(guides.xAdvance, guides.capHeight.y); 39 | 40 | this.#staticGuides.moveTo(0, guides.xHeight.y); 41 | this.#staticGuides.lineTo(guides.xAdvance, guides.xHeight.y); 42 | 43 | this.#staticGuides.moveTo(0, guides.baseline.y); 44 | this.#staticGuides.lineTo(guides.xAdvance, guides.baseline.y); 45 | 46 | this.#staticGuides.moveTo(0, guides.descender.y); 47 | this.#staticGuides.lineTo(guides.xAdvance, guides.descender.y); 48 | 49 | // Draw vertical guide lines 50 | this.#staticGuides.moveTo(0, guides.descender.y); 51 | this.#staticGuides.lineTo(0, guides.ascender.y); 52 | this.#staticGuides.moveTo(guides.xAdvance, guides.descender.y); 53 | this.#staticGuides.lineTo(guides.xAdvance, guides.ascender.y); 54 | 55 | return this.#staticGuides; 56 | } 57 | 58 | public getGuidesPath(): Path2D { 59 | return this.#staticGuides; 60 | } 61 | 62 | public getGlyphPath(): Path2D { 63 | this.rebuildGlyphPath(); 64 | return this.#glyphPath; 65 | } 66 | 67 | public rebuildGlyphPath(): void { 68 | this.#glyphPath.clear(); 69 | 70 | for (const contour of this.#contourManager.contours()) { 71 | if (contour.points.length < 2) { 72 | continue; 73 | } 74 | 75 | const segments = contour.segments(); 76 | 77 | if (segments.length === 0) continue; 78 | 79 | this.#glyphPath.moveTo(segments[0].points.anchor1.x, segments[0].points.anchor1.y); 80 | 81 | for (const segment of segments) { 82 | switch (segment.type) { 83 | case 'line': 84 | this.#glyphPath.lineTo(segment.points.anchor2.x, segment.points.anchor2.y); 85 | break; 86 | case 'quad': 87 | this.#glyphPath.quadTo( 88 | segment.points.control.x, 89 | segment.points.control.y, 90 | segment.points.anchor2.x, 91 | segment.points.anchor2.y 92 | ); 93 | break; 94 | case 'cubic': 95 | this.#glyphPath.cubicTo( 96 | segment.points.control1.x, 97 | segment.points.control1.y, 98 | segment.points.control2.x, 99 | segment.points.control2.y, 100 | segment.points.anchor2.x, 101 | segment.points.anchor2.y 102 | ); 103 | break; 104 | } 105 | } 106 | 107 | if (contour.closed) { 108 | this.#glyphPath.closePath(); 109 | } 110 | } 111 | } 112 | 113 | public addPoint(x: number, y: number, pointType: PointType): EntityId { 114 | return this.#contourManager.addPoint(x, y, pointType); 115 | } 116 | 117 | public getPoint(id: EntityId): ContourPoint | undefined { 118 | return this.#contourManager.getPoint(id); 119 | } 120 | 121 | public removePoint(id: EntityId): ContourPoint | undefined { 122 | return this.#contourManager.removePoint(id); 123 | } 124 | 125 | public getNeighborPoints(p: ContourPoint): ContourPoint[] { 126 | return this.#contourManager.getNeighborPoints(p); 127 | } 128 | 129 | public closeContour(): EntityId { 130 | return this.#contourManager.closeContour(); 131 | } 132 | 133 | public addContour(contour?: Contour): EntityId { 134 | return this.#contourManager.addContour(contour); 135 | } 136 | 137 | public setActiveContour(id: EntityId) { 138 | this.#contourManager.setActiveContour(id); 139 | } 140 | 141 | public invalidateGlyph(): void { 142 | this.#glyphPath.invalidated = true; 143 | } 144 | 145 | public clearContours() { 146 | this.#contourManager.clearContours(); 147 | } 148 | 149 | public movePointTo(id: EntityId, x: number, y: number) { 150 | this.#contourManager.movePointTo(id, x, y); 151 | } 152 | 153 | public movePointBy(id: EntityId, dx: number, dy: number) { 154 | this.#contourManager.movePointBy(id, dx, dy); 155 | } 156 | 157 | public upgradeLineSegment(id: EntityId): EntityId { 158 | return this.#contourManager.upgradeLineSegment(id); 159 | } 160 | 161 | public getSegment(id: EntityId): Segment | undefined { 162 | return this.#contourManager.getSegment(id); 163 | } 164 | 165 | public loadContours(contours: Contour[]): void { 166 | this.#contourManager.loadContours(contours); 167 | } 168 | 169 | // TODO: perhaps make this into a single functions where 170 | // you can pass optional IDs and if not it returns all points 171 | public getAllPoints(): ContourPoint[] { 172 | return this.#contourManager 173 | .contours() 174 | .map((c) => { 175 | return c.points; 176 | }) 177 | .flat(); 178 | } 179 | 180 | public getAllContours() { 181 | return this.#contourManager 182 | .contours() 183 | .map((c) => { 184 | return c; 185 | }) 186 | .flat(); 187 | } 188 | 189 | contours(): Contour[] { 190 | return this.#contourManager.contours(); 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /apps/desktop/lib/editor/Viewport.ts: -------------------------------------------------------------------------------- 1 | import { clamp } from '@/lib/utils/utils'; 2 | import { Point2D, Rect2D } from '@/types/math'; 3 | 4 | export class Viewport { 5 | #padding: number; 6 | #upm: number; 7 | #canvasRect: Rect2D; 8 | 9 | #zoom: number; 10 | #dpr: number; 11 | 12 | #panX: number; 13 | #panY: number; 14 | 15 | #mouseX: number; 16 | #mouseY: number; 17 | 18 | #upmX: number; 19 | #upmY: number; 20 | 21 | constructor() { 22 | this.#upm = 1000; 23 | this.#padding = 300; 24 | 25 | this.#dpr = window.devicePixelRatio || 1; 26 | this.#zoom = 1; 27 | 28 | this.#mouseX = 0; 29 | this.#mouseY = 0; 30 | 31 | this.#upmX = 0; 32 | this.#upmY = 0; 33 | 34 | this.#canvasRect = { 35 | x: 0, 36 | y: 0, 37 | width: 0, 38 | height: 0, 39 | left: 0, 40 | top: 0, 41 | right: 0, 42 | bottom: 0, 43 | }; 44 | 45 | this.#panX = 0; 46 | this.#panY = 0; 47 | } 48 | 49 | // ** 50 | // Set the logical dimensions of the viewport 51 | // @param width - The width of the viewport 52 | // @param height - The height of the viewport 53 | // ** 54 | setRect(rect: Rect2D) { 55 | this.#canvasRect = rect; 56 | } 57 | 58 | // ** 59 | // Get the upm of the viewport 60 | // @returns The upm of the viewport 61 | // ** 62 | get upm(): number { 63 | return this.#upm; 64 | } 65 | 66 | set upm(value: number) { 67 | this.#upm = value; 68 | } 69 | 70 | get padding(): number { 71 | return this.#padding; 72 | } 73 | 74 | // ** 75 | // Get the device width of the viewport, 76 | // scaled to the device pixel ratio 77 | // @returns The device width of the viewport 78 | // ** 79 | get deviceWidth(): number { 80 | return this.#canvasRect.width * this.#dpr; 81 | } 82 | 83 | // ** 84 | // Get the device height of the viewport, 85 | // scaled to the device pixel ratio 86 | // @returns The device height of the viewport 87 | // ** 88 | get deviceHeight(): number { 89 | return this.#canvasRect.height * this.#dpr; 90 | } 91 | 92 | // ** 93 | // Get the logical width of the viewport 94 | // @returns The logical width of the viewport 95 | // ** 96 | get logicalWidth(): number { 97 | return this.#canvasRect.width; 98 | } 99 | 100 | // ** 101 | // Get the logical height of the viewport 102 | // @returns The logical height of the viewport 103 | // ** 104 | get logicalHeight(): number { 105 | return this.#canvasRect.height; 106 | } 107 | 108 | // ** 109 | // Get the scale of the viewport 110 | // @returns The scale of the viewport 111 | // ** 112 | get scale(): number { 113 | return this.#zoom; 114 | } 115 | 116 | // ** 117 | // Get the device pixel ratio of the viewport 118 | // @returns The device pixel ratio of the viewport 119 | // ** 120 | get dpr(): number { 121 | return this.#dpr; 122 | } 123 | 124 | public get zoom(): number { 125 | return this.#zoom; 126 | } 127 | 128 | // ** 129 | // Get the mouse position of the viewport 130 | // @returns The mouse position of the viewport 131 | // ** 132 | #calculateMousePosition(clientX: number, clientY: number): Point2D { 133 | const mouseX = clientX - this.#canvasRect.left; 134 | const mouseY = clientY - this.#canvasRect.top; 135 | 136 | this.#mouseX = Math.floor(mouseX); 137 | this.#mouseY = Math.floor(mouseY); 138 | 139 | return { 140 | x: this.#mouseX, 141 | y: this.#mouseY, 142 | }; 143 | } 144 | 145 | public getMousePosition(x?: number, y?: number): Point2D { 146 | if (x === undefined || y === undefined) { 147 | return this.#calculateMousePosition(this.#mouseX, this.#mouseY); 148 | } 149 | 150 | return this.#calculateMousePosition(x, y); 151 | } 152 | 153 | public setMousePosition(x: number, y: number): void { 154 | this.#mouseX = x; 155 | this.#mouseY = y; 156 | } 157 | 158 | public projectScreenToUpm(x: number, y: number) { 159 | const center = this.getCentrePoint(); 160 | const zoomedX = (x - (this.#panX + center.x * (1 - this.#zoom))) / this.#zoom; 161 | const zoomedY = (y - (this.#panY + center.y * (1 - this.#zoom))) / this.#zoom; 162 | 163 | const upmX = Math.floor(zoomedX - this.#padding); 164 | const upmY = Math.floor(-zoomedY + (this.logicalHeight - this.#padding)); 165 | 166 | return { x: upmX, y: upmY }; 167 | } 168 | 169 | public projectUpmToScreen(x: number, y: number) { 170 | x = x + this.#padding; 171 | 172 | y = -(y - (this.logicalHeight - this.#padding)); 173 | 174 | const panX = this.#panX + this.getCentrePoint().x * (1 - this.#zoom); 175 | const panY = this.#panY + this.getCentrePoint().y * (1 - this.#zoom); 176 | 177 | x = x * this.#zoom + panX; 178 | y = y * this.#zoom + panY; 179 | 180 | return { x, y }; 181 | } 182 | 183 | // ** 184 | // Get the upm mouse position of the viewport 185 | // @returns The upm mouse position of the viewport 186 | // ** 187 | getUpmMousePosition(): Point2D { 188 | return { x: this.#upmX, y: this.#upmY }; 189 | } 190 | 191 | setUpmMousePosition(x: number, y: number): void { 192 | this.#upmX = x; 193 | this.#upmY = y; 194 | } 195 | 196 | public getCentrePoint(): Point2D { 197 | return { x: this.logicalWidth / 2, y: this.logicalHeight / 2 }; 198 | } 199 | 200 | get panX(): number { 201 | return this.#panX; 202 | } 203 | 204 | get panY(): number { 205 | return this.#panY; 206 | } 207 | 208 | // ** 209 | // Pan the viewport 210 | // @param x - The x position of the mouse 211 | // @param y - The y position of the mouse 212 | // ** 213 | pan(x: number, y: number): void { 214 | this.#panX = x; 215 | this.#panY = y; 216 | } 217 | 218 | zoomIn(): void { 219 | this.#zoom = clamp(this.#zoom + 0.25, 0.1, 6); 220 | } 221 | 222 | zoomOut(): void { 223 | this.#zoom = clamp(this.#zoom - 0.25, 0.1, 6); 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /apps/desktop/lib/graphics/Path.ts: -------------------------------------------------------------------------------- 1 | import { PathCommand } from '@shift/shared'; 2 | 3 | import { IPath } from '@/types/graphics'; 4 | 5 | export class Path2D implements IPath { 6 | #commands: PathCommand[] = []; 7 | invalidated: boolean = false; 8 | 9 | constructor() {} 10 | 11 | moveTo(x: number, y: number): void { 12 | this.#commands.push({ type: 'moveTo', x, y }); 13 | } 14 | 15 | lineTo(x: number, y: number): void { 16 | this.#commands.push({ type: 'lineTo', x, y }); 17 | } 18 | 19 | cubicTo(cp1x: number, cp1y: number, cp2x: number, cp2y: number, x: number, y: number): void { 20 | this.#commands.push({ type: 'cubicTo', cp1x, cp1y, cp2x, cp2y, x, y }); 21 | } 22 | 23 | quadTo(cp1x: number, cp1y: number, x: number, y: number): void { 24 | this.#commands.push({ type: 'quadTo', cp1x, cp1y, x, y }); 25 | } 26 | 27 | closePath(): void { 28 | this.#commands.push({ type: 'close' }); 29 | } 30 | 31 | get commands(): ReadonlyArray { 32 | return this.#commands; 33 | } 34 | 35 | isClosed(): boolean { 36 | return this.#commands.filter((c) => c.type === 'close').length > 0; 37 | } 38 | 39 | isEmpty(): boolean { 40 | return this.#commands.length === 0; 41 | } 42 | 43 | clear(): void { 44 | this.#commands = []; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /apps/desktop/lib/graphics/backends/errors.ts: -------------------------------------------------------------------------------- 1 | export class SkiaGraphicsContextError extends Error { 2 | constructor(message: string) { 3 | super(message); 4 | this.name = 'SkiaGraphicsContextError '; 5 | } 6 | } 7 | 8 | export class SkiaRendererError extends Error { 9 | constructor(message: string) { 10 | super(message); 11 | this.name = 'SkiaRendererError '; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /apps/desktop/lib/math/circle.ts: -------------------------------------------------------------------------------- 1 | import { Shape } from '@/lib/math/shape'; 2 | 3 | export class Circle extends Shape { 4 | #radius: number; 5 | 6 | constructor(x: number, y: number, radius: number) { 7 | super(x, y); 8 | this.#radius = radius; 9 | } 10 | 11 | public get radius(): number { 12 | return this.#radius; 13 | } 14 | 15 | hit(): boolean { 16 | return false; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /apps/desktop/lib/math/line.ts: -------------------------------------------------------------------------------- 1 | import { Point2D } from '@/types/math'; 2 | 3 | import { Point } from './point'; 4 | 5 | export class Line { 6 | #x0: number; 7 | #x1: number; 8 | 9 | #y0: number; 10 | #y1: number; 11 | 12 | #startPoint: Point; 13 | #endPoint: Point; 14 | 15 | #length: number; 16 | 17 | constructor(x0: number, x1: number, y0: number, y1: number) { 18 | this.#x0 = x0; 19 | this.#y0 = y0; 20 | 21 | this.#x1 = x1; 22 | this.#y1 = y1; 23 | 24 | this.#startPoint = new Point(x0, y0); 25 | this.#endPoint = new Point(x1, y1); 26 | 27 | this.#length = Math.sqrt(Math.pow(this.#x1 - this.#x0, 2) + Math.pow(this.#y0 - this.#y1, 2)); 28 | } 29 | 30 | get startPoint(): Point { 31 | return this.#startPoint; 32 | } 33 | 34 | get endPoint(): Point { 35 | return this.#endPoint; 36 | } 37 | 38 | get length(): number { 39 | return this.#length; 40 | } 41 | 42 | static lerp(p1: Point2D, p2: Point2D, t: number): Point2D { 43 | return { 44 | x: p1.x + t * (p2.x - p1.x), 45 | y: p1.y + t * (p2.y - p1.y), 46 | }; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /apps/desktop/lib/math/point.ts: -------------------------------------------------------------------------------- 1 | export class Point { 2 | #x: number; 3 | #y: number; 4 | 5 | constructor(x: number, y: number) { 6 | this.#x = x; 7 | this.#y = y; 8 | } 9 | 10 | clone(): Point { 11 | return new Point(this.#x, this.#y); 12 | } 13 | 14 | public static create(x: number, y: number): Point { 15 | return new Point(x, y); 16 | } 17 | 18 | public get x() { 19 | return this.#x; 20 | } 21 | 22 | public get y() { 23 | return this.#y; 24 | } 25 | 26 | public set_x(x: number) { 27 | this.#x = x; 28 | } 29 | 30 | public set_y(y: number) { 31 | this.#y = y; 32 | } 33 | 34 | public distance(x: number, y: number): number { 35 | return Math.hypot(this.#x - x, this.#y - y); 36 | } 37 | 38 | public static distance(x0: number, y0: number, x1: number, y1: number): number { 39 | return Math.hypot(x1 - x0, y1 - y0); 40 | } 41 | 42 | public lerp(p: Point, t: number): Point { 43 | return new Point( 44 | Math.floor(this.#x + t * (p.x - this.#x)), 45 | Math.floor(this.#y + t * (p.y - this.#y)) 46 | ); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /apps/desktop/lib/math/rect.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | 3 | import { Rect } from '@/lib/math/rect'; 4 | 5 | describe('Rect', () => { 6 | describe('initilisation', () => { 7 | it('with x, y and size', () => { 8 | const rect = new Rect(10, 10, 100, 100); 9 | 10 | expect(rect.x).toBe(10); 11 | expect(rect.y).toBe(10); 12 | expect(rect.width).toBe(100); 13 | expect(rect.height).toBe(100); 14 | }); 15 | 16 | it('from bounds', () => { 17 | const rect = Rect.fromBounds(10, 20, 50, 50); 18 | 19 | expect(rect.x).toBe(10); 20 | expect(rect.y).toBe(20); 21 | expect(rect.width).toBe(40); 22 | expect(rect.height).toBe(30); 23 | }); 24 | }); 25 | 26 | describe('hit for rectangle at (5, 5) with width 25 and height 25', () => { 27 | const rect = new Rect(5, 5, 25, 25); 28 | 29 | it('on point (10, 10) is true', () => { 30 | expect(rect.hit(27, 26)).toBe(true); 31 | }); 32 | 33 | it('on point (35, 35) is false', () => { 34 | expect(rect.hit(35, 35)).toBe(false); 35 | }); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /apps/desktop/lib/math/rect.ts: -------------------------------------------------------------------------------- 1 | import { Point2D, Rect2D } from '@/types/math'; 2 | 3 | import { Point } from './point'; 4 | import { Shape } from './shape'; 5 | 6 | export class Rect extends Shape { 7 | #width: number; 8 | #height: number; 9 | 10 | constructor(x: number, y: number, width: number, height: number) { 11 | super(x, y); 12 | this.#width = width; 13 | this.#height = height; 14 | } 15 | 16 | public static fromBounds(left: number, top: number, right: number, bottom: number): Rect { 17 | // TODO: check that it's a valid rectangle 18 | return new Rect(left, top, right - left, bottom - top); 19 | } 20 | 21 | public get_rect(): [number, number, number, number] { 22 | return [this.x, this.y, this.width, this.height]; 23 | } 24 | 25 | public get_centered_position(): Point { 26 | return new Point(this.x - this.width / 2, this.y - this.height / 2); 27 | } 28 | 29 | // ** 30 | // Resize the rectangle 31 | // @param width - The new width of the rectangle 32 | // @param height - The new height of the rectangle 33 | // ** 34 | public resize(width: number, height: number): void { 35 | this.#width = width; 36 | this.#height = height; 37 | } 38 | 39 | public changeOrigin(x: number, y: number): void { 40 | this.position.set_x(x); 41 | this.position.set_y(y); 42 | } 43 | 44 | public clear(): void { 45 | this.#height = 0; 46 | this.#width = 0; 47 | this.position.set_x(0); 48 | this.position.set_y(0); 49 | } 50 | 51 | get left(): number { 52 | return this.x; 53 | } 54 | 55 | get top(): number { 56 | return this.y; 57 | } 58 | 59 | get right(): number { 60 | return this.left + this.width; 61 | } 62 | 63 | get bottom(): number { 64 | return this.top + this.height; 65 | } 66 | 67 | get width(): number { 68 | return this.#width; 69 | } 70 | 71 | get height(): number { 72 | return this.#height; 73 | } 74 | 75 | hit(x: number, y: number): boolean { 76 | return this.x <= x && x <= this.x + this.width && this.y <= y && y <= this.y + this.#height; 77 | } 78 | } 79 | 80 | export class UPMRect extends Rect { 81 | constructor(x: number, y: number, width: number, height: number) { 82 | super(x, y, width, height); 83 | } 84 | 85 | public resize(width: number, height: number) { 86 | if (width < 0) { 87 | const dx = this.x + width; 88 | this.position.set_x(dx); 89 | } 90 | 91 | if (height < 0) { 92 | const dy = this.y + height; 93 | this.position.set_y(dy); 94 | } 95 | 96 | super.resize(Math.abs(width), Math.abs(height)); 97 | } 98 | } 99 | 100 | export class UPMBoundingRect extends Rect { 101 | constructor(points: Point2D[]) { 102 | const minX = Math.min(...points.map((p) => p.x)); 103 | const minY = Math.min(...points.map((p) => p.y)); 104 | const maxX = Math.max(...points.map((p) => p.x)); 105 | const maxY = Math.max(...points.map((p) => p.y)); 106 | 107 | super(minX, minY, maxX - minX, maxY - minY); 108 | } 109 | } 110 | 111 | export function getBoundingRect(points: Point2D[]): Rect2D { 112 | const minX = Math.min(...points.map((p) => p.x)); 113 | const minY = Math.min(...points.map((p) => p.y)); 114 | const maxX = Math.max(...points.map((p) => p.x)); 115 | const maxY = Math.max(...points.map((p) => p.y)); 116 | 117 | return { 118 | x: minX, 119 | y: minY, 120 | width: maxX - minX, 121 | height: maxY - minY, 122 | left: minX, 123 | top: minY, 124 | right: maxX, 125 | bottom: maxY, 126 | }; 127 | } 128 | -------------------------------------------------------------------------------- /apps/desktop/lib/math/shape.ts: -------------------------------------------------------------------------------- 1 | import { Point } from './point'; 2 | 3 | export abstract class Shape { 4 | #position: Point; 5 | 6 | constructor(x: number, y: number) { 7 | this.#position = new Point(x, y); 8 | } 9 | 10 | get position(): Point { 11 | return this.#position; 12 | } 13 | 14 | get x(): number { 15 | return this.#position.x; 16 | } 17 | 18 | get y(): number { 19 | return this.#position.y; 20 | } 21 | 22 | // TODO: add tests 23 | static shoelace(points: Point[]): number { 24 | const n = points.length; 25 | let area = 0; 26 | 27 | for (let i = 0; i < n; i++) { 28 | const p1 = points[i]; 29 | const p2 = points[(i + 1) % n]; 30 | area += p1.x * p2.y - p2.x * p1.y; 31 | } 32 | 33 | return area / 2; 34 | } 35 | 36 | abstract hit(x: number, y: number): boolean; 37 | } 38 | -------------------------------------------------------------------------------- /apps/desktop/lib/math/vector.ts: -------------------------------------------------------------------------------- 1 | export class Vector2D { 2 | #x: number; 3 | #y: number; 4 | 5 | constructor(x: number, y: number) { 6 | this.#x = x; 7 | this.#y = y; 8 | } 9 | 10 | static from(x0: number, y0: number, x1: number, y1: number) { 11 | return new Vector2D(x1 - x0, y1 - y0); 12 | } 13 | 14 | static unitFrom(x0: number, y0: number, x1: number, y1: number) { 15 | const v = Vector2D.from(x0, y0, x1, y1); 16 | const length = v.length(); 17 | return new Vector2D(v.#x / length, v.#y / length); 18 | } 19 | 20 | get x() { 21 | return this.#x; 22 | } 23 | 24 | get y() { 25 | return this.#y; 26 | } 27 | 28 | length() { 29 | return Math.hypot(this.#x, this.#y); 30 | } 31 | 32 | reverse() { 33 | return new Vector2D(-this.#x, -this.#y); 34 | } 35 | 36 | add(vector: Vector2D) { 37 | return new Vector2D(this.#x + vector.#x, this.#y + vector.#y); 38 | } 39 | 40 | subtract(vector: Vector2D) { 41 | return new Vector2D(this.#x - vector.#x, this.#y - vector.#y); 42 | } 43 | 44 | multiply(scalar: number) { 45 | return new Vector2D(this.#x * scalar, this.#y * scalar); 46 | } 47 | 48 | divide(scalar: number) { 49 | return new Vector2D(this.#x / scalar, this.#y / scalar); 50 | } 51 | 52 | dot(vector: Vector2D) { 53 | return this.#x * vector.#x + this.#y * vector.#y; 54 | } 55 | 56 | cross(vector: Vector2D) { 57 | return this.#x * vector.#y - this.#y * vector.#x; 58 | } 59 | 60 | normalize() { 61 | const length = this.length(); 62 | return new Vector2D(this.#x / length, this.#y / length); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /apps/desktop/lib/styles/style.ts: -------------------------------------------------------------------------------- 1 | import { HandleType } from '@/types/handle'; 2 | 3 | export interface DrawStyle { 4 | lineWidth: number; 5 | strokeStyle: string; 6 | fillStyle: string; 7 | antiAlias?: boolean; 8 | dashPattern: number[]; 9 | } 10 | 11 | export const GUIDE_STYLES: DrawStyle = { 12 | lineWidth: 0.5, 13 | strokeStyle: '#0039a6', 14 | fillStyle: 'black', 15 | antiAlias: false, 16 | dashPattern: [], 17 | }; 18 | 19 | export const DEFAULT_STYLES: DrawStyle = { 20 | lineWidth: 0.75, 21 | strokeStyle: 'black', 22 | fillStyle: 'white', 23 | antiAlias: false, 24 | dashPattern: [], 25 | }; 26 | 27 | export interface HandleDimensions { 28 | size: number; 29 | } 30 | 31 | type HandleStyle = DrawStyle & HandleDimensions; 32 | 33 | export interface HandleStyles { 34 | idle: HandleStyle; 35 | hovered: HandleStyle; 36 | selected: HandleStyle; 37 | } 38 | 39 | export const HANDLE_STYLES: Record = { 40 | first: { 41 | idle: { 42 | size: 3, 43 | lineWidth: 0.75, 44 | antiAlias: false, 45 | strokeStyle: '#b933ad', 46 | fillStyle: '#b933ad', 47 | dashPattern: [], 48 | }, 49 | hovered: { 50 | size: 3.5, 51 | lineWidth: 0.75, 52 | antiAlias: false, 53 | strokeStyle: '#b933ad', 54 | fillStyle: '#b933ad', 55 | dashPattern: [], 56 | }, 57 | selected: { 58 | size: 5, 59 | lineWidth: 0.75, 60 | antiAlias: false, 61 | strokeStyle: '#b933ad', 62 | fillStyle: '#b933ad', 63 | dashPattern: [], 64 | }, 65 | }, 66 | corner: { 67 | idle: { 68 | size: 6, 69 | lineWidth: 0.75, 70 | antiAlias: false, 71 | strokeStyle: '#b933ad', 72 | fillStyle: 'transparent', 73 | dashPattern: [], 74 | }, 75 | hovered: { 76 | size: 8, 77 | lineWidth: 1, 78 | antiAlias: false, 79 | strokeStyle: '#b933ad', 80 | fillStyle: 'transparent', 81 | dashPattern: [], 82 | }, 83 | selected: { 84 | size: 12, 85 | lineWidth: 1, 86 | antiAlias: false, 87 | strokeStyle: '#b933ad', 88 | fillStyle: '#b933ad', 89 | dashPattern: [], 90 | }, 91 | }, 92 | control: { 93 | idle: { 94 | size: 1.5, 95 | lineWidth: 2, 96 | antiAlias: false, 97 | strokeStyle: '#ff6319', 98 | fillStyle: 'white', 99 | dashPattern: [], 100 | }, 101 | hovered: { 102 | size: 3, 103 | lineWidth: 2, 104 | strokeStyle: '#ff6319', 105 | fillStyle: '#ff6319', 106 | antiAlias: true, 107 | dashPattern: [], 108 | }, 109 | selected: { 110 | size: 4, 111 | lineWidth: 1, 112 | strokeStyle: '#ff6319', 113 | fillStyle: '#ff6319', 114 | antiAlias: true, 115 | dashPattern: [], 116 | }, 117 | }, 118 | smooth: { 119 | idle: { 120 | size: 3, 121 | lineWidth: 2, 122 | antiAlias: false, 123 | strokeStyle: 'green', 124 | fillStyle: 'green', 125 | dashPattern: [], 126 | }, 127 | hovered: { 128 | size: 3.5, 129 | lineWidth: 2, 130 | antiAlias: false, 131 | strokeStyle: 'green', 132 | fillStyle: 'green', 133 | dashPattern: [], 134 | }, 135 | selected: { 136 | size: 5, 137 | lineWidth: 2, 138 | antiAlias: false, 139 | strokeStyle: 'green', 140 | fillStyle: 'green', 141 | dashPattern: [], 142 | }, 143 | }, 144 | direction: { 145 | idle: { 146 | size: 7, 147 | lineWidth: 0.75, 148 | antiAlias: false, 149 | strokeStyle: '#0039a6', 150 | fillStyle: 'black', 151 | dashPattern: [], 152 | }, 153 | hovered: { 154 | size: 8, 155 | lineWidth: 1, 156 | strokeStyle: '#0039a6', 157 | fillStyle: '#0039a6', 158 | dashPattern: [], 159 | }, 160 | selected: { 161 | size: 9, 162 | lineWidth: 1, 163 | strokeStyle: '#0039a6', 164 | fillStyle: '#0039a6', 165 | dashPattern: [], 166 | }, 167 | }, 168 | last: { 169 | idle: { 170 | size: 6, 171 | lineWidth: 0.75, 172 | antiAlias: false, 173 | strokeStyle: '#b933ad', 174 | fillStyle: '#b933ad', 175 | dashPattern: [], 176 | }, 177 | hovered: { 178 | size: 8, 179 | lineWidth: 0.75, 180 | antiAlias: false, 181 | strokeStyle: '#b933ad', 182 | fillStyle: '#b933ad', 183 | dashPattern: [], 184 | }, 185 | selected: { 186 | size: 10, 187 | lineWidth: 0.75, 188 | antiAlias: false, 189 | strokeStyle: '#b933ad', 190 | fillStyle: '#b933ad', 191 | dashPattern: [], 192 | }, 193 | }, 194 | }; 195 | 196 | export const SELECTION_RECTANGLE_STYLES: DrawStyle = { 197 | lineWidth: 1, 198 | strokeStyle: '#0c8ce9', 199 | fillStyle: 'rgba(59, 130, 246, 0.04)', 200 | antiAlias: false, 201 | dashPattern: [], 202 | }; 203 | 204 | export const BOUNDING_RECTANGLE_STYLES: DrawStyle = { 205 | lineWidth: 0.5, 206 | strokeStyle: '#353535', 207 | fillStyle: 'transparent', 208 | antiAlias: false, 209 | dashPattern: [5, 5], 210 | }; 211 | -------------------------------------------------------------------------------- /apps/desktop/lib/tools/Hand.ts: -------------------------------------------------------------------------------- 1 | import { Point2D } from '@/types/math'; 2 | import { Tool, ToolName } from '@/types/tool'; 3 | 4 | import { Editor } from '../editor/Editor'; 5 | 6 | export type HandState = 7 | | { type: 'idle' } 8 | | { type: 'ready' } 9 | | { type: 'dragging'; startPos: Point2D; startPan: Point2D }; 10 | 11 | export class Hand implements Tool { 12 | public readonly name: ToolName = 'hand'; 13 | 14 | #editor: Editor; 15 | #state: HandState; 16 | 17 | constructor(editor: Editor) { 18 | this.#editor = editor; 19 | this.#state = { type: 'idle' }; 20 | } 21 | 22 | setIdle(): void { 23 | this.#state = { type: 'idle' }; 24 | } 25 | 26 | setReady(): void { 27 | this.#state = { type: 'ready' }; 28 | } 29 | 30 | onMouseDown(e: React.MouseEvent): void { 31 | const startPos = this.#editor.getMousePosition(e.clientX, e.clientY); 32 | const startPan = this.#editor.getPan(); 33 | 34 | this.#state = { type: 'dragging', startPos, startPan }; 35 | } 36 | 37 | onMouseMove(e: React.MouseEvent): void { 38 | if (this.#state.type !== 'dragging') return; 39 | 40 | const { x, y } = this.#editor.getMousePosition(e.clientX, e.clientY); 41 | 42 | const dx = x - this.#state.startPos.x; 43 | const dy = y - this.#state.startPos.y; 44 | 45 | const panX = this.#state.startPan.x + dx; 46 | const panY = this.#state.startPan.y + dy; 47 | 48 | this.#editor.pan(panX, panY); 49 | this.#editor.requestRedraw(); 50 | } 51 | 52 | onMouseUp(_: React.MouseEvent): void { 53 | this.#state = { type: 'idle' }; 54 | this.#editor.cancelRedraw(); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /apps/desktop/lib/tools/Pen.ts: -------------------------------------------------------------------------------- 1 | import { Editor } from '@/lib/editor/Editor'; 2 | import { IRenderer } from '@/types/graphics'; 3 | import { Point2D } from '@/types/math'; 4 | import { Tool, ToolName } from '@/types/tool'; 5 | 6 | import { EntityId } from '../core/EntityId'; 7 | import { Point } from '../math/point'; 8 | import { DEFAULT_STYLES } from '../styles/style'; 9 | 10 | interface AddedPoint { 11 | point: Point2D; 12 | entityId: EntityId; 13 | } 14 | 15 | export type PenState = 16 | | { type: 'ready' } 17 | | { type: 'idle' } 18 | | { type: 'dragging'; point: AddedPoint } 19 | | { 20 | type: 'draggingHandle'; 21 | cornerPoint: Point2D; 22 | segmentId: EntityId; 23 | trailingPoint: Point2D; 24 | }; 25 | 26 | export class Pen implements Tool { 27 | public readonly name: ToolName = 'pen'; 28 | 29 | #editor: Editor; 30 | #toolState: PenState; 31 | 32 | public constructor(editor: Editor) { 33 | this.#editor = editor; 34 | this.#toolState = { type: 'idle' }; 35 | } 36 | 37 | setIdle(): void { 38 | this.#toolState = { type: 'idle' }; 39 | } 40 | 41 | setReady(): void { 42 | this.#toolState = { type: 'ready' }; 43 | } 44 | 45 | onMouseDown(e: React.MouseEvent): void { 46 | if (e.button !== 0) return; 47 | if (this.#toolState.type !== 'ready') return; 48 | 49 | const position = this.#editor.getMousePosition(e.clientX, e.clientY); 50 | const { x, y } = this.#editor.projectScreenToUpm(position.x, position.y); 51 | const addedPointId = this.#editor.addPoint(x, y, 'onCurve'); 52 | 53 | this.#toolState = { 54 | type: 'dragging', 55 | point: { 56 | point: { x, y }, 57 | entityId: addedPointId, 58 | }, 59 | }; 60 | 61 | this.#editor.emit('points:added', { pointIds: [addedPointId] }); 62 | this.#editor.requestRedraw(); 63 | } 64 | 65 | onMouseUp(_: React.MouseEvent): void { 66 | // TODO: if we create a cubic, keep all the points here 67 | this.#toolState = { type: 'ready' }; 68 | this.#editor.requestRedraw(); 69 | } 70 | 71 | onMouseMove(e: React.MouseEvent): void { 72 | const position = this.#editor.getMousePosition(e.clientX, e.clientY); 73 | const { x, y } = this.#editor.projectScreenToUpm(position.x, position.y); 74 | 75 | switch (this.#toolState.type) { 76 | case 'dragging': 77 | { 78 | const distance = Point.distance( 79 | this.#toolState.point.point.x, 80 | this.#toolState.point.point.y, 81 | x, 82 | y 83 | ); 84 | 85 | if (this.#toolState.point.entityId && distance > 3) { 86 | const id = this.#editor.upgradeLineSegment(this.#toolState.point.entityId); 87 | 88 | this.#toolState = { 89 | type: 'draggingHandle', 90 | trailingPoint: { x, y }, 91 | cornerPoint: this.#toolState.point.point, 92 | segmentId: id, 93 | }; 94 | } 95 | } 96 | break; 97 | 98 | case 'draggingHandle': { 99 | this.#toolState = { 100 | ...this.#toolState, 101 | trailingPoint: { x, y }, 102 | }; 103 | 104 | const segment = this.#editor.getSegment(this.#toolState.segmentId); 105 | 106 | if (segment && segment.type === 'cubic') { 107 | const c2 = segment.points.control2; 108 | const anchorX = this.#toolState.cornerPoint.x; 109 | const anchorY = this.#toolState.cornerPoint.y; 110 | const oppositeX = 2 * anchorX - x; 111 | const oppositeY = 2 * anchorY - y; 112 | 113 | this.#editor.movePointTo(c2.entityId, oppositeX, oppositeY); 114 | this.#editor.redrawGlyph(); 115 | } 116 | } 117 | } 118 | 119 | this.#editor.requestRedraw(); 120 | } 121 | 122 | drawTrailingHandle(ctx: IRenderer, x: number, y: number) { 123 | this.#editor.paintHandle(ctx, x, y, 'control', 'idle'); 124 | } 125 | 126 | drawInteractive(ctx: IRenderer): void { 127 | if (this.#toolState.type !== 'draggingHandle') return; 128 | 129 | ctx.setStyle({ 130 | ...DEFAULT_STYLES, 131 | }); 132 | 133 | ctx.beginPath(); 134 | ctx.moveTo(this.#toolState.trailingPoint.x, this.#toolState.trailingPoint.y); 135 | ctx.lineTo(this.#toolState.cornerPoint.x, this.#toolState.cornerPoint.y); 136 | ctx.stroke(); 137 | this.drawTrailingHandle(ctx, this.#toolState.trailingPoint.x, this.#toolState.trailingPoint.y); 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /apps/desktop/lib/tools/Select.ts: -------------------------------------------------------------------------------- 1 | import { ContourPoint } from '@/lib/core/Contour'; 2 | import { UPMRect } from '@/lib/math/rect'; 3 | import { SELECTION_RECTANGLE_STYLES } from '@/lib/styles/style'; 4 | import { EditSession } from '@/types/edit'; 5 | import { IRenderer } from '@/types/graphics'; 6 | import { Point2D } from '@/types/math'; 7 | import { NUDGES_VALUES } from '@/types/nudge'; 8 | import { Tool, ToolName } from '@/types/tool'; 9 | 10 | export type SelectState = 11 | | { type: 'idle' } 12 | | { type: 'ready' } 13 | | { type: 'selecting'; startPos: Point2D } 14 | | { 15 | type: 'modifying'; 16 | startPos: Point2D; 17 | selectedPoint?: ContourPoint; 18 | shiftModifierOn?: boolean; 19 | }; 20 | 21 | export class Select implements Tool { 22 | public readonly name: ToolName = 'select'; 23 | 24 | #session: EditSession; 25 | #state: SelectState; 26 | #selectionRect: UPMRect; 27 | 28 | public constructor(session: EditSession) { 29 | this.#session = session; 30 | this.#state = { type: 'idle' }; 31 | this.#selectionRect = new UPMRect(0, 0, 0, 0); 32 | } 33 | 34 | setIdle(): void { 35 | this.#state = { type: 'idle' }; 36 | } 37 | 38 | setReady(): void { 39 | this.#state = { type: 'ready' }; 40 | } 41 | 42 | gatherHitPoints(hitTest: (p: ContourPoint) => boolean): ContourPoint[] { 43 | return this.#session.getAllPoints().filter(hitTest); 44 | } 45 | 46 | isPointSelected(point: ContourPoint): boolean { 47 | return this.#session.getSelectedPoints().includes(point); 48 | } 49 | 50 | onMouseDown(e: React.MouseEvent): void { 51 | const { x, y } = this.#session.getMousePosition(e.clientX, e.clientY); 52 | 53 | const hitPoints = this.gatherHitPoints((p) => p.distance(x, y) < 4); 54 | const firstHitPoint = hitPoints[0]; 55 | 56 | switch (this.#state.type) { 57 | case 'ready': 58 | if (hitPoints.length === 1) { 59 | this.#state = { type: 'modifying', startPos: { x, y }, selectedPoint: firstHitPoint }; 60 | this.#session.setSelectedPoints(hitPoints); 61 | break; 62 | } 63 | 64 | this.#state = { type: 'selecting', startPos: { x, y } }; 65 | break; 66 | case 'modifying': 67 | if (hitPoints.length === 0) { 68 | this.#state = { type: 'ready' }; 69 | this.#session.clearSelectedPoints(); 70 | break; 71 | } 72 | 73 | if (!this.isPointSelected(firstHitPoint)) { 74 | if (this.#state.shiftModifierOn) { 75 | const selectedPoints = this.#session.getSelectedPoints(); 76 | this.#session.setSelectedPoints([...selectedPoints, ...hitPoints]); 77 | } else { 78 | this.#session.clearSelectedPoints(); 79 | this.#session.setSelectedPoints(hitPoints); 80 | } 81 | } 82 | 83 | this.#state = { type: 'modifying', startPos: { x, y }, selectedPoint: firstHitPoint }; 84 | break; 85 | } 86 | 87 | this.#session.redraw(); 88 | } 89 | 90 | onMouseMove(e: React.MouseEvent): void { 91 | const { x, y } = this.#session.getMousePosition(e.clientX, e.clientY); 92 | 93 | if (this.#state.type === 'selecting') { 94 | const width = x - this.#state.startPos.x; 95 | const height = y - this.#state.startPos.y; 96 | 97 | this.#selectionRect.changeOrigin(this.#state.startPos.x, this.#state.startPos.y); 98 | this.#selectionRect.resize(width, height); 99 | } 100 | 101 | // move the point, if it's an active handle move all points by delta 102 | // otherwise we need to move proportional to an anchor point 103 | if (this.#state.type === 'modifying' && this.#state.selectedPoint) { 104 | const dx = x - this.#state.selectedPoint.x; 105 | const dy = y - this.#state.selectedPoint.y; 106 | 107 | this.#session.preview(dx, dy); 108 | // this.#session.commit(); 109 | } 110 | 111 | const hitPoints = this.gatherHitPoints((p) => p.distance(x, y) < 4); 112 | 113 | if (hitPoints.length > 0) { 114 | this.#session.setHoveredPoint(hitPoints[0]); 115 | } else { 116 | this.#session.clearHoveredPoint(); 117 | } 118 | 119 | this.#session.redraw(); 120 | } 121 | 122 | onMouseUp(e: React.MouseEvent): void { 123 | const { x, y } = this.#session.getMousePosition(e.clientX, e.clientY); 124 | 125 | if (this.#state.type === 'selecting') { 126 | const hitPoints = this.gatherHitPoints((p) => this.#selectionRect.hit(p.x, p.y)); 127 | 128 | if (hitPoints.length === 0) { 129 | this.#state = { type: 'ready' }; 130 | } else { 131 | this.#session.setSelectedPoints(hitPoints); 132 | this.#state = { type: 'modifying', startPos: { x, y } }; 133 | } 134 | 135 | this.#selectionRect.clear(); 136 | } 137 | 138 | if (this.#state.type === 'modifying' && this.#state.selectedPoint) { 139 | const dx = x - this.#state.startPos.x; 140 | const dy = y - this.#state.startPos.y; 141 | 142 | // this.#session.preview(dx, dy); 143 | // this.#session.commit(dx, dy); 144 | this.#state.selectedPoint = undefined; 145 | } 146 | 147 | this.#session.redraw(); 148 | } 149 | 150 | drawInteractive(ctx: IRenderer): void { 151 | switch (this.#state.type) { 152 | case 'selecting': 153 | ctx.setStyle(SELECTION_RECTANGLE_STYLES); 154 | ctx.fillRect( 155 | this.#selectionRect.x, 156 | this.#selectionRect.y, 157 | this.#selectionRect.width, 158 | this.#selectionRect.height 159 | ); 160 | 161 | ctx.setStyle(SELECTION_RECTANGLE_STYLES); 162 | ctx.strokeRect( 163 | this.#selectionRect.x, 164 | this.#selectionRect.y, 165 | this.#selectionRect.width, 166 | this.#selectionRect.height 167 | ); 168 | break; 169 | } 170 | } 171 | 172 | keyDownHandler(e: KeyboardEvent) { 173 | if (this.#state.type === 'modifying') { 174 | this.#state.shiftModifierOn = e.shiftKey; 175 | const modifier = e.metaKey ? 'large' : e.shiftKey ? 'medium' : 'small'; 176 | const nudgeValue = NUDGES_VALUES[modifier]; 177 | 178 | const nudge = (dx: number, dy: number) => { 179 | this.#session.preview(dx, dy); 180 | this.#session.commit(dx, dy); 181 | this.#session.redraw(); 182 | }; 183 | 184 | switch (e.key) { 185 | case 'ArrowLeft': 186 | nudge(-nudgeValue, 0); 187 | break; 188 | 189 | case 'ArrowRight': 190 | nudge(nudgeValue, 0); 191 | break; 192 | 193 | case 'ArrowUp': 194 | nudge(0, nudgeValue); 195 | break; 196 | 197 | case 'ArrowDown': 198 | nudge(0, -nudgeValue); 199 | break; 200 | } 201 | } 202 | } 203 | 204 | keyUpHandler(_: KeyboardEvent) { 205 | if (this.#state.type === 'modifying') { 206 | this.#state.shiftModifierOn = false; 207 | } 208 | } 209 | 210 | onDoubleClick(e: React.MouseEvent): void { 211 | const { x, y } = this.#session.getMousePosition(e.clientX, e.clientY); 212 | 213 | const hitPoints = this.gatherHitPoints((p) => p.distance(x, y) < 4); 214 | 215 | if (hitPoints.length === 1) { 216 | const point = hitPoints[0]; 217 | 218 | if (point.pointType === 'onCurve') { 219 | point.toggleSmooth(); 220 | } 221 | } 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /apps/desktop/lib/tools/Shape.ts: -------------------------------------------------------------------------------- 1 | import { Editor } from '@/lib/editor/Editor'; 2 | import { DEFAULT_STYLES } from '@/lib/styles/style'; 3 | import { IRenderer } from '@/types/graphics'; 4 | import { Point2D, Rect2D } from '@/types/math'; 5 | import { Tool, ToolName } from '@/types/tool'; 6 | 7 | export type ShapeState = 8 | | { type: 'idle' } 9 | | { type: 'ready' } 10 | | { type: 'dragging'; startPos: Point2D }; 11 | 12 | export class Shape implements Tool { 13 | public readonly name: ToolName = 'shape'; 14 | #editor: Editor; 15 | #state: ShapeState; 16 | #rect: Rect2D; 17 | 18 | constructor(editor: Editor) { 19 | this.#editor = editor; 20 | this.#state = { type: 'idle' }; 21 | this.#rect = { 22 | x: 0, 23 | y: 0, 24 | width: 0, 25 | height: 0, 26 | left: 0, 27 | top: 0, 28 | right: 0, 29 | bottom: 0, 30 | }; 31 | } 32 | 33 | setIdle(): void { 34 | this.#state = { type: 'idle' }; 35 | } 36 | 37 | setReady(): void { 38 | this.#state = { type: 'ready' }; 39 | } 40 | 41 | onMouseDown(e: React.MouseEvent): void { 42 | const position = this.#editor.getMousePosition(e.clientX, e.clientY); 43 | const { x, y } = this.#editor.projectScreenToUpm(position.x, position.y); 44 | this.#state = { type: 'dragging', startPos: { x, y } }; 45 | } 46 | 47 | onMouseUp(_: React.MouseEvent): void { 48 | this.#state = { type: 'ready' }; 49 | 50 | const pointIds = []; 51 | 52 | pointIds.push(this.#editor.addPoint(this.#rect.x, this.#rect.y, 'onCurve')); 53 | pointIds.push(this.#editor.addPoint(this.#rect.x + this.#rect.width, this.#rect.y, 'onCurve')); 54 | pointIds.push( 55 | this.#editor.addPoint( 56 | this.#rect.x + this.#rect.width, 57 | this.#rect.y + this.#rect.height, 58 | 'onCurve' 59 | ) 60 | ); 61 | pointIds.push(this.#editor.addPoint(this.#rect.x, this.#rect.y + this.#rect.height, 'onCurve')); 62 | this.#editor.closeContour(); 63 | 64 | this.#editor.emit('points:added', { pointIds }); 65 | } 66 | 67 | onMouseMove(e: React.MouseEvent): void { 68 | if (this.#state.type !== 'dragging') return; 69 | 70 | const position = this.#editor.getMousePosition(e.clientX, e.clientY); 71 | const { x, y } = this.#editor.projectScreenToUpm(position.x, position.y); 72 | 73 | const width = x - this.#state.startPos.x; 74 | const height = y - this.#state.startPos.y; 75 | 76 | this.#rect = { 77 | x: this.#state.startPos.x, 78 | y: this.#state.startPos.y, 79 | width, 80 | height, 81 | left: this.#state.startPos.x, 82 | top: this.#state.startPos.y, 83 | right: this.#state.startPos.x + width, 84 | bottom: this.#state.startPos.y + height, 85 | }; 86 | 87 | this.#editor.requestRedraw(); 88 | } 89 | 90 | drawInteractive(ctx: IRenderer): void { 91 | if (this.#state.type !== 'dragging') return; 92 | 93 | ctx.setStyle({ 94 | ...DEFAULT_STYLES, 95 | fillStyle: 'transparent', 96 | }); 97 | 98 | ctx.strokeRect(this.#rect.x, this.#rect.y, this.#rect.width, this.#rect.height); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /apps/desktop/lib/tools/tools.ts: -------------------------------------------------------------------------------- 1 | import HandIcon from '@/assets/toolbar/hand.svg'; 2 | import PenIcon from '@/assets/toolbar/pen.svg'; 3 | import SelectIcon from '@/assets/toolbar/select.svg'; 4 | import ShapeIcon from '@/assets/toolbar/shape.svg'; 5 | import { Editor } from '@/lib/editor/Editor'; 6 | import { ToolName, Tool } from '@/types/tool'; 7 | 8 | import { Hand } from './Hand'; 9 | import { Pen } from './Pen'; 10 | import { Select } from './Select'; 11 | import { Shape } from './Shape'; 12 | 13 | export interface ToolRegistryItem { 14 | tool: Tool; 15 | icon: React.FC>; 16 | tooltip: string; 17 | } 18 | 19 | export const tools = new Map(); 20 | 21 | export const createToolRegistry = (editor: Editor) => { 22 | tools.set('select', { 23 | tool: new Select(editor.editSession()), 24 | icon: SelectIcon, 25 | tooltip: 'Select Tool (V)', 26 | }); 27 | tools.set('pen', { tool: new Pen(editor), icon: PenIcon, tooltip: 'Pen Tool (P)' }); 28 | tools.set('hand', { tool: new Hand(editor), icon: HandIcon, tooltip: 'Hand Tool (H)' }); 29 | tools.set('shape', { tool: new Shape(editor), icon: ShapeIcon, tooltip: 'Shape Tool (S)' }); 30 | }; 31 | -------------------------------------------------------------------------------- /apps/desktop/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from 'clsx'; 2 | import { twMerge } from 'tailwind-merge'; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | -------------------------------------------------------------------------------- /apps/desktop/lib/utils/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Returns the remainder of a number divided by a divisor, ensuring the result is positive. 3 | * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Remainder 4 | * To obtain a modulo in JavaScript, in place of n % d, use ((n % d) + d) % d. 5 | */ 6 | export const mod = (n: number, d: number) => { 7 | return ((n % d) + d) % d; 8 | }; 9 | 10 | /** 11 | * Returns the width and height of the canvas, taking into account the device pixel ratio. 12 | * @param w - The width of the canvas. 13 | * @param h - The height of the canvas. 14 | * @returns The width and height of the canvas, taking into account the device pixel ratio. 15 | */ 16 | export const dprWH = (w: number, h: number) => { 17 | const dpr = window.devicePixelRatio || 1; 18 | return { 19 | dpr, 20 | width: w * dpr, 21 | height: h * dpr, 22 | }; 23 | }; 24 | 25 | /** 26 | * Scales the canvas to the device pixel ratio. 27 | * @param canvas - The canvas to scale. 28 | */ 29 | export const scaleCanvasDPR = (canvas: HTMLCanvasElement) => { 30 | const rect = canvas.getBoundingClientRect(); 31 | const { width, height } = dprWH(rect.width, rect.height); 32 | 33 | canvas.width = width; 34 | canvas.height = height; 35 | }; 36 | 37 | export const clamp = (value: number, min: number, max: number) => { 38 | return Math.min(Math.max(value, min), max); 39 | }; 40 | -------------------------------------------------------------------------------- /apps/desktop/main.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { createRoot } from 'react-dom/client'; 4 | 5 | import { App } from './app/App'; 6 | import './index.css'; 7 | 8 | createRoot(document.getElementById('root') as HTMLElement).render( 9 | 10 | 11 | 12 | ); 13 | -------------------------------------------------------------------------------- /apps/desktop/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@shift/desktop", 3 | "private": true, 4 | "version": "0.1.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "test": "vitest run", 9 | "test:watch": "vitest", 10 | "clean": "rm -rf dist && rm -rf .turbo && rm -rf node_modules", 11 | "build": "vite build", 12 | "preview": "vite preview", 13 | "lint": "eslint --ext .ts,.tsx src/", 14 | "lint:fix": "eslint --ext .ts,.tsx src/ --fix", 15 | "format": "prettier --write \"src/**/*.{ts,tsx}\"" 16 | }, 17 | "dependencies": { 18 | "@radix-ui/react-tooltip": "^1.1.8", 19 | "@shift/shared": "workspace:*", 20 | "@tauri-apps/api": "^2", 21 | "canvaskit-wasm": "^0.39.1", 22 | "chroma-js": "^3.1.2", 23 | "class-variance-authority": "^0.7.1", 24 | "clsx": "^2.1.1", 25 | "lucide-react": "^0.485.0", 26 | "react": "^18.2.0", 27 | "react-dom": "^18.2.0", 28 | "react-router-dom": "^7.3.0", 29 | "tailwind-merge": "^3.0.2", 30 | "tw-animate-css": "^1.2.5", 31 | "vite-plugin-svgr": "^4.3.0", 32 | "zustand": "^5.0.2" 33 | }, 34 | "devDependencies": { 35 | "@tailwindcss/vite": "^4.0.3", 36 | "@types/chroma-js": "^2.4.4", 37 | "@types/node": "^22.13.10", 38 | "@types/react": "^19.0.7", 39 | "@types/react-dom": "^19.0.3", 40 | "@vitejs/plugin-react": "^4.2.1", 41 | "@vitest/ui": "^3.0.9", 42 | "autoprefixer": "^10.4.20", 43 | "eslint": "^8.57.1", 44 | "eslint-import-resolver-typescript": "^3.6.1", 45 | "eslint-plugin-import": "^2.29.1", 46 | "eslint-plugin-react": "^7.34.1", 47 | "eslint-plugin-react-hooks": "^4.6.0", 48 | "fast-glob": "^3.3.3", 49 | "husky": "^9.1.7", 50 | "lint-staged": "^15.4.3", 51 | "postcss": "^8.5.1", 52 | "prettier": "^3.5.3", 53 | "prettier-plugin-tailwindcss": "^0.6.11", 54 | "tailwindcss": "^4.0.3", 55 | "turbo": "^2.4.4", 56 | "typescript": "^5.2.2", 57 | "vite": "^5.3.1", 58 | "vitest": "^3.0.9" 59 | }, 60 | "prettier": { 61 | "plugins": [ 62 | "prettier-plugin-tailwindcss" 63 | ], 64 | "printWidth": 100, 65 | "semi": true, 66 | "singleQuote": true, 67 | "tabWidth": 2, 68 | "trailingComma": "es5" 69 | }, 70 | "husky": { 71 | "hooks": { 72 | "pre-commit": "lint-staged" 73 | } 74 | }, 75 | "lint-staged": { 76 | "src/**/*.{ts,tsx}": [ 77 | "eslint --fix", 78 | "prettier --write" 79 | ] 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /apps/desktop/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | autoprefixer: {}, 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /apps/desktop/public/canvaskit.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shift-editor/shift/ed519408f54119ea83678ee2390cea7e145148be/apps/desktop/public/canvaskit.wasm -------------------------------------------------------------------------------- /apps/desktop/public/cursors/pen@1.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /apps/desktop/public/cursors/pen@2.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /apps/desktop/store/store.ts: -------------------------------------------------------------------------------- 1 | import { Glyph } from '@shift/shared'; 2 | import { create } from 'zustand'; 3 | 4 | import { TauriEventEmitter } from '@/lib/core/EventEmitter'; 5 | import { Editor } from '@/lib/editor/Editor'; 6 | import { createToolRegistry } from '@/lib/tools/tools'; 7 | import { ToolName } from '@/types/tool'; 8 | 9 | interface AppState { 10 | editor: Editor; 11 | fileName: string; 12 | currentGlyph: Glyph | null; 13 | activeTool: ToolName; 14 | setActiveTool: (tool: ToolName) => void; 15 | setActiveGlyph: (glyph: Glyph) => void; 16 | } 17 | 18 | const AppState = create()((set) => { 19 | const tauriEventEmitter = new TauriEventEmitter(); 20 | const editor = new Editor(tauriEventEmitter); 21 | createToolRegistry(editor); 22 | 23 | return { 24 | editor, 25 | currentGlyph: null, 26 | fileName: '', 27 | activeTool: 'select', 28 | setActiveTool: (tool: ToolName) => { 29 | set({ activeTool: tool }); 30 | }, 31 | setActiveGlyph: (glyph: Glyph) => { 32 | set({ currentGlyph: glyph }); 33 | }, 34 | setFileName: (fileName: string) => { 35 | set({ fileName }); 36 | }, 37 | }; 38 | }); 39 | 40 | export const getEditor = () => AppState.getState().editor; 41 | 42 | export default AppState; 43 | -------------------------------------------------------------------------------- /apps/desktop/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: ['./index.html', './app/**/*.{js,ts,jsx,tsx}'], 4 | theme: {}, 5 | plugins: [], 6 | }; 7 | -------------------------------------------------------------------------------- /apps/desktop/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | "esModuleInterop": true, 18 | "allowSyntheticDefaultImports": true, 19 | 20 | /* Linting */ 21 | "strict": true, 22 | "noUnusedLocals": true, 23 | "noUnusedParameters": true, 24 | "noFallthroughCasesInSwitch": true, 25 | 26 | "baseUrl": ".", 27 | "paths": { 28 | "@/*": ["./*"], 29 | "@/store": ["./store"], 30 | "@/store/*": ["./store/*"], 31 | "@/lib": ["./lib"], 32 | "@/lib/*": ["./lib/*"], 33 | "@/components": ["./components"], 34 | "@/components/*": ["./components/*"], 35 | "@/context": ["./context"], 36 | "@/context/*": ["./context/*"], 37 | "@/types": ["./types"], 38 | "@/types/*": ["./types/*"], 39 | "@/assets": ["./assets"], 40 | "@/assets/*": ["./assets/*"], 41 | "@/hooks": ["./hooks"], 42 | "@/hooks/*": ["./hooks/*"] 43 | }, 44 | "outDir": "./dist", 45 | "declaration": true 46 | }, 47 | "references": [{ "path": "./tsconfig.node.json" }], 48 | "include": ["**/*.ts", "**/*.tsx"], 49 | "exclude": ["vite.config.ts", "node_modules"] 50 | } 51 | -------------------------------------------------------------------------------- /apps/desktop/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /apps/desktop/types/common.ts: -------------------------------------------------------------------------------- 1 | export type Result = { success: true; data: T } | { success: false; error: E }; 2 | 3 | export type Svg = React.FC>; 4 | -------------------------------------------------------------------------------- /apps/desktop/types/edit.ts: -------------------------------------------------------------------------------- 1 | import { ContourPoint } from '@/lib/core/Contour'; 2 | import { EditContext } from '@/lib/core/EditEngine'; 3 | 4 | import { Point2D } from './math'; 5 | 6 | export type Edit = { 7 | point: ContourPoint; 8 | from: Point2D; 9 | to: Point2D; 10 | }; 11 | 12 | export interface AppliedEdit { 13 | point: ContourPoint; 14 | edits: Edit[]; 15 | affectedPoints: ContourPoint[]; 16 | } 17 | 18 | export interface EditRule { 19 | description: string; 20 | apply(ctx: EditContext, point: ContourPoint, dx: number, dy: number): AppliedEdit; 21 | } 22 | 23 | export interface EditSession { 24 | getMousePosition(x: number, y: number): Point2D; 25 | 26 | getSelectedPoints(): ContourPoint[]; 27 | getAllPoints(): ContourPoint[]; 28 | setSelectedPoints(points: ContourPoint[]): void; 29 | clearSelectedPoints(): void; 30 | 31 | getHoveredPoint(): ContourPoint | null; 32 | setHoveredPoint(point: ContourPoint | null): void; 33 | clearHoveredPoint(): void; 34 | 35 | preview(dx: number, dy: number): void; 36 | commit(dx: number, dy: number): void; 37 | redraw(): void; 38 | } 39 | -------------------------------------------------------------------------------- /apps/desktop/types/events.ts: -------------------------------------------------------------------------------- 1 | export type EventName = 'points:added' | 'points:moved' | 'points:removed' | 'segment:upgraded'; 2 | export type EventHandler = (data: T) => void; 3 | 4 | export interface IEventEmitter { 5 | on(event: EventName, handler: EventHandler): void; 6 | emit(event: EventName, data: T): void; 7 | off(event: EventName, handler: EventHandler): void; 8 | } 9 | -------------------------------------------------------------------------------- /apps/desktop/types/graphics.ts: -------------------------------------------------------------------------------- 1 | import { DrawStyle } from '@/lib/styles/style'; 2 | 3 | export type Colour = [number, number, number, number]; 4 | 5 | export interface IPath { 6 | moveTo(x: number, y: number): void; 7 | lineTo(x: number, y: number): void; 8 | closePath(): void; 9 | } 10 | 11 | export interface IRenderer { 12 | save(): void; 13 | restore(): void; 14 | flush(): void; 15 | clear(): void; 16 | dispose(): void; 17 | 18 | lineWidth: number; 19 | strokeStyle: string; 20 | fillStyle: string; 21 | antiAlias: boolean; 22 | dashPattern: number[]; 23 | 24 | setStyle(style: DrawStyle): void; 25 | 26 | drawLine(x0: number, y0: number, x1: number, y1: number): void; 27 | fillRect(x: number, y: number, width: number, height: number): void; 28 | strokeRect(x: number, y: number, width: number, height: number): void; 29 | fillCircle(x: number, y: number, radius: number): void; 30 | strokeCircle(x: number, y: number, radius: number): void; 31 | createPath(): IPath; 32 | beginPath(): void; 33 | moveTo(x: number, y: number): void; 34 | 35 | lineTo(x: number, y: number): void; 36 | drawLine(x0: number, y0: number, x1: number, y1: number): void; 37 | cubicTo(cpx1: number, cpy1: number, cpx2: number, cpy2: number, x: number, y: number): void; 38 | arcTo( 39 | x: number, 40 | y: number, 41 | radius: number, 42 | startAngle: number, 43 | endAngle: number, 44 | isCounterClockwise?: boolean 45 | ): void; 46 | 47 | closePath(): void; 48 | 49 | stroke(path?: IPath): void; 50 | fill(path?: IPath): void; 51 | 52 | scale(x: number, y: number): void; 53 | translate(x: number, y: number): void; 54 | 55 | /** 56 | * @param a - The scale factor for the x-axis 57 | * @param b - The shear factor for the x-axis 58 | * @param c - The shear factor for the y-axis 59 | * @param d - The scale factor for the y-axis 60 | * @param e - The x-axis translation 61 | * @param f - The y-axis translation 62 | */ 63 | transform(a: number, b: number, c: number, d: number, e: number, f: number): void; 64 | } 65 | 66 | export interface IGraphicContext { 67 | resizeCanvas(canvas: HTMLCanvasElement, rect: DOMRectReadOnly): void; 68 | 69 | getContext(): IRenderer; 70 | 71 | destroy(): void; 72 | } 73 | 74 | export type CanvasRef = React.RefObject; 75 | export type GraphicsContextRef = React.RefObject; 76 | -------------------------------------------------------------------------------- /apps/desktop/types/handle.ts: -------------------------------------------------------------------------------- 1 | export type HandleType = 'corner' | 'smooth' | 'control' | 'direction' | 'first' | 'last'; 2 | export type HandleState = 'idle' | 'hovered' | 'selected'; 3 | -------------------------------------------------------------------------------- /apps/desktop/types/math.ts: -------------------------------------------------------------------------------- 1 | export type Point2D = { x: number; y: number }; 2 | 3 | export type Rect2D = { 4 | x: number; 5 | y: number; 6 | width: number; 7 | height: number; 8 | left: number; 9 | top: number; 10 | right: number; 11 | bottom: number; 12 | }; 13 | 14 | /** X scale factor of the transformation matrix */ 15 | export type A = number; 16 | 17 | /** Y skew factor of the transformation matrix */ 18 | export type B = number; 19 | 20 | /** X skew factor of the transformation matrix */ 21 | export type C = number; 22 | 23 | /** Y scale factor of the transformation matrix */ 24 | export type D = number; 25 | 26 | /** X translation of the transformation matrix */ 27 | export type E = number; 28 | 29 | /** Y translation of the transformation matrix */ 30 | export type F = number; 31 | 32 | /** 33 | * Represents a 2D transformation matrix in the form: 34 | * ``` 35 | * | A C E | 36 | * | B D F | 37 | * | 0 0 1 | 38 | * ``` 39 | * Where: 40 | * - A is x scale factor 41 | * - B is y skew factor 42 | * - C is x skew factor 43 | * - D is y scale factor 44 | * - E is x translation 45 | * - F is y translation 46 | */ 47 | export type TransformMatrix = [A, B, C, D, E, F]; 48 | -------------------------------------------------------------------------------- /apps/desktop/types/nudge.ts: -------------------------------------------------------------------------------- 1 | export type NudgeMagnitude = 'small' | 'medium' | 'large'; 2 | export const NUDGES_VALUES: Record = { 3 | small: 1, 4 | medium: 10, 5 | large: 100, 6 | } as const; 7 | -------------------------------------------------------------------------------- /apps/desktop/types/segments.ts: -------------------------------------------------------------------------------- 1 | import { ContourPoint } from '@/lib/core/Contour'; 2 | 3 | export type SegmentType = 'line' | 'quad' | 'cubic'; 4 | 5 | export type LineSegment = { 6 | type: 'line'; 7 | points: { 8 | anchor1: ContourPoint; 9 | anchor2: ContourPoint; 10 | }; 11 | }; 12 | 13 | export type QuadSegment = { 14 | type: 'quad'; 15 | points: { 16 | anchor1: ContourPoint; 17 | control: ContourPoint; 18 | anchor2: ContourPoint; 19 | }; 20 | }; 21 | 22 | export type CubicSegment = { 23 | type: 'cubic'; 24 | points: { 25 | anchor1: ContourPoint; 26 | control1: ContourPoint; 27 | control2: ContourPoint; 28 | anchor2: ContourPoint; 29 | }; 30 | }; 31 | 32 | export type Segment = LineSegment | QuadSegment | CubicSegment; 33 | -------------------------------------------------------------------------------- /apps/desktop/types/svg.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.svg' { 2 | const ReactComponent: React.FC>; 3 | export default ReactComponent; 4 | } 5 | -------------------------------------------------------------------------------- /apps/desktop/types/tool.ts: -------------------------------------------------------------------------------- 1 | import { IRenderer } from './graphics'; 2 | 3 | export type ToolName = 'select' | 'pen' | 'hand' | 'shape' | 'disabled'; 4 | export interface Tool { 5 | name: ToolName; 6 | 7 | setIdle(): void; 8 | setReady(): void; 9 | 10 | onMouseDown(e: React.MouseEvent): void; 11 | onMouseUp(e: React.MouseEvent): void; 12 | onMouseMove(e: React.MouseEvent): void; 13 | 14 | keyDownHandler?(e: KeyboardEvent): void; 15 | keyUpHandler?(e: KeyboardEvent): void; 16 | onDoubleClick?(e: React.MouseEvent): void; 17 | 18 | drawInteractive?(ctx: IRenderer): void; 19 | } 20 | -------------------------------------------------------------------------------- /apps/desktop/views/Editor.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | import { useParams } from 'react-router-dom'; 4 | 5 | import { Toolbar } from '@/components/Toolbar'; 6 | import AppState from '@/store/store'; 7 | 8 | import { EditorView } from '../components/EditorView'; 9 | 10 | export const Editor = () => { 11 | const { glyphId } = useParams(); 12 | 13 | useEffect(() => { 14 | const editor = AppState.getState().editor; 15 | const switchTool = AppState.getState().setActiveTool; 16 | const activeTool = editor.activeTool(); 17 | 18 | const keyDownHandler = (e: KeyboardEvent) => { 19 | e.preventDefault(); 20 | 21 | if (e.key == '=' && e.metaKey) { 22 | editor.zoomIn(); 23 | editor.requestRedraw(); 24 | return; 25 | } 26 | 27 | if (e.key == '-' && e.metaKey) { 28 | editor.zoomOut(); 29 | editor.requestRedraw(); 30 | return; 31 | } 32 | 33 | if (e.key == 'h') { 34 | switchTool('hand'); 35 | editor.requestRedraw(); 36 | } 37 | 38 | // we don't want to trigger a redraw when the space bar is 39 | // held down 40 | if (e.key == ' ' && !e.repeat) { 41 | switchTool('hand'); 42 | editor.setFillContour(true); 43 | editor.requestRedraw(); 44 | } 45 | 46 | if (e.key == 'p') { 47 | switchTool('pen'); 48 | editor.requestRedraw(); 49 | } 50 | 51 | if (e.key == 's') { 52 | switchTool('shape'); 53 | editor.requestRedraw(); 54 | } 55 | 56 | if (e.key == 'v') { 57 | switchTool('select'); 58 | editor.requestRedraw(); 59 | } 60 | 61 | if (e.key == 'z' && e.metaKey) { 62 | editor.undo(); 63 | } 64 | 65 | if (activeTool.keyDownHandler) { 66 | activeTool.keyDownHandler(e); 67 | } 68 | }; 69 | 70 | const keyUpHandler = (e: KeyboardEvent) => { 71 | if (e.key == ' ') { 72 | switchTool('select'); 73 | editor.setFillContour(false); 74 | editor.requestRedraw(); 75 | } 76 | 77 | if (activeTool.keyUpHandler) { 78 | activeTool.keyUpHandler(e); 79 | } 80 | }; 81 | 82 | document.addEventListener('keydown', keyDownHandler); 83 | document.addEventListener('keyup', keyUpHandler); 84 | 85 | return () => { 86 | document.removeEventListener('keydown', keyDownHandler); 87 | document.removeEventListener('keyup', keyUpHandler); 88 | }; 89 | }, [glyphId]); 90 | 91 | return ( 92 |
93 | 94 | 95 |
96 | ); 97 | }; 98 | -------------------------------------------------------------------------------- /apps/desktop/views/FontInfo.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | import { Metrics } from '@shift/shared'; 4 | import { invoke } from '@tauri-apps/api/core'; 5 | 6 | import { Toolbar } from '@/components/Toolbar'; 7 | 8 | export const FontInfo = () => { 9 | const [metrics, setMetrics] = useState(null); 10 | 11 | useEffect(() => { 12 | const fetchFontMetrics = async () => { 13 | const metrics = await invoke('get_font_metrics'); 14 | setMetrics(metrics); 15 | }; 16 | 17 | fetchFontMetrics(); 18 | }, []); 19 | 20 | return ( 21 | <> 22 | 23 |
24 |

Font Info

25 |
26 |

Units per em: {metrics?.unitsPerEm}

27 |

Ascender: {metrics?.ascender}

28 |

Descender: {metrics?.descender}

29 |

Cap height: {metrics?.capHeight}

30 |
31 |
32 | 33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /apps/desktop/views/Home.tsx: -------------------------------------------------------------------------------- 1 | import { GlyphGrid } from '@/components/GlyphGrid'; 2 | import { Toolbar } from '@/components/Toolbar'; 3 | 4 | export const Home = () => { 5 | return ( 6 |
7 | 8 |
9 |
10 | 11 |
12 |
13 |
14 | ); 15 | }; 16 | -------------------------------------------------------------------------------- /apps/desktop/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /apps/desktop/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { spawn } from 'child_process'; 2 | import path from 'path'; 3 | 4 | import tailwindcss from '@tailwindcss/vite'; 5 | import react from '@vitejs/plugin-react'; 6 | import fg from 'fast-glob'; 7 | import { defineConfig } from 'vite'; 8 | import svgr from 'vite-plugin-svgr'; 9 | 10 | const host = process.env.TAURI_DEV_HOST; 11 | 12 | // Add this plugin to log path resolution 13 | const logResolve = () => { 14 | return { 15 | name: 'vite:log-resolve', 16 | configureServer(server) { 17 | server.middlewares.use((req, res, next) => { 18 | console.log(`[Path Request] ${req.url}`); 19 | next(); 20 | }); 21 | }, 22 | resolveId(id, importer) { 23 | if (id.startsWith('@')) { 24 | console.log(`[Resolve] ${id} from ${importer}`); 25 | } 26 | return null; 27 | }, 28 | }; 29 | }; 30 | 31 | // ts-rs live reload 32 | const tsTsRsSLiveReload = () => { 33 | return { 34 | name: 'shift:ts-rs-live-reload', 35 | async configureServer(server) { 36 | const { watcher } = server; 37 | 38 | const root = path.resolve(__dirname, '..', '..'); 39 | const crates = ['shift-font', 'shift-events']; 40 | 41 | const files = await fg( 42 | crates.map((crate) => path.join(root, 'crates', crate, 'src', '**/*.rs')) 43 | ); 44 | 45 | files.forEach((file) => { 46 | watcher.add(file); 47 | }); 48 | 49 | watcher.on('change', async (filePath) => { 50 | spawn('pnpm', ['types:rebuild'], { 51 | stdio: 'inherit', 52 | cwd: root, 53 | }); 54 | }); 55 | }, 56 | }; 57 | }; 58 | 59 | // https://vitejs.dev/config/ 60 | export default defineConfig(async () => ({ 61 | plugins: [ 62 | logResolve(), 63 | react(), 64 | tailwindcss(), 65 | tsTsRsSLiveReload(), 66 | svgr({ 67 | include: '**/*.svg', 68 | }), 69 | ], 70 | root: __dirname, 71 | publicDir: path.resolve(__dirname, 'public'), 72 | assetsInclude: ['**/*.ttf'], 73 | resolve: { 74 | alias: { 75 | '@': __dirname, 76 | '@/store': path.resolve(__dirname, 'store'), 77 | '@/lib': path.resolve(__dirname, 'lib'), 78 | '@/components': path.resolve(__dirname, 'components'), 79 | '@/context': path.resolve(__dirname, 'context'), 80 | '@/types': path.resolve(__dirname, 'types'), 81 | '@/assets': path.resolve(__dirname, 'assets'), 82 | '@/hooks': path.resolve(__dirname, 'hooks'), 83 | }, 84 | }, 85 | 86 | // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` 87 | // 88 | // 1. prevent vite from obscuring rust errors 89 | clearScreen: false, 90 | // 2. tauri expects a fixed port, fail if that port is not available 91 | server: { 92 | port: 1420, 93 | strictPort: true, 94 | host: host || false, 95 | hmr: host 96 | ? { 97 | protocol: 'ws', 98 | host, 99 | port: 1421, 100 | } 101 | : undefined, 102 | watch: { 103 | // 3. tell vite to ignore watching `src-tauri` 104 | // ignored: ['**/crates/shift-tauri/**'], 105 | }, 106 | }, 107 | })); 108 | -------------------------------------------------------------------------------- /crates/shift-editor/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "shift-editor" 3 | version = "0.1.0" 4 | edition = "2024" 5 | 6 | [dependencies] 7 | shift-font.workspace = true 8 | -------------------------------------------------------------------------------- /crates/shift-editor/src/editor.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use shift_font::font::{Font, Metrics}; 4 | use shift_font::font_service::FontService; 5 | use shift_font::glyph::Glyph; 6 | 7 | pub struct Editor { 8 | font_path: PathBuf, 9 | current_font: Font, 10 | font_service: FontService, 11 | } 12 | 13 | impl Editor { 14 | pub fn new() -> Self { 15 | let font_service = FontService::new(); 16 | Self { 17 | font_path: PathBuf::new(), 18 | current_font: Font::default(), 19 | font_service, 20 | } 21 | } 22 | 23 | pub fn current_font(&self) -> &Font { 24 | &self.current_font 25 | } 26 | 27 | pub fn font_path(&self) -> &PathBuf { 28 | &self.font_path 29 | } 30 | 31 | pub fn read_font(&mut self, path: &str) { 32 | let font = self 33 | .font_service 34 | .read_font(path) 35 | .expect("Failed to read font"); 36 | 37 | self.current_font = font; 38 | self.font_path = PathBuf::from(path); 39 | } 40 | 41 | pub fn get_font_metrics(&self) -> Metrics { 42 | Metrics { 43 | units_per_em: self.current_font.metrics.units_per_em, 44 | ascender: self.current_font.metrics.ascender, 45 | descender: self.current_font.metrics.descender, 46 | cap_height: self.current_font.metrics.cap_height, 47 | x_height: self.current_font.metrics.x_height, 48 | } 49 | } 50 | 51 | pub fn get_glyph(&mut self, unicode: u32) -> &Glyph { 52 | self.current_font 53 | .glyphs 54 | .entry(unicode) 55 | .or_insert_with(|| Glyph::new(unicode.to_string(), unicode, vec![], 600.0)) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /crates/shift-editor/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod editor; 2 | 3 | -------------------------------------------------------------------------------- /crates/shift-events/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "shift-events" 3 | version = "0.1.0" 4 | edition = "2024" 5 | 6 | [dependencies] 7 | serde = { version = "1", features = ["derive"] } 8 | ts-rs = "10.1" 9 | shift-font.workspace = true 10 | -------------------------------------------------------------------------------- /crates/shift-events/src/events.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use shift_font::entity::EntityId; 3 | use ts_rs::TS; 4 | 5 | #[derive(Serialize, Clone, Deserialize, TS)] 6 | #[ts(export)] 7 | #[serde(rename_all = "camelCase")] 8 | pub struct FontLoadedEvent { 9 | pub file_name: String, 10 | } 11 | 12 | #[derive(Serialize, Clone, TS)] 13 | #[ts(export)] 14 | #[serde(rename_all = "camelCase")] 15 | pub struct FontCompiledEvent { 16 | pub file_name: String, 17 | pub font_path: String, 18 | } 19 | 20 | #[derive(Serialize, Clone, TS)] 21 | #[ts(export)] 22 | #[serde(rename_all = "camelCase")] 23 | pub struct PointsAddedEvent { 24 | pub point_ids: Vec, 25 | } 26 | 27 | #[derive(Serialize, Clone, TS)] 28 | #[ts(export)] 29 | #[serde(rename_all = "camelCase")] 30 | pub struct MovedPoint { 31 | pub point_id: EntityId, 32 | pub from_x: f32, 33 | pub from_y: f32, 34 | pub to_x: f32, 35 | pub to_y: f32, 36 | } 37 | 38 | #[derive(Serialize, Clone, TS)] 39 | #[ts(export)] 40 | #[serde(rename_all = "camelCase")] 41 | pub struct PointsMovedEvent { 42 | pub points: Vec, 43 | } 44 | -------------------------------------------------------------------------------- /crates/shift-events/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod events; 2 | -------------------------------------------------------------------------------- /crates/shift-font/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "shift-font" 3 | version = "0.1.0" 4 | edition = "2024" 5 | 6 | [dependencies] 7 | write-fonts = { version = "0.36.3", features = ["read"] } 8 | skrifa = "0.28.1" 9 | norad = "0.15.0" 10 | serde = { version = "1", features = ["derive"] } 11 | ts-rs = "10.1" 12 | fontc = { git = "https://github.com/googlefonts/fontc" } 13 | -------------------------------------------------------------------------------- /crates/shift-font/src/contour.rs: -------------------------------------------------------------------------------- 1 | use serde::Serialize; 2 | use ts_rs::TS; 3 | 4 | #[derive(Serialize, Clone, TS)] 5 | #[ts(export)] 6 | #[serde(rename_all = "camelCase")] 7 | pub enum PointType { 8 | OnCurve, 9 | OffCurve, 10 | } 11 | 12 | #[derive(Serialize, Clone, TS)] 13 | #[ts(export)] 14 | #[serde(rename = "IContourPoint", rename_all = "camelCase")] 15 | pub struct ContourPoint { 16 | point_type: PointType, 17 | x: f64, 18 | y: f64, 19 | smooth: bool, 20 | } 21 | 22 | impl ContourPoint { 23 | pub fn new(x: f64, y: f64, point_type: PointType, smooth: bool) -> Self { 24 | Self { 25 | x, 26 | y, 27 | point_type, 28 | smooth, 29 | } 30 | } 31 | } 32 | 33 | #[derive(Serialize, Clone, TS)] 34 | #[ts(export, rename = "IContour")] 35 | pub struct Contour { 36 | points: Vec, 37 | closed: bool, 38 | } 39 | 40 | impl Contour { 41 | pub fn new() -> Self { 42 | Self { 43 | points: Vec::new(), 44 | closed: false, 45 | } 46 | } 47 | 48 | pub fn add_point(&mut self, p: ContourPoint) { 49 | self.points.push(p); 50 | } 51 | 52 | pub fn is_closed(&self) { 53 | self.closed; 54 | } 55 | 56 | pub fn close(&mut self) { 57 | self.closed = true; 58 | } 59 | 60 | pub fn to_svg(&self) -> String { 61 | let mut svg = String::new(); 62 | svg.push_str(""); 63 | svg.push_str(""); 65 | svg.push_str(""); 66 | 67 | svg 68 | } 69 | } 70 | 71 | #[cfg(test)] 72 | mod tests {} 73 | -------------------------------------------------------------------------------- /crates/shift-font/src/entity.rs: -------------------------------------------------------------------------------- 1 | use serde::Serialize; 2 | use ts_rs::TS; 3 | 4 | type Ident = usize; 5 | 6 | #[derive(Serialize, Clone, TS)] 7 | #[ts(export)] 8 | #[serde(rename_all = "camelCase")] 9 | pub struct EntityId { 10 | parent_id: Ident, 11 | id: Ident, 12 | } 13 | -------------------------------------------------------------------------------- /crates/shift-font/src/font.rs: -------------------------------------------------------------------------------- 1 | use crate::glyph::Glyph; 2 | use serde::{Deserialize, Serialize}; 3 | use std::collections::HashMap; 4 | use ts_rs::TS; 5 | 6 | #[derive(Serialize, Deserialize, Debug, Clone, TS)] 7 | #[ts(export, rename_all = "camelCase")] 8 | pub struct FontMetadata { 9 | pub family: String, 10 | pub style_name: String, 11 | pub version: i32, 12 | } 13 | 14 | impl Default for FontMetadata { 15 | fn default() -> Self { 16 | Self { 17 | family: "Untitled Font".to_string(), 18 | style_name: "Regular".to_string(), 19 | version: 1, 20 | } 21 | } 22 | } 23 | 24 | #[derive(Serialize, Deserialize, Debug, Clone, TS)] 25 | #[ts(export)] 26 | #[serde(rename_all = "camelCase")] 27 | pub struct Metrics { 28 | pub units_per_em: f64, 29 | pub ascender: f64, 30 | pub descender: f64, 31 | pub cap_height: f64, 32 | pub x_height: f64, 33 | } 34 | 35 | impl Default for Metrics { 36 | fn default() -> Self { 37 | Metrics { 38 | units_per_em: 1000.0, 39 | ascender: 750.0, 40 | descender: -200.0, 41 | cap_height: 700.0, 42 | x_height: 500.0, 43 | } 44 | } 45 | } 46 | 47 | #[derive(Serialize, Clone, TS)] 48 | #[ts(export)] 49 | pub struct Font { 50 | pub metadata: FontMetadata, 51 | pub metrics: Metrics, 52 | pub glyphs: HashMap, 53 | } 54 | 55 | impl Default for Font { 56 | fn default() -> Self { 57 | Self { 58 | metadata: FontMetadata::default(), 59 | metrics: Metrics::default(), 60 | glyphs: HashMap::new(), 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /crates/shift-font/src/font_service.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::path::Path; 3 | 4 | use crate::font::Font; 5 | use crate::otf_ttf::BytesFontAdaptor; 6 | use crate::ufo::UfoFontAdaptor; 7 | 8 | #[derive(Hash, Eq, PartialEq)] 9 | pub enum FontFormat { 10 | Ufo, 11 | Ttf, 12 | Otf, 13 | } 14 | 15 | pub trait FontAdaptor: Send + Sync { 16 | fn read_font(&self, path: &str) -> Result; 17 | fn write_font(&self, font: &Font, path: &str) -> Result<(), String>; 18 | } 19 | 20 | pub struct FontService { 21 | file_name: String, 22 | adaptors: HashMap>, 23 | } 24 | 25 | impl FontService { 26 | pub fn new() -> Self { 27 | let mut adaptors: HashMap> = HashMap::new(); 28 | adaptors.insert(FontFormat::Ufo, Box::new(UfoFontAdaptor)); 29 | adaptors.insert(FontFormat::Ttf, Box::new(BytesFontAdaptor)); 30 | adaptors.insert(FontFormat::Otf, Box::new(BytesFontAdaptor)); 31 | 32 | Self { 33 | file_name: String::new(), 34 | adaptors, 35 | } 36 | } 37 | 38 | pub fn available_formats(&self) -> Vec<&FontFormat> { 39 | self.adaptors.keys().collect() 40 | } 41 | 42 | pub fn read_font(&mut self, path: &str) -> Result { 43 | let path = Path::new(path); 44 | let extension = path 45 | .extension() 46 | .ok_or_else(|| "File has no extension".to_string())? 47 | .to_str() 48 | .ok_or_else(|| "Invalid UTF-8 in extension".to_string())?; 49 | 50 | let adaptor = match extension { 51 | "ufo" => self.adaptors.get(&FontFormat::Ufo).unwrap(), 52 | "ttf" => self.adaptors.get(&FontFormat::Ttf).unwrap(), 53 | "otf" => self.adaptors.get(&FontFormat::Otf).unwrap(), 54 | _ => { 55 | return Err(format!("Unsupported font format: {}", extension)); 56 | } 57 | }; 58 | 59 | let font = adaptor.read_font(path.to_str().unwrap())?; 60 | self.file_name = path.file_name().unwrap().to_str().unwrap().to_string(); 61 | Ok(font) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /crates/shift-font/src/glyph.rs: -------------------------------------------------------------------------------- 1 | use serde::Serialize; 2 | use ts_rs::TS; 3 | 4 | use crate::contour::Contour; 5 | 6 | #[derive(Clone, Serialize, TS)] 7 | #[ts(export)] 8 | pub struct Glyph { 9 | name: String, 10 | unicode: u32, 11 | contours: Vec, 12 | x_advance: f64, 13 | } 14 | 15 | impl Glyph { 16 | pub fn new(name: String, unicode: u32, contours: Vec, x_advance: f64) -> Self { 17 | Self { 18 | name, 19 | unicode, 20 | contours, 21 | x_advance, 22 | } 23 | } 24 | 25 | pub fn get_name(&self) -> &str { 26 | &self.name 27 | } 28 | 29 | pub fn get_contours(&self) -> &Vec { 30 | &self.contours 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /crates/shift-font/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod contour; 2 | pub mod entity; 3 | pub mod font; 4 | pub mod font_service; 5 | pub mod glyph; 6 | pub mod otf_ttf; 7 | pub mod path; 8 | pub mod ufo; 9 | -------------------------------------------------------------------------------- /crates/shift-font/src/otf_ttf.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::HashMap, 3 | path::{Path, PathBuf}, 4 | time::Instant, 5 | }; 6 | 7 | use crate::{ 8 | contour::{Contour, ContourPoint, PointType}, 9 | font::{Font, FontMetadata, Metrics}, 10 | font_service::FontAdaptor, 11 | glyph::Glyph, 12 | }; 13 | use fontc::JobTimer; 14 | use skrifa::{ 15 | FontRef, MetadataProvider, 16 | outline::{DrawSettings, OutlinePen}, 17 | prelude::{LocationRef, Size}, 18 | raw::TableProvider, 19 | }; 20 | 21 | pub fn load_font(font_bytes: &[u8]) -> Result { 22 | let font = FontRef::new(font_bytes).expect("Failed to load font"); 23 | Ok(font) 24 | } 25 | 26 | #[derive(Default)] 27 | struct ShiftPen { 28 | contours: Vec, 29 | } 30 | 31 | impl OutlinePen for ShiftPen { 32 | fn move_to(&mut self, x: f32, y: f32) { 33 | self.contours.push(Contour::new()); 34 | self.contours 35 | .last_mut() 36 | .unwrap() 37 | .add_point(ContourPoint::new( 38 | x as f64, 39 | y as f64, 40 | PointType::OnCurve, 41 | false, 42 | )); 43 | } 44 | 45 | fn line_to(&mut self, x: f32, y: f32) { 46 | self.contours 47 | .last_mut() 48 | .unwrap() 49 | .add_point(ContourPoint::new( 50 | x as f64, 51 | y as f64, 52 | PointType::OnCurve, 53 | false, 54 | )); 55 | } 56 | 57 | fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) { 58 | self.contours 59 | .last_mut() 60 | .unwrap() 61 | .add_point(ContourPoint::new( 62 | cx0 as f64, 63 | cy0 as f64, 64 | PointType::OffCurve, 65 | false, 66 | )); 67 | 68 | self.contours 69 | .last_mut() 70 | .unwrap() 71 | .add_point(ContourPoint::new( 72 | x as f64, 73 | y as f64, 74 | PointType::OnCurve, 75 | false, 76 | )); 77 | } 78 | 79 | fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) { 80 | self.contours 81 | .last_mut() 82 | .unwrap() 83 | .add_point(ContourPoint::new( 84 | cx0 as f64, 85 | cy0 as f64, 86 | PointType::OffCurve, 87 | false, 88 | )); 89 | 90 | self.contours 91 | .last_mut() 92 | .unwrap() 93 | .add_point(ContourPoint::new( 94 | cx1 as f64, 95 | cy1 as f64, 96 | PointType::OffCurve, 97 | false, 98 | )); 99 | 100 | self.contours 101 | .last_mut() 102 | .unwrap() 103 | .add_point(ContourPoint::new( 104 | x as f64, 105 | y as f64, 106 | PointType::OnCurve, 107 | false, 108 | )); 109 | } 110 | 111 | fn close(&mut self) { 112 | if let Some(contour) = self.contours.last_mut() { 113 | contour.close(); 114 | } 115 | } 116 | } 117 | 118 | impl ShiftPen { 119 | pub fn contours(self) -> Vec { 120 | self.contours 121 | } 122 | } 123 | 124 | impl<'a> From> for Font { 125 | fn from(font: FontRef) -> Self { 126 | let outlines = font.outline_glyphs(); 127 | let char_map = font.charmap(); 128 | 129 | let metrics = font.metrics(Size::unscaled(), LocationRef::default()); 130 | let mut glyphs = HashMap::new(); 131 | 132 | for (unicode, glyph_id) in char_map.mappings() { 133 | let outline = outlines.get(glyph_id).unwrap(); 134 | let settings = DrawSettings::unhinted(Size::unscaled(), LocationRef::default()); 135 | let mut pen = ShiftPen::default(); 136 | outline.draw(settings, &mut pen).unwrap(); 137 | 138 | let hmtx = font.hmtx().unwrap(); 139 | let advance_width = hmtx.advance(glyph_id).unwrap(); 140 | 141 | let glyph = Glyph::new(String::new(), unicode, pen.contours(), advance_width.into()); 142 | glyphs.insert(unicode, glyph); 143 | } 144 | 145 | Font { 146 | metadata: FontMetadata { 147 | family: String::new(), 148 | style_name: String::new(), 149 | version: 1, 150 | }, 151 | metrics: Metrics { 152 | units_per_em: metrics.units_per_em as f64, 153 | ascender: metrics.ascent as f64, 154 | descender: metrics.descent as f64, 155 | cap_height: metrics.cap_height.unwrap_or(0.0) as f64, 156 | x_height: metrics.x_height.unwrap_or(0.0) as f64, 157 | }, 158 | glyphs, 159 | } 160 | } 161 | } 162 | 163 | pub struct BytesFontAdaptor; 164 | impl FontAdaptor for BytesFontAdaptor { 165 | fn read_font(&self, path: &str) -> Result { 166 | let bytes = std::fs::read(path).unwrap(); 167 | let font = FontRef::new(&bytes).unwrap(); 168 | Ok(font.into()) 169 | } 170 | 171 | fn write_font(&self, font: &Font, path: &str) -> Result<(), String> { 172 | Ok(()) 173 | } 174 | } 175 | 176 | pub fn compile_font(path: &str, build_dir: &Path, output_name: &str) -> Result<(), String> { 177 | let mut args = fontc::Args::new(build_dir, PathBuf::from(path)); 178 | 179 | args.output_file = Some(PathBuf::from(output_name)); 180 | let timer = JobTimer::new(Instant::now()); 181 | let exec_result = fontc::run(args, timer); 182 | if exec_result.is_err() { 183 | return Err(format!( 184 | "Failed to compile font: {}", 185 | exec_result.err().unwrap() 186 | )); 187 | } 188 | Ok(()) 189 | } 190 | 191 | #[cfg(test)] 192 | mod tests { 193 | use super::*; 194 | 195 | #[test] 196 | fn can_load_font_from_file() { 197 | let font_bytes = std::fs::read("./src/fonts/Liverpool.ttf").unwrap(); 198 | let font = FontRef::new(&font_bytes).unwrap(); 199 | let glyphs = font.outline_glyphs(); 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /crates/shift-font/src/path.rs: -------------------------------------------------------------------------------- 1 | use serde::Serialize; 2 | use ts_rs::TS; 3 | 4 | #[derive(Debug, Clone, Serialize, TS)] 5 | #[ts(export)] 6 | #[serde(tag = "type", rename_all = "camelCase")] 7 | pub enum PathCommand { 8 | MoveTo { 9 | x: f32, 10 | y: f32, 11 | }, 12 | LineTo { 13 | x: f32, 14 | y: f32, 15 | }, 16 | CubicTo { 17 | cp1x: f32, 18 | cp1y: f32, 19 | cp2x: f32, 20 | cp2y: f32, 21 | x: f32, 22 | y: f32, 23 | }, 24 | QuadTo { 25 | cp1x: f32, 26 | cp1y: f32, 27 | x: f32, 28 | y: f32, 29 | }, 30 | Close, 31 | } 32 | -------------------------------------------------------------------------------- /crates/shift-tauri/.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | 5 | # Generated by Tauri 6 | # will have schema files for capabilities auto-completion 7 | /gen/schemas 8 | 9 | .vscode 10 | .bin -------------------------------------------------------------------------------- /crates/shift-tauri/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "shift-tauri" 3 | version = "0.1.0" 4 | description = "shift-tauri" 5 | authors = ["Kostya Farber"] 6 | edition = "2021" 7 | 8 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 9 | 10 | [lib] 11 | # The `_lib` suffix may seem redundant but it is necessary 12 | # to make the lib name unique and wouldn't conflict with the bin name. 13 | # This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519 14 | doctest = false 15 | crate-type = ["staticlib", "cdylib", "rlib"] 16 | 17 | [[bin]] 18 | name = "shift-tauri" 19 | path = "src/main.rs" 20 | test = false 21 | 22 | [build-dependencies] 23 | tauri-build = { version = "2", features = [] } 24 | 25 | [dependencies] 26 | tauri = { version = "2", features = [] } 27 | log = "0.4" 28 | tauri-plugin-shell = "2" 29 | 30 | serde = { version = "1", features = ["derive"] } 31 | serde_json = "1" 32 | ts-rs = "10.1" 33 | 34 | tauri-plugin-log = "2" 35 | tauri-plugin-dialog = "2.2.0" 36 | tauri-plugin-global-shortcut = "2" 37 | 38 | shift-font.workspace = true 39 | shift-editor.workspace = true 40 | shift-events.workspace = true 41 | -------------------------------------------------------------------------------- /crates/shift-tauri/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | tauri_build::build() 3 | } 4 | -------------------------------------------------------------------------------- /crates/shift-tauri/capabilities/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../gen/schemas/desktop-schema.json", 3 | "identifier": "default", 4 | "description": "Capability for the main window", 5 | "windows": ["main"], 6 | "permissions": [ 7 | "core:default", 8 | "shell:allow-open", 9 | "log:default", 10 | "global-shortcut:allow-is-registered", 11 | "global-shortcut:allow-register", 12 | "global-shortcut:allow-unregister" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /crates/shift-tauri/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shift-editor/shift/ed519408f54119ea83678ee2390cea7e145148be/crates/shift-tauri/icons/128x128.png -------------------------------------------------------------------------------- /crates/shift-tauri/icons/128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shift-editor/shift/ed519408f54119ea83678ee2390cea7e145148be/crates/shift-tauri/icons/128x128@2x.png -------------------------------------------------------------------------------- /crates/shift-tauri/icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shift-editor/shift/ed519408f54119ea83678ee2390cea7e145148be/crates/shift-tauri/icons/32x32.png -------------------------------------------------------------------------------- /crates/shift-tauri/icons/Square107x107Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shift-editor/shift/ed519408f54119ea83678ee2390cea7e145148be/crates/shift-tauri/icons/Square107x107Logo.png -------------------------------------------------------------------------------- /crates/shift-tauri/icons/Square142x142Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shift-editor/shift/ed519408f54119ea83678ee2390cea7e145148be/crates/shift-tauri/icons/Square142x142Logo.png -------------------------------------------------------------------------------- /crates/shift-tauri/icons/Square150x150Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shift-editor/shift/ed519408f54119ea83678ee2390cea7e145148be/crates/shift-tauri/icons/Square150x150Logo.png -------------------------------------------------------------------------------- /crates/shift-tauri/icons/Square284x284Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shift-editor/shift/ed519408f54119ea83678ee2390cea7e145148be/crates/shift-tauri/icons/Square284x284Logo.png -------------------------------------------------------------------------------- /crates/shift-tauri/icons/Square30x30Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shift-editor/shift/ed519408f54119ea83678ee2390cea7e145148be/crates/shift-tauri/icons/Square30x30Logo.png -------------------------------------------------------------------------------- /crates/shift-tauri/icons/Square310x310Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shift-editor/shift/ed519408f54119ea83678ee2390cea7e145148be/crates/shift-tauri/icons/Square310x310Logo.png -------------------------------------------------------------------------------- /crates/shift-tauri/icons/Square44x44Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shift-editor/shift/ed519408f54119ea83678ee2390cea7e145148be/crates/shift-tauri/icons/Square44x44Logo.png -------------------------------------------------------------------------------- /crates/shift-tauri/icons/Square71x71Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shift-editor/shift/ed519408f54119ea83678ee2390cea7e145148be/crates/shift-tauri/icons/Square71x71Logo.png -------------------------------------------------------------------------------- /crates/shift-tauri/icons/Square89x89Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shift-editor/shift/ed519408f54119ea83678ee2390cea7e145148be/crates/shift-tauri/icons/Square89x89Logo.png -------------------------------------------------------------------------------- /crates/shift-tauri/icons/StoreLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shift-editor/shift/ed519408f54119ea83678ee2390cea7e145148be/crates/shift-tauri/icons/StoreLogo.png -------------------------------------------------------------------------------- /crates/shift-tauri/icons/android/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shift-editor/shift/ed519408f54119ea83678ee2390cea7e145148be/crates/shift-tauri/icons/android/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /crates/shift-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shift-editor/shift/ed519408f54119ea83678ee2390cea7e145148be/crates/shift-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /crates/shift-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shift-editor/shift/ed519408f54119ea83678ee2390cea7e145148be/crates/shift-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /crates/shift-tauri/icons/android/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shift-editor/shift/ed519408f54119ea83678ee2390cea7e145148be/crates/shift-tauri/icons/android/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /crates/shift-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shift-editor/shift/ed519408f54119ea83678ee2390cea7e145148be/crates/shift-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /crates/shift-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shift-editor/shift/ed519408f54119ea83678ee2390cea7e145148be/crates/shift-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /crates/shift-tauri/icons/android/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shift-editor/shift/ed519408f54119ea83678ee2390cea7e145148be/crates/shift-tauri/icons/android/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /crates/shift-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shift-editor/shift/ed519408f54119ea83678ee2390cea7e145148be/crates/shift-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /crates/shift-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shift-editor/shift/ed519408f54119ea83678ee2390cea7e145148be/crates/shift-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /crates/shift-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shift-editor/shift/ed519408f54119ea83678ee2390cea7e145148be/crates/shift-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /crates/shift-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shift-editor/shift/ed519408f54119ea83678ee2390cea7e145148be/crates/shift-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /crates/shift-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shift-editor/shift/ed519408f54119ea83678ee2390cea7e145148be/crates/shift-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /crates/shift-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shift-editor/shift/ed519408f54119ea83678ee2390cea7e145148be/crates/shift-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /crates/shift-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shift-editor/shift/ed519408f54119ea83678ee2390cea7e145148be/crates/shift-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /crates/shift-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shift-editor/shift/ed519408f54119ea83678ee2390cea7e145148be/crates/shift-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /crates/shift-tauri/icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shift-editor/shift/ed519408f54119ea83678ee2390cea7e145148be/crates/shift-tauri/icons/icon.icns -------------------------------------------------------------------------------- /crates/shift-tauri/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shift-editor/shift/ed519408f54119ea83678ee2390cea7e145148be/crates/shift-tauri/icons/icon.ico -------------------------------------------------------------------------------- /crates/shift-tauri/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shift-editor/shift/ed519408f54119ea83678ee2390cea7e145148be/crates/shift-tauri/icons/icon.png -------------------------------------------------------------------------------- /crates/shift-tauri/icons/ios/AppIcon-20x20@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shift-editor/shift/ed519408f54119ea83678ee2390cea7e145148be/crates/shift-tauri/icons/ios/AppIcon-20x20@1x.png -------------------------------------------------------------------------------- /crates/shift-tauri/icons/ios/AppIcon-20x20@2x-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shift-editor/shift/ed519408f54119ea83678ee2390cea7e145148be/crates/shift-tauri/icons/ios/AppIcon-20x20@2x-1.png -------------------------------------------------------------------------------- /crates/shift-tauri/icons/ios/AppIcon-20x20@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shift-editor/shift/ed519408f54119ea83678ee2390cea7e145148be/crates/shift-tauri/icons/ios/AppIcon-20x20@2x.png -------------------------------------------------------------------------------- /crates/shift-tauri/icons/ios/AppIcon-20x20@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shift-editor/shift/ed519408f54119ea83678ee2390cea7e145148be/crates/shift-tauri/icons/ios/AppIcon-20x20@3x.png -------------------------------------------------------------------------------- /crates/shift-tauri/icons/ios/AppIcon-29x29@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shift-editor/shift/ed519408f54119ea83678ee2390cea7e145148be/crates/shift-tauri/icons/ios/AppIcon-29x29@1x.png -------------------------------------------------------------------------------- /crates/shift-tauri/icons/ios/AppIcon-29x29@2x-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shift-editor/shift/ed519408f54119ea83678ee2390cea7e145148be/crates/shift-tauri/icons/ios/AppIcon-29x29@2x-1.png -------------------------------------------------------------------------------- /crates/shift-tauri/icons/ios/AppIcon-29x29@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shift-editor/shift/ed519408f54119ea83678ee2390cea7e145148be/crates/shift-tauri/icons/ios/AppIcon-29x29@2x.png -------------------------------------------------------------------------------- /crates/shift-tauri/icons/ios/AppIcon-29x29@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shift-editor/shift/ed519408f54119ea83678ee2390cea7e145148be/crates/shift-tauri/icons/ios/AppIcon-29x29@3x.png -------------------------------------------------------------------------------- /crates/shift-tauri/icons/ios/AppIcon-40x40@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shift-editor/shift/ed519408f54119ea83678ee2390cea7e145148be/crates/shift-tauri/icons/ios/AppIcon-40x40@1x.png -------------------------------------------------------------------------------- /crates/shift-tauri/icons/ios/AppIcon-40x40@2x-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shift-editor/shift/ed519408f54119ea83678ee2390cea7e145148be/crates/shift-tauri/icons/ios/AppIcon-40x40@2x-1.png -------------------------------------------------------------------------------- /crates/shift-tauri/icons/ios/AppIcon-40x40@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shift-editor/shift/ed519408f54119ea83678ee2390cea7e145148be/crates/shift-tauri/icons/ios/AppIcon-40x40@2x.png -------------------------------------------------------------------------------- /crates/shift-tauri/icons/ios/AppIcon-40x40@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shift-editor/shift/ed519408f54119ea83678ee2390cea7e145148be/crates/shift-tauri/icons/ios/AppIcon-40x40@3x.png -------------------------------------------------------------------------------- /crates/shift-tauri/icons/ios/AppIcon-512@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shift-editor/shift/ed519408f54119ea83678ee2390cea7e145148be/crates/shift-tauri/icons/ios/AppIcon-512@2x.png -------------------------------------------------------------------------------- /crates/shift-tauri/icons/ios/AppIcon-60x60@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shift-editor/shift/ed519408f54119ea83678ee2390cea7e145148be/crates/shift-tauri/icons/ios/AppIcon-60x60@2x.png -------------------------------------------------------------------------------- /crates/shift-tauri/icons/ios/AppIcon-60x60@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shift-editor/shift/ed519408f54119ea83678ee2390cea7e145148be/crates/shift-tauri/icons/ios/AppIcon-60x60@3x.png -------------------------------------------------------------------------------- /crates/shift-tauri/icons/ios/AppIcon-76x76@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shift-editor/shift/ed519408f54119ea83678ee2390cea7e145148be/crates/shift-tauri/icons/ios/AppIcon-76x76@1x.png -------------------------------------------------------------------------------- /crates/shift-tauri/icons/ios/AppIcon-76x76@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shift-editor/shift/ed519408f54119ea83678ee2390cea7e145148be/crates/shift-tauri/icons/ios/AppIcon-76x76@2x.png -------------------------------------------------------------------------------- /crates/shift-tauri/icons/ios/AppIcon-83.5x83.5@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shift-editor/shift/ed519408f54119ea83678ee2390cea7e145148be/crates/shift-tauri/icons/ios/AppIcon-83.5x83.5@2x.png -------------------------------------------------------------------------------- /crates/shift-tauri/src/commands.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Mutex; 2 | 3 | use shift_editor::editor::Editor; 4 | use shift_font::{font::Metrics, glyph::Glyph}; 5 | use tauri::State; 6 | 7 | #[tauri::command] 8 | pub fn get_font_metrics(state: State<'_, Mutex>) -> Metrics { 9 | let editor = state.lock().unwrap(); 10 | editor.get_font_metrics() 11 | } 12 | 13 | #[tauri::command] 14 | pub fn get_glyph(state: State<'_, Mutex>, unicode: u32) -> Glyph { 15 | let mut editor = state.lock().unwrap(); 16 | editor.get_glyph(unicode).clone() 17 | } 18 | -------------------------------------------------------------------------------- /crates/shift-tauri/src/core.rs: -------------------------------------------------------------------------------- 1 | use tauri::AppHandle; 2 | 3 | pub fn handle_quit(app: &AppHandle) { 4 | // Add any cleanup logic here before quitting 5 | println!("Quitting Shift font editor..."); 6 | 7 | // You can add save prompts, cleanup, etc. here 8 | // For now, just exit 9 | app.exit(0); 10 | } 11 | -------------------------------------------------------------------------------- /crates/shift-tauri/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] 2 | 3 | pub mod commands; 4 | pub mod core; 5 | pub mod menu; 6 | pub mod shortcuts; 7 | -------------------------------------------------------------------------------- /crates/shift-tauri/src/main.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Mutex; 2 | 3 | use shift_editor::editor::Editor; 4 | use shift_tauri::commands; 5 | use shift_tauri::menu; 6 | use shift_tauri::shortcuts; 7 | use tauri::Manager; 8 | use tauri_plugin_global_shortcut::ShortcutState; 9 | 10 | #[cfg_attr(mobile, tauri::mobile_entry_point)] 11 | fn main() { 12 | tauri::Builder::default() 13 | .setup(|app| { 14 | menu::create_menu(app).unwrap(); 15 | 16 | app.manage(Mutex::new(Editor::new())); 17 | 18 | app.on_menu_event(move |_app, event| { 19 | menu::handle_menu_event(_app, &event); 20 | }); 21 | 22 | // Register global shortcut for force quit 23 | let app_handle = app.handle().clone(); 24 | app.handle().plugin( 25 | tauri_plugin_global_shortcut::Builder::new() 26 | .with_handler(move |_app, shortcut, event| { 27 | if event.state() == ShortcutState::Pressed { 28 | shortcuts::handle_shortcut(&app_handle, shortcut); 29 | } 30 | }) 31 | .build(), 32 | )?; 33 | 34 | Ok(()) 35 | }) 36 | .plugin(tauri_plugin_dialog::init()) 37 | .plugin(tauri_plugin_log::Builder::new().build()) 38 | .plugin(tauri_plugin_shell::init()) 39 | .invoke_handler(tauri::generate_handler!( 40 | commands::get_font_metrics, 41 | commands::get_glyph 42 | )) 43 | .run(tauri::generate_context!()) 44 | .expect("error while running tauri application") 45 | } 46 | -------------------------------------------------------------------------------- /crates/shift-tauri/src/menu.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Mutex; 2 | 3 | use crate::core; 4 | use shift_editor::editor::Editor; 5 | use shift_events::events::{FontCompiledEvent, FontLoadedEvent}; 6 | use tauri::{ 7 | menu::{AboutMetadataBuilder, MenuBuilder, MenuEvent, MenuItemBuilder, SubmenuBuilder}, 8 | App, AppHandle, Emitter, Manager, 9 | }; 10 | 11 | use tauri_plugin_dialog::DialogExt; 12 | 13 | pub fn create_menu(app: &App) -> Result<(), Box> { 14 | let about = AboutMetadataBuilder::new().name(Some("Shift")).build(); 15 | 16 | let app_menu = SubmenuBuilder::new(app, "Shift") 17 | .about_with_text("About Shift", Some(about)) 18 | .build()?; 19 | 20 | let new = MenuItemBuilder::new("New") 21 | .id("new") 22 | .accelerator("CmdOrCtrl+N") 23 | .build(app)?; 24 | 25 | let open = MenuItemBuilder::new("Open") 26 | .id("open") 27 | .accelerator("CmdOrCtrl+O") 28 | .build(app)?; 29 | 30 | let quit = MenuItemBuilder::new("Quit") 31 | .id("quit") 32 | .accelerator("CmdOrCtrl+Q") 33 | .build(app)?; 34 | 35 | let compile = MenuItemBuilder::new("Compile").id("compile").build(app)?; 36 | 37 | let file = SubmenuBuilder::new(app, "File") 38 | .item(&new) 39 | .item(&open) 40 | .item(&compile) 41 | .item(&quit) 42 | .build()?; 43 | 44 | let menu = MenuBuilder::new(app).item(&app_menu).item(&file).build()?; 45 | 46 | app.set_menu(menu)?; 47 | 48 | Ok(()) 49 | } 50 | 51 | pub fn handle_menu_event(app: &AppHandle, event: &MenuEvent) { 52 | if event.id() == "new" { 53 | return; 54 | } 55 | 56 | if event.id() == "quit" { 57 | core::handle_quit(app); 58 | return; 59 | } 60 | 61 | if event.id() == "open" { 62 | let app_handle = app.clone(); 63 | 64 | app.dialog() 65 | .file() 66 | .add_filter("Font", &["ttf", "otf", "ufo"]) 67 | .pick_file(move |file_path| { 68 | let editor = app_handle.state::>(); 69 | let file_path = file_path.unwrap().into_path().unwrap(); 70 | 71 | editor 72 | .lock() 73 | .unwrap() 74 | .read_font(&file_path.to_str().unwrap()); 75 | 76 | app_handle 77 | .emit( 78 | "font:loaded", 79 | FontLoadedEvent { 80 | file_name: file_path.file_name().unwrap().to_str().unwrap().to_string(), 81 | }, 82 | ) 83 | .unwrap(); 84 | }); 85 | } 86 | 87 | if event.id() == "compile" { 88 | let app_handle = app.clone(); 89 | let editor = app_handle.state::>(); 90 | let editor_guard = editor.lock().unwrap(); 91 | let font_path = editor_guard.font_path(); 92 | let font_name = font_path.file_name().unwrap().to_str().unwrap(); 93 | let font_path_str = font_path.to_str().unwrap(); 94 | let data_dir = app.path().download_dir().unwrap(); 95 | 96 | let result = shift_font::otf_ttf::compile_font(font_path_str, &data_dir, font_name); 97 | if result.is_err() { 98 | println!("Failed to compile font: {}", result.err().unwrap()); 99 | } 100 | 101 | app_handle 102 | .emit( 103 | "font:compiled", 104 | FontCompiledEvent { 105 | file_name: font_path.file_name().unwrap().to_str().unwrap().to_string(), 106 | font_path: font_path_str.to_string(), 107 | }, 108 | ) 109 | .unwrap(); 110 | } 111 | 112 | return; 113 | } 114 | -------------------------------------------------------------------------------- /crates/shift-tauri/src/shortcuts.rs: -------------------------------------------------------------------------------- 1 | use crate::core; 2 | use tauri::AppHandle; 3 | use tauri_plugin_global_shortcut::{Code, Modifiers, Shortcut}; 4 | 5 | pub fn handle_shortcut(app: &AppHandle, shortcut: &Shortcut) { 6 | if shortcut.matches(Modifiers::META, Code::KeyQ) { 7 | println!("Force quit triggered via global shortcut"); 8 | core::handle_quit(app); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /crates/shift-tauri/tauri.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://schema.tauri.app/config/2", 3 | "productName": "Shift", 4 | "version": "0.1.0", 5 | "identifier": "com.shift.app", 6 | "build": { 7 | "beforeDevCommand": "pnpm run dev:tauri", 8 | "devUrl": "http://localhost:1420", 9 | "beforeBuildCommand": "pnpm run build:tauri", 10 | "frontendDist": "../../apps/desktop/dist" 11 | }, 12 | "app": { 13 | "windows": [ 14 | { 15 | "title": "", 16 | "width": 800, 17 | "height": 600, 18 | "minHeight": 500, 19 | "minWidth": 300, 20 | "maximized": true, 21 | "titleBarStyle": "Overlay", 22 | "hiddenTitle": true, 23 | "dragDropEnabled": false 24 | } 25 | ], 26 | "security": { 27 | "csp": null 28 | } 29 | }, 30 | "bundle": { 31 | "active": true, 32 | "targets": "all", 33 | "icon": [ 34 | "icons/32x32.png", 35 | "icons/128x128.png", 36 | "icons/128x128@2x.png", 37 | "icons/icon.icns", 38 | "icons/icon.ico" 39 | ] 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /crates/shift-unicode/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "shift-unicode" 3 | version = "0.1.0" 4 | edition = "2024" 5 | 6 | [dependencies] 7 | -------------------------------------------------------------------------------- /crates/shift-unicode/build.rs: -------------------------------------------------------------------------------- 1 | fn main() {} 2 | -------------------------------------------------------------------------------- /crates/shift-unicode/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod unicode; 2 | -------------------------------------------------------------------------------- /crates/shift-unicode/src/unicode.rs: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shift-editor/shift/ed519408f54119ea83678ee2390cea7e145148be/crates/shift-unicode/src/unicode.rs -------------------------------------------------------------------------------- /gen/README.md: -------------------------------------------------------------------------------- 1 | any packages that generate code live here 2 | -------------------------------------------------------------------------------- /gen/charsets/.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | .venv -------------------------------------------------------------------------------- /gen/charsets/.python-version: -------------------------------------------------------------------------------- 1 | 3.10 2 | -------------------------------------------------------------------------------- /gen/charsets/README.md: -------------------------------------------------------------------------------- 1 | Generates unicode data from the unicode database 2 | -------------------------------------------------------------------------------- /gen/charsets/charsets.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import re 3 | 4 | UNICODE_BLOCKS_URL = "https://www.unicode.org/Public/14.0.0/ucd/Blocks.txt" 5 | ADOBE_LATIN = "https://adobe-type-tools.github.io/adobe-latin-charsets/" 6 | 7 | 8 | def generate_unicode_block_ranges(): 9 | ucd_blocks = requests.get(UNICODE_BLOCKS_URL) 10 | 11 | block_map = {} 12 | for line in ucd_blocks.text.splitlines(): 13 | if line.startswith("#") or line.startswith("\n"): 14 | continue 15 | 16 | if not line: 17 | continue 18 | 19 | data = re.split(r"[..;]+", line) 20 | block_map[data[2].strip()] = { 21 | "start": int(data[0], 16), 22 | "end": int(data[1], 16), 23 | } 24 | 25 | return block_map 26 | 27 | 28 | def generate_adobe_charsets(): 29 | adobe = requests.get(ADOBE_LATIN + "adobe-latin-1" + ".txt") 30 | adobe_map = {} 31 | 32 | for line in adobe.text.splitlines()[1:]: 33 | item = line.split("\t") 34 | unicode, name, char_name = item[0], item[2], item[3] 35 | adobe_map[name] = {"unicode": unicode, "char_name": char_name} 36 | 37 | return adobe_map 38 | -------------------------------------------------------------------------------- /gen/charsets/main.py: -------------------------------------------------------------------------------- 1 | import charsets 2 | import json 3 | import os 4 | 5 | 6 | dirname = os.path.dirname(__file__) 7 | data = os.path.join(dirname, "../../apps/desktop/data") 8 | 9 | 10 | def generate_ts_file(charset: str): 11 | const_name = charset.upper().replace(" ", "_").replace("-", "_") 12 | adobe = charsets.generate_adobe_charsets() 13 | ts_content = f"""// Generated file - do not edit directly 14 | // {charset} character set 15 | 16 | 17 | export const {const_name} = {json.dumps(adobe, indent=2, ensure_ascii=False)} 18 | """ 19 | 20 | return ts_content 21 | 22 | 23 | def main(): 24 | content = generate_ts_file("adobe-latin-1") 25 | path = os.path.join(data, "adobe-latin-1.ts") 26 | with open(path, "w") as f: 27 | f.write(content) 28 | 29 | 30 | if __name__ == "__main__": 31 | main() 32 | -------------------------------------------------------------------------------- /gen/charsets/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "unicode" 3 | version = "0.1.0" 4 | description = "Generate unicode tables for Rust" 5 | readme = "README.md" 6 | requires-python = ">=3.10" 7 | dependencies = [ 8 | "fonttools>=4.56.0", 9 | "requests>=2.32.3", 10 | ] 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "shift", 3 | "private": true, 4 | "type": "module", 5 | "repository": "https://github.com/shift-editor/shift", 6 | "scripts": { 7 | "dev:tauri": "turbo run dev", 8 | "types:rebuild": "scripts/rebuild_types.sh", 9 | "dev:app": "pnpm tauri dev", 10 | "build:tauri": "turbo run build", 11 | "build:app": "pnpm tauri build", 12 | "cargo:clean": "cargo clean", 13 | "clean:app": "pnpm cargo:clean && rm -rf .turbo && rm -rf node_modules", 14 | "clean": "turbo run clean", 15 | "build": "turbo run --no-daemon build", 16 | "test": "turbo run test", 17 | "tauri": "tauri", 18 | "format": "prettier --write \"apps/**/*.{js,jsx,ts,tsx,css,md,json}\"", 19 | "format:check": "prettier --check \"apps/**/*.{js,jsx,ts,tsx,css,md,json}\"", 20 | "lint": "eslint \"apps/**/*.{ts,tsx}\" --fix", 21 | "lint:check": "eslint \"apps/**/*.{ts,tsx}\"", 22 | "check-deps": "depcheck" 23 | }, 24 | "packageManager": "pnpm@10.6.5", 25 | "devDependencies": { 26 | "@tauri-apps/cli": "^2.3.1", 27 | "@typescript-eslint/eslint-plugin": "^8.26.1", 28 | "eslint": "^8.57.1", 29 | "prettier": "^3.5.3", 30 | "prettier-plugin-tailwindcss": "^0.6.11", 31 | "turbo": "^2.2.3", 32 | "typescript": "^5.2.2" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /packages/shared/README.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shift-editor/shift/ed519408f54119ea83678ee2390cea7e145148be/packages/shared/README.md -------------------------------------------------------------------------------- /packages/shared/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@shift/shared", 3 | "version": "1.0.0", 4 | "type": "module", 5 | "main": "./dist/index.js", 6 | "types": "./dist/index.d.ts", 7 | "description": "Shared types and utilities between the backend and frontend", 8 | "scripts": { 9 | "clean": "rm -rf dist", 10 | "build": "pnpm run exports:ts-rs && tsc", 11 | "dev": "tsc --watch", 12 | "exports:ts-rs": "node scripts/exports-ts-rs.js" 13 | }, 14 | "keywords": [], 15 | "author": "Kostya Farber " 16 | } 17 | -------------------------------------------------------------------------------- /packages/shared/scripts/exports-ts-rs.js: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | 3 | export const generateIndexTs = () => { 4 | const dir = import.meta.dirname; 5 | const exports = fs.readdirSync(dir + "/../src/types"); 6 | 7 | const exportsLines = exports.map((file) => { 8 | return `export * from "./types/${file.replace(".ts", "")}";`; 9 | }); 10 | 11 | fs.writeFileSync(dir + "/../src/index.ts", [...exportsLines].join("\n")); 12 | }; 13 | 14 | export const main = () => { 15 | generateIndexTs(); 16 | }; 17 | 18 | main(); 19 | -------------------------------------------------------------------------------- /packages/shared/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./types/EntityId"; 2 | export * from "./types/Font"; 3 | export * from "./types/FontCompiledEvent"; 4 | export * from "./types/FontLoadedEvent"; 5 | export * from "./types/FontMetadata"; 6 | export * from "./types/Glyph"; 7 | export * from "./types/IContour"; 8 | export * from "./types/IContourPoint"; 9 | export * from "./types/Metrics"; 10 | export * from "./types/MovedPoint"; 11 | export * from "./types/PathCommand"; 12 | export * from "./types/PointType"; 13 | export * from "./types/PointsAddedEvent"; 14 | export * from "./types/PointsMovedEvent"; -------------------------------------------------------------------------------- /packages/shared/src/types/EntityId.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | 3 | export type EntityId = { parentId: number, id: number, }; 4 | -------------------------------------------------------------------------------- /packages/shared/src/types/Font.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | import type { FontMetadata } from "./FontMetadata"; 3 | import type { Glyph } from "./Glyph"; 4 | import type { Metrics } from "./Metrics"; 5 | 6 | export type Font = { metadata: FontMetadata, metrics: Metrics, glyphs: { [key in number]?: Glyph }, }; 7 | -------------------------------------------------------------------------------- /packages/shared/src/types/FontCompiledEvent.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | 3 | export type FontCompiledEvent = { fileName: string, fontPath: string, }; 4 | -------------------------------------------------------------------------------- /packages/shared/src/types/FontLoadedEvent.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | 3 | export type FontLoadedEvent = { fileName: string, }; 4 | -------------------------------------------------------------------------------- /packages/shared/src/types/FontMetadata.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | 3 | export type FontMetadata = { family: string, styleName: string, version: number, }; 4 | -------------------------------------------------------------------------------- /packages/shared/src/types/Glyph.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | import type { IContour } from "./IContour"; 3 | 4 | export type Glyph = { name: string, unicode: number, contours: Array, x_advance: number, }; 5 | -------------------------------------------------------------------------------- /packages/shared/src/types/IContour.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | import type { IContourPoint } from "./IContourPoint"; 3 | 4 | export type IContour = { points: Array, closed: boolean, }; 5 | -------------------------------------------------------------------------------- /packages/shared/src/types/IContourPoint.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | import type { PointType } from "./PointType"; 3 | 4 | export type IContourPoint = { pointType: PointType, x: number, y: number, smooth: boolean, }; 5 | -------------------------------------------------------------------------------- /packages/shared/src/types/Metrics.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | 3 | export type Metrics = { unitsPerEm: number, ascender: number, descender: number, capHeight: number, xHeight: number, }; 4 | -------------------------------------------------------------------------------- /packages/shared/src/types/MovedPoint.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | import type { EntityId } from "./EntityId"; 3 | 4 | export type MovedPoint = { pointId: EntityId, fromX: number, fromY: number, toX: number, toY: number, }; 5 | -------------------------------------------------------------------------------- /packages/shared/src/types/PathCommand.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | 3 | export type PathCommand = { "type": "moveTo", x: number, y: number, } | { "type": "lineTo", x: number, y: number, } | { "type": "cubicTo", cp1x: number, cp1y: number, cp2x: number, cp2y: number, x: number, y: number, } | { "type": "quadTo", cp1x: number, cp1y: number, x: number, y: number, } | { "type": "close" }; 4 | -------------------------------------------------------------------------------- /packages/shared/src/types/PointType.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | 3 | export type PointType = "onCurve" | "offCurve"; 4 | -------------------------------------------------------------------------------- /packages/shared/src/types/PointsAddedEvent.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | import type { EntityId } from "./EntityId"; 3 | 4 | export type PointsAddedEvent = { pointIds: Array, }; 5 | -------------------------------------------------------------------------------- /packages/shared/src/types/PointsMovedEvent.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | import type { MovedPoint } from "./MovedPoint"; 3 | 4 | export type PointsMovedEvent = { points: Array, }; 5 | -------------------------------------------------------------------------------- /packages/shared/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist", 4 | "declaration": true, 5 | "target": "ES2020", 6 | "module": "ESNext", 7 | "moduleResolution": "node", 8 | "esModuleInterop": true, 9 | "skipLibCheck": true, 10 | "strict": true 11 | }, 12 | "include": ["src/**/*"], 13 | "exclude": ["dist"] 14 | } -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "apps/*" 3 | - "crates/*" 4 | - "packages/*" 5 | -------------------------------------------------------------------------------- /scripts/rebuild_types.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | 4 | pnpm --filter @shift/shared clean 5 | 6 | cargo test -p shift-font export_bindings & 7 | cargo test -p shift-events export_bindings & 8 | 9 | wait 10 | 11 | pnpm --filter @shift/shared build -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "globalDependencies": ["**/.env.*local"], 4 | "tasks": { 5 | "//#clean:app": {}, 6 | "clean": { 7 | "dependsOn": ["//#clean:app"] 8 | }, 9 | "lint": {}, 10 | "//#types:rebuild": { 11 | "inputs": ["packages/shared/dist/**"] 12 | }, 13 | "build": { 14 | "dependsOn": ["^//#types:rebuild", "^build"], 15 | "outputs": ["**/apps/desktop/dist/**"] 16 | }, 17 | "dev": { 18 | "dependsOn": ["^//#types:rebuild"], 19 | "cache": false, 20 | "persistent": true 21 | }, 22 | "test": { 23 | "cache": true 24 | } 25 | } 26 | } 27 | --------------------------------------------------------------------------------