├── .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 | [](https://circleci.com/gh/sketchglass/azurite)
6 |
7 | 
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 |
37 |
--------------------------------------------------------------------------------
/bundles/icons/flaticon/svg/crop.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
44 |
--------------------------------------------------------------------------------
/bundles/icons/flaticon/svg/eraser.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
41 |
--------------------------------------------------------------------------------
/bundles/icons/flaticon/svg/folder-2.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
38 |
--------------------------------------------------------------------------------
/bundles/icons/flaticon/svg/magic-wand.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
51 |
--------------------------------------------------------------------------------
/bundles/icons/flaticon/svg/move.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
50 |
--------------------------------------------------------------------------------
/bundles/icons/flaticon/svg/multiply.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
38 |
--------------------------------------------------------------------------------
/bundles/icons/flaticon/svg/paint-brush-1.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
46 |
--------------------------------------------------------------------------------
/bundles/icons/flaticon/svg/paint-brush.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
42 |
--------------------------------------------------------------------------------
/bundles/icons/flaticon/svg/paint-brushes.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
45 |
--------------------------------------------------------------------------------
/bundles/icons/flaticon/svg/pen-1.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
44 |
--------------------------------------------------------------------------------
/bundles/icons/flaticon/svg/pen.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
45 |
--------------------------------------------------------------------------------
/bundles/icons/flaticon/svg/rotate.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
44 |
--------------------------------------------------------------------------------
/bundles/icons/flaticon/svg/search.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
40 |
--------------------------------------------------------------------------------
/bundles/icons/flaticon/svg/subtract.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
37 |
--------------------------------------------------------------------------------
/bundles/icons/flaticon/svg/zoom-in.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
44 |
--------------------------------------------------------------------------------
/bundles/icons/flaticon/svg/zoom-out.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
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 |
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 | Tolerance |
18 | |
19 |
20 |
21 |
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 |
33 |
41 |
45 |
51 |
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 | {
46 | tools.map((tool, i) => {
47 | const selected = tool === currentTool
48 | const className = classNames('ToolSelection_button', {'ToolSelection_button-selected': selected})
49 | const onClick = () => this.onChange(tool)
50 | const onContextMenu = (e: React.MouseEvent) => this.onContextMenu(e, tool)
51 | return
52 | })
53 | }
54 |
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 |
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 |
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 | Toggle |
39 | this.toggle = s} /> |
40 |
41 |
42 | Temporary |
43 | this.temp = s} /> |
44 |
45 |
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 |
--------------------------------------------------------------------------------
/src/renderer/views/icons/freehand-select.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
22 |
--------------------------------------------------------------------------------
/src/renderer/views/icons/polygon-select.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
21 |
--------------------------------------------------------------------------------
/src/renderer/views/icons/window-close.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/src/renderer/views/icons/window-maximize.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/src/renderer/views/icons/window-minimize.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/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 |
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 |
--------------------------------------------------------------------------------