├── .githooks └── pre-commit ├── .github └── workflows │ └── release.yml ├── .gitignore ├── LICENSE ├── README.md ├── bin └── mumemo.js ├── build └── entitlements.mac.plist ├── docs ├── focus-area.md └── resources │ ├── _debug-step1.png │ ├── _debug-step2.png │ ├── _debug-step3.png │ ├── _debug-step4.png │ ├── _debug-step5.png │ └── mumemo.jpg ├── package.json ├── src ├── main │ ├── Config.ts │ ├── Deferred.ts │ ├── PreviewBrowser.ts │ ├── app.ts │ ├── detect.ts │ ├── index.ts │ ├── macos │ │ ├── Clipboard.ts │ │ └── sendKeyStroke.ts │ ├── resize.ts │ └── timeout.ts └── renderer │ ├── index.css │ └── index.ts ├── static ├── tray.png └── tray@2x.png ├── tools ├── create-memo.ts └── tsconfig.json ├── tsconfig.json └── yarn.lock /.githooks/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | npx --no-install lint-staged 3 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | matrix: 10 | os: [macOS-latest] 11 | 12 | steps: 13 | - uses: actions/checkout@v2 14 | with: 15 | fetch-depth: 1 16 | - name: Use Node.js 17 | uses: actions/setup-node@v2 18 | with: 19 | node-version: 14.x 20 | - name: yarn install 21 | run: | 22 | yarn install 23 | - name: Publish 24 | run: | 25 | yarn run dist 26 | env: 27 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 28 | - name: Cleanup artifacts 29 | run: | 30 | npx del-cli "dist/!(*.exe|*.deb|*.AppImage|*.dmg)" 31 | - name: Upload artifacts 32 | uses: actions/upload-artifact@v1 33 | with: 34 | name: ${{ matrix.os }} 35 | path: dist 36 | - name: Release 37 | uses: softprops/action-gh-release@v1 38 | if: startsWith(github.ref, 'refs/tags/') 39 | with: 40 | files: "dist/**" 41 | env: 42 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | tmp/ 2 | .DS_Store 3 | dist/ 4 | node_modules/ 5 | thumbs.db 6 | .idea/ 7 | *.log 8 | 9 | src/**/*.js 10 | src/**/*.map 11 | tools/**/*.js 12 | tools/**/*.map 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 azu 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mumemo 2 | 3 | ![mumemo](docs/resources/mumemo.jpg) 4 | 5 | Mumemo is screenshot-driven note application. 6 | Mumemo is also No-UI(User Interaction) note application. 7 | 8 | ## Features 9 | 10 | - Screenshot **focus area** automatically and add note about it 11 | - Support No-UI(User Interaction) mode 12 | 13 | mumemo decide **focus area** according to followings: 14 | 15 | - Cursor point 16 | - Highlight ares like sections in the screenshot 17 | - mumemo use [OpenCV.js](https://docs.opencv.org/3.4/d5/d10/tutorial_js_root.html) 18 | 19 | Example of **focus area** workflow(`DEBUG:true`): 20 | 21 | | Input | Step 2 | Step 3 | Step 4 | Output | 22 | | ---- | ---- | ---- | ---- | ---- | 23 | | ![input image](docs/resources/_debug-step1.png) | ![step2](docs/resources/_debug-step2.png) | ![step3](docs/resources/_debug-step3.png) | ![step4](docs/resources/_debug-step4.png) | ![output image](docs/resources/_debug-step5.png) | 24 | 25 | > Screenshot from 26 | 27 | ## Supports 28 | 29 | - [x] macOS 30 | - [ ] [cross platform support #6](https://github.com/azu/mumemo/issues/6) 31 | 32 | ## Installation 33 | 34 | > https://github.com/azu/mumemo/releases/latest 35 | 36 | 1. Download a binary from [the latest releases](https://github.com/azu/mumemo/releases/latest) 37 | 2. Install app 38 | 39 | :warning: This app is not signed. So, OS show warning about it. 40 | 41 | Additional installation steps on macOS: 42 | 43 | 1. Select `mumemo.app` 44 | 2. Open context menu and Click "Open" 45 | 46 | :warning: require permission on macOS. 47 | Open the app, and you need to add permission for mumemo.app 48 | 49 | - **Accessibility** 50 | - use accessibility permission to get `activeWindow` object 51 | - `activeWindow` includes active app info like bundle.id, url, title. 52 | - **Screen Recording** 53 | - use Screen Recording permission to get screenshot 54 | 55 | ## Usage 56 | 57 | ### 1. Setup 58 | 59 | 1. Setup **output directory** 60 | 61 | This app writes Markdown note(`README.md`) and screenshots into the output directory. 62 | 63 | ### 2. Start to note via Global Shortcut 64 | 65 | :memo: You need to allow mumemo.app to access "Accessibility" and "Screen recording" on macOS's Privacy options 66 | This app requires the permission for get active window information. 67 | 68 | 1. Press CommandOrControl+Shift+X (It can be customized by `mumemo.config.js`) 69 | 2. Capture **focus area** and show note window 70 | 71 | 3. Note your memo into the window 72 | 4. Save it 73 | 74 | The app writes the input memo and captured image into **output directory**. 75 | 76 | ## Configuration 77 | 78 | You can customize key config and others by `~/.config/mumemo/mumemo.config.js`. 79 | 80 | - `shortcutKey`: shortcut key for launch 81 | - See also the key syntax: 82 | - `create()`: This function create config and return it 83 | - This function is called when shortcut key was pressed 84 | 85 | ```js 86 | module.exports.shortcutKey = "CommandOrControl+Shift+M" 87 | /** 88 | * app is electron app 89 | * path is Node's path module 90 | * activeWindow is https://github.com/sindresorhus/active-win result 91 | **/ 92 | module.exports.create = ({ app, path, activeWindow }) => { 93 | return { 94 | autoFocus: true, 95 | autoSave: true, 96 | autoSaveTimeoutMs: 5 * 1000, 97 | // DEBUG, 98 | DEBUG: false 99 | }; 100 | } 101 | ``` 102 | 103 | `UserConfig` inteface is following. 104 | 105 | ```ts 106 | { 107 | /** 108 | * Enable debug mode 109 | * Default: false 110 | */ 111 | DEBUG: boolean; 112 | /** 113 | * Output dir path 114 | * if set the path, use the path instead of stored path 115 | * Default: use stored path 116 | */ 117 | outputDir?: string; 118 | /** 119 | * Output content file name 120 | * Default: README.md 121 | */ 122 | outputContentFileName: string; 123 | /** 124 | * Output image directory prefix 125 | * If you want to put images to same dir with README.md, set "." 126 | * Default: img/ 127 | */ 128 | outputImageDirPrefix: string; 129 | /** 130 | * format input by this function and append the result 131 | * Default: for markdown 132 | */ 133 | outputContentTemplate: (args: OutputContentTemplateArgs) => string; 134 | /** 135 | * Auto focus when open input window 136 | * Default: true 137 | */ 138 | autoFocus: boolean; 139 | /** 140 | * Save content automatically without no focus the input window after autoSaveTimeoutMs 141 | * Default: true 142 | */ 143 | autoSave: boolean; 144 | /** 145 | * config for autosave 146 | * Default: 30 * 1000 147 | */ 148 | autoSaveTimeoutMs: number; 149 | /** 150 | * if quoteFrom is clipboard, quote text from clipboard 151 | * if quoteFrom is selectedText, quote text from selected text 152 | * Default: "selectedText" 153 | */ 154 | quoteFrom: "clipboard" | "selectedText"; 155 | /** 156 | * Send key stroke when ready to input window 157 | * Note: macOS only 158 | */ 159 | sendKeyStrokeWhenReadyInputWindow?: { 160 | key: string; 161 | shift?: boolean; 162 | control?: boolean; 163 | option?: boolean; 164 | command?: boolean; 165 | }; 166 | /** 167 | * bound ratio for screenshot 168 | * Increase actual focus area using this ratio. 169 | * Default: 1.2 170 | */ 171 | screenshotBoundRatio: number; 172 | /** 173 | * Max search count for related content that is included into screenshot result 174 | * The higher the number, screenshot size is large. 175 | * Default: 5 176 | */ 177 | screenshotSearchRectangleMaxCount: number; 178 | 179 | /** 180 | * if the rectangle count is over, mumemo give up to create focus image. 181 | * Just use screenshot image instead of focus image 182 | * Default: 80 183 | */ 184 | screenshotGiveUpRectangleMaxCount: number; 185 | 186 | /** 187 | * if the factor value is defined, mumemo resize screenshot image with the factor. 188 | * Retina display's the factor value is 2. 189 | * display size * factor value is the result of screenshot image size. 190 | * if you want to resize the screeenshot image size, set `1` to `screenshotResizeFactor` 191 | * Default: displayFactor's value 192 | */ 193 | screenshotResizeFactor?: number; 194 | } 195 | ``` 196 | 197 | For more details, see [src/main/Config.ts](src/main/Config.ts) 198 | 199 | ## Recipes 200 | 201 | ### No-UI notes 202 | 203 | `mumemo` works with No-UI(User Integration). 204 | 205 | The combination of `autoFocus: false` and `autoSave: true` that allow to save without user interaction. 206 | 207 | 1. Press shortcut 208 | 2. Preview the result in popup window 209 | - You can add a note if you want 210 | 3. Close the window and save it after 3 seconds 211 | 212 | `~/.config/mumemo/mumemo.config.js`: 213 | 214 | ```js 215 | module.exports.shortcutKey = "CommandOrControl+Shift+X" 216 | module.exports.create = ({ app, path }) => { 217 | return { 218 | autoFocus: false, 219 | autoSave: true, 220 | autoSaveTimeoutMs: 3 * 1000, 221 | }; 222 | } 223 | ``` 224 | 225 | ### Change behavior by each app 226 | 227 | You can change config by each app. 228 | 229 | ```ts 230 | module.exports.create = ({ app, path, activeWindow }) => { 231 | // Note: macOS's activeWindow has owner.bundleId 232 | const isKindle = activeWindow?.owner?.bundleId?.includes("Kindle") 233 | return { 234 | autoFocus: true, 235 | autoSave: true, 236 | quoteFrom: isKindle ? "clipboard" : "selectedText" 237 | }; 238 | } 239 | ``` 240 | 241 | ### Resize screenshot size by screen size 242 | 243 | When you use 4K display, resize screenshot size to 1/2. 244 | 245 | ```ts 246 | module.exports.create = ({ app, path, activeWindow }) => { 247 | return { 248 | // If you use 4K display, resize 1/2 screenshot size 249 | // 4K display will be 2 by default. 250 | // so screenshotResizeFactor is `1` that equal to 1/2 size. 251 | screenshotResizeFactor: activeWindow?.bounds?.width >= 2560 ? 1 : undefined 252 | }; 253 | } 254 | ``` 255 | 256 | ## Motivation 257 | 258 | I've liked to write note and capture the screenshot during reading a book. 259 | 260 | This behavior take two steps. 261 | 262 | 1. Capture the screenshot 263 | 2. Go to another note application like [OneNote](https://www.onenote.com/) and paste it 264 | 3. Add a note about the screenshot(page) 265 | 4. Back to viewer application 266 | 267 | `mumemo` reduce the steps. 268 | 269 | 1. Press key -> Capture the screenshot and save it 270 | 2. [Options] Add a note if I want 271 | 272 | 273 | ## Debug 274 | 275 | mumemo output debug log using [electron-log](https://www.npmjs.com/package/electron-log) 276 | 277 | on Linux: ~/.config/{app name}/logs/{process type}.log 278 | on macOS: ~/Library/Logs/{app name}/{process type}.log 279 | on Windows: %USERPROFILE%\AppData\Roaming\{app name}\logs\{process type}.log 280 | 281 | Tail logging 282 | 283 | ``` 284 | $ tail -F ~/Library/Logs/mumemo/main.log 285 | ``` 286 | 287 | ## Developing 288 | 289 | Debug 290 | 291 | yarn install 292 | yarn dev 293 | 294 | Build 295 | 296 | yarn dist 297 | 298 | Release 299 | 300 | npm version {patch,minor,major} 301 | git push --tags 302 | 303 | ## Contributing 304 | 305 | 1. Fork it! 306 | 2. Create your feature branch: `git checkout -b my-new-feature` 307 | 3. Commit your changes: `git commit -am 'Add some feature'` 308 | 4. Push to the branch: `git push origin my-new-feature` 309 | 5. Submit a pull request :D 310 | 311 | ## License 312 | 313 | MIT 314 | -------------------------------------------------------------------------------- /bin/mumemo.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | var os = require("os").type(); 3 | 4 | var electron = /electron/i.test(process.argv[0]); 5 | if (electron) { 6 | require("../"); 7 | } 8 | 9 | if (!electron) { 10 | if ( 11 | os === "Windows_NT" && 12 | process.argv[2] === "init" && 13 | process.versions.node[0] < 4 && 14 | process.versions.node[1] < 1 15 | ) { 16 | require("../"); 17 | } else { 18 | var spawn = require("child_process").spawn; 19 | spawn(require("electron"), [__filename].concat(process.argv.slice(2)), { 20 | stdio: [0, 1, 2] 21 | }).on("close", function (code) { 22 | process.exit(code); 23 | }); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /build/entitlements.mac.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.cs.allow-unsigned-executable-memory 6 | 7 | com.apple.security.automation.apple-events 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /docs/focus-area.md: -------------------------------------------------------------------------------- 1 | ## Focus Area Algorithm 2 | -------------------------------------------------------------------------------- /docs/resources/_debug-step1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/azu/mumemo/5fff74ba7030095ffb28f11e11bea766a4fc419a/docs/resources/_debug-step1.png -------------------------------------------------------------------------------- /docs/resources/_debug-step2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/azu/mumemo/5fff74ba7030095ffb28f11e11bea766a4fc419a/docs/resources/_debug-step2.png -------------------------------------------------------------------------------- /docs/resources/_debug-step3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/azu/mumemo/5fff74ba7030095ffb28f11e11bea766a4fc419a/docs/resources/_debug-step3.png -------------------------------------------------------------------------------- /docs/resources/_debug-step4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/azu/mumemo/5fff74ba7030095ffb28f11e11bea766a4fc419a/docs/resources/_debug-step4.png -------------------------------------------------------------------------------- /docs/resources/_debug-step5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/azu/mumemo/5fff74ba7030095ffb28f11e11bea766a4fc419a/docs/resources/_debug-step5.png -------------------------------------------------------------------------------- /docs/resources/mumemo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/azu/mumemo/5fff74ba7030095ffb28f11e11bea766a4fc419a/docs/resources/mumemo.jpg -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "mumemo", 4 | "version": "1.0.1", 5 | "license": "MIT", 6 | "main": "./dist/main/main.js", 7 | "scripts": { 8 | "dev": "electron-webpack dev", 9 | "compile": "electron-webpack", 10 | "dist": "yarn compile && electron-builder", 11 | "dist:dir": "yarn dist --dir -c.compression=store -c.mac.identity=null", 12 | "format": "prettier --write \"**/*.{js,jsx,ts,tsx,css}\"", 13 | "prepare": "git config --local core.hooksPath .githooks" 14 | }, 15 | "build": { 16 | "productName": "mumemo", 17 | "appId": "info.efcl.mumemo", 18 | "asar": true, 19 | "npmRebuild": false, 20 | "mac": { 21 | "hardenedRuntime": true, 22 | "gatekeeperAssess": false, 23 | "entitlements": "build/entitlements.mac.plist", 24 | "entitlementsInherit": "build/entitlements.mac.plist", 25 | "extendInfo": { 26 | "NSAppleEventsUsageDescription": "Please allow access to script browser applications to detect the current URL when triggering instant lookup." 27 | } 28 | } 29 | }, 30 | "electronWebpack": { 31 | "whiteListedModules": [ 32 | "codemirror" 33 | ] 34 | }, 35 | "dependencies": { 36 | "@electron/remote": "^2.0.8", 37 | "@jxa/global-type": "^1.3.4", 38 | "@jxa/run": "^1.3.4", 39 | "abort-controller": "^3.0.0", 40 | "active-win": "^7.6.1", 41 | "codemirror": "^5.58.2", 42 | "dayjs": "^1.9.6", 43 | "electron-log": "^4.4.7", 44 | "electron-store": "^8.0.2", 45 | "electron-win-state": "^1.1.22", 46 | "execa": "^4.1.0", 47 | "flatbush": "^3.3.0", 48 | "gfm-escape": "^0.1.8", 49 | "jimp": "^0.16.1", 50 | "markdown-escapes": "^1.0.4", 51 | "minimist": "^1.2.5", 52 | "opencv-wasm": "4.3.0-0.3.0", 53 | "pureimage": "^0.3.8", 54 | "sanitize-filename": "^1.6.3", 55 | "shortid": "^2.2.15", 56 | "source-map-support": "^0.5.19", 57 | "tmp": "^0.2.1" 58 | }, 59 | "devDependencies": { 60 | "@jxa/types": "^1.3.4", 61 | "@types/codemirror": "^5.60.5", 62 | "@types/flatbush": "^3.3.0", 63 | "@types/jimp": "^0.2.28", 64 | "@types/minimist": "^1.2.2", 65 | "@types/ndjson": "^2.0.1", 66 | "@types/node": "^16.11.9", 67 | "@types/shortid": "^0.0.29", 68 | "@types/tmp": "^0.2.2", 69 | "electron": "19.0.4", 70 | "electron-builder": "^23.0.3", 71 | "electron-webpack": "^2.8.2", 72 | "electron-webpack-ts": "^4.0.1", 73 | "lint-staged": "^12.0.3", 74 | "meow": "^8.0.0", 75 | "ndjson": "^2.0.0", 76 | "prettier": "^2.4.1", 77 | "ts-node": "~10.5.0", 78 | "typescript": "~4.6.4", 79 | "webpack": "^4.44.2" 80 | }, 81 | "prettier": { 82 | "singleQuote": false, 83 | "printWidth": 120, 84 | "tabWidth": 4, 85 | "trailingComma": "none" 86 | }, 87 | "lint-staged": { 88 | "*.{js,jsx,ts,tsx,css}": [ 89 | "prettier --write" 90 | ] 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/main/Config.ts: -------------------------------------------------------------------------------- 1 | import Electron from "electron"; 2 | import * as path from "path"; 3 | import activeWin from "active-win"; 4 | 5 | export type UserConfig = { 6 | /** 7 | * Enable debug mode 8 | * Default: false 9 | */ 10 | DEBUG: boolean; 11 | /** 12 | * Output dir path 13 | * if set the path, use the path instead of stored path 14 | * Default: use stored path 15 | */ 16 | outputDir?: string; 17 | /** 18 | * Output content file name 19 | * Default: README.md 20 | */ 21 | outputContentFileName: string; 22 | /** 23 | * Output image directory prefix 24 | * If you want to put images to same dir with README.md, set "." 25 | * Default: img/ 26 | */ 27 | outputImageDirPrefix: string; 28 | /** 29 | * format input by this function and append the result 30 | * Default: for markdown 31 | */ 32 | outputContentTemplate: (args: OutputContentTemplateArgs) => string; 33 | /** 34 | * Auto focus when open input window 35 | * Default: true 36 | */ 37 | autoFocus: boolean; 38 | /** 39 | * Save content automatically without no focus the input window after autoSaveTimeoutMs 40 | * Default: true 41 | */ 42 | autoSave: boolean; 43 | /** 44 | * config for autosave 45 | * Default: 30 * 1000 46 | */ 47 | autoSaveTimeoutMs: number; 48 | /** 49 | * if quoteFrom is clipboard, quote text from clipboard 50 | * if quoteFrom is selectedText, quote text from selected text 51 | * Default: "selectedText" 52 | */ 53 | quoteFrom: "clipboard" | "selectedText"; 54 | /** 55 | * If want to transform clipboard, pass the transform function 56 | * @param text 57 | */ 58 | transformClipboard?: (text: string) => string; 59 | /** 60 | * Send key stroke when ready to input window 61 | * Note: macOS only 62 | */ 63 | sendKeyStrokeWhenReadyInputWindow?: { 64 | key: string; 65 | shift?: boolean; 66 | control?: boolean; 67 | option?: boolean; 68 | command?: boolean; 69 | }; 70 | /** 71 | * bound ratio for screenshot 72 | * Increase actual focus area using this ratio. 73 | * Default: 1.2 74 | */ 75 | screenshotBoundRatio: number; 76 | /** 77 | * Max search count for related content that is included into screenshot result 78 | * The higher the number, screenshot size is large. 79 | * Default: 5 80 | */ 81 | screenshotSearchRectangleMaxCount: number; 82 | 83 | /** 84 | * if the rectangle count is over, mumemo give up to create focus image. 85 | * Just use screenshot image instead of focus image 86 | * Default: 80 87 | */ 88 | screenshotGiveUpRectangleMaxCount: number; 89 | 90 | /** 91 | * if the factor value is defined, mumemo resize screenshot image with the factor. 92 | * Retina display's the factor value is 2. 93 | * display size * factor value is the result of screenshot image size. 94 | * if you want to resize the screeenshot image size, set `1` to `screenshotResizeFactor` 95 | * Default: displayFactor's value 96 | */ 97 | screenshotResizeFactor?: number; 98 | }; 99 | export type MumemoConfigFile = { 100 | shortcutKey?: string | string[]; 101 | create?: (args: UserConfigCreatorArgs) => UserConfig; 102 | }; 103 | export type UserConfigCreatorArgs = { 104 | app: Electron.App; 105 | path: typeof path; 106 | activeWindow?: activeWin.Result | {}; 107 | // What shortcut key is pressed 108 | shortcutKey?: string; 109 | }; 110 | export type OutputContentTemplateArgs = { 111 | imgPath: string; 112 | selectedContent: { 113 | raw: string; 114 | value: string; 115 | }; 116 | inputContent: { 117 | raw: string; 118 | value: string; 119 | }; 120 | }; 121 | export const defaultShortcutKey = "CommandOrControl+Shift+X"; 122 | export const createUserConfig = ({ app, path, activeWindow }: UserConfigCreatorArgs): UserConfig => { 123 | return { 124 | outputContentFileName: "README.md", 125 | outputImageDirPrefix: "img/", 126 | // Output Template Function 127 | outputContentTemplate: ({ imgPath, selectedContent, inputContent }: OutputContentTemplateArgs) => { 128 | return ( 129 | `![](${imgPath})\n` + 130 | (selectedContent.value ? `\n> ${selectedContent.value.split("\n").join("\n> ")}\n` : "") + 131 | (inputContent.raw ? `\n${inputContent.raw.trimRight()}\n` : "") + 132 | "\n---\n\n" 133 | ); 134 | }, 135 | autoFocus: true, 136 | autoSave: true, 137 | autoSaveTimeoutMs: 30 * 1000, 138 | screenshotBoundRatio: 1.2, 139 | screenshotSearchRectangleMaxCount: 5, 140 | screenshotGiveUpRectangleMaxCount: 80, 141 | quoteFrom: "selectedText", 142 | DEBUG: false 143 | }; 144 | }; 145 | -------------------------------------------------------------------------------- /src/main/Deferred.ts: -------------------------------------------------------------------------------- 1 | export class Deferred { 2 | promise: Promise; 3 | private _resolve!: (value: T) => void; 4 | private _reject!: (reason?: Error) => void; 5 | 6 | constructor() { 7 | this.promise = new Promise((resolve, reject) => { 8 | this._resolve = resolve; 9 | this._reject = reject; 10 | }); 11 | } 12 | 13 | resolve(value: T) { 14 | this._resolve(value); 15 | } 16 | 17 | reject(reason?: Error) { 18 | this._reject(reason); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/main/PreviewBrowser.ts: -------------------------------------------------------------------------------- 1 | import { BrowserWindow, ipcMain, clipboard, nativeImage } from "electron"; 2 | import path from "path"; 3 | import { format as formatUrl } from "url"; 4 | import { Deferred } from "./Deferred"; 5 | import { UserConfig } from "./Config"; 6 | import WinState from "electron-win-state"; 7 | 8 | const isDevelopment = process.env.NODE_ENV !== "production"; 9 | 10 | let _PreviewBrowser: PreviewBrowser | null = null; 11 | 12 | export class PreviewBrowser { 13 | private mainWindow: Electron.BrowserWindow | null; 14 | private closedDeferred: Deferred; 15 | private focusAtOnce: boolean; 16 | private canceled: boolean; 17 | private timeoutId: NodeJS.Timeout | null; 18 | 19 | get isDeactived() { 20 | return this.mainWindow === null; 21 | } 22 | 23 | static async instance(): Promise { 24 | if (_PreviewBrowser) { 25 | return _PreviewBrowser; 26 | } 27 | const instance = new PreviewBrowser(); 28 | return new Promise((resolve) => { 29 | instance.mainWindow?.webContents.on("did-finish-load", function() { 30 | _PreviewBrowser = instance; 31 | resolve(_PreviewBrowser); 32 | }); 33 | }); 34 | } 35 | 36 | constructor() { 37 | this.mainWindow = this.createMainWindow(); 38 | this.closedDeferred = new Deferred(); 39 | this.focusAtOnce = false; 40 | this.canceled = false; 41 | this.timeoutId = null; 42 | } 43 | 44 | reset() { 45 | this.mainWindow?.webContents.send("reset"); 46 | this.closedDeferred = new Deferred(); 47 | this.focusAtOnce = false; 48 | this.canceled = false; 49 | if (this.timeoutId) { 50 | clearTimeout(this.timeoutId); 51 | } 52 | return this; 53 | } 54 | 55 | createMainWindow() { 56 | const winState = new WinState({ 57 | defaultWidth: 320, 58 | defaultHeight: 320, 59 | dev: isDevelopment 60 | }); 61 | const browserWindow = new BrowserWindow({ 62 | ...winState.winOptions, 63 | webPreferences: { nodeIntegration: true, contextIsolation: false }, 64 | frame: false, 65 | alwaysOnTop: true 66 | }); 67 | if (isDevelopment) { 68 | browserWindow.loadURL(`http://localhost:${process.env.ELECTRON_WEBPACK_WDS_PORT}`); 69 | } else { 70 | browserWindow.loadURL( 71 | formatUrl({ 72 | pathname: path.join(__dirname, "index.html"), 73 | protocol: "file", 74 | slashes: true 75 | }) 76 | ); 77 | } 78 | browserWindow.on("close", (event) => { 79 | event.preventDefault(); 80 | this.hide(); 81 | this.closedDeferred.reject(new Error("Close Window")); 82 | }); 83 | browserWindow.on("focus", () => { 84 | this.focusAtOnce = true; 85 | }); 86 | browserWindow.on("closed", () => { 87 | this.mainWindow = null; 88 | }); 89 | winState.manage(browserWindow); 90 | require("@electron/remote/main").enable(browserWindow.webContents); 91 | return browserWindow; 92 | } 93 | 94 | edit(value: string, imgSrc: string) { 95 | this.mainWindow?.webContents.send("update", value, imgSrc); 96 | } 97 | 98 | updateImage(imgSrc: string) { 99 | this.mainWindow?.webContents.send("update:image", imgSrc); 100 | } 101 | 102 | cancel() { 103 | this.canceled = true; 104 | if (this.timeoutId) { 105 | clearTimeout(this.timeoutId); 106 | } 107 | } 108 | 109 | // resolve this promise then save text 110 | async waitForInput({ 111 | imgSrc, 112 | timeoutMs, 113 | autoSave 114 | }: { 115 | imgSrc: string; 116 | timeoutMs: number; 117 | autoSave: boolean; 118 | }): Promise { 119 | const onCancel = () => { 120 | this.closedDeferred.reject(new Error("Cancel by user")); 121 | this.mainWindow?.close(); 122 | }; 123 | const onSave = (_event: any, value: string) => { 124 | this.closedDeferred.resolve(value); 125 | this.mainWindow?.close(); 126 | }; 127 | const onCopy = (_event: any, value: string) => { 128 | clipboard.write({ 129 | text: value, 130 | image: nativeImage.createFromDataURL(imgSrc) 131 | }); 132 | }; 133 | return new Promise((resolve, reject) => { 134 | ipcMain.once("save", onSave); 135 | ipcMain.once("cancel", onCancel); 136 | ipcMain.addListener("copy", onCopy); 137 | // focus at once, not timeout 138 | // timeout -> hide and save -> reset 139 | this.timeoutId = setTimeout(() => { 140 | if (!this.focusAtOnce && !this.canceled) { 141 | this.hide(); 142 | if (autoSave) { 143 | this.closedDeferred.resolve(""); 144 | } else { 145 | this.closedDeferred.reject(new Error("timeout")); 146 | } 147 | } 148 | }, timeoutMs); 149 | // save -> save 150 | this.closedDeferred.promise 151 | .then((value) => { 152 | resolve(value); 153 | }) 154 | .catch((error) => { 155 | reject(error); 156 | }) 157 | .finally(() => { 158 | ipcMain.off("save", onSave); 159 | ipcMain.off("cancel", onCancel); 160 | ipcMain.off("copy", onCopy); 161 | }); 162 | }); 163 | } 164 | 165 | show(config: UserConfig) { 166 | if (this.mainWindow) { 167 | this.focusAtOnce = true; 168 | this.mainWindow.show(); 169 | } 170 | } 171 | 172 | showInactive(config: UserConfig) { 173 | if (this.mainWindow) { 174 | this.mainWindow.showInactive(); 175 | } 176 | } 177 | 178 | hide() { 179 | if (this.mainWindow) { 180 | this.mainWindow.hide(); 181 | } 182 | } 183 | 184 | close() { 185 | if (this.mainWindow) { 186 | this.mainWindow.destroy(); 187 | } 188 | this.mainWindow = null; 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /src/main/app.ts: -------------------------------------------------------------------------------- 1 | import { clipboard, screen } from "electron"; 2 | import execa from "execa"; 3 | import { createFocusImage, getReactFromImage } from "./detect"; 4 | import fs from "fs"; 5 | import Flatbush from "flatbush"; 6 | import Jimp from "jimp"; 7 | import * as path from "path"; 8 | import tmp from "tmp"; 9 | import { PreviewBrowser } from "./PreviewBrowser"; 10 | import dayjs, { Dayjs } from "dayjs"; 11 | import shortid from "shortid"; 12 | import sanitize from "sanitize-filename"; 13 | import * as os from "os"; 14 | import { copySelectedText } from "./macos/Clipboard"; 15 | import { OutputContentTemplateArgs, UserConfig } from "./Config"; 16 | import activeWin from "active-win"; 17 | import { resizeScreenShotFitCurrentScreenBound } from "./resize"; 18 | import { sendKeyStroke } from "./macos/sendKeyStroke"; 19 | 20 | // binary search for the first value in the array bigger than the given 21 | function upperBound(value: any, arr: any) { 22 | let i = 0; 23 | let j = arr.length - 1; 24 | while (i < j) { 25 | const m = (i + j) >> 1; 26 | if (arr[m] > value) { 27 | j = m; 28 | } else { 29 | i = m + 1; 30 | } 31 | } 32 | return arr[i]; 33 | } 34 | 35 | // @ts-ignore 36 | Flatbush.prototype.overlap = function (this: Flatbush, minX: number, minY: number, maxX: number, maxY: number) { 37 | // @ts-ignore 38 | let nodeIndex = this._boxes.length - 4; 39 | const queue: number[] = []; 40 | const results: number[] = []; 41 | while (nodeIndex !== undefined) { 42 | // find the end index of the node 43 | // @ts-ignore 44 | const end = Math.min(nodeIndex + this.nodeSize * 4, upperBound(nodeIndex, this._levelBounds)); 45 | // search through child nodes 46 | for (let pos = nodeIndex; pos < end; pos += 4) { 47 | // @ts-ignore 48 | const index = this._indices[pos >> 2] | 0; 49 | // @ts-ignore 50 | const nodeMinX = this._boxes[pos]; 51 | // @ts-ignore 52 | const nodeMinY = this._boxes[pos + 1]; 53 | // @ts-ignore 54 | const nodeMaxX = this._boxes[pos + 2]; 55 | // @ts-ignore 56 | const nodeMaxY = this._boxes[pos + 3]; 57 | // Overlap algorithm 58 | // https://developer.mozilla.org/en-US/docs/Games/Techniques/2D_collision_detection 59 | // check if node bbox intersects with query bbox 60 | if (minX < nodeMaxX && maxX > nodeMinX && minY < nodeMaxY && maxY > nodeMinY) { 61 | if (nodeIndex < this.numItems * 4) { 62 | results.push(index); 63 | } else { 64 | queue.push(index); // node; add it to the search queue 65 | } 66 | } 67 | } 68 | 69 | // @ts-ignore 70 | nodeIndex = queue.pop(); 71 | } 72 | 73 | return results; 74 | }; 75 | const markdownEscapedCharaters = require("markdown-escapes"); 76 | const GfmEscape = require("gfm-escape"); 77 | const markdownEscaper = new GfmEscape(); 78 | // const fnt = PImage.registerFont("~/Library/Fonts/Ricty-Bold.ttf", "Source Sans Pro"); 79 | // fnt.load(() => {}); 80 | 81 | async function screenshot({ 82 | windowId, 83 | screenshotFileName 84 | }: { 85 | windowId: string | undefined; 86 | screenshotFileName: string; 87 | }): Promise { 88 | try { 89 | // TODO: cross platform support 90 | await execa("screencapture", (windowId ? ["-o", "-l", windowId] : ["-o"]).concat(screenshotFileName)); 91 | return true; 92 | } catch (error) { 93 | console.error(error); 94 | return false; 95 | } 96 | } 97 | 98 | export type AppConfig = UserConfig & { 99 | outputDir: string; 100 | }; 101 | export const run = async ({ 102 | config, 103 | activeWindow, 104 | abortSignal 105 | }: { 106 | config: AppConfig; 107 | activeWindow: activeWin.Result; 108 | abortSignal: AbortSignal; 109 | }) => { 110 | const processingState = { 111 | needCleanUpFiles: [] as string[], 112 | clean() { 113 | processingState.needCleanUpFiles.forEach((fileName) => { 114 | try { 115 | fs.unlinkSync(fileName); 116 | } catch (error) { 117 | if (config.DEBUG) { 118 | console.error(error); 119 | } 120 | } 121 | }); 122 | }, 123 | use(fileName: T): T { 124 | processingState.needCleanUpFiles.push(fileName); 125 | return fileName; 126 | } 127 | }; 128 | // on abort 129 | let isCanceled = false; 130 | const cancelTask = async () => { 131 | if (isCanceled) { 132 | return; 133 | } 134 | isCanceled = true; 135 | const previewBrowser = await PreviewBrowser.instance(); 136 | previewBrowser.cancel(); 137 | // clean non-saved files 138 | processingState.clean(); 139 | }; 140 | abortSignal.addEventListener("abort", () => cancelTask(), { 141 | once: true 142 | }); 143 | const race = (promise: Promise): Promise => { 144 | if (abortSignal.aborted) { 145 | return Promise.reject(new Error("Cancel")); 146 | } 147 | // on cancel 148 | return promise as Promise; 149 | }; 150 | try { 151 | let now = Date.now(); 152 | const DEBUG = config.DEBUG; 153 | DEBUG && console.log(`${Date.now() - now}ms`, (now = Date.now()), "start"); 154 | const currentAbsolutePoint = screen.getCursorScreenPoint(); 155 | const currentScreen = screen.getDisplayNearestPoint(screen.getCursorScreenPoint()); 156 | const currentScreenSize = currentScreen.size; 157 | const currentScreenBounce = currentScreen.bounds; 158 | const displayScaleFactor = currentScreen.scaleFactor; 159 | const temporaryScreenShot = tmp.fileSync({ 160 | prefix: "mumemo", 161 | postfix: ".png" 162 | }); 163 | DEBUG && console.log(`${Date.now() - now}ms`, (now = Date.now()), "tmp"); 164 | const windowId = activeWindow.id; 165 | if (!windowId) { 166 | console.error("Not found active window id"); 167 | return cancelTask(); 168 | } 169 | const screenshotFileName = DEBUG ? path.join(config.outputDir, "_debug-step1.png") : temporaryScreenShot.name; 170 | const screenshotSuccess = await race( 171 | screenshot({ 172 | screenshotFileName, 173 | windowId: String(windowId) 174 | }) 175 | ); 176 | DEBUG && console.log(`${Date.now() - now}ms`, (now = Date.now()), "screenshot"); 177 | // show editor 178 | const transformClipboard = 179 | config.transformClipboard || 180 | function (text: string) { 181 | return text; 182 | }; 183 | const clipboardTextPromise = transformClipboard( 184 | (await (config.quoteFrom === "selectedText" ? copySelectedText() : clipboard.readText())) ?? "" 185 | ); 186 | // Fast Preview 187 | const previewBrowser = await PreviewBrowser.instance(); 188 | previewBrowser.reset(); 189 | DEBUG && console.log(`${Date.now() - now}ms`, (now = Date.now()), "browser instance"); 190 | const firstImage = await Jimp.read(screenshotFileName); 191 | const firstImageBase64 = await firstImage.getBase64Async("image/png"); 192 | previewBrowser.edit(``, firstImageBase64); 193 | DEBUG && console.log(`${Date.now() - now}ms`, (now = Date.now()), "first view edit"); 194 | // get clipboard text and show window as interactive 195 | let clipboardText = ""; 196 | if (config.autoFocus) { 197 | clipboardText = clipboardTextPromise || ""; 198 | await previewBrowser.show(config); 199 | } else { 200 | previewBrowser.showInactive(config); 201 | clipboardText = clipboardTextPromise || ""; 202 | } 203 | // Some screenshot like `screencapture` command make larger size than display size. 204 | // Resize to fit display size to reduce tasks 205 | const resizedScreenshotFileName = DEBUG 206 | ? path.join(config.outputDir, "_debug-step1.resized.png") 207 | : screenshotFileName; 208 | const normalizedDisplayScaleFactor = 209 | config.screenshotResizeFactor && config.screenshotResizeFactor !== displayScaleFactor 210 | ? config.screenshotResizeFactor 211 | : displayScaleFactor; 212 | const shouldResize = 213 | config.screenshotResizeFactor !== undefined && config.screenshotResizeFactor !== displayScaleFactor; 214 | // if user does not configure valid factor, skip it 215 | if (DEBUG) { 216 | DEBUG && 217 | console.log("normalizedDisplayScaleFactor", normalizedDisplayScaleFactor, " vs ", displayScaleFactor); 218 | DEBUG && console.log("shouldResize", shouldResize); 219 | } 220 | if (shouldResize) { 221 | await resizeScreenShotFitCurrentScreenBound({ 222 | DEBUG, 223 | resizeSize: { 224 | width: currentScreenSize.width * normalizedDisplayScaleFactor, 225 | height: currentScreenSize.height * normalizedDisplayScaleFactor 226 | }, 227 | screenshotFileName, 228 | resizedScreenshotFileName 229 | }); 230 | } 231 | DEBUG && console.log(`${Date.now() - now}ms`, (now = Date.now()), "resize"); 232 | if (!screenshotSuccess) { 233 | return; 234 | } 235 | 236 | const rectangles = await race( 237 | getReactFromImage(resizedScreenshotFileName, { 238 | debugOutputPath: DEBUG ? path.join(config.outputDir, "_debug-step2.png") : undefined 239 | }) 240 | ); 241 | // Update with Focus Image 242 | const sanitizeFileName = (name: string): string => { 243 | const homedir = os.homedir(); 244 | const stripedNamed: string = [homedir, markdownEscapedCharaters].reduce((result, escapeCharacter) => { 245 | return result.split(escapeCharacter).join(""); 246 | }, name); 247 | const spaceToUnderBar = stripedNamed.replace(/\s/g, "_"); 248 | return sanitize(spaceToUnderBar); 249 | }; 250 | const createOutputImageFileName = ({ 251 | dayjs, 252 | owner, 253 | title, 254 | id 255 | }: { 256 | dayjs: Dayjs; 257 | owner: string; 258 | title: string; 259 | id: string; 260 | }) => { 261 | return `${dayjs.format("YYYY-MM-DD")}-${owner}-${title}-${id}.png`; 262 | }; 263 | const outputImageFileName = sanitizeFileName( 264 | createOutputImageFileName({ 265 | id: shortid(), 266 | dayjs: dayjs(), 267 | owner: activeWindow?.owner.name ?? "unknown", 268 | title: activeWindow?.title ?? "unknown" 269 | }) 270 | ); 271 | const outputFileName = processingState.use( 272 | path.join(config.outputDir, config.outputImageDirPrefix, outputImageFileName) 273 | ); 274 | const { outputImage } = await createFocusImage({ 275 | DEBUG, 276 | rectangles, 277 | displayScaleFactor: normalizedDisplayScaleFactor, 278 | currentAbsolutePoint, 279 | currentScreenBounce, 280 | currentScreenSize, 281 | screenshotFileName: resizedScreenshotFileName, 282 | outputFileName, 283 | screenshotBoundRatio: config.screenshotBoundRatio, 284 | config 285 | }); 286 | const outputImageBase64 = await outputImage.getBase64Async("image/png"); 287 | previewBrowser.updateImage(outputImageBase64); 288 | if (config.sendKeyStrokeWhenReadyInputWindow) { 289 | await sendKeyStroke(config.sendKeyStrokeWhenReadyInputWindow.key, config.sendKeyStrokeWhenReadyInputWindow); 290 | } 291 | const input = await previewBrowser.waitForInput({ 292 | imgSrc: outputImageBase64, 293 | autoSave: config.autoSave, 294 | timeoutMs: config.autoSaveTimeoutMs 295 | }); 296 | const inputContent: OutputContentTemplateArgs["inputContent"] = { 297 | raw: input, 298 | value: markdownEscaper.escape(input) 299 | }; 300 | const selectedContent: OutputContentTemplateArgs["selectedContent"] = { 301 | raw: clipboardText.trim(), 302 | value: markdownEscaper.escape(clipboardText.trim()) 303 | }; 304 | fs.appendFileSync( 305 | path.join(config.outputDir, config.outputContentFileName), 306 | config.outputContentTemplate({ 307 | imgPath: path.join(config.outputImageDirPrefix, outputImageFileName), 308 | inputContent, 309 | selectedContent 310 | }), 311 | "utf-8" 312 | ); 313 | } catch (error: any) { 314 | if (config.DEBUG) { 315 | DEBUG && console.log(error.message); 316 | } 317 | // when occur error{timeout,cancel}, cleanup it and suppress error 318 | await cancelTask(); 319 | } 320 | }; 321 | -------------------------------------------------------------------------------- /src/main/detect.ts: -------------------------------------------------------------------------------- 1 | import Jimp from "jimp"; 2 | import path from "path"; 3 | import { AppConfig } from "./app"; 4 | import fs from "fs"; 5 | import Flatbush from "flatbush"; 6 | import * as PImage from "pureimage"; 7 | // @ts-expect-error 8 | import { cv } from "opencv-wasm"; 9 | import { Bitmap } from "pureimage/types/bitmap"; 10 | // add overlap 11 | declare type FlatbushAddtional = { 12 | overlap(this: Flatbush, minX: number, minY: number, maxX: number, maxY: number): number[]; 13 | }; 14 | 15 | export type getReactFromImageResults = getReactFromImageResult[]; 16 | type Rect = { 17 | width: number; 18 | height: number; 19 | x: number; 20 | y: number; 21 | }; 22 | export type getReactFromImageResult = { 23 | rect: Rect; 24 | arcLength: number; 25 | area: number; 26 | minAreaRect: { 27 | angle: number; 28 | center: { 29 | x: number; 30 | y: number; 31 | }; 32 | size: { 33 | height: number; 34 | width: number; 35 | }; 36 | }; 37 | }; 38 | type Deletable = { delete(): void }; 39 | const createInter = () => { 40 | const set = new Set(); 41 | return { 42 | use(target: T): T { 43 | set.add(target); 44 | return target; 45 | }, 46 | release() { 47 | set.forEach((target) => { 48 | try { 49 | target.delete(); 50 | } catch (error) { 51 | console.error(target, "is not deletable"); 52 | throw error; 53 | } 54 | }); 55 | } 56 | }; 57 | }; 58 | 59 | function readPureImage(fileName: string): Promise { 60 | return new Promise((resolve, reject) => { 61 | PImage.decodePNGFromStream(fs.createReadStream(fileName)) 62 | .then((img: any) => { 63 | console.log("done reading", fileName); 64 | resolve(img); 65 | }) 66 | .catch((error: any) => { 67 | reject(error); 68 | }); 69 | }); 70 | } 71 | 72 | export function writePureImage(debugImage: T, fileName: string) { 73 | return new Promise((resolve, reject) => { 74 | PImage.encodePNGToStream(debugImage, fs.createWriteStream(fileName)) 75 | .then(() => { 76 | console.log("done writing", fileName); 77 | resolve(); 78 | }) 79 | .catch((error: any) => { 80 | reject(error); 81 | }); 82 | }); 83 | } 84 | 85 | export async function calculateWrapperRect({ 86 | rects, 87 | relativePoint, 88 | screenshotBoundRatio, 89 | displayScaleFactor, 90 | debugImage, 91 | debugContext, 92 | currentScreenSize, 93 | DEBUG, 94 | config 95 | }: { 96 | rects: getReactFromImageResult[]; 97 | relativePoint: { x: number; y: number }; 98 | debugImage: any; 99 | debugContext: any; 100 | DEBUG: boolean; 101 | currentScreenSize: { width: number; height: number }; 102 | displayScaleFactor: number; 103 | screenshotBoundRatio: number; 104 | config: AppConfig; 105 | }) { 106 | if (DEBUG) { 107 | if (screenshotBoundRatio < 0) { 108 | console.warn("boundRatio should be >= 0"); 109 | } 110 | } 111 | const flatbush = new Flatbush(rects.length) as Flatbush & FlatbushAddtional; 112 | const boundRects = rects.map((result) => { 113 | const rect = result.rect; 114 | const paddingX = Math.round(rect.x * (screenshotBoundRatio - 1)); 115 | const paddingY = Math.round(rect.y * (screenshotBoundRatio - 1)); 116 | return { 117 | x: rect.x - paddingX, 118 | y: rect.y - paddingY, 119 | width: rect.width + paddingX * 2, 120 | height: rect.height + paddingY * 2 121 | }; 122 | }); 123 | if (boundRects.length > config.screenshotGiveUpRectangleMaxCount) { 124 | if (DEBUG) { 125 | console.log("Give up to create focus image because boundRects count is " + boundRects.length); 126 | } 127 | return { 128 | wrapperRect: { 129 | minX: 0, 130 | minY: 0, 131 | maxX: currentScreenSize.width * displayScaleFactor, 132 | maxY: currentScreenSize.height * displayScaleFactor 133 | } 134 | }; 135 | } 136 | for (const boundRect of boundRects) { 137 | flatbush.add(boundRect.x, boundRect.y, boundRect.x + boundRect.width, boundRect.y + boundRect.height); 138 | if (DEBUG) { 139 | debugContext.fillStyle = "rgba(255,0,0,0.5)"; 140 | debugContext.fillRect(boundRect.x, boundRect.y, boundRect.width, boundRect.height); 141 | // context.fillStyle = "#ffffff"; 142 | // context.font = "12pt 'Source Sans Pro'"; 143 | // context.fillText( 144 | // `[${Math.round(boundRect.area)}, ${Math.round(boundRect.arcLength)}]`, 145 | // boundRect.x, 146 | // boundRect.y 147 | // ); 148 | } 149 | } 150 | if (DEBUG) { 151 | try { 152 | await writePureImage(debugImage, path.join(config.outputDir, "_debug-step3.png")); 153 | } catch {} 154 | } 155 | flatbush.finish(); 156 | const rectangleSearchLimit = Math.round( 157 | Math.min(Math.max(2, boundRects.length / 2), config.screenshotSearchRectangleMaxCount) 158 | ); 159 | if (DEBUG) { 160 | console.log("Rectangle count: ", boundRects.length); 161 | console.log("rectangleSearchLimit:", rectangleSearchLimit); 162 | } 163 | const neighborIds = flatbush.neighbors(relativePoint.x, relativePoint.y, rectangleSearchLimit); 164 | const relatedBox = { 165 | minX: new Set(), 166 | minY: new Set(), 167 | maxX: new Set(), 168 | maxY: new Set() 169 | }; 170 | const hitIdSet = new Set(); 171 | neighborIds.forEach((id) => { 172 | const reactFromImageResult = boundRects[id]; 173 | { 174 | relatedBox.minX.add(reactFromImageResult.x); 175 | relatedBox.minY.add(reactFromImageResult.y); 176 | relatedBox.maxX.add(reactFromImageResult.x + reactFromImageResult.width); 177 | relatedBox.maxY.add(reactFromImageResult.y + reactFromImageResult.height); 178 | } 179 | if (DEBUG) { 180 | debugContext.fillStyle = "rgba(55,255,0,0.5)"; 181 | debugContext.fillRect( 182 | reactFromImageResult.x, 183 | reactFromImageResult.y, 184 | reactFromImageResult.width, 185 | reactFromImageResult.height 186 | ); 187 | } 188 | const recursive = (id: number) => { 189 | if (hitIdSet.has(id)) { 190 | return; 191 | } 192 | hitIdSet.add(id); 193 | const rectOfId = boundRects[id]; 194 | const ids = flatbush.overlap( 195 | rectOfId.x, 196 | rectOfId.y, 197 | rectOfId.x + rectOfId.width, 198 | rectOfId.y + rectOfId.height 199 | ); 200 | ids.forEach((boundId) => { 201 | const boundRect = boundRects[boundId]; 202 | { 203 | relatedBox.minX.add(boundRect.x); 204 | relatedBox.minY.add(boundRect.y); 205 | relatedBox.maxX.add(boundRect.x + boundRect.width); 206 | relatedBox.maxY.add(boundRect.y + boundRect.height); 207 | } 208 | if (DEBUG) { 209 | debugContext.fillStyle = "rgba(55,255,0,0.5)"; 210 | debugContext.fillRect(boundRect.x, boundRect.y, boundRect.width, boundRect.height); 211 | } 212 | recursive(boundId); 213 | }); 214 | }; 215 | recursive(id); 216 | }); 217 | const resultBox = { 218 | minX: Math.round(Math.min(...relatedBox.minX)), 219 | minY: Math.round(Math.min(...relatedBox.minY)), 220 | maxX: Math.round(Math.max(...relatedBox.maxX)), 221 | maxY: Math.round(Math.max(...relatedBox.maxY)) 222 | }; 223 | if (DEBUG) { 224 | debugContext.fillStyle = "rgba(0,255,255,0.5)"; 225 | debugContext.fillRect( 226 | resultBox.minX, 227 | resultBox.minY, 228 | resultBox.maxX - resultBox.minX, 229 | resultBox.maxY - resultBox.minY 230 | ); 231 | } 232 | return { 233 | wrapperRect: resultBox 234 | }; 235 | } 236 | 237 | export const createFocusImage = async ({ 238 | DEBUG, 239 | screenshotFileName, 240 | rectangles, 241 | currentScreenBounce, 242 | currentScreenSize, 243 | currentAbsolutePoint, 244 | displayScaleFactor, 245 | screenshotBoundRatio, 246 | outputFileName, 247 | config 248 | }: { 249 | DEBUG: boolean; 250 | screenshotFileName: string; 251 | outputFileName: string; 252 | rectangles: getReactFromImageResults; 253 | currentScreenBounce: { x: number; y: number }; 254 | currentScreenSize: { width: number; height: number }; 255 | currentAbsolutePoint: { x: number; y: number }; 256 | displayScaleFactor: number; 257 | screenshotBoundRatio: number; 258 | config: AppConfig; 259 | }) => { 260 | const img = await readPureImage(screenshotFileName); 261 | // @ts-expect-error: wrong type 262 | const debugImage = DEBUG ? PImage.make(img.width, img.height) : ({} as any); 263 | const context = DEBUG ? debugImage.getContext("2d") : ({} as any); 264 | if (DEBUG) { 265 | context.drawImage( 266 | img, 267 | 0, 268 | 0, 269 | img.width, 270 | img.height, // source dimensions 271 | 0, 272 | 0, 273 | img.width, 274 | img.height // destination dimensions 275 | ); 276 | } 277 | const filteredRects = rectangles.filter((result) => { 278 | if (result.area < 100) { 279 | return false; 280 | } 281 | if (result.arcLength < 100) { 282 | return false; 283 | } 284 | if (result.rect.height < 8) { 285 | return false; 286 | } 287 | // if (result.rect.height < 30) { 288 | // return false; 289 | // } 290 | const screenBoxAreaLimit = currentScreenSize.width * currentScreenSize.height * 0.8; 291 | if (result.area > screenBoxAreaLimit) { 292 | return false; 293 | } 294 | return true; 295 | }); 296 | const relativePointCursorInScreen = { 297 | x: Math.abs(currentScreenBounce.x - currentAbsolutePoint.x * displayScaleFactor), 298 | y: Math.abs(currentScreenBounce.y - currentAbsolutePoint.y * displayScaleFactor) 299 | }; 300 | // Draw Cursor 301 | if (DEBUG) { 302 | console.log("currentScreenSize", currentScreenSize); 303 | console.log("currentScreenBounce", currentScreenBounce); 304 | console.log("currentAbsolutePoint", currentAbsolutePoint); 305 | console.log("displayScaleFactor", displayScaleFactor); 306 | console.log("relativePointCursorInScreen", relativePointCursorInScreen); 307 | context.fillStyle = "rgba(255,0,255, 1)"; 308 | context.fillRect(relativePointCursorInScreen.x, relativePointCursorInScreen.y, 25, 25); 309 | } 310 | const { wrapperRect } = await calculateWrapperRect({ 311 | rects: filteredRects, 312 | relativePoint: relativePointCursorInScreen, 313 | displayScaleFactor, 314 | screenshotBoundRatio: screenshotBoundRatio, 315 | currentScreenSize, 316 | debugContext: context, 317 | debugImage, 318 | DEBUG: DEBUG, 319 | config 320 | }); 321 | if (DEBUG) { 322 | context.fillStyle = "rgba(0,255,255,0.5)"; 323 | context.fillRect( 324 | wrapperRect.minX, 325 | wrapperRect.minY, 326 | wrapperRect.maxX - wrapperRect.minX, 327 | wrapperRect.maxY - wrapperRect.minY 328 | ); 329 | } 330 | // debug 331 | if (DEBUG) { 332 | await writePureImage(debugImage, path.join(config.outputDir, "_debug-step4.png")); 333 | } 334 | // result 335 | const image = await Jimp.read(screenshotFileName); 336 | // avoid overlap 337 | const imageWidth = image.getWidth(); 338 | const imageHeight = image.getHeight(); 339 | image.crop( 340 | wrapperRect.minX, 341 | wrapperRect.minY, 342 | Math.min(imageWidth - wrapperRect.minX, wrapperRect.maxX - wrapperRect.minX), 343 | Math.min(imageHeight - wrapperRect.minY, wrapperRect.maxY - wrapperRect.minY) 344 | ); 345 | image.write(outputFileName); 346 | return { 347 | outputFilePath: outputFileName, 348 | outputImage: image 349 | }; 350 | }; 351 | 352 | export type getReactFromImageOptions = { 353 | debug?: boolean; 354 | debugOutputPath?: string; 355 | }; 356 | export const getReactFromImage = async (imagePath: string, options: getReactFromImageOptions = {}) => { 357 | const impSrc = await Jimp.read(imagePath); 358 | const { use, release } = createInter(); 359 | const results: getReactFromImageResults = []; 360 | const src = cv.matFromImageData(impSrc.bitmap); 361 | const dst = use(new cv.Mat()); 362 | { 363 | const M = use(cv.Mat.ones(5, 5, cv.CV_8U)); 364 | const anchor = new cv.Point(-1, -1); 365 | cv.dilate(src, dst, M, anchor, 1, cv.BORDER_CONSTANT, cv.morphologyDefaultBorderValue()); 366 | // to Grayscale 367 | cv.cvtColor(dst, dst, cv.COLOR_RGBA2GRAY); 368 | // to binary 369 | cv.threshold(dst, dst, 0, 255, cv.THRESH_BINARY + cv.THRESH_TRIANGLE); 370 | // find contours 371 | const contours = use(new cv.MatVector()); 372 | const hierarchy = use(new cv.Mat()); 373 | cv.findContours(dst, contours, hierarchy, cv.RETR_CCOMP, cv.CHAIN_APPROX_SIMPLE); 374 | for (let i = 0; i < contours.size(); ++i) { 375 | const arcLength = cv.arcLength(contours.get(i), true); 376 | const area = cv.contourArea(contours.get(i)); 377 | const minAreaRect = cv.minAreaRect(contours.get(i)); 378 | results.push({ 379 | area: area, 380 | arcLength: arcLength, 381 | minAreaRect, 382 | rect: cv.boundingRect(contours.get(i)) 383 | }); 384 | if (options.debugOutputPath) { 385 | const contoursColor = new cv.Scalar(255, 0, 0, 255); 386 | cv.drawContours(src, contours, i, contoursColor, 5, 1, hierarchy, 0); 387 | } 388 | } 389 | } 390 | // Jimp only support RGC 391 | // Need to gray to RGC before passing jimp 392 | if (options.debugOutputPath) { 393 | cv.cvtColor(dst, dst, cv.COLOR_GRAY2RGB); 394 | new Jimp({ 395 | width: src.cols, 396 | height: src.rows, 397 | data: Buffer.from(src.data) 398 | }).write(options.debugOutputPath); 399 | } 400 | release(); 401 | return results; 402 | }; 403 | -------------------------------------------------------------------------------- /src/main/index.ts: -------------------------------------------------------------------------------- 1 | import { app, dialog, globalShortcut, Menu, Tray, shell, BrowserWindow } from "electron"; 2 | import { timeout } from "./timeout"; 3 | import activeWin from "active-win"; 4 | import { createUserConfig, defaultShortcutKey, MumemoConfigFile } from "./Config"; 5 | import path from "path"; 6 | import { AppConfig, run } from "./app"; 7 | import * as fs from "fs"; 8 | import Store from "electron-store"; 9 | import log from "electron-log"; 10 | import OpenDialogOptions = Electron.OpenDialogOptions; 11 | 12 | require("@electron/remote/main").initialize(); 13 | const store = new Store(); 14 | Object.assign(console, log.functions); 15 | // mumemo:// 16 | app.setAsDefaultProtocolClient("mumemo"); 17 | 18 | /* 19 | const urlToConfig = (urlString?: string): AppConfig => { 20 | if (!urlString) { 21 | return defaultAppConfig; 22 | } 23 | const {query} = url.parse(urlString, true); 24 | return { 25 | DEBUG: query.DEBUG ? Boolean(query.DEBUG) : defaultAppConfig.DEBUG, 26 | debugOutputDir: query.debugOutputDir ? String(query.debugOutputDir) : defaultAppConfig.debugOutputDir, 27 | autoFocus: query.autoFocus ? Boolean(query.autoFocus) : defaultAppConfig.autoFocus, 28 | autoSave: query.autoSave ? Boolean(query.autoSave) : defaultAppConfig.autoSave, 29 | autoSaveTimeoutMs: query.autoSaveTimeoutMs 30 | ? Number(query.autoSaveTimeoutMs) 31 | : defaultAppConfig.autoSaveTimeoutMs, 32 | boundRatio: query.boundRatio ? Number(query.boundRatio) : defaultAppConfig.boundRatio, 33 | outputDir: query.outputDir ? String(query.outputDir) : defaultAppConfig.outputDir, 34 | outputContentTemplate: defaultAppConfig.outputContentTemplate, 35 | }; 36 | }; 37 | const cliToConfig = (): AppConfig => { 38 | const args = minimist(process.argv.slice(2)); 39 | return { 40 | ...defaultAppConfig, 41 | ...args, 42 | }; 43 | }; 44 | 45 | */ 46 | // singleton 47 | const gotTheLock = app.requestSingleInstanceLock(); 48 | if (!gotTheLock) { 49 | app.quit(); 50 | } else { 51 | // app.on("second-instance", (event, commandLine, workingDirectory) => { 52 | // appProcess.main(cliToConfig()); 53 | // }); 54 | } 55 | const appProcess = { 56 | isProcessing: false, 57 | abortDeferred: new AbortController(), 58 | async start(config: AppConfig, activeWindow: activeWin.Result) { 59 | if (appProcess.isProcessing) { 60 | await appProcess.cancel(); 61 | } 62 | appProcess.isProcessing = true; 63 | try { 64 | await run({ 65 | config, 66 | activeWindow, 67 | abortSignal: appProcess.abortDeferred.signal 68 | }); 69 | } catch (error: any) { 70 | if (error.message !== "Cancel") { 71 | console.error(error); 72 | } 73 | } finally { 74 | appProcess.finish(); 75 | } 76 | }, 77 | finish() { 78 | appProcess.abortDeferred = new AbortController(); 79 | appProcess.isProcessing = false; 80 | }, 81 | async cancel() { 82 | appProcess.abortDeferred.abort(); 83 | appProcess.abortDeferred = new AbortController(); 84 | appProcess.isProcessing = false; 85 | await timeout(16); 86 | } 87 | }; 88 | 89 | // let _appConfig: null | AppConfig = null; 90 | // app.on("open-url", function (event, url) { 91 | // event.preventDefault(); 92 | // if (!app.isReady()) { 93 | // _appConfig = urlToConfig(url); 94 | // } else { 95 | // const appConfig = urlToConfig(url); 96 | // appProcess.run(appConfig); 97 | // } 98 | // }); 99 | const getUserConfigFile = (): string | undefined => { 100 | try { 101 | const homedir = app.getPath("home"); 102 | const userConfigPathList = path.join(homedir, ".config/mumemo/mumemo.config.js"); 103 | return fs.existsSync(userConfigPathList) ? userConfigPathList : undefined; 104 | } catch { 105 | return undefined; 106 | } 107 | }; 108 | const onReady = async (): Promise => { 109 | // if (_appConfig) { 110 | // appProcess.run(_appConfig); 111 | // } else { 112 | // const args = minimist(process.argv.slice(2)); 113 | // if (args.startup) { 114 | // appProcess.run(cliToConfig()); 115 | // } 116 | // } 117 | const userConfigPath = getUserConfigFile(); 118 | // restart when config file is changed 119 | if (typeof userConfigPath === "string") { 120 | fs.watch(userConfigPath, (eventType) => { 121 | if (eventType === "change") { 122 | // TODO: unstable 123 | // app.relaunch(); 124 | // app.exit(); 125 | } 126 | }); 127 | } 128 | // FIXME: dynamic require 129 | // electron-webpack does not support require out of app 130 | const userConfig: MumemoConfigFile = typeof userConfigPath === "string" ? eval(`require("${userConfigPath}")`) : {}; 131 | const openDialogReturnValuePromise = (defaultDir?: string) => { 132 | const focusedWindow = new BrowserWindow({ 133 | show: false, 134 | alwaysOnTop: true 135 | }); 136 | const options: OpenDialogOptions = { 137 | properties: ["openDirectory", "createDirectory"], 138 | title: "Select a output directory", 139 | defaultPath: defaultDir ?? path.join(app.getPath("documents")), 140 | buttonLabel: "Save to here" 141 | }; 142 | if (focusedWindow) { 143 | return dialog.showOpenDialog(focusedWindow, options); 144 | } 145 | return dialog.showOpenDialog(options); 146 | }; 147 | let outputDir = store.get("output-dir") as string | undefined; 148 | if (!outputDir) { 149 | const result = await openDialogReturnValuePromise(); 150 | if (result.canceled) { 151 | return onReady(); 152 | } 153 | outputDir = result.filePaths[0] as string; 154 | store.set("output-dir", outputDir); 155 | } 156 | if (!outputDir) { 157 | throw new Error("outputDir is not found"); 158 | } 159 | // Unregister a shortcut. 160 | const definedShortcutKeys: string[] = (() => { 161 | if (!userConfig.shortcutKey) { 162 | return [defaultShortcutKey]; 163 | } 164 | if (Array.isArray(userConfig.shortcutKey)) { 165 | return userConfig.shortcutKey; 166 | } 167 | return [userConfig.shortcutKey]; 168 | })(); 169 | definedShortcutKeys.forEach((shortcutKey) => { 170 | globalShortcut.unregister(shortcutKey); 171 | globalShortcut.register(shortcutKey, () => { 172 | try { 173 | const activeInfo = activeWin.sync(); 174 | if (!activeInfo) { 175 | console.error(new Error("Not found active window")); 176 | return; 177 | } 178 | const outputDir = store.get("output-dir") || path.join(app.getPath("documents"), "mumemo"); 179 | if (typeof outputDir !== "string") { 180 | throw new Error("output-dir is not string"); 181 | } 182 | const config: AppConfig = { 183 | // 1. user config 184 | // 2. store.get(output-dir) 185 | // 3. Default path 186 | outputDir, 187 | 188 | ...createUserConfig({ app, path, activeWindow: activeInfo, shortcutKey }), 189 | ...(userConfig.create 190 | ? userConfig.create({ 191 | app, 192 | path, 193 | activeWindow: activeInfo, 194 | shortcutKey 195 | }) 196 | : {}) 197 | }; 198 | appProcess.start(config, activeInfo); 199 | } catch (error) { 200 | console.log( 201 | "mumemo requires the accessibility permission in “System Preferences › Security & Privacy › Privacy › Accessibility" 202 | ); 203 | console.error(error); 204 | } 205 | }); 206 | }); 207 | // @ts-ignore 208 | const tray = new Tray(path.join(__static, "tray.png")); 209 | const contextMenu = Menu.buildFromTemplate([ 210 | { 211 | label: `Output: ${outputDir}` 212 | }, 213 | { 214 | label: `Change output directory`, 215 | click: async () => { 216 | const result = await openDialogReturnValuePromise(outputDir); 217 | if (result.canceled) { 218 | return; 219 | } 220 | outputDir = result.filePaths[0]; 221 | store.set("output-dir", outputDir); 222 | } 223 | }, 224 | { 225 | type: "separator" 226 | }, 227 | { 228 | label: "Open content file", 229 | click: async () => { 230 | const activeInfo = activeWin.sync() || {}; 231 | if (!outputDir) { 232 | return; 233 | } 234 | const config: AppConfig = { 235 | ...createUserConfig({ app, path }), 236 | ...(userConfig.create ? userConfig.create({ app, path, activeWindow: activeInfo }) : {}), 237 | outputDir 238 | }; 239 | const outputContentFileName = path.join(outputDir, config.outputContentFileName); 240 | console.log("outputContentFileName", outputContentFileName); 241 | await shell.openPath(outputContentFileName); 242 | } 243 | }, 244 | { 245 | type: "separator" 246 | }, 247 | { 248 | label: "Quit", 249 | click: async () => { 250 | app.exit(0); 251 | } 252 | } 253 | ]); 254 | tray.setToolTip("Mumemo"); 255 | tray.setContextMenu(contextMenu); 256 | }; 257 | app.on("ready", () => { 258 | onReady(); 259 | }); 260 | 261 | app.on("will-quit", () => { 262 | // Unregister a shortcut. 263 | globalShortcut.unregister("CommandOrControl+Shift+X"); 264 | // Unregister all shortcuts. 265 | globalShortcut.unregisterAll(); 266 | }); 267 | -------------------------------------------------------------------------------- /src/main/macos/Clipboard.ts: -------------------------------------------------------------------------------- 1 | import { run } from "@jxa/run"; 2 | import type { Application as _ } from "@jxa/types"; 3 | 4 | declare var Application: typeof _; 5 | import { clipboard } from "electron"; 6 | import { timeout } from "../timeout"; 7 | 8 | export type ModifierOption = { 9 | shift?: boolean; 10 | control?: boolean; 11 | option?: boolean; 12 | command?: boolean; 13 | }; 14 | 15 | function createModifier(modifierOption: ModifierOption) { 16 | const modifiers = []; 17 | if (modifierOption.shift) { 18 | modifiers.push("shift down"); 19 | } 20 | if (modifierOption.command) { 21 | modifiers.push("command down"); 22 | } 23 | if (modifierOption.control) { 24 | modifiers.push("control down"); 25 | } 26 | if (modifierOption.option) { 27 | modifiers.push("option down"); 28 | } 29 | return modifiers; 30 | } 31 | 32 | const tryTask = (task: () => boolean, interval: number, count: number = 0): Promise => { 33 | return new Promise((resolve) => { 34 | setTimeout(() => { 35 | const result = task(); 36 | if (!result) { 37 | return tryTask(task, interval, count + 1); 38 | } 39 | return resolve(result); 40 | }, interval); 41 | }); 42 | }; 43 | 44 | export function copySelectedText(): Promise { 45 | const modifiers = createModifier({ 46 | command: true 47 | }); 48 | const oldText = clipboard.readText(); 49 | return run( 50 | (key, modifiers) => { 51 | const SystemEvents = Application("System Events"); 52 | SystemEvents.keystroke(key, { using: modifiers }); 53 | }, 54 | "c", 55 | modifiers 56 | ) 57 | .then(() => { 58 | return Promise.race([ 59 | tryTask(() => { 60 | return oldText !== clipboard.readText(); 61 | }, 16), 62 | timeout(1000) 63 | ]); 64 | }) 65 | .then(() => { 66 | const newText = clipboard.readText().trim(); 67 | if (!newText && oldText) { 68 | clipboard.writeText(oldText); 69 | return oldText; 70 | } 71 | return oldText !== newText ? newText : undefined; 72 | }) 73 | .catch(() => { 74 | return undefined; 75 | }); 76 | } 77 | -------------------------------------------------------------------------------- /src/main/macos/sendKeyStroke.ts: -------------------------------------------------------------------------------- 1 | import "@jxa/global-type"; 2 | import { run } from "@jxa/run"; 3 | 4 | export type ModifierOption = { 5 | shift?: boolean; 6 | control?: boolean; 7 | option?: boolean; 8 | command?: boolean; 9 | }; 10 | 11 | function createModifier(modifierOption: ModifierOption) { 12 | const modifiers = []; 13 | if (modifierOption.shift) { 14 | modifiers.push("shift down"); 15 | } 16 | if (modifierOption.command) { 17 | modifiers.push("command down"); 18 | } 19 | if (modifierOption.control) { 20 | modifiers.push("control down"); 21 | } 22 | if (modifierOption.option) { 23 | modifiers.push("option down"); 24 | } 25 | return modifiers; 26 | } 27 | 28 | export function sendKeyStroke(key: string, modifierOption: ModifierOption) { 29 | const modifiers = createModifier(modifierOption); 30 | return run( 31 | (key, modifiers) => { 32 | const SystemEvents = Application("System Events"); 33 | const app = Application.currentApplication(); 34 | app.includeStandardAdditions = true; 35 | /* 36 | * key code 123 -- left arrow 37 | * key code 124 -- right arrow 38 | * key code 125 -- down arrow 39 | * key code 126 -- up arrow 40 | * key code 49 -- space 41 | */ 42 | if (key === "ArrowLeft") { 43 | SystemEvents.keyCode(123, { using: modifiers }); 44 | } else if (key === "ArrowRight") { 45 | SystemEvents.keyCode(124, { using: modifiers }); 46 | } else if (key === "ArrowDown") { 47 | SystemEvents.keyCode(125, { using: modifiers }); 48 | } else if (key === "ArrowUp") { 49 | SystemEvents.keyCode(126, { using: modifiers }); 50 | } else if (key === "Space") { 51 | SystemEvents.keyCode(49, { using: modifiers }); 52 | } else { 53 | SystemEvents.keystroke(key, { using: modifiers }); 54 | } 55 | }, 56 | key, 57 | modifiers 58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /src/main/resize.ts: -------------------------------------------------------------------------------- 1 | import Jimp from "jimp"; 2 | 3 | /** 4 | * 5 | * @param screenshotFileName input 6 | * @param resizedScreenshotFileName output 7 | * @param resizeSize 8 | * @param DEBUG 9 | */ 10 | export async function resizeScreenShotFitCurrentScreenBound({ 11 | screenshotFileName, 12 | resizedScreenshotFileName, 13 | resizeSize, 14 | DEBUG 15 | }: { 16 | screenshotFileName: string; 17 | resizedScreenshotFileName: string; 18 | resizeSize: { width: number; height: number }; 19 | DEBUG?: boolean; 20 | }): Promise { 21 | const jimp = await Jimp.read(screenshotFileName); 22 | await jimp.resize(resizeSize.width, resizeSize.height); 23 | await jimp.writeAsync(resizedScreenshotFileName); 24 | return resizedScreenshotFileName; 25 | } 26 | -------------------------------------------------------------------------------- /src/main/timeout.ts: -------------------------------------------------------------------------------- 1 | export const timeout = (timeoutMs: number) => { 2 | return new Promise((resolve) => { 3 | setTimeout(resolve, timeoutMs); 4 | }); 5 | }; 6 | -------------------------------------------------------------------------------- /src/renderer/index.css: -------------------------------------------------------------------------------- 1 | html, 2 | body, 3 | #app { 4 | width: 100%; 5 | height: 100%; 6 | box-sizing: border-box; 7 | padding: 4px; 8 | margin: 0; 9 | background: transparent; 10 | -webkit-app-region: drag; 11 | } 12 | 13 | #app { 14 | margin: 0; 15 | } 16 | 17 | #app .CodeMirror { 18 | height: auto; 19 | } 20 | 21 | .img.placeholder { 22 | opacity: 0.5; 23 | } 24 | 25 | .img, 26 | img { 27 | width: 100%; 28 | height: 100%; 29 | background-size: contain; 30 | background-repeat: no-repeat; 31 | transition: background-image 0.5s ease-in; 32 | } 33 | 34 | .saveButton, 35 | .cancelButton, 36 | .cancelButton { 37 | background: #ffffff; 38 | } 39 | 40 | .saveButton { 41 | position: fixed; 42 | right: 1rem; 43 | bottom: 1rem; 44 | /* button */ 45 | font-size: 14px; 46 | display: inline-block; 47 | padding: 0.5em 1em; 48 | text-decoration: none; 49 | color: #67c5ff; 50 | border: solid 2px #67c5ff; 51 | border-radius: 4px; 52 | } 53 | 54 | .saveButton:hover { 55 | background: #67c5ff; 56 | color: white; 57 | } 58 | 59 | .cancelButton { 60 | position: fixed; 61 | left: 1rem; 62 | bottom: 1rem; 63 | /* button */ 64 | font-size: 14px; 65 | display: inline-block; 66 | padding: 0.5em 1em; 67 | text-decoration: none; 68 | color: #ff6779; 69 | border: solid 2px #ff6779; 70 | border-radius: 4px; 71 | } 72 | 73 | .cancelButton:hover { 74 | background: #ff6779; 75 | color: white; 76 | } 77 | 78 | .copyButton { 79 | position: fixed; 80 | left: 50%; 81 | bottom: 1rem; 82 | /* button */ 83 | font-size: 14px; 84 | display: inline-block; 85 | padding: 0.5em 1em; 86 | text-decoration: none; 87 | color: #468937; 88 | border: solid 2px #468937; 89 | border-radius: 4px; 90 | } 91 | 92 | .copyButton:hover { 93 | background: #468937; 94 | color: white; 95 | } 96 | 97 | /* CodeMirror */ 98 | .CodeMirror { 99 | font-size: 16px; 100 | border-top: 1px solid black; 101 | border-bottom: 1px solid black; 102 | } 103 | 104 | .cm-s-default .cm-trailing-space-a:before, 105 | .cm-s-default .cm-trailing-space-b:before { 106 | position: absolute; 107 | content: "\00B7"; 108 | color: #777; 109 | } 110 | 111 | .cm-s-default .cm-trailing-space-new-line:before { 112 | position: absolute; 113 | content: "\21B5"; 114 | color: #777; 115 | } 116 | -------------------------------------------------------------------------------- /src/renderer/index.ts: -------------------------------------------------------------------------------- 1 | require("./index.css"); 2 | require("codemirror/lib/codemirror.css"); 3 | import { ipcRenderer } from "electron"; 4 | import CodeMirror from "codemirror"; 5 | import "codemirror/addon/edit/continuelist.js"; 6 | import "codemirror/mode/xml/xml.js"; 7 | import "codemirror/mode/javascript/javascript.js"; 8 | import "codemirror/mode/markdown/markdown.js"; 9 | 10 | const app = document.getElementById("app"); 11 | if (!app) { 12 | throw new Error("Not found app"); 13 | } 14 | const textarea = document.createElement("textarea"); 15 | app.appendChild(textarea); 16 | const imgDiv = document.createElement("div"); 17 | imgDiv.className = "img"; 18 | // img 19 | const img = document.createElement("img"); 20 | imgDiv.appendChild(img); 21 | app.appendChild(imgDiv); 22 | // Save button 23 | const saveButton = document.createElement("button"); 24 | saveButton.className = "saveButton"; 25 | saveButton.textContent = "Save"; 26 | // Cancel button 27 | const cancelButton = document.createElement("button"); 28 | cancelButton.className = "cancelButton"; 29 | cancelButton.textContent = "Cancel"; 30 | // Copy 31 | const copyButton = document.createElement("button"); 32 | copyButton.className = "copyButton"; 33 | copyButton.textContent = "Copy"; 34 | 35 | function save() { 36 | ipcRenderer.send("save", editor.getValue()); 37 | } 38 | 39 | function cancel() { 40 | ipcRenderer.send("cancel"); 41 | } 42 | 43 | function copy() { 44 | ipcRenderer.send("copy", editor.getValue()); 45 | } 46 | 47 | saveButton.addEventListener("click", () => { 48 | save(); 49 | }); 50 | cancelButton.addEventListener("click", () => { 51 | cancel(); 52 | }); 53 | copyButton.addEventListener("click", () => { 54 | copy(); 55 | }); 56 | app.appendChild(saveButton); 57 | app.appendChild(cancelButton); 58 | app.appendChild(copyButton); 59 | const editor = CodeMirror.fromTextArea(textarea, { 60 | mode: "markdown", 61 | lineNumbers: false, 62 | lineWrapping: true, 63 | extraKeys: { 64 | Enter: "newlineAndIndentContinueMarkdownList", 65 | "Cmd-Enter": function () { 66 | save(); 67 | } 68 | } 69 | }); 70 | 71 | ipcRenderer.on("reset", () => { 72 | editor.setValue(""); 73 | imgDiv.removeAttribute("style"); 74 | imgDiv.classList.remove("placeholder"); 75 | }); 76 | ipcRenderer.on("update", (event, value: string, imageSrc: string) => { 77 | editor.setValue(value); 78 | requestAnimationFrame(() => { 79 | editor.focus(); 80 | editor.setCursor(editor.lineCount(), 0); 81 | }); 82 | imgDiv.classList.add("placeholder"); 83 | imgDiv.style.backgroundImage = `url(${imageSrc})`; 84 | }); 85 | 86 | ipcRenderer.on("update:image", (event, imageSrc: string) => { 87 | imgDiv.classList.remove("placeholder"); 88 | imgDiv.style.backgroundImage = `url(${imageSrc})`; 89 | }); 90 | -------------------------------------------------------------------------------- /static/tray.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/azu/mumemo/5fff74ba7030095ffb28f11e11bea766a4fc419a/static/tray.png -------------------------------------------------------------------------------- /static/tray@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/azu/mumemo/5fff74ba7030095ffb28f11e11bea766a4fc419a/static/tray@2x.png -------------------------------------------------------------------------------- /tools/create-memo.ts: -------------------------------------------------------------------------------- 1 | import meow from "meow"; 2 | import ndjson from "ndjson"; 3 | import * as fs from "fs"; 4 | import * as path from "path"; 5 | 6 | type Item = { 7 | title: string; 8 | imgKey: string; 9 | body: string; 10 | }; 11 | 12 | const convertNDJSONtoMemo = (jsonFile: string): Promise<{ images: string[]; output: string }> => { 13 | return new Promise((resolve, reject) => { 14 | let output = ""; 15 | const baseDir = path.dirname(jsonFile); 16 | const images: string[] = []; 17 | fs.createReadStream(jsonFile) 18 | .pipe(ndjson.parse()) 19 | .on("data", function (item: Item) { 20 | images.push(path.join(baseDir, `${item.imgKey}.png`)); 21 | output += `![](img/${item.imgKey}.png) 22 | ${item.body.trim().length > 0 ? "> " + item.body.split("\n").join("\n> ") + "\n" : ""} 23 | `; 24 | }) 25 | .on("end", () => { 26 | resolve({ 27 | images, 28 | output 29 | }); 30 | }) 31 | .on("error", (error) => { 32 | reject(error); 33 | }); 34 | }); 35 | }; 36 | 37 | export const cli = meow( 38 | ` 39 | Usage 40 | $ node create-memo [file] 41 | 42 | Options 43 | --output [Path:String] output directory path [required] 44 | 45 | Examples 46 | $ node --require ts-node/register create-memo.ts path/to/memo.json --output path/to/memo 47 | `, 48 | { 49 | flags: { 50 | output: { 51 | type: "string" 52 | } 53 | }, 54 | autoHelp: true, 55 | autoVersion: true 56 | } 57 | ); 58 | 59 | export const run = async ( 60 | input = cli.input, 61 | flags = cli.flags 62 | ): Promise<{ exitStatus: number; stdout: string | null; stderr: Error | null }> => { 63 | const outDir = flags.output; 64 | if (!outDir) { 65 | throw new Error("Require --output"); 66 | } 67 | fs.mkdirSync(outDir, { 68 | recursive: true 69 | }); 70 | fs.mkdirSync(path.join(outDir, "img"), { 71 | recursive: true 72 | }); 73 | const { output, images } = await convertNDJSONtoMemo(input[0]); 74 | // write readme 75 | fs.writeFileSync(path.join(outDir, "README.md"), output, "utf-8"); 76 | // copy images 77 | images.forEach((imagePath) => { 78 | const fileName = path.basename(imagePath); 79 | if (fs.existsSync(imagePath)) { 80 | fs.renameSync(imagePath, path.join(outDir, "img", fileName)); 81 | } else { 82 | console.log("skip copy img" + imagePath); 83 | } 84 | }); 85 | return { 86 | stdout: null, 87 | stderr: null, 88 | exitStatus: 0 89 | }; 90 | }; 91 | if (!module.parent) { 92 | (async () => { 93 | run().then( 94 | ({ exitStatus, stderr, stdout }) => { 95 | if (stdout) { 96 | console.log(stdout); 97 | } 98 | if (stderr) { 99 | console.error(stderr); 100 | } 101 | process.exit(exitStatus); 102 | }, 103 | (error) => { 104 | console.error(error); 105 | process.exit(1); 106 | } 107 | ); 108 | })(); 109 | } 110 | -------------------------------------------------------------------------------- /tools/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "esModuleInterop": true, 7 | "newLine": "LF", 8 | "outDir": "./lib/", 9 | "target": "ES2018", 10 | "sourceMap": false, 11 | "declaration": true, 12 | "jsx": "preserve", 13 | "lib": [ 14 | "es2018", 15 | "dom" 16 | ], 17 | /* Strict Type-Checking Options */ 18 | "strict": true, 19 | /* Additional Checks */ 20 | /* Report errors on unused locals. */ 21 | "noUnusedLocals": true, 22 | /* Report errors on unused parameters. */ 23 | "noUnusedParameters": true, 24 | /* Report error when not all code paths in function return a value. */ 25 | "noImplicitReturns": true, 26 | /* Report errors for fallthrough cases in switch statement. */ 27 | "noFallthroughCasesInSwitch": true 28 | }, 29 | "include": [ 30 | "**/*" 31 | ], 32 | "exclude": [ 33 | ".git", 34 | "node_modules" 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./node_modules/electron-webpack/tsconfig-base.json", 3 | "compilerOptions": { 4 | "skipLibCheck": true, 5 | "outDir": "tmp" 6 | } 7 | } 8 | 9 | --------------------------------------------------------------------------------