├── .gitignore ├── README.md ├── autogypi.json ├── binding.gyp ├── bundles ├── fonts │ └── Lato │ │ ├── Lato-Black.woff2 │ │ ├── Lato-BlackItalic.woff2 │ │ ├── Lato-Bold.woff2 │ │ ├── Lato-BoldItalic.woff2 │ │ ├── Lato-Hairline.woff2 │ │ ├── Lato-HairlineItalic.woff2 │ │ ├── Lato-Heavy.woff2 │ │ ├── Lato-HeavyItalic.woff2 │ │ ├── Lato-Italic.woff2 │ │ ├── Lato-Light.woff2 │ │ ├── Lato-LightItalic.woff2 │ │ ├── Lato-Medium.woff2 │ │ ├── Lato-MediumItalic.woff2 │ │ ├── Lato-Regular.woff2 │ │ ├── Lato-Semibold.woff2 │ │ ├── Lato-SemiboldItalic.woff2 │ │ ├── Lato-Thin.woff2 │ │ ├── Lato-ThinItalic.woff2 │ │ └── lato.css └── icons │ └── flaticon │ ├── license │ └── license.pdf │ └── svg │ ├── add.svg │ ├── circle.svg │ ├── circuit-9.svg │ ├── crop.svg │ ├── eraser.svg │ ├── folder-2.svg │ ├── magic-wand.svg │ ├── move.svg │ ├── multiply.svg │ ├── paint-brush-1.svg │ ├── paint-brush.svg │ ├── paint-brushes.svg │ ├── pen-1.svg │ ├── pen.svg │ ├── rotate.svg │ ├── search.svg │ ├── square-21.svg │ ├── subtract.svg │ ├── transform.svg │ ├── zoom-in.svg │ └── zoom-out.svg ├── circle.yml ├── dist ├── dialogs.html ├── index.html ├── preferences.html └── test.html ├── docs └── structure.md ├── images └── screenshot.png ├── package-lock.json ├── package.json ├── scripts └── package.ts ├── src ├── common │ ├── IPCChannels.ts │ ├── constants.ts │ └── nativelib.ts ├── declarations.ts ├── lib │ ├── CanvasEncodeDecode.ts │ ├── Color.ts │ ├── Debounce.ts │ ├── Dirtiness.ts │ ├── Float.ts │ ├── Geometry.ts │ ├── IndexPath.ts │ ├── KeyInput.ts │ ├── KeyRecorder.ts │ ├── ObservableWeakMap.ts │ └── glsl │ │ ├── bicubic.glsl │ │ ├── boxShadow.glsl │ │ └── inverseBilinear.glsl ├── main │ └── index.ts ├── nativelib │ ├── FloodFill.cc │ └── WindowUtilMac.mm ├── renderer │ ├── GLContext.ts │ ├── GLUtil.ts │ ├── actions │ │ ├── Action.ts │ │ ├── ActionIDs.ts │ │ ├── AppActions.ts │ │ ├── CanvasActions.ts │ │ ├── EditActions.ts │ │ ├── FileActions.ts │ │ ├── LayerActions.ts │ │ ├── SelectionAction.ts │ │ └── ViewActions.ts │ ├── app │ │ ├── ActionRegistry.ts │ │ ├── AppState.ts │ │ ├── BrushPresetManager.ts │ │ ├── Config.ts │ │ ├── FormatRegistry.ts │ │ ├── KeyBindingRegistry.ts │ │ ├── PictureState.ts │ │ ├── ThumbnailManager.ts │ │ └── ToolManager.ts │ ├── brush │ │ ├── BrushEngine.ts │ │ ├── BrushPipeline.ts │ │ ├── BrushPreset.ts │ │ ├── BrushRenderer.ts │ │ ├── DefaultBrushPresets.ts │ │ ├── Waypoint.ts │ │ ├── WaypointCurveFilter.ts │ │ ├── WaypointStabilizeFilter.ts │ │ └── shaders │ │ │ ├── brushShape.glsl │ │ │ └── brushVertexOp.glsl │ ├── commands │ │ ├── LayerCommand.ts │ │ ├── PictureCommand.ts │ │ └── SelectionCommand.ts │ ├── formats │ │ ├── PictureFormat.ts │ │ ├── PictureFormatAzurite.ts │ │ └── PictureFormatCanvasImage.ts │ ├── index.ts │ ├── initState.ts │ ├── initView.tsx │ ├── models │ │ ├── Layer.ts │ │ ├── Navigation.ts │ │ ├── Picture.ts │ │ ├── Selection.ts │ │ ├── TextureToCanvas.ts │ │ ├── TiledTexture.ts │ │ ├── UndoStack.ts │ │ └── util.ts │ ├── requireManualResolve.ts │ ├── services │ │ ├── FloodFill.ts │ │ ├── LayerBlender.ts │ │ ├── LayerTransform.ts │ │ ├── PictureBlender.ts │ │ ├── PictureExport.ts │ │ ├── PictureSave.ts │ │ └── ThumbnailGenerator.ts │ ├── tools │ │ ├── BrushTool.tsx │ │ ├── CanvasAreaTool.tsx │ │ ├── FloodFillTool.tsx │ │ ├── FreehandSelectTool.ts │ │ ├── PanTool.ts │ │ ├── PolygonSelectTool.ts │ │ ├── RectMoveTool.ts │ │ ├── RectSelectTool.ts │ │ ├── RotateTool.ts │ │ ├── ShapeSelectTool.ts │ │ ├── Tool.ts │ │ ├── ToolIDs.ts │ │ ├── TransformLayerTool.tsx │ │ └── ZoomTool.ts │ ├── viewmodels │ │ ├── DimensionSelectViewModel.ts │ │ └── PreferencesViewModel.ts │ └── views │ │ ├── BrushSettings.tsx │ │ ├── CurrentFocus.ts │ │ ├── DimensionSelect.css │ │ ├── DimensionSelect.tsx │ │ ├── DrawArea.css │ │ ├── DrawArea.tsx │ │ ├── FloodFillSettings.tsx │ │ ├── KeyBindingHandler.ts │ │ ├── LayerDetail.tsx │ │ ├── MenuBar.ts │ │ ├── PictureTabBar.css │ │ ├── PictureTabBar.tsx │ │ ├── Renderer.ts │ │ ├── RootView.css │ │ ├── RootView.tsx │ │ ├── ToolSelection.css │ │ ├── ToolSelection.tsx │ │ ├── common.css │ │ ├── components │ │ ├── CSSVariables.tsx │ │ ├── ClickToEdit.css │ │ ├── ClickToEdit.tsx │ │ ├── ColorPicker.tsx │ │ ├── ColorSlider.css │ │ ├── ColorSlider.tsx │ │ ├── DialogTitleBar.css │ │ ├── DialogTitleBar.tsx │ │ ├── DraggablePanel.css │ │ ├── DraggablePanel.tsx │ │ ├── FrameDebounced.tsx │ │ ├── Palette.css │ │ ├── Palette.tsx │ │ ├── PointerEvents.tsx │ │ ├── RGBRangeSliders.css │ │ ├── RGBRangeSliders.tsx │ │ ├── RangeSlider.css │ │ ├── RangeSlider.tsx │ │ ├── SVGIcon.css │ │ ├── SVGIcon.tsx │ │ ├── ScrollBar.css │ │ ├── ScrollBar.tsx │ │ ├── ShortcutEdit.css │ │ └── ShortcutEdit.tsx │ │ ├── dialogs │ │ ├── DialogContainer.css │ │ ├── DialogContainer.tsx │ │ ├── DialogIndex.tsx │ │ ├── DialogLauncher.ts │ │ ├── NewPictureDialog.tsx │ │ ├── ResolutionChangeDialog.tsx │ │ └── ToolShortcutsDialog.tsx │ │ ├── icons │ │ ├── check.svg │ │ ├── freehand-select.svg │ │ ├── polygon-select.svg │ │ ├── window-close.svg │ │ ├── window-maximize.svg │ │ └── window-minimize.svg │ │ ├── panels │ │ ├── BrushPresetsPanel.css │ │ ├── BrushPresetsPanel.tsx │ │ ├── ColorPanel.css │ │ ├── ColorPanel.tsx │ │ ├── LayerPanel.css │ │ ├── LayerPanel.tsx │ │ ├── NavigatorPanel.css │ │ ├── NavigatorPanel.tsx │ │ └── ToolSettingsPanel.tsx │ │ ├── preferences │ │ ├── Preferences.css │ │ ├── Preferences.tsx │ │ ├── PreferencesIndex.tsx │ │ └── PreferencesLauncher.ts │ │ └── util.ts └── test │ ├── commands │ └── LayerCommandTest.ts │ ├── index.js │ ├── lib │ └── IndexPathTest.ts │ ├── models │ ├── LayerTest.ts │ └── PictureTest.ts │ ├── services │ └── PictureExportTest.ts │ └── util │ └── TestPattern.ts ├── tsconfig.json ├── tslint.json ├── webpack.config.js └── webpack.config.main.js /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | bin 29 | 30 | # Dependency directories 31 | node_modules 32 | jspm_packages 33 | 34 | # Optional npm cache directory 35 | .npm 36 | 37 | # Optional REPL history 38 | .node_repl_history 39 | 40 | # autogypi 41 | auto.gypi 42 | auto-top.gypi 43 | 44 | /build 45 | /dist/assets 46 | .awcache -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # azurite 2 | 3 | Casual Painting App in Electron 4 | 5 | [![CircleCI](https://circleci.com/gh/sketchglass/azurite.svg?style=svg)](https://circleci.com/gh/sketchglass/azurite) 6 | 7 | ![Screenshot](images/screenshot.png) 8 | 9 | ## Prerequisites 10 | 11 | * Node.js 12 | 13 | ### Windows 14 | 15 | * Python 2.7 / Visual C++ (for building native modules with node-gyp) 16 | 17 | ## Build 18 | 19 | ``` 20 | npm install 21 | npm run watch 22 | ``` 23 | 24 | ### Build native code 25 | 26 | ``` 27 | npm run build:nativelib # rebuild entirely 28 | npm run node-gyp:build # build changed files only 29 | ``` 30 | 31 | ## Run App 32 | 33 | ``` 34 | npm run app 35 | ``` 36 | 37 | ## Test 38 | 39 | ``` 40 | npm test 41 | ``` 42 | 43 | ### with webpack devserver 44 | 45 | ``` 46 | # assuming you are running `npm run watch` 47 | npm run test:dev 48 | ``` 49 | 50 | ## Package app 51 | 52 | ``` 53 | npm run package 54 | ``` 55 | 56 | The packaged app will be in `/build`. 57 | -------------------------------------------------------------------------------- /autogypi.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": [ 3 | "nbind" 4 | ], 5 | "includes": [] 6 | } 7 | -------------------------------------------------------------------------------- /binding.gyp: -------------------------------------------------------------------------------- 1 | { 2 | "targets": [ 3 | { 4 | "includes": [ 5 | "auto.gypi" 6 | ], 7 | "sources": [ 8 | "src/nativelib/FloodFill.cc", 9 | ], 10 | "conditions": [ 11 | ['OS=="mac"', { 12 | 'xcode_settings': { 13 | 'OTHER_CFLAGS': [ 14 | '-std=c++11', 15 | '-stdlib=libc++' 16 | ] 17 | }, 18 | "sources": [ "src/nativelib/WindowUtilMac.mm" ] 19 | }] 20 | ] 21 | } 22 | ], 23 | "includes": [ 24 | "auto-top.gypi" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /bundles/fonts/Lato/Lato-Black.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sketchglass/azurite/7ca3ed4f45691da90b78ec194882372456cb9e1b/bundles/fonts/Lato/Lato-Black.woff2 -------------------------------------------------------------------------------- /bundles/fonts/Lato/Lato-BlackItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sketchglass/azurite/7ca3ed4f45691da90b78ec194882372456cb9e1b/bundles/fonts/Lato/Lato-BlackItalic.woff2 -------------------------------------------------------------------------------- /bundles/fonts/Lato/Lato-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sketchglass/azurite/7ca3ed4f45691da90b78ec194882372456cb9e1b/bundles/fonts/Lato/Lato-Bold.woff2 -------------------------------------------------------------------------------- /bundles/fonts/Lato/Lato-BoldItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sketchglass/azurite/7ca3ed4f45691da90b78ec194882372456cb9e1b/bundles/fonts/Lato/Lato-BoldItalic.woff2 -------------------------------------------------------------------------------- /bundles/fonts/Lato/Lato-Hairline.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sketchglass/azurite/7ca3ed4f45691da90b78ec194882372456cb9e1b/bundles/fonts/Lato/Lato-Hairline.woff2 -------------------------------------------------------------------------------- /bundles/fonts/Lato/Lato-HairlineItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sketchglass/azurite/7ca3ed4f45691da90b78ec194882372456cb9e1b/bundles/fonts/Lato/Lato-HairlineItalic.woff2 -------------------------------------------------------------------------------- /bundles/fonts/Lato/Lato-Heavy.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sketchglass/azurite/7ca3ed4f45691da90b78ec194882372456cb9e1b/bundles/fonts/Lato/Lato-Heavy.woff2 -------------------------------------------------------------------------------- /bundles/fonts/Lato/Lato-HeavyItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sketchglass/azurite/7ca3ed4f45691da90b78ec194882372456cb9e1b/bundles/fonts/Lato/Lato-HeavyItalic.woff2 -------------------------------------------------------------------------------- /bundles/fonts/Lato/Lato-Italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sketchglass/azurite/7ca3ed4f45691da90b78ec194882372456cb9e1b/bundles/fonts/Lato/Lato-Italic.woff2 -------------------------------------------------------------------------------- /bundles/fonts/Lato/Lato-Light.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sketchglass/azurite/7ca3ed4f45691da90b78ec194882372456cb9e1b/bundles/fonts/Lato/Lato-Light.woff2 -------------------------------------------------------------------------------- /bundles/fonts/Lato/Lato-LightItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sketchglass/azurite/7ca3ed4f45691da90b78ec194882372456cb9e1b/bundles/fonts/Lato/Lato-LightItalic.woff2 -------------------------------------------------------------------------------- /bundles/fonts/Lato/Lato-Medium.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sketchglass/azurite/7ca3ed4f45691da90b78ec194882372456cb9e1b/bundles/fonts/Lato/Lato-Medium.woff2 -------------------------------------------------------------------------------- /bundles/fonts/Lato/Lato-MediumItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sketchglass/azurite/7ca3ed4f45691da90b78ec194882372456cb9e1b/bundles/fonts/Lato/Lato-MediumItalic.woff2 -------------------------------------------------------------------------------- /bundles/fonts/Lato/Lato-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sketchglass/azurite/7ca3ed4f45691da90b78ec194882372456cb9e1b/bundles/fonts/Lato/Lato-Regular.woff2 -------------------------------------------------------------------------------- /bundles/fonts/Lato/Lato-Semibold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sketchglass/azurite/7ca3ed4f45691da90b78ec194882372456cb9e1b/bundles/fonts/Lato/Lato-Semibold.woff2 -------------------------------------------------------------------------------- /bundles/fonts/Lato/Lato-SemiboldItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sketchglass/azurite/7ca3ed4f45691da90b78ec194882372456cb9e1b/bundles/fonts/Lato/Lato-SemiboldItalic.woff2 -------------------------------------------------------------------------------- /bundles/fonts/Lato/Lato-Thin.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sketchglass/azurite/7ca3ed4f45691da90b78ec194882372456cb9e1b/bundles/fonts/Lato/Lato-Thin.woff2 -------------------------------------------------------------------------------- /bundles/fonts/Lato/Lato-ThinItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sketchglass/azurite/7ca3ed4f45691da90b78ec194882372456cb9e1b/bundles/fonts/Lato/Lato-ThinItalic.woff2 -------------------------------------------------------------------------------- /bundles/icons/flaticon/license/license.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sketchglass/azurite/7ca3ed4f45691da90b78ec194882372456cb9e1b/bundles/icons/flaticon/license/license.pdf -------------------------------------------------------------------------------- /bundles/icons/flaticon/svg/add.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /bundles/icons/flaticon/svg/crop.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /bundles/icons/flaticon/svg/eraser.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /bundles/icons/flaticon/svg/folder-2.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /bundles/icons/flaticon/svg/magic-wand.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 9 | 10 | 11 | 12 | 13 | 15 | 17 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /bundles/icons/flaticon/svg/move.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /bundles/icons/flaticon/svg/multiply.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /bundles/icons/flaticon/svg/paint-brush-1.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /bundles/icons/flaticon/svg/paint-brush.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /bundles/icons/flaticon/svg/paint-brushes.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /bundles/icons/flaticon/svg/pen-1.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /bundles/icons/flaticon/svg/pen.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /bundles/icons/flaticon/svg/rotate.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 9 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /bundles/icons/flaticon/svg/search.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /bundles/icons/flaticon/svg/subtract.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /bundles/icons/flaticon/svg/zoom-in.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 8 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /bundles/icons/flaticon/svg/zoom-out.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | machine: 2 | node: 3 | version: 6.1.0 4 | dependencies: 5 | pre: 6 | - sudo apt-get update; sudo apt-get install libx11-dev libxkbfile-dev 7 | test: 8 | override: 9 | - npm run lint 10 | - npm run build 11 | -------------------------------------------------------------------------------- /dist/dialogs.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /dist/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Azurite 6 | 7 | 8 |
9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /dist/preferences.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /dist/test.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Azurite Test 6 | 7 | 8 |
9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /docs/structure.md: -------------------------------------------------------------------------------- 1 | # Source structure of Azurite 2 | 3 | * `dist`: HTML / JavaScript / CSS / other assets that are finally referenced by Electron 4 | * `bundles`: vender assets such as icons / fonts 5 | * `docs`: Documentations 6 | * `scripts`: Scripts used in development and build process 7 | * `src`: Source codes 8 | * `common`: Common codes that are accessed from both renderer/main process 9 | * `lib`: Utility codes that are not tied to Azurite itself 10 | * `main`: Codes that run in Electron main process 11 | * `nativelib`: Native codes written in (Objective-)C++. [nbind](https://github.com/charto/nbind) is used to make them accessbie from JavaScript 12 | * `renderer`: Codes that run in Electron renderer process 13 | * `actions`: Unit of code mainly trigerred by menu items, keyboard shortcuts and buttons 14 | * `app`: Codes that manages application 15 | * `commands`: Undoable unit of code that modifies models 16 | * `formats`: Codes that add support for file formats 17 | * `models`: Codes that represents data maniplated in Azurite, such as Pictures or Layers 18 | * `services`: Miscellaneous sets of code that do something 19 | * `tools`: Tools that interact with user in viewport 20 | * `viewmodels`: Abstractions of complex views 21 | * `views`: View components 22 | * `styles`: CSS styles 23 | * `test`: Test codes 24 | -------------------------------------------------------------------------------- /images/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sketchglass/azurite/7ca3ed4f45691da90b78ec194882372456cb9e1b/images/screenshot.png -------------------------------------------------------------------------------- /scripts/package.ts: -------------------------------------------------------------------------------- 1 | const packager = require('electron-packager') 2 | const sh = require('shelljs') 3 | const glob = require('glob') 4 | const path = require('path') 5 | 6 | sh.config.verbose = true 7 | 8 | const INCLUDE_MODULES = [ 9 | 'receive-tablet-event', 'bindings', 'nbind' 10 | ] 11 | 12 | function include(path: string) { 13 | for (const module of INCLUDE_MODULES) { 14 | if (path.startsWith(`node_modules/${module}`)) { 15 | // ignore object files 16 | return !path.endsWith('.o') && !path.endsWith('.obj') 17 | } 18 | } 19 | 20 | if (path.endsWith('nbind.node')) { 21 | return true 22 | } 23 | 24 | // include dist (without sourcemap) 25 | if (path.startsWith('dist')) { 26 | return !path.endsWith('.map') 27 | } 28 | // include package 29 | if (path === 'package.json') { 30 | return true 31 | } 32 | return false 33 | } 34 | 35 | async function copyFiles(appPath: string) { 36 | const distPath = path.join(appPath, 'Azurite.app/Contents/Resources/app') 37 | sh.mkdir(distPath) 38 | 39 | const rootPath = path.dirname(__dirname) 40 | const files = glob.sync(path.join(rootPath, '/**/*')) 41 | // FIXME: why glob does not contain package.json????? 42 | // files.push(path.join(rootPath, 'package.json')) 43 | 44 | for (const file of files) { 45 | const relPath = path.relative(rootPath, file) 46 | const dst = path.join(distPath, path.dirname(relPath)) 47 | if (include(relPath)) { 48 | sh.mkdir('-p', dst) 49 | sh.cp(file, dst) 50 | } 51 | } 52 | } 53 | 54 | async function package() { 55 | // sh.exec("npm run build") 56 | 57 | const options = { 58 | dir: '.', 59 | out: 'build', 60 | overwrite: true, 61 | ignore: () => true 62 | } 63 | 64 | const paths = await new Promise((resolve, reject) => { 65 | packager(options, (err: Error, paths: string[]) => { 66 | if (err) { 67 | reject(err) 68 | } else { 69 | resolve(paths) 70 | } 71 | }) 72 | }) 73 | for (const path of paths) { 74 | await copyFiles(path) 75 | } 76 | } 77 | 78 | package() 79 | -------------------------------------------------------------------------------- /src/common/IPCChannels.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | setTabletCaptureArea: 'setTabletCaptureArea', 3 | tabletDown: 'tabletDown', 4 | tabletMove: 'tabletMove', 5 | tabletUp: 'tabletUp', 6 | quit: 'quit', 7 | windowResize: 'windowResize', 8 | dialogOpen: 'dialogOpen', 9 | dialogDone: 'dialogDone', 10 | preferencesOpen: 'preferencesOpen', 11 | preferencesChange: 'preferencesChange', 12 | } 13 | -------------------------------------------------------------------------------- /src/common/constants.ts: -------------------------------------------------------------------------------- 1 | 2 | export 3 | const MAX_PICTURE_SIZE = 8192 4 | -------------------------------------------------------------------------------- /src/common/nativelib.ts: -------------------------------------------------------------------------------- 1 | const electron = require('electron') 2 | let appPath: string 3 | if (process.type === 'renderer') { 4 | appPath = electron.remote.app.getAppPath() 5 | } else { 6 | appPath = electron.app.getAppPath() 7 | } 8 | 9 | export = require('nbind').init(appPath).lib 10 | -------------------------------------------------------------------------------- /src/declarations.ts: -------------------------------------------------------------------------------- 1 | 2 | declare function requestIdleCallback(callback: (timeRemaining: number, didTimeout: boolean) => void, options?: {timeout?: number}): number 3 | declare function cancelIdleCallback(id: number): void 4 | 5 | declare namespace Electron { 6 | interface Menu { 7 | popup(browserWindow?: BrowserWindow, options?: { 8 | x?: number 9 | y?: number 10 | async?: boolean 11 | positioningItem?: number 12 | }): void 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/lib/CanvasEncodeDecode.ts: -------------------------------------------------------------------------------- 1 | async function blobToBuffer(blob: Blob) { 2 | return new Promise((resolve, reject) => { 3 | const reader = new FileReader() 4 | reader.addEventListener('loadend', (ev) => { 5 | if (ev['error']) { 6 | reject(ev['error']) 7 | } else { 8 | resolve(new Buffer(reader.result)) 9 | } 10 | }) 11 | reader.readAsArrayBuffer(blob) 12 | }) 13 | } 14 | 15 | async function toBlob(canvas: HTMLCanvasElement, mimeType: string) { 16 | return new Promise((resolve) => { 17 | canvas.toBlob(resolve, mimeType) 18 | }) 19 | } 20 | 21 | export async function encodeCanvas(canvas: HTMLCanvasElement, mimeType: string) { 22 | const blob = await toBlob(canvas, mimeType) 23 | if (blob) { 24 | return blobToBuffer(blob) 25 | } else { 26 | throw new Error('Failed to encode image') 27 | } 28 | } 29 | 30 | function imageFromURL(url: string) { 31 | return new Promise((resolve, reject) => { 32 | const image = new Image() 33 | image.onload = () => { 34 | resolve(image) 35 | } 36 | image.onerror = err => { 37 | reject(err) 38 | } 39 | image.src = url 40 | }) 41 | } 42 | 43 | export async function decodeToCanvas(buffer: Buffer, mimeType: string) { 44 | const blob = new Blob([buffer], {type: mimeType}) 45 | const url = URL.createObjectURL(blob) 46 | const image = await imageFromURL(url) 47 | const {width, height} = image 48 | 49 | const canvas = document.createElement('canvas') 50 | canvas.width = width 51 | canvas.height = height 52 | const context = canvas.getContext('2d')! 53 | context.drawImage(image, 0, 0) 54 | 55 | return canvas 56 | } 57 | -------------------------------------------------------------------------------- /src/lib/Debounce.ts: -------------------------------------------------------------------------------- 1 | 2 | // ensure 1 call per frame 3 | export 4 | function frameDebounce(func: T): T { 5 | let needCall = false 6 | let args: IArguments 7 | function onFrame() { 8 | if (needCall) { 9 | func.apply(this, args) 10 | needCall = false 11 | } 12 | } 13 | function debounced() { 14 | needCall = true 15 | args = arguments 16 | requestAnimationFrame(onFrame) 17 | } 18 | return debounced as any 19 | } 20 | -------------------------------------------------------------------------------- /src/lib/Dirtiness.ts: -------------------------------------------------------------------------------- 1 | import {Rect} from 'paintvec' 2 | 3 | export default 4 | class Dirtiness { 5 | private _whole = false 6 | private _rect: Rect|undefined 7 | 8 | get whole() { 9 | return this._whole 10 | } 11 | get rect() { 12 | if (!this._whole) { 13 | return this._rect 14 | } 15 | } 16 | get dirty() { 17 | return this._whole || !!this.rect 18 | } 19 | 20 | clear() { 21 | this._whole = false 22 | this._rect = undefined 23 | } 24 | addWhole() { 25 | this._whole = true 26 | this._rect = undefined 27 | } 28 | addRect(rect: Rect) { 29 | if (this._rect) { 30 | this._rect = this._rect.union(rect) 31 | } else { 32 | this._rect = rect 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/lib/Float.ts: -------------------------------------------------------------------------------- 1 | // http://stackoverflow.com/questions/32633585/how-do-you-convert-to-half-floats-in-javascript 2 | 3 | export 4 | function float32To16(x: number) { 5 | let bits = (x >> 16) & 0x8000 /* Get the sign */ 6 | let m = (x >> 12) & 0x07ff /* Keep one extra bit for rounding */ 7 | let e = (x >> 23) & 0xff /* Using int is faster here */ 8 | 9 | /* If zero, or denormal, or exponent underflows too much for a denormal 10 | * half, return signed zero. */ 11 | if (e < 103) { 12 | return bits 13 | } 14 | 15 | /* If NaN, return NaN. If Inf or exponent overflow, return Inf. */ 16 | if (e > 142) { 17 | bits |= 0x7c00 18 | /* If exponent was 0xff and one mantissa bit was set, it means NaN, 19 | * not Inf, so make sure we set one mantissa bit too. */ 20 | bits |= ((e === 255) ? 0 : 1) && (x & 0x007fffff) 21 | return bits 22 | } 23 | 24 | /* If exponent underflows but not too much, return a denormal */ 25 | if (e < 113) { 26 | m |= 0x0800 27 | /* Extra rounding may overflow and set mantissa to 0 and exponent 28 | * to 1, which is OK. */ 29 | bits |= (m >> (114 - e)) + ((m >> (113 - e)) & 1) 30 | return bits 31 | } 32 | 33 | bits |= ((e - 112) << 10) | (m >> 1) 34 | /* Extra rounding. An overflow will set mantissa to 0 and increment 35 | * the exponent, which is OK. */ 36 | bits += m & 1 37 | return bits 38 | } 39 | 40 | export 41 | function float32ArrayTo16(data: Float32Array) { 42 | const src = new Uint32Array(data.buffer) 43 | const dst = new Uint16Array(data.length) 44 | for (let i = 0; i < data.length; ++i) { 45 | dst[i] = float32To16(src[i]) 46 | } 47 | return dst 48 | } 49 | -------------------------------------------------------------------------------- /src/lib/Geometry.ts: -------------------------------------------------------------------------------- 1 | import {Vec2} from 'paintvec' 2 | 3 | export 4 | class CubicPolynomial { 5 | // x(t) = c0 + c1 * x + c2 * x^2 + c3 * x ^ 3 6 | constructor(public c0: number, public c1: number, public c2: number, public c3: number) { 7 | } 8 | // Calc x(t) 9 | eval(t: number) { 10 | const {c0, c1, c2, c3} = this 11 | const t2 = t * t 12 | const t3 = t2 * t 13 | return c0 + c1 * t + c2 * t2 + c3 * t3 14 | } 15 | // Return x(t) such that x(0) = x0, x(1) = x1, x'(0) = t0, x'(1) = t1 16 | static fromSlopes(x0: number, x1: number, t0: number, t1: number) { 17 | return new CubicPolynomial( 18 | x0, 19 | t0, 20 | -3 * x0 + 3 * x1 - 2 * t0 - t1, 21 | 2 * x0 - 2 * x1 + t0 + t1 22 | ) 23 | } 24 | } 25 | 26 | // Return polynomial for catmull rom interpolation between x1 and x2 27 | export function catmullRom(x0: number, x1: number, x2: number, x3: number) { 28 | return CubicPolynomial.fromSlopes(x1, x2, (x2 - x0) * 0.5, (x3 - x1) * 0.5) 29 | } 30 | 31 | // Return polynomial for non-uniform catmull rom interpolation 32 | export function nonUniformCatmullRom(x0: number, x1: number, x2: number, x3: number, dt0: number, dt1: number, dt2: number) { 33 | let t1 = (x1 - x0) / dt0 - (x2 - x0) / (dt0 + dt1) + (x2 - x1) / dt1 34 | let t2 = (x2 - x1) / dt1 - (x3 - x1) / (dt1 + dt2) + (x3 - x2) / dt2 35 | t1 *= dt1 36 | t2 *= dt1 37 | return CubicPolynomial.fromSlopes(x1, x2, t1, t2) 38 | } 39 | 40 | // Return centripetal catmull rom interpolation between points 41 | // http://stackoverflow.com/questions/9489736/catmull-rom-curve-with-no-cusps-and-no-self-intersections/23980479#23980479 42 | export function centripetalCatmullRom(p0: Vec2, p1: Vec2, p2: Vec2, p3: Vec2): [CubicPolynomial, CubicPolynomial] { 43 | let dt0 = Math.pow(p1.sub(p0).squaredLength(), 0.25) 44 | let dt1 = Math.pow(p2.sub(p1).squaredLength(), 0.25) 45 | let dt2 = Math.pow(p3.sub(p2).squaredLength(), 0.25) 46 | 47 | if (dt1 < 1e-4) dt1 = 1.0 48 | if (dt0 < 1e-4) dt0 = dt1 49 | if (dt2 < 1e-4) dt2 = dt1 50 | 51 | return [ 52 | nonUniformCatmullRom(p0.x, p1.x, p2.x, p3.x, dt0, dt1, dt2), 53 | nonUniformCatmullRom(p0.y, p1.y, p2.y, p3.y, dt0, dt1, dt2), 54 | ] 55 | } 56 | -------------------------------------------------------------------------------- /src/lib/IndexPath.ts: -------------------------------------------------------------------------------- 1 | 2 | export default 3 | class IndexPath { 4 | 5 | constructor(public indices: number[] = []) { 6 | } 7 | 8 | get empty() { 9 | return this.length === 0 10 | } 11 | 12 | get length() { 13 | return this.indices.length 14 | } 15 | 16 | get parent() { 17 | if (this.indices.length > 0) { 18 | return new IndexPath(this.indices.slice(0, -1)) 19 | } 20 | } 21 | 22 | at(i: number) { 23 | return this.indices[i] 24 | } 25 | 26 | get last() { 27 | return this.indices[this.indices.length - 1] 28 | } 29 | 30 | slice(start: number, end: number = this.length) { 31 | return new IndexPath(this.indices.slice(start, end)) 32 | } 33 | 34 | child(index: number) { 35 | return new IndexPath([...this.indices, index]) 36 | } 37 | 38 | equals(other: IndexPath) { 39 | return this.compare(other) === 0 40 | } 41 | 42 | compare(other: IndexPath): number { 43 | if (this.length === 0 && other.length === 0) { 44 | return 0 45 | } else if (this.length === 0) { 46 | return -1 47 | } else if (other.length === 0) { 48 | return 1 49 | } else { 50 | const diff = this.at(0) - other.at(0) 51 | if (diff === 0) { 52 | return this.slice(1).compare(other.slice(1)) 53 | } else { 54 | return diff 55 | } 56 | } 57 | } 58 | 59 | clone() { 60 | return new IndexPath([...this.indices]) 61 | } 62 | 63 | isSibling(other: IndexPath) { 64 | return this.parent && other.parent && this.parent.equals(other.parent) 65 | } 66 | 67 | afterRemove(pathsToRemove: IndexPath[]) { 68 | const newPath = this.clone() 69 | for (let len = this.length; len > 0; --len) { 70 | const subPath = this.slice(0, len) 71 | for (const pathToRemove of pathsToRemove) { 72 | if (pathToRemove.isSibling(subPath) && pathToRemove.last < subPath.last) { 73 | newPath.indices[len - 1]-- 74 | } 75 | } 76 | } 77 | return newPath 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/lib/KeyInput.ts: -------------------------------------------------------------------------------- 1 | const deepEqual = require('deep-equal') 2 | const keyboardLayout = require('keyboard-layout') 3 | 4 | function electronKeyNames(code: string) { 5 | switch (code) { 6 | case 'Meta': 7 | return 'Command' 8 | case 'MetaOrControl': 9 | return 'CommandOrControl' 10 | case 'Space': 11 | return 'Space' 12 | case 'ArrowUp': 13 | return 'Up' 14 | case 'ArrowDown': 15 | return 'Down' 16 | case 'ArrowLeft': 17 | return 'Left' 18 | case 'ArrowRight': 19 | return 'Right' 20 | } 21 | const keymap = keyboardLayout.getCurrentKeymap() 22 | if (code in keymap) { 23 | const key = keymap[code].unmodified 24 | switch (key) { 25 | case '+': 26 | return 'Plus' 27 | default: 28 | return key 29 | } 30 | } 31 | return code 32 | } 33 | 34 | export 35 | type KeyModifier = 'Meta'|'Control'|'MetaOrControl'|'Alt'|'Shift' 36 | 37 | export 38 | const allModifiers: KeyModifier[] = ['Meta', 'Control', 'MetaOrControl', 'Alt', 'Shift'] 39 | 40 | export 41 | interface KeyInputData { 42 | modifiers: KeyModifier[] 43 | code: string 44 | } 45 | 46 | // TODO: make sure to work in non-US keyboards 47 | export default 48 | class KeyInput { 49 | constructor(public modifiers: KeyModifier[], public code: string) { 50 | } 51 | 52 | static fromData(data: KeyInputData) { 53 | return new KeyInput(data.modifiers, data.code) 54 | } 55 | 56 | static fromEvent(e: KeyboardEvent) { 57 | const modifiers = allModifiers.filter(m => e.getModifierState(m)) 58 | return new KeyInput(modifiers, e.code) 59 | } 60 | 61 | toData(): KeyInputData { 62 | const {modifiers, code} = this 63 | return {modifiers, code} 64 | } 65 | 66 | toElectronAccelerator() { 67 | return [...this.modifiers, this.code].map(electronKeyNames).join('+') 68 | } 69 | 70 | equals(other: KeyInput) { 71 | if (this.code === other.code) { 72 | for (const metaOrCtrl of ['Meta', 'Control']) { 73 | const normalizeModifiers = (modifiers: KeyModifier[]) => modifiers.map(m => m === 'MetaOrControl' ? metaOrCtrl : m).sort() 74 | if (deepEqual(normalizeModifiers(this.modifiers), normalizeModifiers(other.modifiers))) { 75 | return true 76 | } 77 | } 78 | } 79 | return false 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/lib/KeyRecorder.ts: -------------------------------------------------------------------------------- 1 | import KeyInput, {KeyModifier, allModifiers} from './KeyInput' 2 | 3 | export default 4 | class KeyRecorder { 5 | pressedCode: string|undefined 6 | pressedModifiers = new Set() 7 | 8 | get keyInput() { 9 | if (this.pressedCode) { 10 | return new KeyInput([...this.pressedModifiers], this.pressedCode) 11 | } 12 | } 13 | 14 | clear() { 15 | this.pressedCode = undefined 16 | this.pressedModifiers.clear() 17 | } 18 | 19 | keyDown(e: KeyboardEvent) { 20 | if (allModifiers.includes(e.key as KeyModifier) && this.pressedCode != undefined && !allModifiers.includes(this.pressedCode as KeyModifier)) { 21 | this.pressedModifiers.add(e.key as KeyModifier) 22 | console.log('add modifier', e.key) 23 | } else { 24 | this.clear() 25 | this.pressedCode = e.code 26 | if (!allModifiers.includes(e.key as KeyModifier)) { 27 | this.pressedModifiers = new Set(allModifiers.filter(m => e.getModifierState(m))) 28 | } 29 | console.log('down', this.pressedCode, this.pressedModifiers) 30 | } 31 | } 32 | 33 | keyUp(e: KeyboardEvent) { 34 | if (this.pressedModifiers.has(e.key as KeyModifier)) { 35 | this.pressedModifiers.delete(e.key as KeyModifier) 36 | console.log('remove modifier', e.key) 37 | } else { 38 | this.pressedCode = undefined 39 | console.log('up', e.code) 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/lib/ObservableWeakMap.ts: -------------------------------------------------------------------------------- 1 | import {observable} from 'mobx' 2 | 3 | class ObservableWeakMapContainer { 4 | @observable value: T 5 | } 6 | 7 | export default 8 | class ObservableWeakMap { 9 | weakMap = new WeakMap>() 10 | 11 | has(key: K) { 12 | return this.weakMap.has(key) 13 | } 14 | 15 | set(key: K, value: V) { 16 | let container = this.weakMap.get(key) 17 | if (!container) { 18 | container = new ObservableWeakMapContainer() 19 | this.weakMap.set(key, container) 20 | } 21 | container.value = value 22 | } 23 | 24 | get(key: K) { 25 | let container = this.weakMap.get(key) 26 | if (container) { 27 | return container.value 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/lib/glsl/bicubic.glsl: -------------------------------------------------------------------------------- 1 | // http://www.java-gaming.org/index.php?topic=35123.0 2 | vec4 cubic(float v) { 3 | vec4 n = vec4(1.0, 2.0, 3.0, 4.0) - v; 4 | vec4 s = n * n * n; 5 | float x = s.x; 6 | float y = s.y - 4.0 * s.x; 7 | float z = s.z - 4.0 * s.y + 6.0 * s.x; 8 | float w = 6.0 - x - y - z; 9 | return vec4(x, y, z, w) * (1.0/6.0); 10 | } 11 | 12 | // assuming sampler filter is GL_LINEAR 13 | vec4 bicubic(sampler2D sampler, vec2 texSize, vec2 texCoords) { 14 | vec2 invTexSize = 1.0 / texSize; 15 | texCoords = texCoords * texSize - 0.5; 16 | 17 | vec2 fxy = fract(texCoords); 18 | texCoords -= fxy; 19 | 20 | vec4 xcubic = cubic(fxy.x); 21 | vec4 ycubic = cubic(fxy.y); 22 | 23 | vec4 c = texCoords.xxyy + vec2(-0.5, +1.5).xyxy; 24 | 25 | vec4 s = vec4(xcubic.xz + xcubic.yw, ycubic.xz + ycubic.yw); 26 | vec4 offset = c + vec4(xcubic.yw, ycubic.yw) / s; 27 | 28 | offset *= invTexSize.xxyy; 29 | 30 | vec4 sample0 = texture2D(sampler, offset.xz); 31 | vec4 sample1 = texture2D(sampler, offset.yz); 32 | vec4 sample2 = texture2D(sampler, offset.xw); 33 | vec4 sample3 = texture2D(sampler, offset.yw); 34 | 35 | float sx = s.x / (s.x + s.y); 36 | float sy = s.z / (s.z + s.w); 37 | 38 | return mix( 39 | mix(sample3, sample2, sx), mix(sample1, sample0, sx) 40 | , sy); 41 | } 42 | 43 | #pragma glslify: export(bicubic) 44 | -------------------------------------------------------------------------------- /src/lib/glsl/boxShadow.glsl: -------------------------------------------------------------------------------- 1 | // http://madebyevan.com/shaders/fast-rounded-rectangle-shadows/ 2 | 3 | // This approximates the error function, needed for the gaussian integral 4 | vec4 erf(vec4 x) { 5 | vec4 s = sign(x), a = abs(x); 6 | x = 1.0 + (0.278393 + (0.230389 + 0.078108 * (a * a)) * a) * a; 7 | x *= x; 8 | return s - s / (x * x); 9 | } 10 | 11 | // Return the mask for the shadow of a box from lower to upper 12 | float boxShadow(vec2 lower, vec2 upper, vec2 point, float sigma) { 13 | vec4 query = vec4(point - lower, point - upper); 14 | vec4 integral = 0.5 + 0.5 * erf(query * (sqrt(0.5) / sigma)); 15 | return (integral.z - integral.x) * (integral.w - integral.y); 16 | } 17 | 18 | #pragma glslify: export(boxShadow) 19 | -------------------------------------------------------------------------------- /src/lib/glsl/inverseBilinear.glsl: -------------------------------------------------------------------------------- 1 | // http://www.iquilezles.org/www/articles/ibilinear/ibilinear.htm 2 | 3 | float cross(vec2 a, vec2 b) { 4 | return a.x*b.y - a.y*b.x; 5 | } 6 | 7 | vec2 inverseBilinear(vec2 p, vec2 a, vec2 b, vec2 c, vec2 d) { 8 | vec2 e = b-a; 9 | vec2 f = d-a; 10 | vec2 g = a-b+c-d; 11 | vec2 h = p-a; 12 | 13 | float k2 = cross( g, f ); 14 | float k1 = cross( e, f ) + cross( h, g ); 15 | float k0 = cross( h, e ); 16 | 17 | float w = k1*k1 - 4.0*k0*k2; 18 | 19 | if( w<0.0 ) return vec2(-1.0); 20 | 21 | w = sqrt( w ); 22 | 23 | if (abs(k2) < 0.001) { 24 | float v = -k0/k1; 25 | float u = (h.x - f.x*v)/(e.x + g.x*v); 26 | return vec2(u, v); 27 | } 28 | 29 | float v1 = (-k1 - w)/(2.0*k2); 30 | float v2 = (-k1 + w)/(2.0*k2); 31 | float u1 = (h.x - f.x*v1)/(e.x + g.x*v1); 32 | float u2 = (h.x - f.x*v2)/(e.x + g.x*v2); 33 | bool b1 = v1>0.0 && v1<1.0 && u1>0.0 && u1<1.0; 34 | bool b2 = v2>0.0 && v2<1.0 && u2>0.0 && u2<1.0; 35 | 36 | vec2 res = vec2(-1.0); 37 | 38 | if( b1 && !b2 ) res = vec2( u1, v1 ); 39 | if( !b1 && b2 ) res = vec2( u2, v2 ); 40 | 41 | return res; 42 | } 43 | 44 | #pragma glslify: export(inverseBilinear) 45 | -------------------------------------------------------------------------------- /src/nativelib/FloodFill.cc: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include "nbind/api.h" 4 | 5 | std::vector> floodFillStack; 6 | 7 | // Stack-based scanline flood fill from http://lodev.org/cgtutor/floodfill.html 8 | void floodFill(int x, int y, int w, int h, nbind::Buffer srcBuf, nbind::Buffer dstBuf) { 9 | int stride = ceil(w / 32.0); 10 | auto srcBase = (const uint32_t *)srcBuf.data(); 11 | auto dstBase = (uint32_t *)dstBuf.data(); 12 | 13 | if (!(0 <= x && x < w && 0 <= y && y < h)) { 14 | return; 15 | } 16 | floodFillStack.clear(); 17 | floodFillStack.push_back(std::make_tuple(x, y)); 18 | 19 | while (floodFillStack.size() > 0) { 20 | int x0; 21 | int y; 22 | std::tie(x0, y) = floodFillStack.back(); 23 | floodFillStack.pop_back(); 24 | 25 | int x = x0; 26 | int index = x >> 5; 27 | auto src = srcBase + y * stride + index; 28 | auto dst = dstBase + y * stride + index; 29 | auto srcAbove = src - stride; 30 | auto srcBelow = src + stride; 31 | uint32_t mask = uint32_t(1) << uint32_t(x - (index << 5)); 32 | if (*dst & mask) { 33 | continue; 34 | } 35 | 36 | auto next = [&] { 37 | ++x; 38 | mask <<= 1; 39 | if (mask == 0) { 40 | mask = 1; 41 | ++src; 42 | ++dst; 43 | ++srcAbove; 44 | ++srcBelow; 45 | } 46 | }; 47 | 48 | auto prev = [&] { 49 | --x; 50 | mask >>= 1; 51 | if (mask == 0) { 52 | mask = 0x80000000; 53 | --src; 54 | --dst; 55 | --srcAbove; 56 | --srcBelow; 57 | } 58 | }; 59 | 60 | while (x >= 0 && *src & mask) { 61 | prev(); 62 | } 63 | next(); 64 | 65 | bool spanAbove = false; 66 | bool spanBelow = false; 67 | 68 | while (x < w && *src & mask) { 69 | *dst |= mask; 70 | if (!spanAbove && y > 0 && *srcAbove & mask) { 71 | floodFillStack.push_back(std::make_tuple(x, y - 1)); 72 | spanAbove = true; 73 | } else if (spanAbove && y > 0 && !(*srcAbove & mask)) { 74 | spanAbove = false; 75 | } 76 | if (!spanBelow && y < h - 1 && *srcBelow & mask) { 77 | floodFillStack.push_back(std::make_tuple(x, y + 1)); 78 | spanBelow = true; 79 | } else if (spanBelow && y < h - 1 && !(*srcBelow & mask)) { 80 | spanBelow = false; 81 | } 82 | next(); 83 | } 84 | } 85 | 86 | dstBuf.commit(); 87 | } 88 | 89 | #include "nbind/nbind.h" 90 | 91 | NBIND_GLOBAL() { 92 | function(floodFill); 93 | } 94 | -------------------------------------------------------------------------------- /src/nativelib/WindowUtilMac.mm: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include "nbind/api.h" 4 | #import 5 | 6 | static NSTextField *findTextField(NSView *view) { 7 | for (NSView *subview in view.subviews) { 8 | if ([subview isKindOfClass:[NSTextField class]]) { 9 | return (NSTextField *)subview; 10 | } else { 11 | NSTextField* result = findTextField(subview); 12 | if (result) { 13 | return result; 14 | } 15 | } 16 | } 17 | return nullptr; 18 | } 19 | 20 | // http://stackoverflow.com/a/29336473 21 | static void setTitleColorImpl(NSWindow *window, double r, double g, double b, double a) { 22 | auto titleField = findTextField([window contentView].superview); 23 | auto color = [NSColor colorWithSRGBRed:r green:g blue:b alpha:a]; 24 | if (titleField) { 25 | auto attributedStr = [[NSAttributedString alloc] initWithString:titleField.stringValue attributes:@{ 26 | NSForegroundColorAttributeName: color 27 | }]; 28 | titleField.attributedStringValue = attributedStr; 29 | } 30 | } 31 | 32 | static NSWindow *windowFromHandle(const nbind::Buffer &handle) { 33 | auto view = *((NSView **)handle.data()); 34 | return view.window; 35 | } 36 | 37 | struct WindowUtilMac { 38 | static void initWindow(nbind::Buffer handle) { 39 | auto win = windowFromHandle(handle); 40 | win.titlebarAppearsTransparent = true; 41 | win.styleMask |= NSFullSizeContentViewWindowMask; 42 | } 43 | 44 | static void setTitleColor(nbind::Buffer handle, double r, double g, double b, double a) { 45 | auto win = windowFromHandle(handle); 46 | setTitleColorImpl(win, r, g, b, a); 47 | } 48 | }; 49 | 50 | #include "nbind/nbind.h" 51 | 52 | NBIND_CLASS(WindowUtilMac) { 53 | method(initWindow); 54 | method(setTitleColor); 55 | } 56 | -------------------------------------------------------------------------------- /src/renderer/GLContext.ts: -------------------------------------------------------------------------------- 1 | import {Context} from 'paintgl' 2 | export const canvas = document.createElement('canvas') 3 | canvas.className = 'DrawArea_canvas' 4 | export const context = new Context(canvas, {preserveDrawingBuffer: true, alpha: false, antialias: false}) 5 | -------------------------------------------------------------------------------- /src/renderer/actions/Action.ts: -------------------------------------------------------------------------------- 1 | import {appState} from '../app/AppState' 2 | 3 | export 4 | abstract class Action { 5 | abstract id: string 6 | abstract title: string 7 | abstract enabled: boolean 8 | abstract run(): void 9 | } 10 | export default Action 11 | 12 | export 13 | abstract class PictureAction extends Action { 14 | get picture() { 15 | return appState.currentPicture 16 | } 17 | get pictureState() { 18 | return appState.currentPictureState 19 | } 20 | get enabled() { 21 | return !!this.picture 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/renderer/actions/ActionIDs.ts: -------------------------------------------------------------------------------- 1 | 2 | export default { 3 | appPreferences: 'app:preferences', 4 | 5 | fileNew: 'file:new', 6 | fileOpen: 'file:open', 7 | fileSave: 'file:save', 8 | fileSaveAs: 'file:saveAs', 9 | fileExport: 'file:export', 10 | fileClose: 'file:close', 11 | 12 | editUndo: 'edit:undo', 13 | editRedo: 'edit:redo', 14 | editCut: 'edit:cut', 15 | editCopy: 'edit:copy', 16 | editPaste: 'edit:paste', 17 | editDelete: 'edit:delete', 18 | 19 | selectionSelectAll: 'selection:selectAll', 20 | selectionClear: 'selection:clear', 21 | selectionInvert: 'selection:invert', 22 | 23 | layerAdd: 'layer:add', 24 | layerAddGroup: 'layer:addGroup', 25 | layerImport: 'layer:import', 26 | layerGroup: 'layer:group', 27 | layerRemove: 'layer:remove', 28 | layerMerge: 'layer:merge', 29 | layerClear: 'layer:clear', 30 | layerFill: 'layer:fill', 31 | 32 | canvasChangeResolution: 'canvas:changeResolution', 33 | canvasRotateLeft: 'canvas:rotateLeft', 34 | canvasRotateRight: 'canvas:rotateRight', 35 | canvasRotate180: 'canvas:rotate180', 36 | canvasFlipHorizontally: 'canvas:flipHorizontally', 37 | canvasFlipVertically: 'canvas:flipVertically', 38 | 39 | viewReload: 'view:reload', 40 | viewToggleDevTools: 'view:toggleDevTools', 41 | viewActualSize: 'view:actualSize', 42 | viewZoomIn: 'view:zoomIn', 43 | viewZoomOut: 'view:zoomOut', 44 | viewToggleUIPanels: 'view:toggleUIPanels', 45 | viewToggleFullscreen: 'view:toggleFullscreen', 46 | } 47 | -------------------------------------------------------------------------------- /src/renderer/actions/AppActions.ts: -------------------------------------------------------------------------------- 1 | import {addAction} from '../app/ActionRegistry' 2 | import {preferencesLauncher} from '../views/preferences/PreferencesLauncher' 3 | import Action from './Action' 4 | import ActionIDs from './ActionIDs' 5 | 6 | @addAction 7 | export class AppPreferencesAction extends Action { 8 | enabled = true 9 | title = 'Preferences...' 10 | id = ActionIDs.appPreferences 11 | 12 | run() { 13 | preferencesLauncher.open() 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/renderer/actions/CanvasActions.ts: -------------------------------------------------------------------------------- 1 | import {addAction} from '../app/ActionRegistry' 2 | import {FlipPictureCommand, Rotate90PictureCommand, Rotate180PictureCommand, ChangePictureResolutionCommand} from '../commands/PictureCommand' 3 | import {dialogLauncher} from '../views/dialogs/DialogLauncher' 4 | import {PictureAction} from './Action' 5 | import ActionIDs from './ActionIDs' 6 | 7 | @addAction 8 | export class CanvasChangeResolutionAction extends PictureAction { 9 | id = ActionIDs.canvasChangeResolution 10 | title = 'Change Canvas Resolution...' 11 | async run() { 12 | if (!this.picture) { 13 | return 14 | } 15 | const newDimension = await dialogLauncher.openResolutionChangeDialog(this.picture.dimension) 16 | if (newDimension) { 17 | this.picture.undoStack.push(new ChangePictureResolutionCommand(this.picture, newDimension)) 18 | } 19 | } 20 | } 21 | 22 | @addAction 23 | export class CanvasRotateLeftAction extends PictureAction { 24 | id = ActionIDs.canvasRotateLeft 25 | title = 'Rotate 90° Left' 26 | run() { 27 | this.picture && this.picture.undoStack.push(new Rotate90PictureCommand(this.picture, 'left')) 28 | } 29 | } 30 | 31 | @addAction 32 | export class CanvasRotateRightAction extends PictureAction { 33 | id = ActionIDs.canvasRotateRight 34 | title = 'Rotate 90° Right' 35 | run() { 36 | this.picture && this.picture.undoStack.push(new Rotate90PictureCommand(this.picture, 'right')) 37 | } 38 | } 39 | 40 | @addAction 41 | export class CanvasRotate180Action extends PictureAction { 42 | id = ActionIDs.canvasRotate180 43 | title = 'Rotate 180°' 44 | run() { 45 | this.picture && this.picture.undoStack.push(new Rotate180PictureCommand(this.picture)) 46 | } 47 | } 48 | 49 | @addAction 50 | export class CanvasFlipHorizontallyAction extends PictureAction { 51 | id = ActionIDs.canvasFlipHorizontally 52 | title = 'Flip Horizontally' 53 | run() { 54 | this.picture && this.picture.undoStack.push(new FlipPictureCommand(this.picture, 'horizontal')) 55 | } 56 | } 57 | 58 | @addAction 59 | export class CanvasFlipVerticallyAction extends PictureAction { 60 | id = ActionIDs.canvasFlipVertically 61 | title = 'Flip Vertically' 62 | run() { 63 | this.picture && this.picture.undoStack.push(new FlipPictureCommand(this.picture, 'vertical')) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/renderer/actions/EditActions.ts: -------------------------------------------------------------------------------- 1 | import {remote} from 'electron' 2 | import {addAction} from '../app/ActionRegistry' 3 | import {appState} from '../app/AppState' 4 | import {currentFocus} from '../views/CurrentFocus' 5 | import Action from './Action' 6 | import ActionIDs from './ActionIDs' 7 | 8 | @addAction 9 | export class EditUndoAction extends Action { 10 | id = ActionIDs.editUndo 11 | get title() { 12 | if (!currentFocus.isTextInput && appState.undoStack) { 13 | const {undoCommand} = appState.undoStack 14 | if (undoCommand) { 15 | return `Undo ${undoCommand.title}` 16 | } 17 | } 18 | return 'Undo' 19 | } 20 | get enabled() { 21 | if (currentFocus.isTextInput) { 22 | return true 23 | } else if (appState.undoStack) { 24 | return appState.undoStack.isUndoable 25 | } 26 | return false 27 | } 28 | run() { 29 | if (currentFocus.isTextInput) { 30 | remote.getCurrentWebContents().undo() 31 | } else if (appState.undoStack) { 32 | appState.undoStack.undo() 33 | } 34 | } 35 | } 36 | 37 | @addAction 38 | export class EditRedoAction extends Action { 39 | id = ActionIDs.editRedo 40 | get title() { 41 | if (!currentFocus.isTextInput && appState.undoStack) { 42 | const {redoCommand} = appState.undoStack 43 | if (redoCommand) { 44 | return `Redo ${redoCommand.title}` 45 | } 46 | } 47 | return 'Redo' 48 | } 49 | get enabled() { 50 | if (currentFocus.isTextInput) { 51 | return true 52 | } else if (appState.undoStack) { 53 | return appState.undoStack.isRedoable 54 | } 55 | return false 56 | } 57 | run() { 58 | if (currentFocus.isTextInput) { 59 | remote.getCurrentWebContents().redo() 60 | } else if (appState.undoStack) { 61 | appState.undoStack.redo() 62 | } 63 | } 64 | } 65 | 66 | @addAction 67 | export class EditCutAction extends Action { 68 | id = ActionIDs.editCut 69 | title = 'Cut' 70 | enabled = true 71 | run() { 72 | remote.getCurrentWebContents().cut() 73 | } 74 | } 75 | 76 | @addAction 77 | export class EditCopyAction extends Action { 78 | id = ActionIDs.editCopy 79 | title = 'Copy' 80 | enabled = true 81 | run() { 82 | remote.getCurrentWebContents().copy() 83 | } 84 | } 85 | 86 | @addAction 87 | export class EditPasteAction extends Action { 88 | id = ActionIDs.editPaste 89 | title = 'Paste' 90 | enabled = true 91 | run() { 92 | remote.getCurrentWebContents().paste() 93 | } 94 | } 95 | 96 | @addAction 97 | export class EditDeleteAction extends Action { 98 | id = ActionIDs.editDelete 99 | title = 'Delete' 100 | enabled = true 101 | run() { 102 | remote.getCurrentWebContents().delete() 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/renderer/actions/FileActions.ts: -------------------------------------------------------------------------------- 1 | import {addAction, actionRegistry} from '../app/ActionRegistry' 2 | import {appState} from '../app/AppState' 3 | import {formatRegistry} from '../app/FormatRegistry' 4 | import PictureFormat from '../formats/PictureFormat' 5 | import {PictureExport} from '../services/PictureExport' 6 | import Action, {PictureAction} from './Action' 7 | import ActionIDs from './ActionIDs' 8 | 9 | @addAction 10 | export class FileNewAction extends Action { 11 | id = ActionIDs.fileNew 12 | title = 'New...' 13 | enabled = true 14 | run() { 15 | appState.newPicture() 16 | } 17 | } 18 | 19 | @addAction 20 | export class FileOpenAction extends Action { 21 | id = ActionIDs.fileOpen 22 | title = 'Open...' 23 | enabled = true 24 | run() { 25 | appState.openPicture() 26 | } 27 | } 28 | 29 | @addAction 30 | export class FileSaveAction extends PictureAction { 31 | id = ActionIDs.fileSave 32 | title = 'Save' 33 | run() { 34 | this.pictureState && this.pictureState.save() 35 | } 36 | } 37 | 38 | @addAction 39 | export class FileSaveAsAction extends PictureAction { 40 | id = ActionIDs.fileSaveAs 41 | title = 'Save As...' 42 | run() { 43 | this.pictureState && this.pictureState.saveAs() 44 | } 45 | } 46 | 47 | export class FileExportAction extends PictureAction { 48 | id = `${ActionIDs.fileExport}:${this.format.mimeType}` 49 | title = `Export ${this.format.title}...` 50 | constructor(public format: PictureFormat) { 51 | super() 52 | } 53 | 54 | async run() { 55 | if (this.picture) { 56 | const pictureExport = new PictureExport(this.picture) 57 | await pictureExport.showExportDialog(this.format) 58 | pictureExport.dispose() 59 | } 60 | } 61 | } 62 | 63 | for (const format of formatRegistry.pictureFormats) { 64 | actionRegistry.add(new FileExportAction(format)) 65 | } 66 | 67 | @addAction 68 | export class FileCloseAction extends PictureAction { 69 | id = ActionIDs.fileClose 70 | title = 'Close' 71 | run() { 72 | appState.closePicture(appState.currentPictureIndex) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/renderer/actions/SelectionAction.ts: -------------------------------------------------------------------------------- 1 | import {remote} from 'electron' 2 | import {addAction} from '../app/ActionRegistry' 3 | import {SelectAllCommand, ClearSelectionCommand, InvertSelectionCommand} from '../commands/SelectionCommand' 4 | import {currentFocus} from '../views/CurrentFocus' 5 | import {PictureAction} from './Action' 6 | import ActionIDs from './ActionIDs' 7 | 8 | @addAction 9 | export class SelectionSelectAllAction extends PictureAction { 10 | id = ActionIDs.selectionSelectAll 11 | title = 'Select All' 12 | get enabled() { 13 | return currentFocus.isTextInput || !!this.pictureState 14 | } 15 | run() { 16 | if (currentFocus.isTextInput) { 17 | remote.getCurrentWebContents().selectAll() 18 | } else if (this.picture) { 19 | this.picture.undoStack.push(new SelectAllCommand(this.picture)) 20 | } 21 | } 22 | } 23 | 24 | @addAction 25 | export class SelectionClearAction extends PictureAction { 26 | id = ActionIDs.selectionClear 27 | title = 'Clear Selection' 28 | run() { 29 | this.picture && this.picture.undoStack.push(new ClearSelectionCommand(this.picture)) 30 | } 31 | } 32 | 33 | @addAction 34 | export class SelectionInvertAction extends PictureAction { 35 | id = ActionIDs.selectionInvert 36 | title = 'Invert Selection' 37 | run() { 38 | this.picture && this.picture.undoStack.push(new InvertSelectionCommand(this.picture)) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/renderer/actions/ViewActions.ts: -------------------------------------------------------------------------------- 1 | import {remote} from 'electron' 2 | import {addAction} from '../app/ActionRegistry' 3 | import {appState} from '../app/AppState' 4 | import Action, {PictureAction} from './Action' 5 | import ActionIDs from './ActionIDs' 6 | 7 | @addAction 8 | export class ViewReloadAction extends Action { 9 | id = ActionIDs.viewReload 10 | title = 'Reload' 11 | enabled = true 12 | run() { 13 | appState.reload() 14 | } 15 | } 16 | 17 | @addAction 18 | export class ViewToggleDevToolsAction extends Action { 19 | id = ActionIDs.viewToggleDevTools 20 | title = 'Toggle Developer Tools' 21 | enabled = true 22 | run() { 23 | remote.BrowserWindow.getFocusedWindow().webContents.toggleDevTools() 24 | } 25 | } 26 | 27 | @addAction 28 | export class ViewActualSizeAction extends PictureAction { 29 | id = ActionIDs.viewActualSize 30 | title = 'Actual Size' 31 | run() { 32 | this.picture && this.picture.navigation.resetScale() 33 | } 34 | } 35 | 36 | @addAction 37 | export class ViewZoomInAction extends PictureAction { 38 | id = ActionIDs.viewZoomIn 39 | title = 'Zoom In' 40 | run() { 41 | this.picture && this.picture.navigation.zoomIn() 42 | } 43 | } 44 | 45 | @addAction 46 | export class ViewZoomOutAction extends PictureAction { 47 | id = ActionIDs.viewZoomOut 48 | title = 'Zoom Out' 49 | run() { 50 | this.picture && this.picture.navigation.zoomOut() 51 | } 52 | } 53 | 54 | @addAction 55 | export class ViewToggleUIPanelsAction extends Action { 56 | id = ActionIDs.viewToggleUIPanels 57 | get title() { return appState.uiVisible ? 'Hide UI Panels' : 'Show UI Panels' } 58 | enabled = true 59 | run() { 60 | appState.toggleUIVisible() 61 | } 62 | } 63 | 64 | @addAction 65 | export class ViewToggleFullscreenAction extends Action { 66 | id = ActionIDs.viewToggleFullscreen 67 | title = 'Toggle Fullscreen' 68 | enabled = true 69 | run() { 70 | appState.toggleUIVisible() 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/renderer/app/ActionRegistry.ts: -------------------------------------------------------------------------------- 1 | import Action from '../actions/Action' 2 | 3 | export default 4 | class ActionRegistry { 5 | actions = new Map() 6 | 7 | add(...actions: Action[]) { 8 | for (const action of actions) { 9 | this.actions.set(action.id, action) 10 | } 11 | } 12 | } 13 | 14 | export const actionRegistry = new ActionRegistry() 15 | 16 | export function addAction(klass: {new(): Action}) { 17 | actionRegistry.add(new klass()) 18 | } 19 | -------------------------------------------------------------------------------- /src/renderer/app/BrushPresetManager.ts: -------------------------------------------------------------------------------- 1 | import {observable, computed} from 'mobx' 2 | import {BrushPreset} from '../brush/BrushPreset' 3 | import {defaultBrushPresets} from '../brush/DefaultBrushPresets' 4 | import {ConfigValues} from './Config' 5 | 6 | export 7 | class BrushPresetManager { 8 | readonly presets = observable([]) 9 | @observable currentPresetIndex = 0 10 | 11 | @computed get currentPreset() { 12 | const i = this.currentPresetIndex 13 | if (i < this.presets.length) { 14 | return this.presets[i] 15 | } 16 | } 17 | 18 | loadConfig(values: ConfigValues) { 19 | const presetsData = values.brushPresets.length > 0 ? values.brushPresets : defaultBrushPresets() 20 | this.presets.replace(presetsData.map(data => new BrushPreset(data))) 21 | this.currentPresetIndex = values.currentBrushPreset 22 | } 23 | 24 | saveConfig() { 25 | return { 26 | brushPresets: this.presets.map(p => p.toData()), 27 | currentBrushPreset: this.currentPresetIndex, 28 | } 29 | } 30 | } 31 | 32 | export const brushPresetManager = new BrushPresetManager() 33 | -------------------------------------------------------------------------------- /src/renderer/app/Config.ts: -------------------------------------------------------------------------------- 1 | import {remote} from 'electron' 2 | import * as fs from 'fs' 3 | import * as path from 'path' 4 | const deepAssign = require('deep-assign') 5 | import {BrushPresetData} from '../brush/BrushPreset' 6 | import {ToolConfigData} from '../tools/Tool' 7 | 8 | interface RectData { 9 | x: number 10 | y: number 11 | width: number 12 | height: number 13 | } 14 | 15 | interface ColorData { 16 | h: number 17 | s: number 18 | v: number 19 | } 20 | 21 | export 22 | interface ConfigValues { 23 | window: { 24 | fullscreen: boolean 25 | bounds?: RectData 26 | maximized: boolean 27 | } 28 | tools: { 29 | [name: string]: ToolConfigData 30 | } 31 | currentTool: string 32 | color: ColorData 33 | palette: (ColorData|undefined)[] 34 | files: string[] 35 | brushPresets: BrushPresetData[] 36 | currentBrushPreset: number 37 | // TODO: preferences 38 | } 39 | 40 | export default 41 | class Config { 42 | private _values: ConfigValues = { 43 | window: { 44 | fullscreen: false, 45 | maximized: false, 46 | }, 47 | tools: { 48 | }, 49 | currentTool: '', 50 | color: {h: 0, s: 0, v: 0}, 51 | palette: [], 52 | files: [], 53 | brushPresets: [], 54 | currentBrushPreset: 0, 55 | } 56 | path = path.join(remote.app.getPath('userData'), 'config.json') 57 | 58 | get values() { 59 | return this._values 60 | } 61 | 62 | set values(values: ConfigValues) { 63 | fs.writeFileSync(this.path, JSON.stringify(values, null, 2)) 64 | this._values = values 65 | } 66 | 67 | constructor() { 68 | try { 69 | const data = fs.readFileSync(this.path, 'utf8') 70 | deepAssign(this.values, JSON.parse(data)) 71 | } catch (e) { 72 | } 73 | } 74 | } 75 | 76 | export const config = new Config() 77 | -------------------------------------------------------------------------------- /src/renderer/app/FormatRegistry.ts: -------------------------------------------------------------------------------- 1 | import PictureFormat from '../formats/PictureFormat' 2 | 3 | export default 4 | class FormatRegistry { 5 | pictureFormats: PictureFormat[] = [] 6 | 7 | pictureFormatForExtension(ext: string) { 8 | return this.pictureFormats.find(f => f.extensions.includes(ext)) 9 | } 10 | 11 | pictureExtensions() { 12 | return this.pictureFormats.map(f => f.extensions).reduce((a, b) => a.concat(b), []) 13 | } 14 | } 15 | 16 | export const formatRegistry = new FormatRegistry() 17 | 18 | export function addPictureFormat(klass: {new(): PictureFormat}) { 19 | formatRegistry.pictureFormats.push(new klass()) 20 | } 21 | -------------------------------------------------------------------------------- /src/renderer/app/PictureState.ts: -------------------------------------------------------------------------------- 1 | import {remote} from 'electron' 2 | const {dialog} = remote 3 | import {ImageLayer} from '../models/Layer' 4 | import Picture from '../models/Picture' 5 | import {PictureSave} from '../services/PictureSave' 6 | import {dialogLauncher} from '../views/dialogs/DialogLauncher' 7 | import ThumbnailManager from './ThumbnailManager' 8 | const Semaphore = require('promise-semaphore') 9 | 10 | export 11 | class PictureState { 12 | thumbnailManager = new ThumbnailManager(this.picture) 13 | saveSemaphore = new Semaphore() 14 | 15 | constructor(public readonly picture: Picture) { 16 | } 17 | 18 | confirmClose(): Promise { 19 | return this.saveSemaphore.add(async () => { 20 | if (this.picture.edited) { 21 | const resultIndex = dialog.showMessageBox(remote.getCurrentWindow(), { 22 | buttons: ['Save', 'Cancel', 'Don\'t Save'], 23 | defaultId: 0, 24 | message: `Do you want to save changes to ${this.picture.fileName}?`, 25 | detail: 'Your changes will be lost without saving.', 26 | cancelId: 1, 27 | }) 28 | if (resultIndex === 1) { 29 | return false 30 | } 31 | if (resultIndex === 0) { 32 | const saved = await new PictureSave(this.picture).save() 33 | if (!saved) { 34 | return false 35 | } 36 | } 37 | } 38 | return true 39 | }) 40 | } 41 | 42 | save(): Promise { 43 | return this.saveSemaphore.add(async () => { 44 | return await new PictureSave(this.picture).save() 45 | }) 46 | } 47 | 48 | saveAs(): Promise { 49 | return this.saveSemaphore.add(async () => { 50 | return await new PictureSave(this.picture).saveAs() 51 | }) 52 | } 53 | 54 | dispose() { 55 | this.thumbnailManager.dispose() 56 | this.picture.dispose() 57 | } 58 | 59 | static async new() { 60 | const dimension = await dialogLauncher.openNewPictureDialog() 61 | if (dimension) { 62 | const picture = new Picture(dimension) 63 | const layer = new ImageLayer(picture, {name: 'Layer'}) 64 | picture.layers.replace([layer]) 65 | picture.selectedLayers.replace([layer]) 66 | 67 | return new PictureState(picture) 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/renderer/app/ThumbnailManager.ts: -------------------------------------------------------------------------------- 1 | import {reaction, action} from 'mobx' 2 | import {Vec2} from 'paintvec' 3 | import ObservableWeakMap from '../../lib/ObservableWeakMap' 4 | import Layer, {ImageLayer} from '../models/Layer' 5 | import Picture, {PictureUpdate} from '../models/Picture' 6 | import ThumbnailGenerator from '../services/ThumbnailGenerator' 7 | 8 | const LAYER_THUMBNAIL_SIZE = new Vec2(40) 9 | const NAVIGATOR_THUMBNAIL_SIZE = new Vec2(96, 96) 10 | 11 | export default 12 | class ThumbnailManager { 13 | private layerThumbnailGenerator: ThumbnailGenerator 14 | private navigatorThumbnailDirty = true 15 | private navigatorThumbnailGenerator: ThumbnailGenerator 16 | private disposers: (() => void)[] = [] 17 | private layerThumbnails = new ObservableWeakMap() 18 | 19 | get navigatorThumbnail() { 20 | this.updateNavigatorThumbnail() 21 | return this.navigatorThumbnailGenerator.thumbnail 22 | } 23 | get navigatorThumbnailScale() { 24 | this.updateNavigatorThumbnail() 25 | return this.navigatorThumbnailGenerator.scale 26 | } 27 | 28 | constructor(public readonly picture: Picture) { 29 | this.disposers.push( 30 | reaction(() => picture.lastUpdate, update => this.onUpdate(update)), 31 | reaction(() => picture.size, () => this.onResize()), 32 | ) 33 | this.onResize() 34 | } 35 | 36 | private updateNavigatorThumbnail() { 37 | if (this.navigatorThumbnailDirty) { 38 | this.navigatorThumbnailGenerator.loadTexture(this.picture.blender.getBlendedTexture()) 39 | this.navigatorThumbnailDirty = false 40 | } 41 | } 42 | 43 | private updateLayerThumbnail(layer: Layer) { 44 | if (!(layer instanceof ImageLayer)) { 45 | return 46 | } 47 | this.layerThumbnailGenerator.loadTiledTexture(layer.tiledTexture) 48 | const thumbnail = this.layerThumbnailGenerator.thumbnail.toDataURL() 49 | this.layerThumbnails.set(layer, thumbnail) 50 | } 51 | 52 | thumbnailForLayer(layer: Layer) { 53 | if (layer instanceof ImageLayer && !this.layerThumbnails.get(layer)) { 54 | this.updateLayerThumbnail(layer) 55 | } 56 | return this.layerThumbnails.get(layer) || '' 57 | } 58 | 59 | @action private onUpdate(update: PictureUpdate) { 60 | this.navigatorThumbnailDirty = true 61 | if (update.layer) { 62 | this.updateLayerThumbnail(update.layer) 63 | } 64 | } 65 | 66 | @action private onResize() { 67 | const {size} = this.picture 68 | if (this.layerThumbnailGenerator) { 69 | this.layerThumbnailGenerator.dispose() 70 | } 71 | if (this.navigatorThumbnailGenerator) { 72 | this.navigatorThumbnailGenerator.dispose() 73 | } 74 | this.layerThumbnailGenerator = new ThumbnailGenerator(size, LAYER_THUMBNAIL_SIZE.mulScalar(window.devicePixelRatio)) 75 | this.navigatorThumbnailGenerator = new ThumbnailGenerator(size, NAVIGATOR_THUMBNAIL_SIZE.mulScalar(window.devicePixelRatio)) 76 | 77 | this.navigatorThumbnailDirty = true 78 | this.picture.forEachLayer(layer => { 79 | this.updateLayerThumbnail(layer) 80 | }) 81 | } 82 | 83 | dispose() { 84 | this.disposers.forEach(f => f()) 85 | this.layerThumbnailGenerator.dispose() 86 | this.navigatorThumbnailGenerator.dispose() 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/renderer/app/ToolManager.ts: -------------------------------------------------------------------------------- 1 | import {observable} from 'mobx' 2 | import BrushTool from '../tools/BrushTool' 3 | import CanvasAreaTool from '../tools/CanvasAreaTool' 4 | import FloodFillTool from '../tools/FloodFillTool' 5 | import FreehandSelectTool from '../tools/FreehandSelectTool' 6 | import PanTool from '../tools/PanTool' 7 | import PolygonSelectTool from '../tools/PolygonSelectTool' 8 | import RectSelectTool from '../tools/RectSelectTool' 9 | import RotateTool from '../tools/RotateTool' 10 | import Tool from '../tools/Tool' 11 | import TransformLayerTool from '../tools/TransformLayerTool' 12 | import {ZoomTool} from '../tools/ZoomTool' 13 | import {ConfigValues} from './Config' 14 | 15 | export 16 | class ToolManager { 17 | readonly tools = observable([]) 18 | @observable currentTool: Tool 19 | @observable overrideTool: Tool|undefined 20 | 21 | add(...tools: Tool[]) { 22 | this.tools.push(...tools) 23 | } 24 | 25 | initTools() { 26 | this.tools.replace([ 27 | new PanTool(), 28 | new ZoomTool(), 29 | new RotateTool(), 30 | new TransformLayerTool(), 31 | new RectSelectTool('rect'), 32 | new RectSelectTool('ellipse'), 33 | new FreehandSelectTool(), 34 | new PolygonSelectTool(), 35 | new FloodFillTool(), 36 | new CanvasAreaTool(), 37 | new BrushTool(), 38 | ]) 39 | this.currentTool = this.tools[0] 40 | } 41 | 42 | loadConfig(values: ConfigValues) { 43 | for (const toolId in values.tools) { 44 | const tool = this.tools.find(t => t.id === toolId) 45 | if (tool) { 46 | tool.loadConfig(values.tools[toolId]) 47 | } 48 | } 49 | const currentTool = this.tools.find(t => t.id === values.currentTool) 50 | if (currentTool) { 51 | this.currentTool = currentTool 52 | } 53 | } 54 | 55 | saveConfig() { 56 | const tools = {} 57 | for (const tool of this.tools) { 58 | tools[tool.id] = tool.saveConfig() 59 | } 60 | return { 61 | tools, 62 | currentTool: this.currentTool.id, 63 | } 64 | } 65 | } 66 | 67 | export const toolManager = new ToolManager() 68 | -------------------------------------------------------------------------------- /src/renderer/brush/BrushEngine.ts: -------------------------------------------------------------------------------- 1 | import {BrushPipeline} from './BrushPipeline' 2 | import {BrushPreset} from './BrushPreset' 3 | import {BrushRenderer} from './BrushRenderer' 4 | import {WaypointCurveFilter} from './WaypointCurveFilter' 5 | import {WaypointStabilizeFilter} from './WaypointStabilizeFilter' 6 | 7 | export class BrushEngine { 8 | private _preset = new BrushPreset({ 9 | title: 'Brush', 10 | type: 'normal', 11 | width: 10, 12 | opacity: 1, 13 | blending: 0.5, 14 | softness: 0.5, 15 | minWidthRatio: 0.5, 16 | minOpacityRatio: 0.5, 17 | stabilizingLevel: 2, 18 | shortcut: undefined 19 | }) 20 | renderer = new BrushRenderer(this._preset) 21 | pipeline = new BrushPipeline( 22 | [new WaypointStabilizeFilter(), new WaypointCurveFilter()], 23 | this.renderer 24 | ) 25 | get preset() { 26 | return this._preset 27 | } 28 | set preset(preset: BrushPreset) { 29 | this._preset = preset 30 | this.renderer.preset = preset 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/renderer/brush/BrushPipeline.ts: -------------------------------------------------------------------------------- 1 | import {Waypoint} from './Waypoint' 2 | 3 | export interface WaypointConsumer { 4 | nextWaypoints(waypoints: Waypoint[]): void 5 | endWaypoint(): void 6 | } 7 | 8 | export interface WaypointFilter extends WaypointConsumer { 9 | outlet: WaypointConsumer 10 | } 11 | 12 | export class BrushPipeline implements WaypointConsumer { 13 | constructor(public waypointFilters: WaypointFilter[], public renderer: WaypointConsumer) { 14 | for (let i = 0; i < waypointFilters.length - 1; ++i) { 15 | waypointFilters[i].outlet = waypointFilters[i + 1] 16 | } 17 | waypointFilters[waypointFilters.length - 1].outlet = renderer 18 | } 19 | 20 | nextWaypoints(waypoints: Waypoint[]) { 21 | this.waypointFilters[0].nextWaypoints(waypoints) 22 | } 23 | 24 | endWaypoint() { 25 | this.waypointFilters[0].endWaypoint() 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/renderer/brush/BrushPreset.ts: -------------------------------------------------------------------------------- 1 | import {observable} from 'mobx' 2 | import KeyInput, {KeyInputData} from '../../lib/KeyInput' 3 | 4 | export type BrushIconType = 'paint-brush'|'pen'|'eraser' 5 | export type BrushType = 'normal'|'eraser' 6 | 7 | export interface BrushPresetData { 8 | title: string 9 | type: BrushType 10 | width: number 11 | opacity: number 12 | blending: number 13 | softness: number 14 | minWidthRatio: number 15 | minOpacityRatio: number 16 | stabilizingLevel: number 17 | shortcut: KeyInputData|undefined 18 | } 19 | 20 | export class BrushPreset implements BrushPresetData { 21 | static nextInternalKey = 0 22 | readonly internalKey = BrushPreset.nextInternalKey++ 23 | 24 | @observable title = 'Brush' 25 | // brush type 26 | @observable type: BrushType = 'normal' 27 | // brush width (diameter) 28 | @observable width = 10 29 | // brush opacity 30 | @observable opacity = 1 31 | // how much color is blended in each dab 32 | @observable blending = 0.5 33 | // distance used to soften edge, compared to brush radius 34 | @observable softness = 0.5 35 | // width drawn in pressure 0, compared to brush width 36 | @observable minWidthRatio = 0.5 37 | // opacity in pressure 0, compared to max opacity 38 | @observable minOpacityRatio = 0.5 39 | // how many neighbor event positions used to stabilize stroke 40 | @observable stabilizingLevel = 2 41 | 42 | @observable shortcut: KeyInput|undefined 43 | 44 | constructor(props: BrushPresetData) { 45 | this.title = props.title 46 | this.type = props.type 47 | this.width = props.width 48 | this.opacity = props.opacity 49 | this.blending = props.blending 50 | this.softness = props.softness 51 | this.minWidthRatio = props.minWidthRatio 52 | this.minOpacityRatio = props.minOpacityRatio 53 | this.stabilizingLevel = props.stabilizingLevel 54 | this.shortcut = props.shortcut && KeyInput.fromData(props.shortcut) 55 | } 56 | 57 | toData(): BrushPresetData { 58 | const {title, type, width, opacity, blending, softness, minWidthRatio, minOpacityRatio, stabilizingLevel, shortcut} = this 59 | return {title, type, width, opacity, blending, softness, minWidthRatio, minOpacityRatio, stabilizingLevel, shortcut: shortcut && shortcut.toData()} 60 | } 61 | 62 | clone() { 63 | return new BrushPreset(this.toData()) 64 | } 65 | 66 | get iconType(): BrushIconType { 67 | if (this.type === 'eraser') { 68 | return 'eraser' 69 | } 70 | if (this.blending === 0) { 71 | return 'pen' 72 | } else { 73 | return 'paint-brush' 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/renderer/brush/DefaultBrushPresets.ts: -------------------------------------------------------------------------------- 1 | import {BrushPresetData} from './BrushPreset' 2 | 3 | export function defaultBrushPresets(): BrushPresetData[] { 4 | return [ 5 | { 6 | title: 'Pen', 7 | type: 'normal', 8 | width: 10, 9 | opacity: 1, 10 | blending: 0, 11 | softness: 0.5, 12 | minWidthRatio: 0.5, 13 | minOpacityRatio: 1, 14 | stabilizingLevel: 2, 15 | shortcut: {modifiers: [], code: 'KeyB'}, 16 | }, 17 | { 18 | title: 'Watercolor', 19 | type: 'normal', 20 | width: 10, 21 | opacity: 1, 22 | blending: 0.5, 23 | softness: 0.5, 24 | minWidthRatio: 1, 25 | minOpacityRatio: 0, 26 | stabilizingLevel: 2, 27 | shortcut: {modifiers: [], code: 'KeyW'}, 28 | }, 29 | { 30 | title: 'Eraser', 31 | type: 'eraser', 32 | width: 10, 33 | opacity: 1, 34 | blending: 0.5, 35 | softness: 0.5, 36 | minWidthRatio: 0.5, 37 | minOpacityRatio: 1, 38 | stabilizingLevel: 2, 39 | shortcut: {modifiers: [], code: 'KeyE'}, 40 | }, 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /src/renderer/brush/Waypoint.ts: -------------------------------------------------------------------------------- 1 | import {Vec2} from 'paintvec' 2 | import {catmullRom, centripetalCatmullRom} from '../../lib/Geometry' 3 | 4 | export class Waypoint { 5 | constructor(public pos: Vec2, public pressure: number) { 6 | } 7 | 8 | // interpolate between start and end with catmull-rom curve and subdivide 9 | static subdivideCurve(prev: Waypoint, start: Waypoint, end: Waypoint, next: Waypoint, getNextSpacing: (waypoint: Waypoint) => number, offset: number) { 10 | const [cx, cy] = centripetalCatmullRom(prev.pos, start.pos, end.pos, next.pos) 11 | const cp = catmullRom(prev.pressure, start.pressure, end.pressure, next.pressure) 12 | 13 | const waypoints: Waypoint[] = [] 14 | let last = start 15 | let nextOffset = offset 16 | const pointCount = Math.min(100, Math.round(end.pos.sub(start.pos).length() * 2)) 17 | 18 | for (let i = 1; i <= pointCount; ++i) { 19 | const t = i / pointCount 20 | 21 | const x = cx.eval(t) 22 | const y = cy.eval(t) 23 | const p = cp.eval(t) 24 | 25 | const wp = new Waypoint(new Vec2(x, y), p) 26 | const result = this.subdivide(last, wp, getNextSpacing, nextOffset) 27 | nextOffset = result.nextOffset 28 | waypoints.push(...result.waypoints) 29 | last = wp 30 | } 31 | return {waypoints, nextOffset} 32 | } 33 | 34 | // subdivide segment into waypoints 35 | static subdivide(start: Waypoint, end: Waypoint, getNextSpacing: (waypoint: Waypoint) => number, offset: number) { 36 | const diff = end.pos.sub(start.pos) 37 | const len = diff.length() 38 | if (len === 0) { 39 | return { 40 | waypoints: [], 41 | nextOffset: 0 42 | } 43 | } 44 | 45 | const waypoints: Waypoint[] = [] 46 | const diffPerLen = diff.divScalar(len) 47 | const pressurePerLen = (end.pressure - start.pressure) / len 48 | let remaining = len 49 | let spacing = offset 50 | 51 | while (true) { 52 | if (remaining < spacing) { 53 | return { 54 | waypoints, 55 | nextOffset: spacing - remaining 56 | } 57 | } 58 | remaining -= spacing 59 | const current = len - remaining 60 | const pos = start.pos.add(diffPerLen.mulScalar(current)) 61 | const pressure = start.pressure + pressurePerLen * current 62 | const waypoint = {pos, pressure} 63 | waypoints.push(waypoint) 64 | spacing = getNextSpacing(waypoint) 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/renderer/brush/WaypointCurveFilter.ts: -------------------------------------------------------------------------------- 1 | import {WaypointFilter, WaypointConsumer} from './BrushPipeline' 2 | import {Waypoint} from './Waypoint' 3 | 4 | export class WaypointCurveFilter implements WaypointFilter { 5 | lastWaypoints: Waypoint[] = [] 6 | outlet: WaypointConsumer 7 | brushSpacing = (wp: Waypoint) => 1 8 | nextDabOffset = 0 9 | 10 | nextWaypoints(waypoints: Waypoint[]) { 11 | for (const wp of waypoints) { 12 | this.nextWaypoint(wp) 13 | } 14 | } 15 | 16 | nextWaypoint(waypoint: Waypoint) { 17 | const {lastWaypoints, brushSpacing} = this 18 | if (lastWaypoints.length === 4) { 19 | lastWaypoints.shift() 20 | } 21 | lastWaypoints.push(waypoint) 22 | 23 | const {waypoints, nextOffset} = (() => { 24 | switch (lastWaypoints.length) { 25 | case 1: 26 | return {waypoints: [waypoint], nextOffset: this.brushSpacing(waypoint)} 27 | case 2: 28 | return {waypoints: [], nextOffset: this.nextDabOffset} 29 | case 3: 30 | return Waypoint.subdivideCurve(lastWaypoints[0], lastWaypoints[0], lastWaypoints[1], lastWaypoints[2], brushSpacing, this.nextDabOffset) 31 | default: 32 | return Waypoint.subdivideCurve(lastWaypoints[0], lastWaypoints[1], lastWaypoints[2], lastWaypoints[3], brushSpacing, this.nextDabOffset) 33 | } 34 | })() 35 | 36 | this.nextDabOffset = nextOffset 37 | 38 | if (waypoints.length !== 0) { 39 | this.outlet.nextWaypoints(waypoints) 40 | } 41 | } 42 | 43 | endWaypoint() { 44 | const {lastWaypoints, brushSpacing} = this 45 | if (lastWaypoints.length < 2) { 46 | return 47 | } 48 | const {waypoints} = (() => { 49 | if (lastWaypoints.length === 2) { 50 | return Waypoint.subdivide(lastWaypoints[0], lastWaypoints[1], brushSpacing, this.nextDabOffset) 51 | } else if (lastWaypoints.length === 3) { 52 | return Waypoint.subdivideCurve(lastWaypoints[0], lastWaypoints[1], lastWaypoints[2], lastWaypoints[2], brushSpacing, this.nextDabOffset) 53 | } else { 54 | return Waypoint.subdivideCurve(lastWaypoints[1], lastWaypoints[2], lastWaypoints[3], lastWaypoints[3], brushSpacing, this.nextDabOffset) 55 | } 56 | })() 57 | 58 | if (waypoints.length !== 0) { 59 | this.outlet.nextWaypoints(waypoints) 60 | } 61 | this.outlet.endWaypoint() 62 | this.lastWaypoints = [] 63 | this.nextDabOffset = 0 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/renderer/brush/WaypointStabilizeFilter.ts: -------------------------------------------------------------------------------- 1 | import {Vec2} from 'paintvec' 2 | import {brushPresetManager} from '../app/BrushPresetManager' 3 | import {WaypointFilter, WaypointConsumer} from './BrushPipeline' 4 | import {Waypoint} from './Waypoint' 5 | 6 | function stabilizeWaypoint(waypoints: Waypoint[], level: number, index: number) { 7 | const nWaypoints = waypoints.length 8 | let sumX = 0 9 | let sumY = 0 10 | let sumPressure = 0 11 | for (let i = index - level; i <= index + level; ++i) { 12 | const {pos: {x, y}, pressure} = waypoints[Math.max(0, Math.min(i, nWaypoints - 1))] 13 | sumX += x 14 | sumY += y 15 | sumPressure += pressure 16 | } 17 | const sumCount = level * 2 + 1 18 | const pos = new Vec2(sumX / sumCount, sumY / sumCount) 19 | const pressure = sumPressure / sumCount 20 | return new Waypoint(pos, pressure) 21 | } 22 | 23 | export class WaypointStabilizeFilter implements WaypointFilter { 24 | outlet: WaypointConsumer 25 | lastWaypoints: Waypoint[] = [] 26 | 27 | get stabilizingLevel() { 28 | const {currentPreset} = brushPresetManager 29 | return currentPreset ? currentPreset.stabilizingLevel : 2 30 | } 31 | 32 | nextWaypoints(waypoints: Waypoint[]) { 33 | for (const wp of waypoints) { 34 | this.nextWaypoint(wp) 35 | } 36 | } 37 | 38 | nextWaypoint(waypoint: Waypoint) { 39 | const waypoints = this.lastWaypoints 40 | waypoints.push(waypoint) 41 | const level = this.stabilizingLevel 42 | const sumCount = level * 2 + 1 43 | if (sumCount === waypoints.length) { 44 | for (let i = 0; i < level; ++i) { 45 | this.outlet.nextWaypoints([stabilizeWaypoint(waypoints, level, i)]) 46 | } 47 | } 48 | if (sumCount <= waypoints.length) { 49 | const i = waypoints.length - 1 - level 50 | this.outlet.nextWaypoints([stabilizeWaypoint(waypoints, level, i)]) 51 | } 52 | } 53 | 54 | endWaypoint() { 55 | const waypoints = this.lastWaypoints 56 | const level = this.stabilizingLevel 57 | let firstUndrawnIndex = 0 58 | if (level * 2 + 1 <= waypoints.length) { 59 | firstUndrawnIndex = waypoints.length - level 60 | } 61 | for (let i = firstUndrawnIndex; i < waypoints.length; ++i) { 62 | this.outlet.nextWaypoints([stabilizeWaypoint(waypoints, level, i)]) 63 | } 64 | this.outlet.endWaypoint() 65 | this.lastWaypoints = [] 66 | } 67 | } -------------------------------------------------------------------------------- /src/renderer/brush/shaders/brushShape.glsl: -------------------------------------------------------------------------------- 1 | 2 | float brushShape( 3 | vec2 offset, 4 | float radius, 5 | float softness, 6 | bool hasSelection, 7 | sampler2D selection, 8 | vec2 selectionUV 9 | ) { 10 | float r = length(offset); 11 | float shape = smoothstep(radius, radius - max(1.0, radius * softness), r); 12 | if (hasSelection) { 13 | return shape * texture2D(selection, selectionUV).a; 14 | } else { 15 | return shape; 16 | } 17 | } 18 | #pragma glslify: export(brushShape) 19 | -------------------------------------------------------------------------------- /src/renderer/brush/shaders/brushVertexOp.glsl: -------------------------------------------------------------------------------- 1 | 2 | float correctOpacity(float opacity, float invWidth) { 3 | return 1.0 - pow(1.0 - min(opacity, 0.998), invWidth); 4 | } 5 | 6 | void brushVertexOp( 7 | vec2 pos, 8 | float pressure, 9 | vec2 center, 10 | float maxWidth, 11 | float minWidthRatio, 12 | float maxOpacity, 13 | float minOpacityRatio, 14 | float maxBlending, 15 | vec2 pictureSize, 16 | out vec2 offset, 17 | out float radius, 18 | out float opacity, 19 | out float blending, 20 | out vec2 selectionUV 21 | ) { 22 | offset = pos - center; 23 | float width = maxWidth * mix(minWidthRatio, 1.0, pressure); 24 | radius = width * 0.5; 25 | float overlappedOpacity = maxOpacity * mix(minOpacityRatio, 1.0, pressure); 26 | float invWidth = 1.0 / width; 27 | opacity = correctOpacity(overlappedOpacity, invWidth); 28 | blending = correctOpacity(maxBlending * pressure, invWidth); 29 | selectionUV = pos / pictureSize; 30 | } 31 | 32 | #pragma glslify: export(brushVertexOp) 33 | 34 | -------------------------------------------------------------------------------- /src/renderer/commands/SelectionCommand.ts: -------------------------------------------------------------------------------- 1 | import Picture from '../models/Picture' 2 | import Selection from '../models/Selection' 3 | import {UndoCommand} from '../models/UndoStack' 4 | 5 | abstract class SelectionCommand implements UndoCommand { 6 | abstract title: string 7 | oldSelection: Selection 8 | 9 | constructor(public picture: Picture) { 10 | } 11 | 12 | undo() { 13 | this.picture.selection = this.oldSelection 14 | this.picture.lastUpdate = {} 15 | } 16 | 17 | abstract newSelection(): Selection 18 | 19 | redo() { 20 | this.oldSelection = this.picture.selection 21 | this.picture.selection = this.newSelection() 22 | this.picture.lastUpdate = {} 23 | } 24 | } 25 | 26 | export 27 | class SelectionChangeCommand extends SelectionCommand { 28 | title = 'Change Selection' 29 | 30 | constructor(public picture: Picture, public selection: Selection) { 31 | super(picture) 32 | } 33 | 34 | newSelection() { 35 | return this.selection.clone() 36 | } 37 | } 38 | 39 | export 40 | class SelectAllCommand extends SelectionCommand { 41 | title = 'Select All' 42 | 43 | newSelection() { 44 | const selection = new Selection(this.picture.size) 45 | selection.selectAll() 46 | return selection 47 | } 48 | } 49 | 50 | 51 | export 52 | class ClearSelectionCommand extends SelectionCommand { 53 | title = 'Clear Selection' 54 | 55 | newSelection() { 56 | return new Selection(this.picture.size) 57 | } 58 | } 59 | 60 | export 61 | class InvertSelectionCommand extends SelectionCommand { 62 | title = 'Invert Selection' 63 | 64 | newSelection() { 65 | return this.picture.selection.invert() 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/renderer/formats/PictureFormat.ts: -------------------------------------------------------------------------------- 1 | import Layer from '../models/Layer' 2 | import Picture from '../models/Picture' 3 | 4 | abstract class PictureFormat { 5 | abstract title: string 6 | abstract mimeType: string 7 | abstract extensions: string[] 8 | 9 | abstract importPicture(buffer: Buffer, name: string): Promise 10 | abstract importLayer(buffer: Buffer, name: string, picture: Picture): Promise 11 | abstract export(picture: Picture): Promise 12 | 13 | get electronFileFilter() { 14 | return { 15 | name: this.title, 16 | extensions: this.extensions 17 | } 18 | } 19 | } 20 | 21 | export default PictureFormat 22 | -------------------------------------------------------------------------------- /src/renderer/formats/PictureFormatAzurite.ts: -------------------------------------------------------------------------------- 1 | import * as msgpack from 'msgpack-lite' 2 | import Layer from '../models/Layer' 3 | import Picture from '../models/Picture' 4 | import PictureFormat from './PictureFormat' 5 | 6 | export default 7 | class PictureFormatAzurite extends PictureFormat { 8 | title = 'Azurite Picture' 9 | mimeType = 'application/x-azurite-picture' 10 | extensions = ['azurite'] 11 | 12 | async importPicture(buffer: Buffer, name: string) { 13 | const data = msgpack.decode(buffer) 14 | return Picture.fromData(data) 15 | } 16 | 17 | async importLayer(buffer: Buffer, name: string, picture: Picture): Promise { 18 | throw new Error('not implemented yet') 19 | } 20 | 21 | async export(picture: Picture) { 22 | return msgpack.encode(picture.toData()) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/renderer/formats/PictureFormatCanvasImage.ts: -------------------------------------------------------------------------------- 1 | import {Vec2} from 'paintvec' 2 | 3 | import {encodeCanvas, decodeToCanvas} from '../../lib/CanvasEncodeDecode' 4 | import {addPictureFormat} from '../app/FormatRegistry' 5 | import {ImageLayer} from '../models/Layer' 6 | import Picture from '../models/Picture' 7 | import TextureToCanvas from '../models/TextureToCanvas' 8 | import PictureFormat from './PictureFormat' 9 | 10 | function canvasToLayer(canvas: HTMLCanvasElement, name: string, picture: Picture) { 11 | const layer = new ImageLayer(picture, {name}) 12 | layer.tiledTexture.putImage(new Vec2(), canvas) 13 | return layer 14 | } 15 | 16 | abstract class PictureFormatCanvasImage extends PictureFormat { 17 | async importPicture(buffer: Buffer, name: string) { 18 | const canvas = await decodeToCanvas(buffer, this.mimeType) 19 | const picture = new Picture({ 20 | width: canvas.width, 21 | height: canvas.height, 22 | dpi: 72 23 | }) 24 | const layer = canvasToLayer(canvas, name, picture) 25 | picture.layers.push(layer) 26 | picture.selectedLayers.push(layer) 27 | return picture 28 | } 29 | 30 | async importLayer(buffer: Buffer, name: string, picture: Picture) { 31 | const canvas = await decodeToCanvas(buffer, this.mimeType) 32 | return canvasToLayer(canvas, name, picture) 33 | } 34 | 35 | async export(picture: Picture) { 36 | const textureToCanvas = new TextureToCanvas(picture.size) 37 | textureToCanvas.loadTexture(picture.blender.getBlendedTexture(), new Vec2(0)) 38 | textureToCanvas.updateCanvas() 39 | return await encodeCanvas(textureToCanvas.canvas, this.mimeType) 40 | } 41 | } 42 | export default PictureFormatCanvasImage 43 | 44 | @addPictureFormat 45 | export 46 | class PictureFormatJPEG extends PictureFormatCanvasImage { 47 | title = 'JPEG' 48 | extensions = ['jpg', 'jpeg'] 49 | mimeType = 'image/jpeg' 50 | } 51 | 52 | @addPictureFormat 53 | export 54 | class PictureFormatPNG extends PictureFormatCanvasImage { 55 | title = 'PNG' 56 | extensions = ['png'] 57 | mimeType = 'image/png' 58 | } 59 | 60 | @addPictureFormat 61 | export 62 | class PictureFormatBMP extends PictureFormatCanvasImage { 63 | title = 'BMP' 64 | extensions = ['bmp'] 65 | mimeType = 'image/bmp' 66 | } 67 | -------------------------------------------------------------------------------- /src/renderer/index.ts: -------------------------------------------------------------------------------- 1 | import './initState' 2 | import './initView' 3 | -------------------------------------------------------------------------------- /src/renderer/initState.ts: -------------------------------------------------------------------------------- 1 | import {webFrame} from 'electron' 2 | import {appState} from './app/AppState' 3 | 4 | webFrame.setVisualZoomLevelLimits(1, 1) 5 | webFrame.setLayoutZoomLevelLimits(1, 1) 6 | 7 | appState.bootstrap() 8 | -------------------------------------------------------------------------------- /src/renderer/initView.tsx: -------------------------------------------------------------------------------- 1 | import React = require('react') 2 | import ReactDOM = require('react-dom') 3 | import RootView from './views/RootView' 4 | 5 | ReactDOM.render(, document.getElementById('app')) 6 | 7 | if (module.hot) { 8 | module.hot.accept() 9 | } 10 | -------------------------------------------------------------------------------- /src/renderer/models/Selection.ts: -------------------------------------------------------------------------------- 1 | import {observable} from 'mobx' 2 | import {Texture, TextureDrawTarget, Color} from 'paintgl' 3 | import {Vec2, Rect, Transform} from 'paintvec' 4 | import {context} from '../GLContext' 5 | import {drawTexture, drawVisibilityToBinary} from '../GLUtil' 6 | import {getBoundingRect} from './util' 7 | 8 | const binaryTexture = new Texture(context, {}) 9 | const binaryDrawTarget = new TextureDrawTarget(context, binaryTexture) 10 | 11 | export default 12 | class Selection { 13 | readonly texture = new Texture(context, {size: this.size}) 14 | readonly drawTarget = new TextureDrawTarget(context, this.texture) 15 | @observable hasSelection = false 16 | 17 | constructor(public readonly size: Vec2) { 18 | } 19 | 20 | includes(pos: Vec2) { 21 | const floored = pos.floor() 22 | const data = new Uint8Array(4) 23 | this.drawTarget.readPixels(new Rect(floored, floored.add(new Vec2(1))), data) 24 | const alpha = data[3] 25 | return alpha > 0 26 | } 27 | 28 | clear() { 29 | this.drawTarget.clear(new Color(0, 0, 0, 0)) 30 | this.hasSelection = false 31 | } 32 | 33 | selectAll() { 34 | this.drawTarget.clear(new Color(1, 1, 1, 1)) 35 | this.hasSelection = true 36 | } 37 | 38 | boundingRect() { 39 | const binaryWidth = Math.ceil(this.size.width / 32) 40 | const binarySize = new Vec2(binaryWidth, this.size.height) 41 | if (!binaryTexture.size.equals(binarySize)) { 42 | binaryTexture.size = binarySize 43 | } 44 | drawVisibilityToBinary(binaryDrawTarget, this.texture) 45 | 46 | const data = new Int32Array(binarySize.width * binarySize.height) 47 | binaryDrawTarget.readPixels(new Rect(new Vec2(), binarySize), new Uint8Array(data.buffer)) 48 | return getBoundingRect(data, this.size) 49 | } 50 | 51 | invert() { 52 | const selection = new Selection(this.size) 53 | selection.hasSelection = this.hasSelection 54 | if (this.hasSelection) { 55 | selection.drawTarget.clear(new Color(1, 1, 1, 1)) 56 | drawTexture(selection.drawTarget, this.texture, {blendMode: 'dst-out'}) 57 | selection.checkHasSelection() 58 | } else { 59 | selection.selectAll() 60 | } 61 | return selection 62 | } 63 | 64 | transform(newSize: Vec2, transform: Transform, opts: {bicubic?: boolean} = {}) { 65 | const selection = new Selection(newSize) 66 | selection.hasSelection = this.hasSelection 67 | if (this.hasSelection) { 68 | if (opts.bicubic) { 69 | this.texture.filter = 'bilinear' 70 | } 71 | drawTexture(selection.drawTarget, this.texture, {blendMode: 'src', transform, ...opts}) 72 | if (opts.bicubic) { 73 | this.texture.filter = 'nearest' 74 | } 75 | selection.checkHasSelection() 76 | } 77 | return selection 78 | } 79 | 80 | clone() { 81 | const selection = new Selection(this.size) 82 | selection.hasSelection = this.hasSelection 83 | if (this.hasSelection) { 84 | drawTexture(selection.drawTarget, this.texture, {blendMode: 'src'}) 85 | } 86 | return selection 87 | } 88 | 89 | dispose() { 90 | this.drawTarget.dispose() 91 | this.texture.dispose() 92 | } 93 | 94 | checkHasSelection() { 95 | this.hasSelection = !!this.boundingRect() 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/renderer/models/TextureToCanvas.ts: -------------------------------------------------------------------------------- 1 | import {ShapeModel, Texture, RectShape, TextureDrawTarget, Color} from 'paintgl' 2 | import {Vec2, Rect} from 'paintvec' 3 | import {context} from '../GLContext' 4 | 5 | const textureToCanvasShader = { 6 | fragment: ` 7 | uniform sampler2D texture; 8 | uniform vec4 background; 9 | void fragmentMain(vec2 pos, vec2 uv, out vec4 outColor) { 10 | vec4 texColor = texture2D(texture, uv); 11 | vec4 color = texColor + background * (1.0 - texColor.a); 12 | vec4 nonPremultColor = vec4(color.rgb / color.a, color.a); 13 | outColor = nonPremultColor; 14 | } 15 | ` 16 | } 17 | 18 | // render texture content to canvas element 19 | export default 20 | class TextureToCanvas { 21 | canvas = document.createElement('canvas') 22 | context = this.canvas.getContext('2d')! 23 | backgroundColor = new Color(1, 1, 1, 1) 24 | imageData = new ImageData(this.size.width, this.size.height) 25 | texture = new Texture(context, {size: this.size}) 26 | drawTarget = new TextureDrawTarget(context, this.texture) 27 | shape = new RectShape(context, { 28 | usage: 'static', 29 | rect: new Rect(new Vec2(), this.size), 30 | }) 31 | model = new ShapeModel(context, { 32 | shape: this.shape, 33 | shader: textureToCanvasShader, 34 | }) 35 | 36 | constructor(public size: Vec2) { 37 | this.canvas.width = size.width 38 | this.canvas.height = size.height 39 | } 40 | 41 | clear() { 42 | this.drawTarget.clear(this.backgroundColor) 43 | } 44 | 45 | loadTexture(texture: Texture, offset: Vec2) { 46 | this.shape.rect = new Rect(offset, offset.add(texture.size)) 47 | this.model.uniforms = {texture} 48 | this.drawTarget.draw(this.model) 49 | } 50 | 51 | updateCanvas() { 52 | this.drawTarget.readPixels(new Rect(new Vec2(0), this.size), new Uint8Array(this.imageData.data.buffer)) 53 | this.context.putImageData(this.imageData, 0, 0) 54 | } 55 | 56 | dispose() { 57 | this.shape.dispose() 58 | this.drawTarget.dispose() 59 | this.texture.dispose() 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/renderer/models/UndoStack.ts: -------------------------------------------------------------------------------- 1 | import {EventEmitter} from 'events' 2 | import {observable, computed, action, IObservableArray} from 'mobx' 3 | 4 | export 5 | interface UndoCommand { 6 | undo(): void 7 | redo(): void 8 | title: string 9 | } 10 | 11 | export 12 | class CompositeUndoCommand implements UndoCommand { 13 | constructor(public title: string, public commands: UndoCommand[]) { 14 | } 15 | undo() { 16 | for (const command of Array.from(this.commands).reverse()) { 17 | command.undo() 18 | } 19 | } 20 | redo() { 21 | for (const command of this.commands) { 22 | command.redo() 23 | } 24 | } 25 | } 26 | 27 | export 28 | class UndoStack extends EventEmitter { 29 | readonly commands: IObservableArray = observable([]) 30 | @observable doneCount = 0 31 | 32 | @computed get undoCommand() { 33 | if (this.isUndoable) { 34 | return this.commands[this.doneCount - 1] 35 | } 36 | } 37 | 38 | @computed get redoCommand() { 39 | if (this.isRedoable) { 40 | return this.commands[this.doneCount] 41 | } 42 | } 43 | 44 | @computed get isUndoable() { 45 | return 1 <= this.doneCount 46 | } 47 | @computed get isRedoable() { 48 | return this.doneCount < this.commands.length 49 | } 50 | 51 | @action undo() { 52 | this.emit('beforeUndo') 53 | const command = this.undoCommand 54 | if (command) { 55 | command.undo() 56 | this.doneCount -= 1 57 | } 58 | } 59 | @action redo() { 60 | this.emit('beforeRedo') 61 | const command = this.redoCommand 62 | if (command) { 63 | command.redo() 64 | this.doneCount += 1 65 | } 66 | } 67 | @action push(command: UndoCommand) { 68 | command.redo() 69 | this.commands.splice(this.doneCount) 70 | this.commands.push(command) 71 | this.doneCount += 1 72 | } 73 | 74 | @action clear() { 75 | this.commands.replace([]) 76 | this.doneCount = 0 77 | } 78 | } 79 | 80 | export const undoStack = new UndoStack() 81 | -------------------------------------------------------------------------------- /src/renderer/models/util.ts: -------------------------------------------------------------------------------- 1 | import {Vec2, Rect} from 'paintvec' 2 | 3 | export function getBoundingRect(data: Int32Array, size: Vec2) { 4 | const {width, height} = size 5 | const stride = Math.ceil(width / 32) 6 | const verticalOrs = new Int32Array(stride) 7 | let left = -1, right = -1, top = -1, bottom = -1 8 | let i = 0 9 | for (let y = 0; y < height; ++y) { 10 | let horizontalOr = 0 11 | for (let x = 0; x < stride; ++x) { 12 | const value = data[i++] 13 | verticalOrs[x] |= value 14 | horizontalOr |= value 15 | } 16 | if (horizontalOr) { 17 | if (top < 0) { 18 | top = y 19 | } 20 | bottom = y 21 | } 22 | } 23 | i = 0 24 | let mask = 1 25 | for (let x = 0; x < width; ++x) { 26 | if (verticalOrs[i] & mask) { 27 | if (left < 0) { 28 | left = x 29 | } 30 | right = x 31 | } 32 | mask <<= 1 33 | if (mask === 0) { 34 | ++i 35 | mask = 1 36 | } 37 | } 38 | if (left >= 0 && top >= 0) { 39 | return new Rect(new Vec2(left, top), new Vec2(right + 1, bottom + 1)) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/renderer/requireManualResolve.ts: -------------------------------------------------------------------------------- 1 | import {remote} from 'electron' 2 | const resolve = require('resolve') 3 | 4 | // workaround: looks like Electron can't resolve require path from JavaScripts loaded in http protocol 5 | // so we have to add alternative require that resolves path manually 6 | // todo: fix Electron 7 | window['requireManualResolve'] = (request: string) => { 8 | const resolved = resolve.sync(request, {basedir: remote.app.getAppPath()}) 9 | return window['require'](resolved) 10 | } 11 | -------------------------------------------------------------------------------- /src/renderer/services/PictureBlender.ts: -------------------------------------------------------------------------------- 1 | import {reaction} from 'mobx' 2 | import {Texture, TextureDrawTarget, Color} from 'paintgl' 3 | import {Vec2, Rect, Transform} from 'paintvec' 4 | import Dirtiness from '../../lib/Dirtiness' 5 | import {context} from '../GLContext' 6 | import {drawTexture} from '../GLUtil' 7 | import Picture from '../models/Picture' 8 | import TiledTexture, {Tile} from '../models/TiledTexture' 9 | import {layerBlender, TileHook} from './LayerBlender' 10 | 11 | export default 12 | class PictureBlender { 13 | private blendedTexture = new Texture(context, { 14 | size: this.picture.size, 15 | pixelFormat: 'rgb', 16 | pixelType: 'byte', 17 | }) 18 | private drawTarget = new TextureDrawTarget(context, this.blendedTexture) 19 | 20 | dirtiness = new Dirtiness() 21 | 22 | tileHook: TileHook|undefined 23 | 24 | constructor(public picture: Picture) { 25 | this.dirtiness.addWhole() 26 | reaction(() => picture.size, size => { 27 | this.blendedTexture.size = size 28 | }) 29 | } 30 | 31 | renderNow() { 32 | if (!this.dirtiness.dirty) { 33 | return 34 | } 35 | layerBlender.tileHook = this.tileHook 36 | const rect = this.drawTarget.scissor = this.dirtiness.rect 37 | this.drawTarget.clear(new Color(1, 1, 1, 1)) 38 | const tileKeys = TiledTexture.keysForRect(rect || new Rect(new Vec2(0), this.picture.size)) 39 | for (const key of tileKeys) { 40 | const offset = key.mulScalar(Tile.width) 41 | const tileScissor = rect && rect.translate(offset.neg()).intersection(Tile.rect) 42 | const rendered = layerBlender.blendTile(this.picture.layers, key, tileScissor) 43 | if (rendered) { 44 | drawTexture(this.drawTarget, layerBlender.blendedTile.texture, {transform: Transform.translate(offset), blendMode: 'src-over'}) 45 | } 46 | } 47 | this.dirtiness.clear() 48 | } 49 | 50 | getBlendedTexture() { 51 | this.renderNow() 52 | return this.blendedTexture 53 | } 54 | 55 | dispose() { 56 | this.drawTarget.dispose() 57 | this.blendedTexture.dispose() 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/renderer/services/PictureExport.ts: -------------------------------------------------------------------------------- 1 | import {remote} from 'electron' 2 | import Picture from '../models/Picture' 3 | import TextureToCanvas from '../models/TextureToCanvas' 4 | const {dialog} = remote 5 | import * as fs from 'fs' 6 | import * as path from 'path' 7 | import {formatRegistry} from '../app/FormatRegistry' 8 | import {AddLayerCommand} from '../commands/LayerCommand' 9 | import PictureFormat from '../formats/PictureFormat' 10 | import {UndoCommand, CompositeUndoCommand} from '../models/UndoStack' 11 | 12 | export 13 | type PictureExportFormat = 'png'|'jpeg'|'bmp' 14 | 15 | export 16 | class PictureExport { 17 | private textureToCanvas = new TextureToCanvas(this.picture.size) 18 | 19 | constructor(public picture: Picture) { 20 | } 21 | 22 | async showExportDialog(format: PictureFormat) { 23 | const filter = {name: format.title, extensions: format.extensions} 24 | const fileName = await new Promise((resolve, reject) => { 25 | dialog.showSaveDialog(remote.getCurrentWindow(), { 26 | title: 'Export...', 27 | filters: [filter] 28 | }, resolve) 29 | }) 30 | if (fileName) { 31 | await this.export(fileName, format) 32 | } 33 | } 34 | 35 | async showImportDialog() { 36 | const extensions = formatRegistry.pictureExtensions() 37 | const fileNames = await new Promise((resolve, reject) => { 38 | dialog.showOpenDialog(remote.getCurrentWindow(), { 39 | title: 'Import...', 40 | filters: [{name: 'Image', extensions}] 41 | }, resolve) 42 | }) 43 | await this.import(fileNames) 44 | } 45 | 46 | async export(fileName: string, format: PictureFormat) { 47 | const buffer = await format.export(this.picture) 48 | fs.writeFileSync(fileName, buffer) 49 | } 50 | 51 | async import(fileNames: string[]) { 52 | if (fileNames.length === 0) { 53 | return 54 | } 55 | const indexPath = this.picture.insertPath 56 | const commands: UndoCommand[] = [] 57 | 58 | for (const fileName of fileNames) { 59 | const ext = path.extname(fileName) 60 | const format = formatRegistry.pictureFormatForExtension(ext.slice(1)) 61 | if (format) { 62 | const buffer = fs.readFileSync(fileName) 63 | const name = path.basename(fileName, ext) 64 | const layer = await format.importLayer(buffer, name, this.picture) 65 | commands.push(new AddLayerCommand(this.picture, indexPath, layer)) 66 | } 67 | } 68 | 69 | const compositeCommand = new CompositeUndoCommand('Import Images', commands) 70 | this.picture.undoStack.push(compositeCommand) 71 | } 72 | 73 | dispose() { 74 | this.textureToCanvas.dispose() 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/renderer/services/PictureSave.ts: -------------------------------------------------------------------------------- 1 | import {remote} from 'electron' 2 | import * as fs from 'fs' 3 | import * as path from 'path' 4 | import {formatRegistry} from '../app/FormatRegistry' 5 | import PictureFormatAzurite from '../formats/PictureFormatAzurite' 6 | import Picture from '../models/Picture' 7 | 8 | const {dialog} = remote 9 | 10 | const appFormat = new PictureFormatAzurite() 11 | 12 | export 13 | class PictureSave { 14 | 15 | constructor(public picture: Picture) { 16 | } 17 | 18 | async save() { 19 | if (this.picture.filePath) { 20 | if (!this.picture.edited) { 21 | return true 22 | } 23 | await this.saveToPath(this.picture.filePath) 24 | return true 25 | } else { 26 | return await this.saveAs() 27 | } 28 | } 29 | 30 | async saveAs() { 31 | const filePath = await new Promise((resolve, reject) => { 32 | dialog.showSaveDialog(remote.getCurrentWindow(), { 33 | title: 'Save As...', 34 | filters: [appFormat.electronFileFilter], 35 | }, resolve) 36 | }) 37 | if (filePath) { 38 | await this.saveToPath(filePath) 39 | return true 40 | } else { 41 | return false 42 | } 43 | } 44 | 45 | async saveToPath(filePath: string) { 46 | const fileData = await appFormat.export(this.picture) 47 | await new Promise((resolve, reject) => { 48 | fs.writeFile(filePath, fileData, (err) => { 49 | if (err) { 50 | reject(err) 51 | } 52 | resolve() 53 | }) 54 | }) 55 | this.picture.filePath = filePath 56 | this.picture.edited = false 57 | } 58 | 59 | static async getOpenPath() { 60 | const filters = [appFormat, ...formatRegistry.pictureFormats].map(f => f.electronFileFilter) 61 | const filePaths = await new Promise((resolve, reject) => { 62 | dialog.showOpenDialog(remote.getCurrentWindow(), { 63 | title: 'Open', 64 | filters, 65 | }, resolve) 66 | }) 67 | if (filePaths && filePaths.length > 0) { 68 | return filePaths[0] 69 | } 70 | } 71 | 72 | static async openFromPath(filePath: string) { 73 | const fileData = await new Promise((resolve, reject) => { 74 | fs.readFile(filePath, (err, data) => { 75 | if (err) { 76 | reject(err) 77 | } else { 78 | resolve(data) 79 | } 80 | }) 81 | }) 82 | const dotExt = path.extname(filePath) 83 | const name = path.basename(filePath, dotExt) 84 | const ext = dotExt.slice(1) 85 | 86 | if (appFormat.extensions.includes(ext)) { 87 | const picture = await appFormat.importPicture(fileData, name) 88 | picture.filePath = filePath 89 | return picture 90 | } else { 91 | const format = formatRegistry.pictureFormatForExtension(ext) 92 | if (!format) { 93 | throw new Error('cannot find format') 94 | } 95 | const picture = await format.importPicture(fileData, name) 96 | picture.edited = true 97 | return picture 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/renderer/tools/FloodFillTool.tsx: -------------------------------------------------------------------------------- 1 | import {reaction, observable} from 'mobx' 2 | import * as React from 'react' 3 | import {SelectionChangeCommand} from '../commands/SelectionCommand' 4 | import FloodFill from '../services/FloodFill' 5 | import FloodFillSettings from '../views/FloodFillSettings' 6 | import Tool, {ToolPointerEvent} from './Tool' 7 | import ToolIDs from './ToolIDs' 8 | 9 | export default 10 | class FloodFillTool extends Tool { 11 | readonly id = ToolIDs.floodFill 12 | readonly title = 'Flood Fill' 13 | @observable tolerance = 0 // 0 ... 255 14 | private floodFill: FloodFill|undefined 15 | 16 | constructor() { 17 | super() 18 | reaction(() => [this.picture, this.picture && this.picture.size], () => { 19 | this.renewFloodFill() 20 | }) 21 | } 22 | 23 | renderSettings() { 24 | return 25 | } 26 | 27 | start(ev: ToolPointerEvent) { 28 | } 29 | 30 | move(ev: ToolPointerEvent) { 31 | } 32 | 33 | end(ev: ToolPointerEvent) { 34 | if (this.picture && this.picture.rect.includes(ev.picturePos)) { 35 | if (this.floodFill) { 36 | this.floodFill.tolerance = Math.max(0.5, this.tolerance) / 255 // allow small tolerance for antialiasing 37 | const selection = this.picture.selection.clone() 38 | this.floodFill.floodFill(ev.picturePos.floor(), selection) 39 | const command = new SelectionChangeCommand(this.picture, selection) 40 | this.picture.undoStack.push(command) 41 | } 42 | } 43 | } 44 | 45 | private renewFloodFill() { 46 | if (this.floodFill) { 47 | this.floodFill.dispose() 48 | this.floodFill = undefined 49 | } 50 | if (this.picture) { 51 | this.floodFill = new FloodFill(this.picture) 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/renderer/tools/FreehandSelectTool.ts: -------------------------------------------------------------------------------- 1 | import {Vec2} from 'paintvec' 2 | import ShapeSelectTool from './ShapeSelectTool' 3 | import {ToolPointerEvent} from './Tool' 4 | import ToolIDs from './ToolIDs' 5 | 6 | export default 7 | class FreehandSelectTool extends ShapeSelectTool { 8 | readonly id = ToolIDs.freehandSelect 9 | readonly title = 'Freehand Select' 10 | get cursor() { 11 | return 'crosshair' 12 | } 13 | positions: Vec2[] = [] 14 | 15 | start(ev: ToolPointerEvent) { 16 | this.positions = [ev.rendererPos.round()] 17 | super.start(ev) 18 | } 19 | 20 | move(ev: ToolPointerEvent) { 21 | this.positions.push(ev.rendererPos.round()) 22 | super.move(ev) 23 | } 24 | 25 | drawShape(context: CanvasRenderingContext2D) { 26 | for (let [i, pos] of this.positions.entries()) { 27 | if (i === 0) { 28 | context.moveTo(pos.x, pos.y) 29 | } else { 30 | context.lineTo(pos.x, pos.y) 31 | } 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/renderer/tools/PanTool.ts: -------------------------------------------------------------------------------- 1 | import {Vec2} from 'paintvec' 2 | import KeyInput from '../../lib/KeyInput' 3 | import Tool, {ToolPointerEvent} from './Tool' 4 | import ToolIDs from './ToolIDs' 5 | 6 | export default 7 | class PanTool extends Tool { 8 | readonly id = ToolIDs.pan 9 | readonly title = 'Pan' 10 | get cursor() { 11 | return 'all-scroll' 12 | } 13 | originalPos = new Vec2(0) 14 | originalTranslation = new Vec2(0) 15 | tempShortcut = new KeyInput([], 'Space') 16 | 17 | start(ev: ToolPointerEvent) { 18 | if (!this.picture) { 19 | return 20 | } 21 | this.originalPos = ev.rendererPos.round() 22 | this.originalTranslation = this.picture.navigation.translation 23 | } 24 | 25 | move(ev: ToolPointerEvent) { 26 | if (!this.picture) { 27 | return 28 | } 29 | const pos = ev.rendererPos.round() 30 | const offset = pos.sub(this.originalPos) 31 | const translation = this.originalTranslation.add(offset) 32 | this.picture.navigation.translation = translation 33 | } 34 | 35 | end() { 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/renderer/tools/PolygonSelectTool.ts: -------------------------------------------------------------------------------- 1 | import {Vec2} from 'paintvec' 2 | import ShapeSelectTool from './ShapeSelectTool' 3 | import {ToolPointerEvent} from './Tool' 4 | import ToolIDs from './ToolIDs' 5 | 6 | export default 7 | class PolygonSelectTool extends ShapeSelectTool { 8 | commitDrawOnEnd = false 9 | readonly id = ToolIDs.polygonSelect 10 | readonly title = 'Polygon Select' 11 | get cursor() { 12 | return 'crosshair' 13 | } 14 | positions: Vec2[] = [] 15 | 16 | start(ev: ToolPointerEvent) { 17 | const pos = ev.rendererPos.round() 18 | if (this.positions.length > 0) { 19 | this.positions.pop() 20 | } 21 | this.positions.push(pos, pos) 22 | super.start(ev) 23 | } 24 | 25 | hover(ev: ToolPointerEvent) { 26 | const pos = ev.rendererPos.round() 27 | if (this.positions.length > 0) { 28 | this.positions[this.positions.length - 1] = pos 29 | } 30 | this.update() 31 | } 32 | 33 | drawShape(context: CanvasRenderingContext2D) { 34 | for (let [i, pos] of this.positions.entries()) { 35 | if (i === 0) { 36 | context.moveTo(pos.x, pos.y) 37 | } else { 38 | context.lineTo(pos.x, pos.y) 39 | } 40 | } 41 | } 42 | 43 | keyDown(ev: React.KeyboardEvent) { 44 | super.keyDown(ev) 45 | if (ev.key === 'Enter') { 46 | this.commit() 47 | this.positions = [] 48 | } 49 | if (ev.key === 'Escape') { 50 | this.positions = [] 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/renderer/tools/RectSelectTool.ts: -------------------------------------------------------------------------------- 1 | import {Vec2, Rect} from 'paintvec' 2 | import ShapeSelectTool from './ShapeSelectTool' 3 | import {ToolPointerEvent} from './Tool' 4 | import ToolIDs from './ToolIDs' 5 | 6 | type RectSelectType = 'rect'|'ellipse' 7 | 8 | export default 9 | class RectSelectTool extends ShapeSelectTool { 10 | readonly id = this.type === 'rect' ? ToolIDs.rectSelect : ToolIDs.ellipseSelect 11 | readonly title = this.type === 'rect' ? 'Rectangle Select' : 'Ellipse Select' 12 | get cursor() { 13 | return 'crosshair' 14 | } 15 | startPos = new Vec2() 16 | currentPos = new Vec2() 17 | 18 | get selectingRect() { 19 | if (this.drawing && !this.startPos.equals(this.currentPos)) { 20 | return Rect.fromTwoPoints(this.startPos, this.currentPos) 21 | } 22 | } 23 | 24 | constructor(public type: RectSelectType) { 25 | super() 26 | } 27 | 28 | start(ev: ToolPointerEvent) { 29 | this.startPos = this.currentPos = ev.rendererPos.round() 30 | super.start(ev) 31 | } 32 | 33 | move(ev: ToolPointerEvent) { 34 | this.currentPos = ev.rendererPos.round() 35 | super.move(ev) 36 | } 37 | 38 | drawShape(context: CanvasRenderingContext2D) { 39 | const rect = this.selectingRect 40 | if (rect) { 41 | if (this.type === 'rect') { 42 | context.rect(rect.left, rect.top, rect.width, rect.height) 43 | } else { 44 | const {center, width, height} = rect 45 | context.ellipse(center.x, center.y, width / 2, height / 2, 0, 0, 2 * Math.PI) 46 | } 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/renderer/tools/RotateTool.ts: -------------------------------------------------------------------------------- 1 | import {Vec2} from 'paintvec' 2 | import KeyInput from '../../lib/KeyInput' 3 | import {renderer} from '../views/Renderer' 4 | import Tool, {ToolPointerEvent} from './Tool' 5 | import ToolIDs from './ToolIDs' 6 | 7 | export default 8 | class RotateTool extends Tool { 9 | readonly id = ToolIDs.rotate 10 | readonly title = 'Rotate' 11 | get cursor() { 12 | return 'ew-resize' // TODO: use more rotate-like cursor 13 | } 14 | private dragging = false 15 | private originalAngle = 0 16 | private originalRotation = 0 17 | 18 | tempShortcut = new KeyInput(['Shift'], 'Space') 19 | 20 | start(ev: ToolPointerEvent) { 21 | if (!this.picture) { 22 | return 23 | } 24 | if (ev.button === 2) { 25 | this.picture.navigation.resetRotation() 26 | return 27 | } 28 | this.originalAngle = this.posAngle(ev.rendererPos) 29 | this.originalRotation = this.picture.navigation.rotation 30 | this.picture.navigation.saveRendererCenter() 31 | this.dragging = true 32 | } 33 | 34 | move(ev: ToolPointerEvent) { 35 | if (!this.picture || !this.dragging) { 36 | return 37 | } 38 | const angle = this.posAngle(ev.rendererPos) 39 | const diff = angle - this.originalAngle 40 | const rotation = diff + this.originalRotation 41 | this.picture.navigation.rotateAroundRendererCenter(rotation) 42 | } 43 | 44 | posAngle(rendererPos: Vec2) { 45 | if (!this.picture) { 46 | return 0 47 | } 48 | const offset = rendererPos.sub(renderer.size.mulScalar(0.5).round()) 49 | return this.picture.navigation.horizontalFlip ? new Vec2(-offset.x, offset.y).angle() : offset.angle() 50 | } 51 | 52 | end() { 53 | this.dragging = false 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/renderer/tools/Tool.ts: -------------------------------------------------------------------------------- 1 | import {computed, observable} from 'mobx' 2 | import {Vec2} from 'paintvec' 3 | import React = require('react') 4 | import KeyInput, {KeyInputData} from '../../lib/KeyInput' 5 | import {appState} from '../app/AppState' 6 | import {toolManager} from '../app/ToolManager' 7 | import Layer from '../models/Layer' 8 | import Selection from '../models/Selection' 9 | import {Tile} from '../models/TiledTexture' 10 | import {UndoStack} from '../models/UndoStack' 11 | import {SelectionShowMode} from '../views/Renderer' 12 | 13 | export 14 | interface ToolConfigData { 15 | toggleShortcut: KeyInputData|null 16 | tempShortcut: KeyInputData|null 17 | } 18 | 19 | export 20 | interface ToolPointerEvent { 21 | rendererPos: Vec2 22 | picturePos: Vec2 23 | pressure: number 24 | button: number 25 | altKey: boolean 26 | ctrlKey: boolean 27 | metaKey: boolean 28 | shiftKey: boolean 29 | } 30 | 31 | abstract class Tool { 32 | @computed get picture() { 33 | return appState.currentPicture 34 | } 35 | @computed get currentLayer() { 36 | if (this.picture) { 37 | return this.picture.currentLayer 38 | } 39 | } 40 | @computed get selectedLayers() { 41 | if (this.picture) { 42 | return this.picture.selectedLayers.peek() 43 | } else { 44 | return [] 45 | } 46 | } 47 | @computed get active() { 48 | return toolManager.currentTool === this 49 | } 50 | 51 | abstract id: string 52 | abstract title: string 53 | 54 | get cursor() { 55 | return 'auto' 56 | } 57 | get cursorImage(): HTMLCanvasElement|undefined { 58 | return undefined 59 | } 60 | get cursorImageSize() { 61 | return 0 62 | } 63 | 64 | get modal() { return false } 65 | get modalUndoStack(): UndoStack|undefined { return } 66 | 67 | abstract start(event: ToolPointerEvent): void 68 | abstract move(event: ToolPointerEvent): void 69 | abstract end(event: ToolPointerEvent): void 70 | hover(event: ToolPointerEvent) {} 71 | keyDown(event: React.KeyboardEvent) {} 72 | 73 | renderSettings(): JSX.Element { return React.createElement('div') } 74 | renderOverlayCanvas?(context: CanvasRenderingContext2D): void 75 | previewLayerTile(layer: Layer, tileKey: Vec2): {tile: Tile|undefined}|undefined { return } 76 | previewSelection(): Selection|false { return false } 77 | get selectionShowMode(): SelectionShowMode { return 'normal' } 78 | 79 | @observable toggleShortcut: KeyInput|undefined 80 | @observable tempShortcut: KeyInput|undefined 81 | 82 | saveConfig(): ToolConfigData { 83 | const toggleShortcut = this.toggleShortcut ? this.toggleShortcut.toData() : null 84 | const tempShortcut = this.tempShortcut ? this.tempShortcut.toData() : null 85 | return {toggleShortcut, tempShortcut} 86 | } 87 | loadConfig(config: ToolConfigData) { 88 | this.toggleShortcut = config.toggleShortcut ? KeyInput.fromData(config.toggleShortcut) : undefined 89 | this.tempShortcut = config.tempShortcut ? KeyInput.fromData(config.tempShortcut) : undefined 90 | } 91 | } 92 | export default Tool 93 | -------------------------------------------------------------------------------- /src/renderer/tools/ToolIDs.ts: -------------------------------------------------------------------------------- 1 | 2 | export default { 3 | pan: 'pan', 4 | rotate: 'rotate', 5 | zoom: 'zoom', 6 | transformLayer: 'transformLayer', 7 | rectSelect: 'rectSelect', 8 | ellipseSelect: 'ellipseSelect', 9 | freehandSelect: 'freehandSelect', 10 | polygonSelect: 'polygonSelect', 11 | floodFill: 'floodFill', 12 | canvasArea: 'canvasArea', 13 | brush: 'brush', 14 | } 15 | -------------------------------------------------------------------------------- /src/renderer/tools/ZoomTool.ts: -------------------------------------------------------------------------------- 1 | import {Vec2} from 'paintvec' 2 | import KeyInput from '../../lib/KeyInput' 3 | import Tool, {ToolPointerEvent} from './Tool' 4 | import ToolIDs from './ToolIDs' 5 | 6 | const modScale = (scale: number) => { 7 | return (scale < 0.25) ? 0.25 : (scale > 32) ? 32 : scale 8 | } 9 | 10 | export 11 | class ZoomTool extends Tool { 12 | readonly id = ToolIDs.zoom 13 | readonly title = 'Zoom' 14 | get cursor() { 15 | return 'zoom-in' 16 | } 17 | private originalScale = 1.0 18 | private dragging = false 19 | private startPos = new Vec2() 20 | tempShortcut = new KeyInput(['MetaOrControl'], 'Space') 21 | 22 | start(ev: ToolPointerEvent) { 23 | if (!this.picture) { 24 | return 25 | } 26 | if (ev.button === 2) { 27 | this.picture.navigation.resetScale() 28 | return 29 | } 30 | const {scale} = this.picture.navigation 31 | this.originalScale = scale 32 | this.picture.navigation.saveRendererCenter() 33 | this.startPos = ev.rendererPos 34 | this.dragging = true 35 | } 36 | 37 | move(ev: ToolPointerEvent) { 38 | if (!this.picture || !this.dragging) { 39 | return 40 | } 41 | const offset = ev.rendererPos.sub(this.startPos) 42 | const distance = Math.pow(2, offset.x / 100) 43 | const scale = modScale(this.originalScale * distance) 44 | this.picture.navigation.scaleAroundRendererCenter(scale) 45 | } 46 | 47 | end() { 48 | this.dragging = false 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/renderer/viewmodels/PreferencesViewModel.ts: -------------------------------------------------------------------------------- 1 | import {ipcRenderer} from 'electron' 2 | import {observable} from 'mobx' 3 | import IPCChannels from '../../common/IPCChannels' 4 | 5 | export interface PreferencesData { 6 | undoGroupingInterval: number 7 | } 8 | 9 | export default class PreferencesViewModel { 10 | @observable undoGroupingInterval = 0 11 | 12 | setData(data: PreferencesData) { 13 | this.undoGroupingInterval = data.undoGroupingInterval 14 | } 15 | 16 | toData(): PreferencesData { 17 | const {undoGroupingInterval} = this 18 | return {undoGroupingInterval} 19 | } 20 | 21 | notifyChange() { 22 | ipcRenderer.send(IPCChannels.preferencesChange, this.toData()) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/renderer/views/BrushSettings.tsx: -------------------------------------------------------------------------------- 1 | import {observer} from 'mobx-react' 2 | import * as React from 'react' 3 | import {brushPresetManager} from '../app/BrushPresetManager' 4 | import RangeSlider from './components/RangeSlider' 5 | 6 | function PercentSlider(props: {value: number, onChange: (value: number) => void}) { 7 | const onPercentChange = (value: number) => { 8 | props.onChange(value / 100) 9 | } 10 | return 11 | } 12 | 13 | @observer 14 | export default 15 | class BrushSettings extends React.Component<{}, {}> { 16 | render() { 17 | const preset = brushPresetManager.currentPreset 18 | if (!preset) { 19 | return 20 | } 21 | const onEraserModeChange = (ev: React.FormEvent) => { 22 | preset.type = ev.currentTarget.checked ? 'eraser' : 'normal' 23 | } 24 | return ( 25 |
26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 |
Opacity preset.opacity = x} />
Min Opacity preset.minOpacityRatio = x} />
Blending preset.blending = x} />
Width preset.width = x} />
Min Width preset.minWidthRatio = x} />
Softness preset.softness = x} />
Eraser
Stabilizing preset.stabilizingLevel = value} min={0} max={10} value={preset.stabilizingLevel} />
61 | ) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/renderer/views/CurrentFocus.ts: -------------------------------------------------------------------------------- 1 | import {observable, computed} from 'mobx' 2 | import {isTextInput} from './util' 3 | 4 | export default 5 | class CurrentFocus { 6 | @observable element = document.activeElement 7 | 8 | @computed get isTextInput() { 9 | return isTextInput(this.element) 10 | } 11 | 12 | constructor() { 13 | window.addEventListener('focus', () => this.onFocusChange(), true) 14 | window.addEventListener('blur', () => this.onFocusChange(), true) 15 | } 16 | 17 | private onFocusChange() { 18 | this.element = document.activeElement 19 | } 20 | } 21 | 22 | export const currentFocus = new CurrentFocus() 23 | -------------------------------------------------------------------------------- /src/renderer/views/DimensionSelect.css: -------------------------------------------------------------------------------- 1 | .DimensionSelect { 2 | display: flex; 3 | flex-direction: column; 4 | } 5 | .DimensionSelect_Row { 6 | margin: 4px 0; 7 | display: flex; 8 | align-items: center; 9 | & > * { 10 | display: block; 11 | } 12 | & > :nth-child(1) { 13 | text-align: right; 14 | margin-right: 8px; 15 | width: 64px; 16 | } 17 | } 18 | .DimensionSelect_Value { 19 | display: flex; 20 | & > * { 21 | display: block; 22 | } 23 | & > :nth-child(1) { 24 | margin-right: 4px; 25 | width: 100px; 26 | } 27 | } 28 | .DimensionSelect_PixelSize { 29 | font-size: 11px; 30 | color: grey; 31 | } 32 | .DimensionSelect_TooLarge { 33 | color: red; 34 | padding-left: 4px; 35 | } 36 | -------------------------------------------------------------------------------- /src/renderer/views/DrawArea.css: -------------------------------------------------------------------------------- 1 | .DrawArea { 2 | position: relative; 3 | flex: 1; 4 | --scroll-bar-width: 8px; 5 | 6 | & .ScrollBar-vertical { 7 | position: absolute; 8 | right: 0; 9 | top: 0; 10 | width: var(--scroll-bar-width); 11 | height: 100%; 12 | } 13 | & .ScrollBar-horizontal { 14 | position: absolute; 15 | left: 0; 16 | bottom: 0; 17 | height: var(--scroll-bar-width); 18 | width: 100%; 19 | } 20 | } 21 | .DrawArea_content { 22 | position: absolute; 23 | left: 0; 24 | top: 0; 25 | width: calc(100% - var(--scroll-bar-width)); 26 | height: calc(100% - var(--scroll-bar-width)); 27 | } 28 | .DrawArea_canvas { 29 | position: fixed; 30 | } 31 | .DrawArea_blank { 32 | position: absolute; 33 | left: 0; 34 | top: 0; 35 | width: 100%; 36 | height: 100%; 37 | background-color: var(--default-background-color); 38 | } 39 | -------------------------------------------------------------------------------- /src/renderer/views/FloodFillSettings.tsx: -------------------------------------------------------------------------------- 1 | import {observer} from 'mobx-react' 2 | import React = require('react') 3 | import FloodFillTool from '../tools/FloodFillTool' 4 | import RangeSlider from './components/RangeSlider' 5 | 6 | @observer export default 7 | class FloodFillSettings extends React.Component<{tool: FloodFillTool}, {}> { 8 | render() { 9 | const {tool} = this.props 10 | const onToleranceChange = (value: number) => { 11 | tool.tolerance = value 12 | } 13 | return ( 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 |
Tolerance
22 | ) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/renderer/views/KeyBindingHandler.ts: -------------------------------------------------------------------------------- 1 | import {action} from 'mobx' 2 | import KeyInput from '../../lib/KeyInput' 3 | import KeyRecorder from '../../lib/KeyRecorder' 4 | import {actionRegistry} from '../app/ActionRegistry' 5 | import {brushPresetManager} from '../app/BrushPresetManager' 6 | import {keyBindingRegistry} from '../app/KeyBindingRegistry' 7 | import {toolManager} from '../app/ToolManager' 8 | import BrushTool from '../tools/BrushTool' 9 | 10 | class KeyBindingHandler { 11 | private keyRecorder = new KeyRecorder() 12 | 13 | constructor() { 14 | document.addEventListener('keydown', e => this.onKeyDown(e)) 15 | document.addEventListener('keyup', e => this.onKeyUp(e)) 16 | window.addEventListener('blur', e => this.onBlur()) 17 | } 18 | 19 | @action private onKeyDown(e: KeyboardEvent) { 20 | const keyBindings = keyBindingRegistry.keyBindingsForCode(e.key) 21 | const keyInput = KeyInput.fromEvent(e) 22 | for (const binding of keyBindings) { 23 | if (binding.keyInput.equals(keyInput)) { 24 | const action = actionRegistry.actions.get(binding.action) 25 | if (action) { 26 | action.run() 27 | e.preventDefault() 28 | return 29 | } 30 | } 31 | } 32 | for (const tool of toolManager.tools) { 33 | if (tool.toggleShortcut && tool.toggleShortcut.equals(keyInput)) { 34 | toolManager.currentTool = tool 35 | e.preventDefault() 36 | return 37 | } 38 | } 39 | const brushTool = toolManager.tools.find(t => t instanceof BrushTool)! 40 | for (const [index, brush] of brushPresetManager.presets.entries()) { 41 | if (brush.shortcut && brush.shortcut.equals(keyInput)) { 42 | toolManager.currentTool = brushTool 43 | brushPresetManager.currentPresetIndex = index 44 | e.preventDefault() 45 | return 46 | } 47 | } 48 | this.keyRecorder.keyDown(e) 49 | this.updateOverrideTool() 50 | } 51 | 52 | @action private onKeyUp(e: KeyboardEvent) { 53 | this.keyRecorder.keyUp(e) 54 | this.updateOverrideTool() 55 | } 56 | 57 | private onBlur() { 58 | this.keyRecorder.clear() 59 | } 60 | 61 | private updateOverrideTool() { 62 | const {keyInput} = this.keyRecorder 63 | if (keyInput) { 64 | for (const tool of toolManager.tools) { 65 | if (tool.tempShortcut && tool.tempShortcut.equals(keyInput)) { 66 | toolManager.overrideTool = tool 67 | return 68 | } 69 | } 70 | } 71 | toolManager.overrideTool = undefined 72 | } 73 | } 74 | 75 | export 76 | const keyBindingHandler = new KeyBindingHandler() 77 | -------------------------------------------------------------------------------- /src/renderer/views/PictureTabBar.css: -------------------------------------------------------------------------------- 1 | .PictureTabBar { 2 | display: flex; 3 | height: 32px; 4 | align-items: flex-end; 5 | z-index: 100; 6 | font-size: 13px; 7 | background-color: var(--darken-background-color); 8 | transform: translateZ(0); 9 | overflow: hidden; 10 | &[hidden] { 11 | display: none; 12 | } 13 | } 14 | .PictureTab { 15 | --margin: 4px; 16 | display: flex; 17 | align-items: center; 18 | justify-content: center; 19 | position: relative; 20 | box-sizing: border-box; 21 | width: calc(var(--width) - var(--margin)); 22 | height: 32px; 23 | user-select: none; 24 | background-color: var(--default-background-color); 25 | margin-right: var(--margin); 26 | transform: translateX(var(--offset)); 27 | & * { 28 | cursor: default; 29 | position: absolute; 30 | bottom: 8px; 31 | } 32 | &-current { 33 | background-color: var(--lighten-background-color); 34 | z-index: 10; 35 | } 36 | } 37 | .PictureTab_title { 38 | } 39 | .PictureTab_close { 40 | right: 8px; 41 | } 42 | .PictureTabFill { 43 | box-sizing: border-box; 44 | flex: 1; 45 | height: 32px; 46 | } 47 | -------------------------------------------------------------------------------- /src/renderer/views/RootView.css: -------------------------------------------------------------------------------- 1 | .RootView { 2 | display: flex; 3 | flex-direction: column; 4 | width: 100vw; 5 | height: 100vh; 6 | } 7 | .TitleBarPaddingMac { 8 | height: 22px; 9 | background-color: var(--default-background-color); 10 | } 11 | .WindowContent { 12 | display: flex; 13 | flex: 1; 14 | } 15 | .ToolSelection { 16 | z-index: 200; 17 | transform: translateZ(0); 18 | } 19 | .CenterArea { 20 | flex: 1; 21 | display: flex; 22 | flex-direction: column; 23 | } 24 | .Sidebar { 25 | z-index: 100; 26 | display: flex; 27 | flex-direction: column; 28 | width: 240px; 29 | box-sizing: border-box; 30 | overflow-x: hidden; 31 | overflow-y: auto; 32 | background-color: var(--default-background-color); 33 | transform: translateZ(0); 34 | &[hidden] { 35 | display: none; 36 | } 37 | } 38 | .ColorPicker { 39 | margin: 6px 12px; 40 | align-self: center; 41 | } 42 | .RGBRangeSliders { 43 | margin: 6px 12px; 44 | } 45 | .Palette { 46 | margin: 6px 0; 47 | } 48 | .PanelTitle { 49 | font-size: 14px; 50 | height: 48px; 51 | line-height: 48px; 52 | padding: 0 12px; 53 | text-transform: uppercase; 54 | color: var(--panel-title-color); 55 | } 56 | .BrushSettings { 57 | width: 100%; 58 | font-size: 11px; 59 | & tbody { 60 | display: block; 61 | } 62 | & tr { 63 | display: flex; 64 | align-items: center; 65 | margin: 4px 0; 66 | } 67 | & td { 68 | display: block; 69 | &:nth-child(1) { 70 | width: 64px; 71 | text-align: right; 72 | margin: 0; 73 | } 74 | &:nth-child(2) { 75 | flex: 1; 76 | margin: 0 8px; 77 | } 78 | &:nth-child(3) { 79 | width: 32px; 80 | } 81 | } 82 | } 83 | 84 | .TransformLayerSettings { 85 | margin: 0 8px; 86 | & .Button { 87 | margin-right: 8px; 88 | } 89 | } 90 | 91 | .CanvasAreaToolSettings { 92 | margin: 0 8px; 93 | display: flex; 94 | flex-direction: column; 95 | & .DimensionSelect_Value > :nth-child(1) { 96 | width: 64px; 97 | } 98 | &_buttons { 99 | margin-top: 8px; 100 | margin-right: 8px; 101 | align-self: flex-end; 102 | display: flex; 103 | & > * { 104 | margin-left: 8px; 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/renderer/views/RootView.tsx: -------------------------------------------------------------------------------- 1 | import {observer} from 'mobx-react' 2 | import React = require('react') 3 | 4 | import DrawArea from './DrawArea' 5 | import {PictureTabBar} from './PictureTabBar' 6 | import ToolSelection from './ToolSelection' 7 | 8 | import BrushPresetsPanel from './panels/BrushPresetsPanel' 9 | import ColorPanel from './panels/ColorPanel' 10 | import LayerPanel from './panels/LayerPanel' 11 | import NavigatorPanel from './panels/NavigatorPanel' 12 | import ToolSettingsPanel from './panels/ToolSettingsPanel' 13 | 14 | import {appState} from '../app/AppState' 15 | import {toolManager} from '../app/ToolManager' 16 | 17 | import './common.css' 18 | import './KeyBindingHandler' 19 | import './MenuBar' 20 | import './RootView.css' 21 | 22 | @observer export default 23 | class RootView extends React.Component<{}, {}> { 24 | render() { 25 | const {currentTool, overrideTool} = toolManager 26 | const {uiVisible} = appState 27 | const picture = appState.currentPicture 28 | return ( 29 |
30 | {process.platform === 'darwin' ?
: undefined} 31 |
32 |
52 |
53 | ) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/renderer/views/ToolSelection.css: -------------------------------------------------------------------------------- 1 | .ToolSelection { 2 | display: flex; 3 | flex-direction: column; 4 | box-sizing: border-box; 5 | width: 40px; 6 | height: 100%; 7 | background-color: var(--default-background-color); 8 | &[hidden] { 9 | display: none; 10 | } 11 | } 12 | .ToolSelection_button { 13 | width: 40px; 14 | height: 40px; 15 | background-color: transparent; 16 | border: none; 17 | display: flex; 18 | align-items: center; 19 | justify-content: center; 20 | padding: 1px; /* workaround for Retina */ 21 | } 22 | .ToolSelection_button-selected { 23 | background-color: var(--ui-primary-background-color); 24 | } 25 | -------------------------------------------------------------------------------- /src/renderer/views/ToolSelection.tsx: -------------------------------------------------------------------------------- 1 | import * as classNames from 'classnames' 2 | import {remote} from 'electron' 3 | import {action} from 'mobx' 4 | import {observer} from 'mobx-react' 5 | import * as React from 'react' 6 | const {Menu} = remote 7 | 8 | import KeyInput from '../../lib/KeyInput' 9 | 10 | import SVGIcon from './components/SVGIcon' 11 | 12 | import {toolManager} from '../app/ToolManager' 13 | 14 | import Tool from '../tools/Tool' 15 | import ToolIDs from '../tools/ToolIDs' 16 | 17 | import {dialogLauncher} from '../views/dialogs/DialogLauncher' 18 | 19 | import './ToolSelection.css' 20 | 21 | const toolToIcon = (tool: Tool) => { 22 | const map = { 23 | [ToolIDs.brush]: 'paint-brush', 24 | [ToolIDs.pan]: 'move', 25 | [ToolIDs.rotate]: 'rotate', 26 | [ToolIDs.zoom]: 'search', 27 | [ToolIDs.transformLayer]: 'transform', 28 | [ToolIDs.rectSelect]: 'rect-select', 29 | [ToolIDs.ellipseSelect]: 'ellipse-select', 30 | [ToolIDs.freehandSelect]: 'freehand-select', 31 | [ToolIDs.polygonSelect]: 'polygon-select', 32 | [ToolIDs.floodFill]: 'magic-wand', 33 | [ToolIDs.canvasArea]: 'crop', 34 | } 35 | return 36 | } 37 | 38 | @observer 39 | export default 40 | class ToolSelection extends React.Component<{hidden: boolean}, {}> { 41 | render() { 42 | const {hidden} = this.props 43 | const {tools, currentTool} = toolManager 44 | return ( 45 | 55 | ) 56 | } 57 | private onChange = action((tool: Tool) => { 58 | toolManager.currentTool = tool 59 | }) 60 | private onContextMenu = action((e: React.MouseEvent, tool: Tool) => { 61 | toolManager.currentTool = tool 62 | const selectShortcuts = async () => { 63 | const result = await dialogLauncher.openToolShortcutsDialog({ 64 | toggle: tool.toggleShortcut && tool.toggleShortcut.toData(), 65 | temp: tool.tempShortcut && tool.tempShortcut.toData(), 66 | }) 67 | if (result) { 68 | const {toggle, temp} = result 69 | tool.toggleShortcut = toggle && KeyInput.fromData(toggle) 70 | tool.tempShortcut = temp && KeyInput.fromData(temp) 71 | } 72 | } 73 | const menu = Menu.buildFromTemplate([ 74 | {label: 'Shortcuts...', click: selectShortcuts}, 75 | ]) 76 | menu.popup(remote.getCurrentWindow(), { 77 | x: e.clientX, 78 | y: e.clientY, 79 | async: true 80 | }) 81 | }) 82 | } 83 | -------------------------------------------------------------------------------- /src/renderer/views/common.css: -------------------------------------------------------------------------------- 1 | :root { 2 | /* fonts */ 3 | --default-font-family: BlinkMacSystemFont, "Segoe UI"; 4 | /* colors */ 5 | --default-color: #ECEEF4; 6 | --default-background-color: #141414; 7 | --darken-background-color: #0A0A0A; 8 | --lighten-background-color: #1F1F1F; 9 | --ui-background-color: #454854; 10 | --ui-primary-background-color: #1472DF; 11 | 12 | --panel-title-color: #AAACB3; 13 | 14 | --preview-panel-background-color: color(#8CF08C alpha(20%)); 15 | --preview-panel-border-color: color(#8CF08C alpha(20%)); 16 | 17 | --range-slider-fill-background-color: #FF6D1C; 18 | } 19 | 20 | * { 21 | color: var(--default-color); 22 | user-select: none; 23 | font-family: var(--default-font-family); 24 | -webkit-font-smoothing: antialiased; 25 | } 26 | 27 | div { 28 | cursor: default; 29 | } 30 | 31 | body { 32 | margin: 0; 33 | font-size: 13px; 34 | position: fixed; 35 | } 36 | 37 | *:focus { 38 | outline: none 39 | } 40 | 41 | .Button { 42 | border: none; 43 | background-color: var(--ui-background-color); 44 | border-radius: 4px; 45 | height: 24px; 46 | padding: 0 16px; 47 | } 48 | 49 | .Button-primary { 50 | background-color: var(--ui-primary-background-color); 51 | } 52 | 53 | .Select { 54 | border: none; 55 | background-color: var(--ui-background-color); 56 | border-radius: 4px; 57 | height: 20px; 58 | padding: 0 8px; 59 | } 60 | 61 | .TextInput { 62 | border: none; 63 | background-color: var(--default-color); 64 | color: var(--default-background-color); 65 | border-radius: 4px; 66 | height: 20px; 67 | padding: 0 8px; 68 | } 69 | 70 | .DialogRoot { 71 | display: flex; 72 | justify-content: center; 73 | } 74 | -------------------------------------------------------------------------------- /src/renderer/views/components/CSSVariables.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import * as ReactDOM from 'react-dom' 3 | const decamelize = require('decamelize') 4 | 5 | interface CSSVariablesProps { 6 | [key: string]: string|number|React.ReactChild 7 | } 8 | 9 | export default 10 | class CSSVariables extends React.Component { 11 | private oldProps: CSSVariablesProps = {} 12 | private element: HTMLElement|undefined 13 | 14 | componentDidMount() { 15 | this.setProperties(this.props) 16 | } 17 | componentWillReceiveProps(props: CSSVariablesProps) { 18 | this.setProperties(props) 19 | } 20 | private setProperties(props: CSSVariablesProps) { 21 | this.element = ReactDOM.findDOMNode(this) as HTMLElement 22 | if (this.element) { 23 | for (const key in props) { 24 | if (['key', 'ref', 'children'].indexOf(key) < 0) { 25 | if (this.oldProps[key] !== props[key]) { 26 | this.element.style.setProperty(`--${decamelize(key, '-')}`, `${props[key]}`) 27 | } 28 | } 29 | } 30 | this.oldProps = props 31 | } 32 | } 33 | 34 | render() { 35 | return React.Children.only(this.props.children) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/renderer/views/components/ClickToEdit.css: -------------------------------------------------------------------------------- 1 | .ClickToEdit { 2 | --height: 16px; 3 | position: relative; 4 | height: var(--height); 5 | & > * { 6 | position: absolute; 7 | left: 0; 8 | top: 0; 9 | width: 100%; 10 | height: 100%; 11 | line-height: var(--height); 12 | font-size: 12px; 13 | } 14 | &_input { 15 | border: none; 16 | outline: none; 17 | background-color: var(--default-color); 18 | color: var(--default-background-color); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/renderer/views/components/ClickToEdit.tsx: -------------------------------------------------------------------------------- 1 | import React = require('react') 2 | import './ClickToEdit.css' 3 | 4 | interface ClickToEditProps { 5 | text: string 6 | onChange: (text: string) => void 7 | editable: boolean 8 | } 9 | 10 | interface ClickToEditState { 11 | isEditing: boolean 12 | } 13 | 14 | export default 15 | class ClickToEdit extends React.Component { 16 | state = { 17 | isEditing: false 18 | } 19 | 20 | componentWillReceiveProps(props: ClickToEditProps) { 21 | if (!props.editable) { 22 | this.setState({ 23 | isEditing: false 24 | }) 25 | } 26 | } 27 | 28 | render() { 29 | const {text} = this.props 30 | const {isEditing} = this.state 31 | return ( 32 |
33 |
{text}
34 | 38 |
39 | ) 40 | } 41 | 42 | get inputElem() { 43 | return this.refs['input'] as HTMLInputElement 44 | } 45 | 46 | onTextClick() { 47 | if (!this.props.editable) { 48 | return 49 | } 50 | this.setState({ 51 | isEditing: true 52 | }) 53 | this.inputElem.setSelectionRange(0, this.inputElem.value.length) 54 | } 55 | 56 | onEditFinish(text: string) { 57 | this.setState({ 58 | isEditing: false 59 | }) 60 | this.props.onChange(text) 61 | } 62 | onInputBlur(event: React.FocusEvent) { 63 | const text = this.inputElem.value 64 | this.onEditFinish(text) 65 | } 66 | onInputKeyPress(event: React.KeyboardEvent) { 67 | if (event.key === 'Enter') { 68 | const text = this.inputElem.value 69 | this.onEditFinish(text) 70 | event.preventDefault() 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/renderer/views/components/ColorSlider.css: -------------------------------------------------------------------------------- 1 | .ColorSlider { 2 | --slider-height: 16px; 3 | position: relative; 4 | height: var(--slider-height); 5 | width: 100%; 6 | } 7 | .ColorSlider_gradient { 8 | --gradient-height: 12px; 9 | background: var(--gradient); 10 | position: absolute; 11 | left: 0px; 12 | top: calc((var(--slider-height) - var(--gradient-height)) / 2); 13 | width: 100%; 14 | height: var(--gradient-height); 15 | border-radius: 4px; 16 | } 17 | .ColorSlider_handle { 18 | background-color: var(--color); 19 | position: absolute; 20 | left: calc(var(--value) * 100% - var(--slider-height) / 2); 21 | top: 0px; 22 | width: var(--slider-height); 23 | height: var(--slider-height); 24 | box-sizing: border-box; 25 | border: 2px solid white; 26 | border-radius: calc(var(--slider-height) / 2); 27 | } -------------------------------------------------------------------------------- /src/renderer/views/components/ColorSlider.tsx: -------------------------------------------------------------------------------- 1 | import {Color} from 'paintgl' 2 | import * as React from 'react' 3 | import {toHexColor} from '../../../lib/Color' 4 | import './ColorSlider.css' 5 | import CSSVariables from './CSSVariables' 6 | import PointerEvents from './PointerEvents' 7 | 8 | interface ColorSliderProps { 9 | color: Color 10 | value: number // [0, 1] 11 | onChange: (value: number) => void 12 | gradientSteps: [Color, number][] 13 | } 14 | 15 | function clamp(x: number, min: number, max: number) { 16 | return Math.min(Math.max(x, min), max) 17 | } 18 | 19 | export default 20 | class ColorSlider extends React.Component { 21 | private element: HTMLElement 22 | private dragged = false 23 | 24 | private onMove(e: PointerEvent) { 25 | const {clientWidth} = this.element 26 | const newValue = clamp(e.offsetX / clientWidth, 0, 1) 27 | this.props.onChange(newValue) 28 | } 29 | private onPointerDown = (e: PointerEvent) => { 30 | this.dragged = true 31 | this.onMove(e) 32 | this.element.setPointerCapture(e.pointerId) 33 | } 34 | private onPointerMove = (e: PointerEvent) => { 35 | if (this.dragged) { 36 | this.onMove(e) 37 | } 38 | } 39 | private onPointerUp = (e: PointerEvent) => { 40 | this.dragged = false 41 | } 42 | render() { 43 | const {color, value} = this.props 44 | const gradientSteps = this.props.gradientSteps.map(([color, pos]) => `${toHexColor(color)} ${pos * 100}%`) 45 | const gradient = `linear-gradient(to right, ${gradientSteps.join(', ')})` 46 | return ( 47 | 48 | 49 |
this.element = e!}> 50 |
51 |
52 |
53 | 54 | 55 | ) 56 | } 57 | 58 | } 59 | -------------------------------------------------------------------------------- /src/renderer/views/components/DialogTitleBar.css: -------------------------------------------------------------------------------- 1 | .DialogTitleBar { 2 | height: var(--height); 3 | line-height: var(--height); 4 | width: 100%; 5 | text-align: center; 6 | position: relative; 7 | -webkit-app-region: drag; 8 | &-darwin { 9 | --height: 22px; 10 | & .DialogTitleBar_close { 11 | display: none; 12 | } 13 | } 14 | &-win32 { 15 | --height: 24px; 16 | } 17 | } 18 | 19 | .DialogTitleBar_close { 20 | width: 32px; 21 | height: 24px; 22 | position: absolute; 23 | right: 0; 24 | top: 0; 25 | -webkit-app-region: no-drag; 26 | &:before { 27 | content: ""; 28 | position: absolute; 29 | right: 8px; 30 | top: 4px; 31 | width: 16px; 32 | height: 16px; 33 | mask: url("../icons/window-close.svg"); 34 | background-color: white; 35 | } 36 | &:hover { 37 | background-color: red; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/renderer/views/components/DialogTitleBar.tsx: -------------------------------------------------------------------------------- 1 | import {remote} from 'electron' 2 | import * as React from 'react' 3 | import './DialogTitleBar.css' 4 | 5 | export default 6 | function DialogTitleBar(props: {title: string}) { 7 | const {title} = props 8 | const className = `DialogTitleBar DialogTitleBar-${process.platform}` 9 | const onClose = () => { 10 | remote.getCurrentWindow().close() 11 | } 12 | return ( 13 |
14 | {title} 15 |
16 |
17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /src/renderer/views/components/DraggablePanel.css: -------------------------------------------------------------------------------- 1 | .DraggablePanel { 2 | position: absolute; 3 | box-sizing: border-box; 4 | display: flex; 5 | flex-direction: column; 6 | top: 48px; 7 | } 8 | .DraggablePanel_label { 9 | font-size: 14px; 10 | max-width: 80px; 11 | cursor: move; 12 | padding: 2px; 13 | height: 32px; 14 | line-height: 32px; 15 | box-sizing: border-box; 16 | text-transform: uppercase; 17 | color: var(--panel-title-color); 18 | } 19 | .DraggablePanel_contents { 20 | flex: 1; 21 | box-sizing: border-box; 22 | } 23 | .PreviewPanel { 24 | position: absolute; 25 | background-color: var(--preview-panel-background-color); 26 | border: 1px solid var(--preview-panel-border-color); 27 | box-sizing: border-box; 28 | } 29 | -------------------------------------------------------------------------------- /src/renderer/views/components/FrameDebounced.tsx: -------------------------------------------------------------------------------- 1 | import {reaction, computed} from 'mobx' 2 | import * as React from 'react' 3 | import {frameDebounce} from '../../../lib/Debounce' 4 | 5 | abstract class FrameDebounced extends React.Component { 6 | private updateDisposer: () => void 7 | 8 | componentDidMount() { 9 | this.updateDisposer = reaction(() => this.rendered, frameDebounce(() => this.forceUpdate())) 10 | } 11 | componentWillUnmount() { 12 | this.updateDisposer() 13 | } 14 | 15 | abstract renderDebounced(): JSX.Element 16 | 17 | @computed get rendered() { 18 | return this.renderDebounced() 19 | } 20 | 21 | render() { 22 | return this.rendered 23 | } 24 | } 25 | export default FrameDebounced 26 | -------------------------------------------------------------------------------- /src/renderer/views/components/Palette.css: -------------------------------------------------------------------------------- 1 | .Palette { 2 | height: 96px; 3 | box-sizing: border-box; 4 | padding: 6px; 5 | background-color: var(--darken-background-color); 6 | overflow-y: scroll; 7 | } 8 | .Palette-row { 9 | display: flex; 10 | } 11 | .Palette-button { 12 | display: flex; 13 | } 14 | .Palette-color { 15 | width: 16px; 16 | height: 16px; 17 | margin: 2px; 18 | &-transparent { 19 | background-color: #fff; 20 | position: relative; 21 | &:before { 22 | content: ""; 23 | position: absolute; 24 | left: 0; top: 0; width: 8px; height: 8px; 25 | background-color: #ccc; 26 | } 27 | &:after { 28 | content: ""; 29 | position: absolute; 30 | right: 0; bottom: 0; width: 8px; height: 8px; 31 | background-color: #ccc; 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/renderer/views/components/Palette.tsx: -------------------------------------------------------------------------------- 1 | import {observer} from 'mobx-react' 2 | import React = require('react') 3 | import {HSVColor} from '../../../lib/Color' 4 | import './Palette.css' 5 | 6 | interface PaletteProps { 7 | palette: (HSVColor|undefined)[] 8 | paletteIndex: number 9 | onChange: (event: React.MouseEvent, index: number) => void 10 | } 11 | 12 | const Palette = observer((props: PaletteProps) => { 13 | const {palette} = props 14 | 15 | const rowLength = 10 16 | const rows: (HSVColor|undefined)[][] = [] 17 | for (let i = 0; i < palette.length; i += rowLength) { 18 | rows.push(palette.slice(i, i + rowLength)) 19 | } 20 | 21 | const rowElems = rows.map((row, y) => { 22 | const buttons = row.map((color, x) => { 23 | const i = y * rowLength + x 24 | const onClick = (e: React.MouseEvent) => { 25 | props.onChange(e, i) 26 | } 27 | const colorElem = color 28 | ?
29 | :
30 | return
{colorElem}
31 | }) 32 | return
{buttons}
33 | }) 34 | 35 | return ( 36 |
37 | {rowElems} 38 |
39 | ) 40 | }) 41 | export default Palette 42 | -------------------------------------------------------------------------------- /src/renderer/views/components/PointerEvents.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import * as ReactDOM from 'react-dom' 3 | 4 | interface PointerEventsProps { 5 | onPointerDown?: (ev: PointerEvent) => void 6 | onPointerMove?: (ev: PointerEvent) => void 7 | onPointerUp?: (ev: PointerEvent) => void 8 | } 9 | 10 | export default 11 | class PointerEvents extends React.Component { 12 | private element: HTMLElement|undefined 13 | 14 | componentDidMount() { 15 | this.element = ReactDOM.findDOMNode(this) as HTMLElement 16 | if (this.element) { 17 | this.element.addEventListener('pointerup', this.onPointerUp) 18 | this.element.addEventListener('pointerdown', this.onPointerDown) 19 | this.element.addEventListener('pointermove', this.onPointerMove) 20 | } 21 | } 22 | componentWillUnmount() { 23 | if (this.element) { 24 | this.element.removeEventListener('pointerup', this.onPointerUp) 25 | this.element.removeEventListener('pointerdown', this.onPointerDown) 26 | this.element.removeEventListener('pointermove', this.onPointerMove) 27 | } 28 | } 29 | render() { 30 | return React.Children.only(this.props.children) 31 | } 32 | private onPointerUp = (e: PointerEvent) => { 33 | if (this.props.onPointerUp) { 34 | this.props.onPointerUp(e) 35 | } 36 | } 37 | private onPointerMove = (e: PointerEvent) => { 38 | if (this.props.onPointerMove) { 39 | this.props.onPointerMove(e) 40 | } 41 | } 42 | private onPointerDown = (e: PointerEvent) => { 43 | if (this.props.onPointerDown) { 44 | this.props.onPointerDown(e) 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/renderer/views/components/RGBRangeSliders.css: -------------------------------------------------------------------------------- 1 | .RGBRangeSliders { 2 | & .ColorSlider { 3 | margin: 2px 0; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/renderer/views/components/RGBRangeSliders.tsx: -------------------------------------------------------------------------------- 1 | import { Color } from 'paintgl' 2 | import React = require('react') 3 | import { HSVColor } from '../../../lib/Color' 4 | import ColorSlider from './ColorSlider' 5 | import './RGBRangeSliders.css' 6 | 7 | interface RGBRangeSlidersProps { 8 | color: HSVColor 9 | onChange: (color: HSVColor) => void 10 | } 11 | 12 | export default 13 | class RGBRangeSliders extends React.Component { 14 | color: Color 15 | constructor(props: RGBRangeSlidersProps) { 16 | super(props) 17 | this.color = this.props.color.toRgb() 18 | } 19 | componentWillReceiveProps(props: RGBRangeSlidersProps) { 20 | this.color = props.color.toRgb() 21 | } 22 | onChangeR = (value: number) => { 23 | this.color.r = value 24 | const {r, g, b} = this.color 25 | this.props.onChange(HSVColor.rgb(r, g, b)) 26 | this.forceUpdate() 27 | } 28 | onChangeG = (value: number) => { 29 | this.color.g = value 30 | const {r, g, b} = this.color 31 | this.props.onChange(HSVColor.rgb(r, g, b)) 32 | this.forceUpdate() 33 | } 34 | onChangeB = (value: number) => { 35 | this.color.b = value 36 | const {r, g, b} = this.color 37 | this.props.onChange(HSVColor.rgb(r, g, b)) 38 | this.forceUpdate() 39 | } 40 | render() { 41 | const {color} = this 42 | const {r, g, b} = color 43 | return ( 44 |
45 | 46 | 47 | 48 |
49 | ) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/renderer/views/components/RangeSlider.css: -------------------------------------------------------------------------------- 1 | .RangeSlider { 2 | position: relative; 3 | } 4 | .RangeSlider_border { 5 | background-color: var(--ui-background-color); 6 | border-radius: 4px; 7 | cursor: pointer; 8 | overflow: hidden; 9 | width: 100%; 10 | height: 16px; 11 | box-sizing: border-box; 12 | } 13 | .RangeSlider_fill { 14 | background-color: var(--ui-primary-background-color); 15 | width: calc(var(--slider-ratio) * 100%); 16 | height: 100%; 17 | &::after { 18 | content: "\200B"; 19 | } 20 | } 21 | .RangeSlider_text { 22 | position: absolute; 23 | right: 4px; 24 | top: 0px; 25 | height: 16px; 26 | line-height: 16px; 27 | font-size: 11px; 28 | } 29 | .RangeSlider_handle { 30 | position: absolute; 31 | } 32 | -------------------------------------------------------------------------------- /src/renderer/views/components/SVGIcon.css: -------------------------------------------------------------------------------- 1 | .SVGIcon { 2 | background: var(--default-color); 3 | width: 24px; 4 | height: 24px; 5 | &.move { 6 | mask: url("../../../../bundles/icons/flaticon/svg/move.svg"); 7 | } 8 | &.paint-brush { 9 | mask: url("../../../../bundles/icons/flaticon/svg/paint-brush.svg"); 10 | } 11 | &.pen { 12 | mask: url("../../../../bundles/icons/flaticon/svg/pen.svg"); 13 | } 14 | &.eraser { 15 | mask: url("../../../../bundles/icons/flaticon/svg/eraser.svg"); 16 | } 17 | &.rotate { 18 | mask: url("../../../../bundles/icons/flaticon/svg/rotate.svg"); 19 | } 20 | &.search { 21 | mask: url("../../../../bundles/icons/flaticon/svg/search.svg"); 22 | } 23 | &.transform { 24 | mask: url("../../../../bundles/icons/flaticon/svg/transform.svg"); 25 | } 26 | &.add { 27 | mask: url("../../../../bundles/icons/flaticon/svg/add.svg"); 28 | } 29 | &.folder { 30 | mask: url("../../../../bundles/icons/flaticon/svg/folder-2.svg"); 31 | } 32 | &.subtract { 33 | mask: url("../../../../bundles/icons/flaticon/svg/subtract.svg"); 34 | } 35 | &.zoom-in { 36 | mask: url("../../../../bundles/icons/flaticon/svg/zoom-in.svg"); 37 | } 38 | &.zoom-out { 39 | mask: url("../../../../bundles/icons/flaticon/svg/zoom-out.svg"); 40 | } 41 | &.rotate-left { 42 | mask: url("../../../../bundles/icons/flaticon/svg/circuit-9.svg"); 43 | } 44 | &.rotate-right { 45 | mask: url("../../../../bundles/icons/flaticon/svg/circuit-9.svg"); 46 | transform: scaleX(-1); 47 | } 48 | &.rect-select { 49 | mask: url("../../../../bundles/icons/flaticon/svg/square-21.svg"); 50 | } 51 | &.ellipse-select { 52 | mask: url("../../../../bundles/icons/flaticon/svg/circle.svg"); 53 | } 54 | &.freehand-select { 55 | mask: url("../icons/freehand-select.svg"); 56 | } 57 | &.polygon-select { 58 | mask: url("../icons/polygon-select.svg"); 59 | } 60 | &.crop { 61 | mask: url("../../../../bundles/icons/flaticon/svg/crop.svg"); 62 | } 63 | &.magic-wand { 64 | mask: url("../../../../bundles/icons/flaticon/svg/magic-wand.svg"); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/renderer/views/components/SVGIcon.tsx: -------------------------------------------------------------------------------- 1 | import React = require('react') 2 | import './SVGIcon.css' 3 | 4 | const SVGIcon = (props: {className: string}) => { 5 | return
6 | } 7 | export default SVGIcon 8 | -------------------------------------------------------------------------------- /src/renderer/views/components/ScrollBar.css: -------------------------------------------------------------------------------- 1 | .ScrollBar { 2 | position: relative; 3 | background-color: var(--darken-background-color); 4 | z-index: 100; 5 | overflow: hidden; 6 | } 7 | .ScrollBar_handle { 8 | position: absolute; 9 | left: 0; 10 | top: 0; 11 | width: 100%; 12 | height: 100%; 13 | background-color: var(--default-color); 14 | opacity: 0.5; 15 | will-change: transform; 16 | } 17 | .ScrollBar-vertical .ScrollBar_handle { 18 | transform-origin: left top; 19 | transform: translateY(calc(var(--handle-start) * 100%)) scaleY(calc(var(--handle-end) - var(--handle-start))); 20 | } 21 | .ScrollBar-horizontal .ScrollBar_handle { 22 | transform-origin: left top; 23 | transform: translateX(calc(var(--handle-start) * 100%)) scaleX(calc(var(--handle-end) - var(--handle-start))); 24 | } 25 | -------------------------------------------------------------------------------- /src/renderer/views/components/ShortcutEdit.css: -------------------------------------------------------------------------------- 1 | .ShortcutEdit { 2 | width: 160px; 3 | height: 24px; 4 | line-height: 24px; 5 | background-color: var(--lighten-background-color); 6 | border-radius: 4px; 7 | position: relative; 8 | padding: 0 6px; 9 | &:focus { 10 | background-color: var(--ui-primary-background-color); 11 | } 12 | &:hover .ShortcutEdit_clear { 13 | display: block; 14 | } 15 | &_clear { 16 | display: none; 17 | position: absolute; 18 | right: 0px; 19 | top: 0px; 20 | width: 8px; 21 | height: 8px; 22 | padding: 8px; 23 | &:before { 24 | content: ""; 25 | position: absolute; 26 | right: 8px; 27 | top: 8px; 28 | width: 8px; 29 | height: 8px; 30 | mask: url("../../../../bundles/icons/flaticon/svg/multiply.svg"); 31 | background-color: var(--default-color); 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/renderer/views/components/ShortcutEdit.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import KeyInput from '../../../lib/KeyInput' 3 | import KeyRecorder from '../../../lib/KeyRecorder' 4 | import './ShortcutEdit.css' 5 | 6 | interface ShortcutEditProps { 7 | shortcut: KeyInput|undefined 8 | onChange: (shortcut: KeyInput|undefined) => void 9 | } 10 | 11 | export default 12 | class ShortcutEdit extends React.Component { 13 | private keyRecorder = new KeyRecorder() 14 | 15 | render() { 16 | const {shortcut} = this.props 17 | return ( 18 |
19 | {shortcut ? shortcut.toElectronAccelerator() : ''} 20 |
21 |
22 | ) 23 | } 24 | 25 | private onClear = () => { 26 | this.props.onChange(undefined) 27 | this.keyRecorder.clear() 28 | } 29 | 30 | private onKeyDown = (e: React.KeyboardEvent) => { 31 | e.preventDefault() 32 | this.keyRecorder.keyDown(e.nativeEvent as KeyboardEvent) 33 | this.props.onChange(this.keyRecorder.keyInput) 34 | } 35 | 36 | private onKeyUp = (e: React.KeyboardEvent) => { 37 | e.preventDefault() 38 | this.keyRecorder.keyUp(e.nativeEvent as KeyboardEvent) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/renderer/views/dialogs/DialogContainer.css: -------------------------------------------------------------------------------- 1 | .DialogContainer { 2 | background-color: var(--default-background-color); 3 | border: 1px solid var(--lighten-background-color); 4 | box-sizing: border-box; 5 | &_content { 6 | padding: 24px; 7 | display: flex; 8 | flex-direction: column; 9 | } 10 | &_buttons { 11 | margin-top: 12px; 12 | align-self: flex-end; 13 | display: flex; 14 | font-size: 14px; 15 | & > * { 16 | margin-left: 8px; 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/renderer/views/dialogs/DialogContainer.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import DialogTitleBar from '../components/DialogTitleBar' 3 | import './DialogContainer.css' 4 | 5 | interface DialogContainerProps { 6 | title: string 7 | okText: string 8 | canOK: boolean 9 | onOK: () => void 10 | onCancel: () => void 11 | } 12 | 13 | export default 14 | class DialogContainer extends React.Component { 15 | render() { 16 | return ( 17 |
18 | {process.platform === 'win32' ? : undefined} 19 |
20 | {this.props.children} 21 |
22 | 23 | 24 |
25 |
26 |
27 | ) 28 | } 29 | 30 | componentDidMount() { 31 | document.addEventListener('keyup', this.onKeyUp) 32 | } 33 | componentWillUnmount() { 34 | document.removeEventListener('keyup', this.onKeyUp) 35 | } 36 | 37 | onKeyUp = (e: KeyboardEvent) => { 38 | if (e.key === 'Enter') { 39 | this.props.onOK() 40 | } 41 | if (e.key === 'Escape') { 42 | this.props.onCancel() 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/renderer/views/dialogs/DialogIndex.tsx: -------------------------------------------------------------------------------- 1 | import {remote, ipcRenderer} from 'electron' 2 | import * as React from 'react' 3 | import * as ReactDOM from 'react-dom' 4 | import '../common.css' 5 | import NewPictureDialog from './NewPictureDialog' 6 | import ResolutionChangeDialog from './ResolutionChangeDialog' 7 | import ToolShortcutsDialog from './ToolShortcutsDialog' 8 | 9 | window.addEventListener('DOMContentLoaded', () => { 10 | const root = document.querySelector('.DialogRoot')! 11 | 12 | const onReadyShow = () => { 13 | const {width, height} = root.firstElementChild!.getBoundingClientRect() 14 | const win = remote.getCurrentWindow() 15 | win.setMenu(null as any) 16 | win.setContentSize(Math.round(width), Math.round(height)) 17 | win.center() 18 | setImmediate(() => { 19 | // delay window show to avoid flicker 20 | remote.getCurrentWindow().show() 21 | }) 22 | } 23 | const onDone = (result: any) => { 24 | remote.getCurrentWindow().hide() 25 | ipcRenderer.send('dialogDone', result) 26 | } 27 | 28 | ipcRenderer.on('dialogOpen', (ev: Electron.IpcMessageEvent, name: string, param: any) => { 29 | ReactDOM.unmountComponentAtNode(root) 30 | let dialog: JSX.Element|undefined 31 | switch (name) { 32 | case 'newPicture': 33 | dialog = 34 | break 35 | case 'resolutionChange': 36 | dialog = 37 | break 38 | case 'toolShortcuts': 39 | dialog = 40 | break 41 | } 42 | if (dialog) { 43 | ReactDOM.render(dialog, root) 44 | } 45 | }) 46 | }) 47 | -------------------------------------------------------------------------------- /src/renderer/views/dialogs/DialogLauncher.ts: -------------------------------------------------------------------------------- 1 | import {ipcRenderer} from 'electron' 2 | import IPCChannels from '../../../common/IPCChannels' 3 | import {PictureDimension} from '../../models/Picture' 4 | import {ToolShortcutsDialogData} from './ToolShortcutsDialog' 5 | 6 | export default 7 | class DialogLauncher { 8 | 9 | openNewPictureDialog() { 10 | return this.open('newPicture', undefined) 11 | } 12 | 13 | openResolutionChangeDialog(init: PictureDimension) { 14 | return this.open('resolutionChange', init) 15 | } 16 | 17 | openToolShortcutsDialog(init: ToolShortcutsDialogData) { 18 | return this.open('toolShortcuts', init) 19 | } 20 | 21 | async open(name: string, param: TParam): Promise { 22 | let callback: any 23 | const result = await new Promise((resolve, reject) => { 24 | callback = (e: Electron.IpcMessageEvent, result: TResult|undefined) => { 25 | resolve(result) 26 | } 27 | ipcRenderer.on(IPCChannels.dialogDone, callback) 28 | ipcRenderer.send(IPCChannels.dialogOpen, name, param) 29 | }) 30 | ipcRenderer.removeListener(IPCChannels.dialogDone, callback) 31 | return result 32 | } 33 | } 34 | 35 | export 36 | const dialogLauncher = new DialogLauncher() 37 | -------------------------------------------------------------------------------- /src/renderer/views/dialogs/NewPictureDialog.tsx: -------------------------------------------------------------------------------- 1 | import {observer} from 'mobx-react' 2 | import React = require('react') 3 | import {PictureDimension} from '../../models/Picture' 4 | import DimensionSelectViewModel from '../../viewmodels/DimensionSelectViewModel' 5 | import DimensionSelect from '../DimensionSelect' 6 | import DialogContainer from './DialogContainer' 7 | 8 | interface NewPictureDialogProps { 9 | onReadyShow: () => void 10 | onDone: (dimension?: PictureDimension) => void 11 | } 12 | 13 | @observer 14 | export default 15 | class NewPictureDialog extends React.Component { 16 | private dimensionSelectViewModel = new DimensionSelectViewModel() 17 | 18 | render() { 19 | return ( 20 | 21 | 22 | 23 | ) 24 | } 25 | 26 | componentDidMount() { 27 | this.props.onReadyShow() 28 | } 29 | 30 | private onCancel = () => { 31 | this.props.onDone() 32 | } 33 | 34 | private onOK = () => { 35 | this.props.onDone(this.dimensionSelectViewModel.dimension) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/renderer/views/dialogs/ResolutionChangeDialog.tsx: -------------------------------------------------------------------------------- 1 | import {observer} from 'mobx-react' 2 | import React = require('react') 3 | import {PictureDimension} from '../../models/Picture' 4 | import DimensionSelectViewModel from '../../viewmodels/DimensionSelectViewModel' 5 | import DimensionSelect from '../DimensionSelect' 6 | import DialogContainer from './DialogContainer' 7 | 8 | interface ResolutionChangeDialogProps { 9 | init: PictureDimension 10 | onReadyShow: () => void 11 | onDone: (dimension?: PictureDimension) => void 12 | } 13 | 14 | @observer 15 | export default 16 | class ResolutionChangeDialog extends React.Component { 17 | private dimensionSelectViewModel: DimensionSelectViewModel 18 | 19 | constructor(props: ResolutionChangeDialogProps) { 20 | super(props) 21 | this.dimensionSelectViewModel = new DimensionSelectViewModel(props.init) 22 | this.dimensionSelectViewModel.unit = 'percent' 23 | } 24 | 25 | render() { 26 | return ( 27 | 28 | 29 | 30 | ) 31 | } 32 | 33 | componentDidMount() { 34 | this.props.onReadyShow() 35 | } 36 | 37 | private onCancel = () => { 38 | this.props.onDone() 39 | } 40 | 41 | private onOK = () => { 42 | this.props.onDone(this.dimensionSelectViewModel.dimension) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/renderer/views/dialogs/ToolShortcutsDialog.tsx: -------------------------------------------------------------------------------- 1 | import {observable} from 'mobx' 2 | import {observer} from 'mobx-react' 3 | import React = require('react') 4 | import KeyInput, {KeyInputData} from '../../../lib/KeyInput' 5 | import ShortcutEdit from '../components/ShortcutEdit' 6 | import DialogContainer from './DialogContainer' 7 | 8 | export interface ToolShortcutsDialogData { 9 | noTemp?: boolean 10 | toggle: KeyInputData|undefined 11 | temp: KeyInputData|undefined 12 | } 13 | 14 | interface ToolShortcutsDialogProps { 15 | onReadyShow: () => void 16 | onDone: (data?: ToolShortcutsDialogData) => void 17 | init: ToolShortcutsDialogData 18 | } 19 | 20 | @observer 21 | export default 22 | class ToolShortcutsDialog extends React.Component { 23 | @observable private toggle: KeyInput|undefined 24 | @observable private temp: KeyInput|undefined 25 | 26 | constructor(props: ToolShortcutsDialogProps) { 27 | super(props) 28 | const {toggle, temp} = this.props.init 29 | this.toggle = toggle && KeyInput.fromData(toggle) 30 | this.temp = temp && KeyInput.fromData(temp) 31 | } 32 | 33 | render() { 34 | return ( 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 |
Toggle this.toggle = s} />
46 |
47 | ) 48 | } 49 | 50 | componentDidMount() { 51 | this.props.onReadyShow() 52 | } 53 | 54 | private onCancel = () => { 55 | this.props.onDone() 56 | } 57 | 58 | private onOK = () => { 59 | this.props.onDone({ 60 | toggle: this.toggle && this.toggle.toData(), 61 | temp: this.temp && this.temp.toData(), 62 | }) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/renderer/views/icons/check.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | check 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/renderer/views/icons/freehand-select.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/renderer/views/icons/polygon-select.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/renderer/views/icons/window-close.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | window-close 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/renderer/views/icons/window-maximize.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | window-maximize 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/renderer/views/icons/window-minimize.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | window-minimize 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/renderer/views/panels/BrushPresetsPanel.css: -------------------------------------------------------------------------------- 1 | .BrushPresetsPanel { 2 | background-color: var(--darken-background-color); 3 | flex: 1; 4 | overflow-y: auto; 5 | & .ReactDraggableTree { 6 | min-height: 100%; 7 | } 8 | & .ReactDraggableTree_row-selected { 9 | background-color: var(--lighten-background-color); 10 | } 11 | &-brushToolActive { 12 | & .ReactDraggableTree_row-selected { 13 | background-color: var(--ui-primary-background-color); 14 | } 15 | } 16 | } 17 | .BrushPresetItem { 18 | display: flex; 19 | align-items: center; 20 | flex: 1; 21 | & .ClickToEdit { 22 | flex: 1; 23 | margin: 0 8px; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/renderer/views/panels/ColorPanel.css: -------------------------------------------------------------------------------- 1 | .ColorPanel { 2 | display: flex; 3 | flex-direction: column; 4 | } -------------------------------------------------------------------------------- /src/renderer/views/panels/ColorPanel.tsx: -------------------------------------------------------------------------------- 1 | import {action} from 'mobx' 2 | import {observer} from 'mobx-react' 3 | import * as React from 'react' 4 | import {HSVColor} from '../../../lib/Color' 5 | import {appState} from '../../app/AppState' 6 | import ColorPicker from '../components/ColorPicker' 7 | import Palette from '../components/Palette' 8 | import RGBRangeSliders from '../components/RGBRangeSliders' 9 | import './ColorPanel.css' 10 | 11 | @observer 12 | export default 13 | class ColorPanel extends React.Component<{}, {}> { 14 | render() { 15 | const {color, paletteIndex, palette} = appState 16 | 17 | return ( 18 |
19 | 20 | 21 | 22 |
23 | ) 24 | } 25 | 26 | private onPaletteChange = action((e: React.MouseEvent, index: number) => { 27 | appState.paletteIndex = index 28 | if (e.shiftKey) { 29 | appState.palette[index] = appState.color 30 | } else { 31 | const color = appState.palette[index] 32 | if (color) { 33 | appState.color = color 34 | } 35 | } 36 | }) 37 | 38 | private onColorChange = action((value: HSVColor) => { 39 | appState.color = value 40 | }) 41 | } 42 | -------------------------------------------------------------------------------- /src/renderer/views/panels/LayerPanel.css: -------------------------------------------------------------------------------- 1 | .LayerPanel { 2 | display: flex; 3 | flex-direction: column; 4 | flex: 1; 5 | } 6 | .LayerPanel_scroll { 7 | flex: 1; 8 | background-color: var(--darken-background-color); 9 | overflow-y: auto; 10 | & .ReactDraggableTree { 11 | min-height: 100%; 12 | } 13 | & .ReactDraggableTree_row-selected { 14 | background-color: var(--ui-primary-background-color); 15 | } 16 | & .ReactDraggableTree_toggler:before { 17 | border-right-color: var(--default-color); 18 | border-bottom-color: var(--default-color); 19 | } 20 | } 21 | .LayerPanel_layer { 22 | height: 40px; 23 | padding: 4px; 24 | padding-left: 4px; 25 | display: flex; 26 | align-items: center; 27 | flex: 1; 28 | &-clipped { 29 | &:before { 30 | content: ""; 31 | width: 4px; 32 | height: 100%; 33 | margin-right: 8px; 34 | background-color: var(--ui-primary-background-color); 35 | } 36 | } 37 | } 38 | .LayerPanel_layer .ClickToEdit { 39 | flex: 1; 40 | margin-right: 4px; 41 | } 42 | .LayerPanel_layer img { 43 | margin-right: 12px; 44 | box-shadow: 0 0 4px rgba(0,0,0,0.25); 45 | max-width: 40px; 46 | max-height: 40px; 47 | } 48 | .LayerPanel_layer-current { 49 | background-color: grey; 50 | } 51 | .LayerPanel_buttons { 52 | margin: 4px 8px; 53 | & button { 54 | border: none; 55 | background: none; 56 | padding: 4px; 57 | } 58 | & .SVGIcon { 59 | width: 16px; 60 | height: 16px; 61 | } 62 | } 63 | .LayerDetail { 64 | margin: 8px; 65 | & > * { 66 | display: flex; 67 | align-items: center; 68 | margin: 4px 0; 69 | & > :nth-child(1) { 70 | width: 48px; 71 | } 72 | & > :nth-child(2) { 73 | flex: 1; 74 | } 75 | & > :nth-child(3) { 76 | width: 32px; 77 | text-align: right; 78 | } 79 | } 80 | } -------------------------------------------------------------------------------- /src/renderer/views/panels/NavigatorPanel.css: -------------------------------------------------------------------------------- 1 | .NavigatorPanel { 2 | font-size: 12px; 3 | &_minimap { 4 | width: 240px; 5 | height: 120px; 6 | } 7 | &_sliderRow { 8 | display: flex; 9 | align-items: center; 10 | margin: 0 4px 4px; 11 | } 12 | & .RangeSlider { 13 | --handle-size: 12px; 14 | --rail-height: 4px; 15 | width: 120px; 16 | height: var(--handle-size); 17 | margin: 0 10px; 18 | &_border { 19 | position: absolute; 20 | height: var(--rail-height); 21 | border: none; 22 | border-radius: 2px; 23 | top: calc((var(--handle-size) - var(--rail-height)) / 2); 24 | background-color: var(--ui-background-color); 25 | } 26 | &_fill { 27 | display: none; 28 | } 29 | &_text { 30 | display: none; 31 | } 32 | &_handle { 33 | width: var(--handle-size); 34 | height: var(--handle-size); 35 | border: none; 36 | border-radius: 50%; 37 | background-color: var(--default-color); 38 | top: 0px; 39 | left: calc(var(--slider-ratio) * 100% - var(--handle-size) / 2); 40 | } 41 | } 42 | & button { 43 | border: none; 44 | background: none; 45 | margin: 0; 46 | padding: 0; 47 | } 48 | & .SVGIcon { 49 | width: 16px; 50 | height: 16px; 51 | } 52 | &_reset { 53 | position: relative; 54 | width: 16px; 55 | height: 16px; 56 | &:before { 57 | content: ""; 58 | position: absolute; 59 | left: 6px; 60 | top: 6px; 61 | width: 4px; 62 | height: 4px; 63 | border-radius: 2px; 64 | background-color: var(--default-color); 65 | } 66 | } 67 | &_check { 68 | margin: 0 4px; 69 | display: flex; 70 | align-items: center; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/renderer/views/panels/ToolSettingsPanel.tsx: -------------------------------------------------------------------------------- 1 | import {observer} from 'mobx-react' 2 | import {toolManager} from '../../app/ToolManager' 3 | 4 | export default observer(() => { 5 | const {currentTool} = toolManager 6 | return currentTool.renderSettings() 7 | }) 8 | -------------------------------------------------------------------------------- /src/renderer/views/preferences/Preferences.css: -------------------------------------------------------------------------------- 1 | .Preferences { 2 | width: 100vw; 3 | height: 100vh; 4 | background-color: var(--default-background-color); 5 | border: 1px solid var(--lighten-background-color); 6 | box-sizing: border-box; 7 | } 8 | .PreferencesTabBar { 9 | display: flex; 10 | padding: 16px; 11 | padding-bottom: 0; 12 | } 13 | .PreferencesTab { 14 | height: 24px; 15 | line-height: 24px; 16 | padding: 0 16px; 17 | border-radius: 12px; 18 | &-selected { 19 | background-color: var(--ui-primary-background-color); 20 | } 21 | } 22 | .PreferencesPane { 23 | width: 100%; 24 | padding: 16px; 25 | box-sizing: border-box; 26 | } 27 | .PreferencesRow { 28 | display: flex; 29 | align-items: center; 30 | & > *:nth-child(1) { 31 | flex: 1; 32 | } 33 | & > *:nth-child(2) { 34 | display: flex; 35 | align-items: center; 36 | & > * { 37 | margin-left: 8px; 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/renderer/views/preferences/Preferences.tsx: -------------------------------------------------------------------------------- 1 | import {observer} from 'mobx-react' 2 | import * as React from 'react' 3 | import PreferencesViewModel from '../../viewmodels/PreferencesViewModel' 4 | import DialogTitleBar from '../components/DialogTitleBar' 5 | import './Preferences.css' 6 | 7 | @observer 8 | export default 9 | class Preferences extends React.Component<{}, {}> { 10 | viewModel = new PreferencesViewModel() 11 | 12 | render() { 13 | const {undoGroupingInterval} = this.viewModel 14 | 15 | const onUndoGroupingIntervalChange = (e: React.FormEvent) => { 16 | const value = parseInt((e.target as HTMLInputElement).value) 17 | this.viewModel.undoGroupingInterval = value 18 | this.viewModel.notifyChange() 19 | } 20 | 21 | return ( 22 |
23 | 24 |
25 |
26 | General 27 |
28 |
29 |
30 |
31 |
Undo Grouping Interval
32 |
33 | 34 |
milliseconds
35 |
36 |
37 |
38 |
39 | ) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/renderer/views/preferences/PreferencesIndex.tsx: -------------------------------------------------------------------------------- 1 | import {ipcRenderer} from 'electron' 2 | import * as React from 'react' 3 | import * as ReactDOM from 'react-dom' 4 | import IPCChannels from '../../../common/IPCChannels' 5 | import {PreferencesData} from '../../viewmodels/PreferencesViewModel' 6 | import '../common.css' 7 | import Preferences from './Preferences' 8 | 9 | window.addEventListener('DOMContentLoaded', () => { 10 | const root = document.querySelector('.PreferencesRoot')! 11 | let preferences: Preferences 12 | ReactDOM.render( preferences = e!} />, root) 13 | ipcRenderer.on(IPCChannels.preferencesOpen, (ev: Electron.IpcMessageEvent, data: PreferencesData) => { 14 | preferences.viewModel.setData(data) 15 | }) 16 | }) 17 | -------------------------------------------------------------------------------- /src/renderer/views/preferences/PreferencesLauncher.ts: -------------------------------------------------------------------------------- 1 | import {ipcRenderer} from 'electron' 2 | import IPCChannels from '../../../common/IPCChannels' 3 | import {appState} from '../../app/AppState' 4 | import {PreferencesData} from '../../viewmodels/PreferencesViewModel' 5 | 6 | export default 7 | class PreferencesLauncher { 8 | constructor() { 9 | ipcRenderer.on(IPCChannels.preferencesChange, (e: Electron.IpcMessageEvent, data: PreferencesData) => { 10 | appState.undoGroupingInterval = data.undoGroupingInterval 11 | }) 12 | } 13 | 14 | open() { 15 | const {undoGroupingInterval} = appState 16 | const data: PreferencesData = { 17 | undoGroupingInterval 18 | } 19 | ipcRenderer.send(IPCChannels.preferencesOpen, data) 20 | } 21 | } 22 | 23 | export const preferencesLauncher = new PreferencesLauncher() 24 | -------------------------------------------------------------------------------- /src/renderer/views/util.ts: -------------------------------------------------------------------------------- 1 | export 2 | function isTextInput(elem: Element) { 3 | if (elem instanceof HTMLTextAreaElement) { 4 | return true 5 | } 6 | if (elem instanceof HTMLInputElement) { 7 | const inputTypes = ['text', 'password', 'number', 'email', 'url', 'search', 'date', 'datetime', 'datetime-local', 'time', 'month', 'week'] 8 | return inputTypes.indexOf(elem.type) >= 0 9 | } 10 | return false 11 | } 12 | -------------------------------------------------------------------------------- /src/test/index.js: -------------------------------------------------------------------------------- 1 | const context = require.context('mocha-loader!./', true, /Test\.ts$/) 2 | context.keys().forEach(context) 3 | -------------------------------------------------------------------------------- /src/test/models/PictureTest.ts: -------------------------------------------------------------------------------- 1 | import * as assert from 'power-assert' 2 | import Picture from '../../renderer/models/Picture' 3 | 4 | describe('Picture', () => { 5 | let dimension = {width: 1000, height: 2000, dpi: 72} 6 | let picture: Picture 7 | beforeEach(() => { 8 | picture = new Picture(dimension) 9 | }) 10 | 11 | describe('#size', () => { 12 | it('returns width and height of picture', () => { 13 | assert(picture.size.width === dimension.width) 14 | assert(picture.size.height === dimension.height) 15 | }) 16 | }) 17 | 18 | describe('#rect', () => { 19 | it('returns rwectangle with (0, 0) top-left and picture size', () => { 20 | assert(picture.rect.left === 0) 21 | assert(picture.rect.top === 0) 22 | assert(picture.rect.width === dimension.width) 23 | assert(picture.rect.height === dimension.height) 24 | }) 25 | }) 26 | 27 | describe('#fileName', () => { 28 | it('returns \'Untitled\' when filePath is not set', () => { 29 | assert(picture.fileName === 'Untitled') 30 | }) 31 | it('returns basename of filePath', () => { 32 | picture.filePath = '/foo/bar/baz.azurite' 33 | assert(picture.fileName === 'baz.azurite') 34 | }) 35 | }) 36 | }) 37 | -------------------------------------------------------------------------------- /src/test/services/PictureExportTest.ts: -------------------------------------------------------------------------------- 1 | import {remote} from 'electron' 2 | import {Vec2} from 'paintvec' 3 | import * as path from 'path' 4 | import * as assert from 'power-assert' 5 | import {formatRegistry} from '../../renderer/app/FormatRegistry' 6 | import '../../renderer/formats/PictureFormatCanvasImage' 7 | import {ImageLayer} from '../../renderer/models/Layer' 8 | import Picture from '../../renderer/models/Picture' 9 | import {PictureExport} from '../../renderer/services/PictureExport' 10 | import TestPattern from '../util/TestPattern' 11 | 12 | const tempPath = remote.app.getPath('temp') 13 | 14 | describe('PictureExport', () => { 15 | describe('import/export', () => { 16 | it('imports/exports image', async () => { 17 | const testPattern = new TestPattern() 18 | 19 | const picture1 = new Picture({width: 1000, height: 2000, dpi: 72}) 20 | 21 | const layer = new ImageLayer(picture1, {name: 'Layer'}) 22 | layer.tiledTexture.putImage(new Vec2(), testPattern.canvas) 23 | 24 | picture1.rootLayer.children.push(layer) 25 | 26 | const filePath = path.join(tempPath, 'test-export.png') 27 | 28 | const pictureExport1 = new PictureExport(picture1) 29 | const format = formatRegistry.pictureFormatForExtension('png')! 30 | await pictureExport1.export(filePath, format) 31 | pictureExport1.dispose() 32 | 33 | const picture2 = new Picture({width: 1000, height: 2000, dpi: 72}) 34 | const pictureExport2 = new PictureExport(picture2) 35 | await pictureExport2.import([filePath]) 36 | pictureExport2.dispose() 37 | 38 | const children2 = picture2.rootLayer.children 39 | assert(children2.length === 1) 40 | const layer2 = children2[0] as ImageLayer 41 | assert(layer2.name === 'test-export') 42 | assert(layer2.tiledTexture.keys().length > 0) 43 | testPattern.assert(layer2.tiledTexture) 44 | 45 | picture1.dispose() 46 | picture2.dispose() 47 | }) 48 | }) 49 | }) 50 | -------------------------------------------------------------------------------- /src/test/util/TestPattern.ts: -------------------------------------------------------------------------------- 1 | import {Color} from 'paintgl' 2 | import {Vec2} from 'paintvec' 3 | import * as assert from 'power-assert' 4 | import TiledTexture from '../../renderer/models/TiledTexture' 5 | 6 | export default 7 | class TestPattern { 8 | canvas = document.createElement('canvas') 9 | 10 | constructor() { 11 | const {canvas} = this 12 | canvas.width = 100 13 | canvas.height = 200 14 | const context = canvas.getContext('2d')! 15 | context.fillStyle = 'red' 16 | context.fillRect(0, 0, canvas.width, canvas.height) 17 | context.fillStyle = 'blue' 18 | context.fillRect(10, 20, 30, 40) 19 | } 20 | 21 | assert(tiledTexture: TiledTexture, offset = new Vec2()) { 22 | assert.deepEqual(tiledTexture.colorAt(new Vec2(5, 5).add(offset)), new Color(1, 0, 0, 1)) 23 | assert.deepEqual(tiledTexture.colorAt(new Vec2(15, 30).add(offset)), new Color(0, 0, 1, 1)) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es6", 5 | "experimentalDecorators": true, 6 | "noImplicitAny": true, 7 | "noUnusedLocals": true, 8 | "suppressImplicitAnyIndexErrors": true, 9 | "strictNullChecks": true, 10 | "sourceMap": true, 11 | "jsx": "react", 12 | "lib" :[ 13 | "es2016", 14 | "dom" 15 | ], 16 | "types": [ 17 | "electron", 18 | "mocha", 19 | "webpack-env" 20 | ] 21 | }, 22 | "exclude": [ 23 | "node_modules", 24 | "build" 25 | ], 26 | "compileOnSave": false 27 | } 28 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "class-name": true, 4 | "comment-format": [ 5 | true, 6 | "check-space" 7 | ], 8 | "indent": [ 9 | true, 10 | "spaces" 11 | ], 12 | "no-eval": true, 13 | "no-internal-module": true, 14 | "no-trailing-whitespace": true, 15 | "no-unsafe-finally": true, 16 | "no-var-keyword": true, 17 | "one-line": [ 18 | true, 19 | "check-open-brace", 20 | "check-whitespace" 21 | ], 22 | "quotemark": [ 23 | true, 24 | "single" 25 | ], 26 | "semicolon": [ 27 | true, 28 | "never" 29 | ], 30 | "triple-equals": [ 31 | true, 32 | "allow-null-check", 33 | "allow-undefined-check" 34 | ], 35 | "typedef-whitespace": [ 36 | true, 37 | { 38 | "call-signature": "nospace", 39 | "index-signature": "nospace", 40 | "parameter": "nospace", 41 | "property-declaration": "nospace", 42 | "variable-declaration": "nospace" 43 | } 44 | ], 45 | "variable-name": [ 46 | true, 47 | "ban-keywords" 48 | ], 49 | "whitespace": [ 50 | true, 51 | "check-branch", 52 | "check-decl", 53 | "check-operator", 54 | "check-separator", 55 | "check-type" 56 | ], 57 | "ordered-imports": [ 58 | true, 59 | { 60 | "import-sources-order": "case-insensitive", 61 | "named-imports-order": "any" 62 | } 63 | ] 64 | } 65 | } -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path") 2 | const webpack = require("webpack") 3 | const {CheckerPlugin} = require('awesome-typescript-loader') 4 | const commonEntries = ["./src/renderer/requireManualResolve.ts"] 5 | 6 | module.exports = { 7 | entry: { 8 | renderer: [...commonEntries, "./src/renderer/index.ts"], 9 | dialogs: [...commonEntries, "./src/renderer/views/dialogs/DialogIndex.tsx"], 10 | preferences: [...commonEntries, "./src/renderer/views/preferences/PreferencesIndex.tsx"], 11 | test: [...commonEntries, "./src/test/index.js"], 12 | }, 13 | output: { 14 | path: path.resolve(__dirname, "./dist/assets"), 15 | publicPath: "/assets/", 16 | filename: '[name].js', 17 | }, 18 | target: "electron-renderer", 19 | node: { 20 | __filename: false, 21 | __dirname: false, 22 | }, 23 | externals: { 24 | "glslify": "undefined", // glslify will be transformed with babel-plugin-glslify so don't have to be required 25 | "nbind": "requireManualResolve('nbind')", 26 | "keyboard-layout": "requireManualResolve('keyboard-layout')", 27 | }, 28 | resolve: { 29 | extensions: [".ts", ".tsx", ".js"], 30 | }, 31 | module: { 32 | loaders: [ 33 | { 34 | test: /\.json$/, 35 | use: 'json-loader', 36 | }, 37 | { 38 | test: /\.tsx?$/, 39 | exclude: /Test\.tsx?$/, 40 | use: { 41 | loader: 'awesome-typescript-loader', 42 | options: { 43 | useBabel: true, 44 | babelOptions: { 45 | plugins: ["glslify"] 46 | }, 47 | useCache: true 48 | } 49 | } 50 | }, 51 | { 52 | test: /Test\.tsx?$/, 53 | use: { 54 | loader: 'awesome-typescript-loader', 55 | options: { 56 | useBabel: true, 57 | babelOptions: { 58 | plugins: ["espower"] 59 | }, 60 | useCache: true 61 | } 62 | } 63 | }, 64 | { 65 | test: /\.css$/, 66 | use: [ 67 | 'style-loader', 68 | 'css-loader?importLoaders=1!', 69 | { 70 | loader: 'postcss-loader', 71 | options: { 72 | plugins: () => { 73 | return [ 74 | require('postcss-import'), 75 | require('postcss-url'), 76 | require('postcss-cssnext')({ 77 | features: { 78 | customProperties: false, 79 | }, 80 | }), 81 | ]; 82 | } 83 | } 84 | }, 85 | ], 86 | }, 87 | { 88 | test: /\.(jpg|png|woff|woff2|eot|ttf|svg)/, 89 | use: [ 90 | 'url-loader?limit=10000', 91 | ], 92 | }, 93 | ], 94 | }, 95 | plugins: [ 96 | new webpack.NamedModulesPlugin(), 97 | require("webpack-fail-plugin"), 98 | ], 99 | devtool: "inline-source-map", 100 | devServer: { 101 | contentBase: './dist', 102 | port: 23000, 103 | inline: true, 104 | }, 105 | } 106 | -------------------------------------------------------------------------------- /webpack.config.main.js: -------------------------------------------------------------------------------- 1 | const path = require("path") 2 | 3 | module.exports = { 4 | entry: "./src/main/index.ts", 5 | output: { 6 | path: path.resolve(__dirname, "./dist/assets"), 7 | filename: 'main.js', 8 | libraryTarget: "commonjs", 9 | }, 10 | target: "electron", 11 | node: { 12 | __filename: false, 13 | __dirname: false, 14 | }, 15 | externals: { 16 | "receive-tablet-event": true, 17 | "electron-devtools-installer": true, 18 | "nbind": true, 19 | }, 20 | resolve: { 21 | extensions: [".ts", ".js"], 22 | }, 23 | module: { 24 | loaders: [ 25 | { 26 | test: /\.ts$/, 27 | use: "awesome-typescript-loader", 28 | }, 29 | ], 30 | }, 31 | plugins: [ 32 | require("webpack-fail-plugin"), 33 | ], 34 | } 35 | --------------------------------------------------------------------------------