├── .github ├── FUNDING.yml ├── maskable-demo.gif └── workflows │ └── main.yml ├── partials ├── ad.hbs ├── github.hbs ├── donate.hbs ├── navbar.hbs ├── meta.hbs ├── clippaths.hbs └── controls.hbs ├── public ├── demo │ ├── proxx.png │ ├── big-island-buses.png │ ├── color-breakdown.png │ ├── svgomg.svg │ ├── spec.svg │ └── insightful-energy.svg ├── favicon │ ├── editor_192.png │ ├── favicon_16.png │ ├── favicon_196.png │ ├── favicon_32.png │ ├── favicon_512.png │ ├── logo.afdesign │ ├── github_social.png │ ├── maskable.afdesign │ ├── maskable_512.png │ ├── monochrome_256.png │ ├── editor.svg │ ├── favicon.svg │ └── maskable.svg ├── toggle │ ├── moon.svg │ └── sun.svg ├── css │ ├── settings.css │ ├── editor.css │ └── viewer.css ├── tigeroakes.svg ├── manifest.json └── kofi.svg ├── .gitignore ├── .editorconfig ├── netlify.toml ├── src ├── viewer │ ├── load-url.js │ ├── libs.js │ ├── keys.js │ ├── change-mask.js │ ├── masks.js │ ├── dialog.js │ └── upload-icon.js ├── settings │ └── mask-settings.js ├── missing-types.d.ts └── editor │ ├── options.js │ ├── history.js │ ├── layer.js │ ├── export.js │ ├── canvas.js │ └── main.js ├── tsconfig.json ├── eslint.config.js ├── netlify └── edge-functions │ └── open.ts ├── LICENSE ├── tests ├── editor │ ├── layer.spec.ts │ └── canvas.spec.ts ├── e2e │ ├── viewer.spec.ts │ ├── settings.spec.ts │ └── export.spec.ts └── viewer │ └── masks.spec.ts ├── README.md ├── playwright.config.ts ├── package.json ├── vite.config.js ├── index.html ├── settings.html └── editor.html /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | ko_fi: notwoods 2 | -------------------------------------------------------------------------------- /partials/ad.hbs: -------------------------------------------------------------------------------- 1 |
-------------------------------------------------------------------------------- /public/demo/proxx.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NotWoods/maskable/HEAD/public/demo/proxx.png -------------------------------------------------------------------------------- /.github/maskable-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NotWoods/maskable/HEAD/.github/maskable-demo.gif -------------------------------------------------------------------------------- /public/favicon/editor_192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NotWoods/maskable/HEAD/public/favicon/editor_192.png -------------------------------------------------------------------------------- /public/favicon/favicon_16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NotWoods/maskable/HEAD/public/favicon/favicon_16.png -------------------------------------------------------------------------------- /public/favicon/favicon_196.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NotWoods/maskable/HEAD/public/favicon/favicon_196.png -------------------------------------------------------------------------------- /public/favicon/favicon_32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NotWoods/maskable/HEAD/public/favicon/favicon_32.png -------------------------------------------------------------------------------- /public/favicon/favicon_512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NotWoods/maskable/HEAD/public/favicon/favicon_512.png -------------------------------------------------------------------------------- /public/favicon/logo.afdesign: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NotWoods/maskable/HEAD/public/favicon/logo.afdesign -------------------------------------------------------------------------------- /public/demo/big-island-buses.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NotWoods/maskable/HEAD/public/demo/big-island-buses.png -------------------------------------------------------------------------------- /public/demo/color-breakdown.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NotWoods/maskable/HEAD/public/demo/color-breakdown.png -------------------------------------------------------------------------------- /public/favicon/github_social.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NotWoods/maskable/HEAD/public/favicon/github_social.png -------------------------------------------------------------------------------- /public/favicon/maskable.afdesign: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NotWoods/maskable/HEAD/public/favicon/maskable.afdesign -------------------------------------------------------------------------------- /public/favicon/maskable_512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NotWoods/maskable/HEAD/public/favicon/maskable_512.png -------------------------------------------------------------------------------- /public/favicon/monochrome_256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NotWoods/maskable/HEAD/public/favicon/monochrome_256.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | dist 4 | /test-results/ 5 | /playwright-report/ 6 | /blob-report/ 7 | /playwright/.cache/ 8 | -------------------------------------------------------------------------------- /partials/github.hbs: -------------------------------------------------------------------------------- 1 |

2 | Edit on 3 | GitHub 4 |

-------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | tab_width = 4 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /public/toggle/moon.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /public/css/settings.css: -------------------------------------------------------------------------------- 1 | .settings { 2 | max-width: 40rem; 3 | margin: 0 auto; 4 | } 5 | .mask-settings { 6 | columns: 2; 7 | column-gap: 1rem; 8 | } 9 | 10 | @media (max-width: 40rem) { 11 | .mask-settings { 12 | columns: 1; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | publish = "dist/" 3 | command = "npm run build" 4 | 5 | [build.environment] 6 | NODE_VERSION = "20" 7 | NPM_VERSION = "9.4.0" 8 | 9 | [[plugins]] 10 | # Installs the Lighthouse Build Plugin for all deploy contexts 11 | package = "@netlify/plugin-lighthouse" 12 | 13 | [[edge_functions]] 14 | path = "/open" 15 | function = "open" 16 | -------------------------------------------------------------------------------- /public/demo/svgomg.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /partials/donate.hbs: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | Created by 5 | 6 | 7 | 8 |
9 | 10 | Buy Me a Coffee at ko-fi.com 11 | 12 |
-------------------------------------------------------------------------------- /public/toggle/sun.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/viewer/load-url.js: -------------------------------------------------------------------------------- 1 | import { DialogManager } from './dialog.js'; 2 | 3 | const urlDialog = new DialogManager(document.querySelector('.url-dialog')); 4 | urlDialog.setupContent = function () { 5 | const selectedUrl = new URL(location.href).searchParams.get('demo'); 6 | 7 | if (selectedUrl) { 8 | /** @type {HTMLInputElement} */ 9 | const input = this.dialog.querySelector('#url'); 10 | input.value = new URL(selectedUrl, location.href).href; 11 | } 12 | return () => {}; 13 | }; 14 | 15 | for (const element of document.querySelectorAll('.toggle--url')) { 16 | element.addEventListener('click', () => urlDialog.toggleDialog()); 17 | } 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": ["node_modules/**", "src/*-bundle.js", "workbox-*.js", "sw.js"], 3 | "include": [ 4 | "src/**/*", 5 | "tests/**/*", 6 | "src/missing-types.d.ts", 7 | "*.mjs", 8 | "*.cjs" 9 | ], 10 | "compilerOptions": { 11 | "allowJs": true, 12 | "checkJs": true, 13 | "target": "es2015", 14 | "module": "nodenext", 15 | "moduleResolution": "nodenext", 16 | "skipLibCheck": true, 17 | "strict": true, 18 | "strictNullChecks": false, 19 | "noImplicitAny": false, 20 | "noEmit": true, 21 | "baseUrl": ".", 22 | "types": ["node", "vite/client", "vite-plugin-pwa/client"], 23 | "lib": ["dom", "dom.iterable", "es2015", "es2018.promise"] 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import js from '@eslint/js'; 3 | import eslintConfigPrettier from 'eslint-config-prettier'; 4 | import globals from 'globals'; 5 | import gitignore from 'eslint-config-flat-gitignore'; 6 | 7 | /** 8 | * @type {import("eslint").Linter.Config[]} 9 | */ 10 | const config = [ 11 | gitignore(), 12 | js.configs.recommended, 13 | eslintConfigPrettier, 14 | { 15 | languageOptions: { 16 | globals: { 17 | ...globals.browser, 18 | fathom: 'readonly', 19 | }, 20 | 21 | ecmaVersion: 2020, 22 | sourceType: 'module', 23 | }, 24 | 25 | rules: { 26 | 'prefer-arrow-callback': ['error', { allowNamedFunctions: true }], 27 | 'prefer-const': ['error', { destructuring: 'all' }], 28 | 'no-var': 'error', 29 | }, 30 | }, 31 | ]; 32 | 33 | export default config; 34 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | pull_request: 5 | push: 6 | 7 | jobs: 8 | unit_test: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-node@v4 14 | with: 15 | node-version: lts/* 16 | cache: 'npm' 17 | - run: npm ci 18 | - run: npm run lint 19 | - run: npm run check 20 | - run: npm run test 21 | 22 | e2e_test: 23 | runs-on: ubuntu-latest 24 | 25 | steps: 26 | - uses: actions/checkout@v4 27 | - uses: actions/setup-node@v4 28 | with: 29 | node-version: lts/* 30 | cache: 'npm' 31 | - run: npm ci 32 | - run: npm run build 33 | - name: Install Playwright Browsers 34 | run: npx playwright install --with-deps 35 | - run: npx playwright test 36 | -------------------------------------------------------------------------------- /public/favicon/editor.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/favicon/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /netlify/edge-functions/open.ts: -------------------------------------------------------------------------------- 1 | function toDataUrl(blob: Blob) { 2 | return new Promise((resolve, reject) => { 3 | const reader = new FileReader(); 4 | reader.onload = () => resolve(reader.result as string); 5 | reader.onerror = () => reject(reader.error); 6 | reader.onabort = () => reject(new Error('Read aborted')); 7 | reader.readAsDataURL(blob); 8 | }); 9 | } 10 | 11 | export default async function open(req: Request) { 12 | if (req.method !== 'POST') { 13 | return new Response('Method Not Allowed', { status: 405 }); 14 | } 15 | 16 | const body = await req.formData(); 17 | const image = body.get('image'); 18 | if (image === null || typeof image === 'string') { 19 | return new Response('Unsupported Media Type', { status: 415 }); 20 | } 21 | 22 | const demoUrl = await toDataUrl(image); 23 | const params = new URLSearchParams({ demo: demoUrl }); 24 | 25 | return Response.redirect(new URL(`/?${params}`, req.url).href, 303); 26 | } 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Tiger Oakes 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/editor/layer.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest'; 2 | import { backgroundLayer, createLayer } from '../../src/editor/layer.js'; 3 | 4 | describe('createLayer', () => { 5 | test('create layer with fill', () => { 6 | expect(createLayer('#f00')).toEqual({ 7 | name: 'Layer', 8 | fill: '#f00', 9 | padding: 0, 10 | x: 0, 11 | y: 0, 12 | alpha: 100, 13 | rotation: 0, 14 | locked: false, 15 | fit: 'contain', 16 | }); 17 | }); 18 | 19 | test('create layer with image', () => { 20 | const img = { close() {} } as ImageBitmap; 21 | expect(createLayer('#ff0', img)).toEqual({ 22 | src: img, 23 | name: 'Layer', 24 | fill: '#ff0', 25 | padding: 0, 26 | x: 0, 27 | y: 0, 28 | alpha: 0, 29 | rotation: 0, 30 | locked: false, 31 | fit: 'contain', 32 | }); 33 | }); 34 | 35 | test('create background layer', () => { 36 | expect(backgroundLayer()).toEqual({ 37 | name: 'Layer', 38 | fill: '#448AFF', 39 | padding: 0, 40 | x: 0, 41 | y: 0, 42 | alpha: 100, 43 | rotation: 0, 44 | locked: true, 45 | fit: 'contain', 46 | }); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /partials/navbar.hbs: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Maskable.app 4 | 5 | _Preview 6 | [maskable icons](https://css-tricks.com/maskable-icons-android-adaptive-icons-for-your-pwa/) 7 | in the browser!_ 8 | 9 | ![Demo usage](.github/maskable-demo.gif) 10 | 11 | --- 12 | 13 | [Maskable icons](https://www.w3.org/TR/appmanifest/#examples-of-masks) allow web 14 | developers to specify a full-bleed icon that will be cropped by the user-agent 15 | to match other icons on the device. On Android, this lets developers get rid of 16 | the default white background around their icons and use the entire provided 17 | space. 18 | 19 | It's important to test maskable icons to ensure the important regions of the 20 | icon are visible on any device and in any shape. Upload a maskable icon or drag 21 | and drop it into [Maskable.app](https://maskable.app), then preview how it will 22 | appear on different Android launchers. 23 | 24 | ## Developing 25 | 26 | Install dependencies: 27 | 28 | ```shell 29 | npm install 30 | ``` 31 | 32 | Once the modules are installed, run: 33 | 34 | ```shell 35 | npm run dev 36 | ``` 37 | 38 | This starts a development server using [Vite](https://vitejs.dev/). 39 | 40 | ## Licensing 41 | 42 | This project is available under the MIT License. 43 | -------------------------------------------------------------------------------- /partials/meta.hbs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /src/viewer/libs.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | if (window.customElements) { 4 | import('file-drop-element'); 5 | import('dark-mode-toggle'); 6 | } 7 | 8 | /** @type {import('dark-mode-toggle').DarkModeToggle} */ 9 | const toggle = document.querySelector('dark-mode-toggle'); 10 | /** @type {HTMLElement | null} */ 11 | const ad = document.querySelector('[data-ea-publisher]'); 12 | 13 | /** 14 | * Set or remove the `dark` class on body and ads. 15 | * @param {boolean} darkMode 16 | */ 17 | function updateDarkModeClasses(darkMode) { 18 | document.body.classList.toggle('dark', darkMode); 19 | if (ad) { 20 | ad.classList.toggle('dark', darkMode); 21 | } 22 | } 23 | 24 | // Initialize the toggle based on `prefers-color-scheme`, defaulting to 'light'. 25 | toggle.mode = matchMedia('(prefers-color-scheme: dark)').matches 26 | ? 'dark' 27 | : 'light'; 28 | // Set or remove the `dark` class the first time. 29 | updateDarkModeClasses(toggle.mode === 'dark'); 30 | // Listen for toggle changes (which includes `prefers-color-scheme` changes) 31 | // and toggle the `dark` class accordingly. 32 | toggle.addEventListener('colorschemechange', () => { 33 | updateDarkModeClasses(toggle.mode === 'dark'); 34 | }); 35 | 36 | if (document.monetization && ad) { 37 | function onMonetizationStart() { 38 | if (document.monetization.state === 'started') { 39 | console.log('Payment started, hiding ads'); 40 | ad.hidden = true; 41 | } 42 | } 43 | document.monetization.addEventListener( 44 | 'monetizationstart', 45 | onMonetizationStart, 46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /src/viewer/keys.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Builds a map of access keys to input elements. 3 | * Inputs with the `accesskey` HTML attribute will be placed in here. 4 | * @returns {ReadonlyMap} 5 | */ 6 | function getAccessKeys() { 7 | /** @type {Map} */ 8 | const accessKeys = new Map(); 9 | /** @type {NodeListOf} */ 10 | const focusable = document.querySelectorAll('input[accesskey]'); 11 | for (const input of focusable) { 12 | accessKeys.set(input.accessKey, input); 13 | } 14 | return accessKeys; 15 | } 16 | 17 | const accessKeys = getAccessKeys(); 18 | const masks = /** @type {HTMLCollectionOf} */ ( 19 | document.getElementsByClassName('mask__option') 20 | ); 21 | document.addEventListener('keydown', (evt) => { 22 | if (evt.repeat) return; // Ignore holding down keys 23 | if (evt.target instanceof HTMLInputElement && evt.target.type === 'text') { 24 | // Ignore typing in text fields 25 | return; 26 | } 27 | 28 | const index = Number(evt.key); 29 | 30 | /** @type {HTMLElement | undefined} */ 31 | let clickable; 32 | if (Number.isNaN(index)) { 33 | clickable = accessKeys.get(evt.key); 34 | } else { 35 | // Find option using 0-9 access key 36 | const maskOption = masks[index]; 37 | if (maskOption instanceof HTMLAnchorElement) { 38 | clickable = maskOption; 39 | } else if (maskOption) { 40 | clickable = maskOption.querySelector('input'); 41 | } 42 | } 43 | 44 | if (clickable) { 45 | evt.preventDefault(); 46 | clickable.click(); 47 | } 48 | }); 49 | -------------------------------------------------------------------------------- /src/settings/mask-settings.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | const form = document.querySelector('form'); 4 | 5 | /** 6 | * Get shown masks from storage. 7 | * Also ensures settings page checkboxes match current saved state. 8 | * @returns {Set} 9 | */ 10 | function loadShownMasks() { 11 | const savedShownMasks = localStorage.getItem('shownMasks'); 12 | if (savedShownMasks != undefined) { 13 | const savedHiddenMasksList = new Set(savedShownMasks.split(',')); 14 | /** @type {NodeListOf} */ 15 | const maskCheckboxes = form['mask']; 16 | for (const mask of maskCheckboxes) { 17 | mask.checked = savedHiddenMasksList.has(mask.value); 18 | } 19 | return savedHiddenMasksList; 20 | } else { 21 | /** @type {NodeListOf} */ 22 | const checkedMasks = form.querySelectorAll('input[name="mask"]:checked'); 23 | return new Set(Array.from(checkedMasks, (element) => element.value)); 24 | } 25 | } 26 | 27 | /** 28 | * Save shown masks to storage. 29 | * @param {Iterable} shownMasks 30 | */ 31 | function saveShownMasks(shownMasks) { 32 | const serialized = Array.from(shownMasks).join(','); 33 | localStorage.setItem('shownMasks', serialized); 34 | } 35 | 36 | const savedMasks = loadShownMasks(); 37 | 38 | form.addEventListener('change', (event) => { 39 | const input = /** @type {HTMLInputElement} */ (event.target); 40 | if (input.name === 'mask') { 41 | if (input.checked) { 42 | savedMasks.add(input.value); 43 | } else { 44 | savedMasks.delete(input.value); 45 | } 46 | saveShownMasks(savedMasks); 47 | } 48 | }); 49 | -------------------------------------------------------------------------------- /partials/clippaths.hbs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 | 9 | 12 | 13 | 14 | 17 | 18 | 19 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from '@playwright/test'; 2 | 3 | const webServer = !!process.env.CI; 4 | 5 | /** 6 | * See https://playwright.dev/docs/test-configuration. 7 | */ 8 | export default defineConfig({ 9 | testDir: './tests/e2e', 10 | /* Run tests in files in parallel */ 11 | fullyParallel: true, 12 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 13 | forbidOnly: !!process.env.CI, 14 | /* Retry on CI only */ 15 | retries: process.env.CI ? 2 : 0, 16 | workers: process.env.CI ? 1 : undefined, 17 | use: { 18 | baseURL: webServer ? 'http://localhost:4173' : 'https://maskable.app/', 19 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 20 | trace: 'on-first-retry', 21 | }, 22 | 23 | /* Configure projects for major browsers */ 24 | projects: [ 25 | { 26 | name: 'chromium', 27 | use: { ...devices['Desktop Chrome'] }, 28 | }, 29 | 30 | { 31 | name: 'firefox', 32 | use: { ...devices['Desktop Firefox'] }, 33 | }, 34 | 35 | { 36 | name: 'webkit', 37 | use: { ...devices['Desktop Safari'] }, 38 | }, 39 | 40 | /* Test against mobile viewports. */ 41 | { 42 | name: 'Mobile Chrome', 43 | use: { ...devices['Pixel 5'] }, 44 | }, 45 | { 46 | name: 'Mobile Safari', 47 | use: { ...devices['iPhone 12'] }, 48 | }, 49 | ], 50 | 51 | /* Run your local dev server before starting the tests */ 52 | webServer: webServer 53 | ? { 54 | command: 'npm run preview', 55 | port: 4173, 56 | reuseExistingServer: !process.env.CI, 57 | } 58 | : undefined, 59 | }); 60 | -------------------------------------------------------------------------------- /src/missing-types.d.ts: -------------------------------------------------------------------------------- 1 | // Prefixed CSS properties 2 | 3 | import { FileDropEvent } from 'file-drop-element'; 4 | 5 | interface Fathom { 6 | trackPageview(opts?: { url?: string; referrer?: string }): void; 7 | trackGoal(code: string, cents: number): void; 8 | } 9 | 10 | interface ColorSelectionOptions { 11 | /** 12 | * An `AbortSignal`. The eyedropper mode will be aborted when the `AbortSignal`'s `abort()` method is called. 13 | */ 14 | signal?: AbortSignal; 15 | } 16 | 17 | interface ColorSelectionResult { 18 | /** 19 | * A string representing the selected color, in hexadecimal sRGB format (`#aabbcc`). 20 | */ 21 | sRGBHex: string; 22 | } 23 | 24 | declare global { 25 | interface CSSStyleDeclaration { 26 | webkitClipPath?: string; 27 | } 28 | 29 | interface HTMLElementEventMap { 30 | filedrop: FileDropEvent; 31 | } 32 | 33 | /** 34 | * The `EyeDropper` interface represents an instance of an eyedropper tool that can be opened and used by the user to select colors from the screen. 35 | * @see https://developer.mozilla.org/en-US/docs/Web/API/EyeDropper 36 | */ 37 | class EyeDropper { 38 | constructor(); 39 | /** 40 | * The `EyeDropper.open()` method starts the eyedropper mode, 41 | * returning a promise which is fulfilled once the user has either selected a color or dismissed the eyedropper mode. 42 | * @param options An options object to pass an `AbortSignal` signal. 43 | * @see https://developer.mozilla.org/en-US/docs/Web/API/EyeDropper/open 44 | */ 45 | open(options?: ColorSelectionOptions): Promise; 46 | } 47 | 48 | interface Window { 49 | EyeDropper?: typeof EyeDropper; 50 | } 51 | 52 | let fathom: Fathom; 53 | } 54 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "maskable", 3 | "private": true, 4 | "type": "module", 5 | "scripts": { 6 | "lint": "prettier . --ignore-path .gitignore --check", 7 | "postlint": "eslint .", 8 | "format": "prettier . --ignore-path .gitignore --write", 9 | "postformat": "eslint . --fix", 10 | "test": "npm run check && vitest", 11 | "e2e": "playwright test --project chromium", 12 | "check": "tsc --noEmit", 13 | "dev": "vite", 14 | "build": "vite build", 15 | "preview": "vite preview" 16 | }, 17 | "dependencies": { 18 | "dark-mode-toggle": "^0.15.0", 19 | "file-drop-element": "^1.0.1" 20 | }, 21 | "devDependencies": { 22 | "@eslint/js": "^9.8.0", 23 | "@playwright/test": "^1.46.0", 24 | "@types/node": "^22.1.0", 25 | "@typescript/lib-dom": "npm:@types/web@^0.0.151", 26 | "@vitejs/plugin-legacy": "^5.4.1", 27 | "eslint": "^9.8.0", 28 | "eslint-config-flat-gitignore": "^0.1.8", 29 | "eslint-config-prettier": "^9.1.0", 30 | "globals": "^15.8.0", 31 | "jsdom": "^24.1.1", 32 | "prettier": "~3.3.3", 33 | "types-wm": "^1.1.0", 34 | "typescript": "^5.5.4", 35 | "vite": "^5.3.5", 36 | "vite-plugin-handlebars": "^2.0.0", 37 | "vite-plugin-pwa": "^0.20.1", 38 | "vite-plugin-webfont-dl": "^3.9.4", 39 | "vitest": "^2.0.4" 40 | }, 41 | "prettier": { 42 | "singleQuote": true, 43 | "proseWrap": "always", 44 | "overrides": [ 45 | { 46 | "files": [ 47 | "*.html", 48 | "*.hbs" 49 | ], 50 | "options": { 51 | "printWidth": 120, 52 | "singleQuote": false 53 | } 54 | }, 55 | { 56 | "files": "meta.hbs", 57 | "options": { 58 | "parser": "html" 59 | } 60 | } 61 | ] 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /public/tigeroakes.svg: -------------------------------------------------------------------------------- 1 | 2 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /public/favicon/maskable.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { resolve } from 'node:path'; 2 | import { fileURLToPath } from 'node:url'; 3 | import { defineConfig } from 'vite'; 4 | import legacy from '@vitejs/plugin-legacy'; 5 | import handlebars from 'vite-plugin-handlebars'; 6 | import { VitePWA as pwa } from 'vite-plugin-pwa'; 7 | import { ViteWebfontDownload as webfont } from 'vite-plugin-webfont-dl'; 8 | 9 | const __dirname = fileURLToPath(new URL('.', import.meta.url)); 10 | 11 | export default defineConfig({ 12 | build: { 13 | minify: false, 14 | rollupOptions: { 15 | input: { 16 | viewer: resolve(__dirname, 'index.html'), 17 | editor: resolve(__dirname, 'editor.html'), 18 | settings: resolve(__dirname, 'settings.html'), 19 | }, 20 | }, 21 | }, 22 | plugins: [ 23 | handlebars({ 24 | partialDirectory: resolve(__dirname, 'partials'), 25 | helpers: { 26 | activeIf(context, name) { 27 | if (context === name) { 28 | return ' navbar__link--active'; 29 | } else { 30 | return ''; 31 | } 32 | }, 33 | }, 34 | }), 35 | webfont( 36 | 'https://fonts.googleapis.com/css2?family=Lato:wght@400;900&display=swap', 37 | ), 38 | pwa({ 39 | manifest: false, 40 | workbox: { 41 | cacheId: 'maskable.app', 42 | globPatterns: [ 43 | '*.{html,css,svg,woff2}', 44 | 'assets/*.js', 45 | 'demo/*.{png,svg}', 46 | 'favicon/favicon_*.png', 47 | 'toggle/*.svg', 48 | ], 49 | globIgnores: ['assets/*-legacy.*.js', 'open'], 50 | ignoreURLParametersMatching: [/demo/, /fbclid/], 51 | }, 52 | }), 53 | legacy({ 54 | targets: ['defaults', 'not IE 11', 'kaios >= 2'], 55 | }), 56 | ], 57 | test: { 58 | environment: 'jsdom', 59 | include: ['tests/**/*.spec.ts'], 60 | exclude: ['tests/e2e/**/*'], 61 | }, 62 | }); 63 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Maskable.app", 3 | "short_name": "Maskable", 4 | "start_url": ".", 5 | "display": "standalone", 6 | "background_color": "#fafafa", 7 | "theme_color": "#FFD567", 8 | "description": "Preview maskable icons in the browser.", 9 | "icons": [ 10 | { 11 | "src": "favicon/favicon_512.png", 12 | "sizes": "512x512", 13 | "type": "image/png", 14 | "purpose": "any" 15 | }, 16 | { 17 | "src": "favicon/favicon.svg", 18 | "sizes": "any", 19 | "type": "image/svg+xml", 20 | "purpose": "any" 21 | }, 22 | { 23 | "src": "favicon/maskable_512.png", 24 | "sizes": "512x512", 25 | "type": "image/png", 26 | "purpose": "maskable" 27 | }, 28 | { 29 | "src": "favicon/maskable.svg", 30 | "sizes": "any", 31 | "type": "image/svg+xml", 32 | "purpose": "maskable" 33 | }, 34 | { 35 | "src": "favicon/monochrome_256.png", 36 | "sizes": "256x256", 37 | "type": "image/png", 38 | "purpose": "monochrome" 39 | } 40 | ], 41 | "shortcuts": [ 42 | { 43 | "name": "Editor", 44 | "description": "Build your own maskable icon", 45 | "url": "/editor", 46 | "icons": [ 47 | { 48 | "src": "favicon/editor_192.png", 49 | "sizes": "192x192", 50 | "type": "image/png", 51 | "purpose": "any monochrome" 52 | }, 53 | { 54 | "src": "favicon/editor.svg", 55 | "sizes": "any", 56 | "type": "image/svg+xml", 57 | "purpose": "any monochrome" 58 | } 59 | ] 60 | } 61 | ], 62 | "share_target": { 63 | "action": "/open", 64 | "method": "POST", 65 | "enctype": "multipart/form-data", 66 | "params": { 67 | "files": [ 68 | { 69 | "name": "image", 70 | "accept": ["image/*", ".png"] 71 | } 72 | ] 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /tests/e2e/viewer.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | 3 | test.describe('Viewer', () => { 4 | test.beforeEach(async ({ page }) => { 5 | await page.goto('/'); 6 | }); 7 | 8 | test('default preview', async ({ page }) => { 9 | await expect(page).toHaveTitle('Maskable.app'); 10 | 11 | await expect( 12 | page.getByRole('img', { name: 'Preview of maskable icon' }), 13 | ).toHaveAttribute('src', /demo\/spec\.svg$/); 14 | }); 15 | 16 | [ 17 | { name: 'W3C Example', file: 'spec.svg' }, 18 | { name: 'Color Breakdown', file: 'color-breakdown.png' }, 19 | { name: 'Insightful Energy', file: 'insightful-energy.svg' }, 20 | { name: 'Big Island Buses', file: 'big-island-buses.png' }, 21 | { name: 'PROXX', file: 'proxx.png' }, 22 | { name: 'SVGOMG', file: 'svgomg.svg' }, 23 | ].forEach(({ name, file }) => { 24 | test(`demo preview ${name}`, async ({ page }) => { 25 | await page.getByRole('list').getByRole('link', { name }).click(); 26 | 27 | const expectedSrc = new RegExp(`demo/${file.replace('.', '\\.')}$`); 28 | 29 | await expect( 30 | page.getByRole('img', { name: 'Preview of maskable icon' }), 31 | ).toHaveAttribute('src', expectedSrc); 32 | await expect( 33 | page.getByRole('img', { name: 'Preview of original icon' }), 34 | ).toHaveAttribute('src', expectedSrc); 35 | }); 36 | }); 37 | 38 | test('show ghost image when control is checked', async ({ page }) => { 39 | const ghostIcon = page.getByRole('img', { 40 | name: 'Preview of original icon', 41 | }); 42 | const ghostIconContainer = ghostIcon.locator('xpath=..'); 43 | 44 | await expect(ghostIconContainer).toHaveCSS('opacity', '0'); 45 | 46 | await page.getByLabel('Show ghost image').check(); 47 | 48 | await expect(ghostIconContainer).not.toHaveCSS('opacity', '0'); 49 | await expect(ghostIconContainer).toHaveCSS('opacity', '0.4'); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /src/viewer/change-mask.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { applyMask, simpleMasks } from './masks.js'; 3 | 4 | class MaskManager { 5 | constructor() { 6 | this.container = document.querySelector('.masks'); 7 | this.hideMasks(); 8 | } 9 | 10 | /** 11 | * @type {NodeListOf} 12 | */ 13 | get masks() { 14 | return this.container.querySelectorAll('.mask__option input'); 15 | } 16 | 17 | /** 18 | * Get hidden masks from storage. Defaults to masks that aren't well supported. 19 | * @private 20 | * @returns {readonly string[]} 21 | */ 22 | getShownMasks() { 23 | const shownMasks = localStorage.getItem('shownMasks'); 24 | if (shownMasks != undefined) { 25 | return shownMasks.split(','); 26 | } else { 27 | undefined; 28 | } 29 | } 30 | 31 | hideMasks() { 32 | const shownMasks = this.getShownMasks(); 33 | 34 | const toShow = new Set(shownMasks ?? simpleMasks); 35 | for (const mask of this.masks) { 36 | mask.parentElement.hidden = !toShow.has(mask.value); 37 | } 38 | } 39 | } 40 | 41 | /** @type {HTMLElement} */ 42 | const container = document.querySelector('.icon__grid'); 43 | /** @type {NodeListOf} All elements to change the mask of. */ 44 | const masked = document.querySelectorAll('.masked'); 45 | /** @type {NodeListOf} */ 46 | const icons = document.querySelectorAll('.icon'); 47 | 48 | const maskManager = new MaskManager(); 49 | maskManager.container.addEventListener('change', (evt) => { 50 | const radio = /** @type {HTMLInputElement} */ (evt.target); 51 | if (radio.name === 'mask') { 52 | applyMask(masked, icons, radio.value); 53 | } 54 | }); 55 | document.querySelector('.controls').addEventListener('change', (evt) => { 56 | const checkbox = /** @type {HTMLInputElement} */ (evt.target); 57 | switch (checkbox.name) { 58 | case 'shrink': { 59 | // Shrink the icon to 1/4 size 60 | const size = checkbox.checked ? '0.25' : '1'; 61 | container.style.transform = `scale(${size})`; 62 | break; 63 | } 64 | case 'ghost': 65 | // Show ghost image behind icon 66 | container.classList.toggle('icon--ghost', checkbox.checked); 67 | break; 68 | } 69 | }); 70 | -------------------------------------------------------------------------------- /tests/editor/canvas.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest'; 2 | import { backgroundLayer } from '../../src/editor/layer.js'; 3 | import { toUrl, CanvasController } from '../../src/editor/canvas.js'; 4 | 5 | function mockCanvas(opts: { toBlob?: boolean }) { 6 | const canvas = { 7 | toDataURL(type: string) { 8 | if (type === 'image/png') { 9 | return 'data:,MOCK'; 10 | } else { 11 | throw new Error(); 12 | } 13 | }, 14 | } as HTMLCanvasElement; 15 | 16 | if (opts.toBlob) { 17 | canvas.toBlob = function (callback, type) { 18 | if (type === 'image/png') { 19 | callback(new Blob()); 20 | } else { 21 | throw new Error(); 22 | } 23 | }; 24 | } 25 | 26 | return canvas; 27 | } 28 | 29 | globalThis.URL.createObjectURL = () => 'blob:MOCK'; 30 | 31 | describe('toUrl', () => { 32 | test('Return data URL when requested', async () => { 33 | const canvas1 = mockCanvas({ toBlob: true }); 34 | expect(await toUrl(canvas1, false)).toBe('data:,MOCK'); 35 | 36 | const canvas2 = mockCanvas({ toBlob: false }); 37 | expect(await toUrl(canvas2, false)).toBe('data:,MOCK'); 38 | }); 39 | 40 | test('Return blob URL when requested', async () => { 41 | const canvas = mockCanvas({ toBlob: true }); 42 | expect(await toUrl(canvas, true)).toMatch(/^blob:/); 43 | }); 44 | 45 | test(`Return data URL when blob URL isn't supported`, async () => { 46 | const canvas = mockCanvas({ toBlob: false }); 47 | expect(await toUrl(canvas, true)).toBe('data:,MOCK'); 48 | }); 49 | }); 50 | 51 | describe('CanvasController', () => { 52 | test('Default values', async () => { 53 | const controller = new CanvasController(); 54 | expect(controller.getLayerCount()).toBe(0); 55 | expect(controller.getSize()).toBe(1024); 56 | }); 57 | 58 | test('Add and remove layers', async () => { 59 | const controller = new CanvasController(); 60 | const layer1 = backgroundLayer(); 61 | const layer2 = backgroundLayer(); 62 | const layer3 = backgroundLayer(); 63 | controller.add(layer1, []); 64 | controller.add(layer2, []); 65 | expect(controller.getLayerCount()).toBe(2); 66 | 67 | controller.delete(layer1); 68 | expect(controller.getLayerCount()).toBe(1); 69 | 70 | // Layer not in controller 71 | controller.delete(layer3); 72 | expect(controller.getLayerCount()).toBe(1); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /partials/controls.hbs: -------------------------------------------------------------------------------- 1 |
2 | 5 |

Masks

6 | 10 | 14 | 18 | 22 | 26 | 30 | 34 | 38 | 42 | 46 | 50 | 54 | 55 | More... 56 | 57 |
58 |
59 |

Controls

60 | 64 | 68 |
-------------------------------------------------------------------------------- /src/viewer/masks.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {Readonly>>} 3 | * Set of masks corresponding to radio buttons on the page. 4 | */ 5 | const defaultMasks = { 6 | none: 'inset(0)', 7 | circle: 'inset(6.36% round 50%)', 8 | rounded_rect: 'inset(6.36% round 34px)', 9 | sharp_rect: 'inset(6.36%)', 10 | drop: 'inset(6.36% round 50% 50% 34px)', 11 | cylinder: 'inset(6.36% round 50% / 30%)', 12 | minimum: 'inset(10% round 50%)', 13 | squircle: 'url(#squircle)', 14 | flower: 'url(#flower)', 15 | pebble: 'url(#pebble)', 16 | vessel: 'url(#vessel)', 17 | hexagon: 'url(#hexagon)', 18 | }; 19 | /** 20 | * @type {Readonly>>} 21 | */ 22 | const borderRadiiAndScale = { 23 | none: ['0', 'scale(1)'], 24 | circle: ['50%', 'scale(1.15)'], 25 | rounded_rect: ['34px', 'scale(1.15)'], 26 | sharp_rect: ['0', 'scale(1.15)'], 27 | drop: ['50% 50% 34px', 'scale(1.15)'], 28 | cylinder: ['50% / 30%', 'scale(1.15)'], 29 | minimum: ['50%', 'scale(1.25)'], 30 | }; 31 | 32 | /** 33 | * Checks if the clip-path CSS property is supported in the browser. 34 | */ 35 | function maskSupport() { 36 | return CSS.supports('(clip-path: inset(0)) or (-webkit-clip-path: inset(0))'); 37 | } 38 | 39 | /** 40 | * Apply the given mask onto the given HTML elements. 41 | * @param {Iterable} masked 42 | * @param {Iterable} icons 43 | * @param {string} maskName Name of a mask. 44 | * @returns {boolean} True if successful. 45 | */ 46 | export function applyMask(masked, icons, maskName) { 47 | if (maskSupport()) { 48 | const clipPath = defaultMasks[maskName]; 49 | if (!clipPath) return false; 50 | 51 | for (const mask of masked) { 52 | // When the radio buttons are selected, 53 | // change the clip path to the new mask. 54 | mask.style.webkitClipPath = clipPath; 55 | mask.style.clipPath = clipPath; 56 | } 57 | } else { 58 | const tuple = borderRadiiAndScale[maskName]; 59 | if (!tuple) return false; 60 | 61 | const [borderRadius, scale] = tuple; 62 | for (const icon of icons) { 63 | icon.style.transform = scale; 64 | } 65 | for (const mask of masked) { 66 | mask.style.borderRadius = borderRadius; 67 | } 68 | } 69 | return true; 70 | } 71 | 72 | /** 73 | * Masks that don't require SVG path references, which can't be animated. 74 | */ 75 | export const simpleMasks = Object.keys(borderRadiiAndScale); 76 | -------------------------------------------------------------------------------- /src/editor/options.js: -------------------------------------------------------------------------------- 1 | /** @type {HTMLFormElement} */ 2 | const options = document.querySelector('.options'); 3 | /** @type {HTMLParagraphElement} */ 4 | const backgroundInfo = document.querySelector('.info--background'); 5 | /** @type {HTMLParagraphElement} */ 6 | const fitInfo = document.querySelector('.info--fit'); 7 | 8 | /** 9 | * Return the preview element corresponding to a changing input. 10 | * @param {HTMLInputElement} input 11 | * @returns {HTMLSpanElement | HTMLInputElement | undefined} 12 | */ 13 | function findPreview(input) { 14 | if (input.className === 'control__input') { 15 | return /** @type {HTMLSpanElement | HTMLInputElement} */ ( 16 | input.nextElementSibling 17 | ); 18 | } else if (input.className === 'control__preview') { 19 | // Special case for the "color" text input 20 | return /** @type {HTMLInputElement} */ (input.previousElementSibling); 21 | } else { 22 | return undefined; 23 | } 24 | } 25 | 26 | /** 27 | * Updates the preview span adjacent to an input with the current value. 28 | * @param {HTMLInputElement} input 29 | */ 30 | export function updatePreview(input) { 31 | const preview = findPreview(input); 32 | if (!preview) return; 33 | 34 | const text = input.value + (preview.dataset.suffix || ''); 35 | if (preview instanceof HTMLInputElement) { 36 | preview.value = text; 37 | } else { 38 | preview.textContent = text; 39 | } 40 | 41 | // Set CSS custom property for accent color styling 42 | if (input.name === 'fill') { 43 | options.style.setProperty('--fill', text); 44 | } 45 | } 46 | 47 | /** 48 | * Sets the selected layer based on a radio element. 49 | * @param {import("./layer.js").Layer} layer 50 | */ 51 | export function selectLayer(layer) { 52 | options.padding.value = layer.padding; 53 | options.padding.disabled = layer.locked; 54 | options.x.value = layer.x; 55 | options.y.value = layer.y; 56 | for (const input of options.fill) { 57 | input.value = layer.fill; 58 | } 59 | options.alpha.value = layer.alpha; 60 | options.alpha.disabled = layer.locked; 61 | options.delete.disabled = layer.locked; 62 | backgroundInfo.hidden = !layer.locked; 63 | options.fit[0].disabled = !layer.src; 64 | fitInfo.hidden = Boolean(layer.src); 65 | options.rotation.value = layer.rotation; 66 | updatePreviews(); 67 | } 68 | 69 | function updatePreviews() { 70 | const inputs = /** @type {HTMLInputElement[]} */ ( 71 | Array.from(options.elements) 72 | ); 73 | inputs.forEach(updatePreview); 74 | } 75 | 76 | updatePreviews(); 77 | -------------------------------------------------------------------------------- /public/demo/spec.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /tests/e2e/settings.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | 3 | [ 4 | { name: 'Viewer', path: '/' }, 5 | { name: 'Editor', path: '/editor' }, 6 | ].forEach(({ name, path }) => { 7 | test.describe(`Settings for ${name}`, () => { 8 | test.beforeEach(async ({ page }) => { 9 | await page.goto(path); 10 | }); 11 | 12 | test.afterEach(async ({ page }) => { 13 | await page.evaluate(() => localStorage.clear()); 14 | }); 15 | 16 | test('default visible masks', async ({ page }) => { 17 | await expect(page.getByLabel('None')).toBeVisible(); 18 | await expect(page.getByLabel('Minimum safe area')).toBeChecked(); 19 | await expect(page.getByLabel('Circle')).toBeVisible(); 20 | await expect(page.getByLabel('Rounded Rectangle')).toBeVisible(); 21 | await expect(page.getByLabel('Square')).toBeVisible(); 22 | await expect(page.getByLabel('Drop')).toBeVisible(); 23 | await expect(page.getByLabel('Cylinder')).toBeVisible(); 24 | }); 25 | 26 | test('disable mask in settings', async ({ page }) => { 27 | // Open settings 28 | await page.getByRole('link', { name: 'More masks' }).click(); 29 | await expect(page).toHaveTitle('Maskable.app Settings'); 30 | 31 | // Disable masks 32 | await expect(page.getByLabel('Minimum safe area')).toBeDisabled(); 33 | await expect(page.getByLabel('Square')).toBeEnabled(); 34 | await page.getByLabel('Circle').uncheck(); 35 | 36 | // Check that the masks are not visible 37 | await page.goBack(); 38 | await expect(page.getByLabel('Minimum safe area')).toBeVisible(); 39 | await expect(page.getByLabel('Circle')).toBeHidden(); 40 | await expect(page.getByLabel('Square')).toBeVisible(); 41 | }); 42 | 43 | test('enable mask in settings', async ({ page }) => { 44 | // Open settings 45 | await page.getByRole('link', { name: 'More masks' }).click(); 46 | await expect(page).toHaveTitle('Maskable.app Settings'); 47 | 48 | // Disable masks 49 | await expect(page.getByLabel('Minimum safe area')).toBeChecked(); 50 | await expect(page.getByLabel('Square')).toBeChecked(); 51 | await expect(page.getByLabel('Squircle')).not.toBeChecked(); 52 | await page.getByLabel('Squircle').check(); 53 | 54 | // Check that the masks are not visible 55 | await page.goBack(); 56 | await expect(page.getByLabel('Minimum safe area')).toBeVisible(); 57 | await expect(page.getByLabel('Square')).toBeVisible(); 58 | await expect(page.getByLabel('Squircle')).toBeVisible(); 59 | }); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /src/editor/history.js: -------------------------------------------------------------------------------- 1 | export class History { 2 | /** 3 | * @param {import('./layer.js').Layer} layer 4 | * @param {HTMLInputElement} input 5 | * @param {Number} position 6 | */ 7 | 8 | constructor(layer, input, position) { 9 | /** 10 | * @type {import("./layer.js").Layer[]} 11 | */ 12 | this.stack = []; 13 | this.stack.push(layer); 14 | /** 15 | * @type {HTMLInputElement[]} 16 | */ 17 | this.inputs = []; 18 | this.inputs.push(input); 19 | 20 | /** 21 | * @type {Number[]} 22 | */ 23 | this.positions = []; 24 | this.positions.push(position); 25 | } 26 | 27 | /** 28 | * @param {import("./layer.js").Layer} layer 29 | * @param {HTMLInputElement} input 30 | */ 31 | push(layer, input, position) { 32 | this.stack.push(layer); 33 | this.inputs.push(input); 34 | this.positions.push(position); 35 | } 36 | 37 | pop() { 38 | return { 39 | layer: this.stack.pop(), 40 | input: this.inputs.pop(), 41 | position: this.positions.pop(), 42 | }; 43 | } 44 | 45 | getLast() { 46 | return { 47 | layer: this.stack[this.stack.length - 1], 48 | input: this.inputs[this.inputs.length - 1], 49 | position: this.positions[this.positions.length - 1], 50 | }; 51 | } 52 | 53 | getLastOfPosition(position) { 54 | const index = this.positions.lastIndexOf(position); 55 | return { 56 | layer: this.stack[index], 57 | input: this.inputs[index], 58 | position: this.positions[index], 59 | }; 60 | } 61 | 62 | increasePosition() { 63 | // this will be called when new layer is added 64 | // new layer is added to the front 65 | this.positions = this.positions.map((pos) => ++pos); 66 | } 67 | 68 | decreasePosition() { 69 | // this will be called when a layer is deleted 70 | this.positions = this.positions.map((pos) => --pos); 71 | } 72 | 73 | isAvailableToPop() { 74 | // stack always has at least 1 element 75 | return this.stack.length > 1; 76 | } 77 | 78 | isLastOne(position) { 79 | return this.positions.indexOf(position) === -1; 80 | } 81 | 82 | removeOnePosition(position) { 83 | const indexes = []; 84 | let i = -1; 85 | while ((i = this.positions.indexOf(position, i + 1)) != -1) { 86 | indexes.push(i); 87 | } 88 | 89 | for (let j = indexes.length - 1; j >= 0; j--) { 90 | this.inputs.splice(indexes[j], 1); 91 | this.stack.splice(indexes[j], 1); 92 | this.positions.splice(indexes[j], 1); 93 | } 94 | 95 | this.positions = this.positions.map((pos) => { 96 | if (pos > position) pos--; 97 | return pos; 98 | }); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/viewer/dialog.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Manages progressively enhancing a `` element. 3 | */ 4 | export class DialogManager { 5 | /** 6 | * @param {HTMLDialogElement} dialog 7 | */ 8 | constructor(dialog) { 9 | this.dialog = dialog; 10 | /** 11 | * @type {HTMLElement} 12 | * Title of the dialog, will be focused on when dialog is opened. 13 | */ 14 | this.title = dialog.querySelector( 15 | `#${dialog.getAttribute('aria-labelledby')}`, 16 | ); 17 | /** 18 | * @type {NodeListOf} 19 | * Focusable elements inside the dialog. 20 | */ 21 | this.focusable = dialog.querySelectorAll('[tabindex="0"], input, button'); 22 | 23 | /** 24 | * Cleanup content listeners created in `setupContent`. 25 | * @see {DialogManager.setupContent} 26 | */ 27 | this.cleanupContent = () => {}; 28 | 29 | this.dialog.addEventListener('keyup', (event) => { 30 | if (event.key === 'Escape' && this.dialog.open) { 31 | event.preventDefault(); 32 | this.closeDialog(); 33 | } 34 | }); 35 | } 36 | 37 | openDialog() { 38 | const { dialog } = this; 39 | if (typeof dialog.showModal === 'function') { 40 | dialog.showModal(); 41 | } 42 | 43 | dialog.open = true; 44 | dialog.setAttribute('open', ''); 45 | dialog.removeAttribute('inert'); 46 | dialog.setAttribute('aria-hidden', 'false'); 47 | 48 | this.title.focus(); 49 | 50 | this.cleanupContent = this.setupContent(); 51 | } 52 | 53 | closeDialog() { 54 | const { dialog } = this; 55 | if (typeof dialog.close === 'function') { 56 | dialog.close(); 57 | } 58 | 59 | dialog.open = false; 60 | dialog.removeAttribute('open'); 61 | dialog.setAttribute('inert', ''); 62 | dialog.setAttribute('aria-hidden', 'true'); 63 | 64 | this.cleanupContent(); 65 | } 66 | 67 | /** 68 | * Toggle the dialog open or closed 69 | * @param {boolean} [open] True to open the dialog, false to close. 70 | * Defaults to toggling the current state. 71 | */ 72 | toggleDialog(open = !this.dialog.open) { 73 | if (open) { 74 | this.openDialog(); 75 | } else { 76 | this.closeDialog(); 77 | } 78 | } 79 | 80 | /** 81 | * @abstract 82 | * Manage dialog content and setup any listeners needed. 83 | * @returns {() => void} Cleanup function used to remove listeners. 84 | */ 85 | setupContent() { 86 | return this.cleanupContent; 87 | } 88 | } 89 | 90 | /** 91 | * Lazy load a promise. 92 | * @template T 93 | * @param {() => Promise} setupFn 94 | * @returns {() => Promise} 95 | */ 96 | export function lazy(setupFn) { 97 | /** @type {Promise} */ 98 | let promise; 99 | return () => { 100 | if (promise) return promise; 101 | 102 | promise = setupFn(); 103 | return promise; 104 | }; 105 | } 106 | -------------------------------------------------------------------------------- /src/editor/layer.js: -------------------------------------------------------------------------------- 1 | /** 2 | * `CanvasImageSource` variant that has number width/height 3 | * @typedef {HTMLImageElement | HTMLVideoElement | HTMLCanvasElement | ImageBitmap | OffscreenCanvas} CanvasImageSourceNum 4 | */ 5 | 6 | /** 7 | * Data class representing a layer that can be drawn onto the canvas. 8 | * @typedef {object} Layer 9 | * @property {CanvasImageSourceNum} [src] Original image of the layer, unless it's only a color 10 | * @property {string} name Name of the layer. Defaults to filename. 11 | * @property {string} fill CSS color used to tint the layer. 12 | * @property {number} alpha Value from [0 - 100] representing the layer opacity. 13 | * @property {number} padding Padding around the layer. 14 | * @property {number} x Value from [-200 - 200] representing the x offset percentage. 15 | * @property {number} y Value from [-200 - 200] representing the y offset percentage. 16 | * @property {boolean} locked Whether the layer is locked and cannot be edited. 17 | * @property {number} rotation Value from [0 - 360] representing the layer rotation. 18 | * @property {'fill' | 'contain' | 'cover'} fit Fit style for image layers. No effect on color layers. 19 | * - fill: The image dimensions will match the canvas, discarding the 20 | * aspect ratio. 21 | * - contain: The image will be scaled down so that it is entirely visible. 22 | * - cover: The image will be scaled up so that its smaller side matches 23 | * the canvas size. 24 | */ 25 | 26 | /** 27 | * Create a new image from a blob. 28 | * 29 | * @param {File} source 30 | * @returns {Promise} 31 | */ 32 | async function createImage(source) { 33 | const img = new Image(); 34 | img.src = URL.createObjectURL(source); 35 | img.dataset.mime_type = source.type; 36 | await img.decode(); 37 | URL.revokeObjectURL(img.src); 38 | return img; 39 | } 40 | 41 | /** 42 | * Create a list of layers from a list of files 43 | * @param {Iterable} files 44 | * @returns {Promise} 45 | */ 46 | export async function layersFromFiles(files) { 47 | return Promise.all( 48 | Array.from(files).map(async (file) => { 49 | const img = await createImage(file); 50 | const layer = createLayer('#ffffff', img); 51 | layer.name = file.name; 52 | return layer; 53 | }), 54 | ); 55 | } 56 | 57 | /** 58 | * Create a new image or color canvas. 59 | * @param {string} fill 60 | * @param {CanvasImageSourceNum} [src] 61 | * @returns {Layer} 62 | */ 63 | export function createLayer(fill, src) { 64 | return { 65 | src, 66 | name: 'Layer', 67 | fill, 68 | padding: 0, 69 | x: 0, 70 | y: 0, 71 | rotation: 0, 72 | alpha: src ? 0 : 100, 73 | locked: false, 74 | fit: 'contain', 75 | }; 76 | } 77 | 78 | export function backgroundLayer() { 79 | const layer = createLayer('#448AFF'); 80 | layer.locked = true; 81 | return layer; 82 | } 83 | 84 | /** 85 | * @param {Layer} layer 86 | * @returns {Layer} 87 | */ 88 | export function copyLayer(layer) { 89 | return { 90 | src: layer.src, 91 | name: layer.name, 92 | fill: layer.fill, 93 | padding: layer.padding, 94 | x: layer.x, 95 | y: layer.y, 96 | rotation: layer.rotation, 97 | alpha: layer.alpha, 98 | locked: layer.locked, 99 | fit: layer.fit, 100 | }; 101 | } 102 | -------------------------------------------------------------------------------- /public/kofi.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /tests/e2e/export.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | 3 | test.describe('Editor Export', () => { 4 | test.beforeEach(async ({ page }) => { 5 | await page.goto('/editor'); 6 | }); 7 | 8 | test('export selected size and max size', async ({ page, browserName }) => { 9 | // Open export dialog 10 | await page.getByRole('button', { name: 'Export' }).click(); 11 | await expect(page.getByRole('dialog', { name: 'Export' })).toBeVisible(); 12 | 13 | // Check 128x128 in addition to the default Max size 14 | await page.getByLabel('128x128').check(); 15 | 16 | if (browserName !== 'webkit') { 17 | const downloadMaxSize = page.waitForEvent( 18 | 'download', 19 | (download) => download.suggestedFilename() === 'maskable_icon.png', 20 | ); 21 | const download128 = page.waitForEvent( 22 | 'download', 23 | (download) => download.suggestedFilename() === 'maskable_icon_x128.png', 24 | ); 25 | await page.getByRole('button', { name: 'Download' }).click(); 26 | 27 | await downloadMaxSize; 28 | await download128; 29 | } 30 | }); 31 | 32 | test('JSON preview shows manifest corresponding to selected checkboxes', async ({ 33 | page, 34 | }) => { 35 | // Open export dialog 36 | await page.getByRole('button', { name: 'Export' }).click(); 37 | 38 | const details = page.getByRole('group').filter({ hasText: 'Show JSON' }); 39 | await expect(details).not.toHaveAttribute('open'); 40 | 41 | // Open JSON preview 42 | await page.getByText('Show JSON').click(); 43 | await expect(details).toHaveAttribute('open'); 44 | 45 | await expect(details).toContainText( 46 | JSON.stringify( 47 | [ 48 | { 49 | purpose: 'maskable', 50 | sizes: '1024x1024', 51 | src: 'maskable_icon.png', 52 | type: 'image/png', 53 | }, 54 | ], 55 | undefined, 56 | 2, 57 | ), 58 | { useInnerText: true }, 59 | ); 60 | 61 | // Check 128x128 and 48x48 in addition to the default Max size 62 | await page.getByLabel('128x128').check(); 63 | await page.getByLabel('48x48').check(); 64 | 65 | await expect(details).toContainText( 66 | JSON.stringify( 67 | [ 68 | { 69 | purpose: 'maskable', 70 | sizes: '1024x1024', 71 | src: 'maskable_icon.png', 72 | type: 'image/png', 73 | }, 74 | { 75 | purpose: 'maskable', 76 | sizes: '48x48', 77 | src: 'maskable_icon_x48.png', 78 | type: 'image/png', 79 | }, 80 | { 81 | purpose: 'maskable', 82 | sizes: '128x128', 83 | src: 'maskable_icon_x128.png', 84 | type: 'image/png', 85 | }, 86 | ], 87 | undefined, 88 | 2, 89 | ), 90 | { useInnerText: true }, 91 | ); 92 | }); 93 | 94 | test('x button closes dialog', async ({ page }) => { 95 | // Open export dialog 96 | await page.getByRole('button', { name: 'Export' }).click(); 97 | await expect(page.getByRole('dialog', { name: 'Export' })).toBeVisible(); 98 | 99 | // Close export dialog 100 | await page.getByRole('button', { name: 'Close export dialog' }).click(); 101 | await expect( 102 | page.getByRole('dialog', { name: 'Export' }), 103 | ).not.toBeVisible(); 104 | }); 105 | 106 | test('Cancel button closes dialog', async ({ page }) => { 107 | // Open export dialog 108 | await page.getByRole('button', { name: 'Export' }).click(); 109 | await expect(page.getByRole('dialog', { name: 'Export' })).toBeVisible(); 110 | 111 | // Close export dialog 112 | await page.getByRole('button', { name: 'Cancel' }).click(); 113 | await expect( 114 | page.getByRole('dialog', { name: 'Export' }), 115 | ).not.toBeVisible(); 116 | }); 117 | }); 118 | -------------------------------------------------------------------------------- /tests/viewer/masks.spec.ts: -------------------------------------------------------------------------------- 1 | import { beforeAll, describe, expect, test } from 'vitest'; 2 | import { applyMask } from '../../src/viewer/masks.js'; 3 | 4 | describe('applyMask with clip-path support', () => { 5 | beforeAll(() => { 6 | window.CSS = { supports: () => true } as any; 7 | }); 8 | 9 | test('set clip-path and -webkit-clip-path', () => { 10 | const masked = { style: {} } as HTMLElement; 11 | const icons = { style: {} } as HTMLElement; 12 | 13 | expect(applyMask([masked], [icons], 'circle')).toBe(true); 14 | 15 | expect(masked.style.webkitClipPath).toBe('inset(6.36% round 50%)'); 16 | expect(masked.style.clipPath).toBe('inset(6.36% round 50%)'); 17 | expect(masked.style.borderRadius).toBeUndefined(); 18 | expect(masked.style.transform).toBeUndefined(); 19 | }); 20 | 21 | test('set clip-path and -webkit-clip-path for svg mask', () => { 22 | const masked = { style: {} } as HTMLElement; 23 | const icons = { style: {} } as HTMLElement; 24 | 25 | expect(applyMask([masked], [icons], 'squircle')).toBe(true); 26 | 27 | expect(masked.style.webkitClipPath).toBe('url(#squircle)'); 28 | expect(masked.style.clipPath).toBe('url(#squircle)'); 29 | expect(masked.style.borderRadius).toBeUndefined(); 30 | expect(masked.style.transform).toBeUndefined(); 31 | }); 32 | 33 | test('fail when mask is invalid', () => { 34 | const masked = { style: {} } as HTMLElement; 35 | const icons = { style: {} } as HTMLElement; 36 | 37 | expect(applyMask([masked], [icons], 'foo')).toBe(false); 38 | 39 | expect(masked.style.webkitClipPath).toBeUndefined(); 40 | expect(masked.style.clipPath).toBeUndefined(); 41 | expect(masked.style.borderRadius).toBeUndefined(); 42 | expect(masked.style.transform).toBeUndefined(); 43 | }); 44 | }); 45 | 46 | describe('applyMask without clip-path support', () => { 47 | beforeAll(() => { 48 | window.CSS = { supports: () => false } as any; 49 | }); 50 | 51 | test('set border radius and scale', () => { 52 | const masked = { style: {} } as HTMLElement; 53 | const icons = { style: {} } as HTMLElement; 54 | 55 | expect(applyMask([masked], [icons], 'circle')).toBe(true); 56 | 57 | expect(masked.style.webkitClipPath).toBeUndefined(); 58 | expect(masked.style.clipPath).toBeUndefined(); 59 | expect(masked.style.borderRadius).toBe('50%'); 60 | expect(masked.style.transform).toBeUndefined(); 61 | 62 | expect(icons.style.webkitClipPath).toBeUndefined(); 63 | expect(icons.style.clipPath).toBeUndefined(); 64 | expect(icons.style.borderRadius).toBeUndefined(); 65 | expect(icons.style.transform).toBe('scale(1.15)'); 66 | }); 67 | 68 | test('fail when mask is unsupported', () => { 69 | const masked = { style: {} } as HTMLElement; 70 | const icons = { style: {} } as HTMLElement; 71 | 72 | expect(applyMask([masked], [icons], 'squircle')).toBe(false); 73 | 74 | expect(masked.style.webkitClipPath).toBeUndefined(); 75 | expect(masked.style.clipPath).toBeUndefined(); 76 | expect(masked.style.borderRadius).toBeUndefined(); 77 | expect(masked.style.transform).toBeUndefined(); 78 | 79 | expect(icons.style.webkitClipPath).toBeUndefined(); 80 | expect(icons.style.clipPath).toBeUndefined(); 81 | expect(icons.style.borderRadius).toBeUndefined(); 82 | expect(icons.style.transform).toBeUndefined(); 83 | }); 84 | 85 | test('fail when mask is invalid', () => { 86 | const masked = { style: {} } as HTMLElement; 87 | const icons = { style: {} } as HTMLElement; 88 | 89 | expect(applyMask([masked], [icons], 'foo')).toBe(false); 90 | 91 | expect(masked.style.webkitClipPath).toBeUndefined(); 92 | expect(masked.style.clipPath).toBeUndefined(); 93 | expect(masked.style.borderRadius).toBeUndefined(); 94 | expect(masked.style.transform).toBeUndefined(); 95 | 96 | expect(icons.style.webkitClipPath).toBeUndefined(); 97 | expect(icons.style.clipPath).toBeUndefined(); 98 | expect(icons.style.borderRadius).toBeUndefined(); 99 | expect(icons.style.transform).toBeUndefined(); 100 | }); 101 | }); 102 | -------------------------------------------------------------------------------- /src/editor/export.js: -------------------------------------------------------------------------------- 1 | import { toUrl } from './canvas.js'; 2 | 3 | /** @type {HTMLFormElement} */ 4 | const sizes = document.querySelector('#exportSizes'); 5 | const maxSizeValue = sizes.querySelector('#maxSize'); 6 | const sizeInputs = /** @type {NodeListOf} */ ( 7 | document.getElementsByName('sizes') 8 | ); 9 | const jsonPreview = document.querySelector('.mask__json-view__preview'); 10 | 11 | /** 12 | * Returns selected sizes 13 | */ 14 | function getFormSizesValues() { 15 | const exportSizes = new FormData(sizes).getAll('sizes').map(toSize); 16 | 17 | return exportSizes; 18 | } 19 | 20 | /** 21 | * Get the suggested file name for the given maskable icon size. 22 | * @param {number | undefined} size Size of the maskable icon, or `undefined` for max size. 23 | */ 24 | const fileName = (size) => 25 | size != undefined ? `maskable_icon_x${size}.png` : 'maskable_icon.png'; 26 | 27 | /** 28 | * @param {File | string} value 29 | * @returns {number | undefined} Number for one of the checkboxes in the second row, 30 | * or `undefined` for max size. 31 | */ 32 | function toSize(value) { 33 | if (value instanceof File) { 34 | throw new Error(); 35 | } 36 | const size = parseInt(value, 10); 37 | if (size > -1) { 38 | return size; 39 | } else { 40 | return undefined; 41 | } 42 | } 43 | 44 | /** 45 | * Updates Web App Manifest JSON preview based on selected sizes. 46 | * @param {import('./canvas.js').CanvasController} controller 47 | */ 48 | function updateJsonPreview(controller) { 49 | const exportSizes = getFormSizesValues().map((size) => { 50 | const pixelSize = size ?? controller.getSize(); 51 | return { 52 | purpose: 'maskable', 53 | sizes: `${pixelSize}x${pixelSize}`, 54 | src: fileName(size), 55 | type: 'image/png', 56 | }; 57 | }); 58 | 59 | jsonPreview.textContent = JSON.stringify(exportSizes, null, 2); 60 | } 61 | 62 | /** 63 | * Enables/disables export size checkboxes based on the biggest layer. 64 | * @param {import('./canvas.js').CanvasController} controller 65 | */ 66 | function updateExportSizes(controller) { 67 | const maxSize = controller.getSize(); 68 | 69 | maxSizeValue.textContent = `${maxSize}x${maxSize}`; 70 | for (const element of sizeInputs) { 71 | const size = toSize(element.value); 72 | if (size != undefined) { 73 | element.disabled = size > maxSize; 74 | } 75 | } 76 | } 77 | 78 | /** 79 | * Downloads the current image off the canvas. 80 | * @param {import('./canvas.js').CanvasController} controller 81 | */ 82 | async function download(controller) { 83 | const exportSizes = getFormSizesValues(); 84 | 85 | const exported = Promise.all( 86 | exportSizes.map(async (size) => { 87 | const url = await toUrl(controller.export(size), true); 88 | 89 | const a = document.createElement('a'); 90 | a.href = url; 91 | a.download = fileName(size); 92 | document.body.appendChild(a); 93 | a.click(); 94 | document.body.removeChild(a); 95 | 96 | if (url.startsWith('blob:')) { 97 | URL.revokeObjectURL(url); 98 | } 99 | }), 100 | ); 101 | 102 | try { 103 | const layers = controller.getLayerCount(); 104 | fathom?.trackGoal('exportItem', layers); 105 | } catch { 106 | // Blocked by ad blocker 107 | } 108 | await exported; 109 | } 110 | 111 | /** 112 | * @param {import('./canvas.js').CanvasController} controller 113 | */ 114 | export function setupExportDialog(controller) { 115 | /** 116 | * @param {Event} evt 117 | */ 118 | function handleSubmit(evt) { 119 | evt.preventDefault(); 120 | download(controller); 121 | } 122 | 123 | function handleChange() { 124 | updateJsonPreview(controller); 125 | } 126 | 127 | updateExportSizes(controller); 128 | updateJsonPreview(controller); 129 | sizes.addEventListener('submit', handleSubmit); 130 | sizes.addEventListener('change', handleChange); 131 | 132 | return function cleanup() { 133 | sizes.removeEventListener('submit', handleSubmit); 134 | sizes.removeEventListener('change', handleChange); 135 | 136 | jsonPreview.textContent = 'Select some size to display the JSON preview'; 137 | 138 | // reset form fields 139 | sizes.reset(); 140 | }; 141 | } 142 | -------------------------------------------------------------------------------- /src/viewer/upload-icon.js: -------------------------------------------------------------------------------- 1 | const imgElements = 2 | /** @type {HTMLCollectionOf} */ ( 3 | document.getElementsByClassName('icon') 4 | ); 5 | const lastImg = /** @type {HTMLImageElement} */ (imgElements[0]); 6 | 7 | /** 8 | * Changes the displayed icon in the center of the screen. 9 | * 10 | * @param {Blob | string | undefined} source URL or File for the icon. 11 | * If a File/Blob, an object URL is created and displayed. 12 | * If a string, the string is used as a URL directly. 13 | * If undefined (or falsy), nothing happens. 14 | */ 15 | function updateDisplayedIcon(source) { 16 | if (!source) return; 17 | 18 | // Revoke the old URL 19 | const oldUrl = lastImg.src; 20 | if (oldUrl.startsWith('blob:')) { 21 | URL.revokeObjectURL(oldUrl); 22 | } 23 | 24 | // Update the URL bar 25 | if (typeof source === 'string') { 26 | history.replaceState(undefined, '', `?demo=${source}`); 27 | } else { 28 | // Create a URL corresponding to the file. 29 | source = URL.createObjectURL(source); 30 | history.replaceState(undefined, '', '.'); 31 | } 32 | 33 | updateSource(source); 34 | for (let i = 0; i < imgElements.length; i++) { 35 | const imgElement = imgElements[i]; 36 | if (imgElement instanceof SVGImageElement) { 37 | imgElement.setAttribute('href', source); 38 | } else { 39 | imgElement.src = source; 40 | } 41 | } 42 | } 43 | 44 | /** 45 | * Changes the "Icon from" credits at the bottom of the app. 46 | * The credits are embedded in the HTML of the demo icons at the top of the screen. 47 | * The `alt` attribute is used for the human-readable portion of the link. 48 | * The `data-source` attribute is used for the URL of the link. 49 | * 50 | * @param {string} source Source URL of the displayed icon. 51 | * If the URL does not correspond to one of the demo icons, then the credits text is hidden. 52 | */ 53 | function updateSource(source) { 54 | /** @type {HTMLElement} */ 55 | const sourceDisplay = document.querySelector('.source'); 56 | if (!sourceDisplay) return; 57 | 58 | /** @type {HTMLAnchorElement} */ 59 | const sourceLink = sourceDisplay.querySelector('.source__link'); 60 | 61 | /** @type {HTMLImageElement} */ 62 | const preview = document.querySelector(`.demo__preview[src$="${source}"]`); 63 | if (preview != undefined) { 64 | sourceDisplay.hidden = false; 65 | sourceLink.href = preview.dataset.source; 66 | sourceLink.textContent = preview.alt; 67 | } else { 68 | sourceDisplay.hidden = true; 69 | } 70 | } 71 | 72 | /** @type {HTMLInputElement} The "Open icon file" button */ 73 | const fileInput = document.querySelector('#icon_file'); 74 | /** @type {import('file-drop-element').FileDropElement} The invisible file drop area */ 75 | const fileDrop = document.querySelector('#icon_drop'); 76 | 77 | if (fileInput) { 78 | /** @type {AddEventListenerOptions} */ 79 | const pas = { passive: true }; 80 | 81 | // Update the displayed icon when the "Open icon file" button is used 82 | fileInput.addEventListener( 83 | 'change', 84 | () => updateDisplayedIcon(fileInput.files[0]), 85 | pas, 86 | ); 87 | // Update the displayed icon when a file is dropped in 88 | fileDrop.addEventListener( 89 | 'filedrop', 90 | (evt) => updateDisplayedIcon(evt.files[0]), 91 | pas, 92 | ); 93 | 94 | // File input focus polyfill for Firefox 95 | fileInput.addEventListener( 96 | 'focus', 97 | () => fileInput.classList.add('focus'), 98 | pas, 99 | ); 100 | fileInput.addEventListener( 101 | 'blur', 102 | () => fileInput.classList.remove('focus'), 103 | pas, 104 | ); 105 | } 106 | 107 | // If there's a URL present in the "?demo" query parameter, use it as the icon URL. 108 | const demoUrl = new URL(location.href).searchParams.get('demo'); 109 | updateDisplayedIcon(demoUrl); 110 | 111 | /** @type {HTMLUListElement} */ 112 | const demoLinks = document.querySelector('.demo__list'); 113 | if (demoLinks) { 114 | demoLinks.addEventListener('click', (evt) => { 115 | const target = /** @type {HTMLElement} */ (evt.target); 116 | const link = /** @type {HTMLAnchorElement | null} */ ( 117 | target.closest('.demo__link') 118 | ); 119 | if (link != undefined) { 120 | evt.preventDefault(); 121 | const demoUrl = new URL(link.href).searchParams.get('demo'); 122 | updateDisplayedIcon(demoUrl); 123 | } 124 | }); 125 | } 126 | -------------------------------------------------------------------------------- /public/css/editor.css: -------------------------------------------------------------------------------- 1 | .body { 2 | display: grid; 3 | grid-template-columns: auto min-content; 4 | grid-template-areas: 'main sidebar'; 5 | height: 100vh; 6 | } 7 | 8 | dark-mode-toggle { 9 | right: 0; 10 | } 11 | 12 | main { 13 | overflow-y: auto; 14 | padding-left: env(safe-area-inset-left); 15 | padding-right: env(safe-area-inset-right); 16 | } 17 | .viewer { 18 | position: relative; 19 | } 20 | .layers { 21 | padding-left: env(safe-area-inset-left); 22 | resize: horizontal; 23 | overflow-x: hidden; 24 | overflow-y: auto; 25 | min-width: 240px; 26 | max-width: 90vw; 27 | background-color: #eee; 28 | z-index: 5; 29 | } 30 | .dark .layers { 31 | background-color: #444; 32 | } 33 | .editor__preview { 34 | background: #448aff; 35 | } 36 | 37 | .button--secondary { 38 | color: inherit; 39 | background: transparent !important; 40 | border-color: currentColor; 41 | } 42 | .button--secondary:hover, 43 | input:focus + label.button--secondary, 44 | input.focus + label.button--secondary { 45 | background: hsl(43, 100%, 55.7%); 46 | } 47 | .button--secondary:active { 48 | background: hsl(43.4, 100%, 70.2%); 49 | } 50 | 51 | .layers__list { 52 | padding: 0; 53 | } 54 | .layer { 55 | display: block; 56 | } 57 | .layer__preview-button { 58 | display: grid; 59 | grid-template-columns: min-content 64px auto; 60 | grid-template-rows: 64px; 61 | grid-template-areas: 'radio preview name'; 62 | width: 100%; 63 | 64 | align-items: center; 65 | grid-gap: 0.5rem; 66 | border: 0; 67 | background: transparent; 68 | padding: 0.5rem; 69 | } 70 | 71 | .layer__radio { 72 | grid-area: radio; 73 | } 74 | .layer__preview { 75 | grid-area: preview; 76 | display: block; 77 | height: 100%; 78 | width: 100%; 79 | position: relative; 80 | fill: #ffbf1d; 81 | background-image: linear-gradient( 82 | 45deg, 83 | rgba(0, 0, 0, 0.2) 25%, 84 | transparent 25% 85 | ), 86 | linear-gradient(-45deg, rgba(0, 0, 0, 0.2) 25%, transparent 25%), 87 | linear-gradient(45deg, transparent 75%, rgba(0, 0, 0, 0.2) 75%), 88 | linear-gradient(-45deg, transparent 75%, rgba(0, 0, 0, 0.2) 75%); 89 | background-size: 16px 16px; 90 | background-position: 91 | 0 0, 92 | 0 8px, 93 | 8px -8px, 94 | -8px 0px; 95 | } 96 | .layer__preview--mask { 97 | background-image: none; 98 | } 99 | .layer__name { 100 | grid-area: name; 101 | align-self: center; 102 | } 103 | 104 | .options { 105 | padding: 0.5rem; 106 | 107 | text-align: left; 108 | border-top: 1px solid rgba(0, 0, 0, 0.5); 109 | } 110 | 111 | .info { 112 | opacity: 0.7; 113 | margin: 0 0 0.5em; 114 | font-size: 0.9rem; 115 | } 116 | 117 | .control { 118 | display: grid; 119 | grid-template-columns: auto 5rem; 120 | grid-template-rows: min-content; 121 | grid-template-areas: 122 | 'label label' 123 | 'input preview'; 124 | grid-gap: 0.25rem; 125 | } 126 | 127 | .control, 128 | .control--radio { 129 | border: 0; 130 | padding: 0; 131 | margin: 0; 132 | margin-bottom: 0.5rem; 133 | } 134 | .control__label { 135 | grid-area: label; 136 | } 137 | .control__input { 138 | grid-area: input; 139 | } 140 | .control__input:disabled, 141 | input[type='text']:disabled, 142 | .control--radio:disabled label, 143 | .button--secondary:disabled { 144 | opacity: 0.5; 145 | } 146 | .control__preview { 147 | grid-area: preview; 148 | } 149 | .accent--fill { 150 | accent-color: var(--fill); 151 | } 152 | 153 | .export-dialog { 154 | max-width: calc(100vw - 4rem); 155 | } 156 | .export-dialog[open] + .scrim.toggle--export { 157 | visibility: visible; 158 | } 159 | 160 | .mask__json-view { 161 | margin-top: 0.8rem; 162 | } 163 | 164 | .mask__json-view__details { 165 | margin-left: 0.4rem; 166 | } 167 | 168 | .mask__json-view__summary { 169 | list-style: none; 170 | color: #165fda; 171 | } 172 | 173 | .dark .mask__json-view__summary { 174 | color: #448aff; 175 | } 176 | 177 | .mask__json-view__preview { 178 | background-color: #ededed; 179 | border-radius: 8px; 180 | font-family: monospace; 181 | margin-top: 0.5rem; 182 | max-height: 400px; 183 | overflow-y: auto; 184 | padding: 1rem; 185 | } 186 | 187 | .dark .mask__json-view__preview { 188 | background-color: #444; 189 | } 190 | 191 | .mask__json-view-info { 192 | font-size: 0.9rem; 193 | } 194 | 195 | .mask__json-view-info code { 196 | background-color: #ededed; 197 | padding: 2px; 198 | border-radius: 4px; 199 | } 200 | 201 | .dark .mask__json-view-info code { 202 | background-color: #444; 203 | } 204 | 205 | .mask__json-view-info a { 206 | color: #165fda; 207 | } 208 | 209 | .dark .mask__json-view-info a { 210 | color: #448aff; 211 | } 212 | 213 | @media (max-width: 56rem) { 214 | dark-mode-toggle { 215 | right: 0; 216 | } 217 | .body { 218 | grid-template-columns: auto; 219 | grid-template-areas: 'main'; 220 | } 221 | .layers { 222 | position: absolute; 223 | top: 0; 224 | left: 0; 225 | bottom: 0; 226 | 227 | transform: translateX(-100%); 228 | transition: transform 0.1s ease; 229 | } 230 | .open .layers { 231 | transform: translateX(0); 232 | } 233 | .open .scrim.toggle--layers { 234 | visibility: visible; 235 | } 236 | } 237 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Maskable.app 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | {{> meta }} 15 | 16 | 17 | 18 | 19 |
20 | {{> navbar page="viewer" }} 21 |
22 |
23 | 102 | 103 |
104 | 105 | 106 | 107 | 110 |
111 |
112 | 113 |
114 |
115 | Preview of original icon 116 |
117 |
118 | Preview of maskable icon 119 |
120 |
121 |
122 | 123 | {{> controls }} 124 | 130 | 131 |
132 | 147 |
148 |
149 | 150 | 151 | 152 |
153 | 161 | 162 | 163 | 164 |
165 | 166 |
167 |
168 |
169 | 170 |
171 | {{> clippaths}} 172 | 173 | 174 | -------------------------------------------------------------------------------- /src/editor/canvas.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {object} CanvasContainer 3 | * Wrapper around canvas element with reference to rendering context and size. 4 | * 5 | * @prop {HTMLCanvasElement} canvas The referenced canvas element. 6 | * @prop {CanvasRenderingContext2D} ctx Rendering context for `canvas`. 7 | * @prop {number} size Width and height of the square `canvas`. 8 | */ 9 | 10 | /** 11 | * Returns the multiplier to scale the `layer` by. 12 | * For example, if padding is 0% then the return value will be 1. 13 | * @param {import('./layer.js').Layer} layer 14 | */ 15 | function getScale(layer) { 16 | return 1 - layer.padding / 100; 17 | } 18 | 19 | /** 20 | * Checks if `img` is an image element containing an SVG image. 21 | * The data attribute is set in `createImage`. 22 | * 23 | * @param {unknown} img Potential image element from the `createImage` function. 24 | * @returns {img is HTMLImageElement} 25 | */ 26 | function isSvg(img) { 27 | return ( 28 | img instanceof HTMLImageElement && img.dataset.mime_type === 'image/svg+xml' 29 | ); 30 | } 31 | 32 | /** 33 | * Render layer to given canvas. 34 | * 35 | * The canvas will be cleared and the layer will be drawn depending on its 36 | * various properties. 37 | * 38 | * @param {import('./layer.js').Layer} layer Layer to render. 39 | * @param {CanvasRenderingContext2D} ctx Canvas context. 40 | * @param {number} size Width and height of the square canvas. 41 | */ 42 | export function drawLayer(layer, ctx, size) { 43 | ctx.clearRect(0, 0, size, size); 44 | let width = getScale(layer) * size; 45 | let height = width; 46 | 47 | const offsetX = (layer.x / 100) * width; 48 | const offsetY = (layer.y / 100) * height; 49 | const insetX = (size - width) / 2 + offsetX; 50 | const insetY = (size - height) / 2 + offsetY; 51 | 52 | // Save the untranslated and unrotated version of canvas 53 | ctx.save(); 54 | ctx.translate(size / 2, size / 2); 55 | ctx.rotate((layer.rotation * Math.PI) / 180); 56 | ctx.translate(-(size / 2), -(size / 2)); 57 | 58 | ctx.globalCompositeOperation = 'source-over'; 59 | if (layer.src) { 60 | // If image layer... 61 | const { height: srcHeight, width: srcWidth } = layer.src; 62 | const srcRatio = srcWidth / srcHeight; 63 | 64 | if (layer.fit === 'fill') { 65 | // leave width and height as default 66 | } else if (layer.fit === 'contain' ? srcRatio > 1 : srcRatio < 1) { 67 | height = width / srcRatio; 68 | } else { 69 | width = height * srcRatio; 70 | } 71 | const insetX = (size - width) / 2 + offsetX; 72 | const insetY = (size - height) / 2 + offsetY; 73 | 74 | ctx.globalAlpha = 1; 75 | ctx.drawImage(layer.src, insetX, insetY, width, height); 76 | ctx.globalCompositeOperation = 'source-atop'; 77 | } 78 | 79 | ctx.fillStyle = layer.fill; 80 | ctx.globalAlpha = layer.alpha / 100; 81 | ctx.fillRect(insetX, insetY, width, height); 82 | 83 | // Restore the untranslated and unrotated version of canvas 84 | ctx.restore(); 85 | } 86 | 87 | /** 88 | * Creates a blob URL or data URL for the canvas. 89 | * @param {HTMLCanvasElement} canvas 90 | * @param {boolean} blob If true, try to return Blob URL. 91 | * @returns {Promise} 92 | */ 93 | export async function toUrl(canvas, blob) { 94 | if (blob && canvas.toBlob) { 95 | /** @type {Blob | null} */ 96 | const blob = await new Promise((resolve) => 97 | canvas.toBlob(resolve, 'image/png'), 98 | ); 99 | return URL.createObjectURL(blob); 100 | } else { 101 | // No blob API, fallback to data URL 102 | return canvas.toDataURL('image/png'); 103 | } 104 | } 105 | 106 | /** 107 | * Create a new canvas element. 108 | * 109 | * @param {number} size Width and height of the square canvas element. 110 | * @param {number} scale Scale factor for the canvas, based on display density. 111 | * @returns {CanvasContainer} 112 | */ 113 | export function createCanvas(size, scale = 1) { 114 | const canvas = document.createElement('canvas'); 115 | return scaleCanvas(canvas, size, scale); 116 | } 117 | 118 | /** 119 | * Scale an existing canvas element. 120 | * 121 | * @param {HTMLCanvasElement} canvas Canvas element to modify. 122 | * @param {number} size Width and height of the square canvas element. 123 | * @param {number} scale Scale factor for the canvas, based on display density. 124 | * @returns {CanvasContainer} 125 | */ 126 | export function scaleCanvas(canvas, size, scale = 1) { 127 | canvas.width = size * scale; 128 | canvas.height = size * scale; 129 | const ctx = canvas.getContext('2d'); 130 | // Try to improve the image quality when shrinking large images. 131 | ctx.imageSmoothingEnabled = true; 132 | ctx.imageSmoothingQuality = 'high'; 133 | ctx.scale(scale, scale); 134 | return { canvas, ctx, size }; 135 | } 136 | 137 | export class CanvasController { 138 | constructor() { 139 | /** 140 | * List of layers to render 141 | * @private 142 | * @readonly 143 | * @type {import('./layer.js').Layer[]} 144 | */ 145 | this.layers = []; 146 | /** 147 | * Canvases corresponding to each layer 148 | * @private 149 | * @readonly 150 | * @type {Map} 151 | */ 152 | this.canvases = new Map(); 153 | } 154 | 155 | /** 156 | * Returns the number of layers in the controller. 157 | */ 158 | getLayerCount() { 159 | return this.layers.length; 160 | } 161 | 162 | /** 163 | * Returns the size of the biggest pixel layer. 164 | */ 165 | getSize() { 166 | const sizes = this.layers 167 | .filter((layer) => layer.src && !isSvg(layer.src)) 168 | .map((layer) => { 169 | const src = /** @type {HTMLImageElement} */ (layer.src); 170 | return Math.max(src.width, src.height) * (1 / getScale(layer)); 171 | }); 172 | 173 | // If all layers are SVG, default to 1024. 174 | return sizes.length === 0 175 | ? 1024 176 | : sizes.reduce((acc, n) => Math.max(acc, n), 0); 177 | } 178 | 179 | /** 180 | * Add a layer and display its canvas 181 | * @param {import('./layer.js').Layer} layer 182 | * @param {readonly Pick[]} canvases 183 | */ 184 | add(layer, canvases) { 185 | this.layers.unshift(layer); 186 | this.canvases.set( 187 | layer, 188 | canvases.map(({ canvas, size }) => { 189 | return { canvas, size, ctx: canvas.getContext('2d') }; 190 | }), 191 | ); 192 | this.draw(layer); 193 | } 194 | 195 | /** 196 | * Delete a layer and its corresponding canvas 197 | * @param {import('./layer.js').Layer} layer 198 | */ 199 | delete(layer) { 200 | const index = this.layers.indexOf(layer); 201 | if (index > -1) { 202 | this.layers.splice(index, 1); 203 | this.canvases.get(layer).forEach(({ canvas }) => canvas.remove()); 204 | this.canvases.delete(layer); 205 | } 206 | } 207 | 208 | /** 209 | * Export the layers onto a single canvas 210 | * @param {number} size 211 | */ 212 | export(size = this.getSize()) { 213 | const { canvas: mainCanvas, ctx } = createCanvas(size); 214 | const { canvas: layerCanvas, ctx: layerCtx } = createCanvas(size); 215 | 216 | this.layers 217 | .slice() 218 | .reverse() 219 | .forEach((layer) => { 220 | drawLayer(layer, layerCtx, size); 221 | ctx.drawImage(layerCanvas, 0, 0); 222 | }); 223 | 224 | return mainCanvas; 225 | } 226 | 227 | /** 228 | * Draw the layer on its corresponding canvases 229 | * @param {import('./layer.js').Layer} layer 230 | */ 231 | draw(layer) { 232 | const canvases = this.canvases.get(layer); 233 | for (const { ctx, size } of canvases) { 234 | drawLayer(layer, ctx, size); 235 | } 236 | } 237 | 238 | getPosition(layer) { 239 | return this.layers.indexOf(layer); 240 | } 241 | 242 | getLayer(position) { 243 | return this.layers[position]; 244 | } 245 | } 246 | -------------------------------------------------------------------------------- /public/demo/insightful-energy.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /settings.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Maskable.app Settings 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | {{> meta }} 16 | 17 | 18 | 19 | 20 |
21 | {{> navbar page="settings" }} 22 | 23 |
24 |

Settings

25 |
26 | 27 |
28 |

Available Masks

29 |
    30 |
  • 31 | 49 |
  • 50 |
  • 51 | 69 |
  • 70 |
  • 71 | 81 |
  • 82 |
  • 83 | 100 |
  • 101 |
  • 102 | 119 |
  • 120 |
  • 121 | 134 |
  • 135 |
  • 136 | 146 |
  • 147 |
  • 148 | 155 |
  • 156 |
  • 157 | 164 |
  • 165 |
  • 166 | 173 |
  • 174 |
  • 175 | 182 |
  • 183 |
  • 184 | 191 |
  • 192 |
193 |
194 |
195 | 196 | 197 |
198 |
199 | {{> clippaths}} 200 | 201 | 202 | -------------------------------------------------------------------------------- /src/editor/main.js: -------------------------------------------------------------------------------- 1 | import { 2 | CanvasController, 3 | createCanvas, 4 | scaleCanvas, 5 | toUrl, 6 | } from './canvas.js'; 7 | import { DialogManager, lazy } from '../viewer/dialog.js'; 8 | import { 9 | backgroundLayer, 10 | createLayer, 11 | layersFromFiles, 12 | copyLayer, 13 | } from './layer.js'; 14 | import { selectLayer, updatePreview } from './options.js'; 15 | import { History } from './history.js'; 16 | 17 | const VIEWER_SIZE = 192; 18 | const PREVIEW_SIZE = 64; 19 | const DPR = window.devicePixelRatio || 1; 20 | 21 | /** @type {HTMLUListElement} */ 22 | const list = document.querySelector('.layers__list'); 23 | /** @type {HTMLTemplateElement} */ 24 | const template = document.querySelector('.layer__template'); 25 | /** @type {HTMLFormElement} */ 26 | const options = document.querySelector('.options'); 27 | /** @type {NodeListOf} */ 28 | const canvasContainers = document.querySelectorAll( 29 | '.icon__mask, .icon__original', 30 | ); 31 | 32 | /** @type {import("./history.js").History} */ 33 | let history; 34 | 35 | /** @type {WeakMap} */ 36 | const layers = new WeakMap(); 37 | const controller = new CanvasController(); 38 | 39 | /** @param {HTMLCanvasElement} preview */ 40 | function createCanvases(preview) { 41 | const viewerCanvases = Array.from(canvasContainers).map((container) => { 42 | const c = createCanvas(VIEWER_SIZE, DPR); 43 | c.canvas.className = 'icon'; 44 | container.append(c.canvas); 45 | return c; 46 | }); 47 | 48 | return viewerCanvases.concat(scaleCanvas(preview, PREVIEW_SIZE, DPR)); 49 | } 50 | 51 | { 52 | const background = backgroundLayer(); 53 | 54 | /** @type {HTMLCanvasElement} */ 55 | const backgroundPreview = document.querySelector( 56 | '.layer__preview--background', 57 | ); 58 | const canvases = createCanvases(backgroundPreview); 59 | 60 | layers.set( 61 | document.querySelector('input[name="layer"][value="background"]'), 62 | background, 63 | ); 64 | 65 | const newLayer = copyLayer(background); 66 | 67 | history = new History( 68 | newLayer, 69 | document.querySelector('input[name="layer"][value="background"]'), 70 | 0, 71 | ); 72 | 73 | controller.add(background, canvases); 74 | } 75 | 76 | function checked() { 77 | /** @type {HTMLInputElement} */ 78 | const radio = list.querySelector('input[name="layer"]:checked'); 79 | return radio; 80 | } 81 | 82 | /** 83 | * Creates a new element representing a layer. 84 | * @param {import("./layer.js").Layer} layer 85 | */ 86 | function newLayerElement(layer) { 87 | const randomId = Math.random() 88 | .toString(36) 89 | .replace(/[^a-z]+/g, '') 90 | .substr(2, 10); 91 | const clone = document.importNode(template.content, true); 92 | 93 | /** @type {HTMLInputElement} */ 94 | const radio = clone.querySelector('input[name="layer"]'); 95 | radio.value = randomId; 96 | radio.id = randomId; 97 | radio.checked = true; 98 | 99 | clone.querySelector('label').htmlFor = randomId; 100 | 101 | /** @type {HTMLInputElement} */ 102 | const textInput = clone.querySelector('input[name="name"]'); 103 | textInput.value = layer.name; 104 | 105 | selectLayer(layer); 106 | 107 | const newLayer = copyLayer(layer); 108 | history.increasePosition(); 109 | history.push(newLayer, radio, 0); 110 | 111 | /** @type {HTMLCanvasElement} */ 112 | const preview = clone.querySelector('.layer__preview'); 113 | const canvases = createCanvases(preview); 114 | 115 | layers.set(radio, layer); 116 | controller.add(layer, canvases); 117 | list.prepend(clone); 118 | } 119 | 120 | /** 121 | * Sets the name of a layer based on the value in the text input. 122 | * @param {HTMLInputElement} textInput 123 | */ 124 | function setLayerName(textInput) { 125 | const radio = textInput.parentElement.previousElementSibling; 126 | const layer = layers.get(radio); 127 | layer.name = textInput.value; 128 | } 129 | 130 | selectLayer(layers.get(checked())); 131 | 132 | list.addEventListener('change', (evt) => { 133 | const input = /** @type {HTMLInputElement} */ (evt.target); 134 | 135 | if (input.name === 'layer') { 136 | selectLayer(layers.get(input)); 137 | } else if (input.name === 'name') { 138 | setLayerName(input); 139 | } 140 | }); 141 | 142 | /** @type {number | undefined} */ 143 | let lastHandle; 144 | /** 145 | * Update the layer preview on the next animation frame. 146 | * @param {HTMLInputElement} input 147 | * @param {import('./layer.js').Layer} layer 148 | */ 149 | function drawOnNextFrame(input, layer) { 150 | cancelAnimationFrame(lastHandle); 151 | lastHandle = requestAnimationFrame(() => { 152 | updatePreview(input); 153 | controller.draw(layer); 154 | }); 155 | } 156 | 157 | options.addEventListener('input', (evt) => { 158 | const input = /** @type {HTMLInputElement} */ (evt.target); 159 | 160 | const layer = layers.get(checked()); 161 | layer[input.name] = 162 | input.type === 'range' ? Number.parseInt(input.value, 10) : input.value; 163 | 164 | const newLayer = copyLayer(layer); 165 | 166 | const position = controller.getPosition(layer); 167 | 168 | history.push(newLayer, input, position); 169 | 170 | drawOnNextFrame(input, layer); 171 | }); 172 | 173 | document.addEventListener('keydown', (e) => { 174 | if (e.ctrlKey && e.key === 'z' && history.isAvailableToPop()) { 175 | let current = history.pop(); 176 | // also delete layer by looping through the history 177 | // check if there is any current.position in the array 178 | if (history.isLastOne(current.position)) { 179 | const layerToDelete = controller.getLayer(current.position); 180 | if (layerToDelete.locked) return; 181 | const radio = current.input; 182 | const sibling = radio.closest('.layer').nextElementSibling; 183 | /** @type {HTMLInputElement} */ 184 | const nextRadio = sibling.querySelector('input[name="layer"]'); 185 | nextRadio.checked = true; 186 | selectLayer(layers.get(nextRadio)); 187 | 188 | controller.delete(layerToDelete); 189 | radio.closest('.layer').remove(); 190 | history.decreasePosition(); 191 | } else if (current.position !== history.getLast().position) { 192 | current = history.getLastOfPosition(current.position); 193 | const position = current.position; 194 | 195 | selectLayer(current.layer); 196 | 197 | const layer = controller.getLayer(position); 198 | 199 | Object.assign(layer, current.layer); 200 | drawOnNextFrame(current.input, layer); 201 | } else { 202 | const position = history.getLast().position; 203 | 204 | selectLayer(history.getLast().layer); 205 | 206 | const layer = controller.getLayer(position); 207 | 208 | Object.assign(layer, history.getLast().layer); 209 | drawOnNextFrame(history.getLast().input, layer); 210 | } 211 | } 212 | }); 213 | 214 | /** @param {Iterable} files */ 215 | async function addFiles(files) { 216 | // For each selected file, create a layer 217 | const layers = await layersFromFiles(files); 218 | // Insert layers after all load so that the order matches upload order 219 | layers.forEach(newLayerElement); 220 | } 221 | 222 | /** 223 | * Attach click listener to button 224 | * @param {string} name 225 | * @param {() => void} listener 226 | */ 227 | function button(name, listener) { 228 | document 229 | .querySelector(`button[name="${name}"]`) 230 | .addEventListener('click', listener); 231 | } 232 | 233 | button('add', () => { 234 | const color = '#' + Math.random().toString(16).substr(-6); 235 | newLayerElement(createLayer(color)); 236 | }); 237 | button('delete', () => { 238 | const radio = checked(); 239 | const layer = layers.get(radio); 240 | if (layer.locked) return; 241 | const sibling = radio.closest('.layer').nextElementSibling; 242 | /** @type {HTMLInputElement} */ 243 | const nextRadio = sibling.querySelector('input[name="layer"]'); 244 | selectLayer(layers.get(nextRadio)); 245 | nextRadio.checked = true; 246 | 247 | const position = controller.getPosition(layer); 248 | history.removeOnePosition(position); 249 | 250 | controller.delete(layer); 251 | radio.closest('.layer').remove(); 252 | }); 253 | button('share', async () => { 254 | const url = await toUrl(controller.export(), false); 255 | const params = new URLSearchParams({ demo: url }); 256 | const previewUrl = `https://maskable.app/?${params}`; 257 | 258 | if (navigator.share) { 259 | navigator.share({ 260 | url: previewUrl, 261 | title: 'Maskable icon', 262 | }); 263 | } else { 264 | window.open(previewUrl, '_blank'); 265 | } 266 | }); 267 | document.addEventListener('paste', (event) => { 268 | if (event.clipboardData.files.length > 0) { 269 | event.preventDefault(); 270 | addFiles(event.clipboardData.files); 271 | } 272 | }); 273 | 274 | if (window.EyeDropper) { 275 | /** @type {HTMLInputElement} */ 276 | const colorInput = document.querySelector('input[name="fill"]'); 277 | /** @type {HTMLButtonElement} */ 278 | const eyeDropperButton = document.querySelector(`button[name="eyedropper"]`); 279 | eyeDropperButton.hidden = false; 280 | const eyeDropper = new window.EyeDropper(); 281 | 282 | eyeDropperButton.addEventListener('click', () => { 283 | eyeDropperButton.style.fill = '#1c7bfd'; 284 | eyeDropper 285 | .open() 286 | .then((result) => { 287 | colorInput.value = result.sRGBHex; 288 | colorInput.dispatchEvent(new Event('input', { bubbles: true })); 289 | }) 290 | .finally(() => { 291 | eyeDropperButton.style.fill = 'currentColor'; 292 | }); 293 | }); 294 | } 295 | 296 | { 297 | /** @type {HTMLInputElement} The "Upload" button */ 298 | const fileInput = document.querySelector('.layers [name="upload"]'); 299 | /** @type {import('file-drop-element').FileDropElement} The invisible file drop area */ 300 | const fileDrop = document.querySelector('#icon_drop'); 301 | 302 | fileInput.addEventListener('change', () => addFiles(fileInput.files)); 303 | fileDrop.addEventListener('filedrop', (evt) => addFiles(evt.files)); 304 | } 305 | 306 | for (const element of document.querySelectorAll('.toggle--layers')) { 307 | element.addEventListener('click', () => 308 | document.body.classList.toggle('open'), 309 | ); 310 | } 311 | 312 | { 313 | const exportDialog = new DialogManager( 314 | document.querySelector('.export-dialog'), 315 | ); 316 | const lazyLoadSetup = lazy(() => 317 | import('./export.js').then(({ setupExportDialog }) => { 318 | exportDialog.setupContent = () => setupExportDialog(controller); 319 | }), 320 | ); 321 | 322 | for (const element of document.querySelectorAll('.toggle--export')) { 323 | element.addEventListener('mouseover', lazyLoadSetup); 324 | element.addEventListener('click', async () => { 325 | await lazyLoadSetup(); 326 | exportDialog.toggleDialog(); 327 | }); 328 | } 329 | } 330 | -------------------------------------------------------------------------------- /editor.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Maskable.app Editor 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | {{> meta }} 14 | 15 | 16 | 17 | 18 | 19 |
20 | {{> navbar page="editor" }} 21 |
22 |
23 | 26 | 29 | 30 |
31 | 32 |
33 |
34 |
35 |
36 |
37 | 38 | {{> controls }} 39 |
40 | 55 |
56 |
57 | 58 | 149 | 150 | 151 |

Export

152 |
153 | 161 |
162 | 166 |
167 |
168 | 172 | 176 | 180 | 184 | 188 | 192 | 196 |
197 | 198 |
199 |
200 | Show JSON 201 |
Select some size to display the JSON preview
202 |
203 | Copy this JSON into the "icons" array of your 204 | 209 | Web App Manifest 211 |
212 |
213 |
214 | 215 |
216 | 217 | 218 |
219 |
220 |
221 | 222 |
223 | {{> clippaths}} 224 | 225 | 226 | -------------------------------------------------------------------------------- /public/css/viewer.css: -------------------------------------------------------------------------------- 1 | body { 2 | --background: #fafafa; 3 | --text-color: black; 4 | --subtle-text-color: rgba(0, 0, 0, 0.7); 5 | --divider-color: rgba(0, 0, 0, 0.25); 6 | --tabs-height: 3rem; 7 | 8 | --accent: hsl(43.4, 100%, 70.2%); 9 | --accent-hover: hsl(43, 100%, 55.7%); 10 | --accent-pressed: hsl(43, 100%, 80%); 11 | --hint-hover: hsla(43, 100%, 55.7%, 0.3); 12 | --hint-active: hsla(43, 100%, 55.7%, 0.4); 13 | 14 | margin: 0; 15 | min-height: calc(100vh - 1rem); /* Not sure why this works (: */ 16 | 17 | font-family: 18 | 'Lato', 19 | -apple-system, 20 | BlinkMacSystemFont, 21 | 'Segoe UI', 22 | Roboto, 23 | Oxygen, 24 | Ubuntu, 25 | Cantarell, 26 | 'Open Sans', 27 | 'Helvetica Neue', 28 | sans-serif; 29 | color: var(--text-color); 30 | background: var(--background); 31 | text-align: center; 32 | accent-color: var(--accent); 33 | } 34 | .dark { 35 | --text-color: white; 36 | --subtle-text-color: rgba(255, 255, 255, 0.7); 37 | --background: #333; 38 | --divider-color: rgba(255, 255, 255, 0.7); 39 | } 40 | .dark .about__self-logo { 41 | filter: invert(100%); 42 | } 43 | 44 | h2.small, 45 | h3 { 46 | font-size: 1.17em; 47 | } 48 | 49 | .link { 50 | color: inherit; 51 | } 52 | .link:hover { 53 | text-decoration: none; 54 | } 55 | 56 | label, 57 | input, 58 | button, 59 | summary { 60 | font: inherit; 61 | color: inherit; 62 | cursor: pointer; 63 | } 64 | 65 | dialog { 66 | position: absolute; 67 | display: block; 68 | visibility: hidden; 69 | width: max-content; 70 | height: max-content; 71 | top: 0; 72 | left: 0; 73 | right: 0; 74 | bottom: 0; 75 | margin: auto; 76 | border: 0; 77 | 78 | border-radius: 4px; 79 | padding: 1rem; 80 | z-index: 6; 81 | text-align: left; 82 | background: var(--background); 83 | color: var(--text-color); 84 | transition: opacity 0.3s ease; 85 | opacity: 0; 86 | } 87 | dialog[open] { 88 | visibility: visible; 89 | opacity: 1; 90 | } 91 | dialog::backdrop { 92 | /* .scrim is used instead */ 93 | display: none; 94 | } 95 | 96 | .url-dialog[open] + .scrim.toggle--url { 97 | visibility: visible; 98 | } 99 | 100 | .title { 101 | margin: 0; 102 | font-size: 1.6rem; 103 | } 104 | .navbar { 105 | display: grid; 106 | grid-template: 107 | 'logo title links toggle' var(--tabs-height) 108 | / 40px fit-content(14rem) auto 2rem; 109 | column-gap: 0.5rem; 110 | margin-bottom: 2rem; 111 | align-items: center; 112 | padding: 0 0.5rem; 113 | } 114 | @media (max-width: 40rem) { 115 | .navbar { 116 | grid-template: 117 | 'logo title toggle' 3rem 118 | 'links links links' auto 119 | / 40px auto 2rem; 120 | } 121 | } 122 | 123 | .navbar__links { 124 | margin: auto; 125 | display: flex; 126 | grid-area: links; 127 | flex-wrap: wrap; 128 | justify-content: center; 129 | } 130 | .navbar__link { 131 | display: block; 132 | padding: 0 1rem; 133 | border-bottom: 2px solid transparent; 134 | 135 | line-height: calc(var(--tabs-height) - 2px); 136 | text-decoration: none; 137 | color: var(--subtle-text-color); 138 | font-weight: bold; 139 | white-space: nowrap; 140 | } 141 | .navbar__link:hover { 142 | background: var(--hint-hover); 143 | } 144 | .navbar__link:active, 145 | .navbar__link:focus { 146 | background: var(--hint-active); 147 | } 148 | .navbar__link--active { 149 | color: inherit; 150 | border-color: currentColor; 151 | } 152 | .navbar__logo { 153 | display: block; 154 | } 155 | dark-mode-toggle { 156 | --dark-mode-toggle-light-icon: url('toggle/sun.svg'); 157 | --dark-mode-toggle-dark-icon: url('toggle/moon.svg'); 158 | --dark-mode-toggle-icon-filter: invert(100%); 159 | } 160 | 161 | .hidden-offscreen { 162 | width: 0.1px; 163 | height: 0.1px; 164 | opacity: 0; 165 | overflow: hidden; 166 | position: absolute; 167 | top: 0; 168 | z-index: -1; 169 | } 170 | 171 | input[type='text'], 172 | input[type='url'] { 173 | border: 0; 174 | padding: 0; 175 | background: transparent; 176 | border-bottom: 1px solid var(--divider-color); 177 | } 178 | 179 | .button--primary, 180 | .button--secondary { 181 | display: inline-block; 182 | padding: 0 0.75rem; 183 | line-height: 2.25rem; 184 | 185 | border: 1px solid transparent; 186 | border-radius: 4px; 187 | color: black; 188 | background: var(--accent); 189 | font-weight: 700; 190 | font-size: 0.875em; 191 | } 192 | .button--primary:hover, 193 | .button--secondary:hover, 194 | input:focus + label.button--primary, 195 | input.focus + label.button--primary input:focus + label.button--secondary, 196 | input.focus + label.button--secondary { 197 | background: #ffbf1d; 198 | box-shadow: 199 | 0 1px 2px #3c40434d, 200 | 0 1px 3px 1px #3c404326; 201 | } 202 | .button--primary:active, 203 | .button--secondary:active { 204 | background: #ffd567; 205 | box-shadow: 206 | 0 1px 2px #3c40434d, 207 | 0 2px 6px 2px #3c404326; 208 | } 209 | 210 | .button__row { 211 | display: flex; 212 | flex-wrap: wrap; 213 | gap: 12px; 214 | justify-content: center; 215 | } 216 | 217 | .scrim, 218 | .button--small { 219 | border: 0; 220 | padding: 0; 221 | background: transparent; 222 | } 223 | .scrim, 224 | dialog::backdrop { 225 | background: rgba(0, 0, 0, 0.5); 226 | } 227 | .scrim { 228 | display: block; 229 | visibility: hidden; 230 | z-index: 5; 231 | position: fixed; 232 | top: 0; 233 | left: 0; 234 | bottom: 0; 235 | right: 0; 236 | width: 100%; 237 | } 238 | .button--small { 239 | width: 2rem; 240 | fill: currentColor; 241 | } 242 | .close-button { 243 | position: absolute; 244 | line-height: 2rem; 245 | width: 2rem; 246 | top: 1rem; 247 | right: 1rem; 248 | } 249 | 250 | .dialog-buttons { 251 | margin-top: 1rem; 252 | justify-content: end; 253 | } 254 | 255 | .drop { 256 | overflow: hidden; 257 | touch-action: none; 258 | height: 100%; 259 | width: 100%; 260 | } 261 | .drop::after { 262 | content: ''; 263 | position: absolute; 264 | display: block; 265 | left: 10px; 266 | top: 10px; 267 | right: 10px; 268 | bottom: 10px; 269 | border: 2px dashed rgba(0, 0, 0, 0.5); 270 | background-color: rgba(0, 0, 0, 0.2); 271 | border-radius: 10px; 272 | opacity: 0; 273 | transform: scale(0.95); 274 | transition: all 200ms ease-in; 275 | transition-property: transform, opacity; 276 | pointer-events: none; 277 | z-index: 10; 278 | } 279 | .drop.drop-valid::after { 280 | opacity: 1; 281 | transform: scale(1); 282 | transition-timing-function: ease-out; 283 | } 284 | 285 | .demo__container { 286 | position: relative; 287 | } 288 | .demo__url-container { 289 | display: inline; 290 | } 291 | .demo__url { 292 | position: absolute; 293 | top: 0; 294 | left: 0; 295 | right: 0; 296 | padding: 1rem; 297 | background: var(--background); 298 | } 299 | 300 | .demo__list { 301 | display: flex; 302 | padding: 0; 303 | margin: 1rem 0; 304 | gap: 4px; 305 | justify-content: center; 306 | } 307 | .demo { 308 | display: inline-block; 309 | width: 100%; 310 | max-width: 56px; 311 | 312 | box-shadow: 0 1px 2px rgba(0, 0, 0, 0.24); 313 | border-radius: 2px; 314 | } 315 | .demo__link { 316 | display: block; 317 | width: 100%; 318 | height: 0; 319 | overflow: hidden; 320 | padding-top: 100%; 321 | position: relative; 322 | } 323 | .demo__preview { 324 | display: block; 325 | position: absolute; 326 | top: 0; 327 | left: 0; 328 | width: 100%; 329 | height: 100%; 330 | } 331 | 332 | .icon__grid { 333 | height: 192px; 334 | width: 192px; 335 | margin: 1em auto; 336 | position: relative; 337 | 338 | border: 1px solid transparent; 339 | transition: transform 0.3s ease; 340 | } 341 | .icon__original, 342 | .icon__mask, 343 | .icon__shadow { 344 | position: absolute; 345 | height: 100%; 346 | width: 100%; 347 | } 348 | .masked { 349 | border-radius: 50%; 350 | overflow: hidden; 351 | transition: border-radius 0.3s ease; 352 | } 353 | .icon { 354 | position: absolute; 355 | display: block; 356 | width: 100%; 357 | height: 100%; 358 | transform: scale(1.25); 359 | transition: transform 0.3s ease; 360 | } 361 | @supports (clip-path: inset(0)) or (-webkit-clip-path: inset(0)) { 362 | .masked { 363 | border-radius: 0px; 364 | -webkit-clip-path: inset(10% round 50%); 365 | clip-path: inset(10% round 50%); 366 | transition: 367 | clip-path 0.3s ease, 368 | -webkit-clip-path 0.3s ease; 369 | } 370 | .icon { 371 | transform: none; 372 | transition: none; 373 | } 374 | } 375 | .icon__shadow { 376 | z-index: -1; 377 | 378 | background: rgba(0, 0, 0, 0.2); 379 | transform: scale(1.01) translateY(1px); 380 | } 381 | .icon__original { 382 | opacity: 0; 383 | transition: opacity 0.3s ease; 384 | } 385 | .icon--ghost > .icon__original { 386 | opacity: 0.4; 387 | } 388 | 389 | .mask__option { 390 | display: inline-block; 391 | line-height: 1.5em; 392 | } 393 | a.mask__option { 394 | margin-left: 1em; 395 | } 396 | 397 | .source, 398 | .about p { 399 | opacity: 0.7; 400 | } 401 | 402 | .text { 403 | padding: 0 0.5em; 404 | } 405 | hr { 406 | margin: 2rem auto; 407 | max-width: 20rem; 408 | } 409 | .about { 410 | margin: auto; 411 | max-width: 42rem; 412 | } 413 | .about__extra { 414 | margin: 0.5em 0; 415 | } 416 | .about__row { 417 | gap: 1em; 418 | display: flex; 419 | flex-wrap: wrap; 420 | justify-content: center; 421 | } 422 | .about__self-logo { 423 | display: block; 424 | margin: auto; 425 | } 426 | 427 | .clip-paths { 428 | position: absolute; 429 | } 430 | 431 | @media (max-height: 400px) { 432 | .button--primary, 433 | .button--secondary { 434 | line-height: 1.5rem; 435 | } 436 | .icon__grid { 437 | height: 128px; 438 | width: 128px; 439 | } 440 | body { 441 | --tabs-height: 2rem; 442 | } 443 | } 444 | 445 | @media (prefers-reduced-motion: reduce) { 446 | .drop, 447 | .icon, 448 | .icon__grid, 449 | .icon__mask, 450 | .icon__shadow, 451 | .icon__original { 452 | transition: none; 453 | } 454 | } 455 | 456 | @media (max-width: 20rem) { 457 | .title { 458 | font-size: 8vw; 459 | } 460 | } 461 | 462 | .mask--path { 463 | display: none; 464 | } 465 | @supports (clip-path: url(#squircle)) or (-webkit-clip-path: url(#squircle)) { 466 | .mask--path { 467 | display: inline-block; 468 | } 469 | .mask__option[hidden] { 470 | display: none; 471 | } 472 | } 473 | 474 | #url { 475 | width: 20em; 476 | max-width: 100%; 477 | } 478 | 479 | @keyframes slide-in-from-right { 480 | from { 481 | translate: 100% 0; 482 | } 483 | } 484 | @keyframes slide-out-to-right { 485 | to { 486 | translate: 100% 0; 487 | } 488 | } 489 | 490 | @media (prefers-reduced-motion: no-preference) { 491 | @view-transition { 492 | navigation: auto; 493 | } 494 | .viewer { 495 | view-transition-name: viewer; 496 | } 497 | .layers { 498 | view-transition-name: layers; 499 | } 500 | 501 | ::view-transition-old(*) { 502 | animation-duration: 0.2s; 503 | animation-timing-function: ease-in; 504 | } 505 | @media (min-width: 56rem) { 506 | ::view-transition-old(layers) { 507 | animation: 0.2s ease-in both slide-out-to-right; 508 | } 509 | ::view-transition-new(layers) { 510 | animation: 0.2s ease-in both slide-in-from-right; 511 | } 512 | } 513 | } 514 | --------------------------------------------------------------------------------