├── .editorconfig ├── .eslintrc.json ├── .github ├── cover.jpg └── workflows │ ├── examples.yml │ └── main.yml ├── .gitignore ├── .prettierrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── core ├── .gitignore ├── LICENSE ├── README.md ├── package.json ├── rollup.config.mjs ├── src │ ├── canvas-grid.ts │ ├── index.ts │ ├── mce-canvas-common.ts │ ├── mce-canvas.ts │ ├── mce-image-json.ts │ ├── mce-layer.ts │ ├── mce-static-canvas.ts │ ├── replacer │ │ ├── fill-image.ts │ │ ├── fit-image.ts │ │ ├── index.ts │ │ ├── load-image.ts │ │ ├── mce-canvas-replacer.ts │ │ └── stretch-image.ts │ └── shapes │ │ ├── index.ts │ │ ├── mce-image.ts │ │ ├── mce-path.ts │ │ ├── mce-rect.ts │ │ └── mce-textbox.ts └── tsconfig.json ├── demos ├── svelte-app │ ├── .gitignore │ ├── package.json │ ├── src │ │ ├── app.d.ts │ │ ├── app.html │ │ ├── lib │ │ │ └── index.ts │ │ └── routes │ │ │ ├── +page.svelte │ │ │ ├── backend │ │ │ └── +server.ts │ │ │ └── style.css │ ├── static │ │ ├── favicon.ico │ │ └── noise.png │ ├── svelte.config.js │ ├── tsconfig.json │ └── vite.config.ts └── webpack-app │ ├── .gitignore │ ├── package.json │ ├── public │ ├── assets │ │ ├── background-pattern.svg │ │ ├── cat.jpg │ │ ├── common.css │ │ └── favicon.ico │ ├── crop.html │ ├── inpainting-mask.html │ ├── template-creator.html │ └── vanilla-javascript.html │ ├── src │ ├── crop.ts │ ├── inpainting-mask.ts │ └── template-creator.ts │ ├── tsconfig.json │ └── webpack.config.js ├── editor ├── .gitignore ├── css │ └── editor.css ├── karma.conf.cjs ├── package.json ├── rollup.config.mjs ├── src │ ├── components │ │ ├── component.ts │ │ ├── icon-button-component.ts │ │ └── panel-component.ts │ ├── core │ │ ├── html.ts │ │ ├── icons.ts │ │ └── simple-event.ts │ ├── editor-configuration.ts │ ├── editor-state.ts │ ├── editor.ts │ ├── index.ts │ ├── layout-controller.ts │ ├── sidebar │ │ ├── layers │ │ │ ├── layer-item.ts │ │ │ └── layers-panel.ts │ │ ├── properties │ │ │ ├── editors │ │ │ │ ├── choice-property-editor.ts │ │ │ │ ├── color-property-editor.ts │ │ │ │ ├── number-property-editor.ts │ │ │ │ └── simple-property-editor.ts │ │ │ ├── layout │ │ │ │ ├── property-editor-row.ts │ │ │ │ └── property-editor-rows.ts │ │ │ ├── object-properties-editor.ts │ │ │ ├── properties-panel.ts │ │ │ ├── property-accessor.ts │ │ │ ├── root-properties-editor.ts │ │ │ ├── shapes │ │ │ │ ├── circle-shape-editor.ts │ │ │ │ ├── common-shape-editor.ts │ │ │ │ ├── image-shape-editor.ts │ │ │ │ ├── rect-shape-editor.ts │ │ │ │ ├── shape-editor-factory.ts │ │ │ │ ├── textbox-shape-editor.ts │ │ │ │ └── unknown-shape-editor.ts │ │ │ └── update-manager.ts │ │ └── sidebar.ts │ ├── toolbar │ │ ├── editors │ │ │ ├── color-toolbar-input.ts │ │ │ └── number-toolbar-input.ts │ │ ├── modes │ │ │ ├── brush-toolbar-mode.ts │ │ │ ├── rect-toolbar-mode.ts │ │ │ └── toolbar-mode-factory.ts │ │ └── toolbar.ts │ ├── toolbox │ │ ├── actions │ │ │ ├── open-image-action.ts │ │ │ └── open-image-file.ts │ │ ├── toolbox-item.ts │ │ ├── toolbox-zoom.ts │ │ └── toolbox.ts │ └── workspace │ │ ├── modes │ │ ├── box-painter.ts │ │ ├── brush-workspace-mode.ts │ │ ├── rect-workspace-mode.ts │ │ ├── select-workspace-mode.ts │ │ ├── textbox-workspace-mode.ts │ │ ├── workspace-mode-factory.ts │ │ └── workspace-mode.ts │ │ └── workspace.ts └── tsconfig.json ├── package.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml └── scripts ├── append-ga.cjs ├── publish.sh └── set-version.cjs /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = tab 6 | tab_width = 4 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | 10 | [*.ts] 11 | quote_type = single 12 | 13 | [*.md] 14 | indent_style = space 15 | max_line_length = off 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": [ 5 | "@typescript-eslint" 6 | ], 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:@typescript-eslint/eslint-recommended", 10 | "plugin:@typescript-eslint/recommended" 11 | ], 12 | "rules": { 13 | "no-mixed-spaces-and-tabs": "off" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.github/cover.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nocode-js/mini-canvas-editor/420ec78ab7659741e4834659f5d0ba2546871431/.github/cover.jpg -------------------------------------------------------------------------------- /.github/workflows/examples.yml: -------------------------------------------------------------------------------- 1 | name: examples 2 | on: 3 | push: 4 | branches: 5 | - main 6 | jobs: 7 | build: 8 | name: Build 9 | runs-on: ${{matrix.os}} 10 | strategy: 11 | matrix: 12 | os: 13 | - ubuntu-latest 14 | permissions: 15 | contents: read 16 | pages: write 17 | id-token: write 18 | environment: 19 | name: github-pages 20 | url: ${{ steps.deployment.outputs.page_url }} 21 | steps: 22 | - name: Checkout Repo 23 | uses: actions/checkout@v3 24 | - name: Setup Node 25 | uses: actions/setup-node@v2 26 | with: 27 | node-version: 18 28 | - uses: pnpm/action-setup@v2 29 | name: Install pnpm 30 | with: 31 | version: 8 32 | run_install: false 33 | - name: Install 34 | run: pnpm install 35 | - name: Build 36 | run: pnpm build 37 | - name: Append Analytics 38 | run: | 39 | node scripts/append-ga.cjs demos/webpack-app/public 40 | - name: Upload artifact 41 | uses: actions/upload-pages-artifact@v3 42 | with: 43 | path: demos 44 | - name: Deploy 45 | id: deployment 46 | uses: actions/deploy-pages@v4 47 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: main 2 | on: 3 | pull_request: 4 | branches: 5 | - main 6 | push: 7 | branches: 8 | - main 9 | jobs: 10 | build: 11 | name: Build 12 | runs-on: ${{matrix.os}} 13 | strategy: 14 | matrix: 15 | os: [ubuntu-latest] 16 | steps: 17 | - name: Checkout Repo 18 | uses: actions/checkout@v2 19 | - name: Setup Node 20 | uses: actions/setup-node@v2 21 | with: 22 | node-version: 18 23 | - uses: pnpm/action-setup@v2 24 | name: Install pnpm 25 | with: 26 | version: '9.9.0' 27 | run_install: false 28 | - name: Install 29 | run: pnpm install 30 | - name: Build 31 | run: pnpm build 32 | - name: Prettier 33 | run: pnpm prettier 34 | - name: Eslint 35 | run: pnpm eslint 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .yarn/ 2 | build/ 3 | node_modules/ 4 | coverage/ 5 | .vscode/ 6 | dist/ 7 | yarn-*.log 8 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 140, 3 | "singleQuote": true, 4 | "trailingComma": "none", 5 | "semi": true, 6 | "useTabs": true, 7 | "arrowParens": "avoid" 8 | } 9 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.3.2 2 | 3 | Changed the project location on GitHub to [nocode-js/mini-canvas-editor](https://github.com/nocode-js/mini-canvas-editor). 4 | 5 | ## 0.3.1 6 | 7 | Added a new fill mode to the `replaceRectToImage` method in the `MceCanvasReplacer` class (credit: [@seven7seven](https://github.com/seven7seven)). 8 | 9 | ## 0.3.0 10 | 11 | This version changed the license to MIT. 12 | 13 | ## 0.2.0 14 | 15 | This version improves support for UMD bundles. 16 | 17 | ## 0.1.1 18 | 19 | Add `stroke` and `strokeWidth` fields to the properties editor for the textbox. 20 | 21 | ## 0.1.0 22 | 23 | Initial release. 🚀 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2024 N4NO.com 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Mini Canvas Editor](.github/cover.jpg) 2 | 3 | # Mini Canvas Editor 4 | 5 | [![Build Status](https://img.shields.io/endpoint.svg?url=https%3A%2F%2Factions-badge.atrox.dev%2Fnocode-js%2Fmini-canvas-editor%2Fbadge%3Fref%3Dmain&style=flat-square)](https://actions-badge.atrox.dev/nocode-js/mini-canvas-editor/goto?ref=main) [![License: MIT](https://img.shields.io/badge/license-MIT-green?style=flat-square)](/LICENSE) [![View this project on NPM](https://img.shields.io/npm/v/mini-canvas-editor.svg?style=flat-square)](https://npmjs.org/package/mini-canvas-editor) 6 | 7 | Canvas editor component for JavaScript application. Works with any front-end framework. Easy to integrate and use. Well-known graphical interface. Edit images, draw shapes, add texts and more. Gzipped size less than 100 KB. Uses Fabric.js internally. 8 | 9 | Main use cases: 10 | 11 | * resize image, 12 | * crop image, 13 | * create a template, render it on the front-end and the back-end (Node.js only), 14 | * create inpainting mask. 15 | 16 | Online Examples: 17 | 18 | * [🎬 Template Creator](https://nocode-js.github.io/mini-canvas-editor/webpack-app/public/template-creator.html) 19 | * [🎨 Inpainting Mask](https://nocode-js.github.io/mini-canvas-editor/webpack-app/public/inpainting-mask.html) 20 | * [🔲 Crop](https://nocode-js.github.io/mini-canvas-editor/webpack-app/public/crop.html) 21 | * [📦 Vanilla JavaScript](https://nocode-js.github.io/mini-canvas-editor/webpack-app/public/vanilla-javascript.html) 22 | 23 | ## 🚀 Installation 24 | 25 | To use the editor you should add JS/TS files and CSS files to your project. 26 | 27 | ### NPM 28 | 29 | Install this package by [NPM](https://www.npmjs.com/) command: 30 | 31 | `npm i mini-canvas-editor` 32 | 33 | To import the package: 34 | 35 | ```ts 36 | import { Editor } from 'mini-canvas-editor'; 37 | ``` 38 | 39 | If you use [css-loader](https://webpack.js.org/loaders/css-loader/) or similar, you can add CSS files to your bundle: 40 | 41 | ```ts 42 | import 'mini-canvas-editor/css/editor.css'; 43 | ``` 44 | 45 | To create the editor write the below code: 46 | 47 | ```ts 48 | Editor.createBlank(placeholder, 200, 300, {}); 49 | ``` 50 | 51 | ### CDN 52 | 53 | Add the below code to your head section in HTML document. 54 | 55 | ```html 56 | 57 | ... 58 | 59 | 60 | 61 | ``` 62 | 63 | Create the editor by: 64 | 65 | ```js 66 | miniCanvasEditor.Editor.createBlank(placeholder, 200, 300, {}); 67 | ``` 68 | 69 | ## 💡 License 70 | 71 | This project is released under the MIT license. 72 | -------------------------------------------------------------------------------- /core/.gitignore: -------------------------------------------------------------------------------- 1 | lib/ 2 | -------------------------------------------------------------------------------- /core/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2024 N4NO.com 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /core/README.md: -------------------------------------------------------------------------------- 1 | # Mini Canvas 2 | 3 | This package contains the core features for [Mini Canvas Editor](https://github.com/img-js/mini-canvas-editor). 4 | 5 | ## 💡 License 6 | 7 | See the [LICENSE](/LICENSE) file for more details. 8 | -------------------------------------------------------------------------------- /core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mini-canvas-core", 3 | "description": "Core library for Mini Canvas Editor.", 4 | "version": "0.3.2", 5 | "license": "SEE LICENSE IN LICENSE", 6 | "type": "module", 7 | "main": "./lib/cjs/index.browser.cjs", 8 | "types": "./lib/index.browser.d.ts", 9 | "exports": { 10 | ".": { 11 | "types": { 12 | "require": "./lib/index.browser.d.ts", 13 | "default": "./lib/index.browser.d.ts" 14 | }, 15 | "default": { 16 | "require": "./lib/cjs/index.browser.cjs", 17 | "default": "./lib/esm/index.browser.js" 18 | } 19 | }, 20 | "./node": { 21 | "types": { 22 | "require": "./lib/index.node.d.ts", 23 | "default": "./lib/index.node.d.ts" 24 | }, 25 | "default": { 26 | "require": "./lib/cjs/index.node.cjs", 27 | "default": "./lib/esm/index.node.js" 28 | } 29 | } 30 | }, 31 | "typesVersions": { 32 | "*": { 33 | ".": [ 34 | "./lib/index.browser.d.ts" 35 | ], 36 | "node": [ 37 | "./lib/index.node.d.ts" 38 | ] 39 | } 40 | }, 41 | "sideEffects": false, 42 | "files": [ 43 | "lib/", 44 | "dist/" 45 | ], 46 | "publishConfig": { 47 | "registry": "https://registry.npmjs.org/" 48 | }, 49 | "repository": { 50 | "type": "git", 51 | "url": "https://github.com/b4rtaz/mini-canvas-editor.git" 52 | }, 53 | "author": { 54 | "name": "N4NO", 55 | "url": "https://n4no.com/" 56 | }, 57 | "homepage": "https://n4no.com/", 58 | "scripts": { 59 | "prepare": "cp ../LICENSE LICENSE", 60 | "clean": "rm -rf lib && rm -rf node_modules/.cache/rollup-plugin-typescript2", 61 | "build": "pnpm clean && rollup -c", 62 | "start": "pnpm clean && rollup -c --watch", 63 | "eslint": "eslint ./src --ext .ts", 64 | "prettier": "prettier --check ./src", 65 | "prettier:fix": "prettier --write ./src" 66 | }, 67 | "dependencies": { 68 | "fabric": "6.0.0-beta14" 69 | }, 70 | "devDependencies": { 71 | "tslib": "^2.6.2", 72 | "typescript": "^5.2.2", 73 | "rollup": "^4.1.4", 74 | "rollup-plugin-dts": "^6.1.0", 75 | "rollup-plugin-typescript2": "^0.36.0", 76 | "@rollup/plugin-node-resolve": "^15.2.2", 77 | "@rollup/plugin-terser": "^0.4.4", 78 | "@rollup/plugin-replace": "^5.0.4", 79 | "prettier": "^3.0.3", 80 | "@typescript-eslint/eslint-plugin": "^6.7.5", 81 | "@typescript-eslint/parser": "^6.7.5", 82 | "eslint": "^8.51.0" 83 | }, 84 | "keywords": [ 85 | "canvas", 86 | "editor", 87 | "image", 88 | "image editor", 89 | "photo editor", 90 | "photo", 91 | "javascript image editor", 92 | "paint", 93 | "js paint", 94 | "image crop", 95 | "image resize", 96 | "inpainting" 97 | ] 98 | } -------------------------------------------------------------------------------- /core/rollup.config.mjs: -------------------------------------------------------------------------------- 1 | import dts from 'rollup-plugin-dts'; 2 | import typescript from 'rollup-plugin-typescript2'; 3 | import replace from '@rollup/plugin-replace'; 4 | import { nodeResolve } from '@rollup/plugin-node-resolve'; 5 | import terser from '@rollup/plugin-terser'; 6 | import fs from 'fs'; 7 | 8 | const packageJson = JSON.parse(fs.readFileSync('./package.json', 'utf8')); 9 | const external = Object.keys(packageJson.dependencies); 10 | 11 | const ts = typescript({ 12 | useTsconfigDeclarationDir: true 13 | }); 14 | 15 | function bundle(fileName, node) { 16 | return [ 17 | { 18 | input: './src/index.ts', 19 | plugins: [ 20 | node ? replace({ 21 | preventAssignment: true, 22 | delimiters: ['', ''], 23 | values: { 24 | '\'fabric\'': '\'fabric/node\'', 25 | } 26 | }) : false, 27 | ts, 28 | terser() 29 | ], 30 | cache: false, 31 | external, 32 | output: [ 33 | { 34 | file: `./lib/cjs/${fileName}.cjs`, 35 | format: 'cjs' 36 | }, 37 | { 38 | file: `./lib/esm/${fileName}.js`, 39 | format: 'es' 40 | } 41 | ] 42 | }, 43 | { 44 | input: `./build/index.d.ts`, 45 | output: [ 46 | { 47 | file: `./lib/${fileName}.d.ts`, 48 | format: 'es' 49 | } 50 | ], 51 | plugins: [dts()], 52 | } 53 | ]; 54 | } 55 | 56 | export default [ 57 | ...bundle('index.browser', false), 58 | ...bundle('index.node', true), 59 | { 60 | input: './src/index.ts', 61 | plugins: [ 62 | nodeResolve({ 63 | browser: true, 64 | }), 65 | ts, 66 | terser() 67 | ], 68 | output: [ 69 | { 70 | file: './dist/index.umd.js', 71 | format: 'umd', 72 | name: 'miniCanvasCore' 73 | } 74 | ] 75 | } 76 | ]; 77 | -------------------------------------------------------------------------------- /core/src/canvas-grid.ts: -------------------------------------------------------------------------------- 1 | export function createCanvasGrid(): HTMLCanvasElement { 2 | const canvas = document.createElement('canvas'); 3 | canvas.width = 10; 4 | canvas.height = 10; 5 | const context = canvas.getContext('2d')!; 6 | context.fillStyle = '#ffffff'; 7 | context.fillRect(0, 0, 10, 10); 8 | context.fillStyle = '#f0f0f0'; 9 | context.fillRect(0, 0, 5, 5); 10 | context.fillRect(5, 5, 5, 5); 11 | return canvas; 12 | } 13 | -------------------------------------------------------------------------------- /core/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from 'fabric'; 2 | export * from './replacer'; 3 | export * from './shapes'; 4 | export * from './mce-canvas'; 5 | export * from './mce-image-json'; 6 | export * from './mce-layer'; 7 | export * from './mce-static-canvas'; 8 | -------------------------------------------------------------------------------- /core/src/mce-canvas-common.ts: -------------------------------------------------------------------------------- 1 | import { StaticCanvas } from 'fabric'; 2 | import { MceRect } from './shapes'; 3 | import { MceLayer } from './mce-layer'; 4 | 5 | export class MceCanvasCommon { 6 | public constructor(private readonly canvas: StaticCanvas & { workspaceBackground: MceRect }) {} 7 | 8 | public readonly getWorkspaceObjects = () => { 9 | return this.canvas.getObjects().filter(object => object !== this.canvas.workspaceBackground); 10 | }; 11 | 12 | public readonly getLayers = (): MceLayer[] => { 13 | const objects = this.canvas.getObjects(); 14 | const layers: MceLayer[] = []; 15 | for (let i = 0; i < objects.length; i++) { 16 | const object = objects[i]; 17 | if (object !== this.canvas.workspaceBackground) { 18 | layers.push({ 19 | index: layers.length, 20 | realIndex: i, 21 | name: object.get('label') ?? object.type, 22 | type: object.type 23 | }); 24 | } 25 | } 26 | return layers; 27 | }; 28 | } 29 | -------------------------------------------------------------------------------- /core/src/mce-canvas.ts: -------------------------------------------------------------------------------- 1 | import { Canvas, CanvasOptions, TOptions, Pattern } from 'fabric'; 2 | import { MceImageJSON } from './mce-image-json'; 3 | import { createCanvasGrid } from './canvas-grid'; 4 | import { MceRect } from './shapes'; 5 | import { MceCanvasCommon } from './mce-canvas-common'; 6 | 7 | export class MceCanvas extends Canvas { 8 | public static createBlank( 9 | workspaceWidth: number, 10 | workspaceHeight: number, 11 | canvasElement: HTMLCanvasElement, 12 | options?: TOptions 13 | ): MceCanvas { 14 | const workspaceBackground = new MceRect({ 15 | left: 0, 16 | top: 0, 17 | width: workspaceWidth, 18 | height: workspaceHeight, 19 | fill: new Pattern({ 20 | repeat: 'repeat', 21 | source: createCanvasGrid() 22 | }), 23 | selectable: false 24 | }); 25 | const canvas = new MceCanvas(workspaceWidth, workspaceHeight, canvasElement, { 26 | clipPath: workspaceBackground, 27 | ...options 28 | }); 29 | canvas.add(workspaceBackground); 30 | canvas.workspaceBackground = workspaceBackground; 31 | return canvas; 32 | } 33 | 34 | public static async createFromJSON( 35 | json: MceImageJSON, 36 | canvasElement: HTMLCanvasElement, 37 | options?: TOptions 38 | ): Promise { 39 | const canvas = new MceCanvas(json.width, json.height, canvasElement, options); 40 | await canvas.loadFromJSON(json.data); 41 | const objects = canvas.getObjects(); 42 | canvas.workspaceBackground = objects[0] as MceRect; 43 | if (!canvas.workspaceBackground) { 44 | throw new Error('JSON does not contain a workspace background'); 45 | } 46 | canvas.clipPath = canvas.workspaceBackground; 47 | return canvas; 48 | } 49 | 50 | public workspaceBackground!: MceRect; 51 | private readonly common = new MceCanvasCommon(this); 52 | 53 | private constructor( 54 | public workspaceWidth: number, 55 | public workspaceHeight: number, 56 | canvasElement: HTMLCanvasElement, 57 | options?: TOptions 58 | ) { 59 | super(canvasElement, options); 60 | } 61 | 62 | public readonly getWorkspaceObjects = this.common.getWorkspaceObjects; 63 | public readonly getLayers = this.common.getLayers; 64 | 65 | public toImageJSON(): MceImageJSON { 66 | const json = { 67 | width: this.workspaceWidth, 68 | height: this.workspaceHeight, 69 | data: this.toJSON() 70 | }; 71 | return json; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /core/src/mce-image-json.ts: -------------------------------------------------------------------------------- 1 | export interface MceImageJSON { 2 | width: number; 3 | height: number; 4 | data: object; 5 | } 6 | -------------------------------------------------------------------------------- /core/src/mce-layer.ts: -------------------------------------------------------------------------------- 1 | export interface MceLayer { 2 | index: number; 3 | realIndex: number; 4 | name: string; 5 | type: string; 6 | } 7 | -------------------------------------------------------------------------------- /core/src/mce-static-canvas.ts: -------------------------------------------------------------------------------- 1 | import { ImageFormat, StaticCanvas, TOptions, StaticCanvasOptions } from 'fabric'; 2 | import { MceCanvasReplacer } from './replacer/mce-canvas-replacer'; 3 | import { MceImageJSON } from './mce-image-json'; 4 | import { MceCanvasCommon } from './mce-canvas-common'; 5 | import { MceRect } from './shapes'; 6 | 7 | export class MceStaticCanvas extends StaticCanvas { 8 | public static async createFromJSON(json: MceImageJSON): Promise { 9 | const canvas = new MceStaticCanvas(json.width, json.height, { 10 | width: json.width, 11 | height: json.height 12 | }); 13 | await canvas.loadFromJSON(json.data); 14 | canvas.workspaceBackground = canvas.getObjects()[0] as MceRect; 15 | if (!canvas.workspaceBackground) { 16 | throw new Error('JSON does not contain a workspace background'); 17 | } 18 | return canvas; 19 | } 20 | 21 | public workspaceBackground!: MceRect; 22 | private readonly common = new MceCanvasCommon(this); 23 | 24 | private constructor( 25 | private workspaceWidth: number, 26 | private workspaceHeight: number, 27 | options?: TOptions 28 | ) { 29 | super(null as unknown as HTMLCanvasElement, options); 30 | } 31 | 32 | public readonly getWorkspaceObjects = this.common.getWorkspaceObjects; 33 | public readonly getLayers = this.common.getLayers; 34 | 35 | public getReplacer(): MceCanvasReplacer { 36 | return MceCanvasReplacer.create(this); 37 | } 38 | 39 | public exportToDataURL(format: ImageFormat, quality = 1): string { 40 | this.workspaceBackground.visible = false; 41 | 42 | return this.toDataURL({ 43 | width: this.workspaceWidth, 44 | height: this.workspaceHeight, 45 | multiplier: 1, 46 | format, 47 | quality 48 | }); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /core/src/replacer/fill-image.ts: -------------------------------------------------------------------------------- 1 | import { MceImage, MceRect } from '../shapes'; 2 | 3 | export function fillImage(rect: MceRect, sourceImage: HTMLImageElement): MceImage { 4 | const scale = Math.min(rect.width / sourceImage.width, rect.height / sourceImage.height); 5 | const cropWidth = rect.width / scale; 6 | const cropHeight = rect.height / scale; 7 | 8 | // Center the crop area 9 | const cropY = Math.max(0, (sourceImage.height - cropHeight) / 2); 10 | const cropX = Math.max(0, (sourceImage.width - cropWidth) / 2); 11 | 12 | // Center the image in the rect 13 | const left = rect.left + (rect.width - sourceImage.width * scale) / 2; 14 | const top = rect.top + (rect.height - sourceImage.height * scale) / 2; 15 | 16 | return new MceImage(sourceImage, { 17 | angle: rect.angle, 18 | left, 19 | top, 20 | width: cropWidth, 21 | height: cropHeight, 22 | scaleX: scale, 23 | scaleY: scale, 24 | cropY, 25 | cropX 26 | }); 27 | } 28 | -------------------------------------------------------------------------------- /core/src/replacer/fit-image.ts: -------------------------------------------------------------------------------- 1 | import { MceImage, MceRect } from '../shapes'; 2 | 3 | export function fitImage(rect: MceRect, sourceImage: HTMLImageElement): MceImage { 4 | const scale = Math.max(rect.getScaledWidth() / sourceImage.width, rect.getScaledHeight() / sourceImage.height); 5 | const cropWidth = rect.getScaledWidth() / scale; 6 | const cropHeight = rect.getScaledHeight() / scale; 7 | const cropY = (sourceImage.height - rect.getScaledHeight() / scale) / 2; 8 | const cropX = (sourceImage.width - rect.getScaledWidth() / scale) / 2; 9 | 10 | return new MceImage(sourceImage, { 11 | angle: rect.angle, 12 | left: rect.left, 13 | top: rect.top, 14 | width: cropWidth, 15 | height: cropHeight, 16 | scaleX: scale, 17 | scaleY: scale, 18 | cropY, 19 | cropX 20 | }); 21 | } 22 | -------------------------------------------------------------------------------- /core/src/replacer/index.ts: -------------------------------------------------------------------------------- 1 | export * from './fit-image'; 2 | export * from './mce-canvas-replacer'; 3 | export * from './stretch-image'; 4 | export * from './fill-image'; 5 | -------------------------------------------------------------------------------- /core/src/replacer/load-image.ts: -------------------------------------------------------------------------------- 1 | import { getEnv } from 'fabric'; 2 | 3 | export function loadImage(url: string): Promise { 4 | return new Promise((resolve, reject) => { 5 | const image = getEnv().document.createElement('img'); 6 | image.onload = () => resolve(image); 7 | image.onerror = () => { 8 | const safeUrl = url.length > 20 ? url.substring(0, 20) + '...' : url; 9 | reject(new Error(`Failed to load image: ${safeUrl}`)); 10 | }; 11 | image.src = url; 12 | }); 13 | } 14 | -------------------------------------------------------------------------------- /core/src/replacer/mce-canvas-replacer.ts: -------------------------------------------------------------------------------- 1 | import { MceImage, MceRect, MceTextbox } from '../shapes'; 2 | import { fitImage } from './fit-image'; 3 | import { stretchImage } from './stretch-image'; 4 | import { fillImage } from './fill-image'; 5 | import { MceStaticCanvas } from '../mce-static-canvas'; 6 | import { Object as FabricObject } from 'fabric'; 7 | import { loadImage } from './load-image'; 8 | import { MceLayer } from '../mce-layer'; 9 | 10 | export class MceCanvasReplacer { 11 | public static create(canvas: MceStaticCanvas) { 12 | const objects = canvas.getObjects(); 13 | return new MceCanvasReplacer(canvas, objects); 14 | } 15 | 16 | public constructor( 17 | private readonly canvas: MceStaticCanvas, 18 | private readonly objects: FabricObject[] 19 | ) {} 20 | 21 | public getText(layer: MceLayer): string { 22 | const textbox = this.objects[layer.realIndex] as MceTextbox; 23 | return textbox.text; 24 | } 25 | 26 | public replaceText(layer: MceLayer, text: string) { 27 | const textbox = this.objects[layer.realIndex] as MceTextbox; 28 | if (textbox.visible) { 29 | textbox.setText(text); 30 | } 31 | } 32 | 33 | /** 34 | * Replace rectangle to image. 35 | * @param layer Layer. 36 | * @param sourceImage Image element or URL (for example data URL: `data:image/png;base64,...`). 37 | * @param mode Mode of fitting image to the rectangle. 38 | * @returns Promise that resolves when the rect is replaced. 39 | */ 40 | public async replaceRectToImage( 41 | layer: MceLayer, 42 | sourceImage: HTMLImageElement | string, 43 | mode: 'stretch' | 'fit' | 'fill' 44 | ): Promise { 45 | const rect = this.objects[layer.realIndex] as MceRect; 46 | if (!rect.visible) { 47 | // If the layer is hidden, do nothing. 48 | return; 49 | } 50 | if (typeof sourceImage === 'string') { 51 | sourceImage = await loadImage(sourceImage); 52 | } 53 | 54 | let image: MceImage; 55 | switch (mode) { 56 | case 'stretch': 57 | image = stretchImage(rect, sourceImage); 58 | break; 59 | case 'fit': 60 | image = fitImage(rect, sourceImage); 61 | break; 62 | case 'fill': 63 | image = fillImage(rect, sourceImage); 64 | break; 65 | default: 66 | throw new Error(`Unknown mode: ${mode}`); 67 | } 68 | 69 | this.canvas.remove(rect); 70 | this.canvas.insertAt(layer.realIndex, image); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /core/src/replacer/stretch-image.ts: -------------------------------------------------------------------------------- 1 | import { MceImage, MceRect } from '../shapes'; 2 | 3 | export function stretchImage(rect: MceRect, sourceImage: HTMLImageElement): MceImage { 4 | const scaleX = rect.width / sourceImage.width; 5 | const scaleY = rect.height / sourceImage.height; 6 | return new MceImage(sourceImage, { 7 | angle: rect.angle, 8 | left: rect.left, 9 | top: rect.top, 10 | width: sourceImage.width, 11 | height: sourceImage.height, 12 | scaleX, 13 | scaleY 14 | }); 15 | } 16 | -------------------------------------------------------------------------------- /core/src/shapes/index.ts: -------------------------------------------------------------------------------- 1 | export * from './mce-image'; 2 | export * from './mce-path'; 3 | export * from './mce-rect'; 4 | export * from './mce-textbox'; 5 | -------------------------------------------------------------------------------- /core/src/shapes/mce-image.ts: -------------------------------------------------------------------------------- 1 | import { Image, ImageProps, TOptions, classRegistry } from 'fabric'; 2 | 3 | export interface MceImageProps extends ImageProps { 4 | label: string; 5 | } 6 | 7 | export class MceImage extends Image { 8 | public label!: string; 9 | 10 | public constructor(image: HTMLImageElement, options: TOptions) { 11 | super(image, { 12 | label: 'Image', 13 | ...options 14 | }); 15 | } 16 | 17 | // @ts-expect-error TS this typing limitations 18 | public toObject(propertiesToInclude: string[] = []) { 19 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 20 | return super.toObject(propertiesToInclude.concat(['label', 'selectable']) as any); 21 | } 22 | } 23 | 24 | classRegistry.setClass(MceImage); 25 | -------------------------------------------------------------------------------- /core/src/shapes/mce-path.ts: -------------------------------------------------------------------------------- 1 | import { Path, PathProps, TOptions, classRegistry } from 'fabric'; 2 | 3 | export interface McePathProps extends PathProps { 4 | label: string; 5 | } 6 | 7 | export class McePath extends Path { 8 | public label!: string; 9 | 10 | public set type(_: string) { 11 | // This override is needed to silent "Setting type has no effect" log. 12 | } 13 | 14 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 15 | public constructor(path: any, options: TOptions) { 16 | super(path, { 17 | label: 'Path', 18 | ...options 19 | }); 20 | } 21 | 22 | // @ts-expect-error TS this typing limitations 23 | public toObject(propertiesToInclude: string[] = []) { 24 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 25 | return super.toObject(propertiesToInclude.concat(['label', 'selectable']) as any); 26 | } 27 | } 28 | 29 | classRegistry.setClass(McePath); 30 | -------------------------------------------------------------------------------- /core/src/shapes/mce-rect.ts: -------------------------------------------------------------------------------- 1 | import { Rect, TOptions, RectProps, classRegistry } from 'fabric'; 2 | 3 | export interface MceMceRect extends RectProps { 4 | label: string; 5 | } 6 | 7 | export class MceRect extends Rect { 8 | public label!: string; 9 | 10 | public constructor(options: TOptions) { 11 | super({ 12 | fill: '#000000', 13 | strokeWidth: 0, 14 | stroke: '#000000', 15 | label: 'Rect', 16 | ...options 17 | }); 18 | this.on('scaling', this.onScaled); 19 | } 20 | 21 | private readonly onScaled = () => { 22 | const width = this.width * this.scaleX; 23 | const height = this.height * this.scaleY; 24 | this.set({ 25 | width, 26 | height, 27 | scaleX: 1, 28 | scaleY: 1 29 | }); 30 | }; 31 | 32 | // @ts-expect-error TS this typing limitations 33 | public toObject(propertiesToInclude: string[] = []) { 34 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 35 | return super.toObject(propertiesToInclude.concat(['label', 'selectable']) as any); 36 | } 37 | } 38 | 39 | classRegistry.setClass(MceRect); 40 | -------------------------------------------------------------------------------- /core/src/shapes/mce-textbox.ts: -------------------------------------------------------------------------------- 1 | import { TOptions, TextboxProps, Textbox, classRegistry } from 'fabric'; 2 | 3 | export enum MceVerticalAlign { 4 | top = 1, 5 | middle = 2, 6 | bottom = 3 7 | } 8 | 9 | export enum MceTextBackground { 10 | none = 0, 11 | behind = 1 12 | } 13 | 14 | export interface MceTextboxProps extends TextboxProps { 15 | label: string; 16 | maxHeight: number; 17 | verticalAlign: MceVerticalAlign; 18 | textBackground: MceTextBackground; 19 | textBackgroundFill: string; 20 | } 21 | 22 | export class MceTextbox extends Textbox { 23 | public label!: string; 24 | public verticalAlign!: MceVerticalAlign; 25 | public textBackground!: MceTextBackground; 26 | public textBackgroundFill!: string; 27 | public maxHeight!: number; 28 | 29 | public static cacheProperties = [...Textbox.cacheProperties, 'verticalAlign', 'textBackground', 'textBackgroundFill']; 30 | 31 | public constructor(text: string, options: TOptions) { 32 | super(text, { 33 | label: 'Textbox', 34 | fill: '#000000', 35 | fontSize: 30, 36 | fontFamily: 'serif', 37 | verticalAlign: MceVerticalAlign.top, 38 | textBackground: MceTextBackground.none, 39 | textBackgroundFill: '#ff0000', 40 | stroke: '#ff0000', 41 | strokeWidth: 0, 42 | paintFirst: 'stroke', 43 | ...options 44 | }); 45 | this.on('scaling', this.onScaled); 46 | } 47 | 48 | public setText(text: string) { 49 | this.set({ text }); 50 | } 51 | 52 | private readonly onScaled = () => { 53 | const width = this.width * this.scaleX; 54 | const maxHeight = this.maxHeight * this.scaleY; 55 | this.set({ 56 | width, 57 | maxHeight, 58 | scaleX: 1, 59 | scaleY: 1 60 | }); 61 | }; 62 | 63 | protected calcTextHeight(): number { 64 | return this.maxHeight; 65 | } 66 | 67 | protected _getTopOffset(): number { 68 | const y = -this.maxHeight / 2; 69 | if (this.verticalAlign === MceVerticalAlign.top) { 70 | return y; 71 | } 72 | const allLinesHeight = this.getHeightOfLine(0) * this.textLines.length; 73 | const remainingHeight = Math.max(this.maxHeight - allLinesHeight, 0); 74 | if (this.verticalAlign === MceVerticalAlign.middle) { 75 | return y + remainingHeight / 2; 76 | } 77 | if (this.verticalAlign === MceVerticalAlign.bottom) { 78 | return y + remainingHeight; 79 | } 80 | throw new Error('Unsupported vertical align'); 81 | } 82 | 83 | protected _renderTextCommon(ctx: CanvasRenderingContext2D, method: 'fillText' | 'strokeText') { 84 | ctx.save(); 85 | const left = this._getLeftOffset(); 86 | const top = this._getTopOffset(); 87 | 88 | if (this.textBackground === MceTextBackground.behind) { 89 | ctx.fillStyle = this.textBackgroundFill; 90 | } 91 | 92 | let lineHeights = 0; 93 | for (let i = 0, len = this._textLines.length; i < len; i++) { 94 | const heightOfLine = this.getHeightOfLine(i); 95 | if (lineHeights + heightOfLine > this.maxHeight) { 96 | break; 97 | } 98 | 99 | const maxHeight = heightOfLine / this.lineHeight; 100 | const leftOffset = this._getLineLeftOffset(i); 101 | 102 | if (this.textBackground === MceTextBackground.behind) { 103 | const firstCharBox = this.__charBounds[i][0]; 104 | const lastCharBox = this.__charBounds[i][this.__charBounds[i].length - 1]; 105 | ctx.fillRect(left + leftOffset, top + lineHeights, lastCharBox.left - firstCharBox.left + lastCharBox.width, maxHeight); 106 | } 107 | 108 | this._renderTextLine(method, ctx, this._textLines[i], left + leftOffset, top + lineHeights + maxHeight, i); 109 | lineHeights += heightOfLine; 110 | } 111 | ctx.restore(); 112 | } 113 | 114 | // @ts-expect-error TS this typing limitations 115 | public toObject(propertiesToInclude: string[] = []) { 116 | return super.toObject( 117 | propertiesToInclude.concat([ 118 | 'label', 119 | 'selectable', 120 | 'verticalAlign', 121 | 'maxHeight', 122 | 'verticalAlign', 123 | 'textBackground', 124 | 'textBackgroundFill' 125 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 126 | ]) as any 127 | ); 128 | } 129 | } 130 | 131 | classRegistry.setClass(MceTextbox); 132 | -------------------------------------------------------------------------------- /core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./build", 4 | "noImplicitAny": true, 5 | "target": "es6", 6 | "module": "es2015", 7 | "sourceMap": false, 8 | "strict": true, 9 | "allowJs": false, 10 | "declaration": true, 11 | "declarationDir": "./build", 12 | "moduleResolution": "node", 13 | "esModuleInterop": true, 14 | "lib": ["DOM", "es2015"] 15 | }, 16 | "include": [ 17 | "./src/" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /demos/svelte-app/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | vite.config.js.timestamp-* 10 | vite.config.ts.timestamp-* 11 | -------------------------------------------------------------------------------- /demos/svelte-app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "svelte-app-demo", 3 | "author": "b4rtaz", 4 | "license": "MIT", 5 | "private": true, 6 | "version": "1.0.0", 7 | "scripts": { 8 | "start": "svelte-kit sync && vite dev", 9 | "build": "vite build", 10 | "preview": "vite preview", 11 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 12 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 13 | "prettier": "prettier --check ./src", 14 | "prettier:fix": "prettier --write ./src" 15 | }, 16 | "dependencies": { 17 | "mini-canvas-editor": "^0.3.2", 18 | "mini-canvas-core": "^0.3.2", 19 | "fabric": "6.0.0-beta13", 20 | "canvas": "^2.11.2" 21 | }, 22 | "devDependencies": { 23 | "@sveltejs/adapter-auto": "^2.1.0", 24 | "@sveltejs/kit": "^1.26.0", 25 | "svelte": "^4.0.5", 26 | "svelte-check": "^3.4.3", 27 | "tslib": "^2.4.1", 28 | "typescript": "^5.2.2", 29 | "vite": "^4.4.2", 30 | "prettier": "^3.0.3" 31 | }, 32 | "type": "module" 33 | } -------------------------------------------------------------------------------- /demos/svelte-app/src/app.d.ts: -------------------------------------------------------------------------------- 1 | // See https://kit.svelte.dev/docs/types#app 2 | // for information about these interfaces 3 | declare global { 4 | namespace App { 5 | // interface Error {} 6 | // interface Locals {} 7 | // interface PageData {} 8 | // interface Platform {} 9 | } 10 | } 11 | 12 | export {}; 13 | -------------------------------------------------------------------------------- /demos/svelte-app/src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Server Rendering - Mini Canvas Editor 6 | 7 | 8 | %sveltekit.head% 9 | 10 | 11 |
%sveltekit.body%
12 | 13 | 14 | -------------------------------------------------------------------------------- /demos/svelte-app/src/lib/index.ts: -------------------------------------------------------------------------------- 1 | // 2 | -------------------------------------------------------------------------------- /demos/svelte-app/src/routes/+page.svelte: -------------------------------------------------------------------------------- 1 | 51 | 52 |
53 | 54 |
55 |

Server output

56 | 57 | 58 |
59 | -------------------------------------------------------------------------------- /demos/svelte-app/src/routes/backend/+server.ts: -------------------------------------------------------------------------------- 1 | import { MceStaticCanvas, type MceImageJSON } from 'mini-canvas-core/node'; 2 | import { readFileSync } from 'fs'; 3 | 4 | export async function POST({ request }: { request: Request }) { 5 | const json = (await request.json()) as MceImageJSON; 6 | const staticCanvas = await MceStaticCanvas.createFromJSON(json); 7 | 8 | const replacer = staticCanvas.getReplacer(); 9 | const layers = staticCanvas.getLayers(); 10 | 11 | layers 12 | .filter(layer => layer.type === 'textbox') 13 | .forEach(layer => { 14 | const text = replacer.getText(layer); 15 | const newText = text.replace('$time', new Date().toLocaleTimeString()); 16 | if (newText !== text) { 17 | replacer.replaceText(layer, newText); 18 | } 19 | }); 20 | 21 | const rectLayer = layers.find(layer => layer.type === 'rect'); 22 | if (rectLayer) { 23 | const image = readFileSync('./static/noise.png', 'base64'); 24 | const imageDataUrl = `data:image/png;base64,${image}`; 25 | await replacer.replaceRectToImage(rectLayer, imageDataUrl, 'stretch'); 26 | } 27 | 28 | const dataUrl = staticCanvas.exportToDataURL('png'); 29 | return new Response(dataUrl); 30 | } 31 | -------------------------------------------------------------------------------- /demos/svelte-app/src/routes/style.css: -------------------------------------------------------------------------------- 1 | @import 'mini-canvas-editor/css/editor.css'; 2 | 3 | body { 4 | font: 5 | 14px/1.4em Arial, 6 | Verdana, 7 | serif; 8 | } 9 | .editor-placeholder { 10 | width: 50vw; 11 | height: 40vh; 12 | margin: 0 auto; 13 | } 14 | .output { 15 | text-align: center; 16 | } 17 | .output-image { 18 | border: 1px solid #ccc; 19 | } 20 | -------------------------------------------------------------------------------- /demos/svelte-app/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nocode-js/mini-canvas-editor/420ec78ab7659741e4834659f5d0ba2546871431/demos/svelte-app/static/favicon.ico -------------------------------------------------------------------------------- /demos/svelte-app/static/noise.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nocode-js/mini-canvas-editor/420ec78ab7659741e4834659f5d0ba2546871431/demos/svelte-app/static/noise.png -------------------------------------------------------------------------------- /demos/svelte-app/svelte.config.js: -------------------------------------------------------------------------------- 1 | import adapter from '@sveltejs/adapter-auto'; 2 | import { vitePreprocess } from '@sveltejs/kit/vite'; 3 | 4 | /** @type {import('@sveltejs/kit').Config} */ 5 | const config = { 6 | preprocess: vitePreprocess(), 7 | kit: { 8 | adapter: adapter() 9 | } 10 | }; 11 | 12 | export default config; 13 | -------------------------------------------------------------------------------- /demos/svelte-app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.svelte-kit/tsconfig.json", 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "checkJs": true, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "resolveJsonModule": true, 9 | "skipLibCheck": true, 10 | "sourceMap": true, 11 | "strict": true 12 | } 13 | // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias 14 | // 15 | // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes 16 | // from the referenced tsconfig.json - TypeScript does not merge them in 17 | } 18 | -------------------------------------------------------------------------------- /demos/svelte-app/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { sveltekit } from '@sveltejs/kit/vite'; 2 | import { defineConfig } from 'vite'; 3 | 4 | export default defineConfig({ 5 | plugins: [sveltekit()] 6 | }); 7 | -------------------------------------------------------------------------------- /demos/webpack-app/.gitignore: -------------------------------------------------------------------------------- 1 | public/builds/ 2 | -------------------------------------------------------------------------------- /demos/webpack-app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webpack-app-demo", 3 | "author": "b4rtaz", 4 | "license": "MIT", 5 | "private": true, 6 | "version": "1.0.0", 7 | "scripts": { 8 | "clean": "rm -rf public/build", 9 | "start": "webpack --mode development --watch", 10 | "build": "pnpm clean && webpack --mode production", 11 | "eslint": "eslint ./src --ext .ts", 12 | "test:single": "echo \"No tests yet\"", 13 | "prettier": "prettier --check ./src ./public/**/*.html ./public/**/*.css", 14 | "prettier:fix": "prettier --write ./src ./public/**/*.html ./public/**/*.css" 15 | }, 16 | "dependencies": { 17 | "mini-canvas-editor": "^0.3.2", 18 | "mini-canvas-core": "^0.3.2" 19 | }, 20 | "devDependencies": { 21 | "ts-loader": "^9.4.2", 22 | "style-loader": "^3.3.1", 23 | "css-loader": "^6.7.3", 24 | "typescript": "^5.2.2", 25 | "webpack": "^5.89.0", 26 | "webpack-cli": "^5.1.4", 27 | "prettier": "^2.8.7", 28 | "@typescript-eslint/eslint-plugin": "^6.7.5", 29 | "@typescript-eslint/parser": "^6.7.5", 30 | "eslint": "^8.51.0" 31 | } 32 | } -------------------------------------------------------------------------------- /demos/webpack-app/public/assets/background-pattern.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /demos/webpack-app/public/assets/cat.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nocode-js/mini-canvas-editor/420ec78ab7659741e4834659f5d0ba2546871431/demos/webpack-app/public/assets/cat.jpg -------------------------------------------------------------------------------- /demos/webpack-app/public/assets/common.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | margin: 0; 4 | padding: 0; 5 | font: 14px/1.5em Arial, Verdana, sans-serif; 6 | } 7 | 8 | .header { 9 | position: relative; 10 | z-index: 1000; 11 | display: flex; 12 | width: 100%; 13 | background: #474747; 14 | color: #fff; 15 | } 16 | .header.bb { 17 | border-bottom: 4px solid #252525; 18 | } 19 | .header-title { 20 | flex: 1; 21 | padding: 10px; 22 | } 23 | .header-title h1 { 24 | margin: 0; 25 | padding: 0; 26 | font-size: 14px; 27 | font-weight: normal; 28 | } 29 | .header-links { 30 | padding: 10px; 31 | } 32 | .header-links a { 33 | color: #fff; 34 | text-decoration: underline; 35 | } 36 | .header-links a:hover { 37 | text-decoration: none; 38 | } 39 | -------------------------------------------------------------------------------- /demos/webpack-app/public/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nocode-js/mini-canvas-editor/420ec78ab7659741e4834659f5d0ba2546871431/demos/webpack-app/public/assets/favicon.ico -------------------------------------------------------------------------------- /demos/webpack-app/public/crop.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 🔲 Crop Example - Mini Canvas Editor 6 | 7 | 8 | 9 | 47 | 48 | 49 | 50 |
51 |
52 |

🔲 Crop Example

53 |
54 | 57 |
58 | 59 |
60 |

61 | 62 |

63 | 64 | 65 | -------------------------------------------------------------------------------- /demos/webpack-app/public/inpainting-mask.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 🎨 Inpainting Mask Example - Mini Canvas Editor 6 | 7 | 8 | 9 | 29 | 30 | 31 | 32 |
33 |
34 |

🎨 Inpainting Mask Example

35 |
36 | 39 |
40 | 41 |
42 | 43 |
44 | 45 | 46 |
47 | 48 | 49 | -------------------------------------------------------------------------------- /demos/webpack-app/public/template-creator.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 🎬 Template Creator Example - Mini Canvas Editor 6 | 7 | 8 | 9 | 35 | 36 | 37 | 38 |
39 |
40 |

🎬 Template Creator Example

41 |
42 | 45 |
46 | 47 |
48 | 49 |

Rects named $cat are replaced with a cat image, $name variables in texboxes are replaced by some name.

50 | 51 |

52 | 53 |
54 | 55 |
56 | 57 | 58 | -------------------------------------------------------------------------------- /demos/webpack-app/public/vanilla-javascript.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 📦 Vanilla JavaScript Example - Mini Canvas Editor 6 | 7 | 8 | 9 | 22 | 23 | 24 |
25 |
26 |

📦 Vanilla JavaScript Example

27 |
28 | 31 |
32 | 33 |
34 | 35 |

36 | 37 |

38 |

This example uses vanilla JavaScript!

39 | 40 | 56 | 57 | 73 | 74 | 75 | -------------------------------------------------------------------------------- /demos/webpack-app/src/crop.ts: -------------------------------------------------------------------------------- 1 | import { Editor, EditorMode } from 'mini-canvas-editor'; 2 | 3 | import 'mini-canvas-editor/css/editor.css'; 4 | 5 | export class App { 6 | public static preload() { 7 | const preloader = new Image(); 8 | preloader.src = './assets/cat.jpg'; 9 | preloader.onload = () => App.create(preloader); 10 | } 11 | 12 | public static create(image: HTMLImageElement) { 13 | const placeholder = document.getElementById('editor-placeholder') as HTMLElement; 14 | const saveButton = document.getElementById('save-button') as HTMLElement; 15 | 16 | const editor = Editor.createFromImage( 17 | placeholder, 18 | image, 19 | { 20 | workspaceHeight: 160, 21 | workspaceWidth: 200, 22 | fitToWorkspace: true, 23 | select: true 24 | }, 25 | { 26 | initialMode: EditorMode.select, 27 | brush: false, 28 | rect: false, 29 | image: false, 30 | textbox: false, 31 | sidebar: false 32 | } 33 | ); 34 | const app = new App(editor); 35 | saveButton.addEventListener('click', app.onSaveClicked, false); 36 | return app; 37 | } 38 | 39 | private constructor(private readonly editor: Editor) {} 40 | 41 | private readonly onSaveClicked = () => { 42 | const a = document.createElement('a'); 43 | a.download = 'crop.png'; 44 | a.href = this.editor.render().toDataURL('image/png'); 45 | a.click(); 46 | }; 47 | } 48 | 49 | document.addEventListener('DOMContentLoaded', App.preload, false); 50 | -------------------------------------------------------------------------------- /demos/webpack-app/src/inpainting-mask.ts: -------------------------------------------------------------------------------- 1 | import { Editor, EditorMode } from 'mini-canvas-editor'; 2 | import { MceImage } from 'mini-canvas-core'; 3 | 4 | import 'mini-canvas-editor/css/editor.css'; 5 | 6 | export class App { 7 | public static preload() { 8 | const preloader = new Image(); 9 | preloader.src = './assets/cat.jpg'; 10 | preloader.onload = () => { 11 | App.create(preloader); 12 | }; 13 | } 14 | 15 | public static create(image: HTMLImageElement) { 16 | const placeholder = document.getElementById('placeholder') as HTMLElement; 17 | const previewImage = document.getElementById('previewImage') as HTMLImageElement; 18 | const previewMaskImage = document.getElementById('previewMaskImage') as HTMLImageElement; 19 | 20 | const editor = Editor.createFromImage( 21 | placeholder, 22 | image, 23 | { 24 | selectable: false 25 | }, 26 | { 27 | initialMode: EditorMode.brush, 28 | brush: { 29 | brushColor: '#ff0000', 30 | brushSize: 20 31 | }, 32 | rect: { 33 | fillColor: '#ff0000' 34 | }, 35 | image: false, 36 | textbox: false 37 | } 38 | ); 39 | const app = new App(editor, previewImage, previewMaskImage); 40 | editor.onChanged.subscribe(app.reloadPreview); 41 | app.reloadPreview(); 42 | return app; 43 | } 44 | 45 | private constructor( 46 | private readonly editor: Editor, 47 | private readonly previewImage: HTMLImageElement, 48 | private readonly previewMaskImage: HTMLImageElement 49 | ) {} 50 | 51 | private readonly reloadPreview = async () => { 52 | const objects = this.editor.getWorkspaceObjects(); 53 | const image = objects.find(o => o instanceof MceImage) as MceImage; 54 | if (!image || !image.visible) { 55 | this.previewImage.src = ''; 56 | this.previewMaskImage.src = ''; 57 | return; 58 | } 59 | 60 | const [maskCanvas, maskCanvasContext] = createMemoryCanvas(this.editor.getWidth(), this.editor.getHeight()); 61 | objects.forEach(obj => { 62 | if (obj !== image) { 63 | obj.render(maskCanvasContext); 64 | } 65 | }); 66 | 67 | const [imageCanvas, imageCanvasContext] = createMemoryCanvas(this.editor.getWidth(), this.editor.getHeight()); 68 | image.render(imageCanvasContext); 69 | 70 | applyMask(this.editor.getWidth(), this.editor.getHeight(), imageCanvasContext, maskCanvasContext); 71 | 72 | this.previewImage.src = imageCanvas.toDataURL(); 73 | this.previewMaskImage.src = maskCanvas.toDataURL(); 74 | }; 75 | } 76 | 77 | document.addEventListener('DOMContentLoaded', App.preload, false); 78 | 79 | function createMemoryCanvas(width: number, height: number): [HTMLCanvasElement, CanvasRenderingContext2D] { 80 | const canvas = document.createElement('canvas'); 81 | canvas.width = width; 82 | canvas.height = height; 83 | const canvasContext = canvas.getContext('2d') as CanvasRenderingContext2D; 84 | canvasContext.fillStyle = '#ffffff00'; 85 | canvasContext.fillRect(0, 0, width, height); 86 | return [canvas, canvasContext]; 87 | } 88 | 89 | function applyMask(width: number, height: number, target: CanvasRenderingContext2D, mask: CanvasRenderingContext2D) { 90 | const imageCanvasData = target.getImageData(0, 0, width, height); 91 | const maskCanvasData = mask.getImageData(0, 0, width, height); 92 | for (let i = 0; i < imageCanvasData.data.length; i += 4) { 93 | imageCanvasData.data[i + 3] = 255 - maskCanvasData.data[i + 3]; 94 | } 95 | target.putImageData(imageCanvasData, 0, 0); 96 | } 97 | -------------------------------------------------------------------------------- /demos/webpack-app/src/template-creator.ts: -------------------------------------------------------------------------------- 1 | import { Editor } from 'mini-canvas-editor'; 2 | import { MceImageJSON, MceRect, MceTextbox } from 'mini-canvas-core'; 3 | 4 | import 'mini-canvas-editor/css/editor.css'; 5 | 6 | export class App { 7 | public static async create(reset: boolean): Promise { 8 | const placeholder = document.getElementById('placeholder') as HTMLElement; 9 | const preview = document.getElementById('preview') as HTMLImageElement; 10 | 11 | const saved = Storage.tryGet(); 12 | let editor: Editor; 13 | if (saved && !reset) { 14 | editor = await Editor.createFromJSON(saved, placeholder, {}); 15 | } else { 16 | editor = Editor.createBlank(placeholder, 400, 300, {}); 17 | editor.add( 18 | new MceRect({ 19 | fill: '#ffffff', 20 | left: 0, 21 | top: 0, 22 | width: 400, 23 | height: 300, 24 | label: 'Background', 25 | selectable: false 26 | }) 27 | ); 28 | editor.add( 29 | new MceRect({ 30 | fill: '#80a30b', 31 | left: 20, 32 | top: 20, 33 | width: 100, 34 | height: 260, 35 | label: '$cat' 36 | }) 37 | ); 38 | editor.add( 39 | new MceTextbox('Hello $name', { 40 | fontSize: 40, 41 | left: 140, 42 | top: 52, 43 | width: 240, 44 | maxHeight: 60 45 | }) 46 | ); 47 | editor.add( 48 | new MceTextbox('Time: $time', { 49 | fontSize: 20, 50 | left: 140, 51 | top: 120, 52 | width: 240, 53 | fill: '#8c8c8c', 54 | maxHeight: 30 55 | }) 56 | ); 57 | } 58 | 59 | editor.onChanged.subscribe(() => { 60 | const png = editor.render().toDataURL('image/png'); 61 | preview.src = png; 62 | 63 | const json = editor.toJSON(); 64 | Storage.set(json); 65 | }); 66 | const app = new App(editor, preview); 67 | editor.onChanged.subscribe(app.reloadPreview); 68 | app.reloadPreview(); 69 | 70 | const preloader = new Image(); 71 | preloader.src = './assets/cat.jpg'; 72 | preloader.onload = () => { 73 | app.catImage = preloader; 74 | app.reloadPreview(); 75 | }; 76 | return app; 77 | } 78 | 79 | public catImage?: HTMLImageElement; 80 | 81 | private constructor(private readonly editor: Editor, private readonly preview: HTMLImageElement) {} 82 | 83 | private readonly reloadPreview = async () => { 84 | const canvas = await this.editor.cloneToStaticCanvas(); 85 | const replacer = canvas.getReplacer(); 86 | const layers = canvas.getLayers(); 87 | 88 | layers.forEach(layer => { 89 | if (layer.type === 'textbox') { 90 | const text = replacer.getText(layer); 91 | const newText = text.replace('$name', 'Tosiek').replace('$time', new Date().toLocaleString()); 92 | if (newText !== text) { 93 | replacer.replaceText(layer, newText); 94 | } 95 | } 96 | if (this.catImage && layer.type === 'rect' && layer.name === '$cat') { 97 | replacer.replaceRectToImage(layer, this.catImage, 'fit'); 98 | } 99 | }); 100 | 101 | this.preview.src = canvas.exportToDataURL('png'); 102 | }; 103 | 104 | public async destroy() { 105 | await this.editor.destroy(); 106 | } 107 | } 108 | 109 | const localStorageKey = 'mceTemplateCreator_v2'; 110 | 111 | export class Storage { 112 | public static tryGet(): MceImageJSON | undefined { 113 | const raw = localStorage[localStorageKey]; 114 | return raw ? JSON.parse(raw) : undefined; 115 | } 116 | 117 | public static set(json: MceImageJSON) { 118 | localStorage[localStorageKey] = JSON.stringify(json); 119 | } 120 | 121 | public static clear() { 122 | localStorage.removeItem(localStorageKey); 123 | } 124 | } 125 | 126 | async function load() { 127 | const resetTemplateButton = document.getElementById('resetTemplateButton'); 128 | let app = await App.create(false); 129 | 130 | resetTemplateButton?.addEventListener( 131 | 'click', 132 | async e => { 133 | e.preventDefault(); 134 | await app.destroy(); 135 | Storage.clear(); 136 | app = await App.create(true); 137 | }, 138 | false 139 | ); 140 | } 141 | 142 | document.addEventListener('DOMContentLoaded', load, false); 143 | -------------------------------------------------------------------------------- /demos/webpack-app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./lib", 4 | "noImplicitAny": true, 5 | "target": "es6", 6 | "module": "es2015", 7 | "sourceMap": false, 8 | "strict": true, 9 | "allowJs": false, 10 | "declaration": false, 11 | "moduleResolution": "node", 12 | "lib": ["es2015", "dom"] 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /demos/webpack-app/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | function bundle(name) { 4 | return { 5 | entry: `./src/${name}.ts`, 6 | cache: false, 7 | module: { 8 | rules: [ 9 | { 10 | test: /\.ts$/, 11 | use: 'ts-loader', 12 | exclude: /node_modules/, 13 | }, 14 | { 15 | test: /\.css$/i, 16 | use: ['style-loader', 'css-loader'], 17 | }, 18 | ], 19 | }, 20 | resolve: { 21 | extensions: ['.tsx', '.ts', '.js'], 22 | }, 23 | output: { 24 | filename: `${name}.js`, 25 | path: path.resolve(__dirname, 'public/builds'), 26 | } 27 | } 28 | } 29 | 30 | module.exports = [ 31 | bundle('crop'), 32 | bundle('inpainting-mask'), 33 | bundle('template-creator'), 34 | ]; 35 | -------------------------------------------------------------------------------- /editor/.gitignore: -------------------------------------------------------------------------------- 1 | /lib 2 | LICENSE 3 | README.md 4 | -------------------------------------------------------------------------------- /editor/css/editor.css: -------------------------------------------------------------------------------- 1 | .mce-editor { 2 | position: relative; 3 | display: flex; 4 | width: 100%; 5 | height: 100%; 6 | } 7 | .mce-workspace { 8 | flex: 1; 9 | overflow: hidden; 10 | background: #252525; 11 | } 12 | .mce-sidebar { 13 | width: 10%; 14 | min-width: 200px; 15 | height: 100%; 16 | background: #474747; 17 | color: #d4d4d4; 18 | } 19 | .mce-sidebar-body { 20 | height: 100%; 21 | } 22 | .mce-sidebar-switch { 23 | display: none; 24 | } 25 | .mce-mobile .mce-sidebar { 26 | position: absolute; 27 | z-index: 100; 28 | top: 0; 29 | right: 0; 30 | width: auto; 31 | min-width: auto; 32 | } 33 | .mce-mobile .mce-sidebar-body { 34 | display: none; 35 | width: 0; 36 | } 37 | .mce-mobile .mce-visible .mce-sidebar-body { 38 | display: block; 39 | max-width: 200px; 40 | width: 100%; 41 | } 42 | .mce-mobile .mce-sidebar-switch { 43 | display: block; 44 | position: absolute; 45 | bottom: 0; 46 | right: 100%; 47 | width: 40px; 48 | height: 40px; 49 | background: #474747; 50 | cursor: pointer; 51 | } 52 | .mce-mobile .mce-sidebar-switch-icon { 53 | width: 20px; 54 | height: 20px; 55 | margin: 10px; 56 | } 57 | .mce-mobile .mce-sidebar-switch-icon path { 58 | fill: #fff; 59 | } 60 | 61 | /* .mce-toolbar */ 62 | .mce-toolbar { 63 | position: absolute; 64 | z-index: 99; 65 | top: 0; 66 | left: 40px; 67 | background: #474747; 68 | color: #d4d4d4; 69 | padding: 6px; 70 | border-top-right-radius: 4px; 71 | border-bottom-right-radius: 4px; 72 | } 73 | .mce-toolbar.mce-hidden { 74 | display: none; 75 | } 76 | .mce-toolbar-item { 77 | display: inline-block; 78 | } 79 | .mce-toolbar-label { 80 | padding: 0 5px; 81 | vertical-align: middle; 82 | } 83 | .mce-toolbar-number-input { 84 | width: 60px; 85 | background: #2b2b2b; 86 | color: #fff; 87 | box-sizing: border-box; 88 | border: 0; 89 | outline: 0; 90 | padding: 5px; 91 | border-radius: 4px; 92 | vertical-align: middle; 93 | } 94 | .mce-toolbar-color-input { 95 | width: 60px; 96 | border: 0; 97 | outline: 0; 98 | padding: 0; 99 | height: 26px; 100 | border-radius: 4px; 101 | -webkit-appearance: none; 102 | vertical-align: middle; 103 | } 104 | .mce-toolbar-color-input::-webkit-color-swatch-wrapper { 105 | padding: 0; 106 | } 107 | .mce-toolbar-color-input::-webkit-color-swatch { 108 | border: none; 109 | } 110 | 111 | /* .mce-toolbox */ 112 | .mce-toolbox { 113 | display: flex; 114 | flex-direction: column; 115 | width: 40px; 116 | background: #474747; 117 | } 118 | .mce-toolbox-top { 119 | flex: 1; 120 | } 121 | 122 | .mce-toolbox-item { 123 | display: flex; 124 | align-items: center; 125 | justify-content: center; 126 | width: 32px; 127 | height: 32px; 128 | margin: 4px; 129 | cursor: pointer; 130 | border-radius: 4px; 131 | user-select: none; 132 | } 133 | .mce-toolbox-item:hover, 134 | .mce-toolbox-item.mce-selected { 135 | background: #5d5d5d; 136 | } 137 | .mce-toolbox-item-icon { 138 | width: 20px; 139 | height: 20px; 140 | margin: 6px; 141 | } 142 | .mce-toolbox-item-icon path { 143 | fill: #fff; 144 | } 145 | .mce-toolbox-item-zoom { 146 | color: #fff; 147 | font-size: 11px; 148 | } 149 | 150 | /* .mce-icon-button */ 151 | .mce-icon-button { 152 | cursor: pointer; 153 | } 154 | .mce-icon-button:hover { 155 | opacity: 0.8; 156 | } 157 | .mce-icon-button.mce-icon-button-sm { 158 | width: 16px; 159 | height: 16px; 160 | } 161 | .mce-icon-button.mce-icon-button-sm svg { 162 | width: 14px; 163 | height: 14px; 164 | margin: 1px; 165 | } 166 | .mce-icon-button.mce-icon-button-sm svg path { 167 | fill: #fff; 168 | } 169 | 170 | /* .mce-panel */ 171 | .mce-panel { 172 | display: flex; 173 | flex-direction: column; 174 | height: 50%; 175 | } 176 | .mce-panel-tabs { 177 | background: #252525; 178 | padding: 4px 0 0; 179 | } 180 | .mce-panel-tab { 181 | display: inline-block; 182 | background: #474747; 183 | padding: 3px 6px; 184 | border-top-left-radius: 4px; 185 | border-top-right-radius: 4px; 186 | } 187 | .mce-panel-body { 188 | flex: 1; 189 | overflow: auto; 190 | } 191 | .mce-panel-empty { 192 | text-align: center; 193 | padding: 5px 0; 194 | } 195 | 196 | .mce-layers-panel-item { 197 | display: flex; 198 | width: 100%; 199 | flex-direction: row; 200 | align-items: center; 201 | border-bottom: 1px solid #252525; 202 | } 203 | .mce-layers-panel-item.mce-selected { 204 | background: #5d5d5d; 205 | } 206 | .mce-layers-panel-item .mce-icon-button { 207 | margin: 0 4px; 208 | } 209 | .mce-layers-panel-item-label { 210 | flex: 1; 211 | margin: 6px 4px; 212 | } 213 | .mce-layers-panel-item-label-input { 214 | width: 100%; 215 | border: 0; 216 | outline: 0; 217 | margin: 0; 218 | padding: 3px 0; 219 | background: transparent; 220 | color: #d4d4d4; 221 | } 222 | .mce-layers-panel-item-label-input:read-only { 223 | cursor: default; 224 | pointer-events: none; 225 | } 226 | 227 | .mce-properties-editor { 228 | padding: 4px; 229 | } 230 | 231 | /* .mce-prop-row */ 232 | .mce-prop-row { 233 | display: flex; 234 | width: 100%; 235 | gap: 4px; 236 | } 237 | .mce-prop-col { 238 | flex: 1; 239 | } 240 | 241 | /* .mce-prop-simple */ 242 | .mce-prop-simple { 243 | display: flex; 244 | width: 100%; 245 | align-items: center; 246 | box-sizing: border-box; 247 | } 248 | .mce-prop-simple-label { 249 | padding: 2px 8px 2px 2px; 250 | } 251 | .mce-prop-simple-body { 252 | flex: 1; 253 | padding: 2px 4px 2px 0; 254 | } 255 | 256 | /* .mce-prop-number-input */ 257 | .mce-prop-number-input { 258 | width: 100%; 259 | background: #2b2b2b; 260 | color: #fff; 261 | box-sizing: border-box; 262 | border: 0; 263 | outline: 0; 264 | padding: 5px; 265 | border-radius: 4px; 266 | } 267 | 268 | /* .mce-prop-color-input */ 269 | .mce-prop-color-input { 270 | width: 100%; 271 | border: 0; 272 | outline: 0; 273 | padding: 0; 274 | height: 26px; 275 | border-radius: 4px; 276 | -webkit-appearance: none; 277 | } 278 | .mce-prop-color-input::-webkit-color-swatch-wrapper { 279 | padding: 0; 280 | } 281 | .mce-prop-color-input::-webkit-color-swatch { 282 | border: none; 283 | } 284 | 285 | /* .mce-prop-number-input-color */ 286 | .mce-prop-choice { 287 | width: 100%; 288 | background: #2b2b2b; 289 | color: #fff; 290 | box-sizing: border-box; 291 | border: 0; 292 | outline: 0; 293 | padding: 5px; 294 | border-radius: 4px; 295 | } 296 | -------------------------------------------------------------------------------- /editor/karma.conf.cjs: -------------------------------------------------------------------------------- 1 | module.exports = config => { 2 | config.set({ 3 | frameworks: [ 4 | 'jasmine', 5 | 'karma-typescript' 6 | ], 7 | plugins: [ 8 | require('karma-jasmine'), 9 | require('karma-chrome-launcher'), 10 | require('karma-spec-reporter'), 11 | require('karma-typescript') 12 | ], 13 | files: [ 14 | { pattern: 'src/**/*.ts' } 15 | ], 16 | preprocessors: { 17 | 'src/**/*.ts': 'karma-typescript' 18 | }, 19 | reporters: [ 20 | 'progress', 21 | 'karma-typescript' 22 | ], 23 | browsers: [ 24 | 'ChromeHeadless' 25 | ], 26 | karmaTypescriptConfig: { 27 | compilerOptions: { 28 | skipLibCheck: true 29 | }, 30 | bundlerOptions: { 31 | transforms: [ 32 | require("karma-typescript-es6-transform")() 33 | ] 34 | } 35 | } 36 | }); 37 | }; 38 | -------------------------------------------------------------------------------- /editor/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mini-canvas-editor", 3 | "description": "Canvas editor component for JavaScript application. Easy to integrate and use.", 4 | "version": "0.3.2", 5 | "type": "module", 6 | "main": "./lib/esm/index.js", 7 | "types": "./lib/index.d.ts", 8 | "exports": { 9 | ".": { 10 | "types": { 11 | "require": "./lib/index.d.ts", 12 | "default": "./lib/index.d.ts" 13 | }, 14 | "default": { 15 | "require": "./lib/cjs/index.cjs", 16 | "default": "./lib/esm/index.js" 17 | } 18 | }, 19 | "./css/editor.css": { 20 | "default": "./css/editor.css" 21 | } 22 | }, 23 | "license": "SEE LICENSE IN LICENSE", 24 | "files": [ 25 | "css/", 26 | "lib/", 27 | "dist/" 28 | ], 29 | "publishConfig": { 30 | "registry": "https://registry.npmjs.org/" 31 | }, 32 | "repository": { 33 | "type": "git", 34 | "url": "https://github.com/b4rtaz/mini-canvas-editor.git" 35 | }, 36 | "author": { 37 | "name": "N4NO", 38 | "url": "https://n4no.com/" 39 | }, 40 | "homepage": "https://n4no.com/", 41 | "scripts": { 42 | "prepare": "cp ../LICENSE LICENSE && cp ../README.md README.md", 43 | "clean": "rm -rf lib && rm -rf build && rm -rf dist && rm -rf node_modules/.cache/rollup-plugin-typescript2", 44 | "start": "rollup -c --watch", 45 | "build": "pnpm clean && rollup -c", 46 | "eslint": "eslint ./src --ext .ts", 47 | "test": "karma start karma.conf.cjs", 48 | "test:single": "karma start karma.conf.cjs --single-run", 49 | "prettier": "prettier --check ./src", 50 | "prettier:fix": "prettier --write ./src ./css" 51 | }, 52 | "dependencies": { 53 | "mini-canvas-core": "^0.3.2" 54 | }, 55 | "peerDependencies": { 56 | "mini-canvas-core": "^0.3.2" 57 | }, 58 | "devDependencies": { 59 | "tslib": "^2.6.2", 60 | "rollup": "^4.1.4", 61 | "rollup-plugin-dts": "^6.1.0", 62 | "rollup-plugin-typescript2": "^0.36.0", 63 | "@rollup/plugin-node-resolve": "^15.2.2", 64 | "@rollup/plugin-terser": "^0.4.4", 65 | "typescript": "^5.2.2", 66 | "prettier": "^3.0.3", 67 | "@typescript-eslint/eslint-plugin": "^6.7.5", 68 | "@typescript-eslint/parser": "^6.7.5", 69 | "eslint": "^8.51.0", 70 | "karma": "^6.4.2", 71 | "karma-chrome-launcher": "^3.2.0", 72 | "karma-jasmine": "^5.1.0", 73 | "karma-spec-reporter": "^0.0.36", 74 | "karma-typescript": "^5.5.4", 75 | "karma-typescript-es6-transform": "^5.5.4" 76 | }, 77 | "keywords": [ 78 | "canvas", 79 | "editor", 80 | "image", 81 | "image editor", 82 | "photo editor", 83 | "photo", 84 | "javascript image editor", 85 | "paint", 86 | "js paint", 87 | "image crop", 88 | "image resize", 89 | "inpainting" 90 | ] 91 | } -------------------------------------------------------------------------------- /editor/rollup.config.mjs: -------------------------------------------------------------------------------- 1 | import dts from 'rollup-plugin-dts'; 2 | import typescript from 'rollup-plugin-typescript2'; 3 | import { nodeResolve } from '@rollup/plugin-node-resolve'; 4 | import terser from '@rollup/plugin-terser'; 5 | import fs from 'fs'; 6 | 7 | const packageJson = JSON.parse(fs.readFileSync('./package.json', 'utf8')); 8 | const external = Object.keys(packageJson.dependencies ?? []); 9 | 10 | const ts = typescript({ 11 | useTsconfigDeclarationDir: true 12 | }); 13 | 14 | export default [ 15 | { 16 | input: './src/index.ts', 17 | plugins: [ 18 | ts, 19 | terser() 20 | ], 21 | external, 22 | cache: false, 23 | output: [ 24 | { 25 | file: './lib/esm/index.js', 26 | format: 'es' 27 | }, 28 | { 29 | file: './lib/cjs/index.cjs', 30 | format: 'cjs' 31 | } 32 | ] 33 | }, 34 | { 35 | input: './build/index.d.ts', 36 | output: [ 37 | { 38 | file: './lib/index.d.ts', 39 | format: 'es' 40 | } 41 | ], 42 | plugins: [dts()], 43 | }, 44 | { 45 | input: './src/index.ts', 46 | plugins: [ 47 | nodeResolve({ 48 | browser: true, 49 | }), 50 | ts, 51 | terser() 52 | ], 53 | external: [ 54 | 'mini-canvas-core' 55 | ], 56 | output: [ 57 | { 58 | file: './dist/index.umd.js', 59 | format: 'umd', 60 | name: 'miniCanvasEditor' 61 | } 62 | ] 63 | } 64 | ]; 65 | -------------------------------------------------------------------------------- /editor/src/components/component.ts: -------------------------------------------------------------------------------- 1 | export interface Component { 2 | view: HTMLElement; 3 | } 4 | 5 | export interface DestroyableComponent extends Component { 6 | destroy(): void; 7 | } 8 | -------------------------------------------------------------------------------- /editor/src/components/icon-button-component.ts: -------------------------------------------------------------------------------- 1 | import { Html } from '../core/html'; 2 | import { Icons } from '../core/icons'; 3 | import { SimpleEvent } from '../core/simple-event'; 4 | import { Component } from './component'; 5 | 6 | export interface IconButtonComponent extends Component { 7 | onClicked: SimpleEvent; 8 | setIcon(icon: string): void; 9 | } 10 | 11 | export function iconButtonComponent(icon: string, title: string, size: 'sm'): IconButtonComponent { 12 | const onClicked = new SimpleEvent(); 13 | 14 | const view = Html.element('span', { 15 | class: `mce-icon-button mce-icon-button-${size}`, 16 | title 17 | }); 18 | const svg = Icons.createSvg(icon, 'mce-icon-button-icon'); 19 | const path = svg.querySelector('path') as SVGPathElement; 20 | view.appendChild(svg); 21 | 22 | view.addEventListener( 23 | 'click', 24 | e => { 25 | e.preventDefault(); 26 | e.stopPropagation(); 27 | onClicked.forward(); 28 | }, 29 | false 30 | ); 31 | 32 | return { 33 | view, 34 | onClicked, 35 | setIcon(newIcon: string) { 36 | path.setAttribute('d', newIcon); 37 | } 38 | }; 39 | } 40 | -------------------------------------------------------------------------------- /editor/src/components/panel-component.ts: -------------------------------------------------------------------------------- 1 | import { Html } from '../core/html'; 2 | import { Component } from './component'; 3 | 4 | export function panelComponent(tabLabel: string, bodyContent: HTMLElement): Component { 5 | const view = Html.div({ 6 | class: 'mce-panel' 7 | }); 8 | 9 | const tabs = Html.div({ 10 | class: 'mce-panel-tabs' 11 | }); 12 | const firstTab = Html.div({ 13 | class: 'mce-panel-tab' 14 | }); 15 | firstTab.innerText = tabLabel; 16 | tabs.appendChild(firstTab); 17 | 18 | const body = Html.div({ 19 | class: 'mce-panel-body' 20 | }); 21 | body.className = 'mce-panel-body'; 22 | body.appendChild(bodyContent); 23 | 24 | view.appendChild(tabs); 25 | view.appendChild(body); 26 | return { view }; 27 | } 28 | -------------------------------------------------------------------------------- /editor/src/core/html.ts: -------------------------------------------------------------------------------- 1 | export interface Attributes { 2 | [name: string]: string | number; 3 | } 4 | 5 | export class Html { 6 | public static attrs(element: Element, attributes: Attributes) { 7 | Object.keys(attributes).forEach(name => { 8 | const value = attributes[name]; 9 | element.setAttribute(name, typeof value === 'string' ? value : value.toString()); 10 | }); 11 | } 12 | 13 | public static element(name: T, attributes?: Attributes): HTMLElementTagNameMap[T] { 14 | const element = document.createElement(name); 15 | if (attributes) { 16 | Html.attrs(element, attributes); 17 | } 18 | return element; 19 | } 20 | 21 | public static div(attributes?: Attributes): HTMLDivElement { 22 | return Html.element('div', attributes); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /editor/src/core/icons.ts: -------------------------------------------------------------------------------- 1 | const ns = 'http://www.w3.org/2000/svg'; 2 | 3 | export class Icons { 4 | public static cursor = 'm320-410 79-110h170L320-716v306ZM551-80 406-392 240-160v-720l560 440H516l144 309-109 51ZM399-520Z'; 5 | public static rect = 6 | 'M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200Zm0-80h560v-560H200v560Zm0 0v-560 560Z'; 7 | public static eye = 8 | 'M480-320q75 0 127.5-52.5T660-500q0-75-52.5-127.5T480-680q-75 0-127.5 52.5T300-500q0 75 52.5 127.5T480-320Zm0-72q-45 0-76.5-31.5T372-500q0-45 31.5-76.5T480-608q45 0 76.5 31.5T588-500q0 45-31.5 76.5T480-392Zm0 192q-146 0-266-81.5T40-500q54-137 174-218.5T480-800q146 0 266 81.5T920-500q-54 137-174 218.5T480-200Zm0-300Zm0 220q113 0 207.5-59.5T832-500q-50-101-144.5-160.5T480-720q-113 0-207.5 59.5T128-500q50 101 144.5 160.5T480-280Z'; 9 | public static eyeOff = 10 | 'm644-428-58-58q9-47-27-88t-93-32l-58-58q17-8 34.5-12t37.5-4q75 0 127.5 52.5T660-500q0 20-4 37.5T644-428Zm128 126-58-56q38-29 67.5-63.5T832-500q-50-101-143.5-160.5T480-720q-29 0-57 4t-55 12l-62-62q41-17 84-25.5t90-8.5q151 0 269 83.5T920-500q-23 59-60.5 109.5T772-302Zm20 246L624-222q-35 11-70.5 16.5T480-200q-151 0-269-83.5T40-500q21-53 53-98.5t73-81.5L56-792l56-56 736 736-56 56ZM222-624q-29 26-53 57t-41 67q50 101 143.5 160.5T480-280q20 0 39-2.5t39-5.5l-36-38q-11 3-21 4.5t-21 1.5q-75 0-127.5-52.5T300-500q0-11 1.5-21t4.5-21l-84-82Zm319 93Zm-151 75Z'; 11 | public static locked = 12 | 'M240-80q-33 0-56.5-23.5T160-160v-400q0-33 23.5-56.5T240-640h40v-80q0-83 58.5-141.5T480-920q83 0 141.5 58.5T680-720v80h40q33 0 56.5 23.5T800-560v400q0 33-23.5 56.5T720-80H240Zm0-80h480v-400H240v400Zm240-120q33 0 56.5-23.5T560-360q0-33-23.5-56.5T480-440q-33 0-56.5 23.5T400-360q0 33 23.5 56.5T480-280ZM360-640h240v-80q0-50-35-85t-85-35q-50 0-85 35t-35 85v80ZM240-160v-400 400Z'; 13 | public static close = 'm256-200-56-56 224-224-224-224 56-56 224 224 224-224 56 56-224 224 224 224-56 56-224-224-224 224Z'; 14 | public static text = 'M420-160v-520H200v-120h560v120H540v520H420Z'; 15 | public static image = 16 | 'M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200Zm0-80h560v-560H200v560Zm40-80h480L570-480 450-320l-90-120-120 160Zm-40 80v-560 560Z'; 17 | public static brush = 18 | 'M240-120q-45 0-89-22t-71-58q26 0 53-20.5t27-59.5q0-50 35-85t85-35q50 0 85 35t35 85q0 66-47 113t-113 47Zm0-80q33 0 56.5-23.5T320-280q0-17-11.5-28.5T280-320q-17 0-28.5 11.5T240-280q0 23-5.5 42T220-202q5 2 10 2h10Zm230-160L360-470l358-358q11-11 27.5-11.5T774-828l54 54q12 12 12 28t-12 28L470-360Zm-190 80Z'; 19 | public static erase = 20 | 'M690-240h190v80H610l80-80Zm-500 80-85-85q-23-23-23.5-57t22.5-58l440-456q23-24 56.5-24t56.5 23l199 199q23 23 23 57t-23 57L520-160H190Zm296-80 314-322-198-198-442 456 64 64h262Zm-6-240Z'; 21 | public static arrowUp = 'M440-160v-487L216-423l-56-57 320-320 320 320-56 57-224-224v487h-80Z'; 22 | public static arrowDown = 'M440-800v487L216-537l-56 57 320 320 320-320-56-57-224 224v-487h-80Z'; 23 | public static menu = 'M120-240v-80h720v80H120Zm0-200v-80h720v80H120Zm0-200v-80h720v80H120Z'; 24 | 25 | public static createSvg(icon: string, cls: string): SVGElement { 26 | const svg = document.createElementNS(ns, 'svg'); 27 | svg.setAttribute('viewBox', '0 -960 960 960'); 28 | svg.classList.add(cls); 29 | const path = document.createElementNS(ns, 'path'); 30 | path.setAttribute('d', icon); 31 | svg.appendChild(path); 32 | return svg; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /editor/src/core/simple-event.ts: -------------------------------------------------------------------------------- 1 | export class SimpleEvent { 2 | private readonly listeners: SimpleEventListener[] = []; 3 | 4 | public subscribe(listener: SimpleEventListener) { 5 | this.listeners.push(listener); 6 | } 7 | 8 | public unsubscribe(listener: SimpleEventListener) { 9 | const index = this.listeners.indexOf(listener); 10 | if (index >= 0) { 11 | this.listeners.splice(index, 1); 12 | } else { 13 | throw new Error('Unknown listener'); 14 | } 15 | } 16 | 17 | public readonly forward = (value: T) => { 18 | if (this.listeners.length > 0) { 19 | this.listeners.forEach(listener => listener(value)); 20 | } 21 | }; 22 | 23 | public count(): number { 24 | return this.listeners.length; 25 | } 26 | } 27 | 28 | export type SimpleEventListener = (value: T) => void; 29 | -------------------------------------------------------------------------------- /editor/src/editor-configuration.ts: -------------------------------------------------------------------------------- 1 | export interface EditorConfiguration { 2 | initialMode?: EditorMode; 3 | rect?: RectModeConfiguration | false; 4 | brush?: BrushModeConfiguration | false; 5 | textbox?: TextboxModeConfiguration | false; 6 | image?: ImageModeConfiguration | false; 7 | sidebar?: SidebarConfiguration | boolean; 8 | } 9 | 10 | export interface SidebarConfiguration { 11 | /** 12 | * Whether to show the properties panel. 13 | * @default true 14 | */ 15 | properties?: boolean; 16 | 17 | /** 18 | * Whether to show the layers panel. 19 | * @default true 20 | */ 21 | layers?: boolean; 22 | } 23 | 24 | export interface RectModeConfiguration { 25 | fillColor?: string; 26 | } 27 | 28 | export interface BrushModeConfiguration { 29 | brushSize?: number; 30 | brushColor?: string; 31 | } 32 | 33 | export interface TextboxModeConfiguration {} 34 | 35 | export interface ImageModeConfiguration {} 36 | 37 | export enum EditorMode { 38 | select = 'select', 39 | rect = 'rect', 40 | brush = 'brush', 41 | textbox = 'textbox' 42 | } 43 | -------------------------------------------------------------------------------- /editor/src/editor-state.ts: -------------------------------------------------------------------------------- 1 | import { FabricObject, MceCanvas, Point } from 'mini-canvas-core'; 2 | import { SimpleEvent } from './core/simple-event'; 3 | import { BrushModeConfiguration, EditorConfiguration, EditorMode, RectModeConfiguration } from './editor-configuration'; 4 | 5 | export class EditorState { 6 | public readonly onModeChanged = new SimpleEvent(); 7 | public readonly onZoomChanged = new SimpleEvent(); 8 | public readonly onLayerOrderChanged = new SimpleEvent(); 9 | public readonly onPropertiesChanged = new SimpleEvent(); 10 | public readonly onBrushConfigurationChanged = new SimpleEvent(); 11 | 12 | public brush: Required = { 13 | brushSize: 10, 14 | brushColor: '#ff0000', 15 | ...(this.configuration.brush || {}) 16 | }; 17 | 18 | public rect: Required = { 19 | fillColor: '#ff0000', 20 | ...(this.configuration.rect || {}) 21 | }; 22 | 23 | public constructor( 24 | public readonly canvas: MceCanvas, 25 | public mode: EditorMode, 26 | private readonly configuration: EditorConfiguration, 27 | private readonly container: HTMLElement 28 | ) {} 29 | 30 | public center() { 31 | const scale = Math.min( 32 | 1, 33 | this.container.clientWidth / this.canvas.workspaceWidth, 34 | this.container.clientHeight / this.canvas.workspaceHeight 35 | ); 36 | const width = this.canvas.workspaceWidth * scale; 37 | const height = this.canvas.workspaceHeight * scale; 38 | const x = this.container.clientWidth / 2 - width / 2; 39 | const y = this.container.clientHeight / 2 - height / 2; 40 | this.canvas.setZoom(scale); 41 | this.canvas.absolutePan(new Point(-x, -y)); 42 | this.onZoomChanged.forward(); 43 | } 44 | 45 | public add(object: FabricObject) { 46 | this.canvas.add(object); 47 | this.canvas.requestRenderAll(); 48 | } 49 | 50 | public selectObject(object: FabricObject) { 51 | this.canvas.discardActiveObject(); 52 | this.canvas.setActiveObject(object); 53 | this.canvas.renderAll(); 54 | } 55 | 56 | public forEachObject(callback: (object: FabricObject, index: number, isLast: boolean) => void) { 57 | const objects = this.canvas.getWorkspaceObjects(); 58 | for (let index = 0; index < objects.length; index++) { 59 | callback(objects[index], index, index + 1 === objects.length); 60 | } 61 | } 62 | 63 | public disableSelection(): SelectionReverter { 64 | this.canvas.selection = false; 65 | const map = new Map(); 66 | this.forEachObject(o => { 67 | map.set(o, o.selectable); 68 | if (o.selectable) { 69 | o.set('selectable', false); 70 | } 71 | }); 72 | return new SelectionReverter(map); 73 | } 74 | 75 | public setMode(mode: EditorMode) { 76 | this.mode = mode; 77 | this.onModeChanged.forward(mode); 78 | } 79 | } 80 | 81 | export class SelectionReverter { 82 | public constructor(private readonly map: Map) {} 83 | 84 | public revert() { 85 | this.map.forEach((value, key) => key.set('selectable', value)); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /editor/src/editor.ts: -------------------------------------------------------------------------------- 1 | import { Sidebar } from './sidebar/sidebar'; 2 | import { Html } from './core/html'; 3 | import { Workspace } from './workspace/workspace'; 4 | import { EditorConfiguration } from './editor-configuration'; 5 | import { Toolbox } from './toolbox/toolbox'; 6 | import { SimpleEvent } from './core/simple-event'; 7 | import { EditorState } from './editor-state'; 8 | import { FabricObject, MceImage, MceImageJSON, MceImageProps, MceStaticCanvas, Point, TOptions } from 'mini-canvas-core'; 9 | import { LayoutController } from './layout-controller'; 10 | import { Toolbar } from './toolbar/toolbar'; 11 | 12 | export interface CreateFromImageOptions { 13 | selectable?: boolean; 14 | select?: boolean; 15 | workspaceWidth?: number; 16 | workspaceHeight?: number; 17 | fitToWorkspace?: boolean; 18 | } 19 | 20 | export class Editor { 21 | public static createBlank( 22 | placeholder: HTMLElement, 23 | workspaceWidth: number, 24 | workspaceHeight: number, 25 | configuration: EditorConfiguration 26 | ): Editor { 27 | const workspace = Workspace.createBlank(workspaceWidth, workspaceHeight, configuration); 28 | return Editor.create(placeholder, workspace, configuration); 29 | } 30 | 31 | public static createFromImage( 32 | placeholder: HTMLElement, 33 | image: HTMLImageElement, 34 | imageOptions: CreateFromImageOptions, 35 | configuration: EditorConfiguration 36 | ): Editor { 37 | const workspaceWidth = imageOptions.workspaceWidth ?? image.width; 38 | const workspaceHeight = imageOptions.workspaceHeight ?? image.height; 39 | 40 | const editor = Editor.createBlank(placeholder, workspaceWidth, workspaceHeight, configuration); 41 | 42 | const layerOptions: TOptions = { 43 | width: image.width, 44 | height: image.height, 45 | selectable: imageOptions.selectable ?? true 46 | }; 47 | if (imageOptions.fitToWorkspace) { 48 | const scale = Math.max(workspaceWidth / image.width, workspaceHeight / image.height); 49 | layerOptions.left = (workspaceWidth - image.width * scale) / 2; 50 | layerOptions.top = (workspaceHeight - image.height * scale) / 2; 51 | layerOptions.scaleX = scale; 52 | layerOptions.scaleY = scale; 53 | } 54 | const layer = new MceImage(image, layerOptions); 55 | editor.add(layer); 56 | 57 | if (imageOptions.selectable === false && imageOptions.select) { 58 | throw new Error('Cannot select an image that is not selectable'); 59 | } 60 | if (imageOptions.selectable !== false && imageOptions.select) { 61 | editor.state.canvas.setActiveObject(layer); 62 | } 63 | return editor; 64 | } 65 | 66 | public static async createFromJSON(json: MceImageJSON, placeholder: HTMLElement, configuration: EditorConfiguration): Promise { 67 | const workspace = await Workspace.createFromJSON(json, configuration); 68 | return Editor.create(placeholder, workspace, configuration); 69 | } 70 | 71 | private static create(placeholder: HTMLElement, workspace: Workspace, configuration: EditorConfiguration) { 72 | const view = Html.div({ 73 | class: 'mce-editor' 74 | }); 75 | 76 | const state = workspace.state; 77 | const toolbox = Toolbox.create(state, configuration); 78 | const toolbar = Toolbar.create(state); 79 | 80 | view.appendChild(toolbox.view); 81 | view.appendChild(toolbar.view); 82 | view.appendChild(workspace.view); 83 | 84 | if (configuration.sidebar !== false) { 85 | const sidebar = Sidebar.create(state, typeof configuration.sidebar === 'object' ? configuration.sidebar : {}); 86 | view.appendChild(sidebar.view); 87 | } 88 | 89 | placeholder.appendChild(view); 90 | 91 | workspace.startAutoLayout(true); 92 | 93 | const layoutController = LayoutController.create(view); 94 | 95 | const editor = new Editor(view, workspace, state, layoutController); 96 | state.canvas.on('object:added', editor.onAnyChange); 97 | state.canvas.on('object:modified', editor.onAnyChange); 98 | state.canvas.on('object:moving', editor.onAnyChange); 99 | state.canvas.on('object:removed', editor.onAnyChange); 100 | state.canvas.on('object:resizing', editor.onAnyChange); 101 | state.canvas.on('object:rotating', editor.onAnyChange); 102 | state.canvas.on('object:scaling', editor.onAnyChange); 103 | state.canvas.on('object:skewing', editor.onAnyChange); 104 | state.canvas.on('text:changed', editor.onAnyChange); 105 | state.canvas.on('text:editing:exited', editor.onAnyChange); 106 | state.canvas.on('selection:cleared', editor.onAnyChange); 107 | state.canvas.on('selection:created', editor.onAnyChange); 108 | state.canvas.on('selection:updated', editor.onAnyChange); 109 | state.onLayerOrderChanged.subscribe(editor.onAnyChange); 110 | state.onPropertiesChanged.subscribe(editor.onAnyChange); 111 | return editor; 112 | } 113 | 114 | public readonly onChanged = new SimpleEvent(); 115 | 116 | private constructor( 117 | private readonly view: HTMLElement, 118 | private readonly workspace: Workspace, 119 | private readonly state: EditorState, 120 | private readonly layoutController: LayoutController 121 | ) {} 122 | 123 | private readonly onAnyChange = () => { 124 | this.onChanged.forward(); 125 | }; 126 | 127 | public getWidth(): number { 128 | return this.state.canvas.workspaceWidth; 129 | } 130 | 131 | public getHeight(): number { 132 | return this.state.canvas.workspaceHeight; 133 | } 134 | 135 | public getWorkspaceObjects(): FabricObject[] { 136 | return this.state.canvas.getWorkspaceObjects(); 137 | } 138 | 139 | public add(object: FabricObject) { 140 | this.state.add(object); 141 | } 142 | 143 | public toJSON(): MceImageJSON { 144 | return this.state.canvas.toImageJSON(); 145 | } 146 | 147 | public cloneToStaticCanvas(): Promise { 148 | // TODO: this is not too efficient 149 | return MceStaticCanvas.createFromJSON(this.toJSON()); 150 | } 151 | 152 | public render(): HTMLCanvasElement { 153 | const currentZoom = this.state.canvas.getZoom(); 154 | const currentWidth = this.state.canvas.width; 155 | const currentHeight = this.state.canvas.height; 156 | const currentViewport = this.state.canvas.viewportTransform; 157 | 158 | const activeObjects = this.state.canvas.getActiveObjects().map(object => { 159 | const controls = object.controls; 160 | const hasBorders = object.hasBorders; 161 | object.controls = {}; 162 | object.hasBorders = false; 163 | return { 164 | object, 165 | controls, 166 | hasBorders 167 | }; 168 | }); 169 | 170 | this.state.canvas.setWidth(this.state.canvas.workspaceWidth); 171 | this.state.canvas.setHeight(this.state.canvas.workspaceHeight); 172 | this.state.canvas.setZoom(1); 173 | this.state.canvas.absolutePan(new Point(0, 0)); 174 | this.state.canvas.workspaceBackground.visible = false; 175 | 176 | const result = this.state.canvas.toCanvasElement(1, { 177 | left: 0, 178 | top: 0, 179 | width: this.state.canvas.workspaceWidth, 180 | height: this.state.canvas.workspaceHeight 181 | }); 182 | 183 | this.state.canvas.setZoom(currentZoom); 184 | this.state.canvas.setWidth(currentWidth); 185 | this.state.canvas.setHeight(currentHeight); 186 | this.state.canvas.setViewportTransform(currentViewport); 187 | this.state.canvas.workspaceBackground.visible = true; 188 | 189 | activeObjects.forEach(({ object, controls, hasBorders }) => { 190 | object.controls = controls; 191 | object.hasBorders = hasBorders; 192 | }); 193 | return result; 194 | } 195 | 196 | public async destroy(): Promise { 197 | await this.workspace.destroy(); 198 | this.layoutController.destroy(); 199 | this.view.parentElement?.removeChild(this.view); 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /editor/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './editor-configuration'; 2 | export * from './editor'; 3 | -------------------------------------------------------------------------------- /editor/src/layout-controller.ts: -------------------------------------------------------------------------------- 1 | export class LayoutController { 2 | public static create(root: HTMLElement): LayoutController { 3 | const controller = new LayoutController(root); 4 | window.addEventListener('resize', controller.reload, false); 5 | controller.reload(); 6 | return controller; 7 | } 8 | 9 | private constructor(private readonly root: HTMLElement) {} 10 | 11 | private readonly reload = () => { 12 | const isMobile = this.root.clientWidth < 400; // TODO 13 | if (isMobile) { 14 | this.root.classList.add('mce-mobile'); 15 | } else { 16 | this.root.classList.remove('mce-mobile'); 17 | } 18 | }; 19 | 20 | public destroy() { 21 | window.removeEventListener('resize', this.reload, false); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /editor/src/sidebar/layers/layer-item.ts: -------------------------------------------------------------------------------- 1 | import { EditorState } from '../../editor-state'; 2 | import { Component } from '../../components/component'; 3 | import { IconButtonComponent, iconButtonComponent } from '../../components/icon-button-component'; 4 | import { Html } from '../../core/html'; 5 | import { Icons } from '../../core/icons'; 6 | import { FabricObject } from 'mini-canvas-core'; 7 | 8 | export class LayerItem implements Component { 9 | public static create(state: EditorState, object: FabricObject, isFirst: boolean, isLast: boolean) { 10 | const root = document.createElement('div'); 11 | root.className = 'mce-layers-panel-item'; 12 | 13 | const visibilityButton = iconButtonComponent(getIcon(object), 'Toggle visibility', 'sm'); 14 | const deleteButton = iconButtonComponent(Icons.close, 'Delete layer', 'sm'); 15 | let moveUpButton: IconButtonComponent | undefined; 16 | let moveDownButton: IconButtonComponent | undefined; 17 | 18 | const label = Html.div({ 19 | class: 'mce-layers-panel-item-label' 20 | }); 21 | 22 | const labelInput = Html.element('input', { 23 | class: 'mce-layers-panel-item-label-input' 24 | }); 25 | labelInput.readOnly = true; 26 | labelInput.value = object.get('label') ?? object.type; 27 | 28 | label.appendChild(labelInput); 29 | root.appendChild(visibilityButton.view); 30 | root.appendChild(label); 31 | 32 | if (!isLast) { 33 | moveUpButton = iconButtonComponent(Icons.arrowUp, 'Move layer up', 'sm'); 34 | root.appendChild(moveUpButton.view); 35 | } 36 | if (!isFirst) { 37 | moveDownButton = iconButtonComponent(Icons.arrowDown, 'Move layer down', 'sm'); 38 | root.appendChild(moveDownButton.view); 39 | } 40 | 41 | root.appendChild(deleteButton.view); 42 | 43 | const item = new LayerItem(root, state, object, labelInput, visibilityButton); 44 | root.addEventListener('click', item.onItemClicked, false); 45 | labelInput.addEventListener('input', item.onLabelChanged, false); 46 | visibilityButton.onClicked.subscribe(item.onVisibilityClicked); 47 | moveUpButton?.onClicked.subscribe(item.onMoveUpClicked); 48 | moveDownButton?.onClicked.subscribe(item.onMoveDownClicked); 49 | deleteButton.onClicked.subscribe(item.onDeleteClicked); 50 | return item; 51 | } 52 | 53 | public isSelected = false; 54 | 55 | private constructor( 56 | public readonly view: HTMLElement, 57 | public readonly state: EditorState, 58 | public readonly object: FabricObject, 59 | private readonly labelInput: HTMLInputElement, 60 | private readonly visibilityButton: IconButtonComponent 61 | ) {} 62 | 63 | private readonly onItemClicked = (e: Event) => { 64 | e.preventDefault(); 65 | e.stopPropagation(); 66 | this.state.selectObject(this.object); 67 | }; 68 | 69 | private readonly onVisibilityClicked = () => { 70 | if (this.object.selectable && this.object.visible) { 71 | this.object.visible = true; 72 | this.object.selectable = false; 73 | if (this.state.canvas.getActiveObject() === this.object) { 74 | this.state.canvas.discardActiveObject(); 75 | } 76 | } else if (this.object.visible && !this.object.selectable) { 77 | this.object.visible = false; 78 | this.object.selectable = true; 79 | } else { 80 | this.object.visible = true; 81 | this.object.selectable = true; 82 | } 83 | 84 | this.visibilityButton.setIcon(getIcon(this.object)); 85 | this.state.canvas.requestRenderAll(); 86 | this.state.onPropertiesChanged.forward(this.object); 87 | }; 88 | 89 | private readonly onMoveUpClicked = () => { 90 | this.state.canvas.bringObjectForward(this.object); 91 | this.state.onLayerOrderChanged.forward(); 92 | }; 93 | 94 | private readonly onMoveDownClicked = () => { 95 | this.state.canvas.sendObjectBackwards(this.object); 96 | this.state.onLayerOrderChanged.forward(); 97 | }; 98 | 99 | private readonly onDeleteClicked = () => { 100 | this.state.canvas.remove(this.object); 101 | }; 102 | 103 | private readonly onLabelChanged = () => { 104 | this.object.set('label', this.labelInput.value); 105 | this.state.onPropertiesChanged.forward(this.object); 106 | }; 107 | 108 | public setIsSelected(isSelected: boolean) { 109 | this.isSelected = isSelected; 110 | this.labelInput.readOnly = !isSelected; 111 | this.view.classList.toggle('mce-selected', isSelected); 112 | } 113 | } 114 | 115 | function getIcon(object: FabricObject) { 116 | if (!object.selectable) { 117 | if (!object.visible) { 118 | throw new Error('Unsupported state'); 119 | } 120 | return Icons.locked; 121 | } 122 | return object.visible ? Icons.eye : Icons.eyeOff; 123 | } 124 | -------------------------------------------------------------------------------- /editor/src/sidebar/layers/layers-panel.ts: -------------------------------------------------------------------------------- 1 | import { LayerItem } from './layer-item'; 2 | import { EditorState } from '../../editor-state'; 3 | import { Component } from '../../components/component'; 4 | import { panelComponent } from '../../components/panel-component'; 5 | 6 | export class LayersPanel implements Component { 7 | public static create(state: EditorState): LayersPanel { 8 | const list = document.createElement('div'); 9 | list.className = 'mce-layers-list'; 10 | 11 | const panel = panelComponent('Layers', list); 12 | 13 | const inst = new LayersPanel(panel.view, state, list); 14 | state.canvas.on('object:added', inst.reloadList); 15 | state.canvas.on('object:removed', inst.reloadList); 16 | state.canvas.on('selection:cleared', inst.reloadSelection); 17 | state.canvas.on('selection:created', inst.reloadSelection); 18 | state.canvas.on('selection:updated', inst.reloadSelection); 19 | state.onLayerOrderChanged.subscribe(inst.reloadList); 20 | inst.reloadList(); 21 | return inst; 22 | } 23 | 24 | private readonly layers: LayerItem[] = []; 25 | 26 | private constructor( 27 | public readonly view: HTMLElement, 28 | private readonly state: EditorState, 29 | private readonly list: HTMLElement 30 | ) {} 31 | 32 | private readonly reloadList = () => { 33 | while (this.list.firstChild) { 34 | this.list.removeChild(this.list.firstChild); 35 | } 36 | 37 | this.state.forEachObject((object, index, isLast) => { 38 | const isFirst = index === 0; 39 | const item = LayerItem.create(this.state, object, isFirst, isLast); 40 | this.layers.push(item); 41 | if (this.list.firstChild) { 42 | this.list.insertBefore(item.view, this.list.firstChild); 43 | } else { 44 | this.list.appendChild(item.view); 45 | } 46 | }); 47 | this.reloadSelection(); 48 | }; 49 | 50 | private readonly reloadSelection = () => { 51 | const a = this.state.canvas.getActiveObjects(); 52 | for (const layer of this.layers) { 53 | layer.setIsSelected(a.includes(layer.object)); 54 | } 55 | }; 56 | } 57 | -------------------------------------------------------------------------------- /editor/src/sidebar/properties/editors/choice-property-editor.ts: -------------------------------------------------------------------------------- 1 | import { Html } from '../../../core/html'; 2 | import { DestroyableComponent } from '../../../components/component'; 3 | import { simplePropertyEditor } from './simple-property-editor'; 4 | import { PropertyAccessor } from '../property-accessor'; 5 | 6 | export type Choices = Record; 7 | 8 | export function choicePropertyEditor(label: string, choices: Choices, accessor: PropertyAccessor): DestroyableComponent { 9 | function onChange() { 10 | const value = choiceValues[select.selectedIndex]; 11 | accessor.setValue(value); 12 | } 13 | 14 | function setValue(value: T) { 15 | const index = choiceValues.indexOf(value); 16 | if (index >= 0) { 17 | for (let i = 0; i < options.length; i++) { 18 | options[i].selected = i === index; 19 | } 20 | } 21 | } 22 | 23 | function destroy() { 24 | // 25 | } 26 | 27 | const initialValue = accessor.getValue(); 28 | const select = Html.element('select', { 29 | class: 'mce-prop-choice' 30 | }); 31 | select.addEventListener('change', onChange, false); 32 | const choiceValues = Object.values(choices); 33 | const options = Object.keys(choices).map(label => { 34 | const option = Html.element('option', { 35 | value: label 36 | }); 37 | option.text = label; 38 | if (choices[label] === initialValue) { 39 | option.selected = true; 40 | } 41 | select.appendChild(option); 42 | return option; 43 | }); 44 | 45 | accessor.onExternalChanged.subscribe(setValue); 46 | 47 | return { 48 | view: simplePropertyEditor(label, select).view, 49 | destroy 50 | }; 51 | } 52 | -------------------------------------------------------------------------------- /editor/src/sidebar/properties/editors/color-property-editor.ts: -------------------------------------------------------------------------------- 1 | import { Html } from '../../../core/html'; 2 | import { DestroyableComponent } from '../../../components/component'; 3 | import { simplePropertyEditor } from './simple-property-editor'; 4 | import { TFiller } from 'mini-canvas-core'; 5 | import { PropertyAccessor } from '../property-accessor'; 6 | 7 | export type FillType = string | TFiller | null; 8 | 9 | export function colorPropertyEditor(label: string, accessor: PropertyAccessor): DestroyableComponent { 10 | function onInput() { 11 | accessor.setValue(input.value); 12 | } 13 | 14 | function setValue(value: FillType) { 15 | input.value = value as string; 16 | } 17 | 18 | function destroy() { 19 | // 20 | } 21 | 22 | const input = Html.element('input', { 23 | class: 'mce-prop-color-input', 24 | type: 'color', 25 | value: (accessor.getValue() as string) || '#000' 26 | }); 27 | 28 | input.addEventListener('input', onInput, false); 29 | accessor.onExternalChanged.subscribe(setValue); 30 | 31 | return { 32 | view: simplePropertyEditor(label, input).view, 33 | destroy 34 | }; 35 | } 36 | -------------------------------------------------------------------------------- /editor/src/sidebar/properties/editors/number-property-editor.ts: -------------------------------------------------------------------------------- 1 | import { Html } from '../../../core/html'; 2 | import { DestroyableComponent } from '../../../components/component'; 3 | import { simplePropertyEditor } from './simple-property-editor'; 4 | import { PropertyAccessor } from '../property-accessor'; 5 | 6 | export interface NumberPropertyEditorConfiguration { 7 | step?: number; 8 | decimals?: number; 9 | min?: number; 10 | max?: number; 11 | } 12 | 13 | export function numberPropertyEditor( 14 | label: string, 15 | accessor: PropertyAccessor, 16 | configuration?: NumberPropertyEditorConfiguration 17 | ): DestroyableComponent { 18 | function onInput() { 19 | const value = Number(input.value); 20 | accessor.setValue(value); 21 | } 22 | 23 | function setValue(value: number) { 24 | input.value = value.toFixed(decimals); 25 | } 26 | 27 | function destroy() { 28 | // 29 | } 30 | 31 | const decimals = configuration?.decimals ?? 1; 32 | const input = Html.element('input', { 33 | class: 'mce-prop-number-input', 34 | type: 'number', 35 | value: accessor.getValue().toFixed(decimals) 36 | }); 37 | if (configuration) { 38 | if (configuration.step) { 39 | input.step = configuration.step.toString(); 40 | } 41 | if (typeof configuration.min === 'number') { 42 | input.setAttribute('min', String(configuration.min)); 43 | } 44 | if (typeof configuration.max === 'number') { 45 | input.setAttribute('max', String(configuration.max)); 46 | } 47 | } 48 | input.addEventListener('input', onInput, false); 49 | accessor.onExternalChanged.subscribe(setValue); 50 | 51 | return { 52 | view: simplePropertyEditor(label, input).view, 53 | destroy 54 | }; 55 | } 56 | -------------------------------------------------------------------------------- /editor/src/sidebar/properties/editors/simple-property-editor.ts: -------------------------------------------------------------------------------- 1 | import { Html } from '../../../core/html'; 2 | import { Component } from '../../../components/component'; 3 | 4 | export function simplePropertyEditor(label: string, input: HTMLElement): Component { 5 | const view = Html.div({ 6 | class: 'mce-prop-simple' 7 | }); 8 | const lb = Html.div({ 9 | class: 'mce-prop-simple-label' 10 | }); 11 | lb.innerText = label; 12 | 13 | const body = Html.div({ 14 | class: 'mce-prop-simple-body' 15 | }); 16 | 17 | view.appendChild(lb); 18 | view.appendChild(body); 19 | body.appendChild(input); 20 | 21 | return { 22 | view 23 | }; 24 | } 25 | -------------------------------------------------------------------------------- /editor/src/sidebar/properties/layout/property-editor-row.ts: -------------------------------------------------------------------------------- 1 | import { DestroyableComponent } from '../../../components/component'; 2 | import { Html } from '../../../core/html'; 3 | 4 | export function propertyEditorRow(components: DestroyableComponent[]): DestroyableComponent { 5 | function destroy() { 6 | components.forEach(c => c.destroy()); 7 | } 8 | 9 | const view = Html.div({ 10 | class: 'mce-prop-row' 11 | }); 12 | 13 | for (const editor of components) { 14 | const col = Html.div({ 15 | class: 'mce-prop-col' 16 | }); 17 | col.appendChild(editor.view); 18 | view.appendChild(col); 19 | } 20 | 21 | return { 22 | view, 23 | destroy 24 | }; 25 | } 26 | -------------------------------------------------------------------------------- /editor/src/sidebar/properties/layout/property-editor-rows.ts: -------------------------------------------------------------------------------- 1 | import { Html } from '../../../core/html'; 2 | import { DestroyableComponent } from '../../../components/component'; 3 | 4 | export function propertyEditorRows(components: DestroyableComponent[]): DestroyableComponent { 5 | function destroy() { 6 | components.forEach(c => c.destroy()); 7 | } 8 | 9 | const view = Html.div({ 10 | class: 'mce-prop-rows' 11 | }); 12 | 13 | for (const component of components) { 14 | view.appendChild(component.view); 15 | } 16 | 17 | return { 18 | view, 19 | destroy 20 | }; 21 | } 22 | -------------------------------------------------------------------------------- /editor/src/sidebar/properties/object-properties-editor.ts: -------------------------------------------------------------------------------- 1 | import { FabricObject, ObjectEvents } from 'mini-canvas-core'; 2 | import { DestroyableComponent } from '../../components/component'; 3 | import { Html } from '../../core/html'; 4 | import { EditorState } from '../../editor-state'; 5 | import { UpdateManager } from './update-manager'; 6 | import { ShapeEditorFactory } from './shapes/shape-editor-factory'; 7 | 8 | export class ObjectPropertiesEditor implements DestroyableComponent { 9 | public static create(state: EditorState, object: FabricObject) { 10 | const container = Html.div({ 11 | class: 'mce-properties-editor' 12 | }); 13 | 14 | const manager = UpdateManager.create(state, ['modified', 'scaling'], object); 15 | manager.onChanged.subscribe(() => state.onPropertiesChanged.forward(object)); 16 | 17 | const editor = ShapeEditorFactory.create(object.type, manager); 18 | 19 | container.appendChild(editor.view); 20 | return new ObjectPropertiesEditor(container, manager, editor); 21 | } 22 | 23 | private constructor( 24 | public readonly view: HTMLElement, 25 | private readonly manager: UpdateManager, 26 | private readonly editor: DestroyableComponent 27 | ) {} 28 | 29 | public destroy() { 30 | this.manager.destroy(); 31 | this.editor.destroy(); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /editor/src/sidebar/properties/properties-panel.ts: -------------------------------------------------------------------------------- 1 | import { EditorState } from '../../editor-state'; 2 | import { Component, DestroyableComponent } from '../../components/component'; 3 | import { panelComponent } from '../../components/panel-component'; 4 | import { ObjectPropertiesEditor } from './object-properties-editor'; 5 | import { RootPropertiesEditor } from './root-properties-editor'; 6 | 7 | export class PropertiesPanel implements Component { 8 | public static create(state: EditorState): PropertiesPanel { 9 | const container = document.createElement('div'); 10 | 11 | const panel = panelComponent('Properties', container); 12 | 13 | const pp = new PropertiesPanel(panel.view, state, container); 14 | state.canvas.on('selection:cleared', pp.onSelectionUpdated); 15 | state.canvas.on('selection:updated', pp.onSelectionUpdated); 16 | state.canvas.on('selection:created', pp.onSelectionUpdated); 17 | pp.refresh(); 18 | return pp; 19 | } 20 | 21 | private component?: DestroyableComponent; 22 | 23 | private constructor( 24 | public readonly view: HTMLElement, 25 | private readonly state: EditorState, 26 | private readonly container: HTMLElement 27 | ) {} 28 | 29 | private readonly onSelectionUpdated = () => { 30 | this.refresh(); 31 | }; 32 | 33 | private refresh() { 34 | const activeObjects = this.state.canvas.getActiveObjects(); 35 | 36 | if (this.component) { 37 | this.container.removeChild(this.component.view); 38 | } 39 | if (activeObjects.length === 1) { 40 | const activeObject = activeObjects[0]; 41 | this.component = ObjectPropertiesEditor.create(this.state, activeObject); 42 | } else { 43 | this.component = RootPropertiesEditor.create(this.state); 44 | } 45 | this.container.appendChild(this.component.view); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /editor/src/sidebar/properties/property-accessor.ts: -------------------------------------------------------------------------------- 1 | import { SimpleEvent } from '../../core/simple-event'; 2 | 3 | export interface PropertyAccessor { 4 | onExternalChanged: SimpleEvent; 5 | lastValue: Val; 6 | getValue: () => Val; 7 | setValue: (value: Val) => void; 8 | } 9 | -------------------------------------------------------------------------------- /editor/src/sidebar/properties/root-properties-editor.ts: -------------------------------------------------------------------------------- 1 | import { CanvasEvents, MceCanvas } from 'mini-canvas-core'; 2 | import { DestroyableComponent } from '../../components/component'; 3 | import { EditorState } from '../../editor-state'; 4 | import { numberPropertyEditor } from './editors/number-property-editor'; 5 | import { propertyEditorRows } from './layout/property-editor-rows'; 6 | import { UpdateManager } from './update-manager'; 7 | import { propertyEditorRow } from './layout/property-editor-row'; 8 | import { Html } from '../../core/html'; 9 | 10 | export class RootPropertiesEditor implements DestroyableComponent { 11 | public static create(state: EditorState) { 12 | const manager = UpdateManager.create(state, [], state.canvas); 13 | manager.onChanged.subscribe(() => state.onPropertiesChanged.forward(state.canvas)); 14 | 15 | const view = Html.div({ 16 | class: 'mce-properties-editor' 17 | }); 18 | 19 | const rows = propertyEditorRows([ 20 | propertyEditorRow([ 21 | numberPropertyEditor( 22 | 'Width', 23 | manager.bind( 24 | o => o.workspaceWidth, 25 | (o, v) => { 26 | o.workspaceBackground.set('width', v); 27 | o.workspaceWidth = v; 28 | } 29 | ), 30 | { 31 | decimals: 0 32 | } 33 | ) 34 | ]), 35 | propertyEditorRow([ 36 | numberPropertyEditor( 37 | 'Height', 38 | manager.bind( 39 | o => o.workspaceHeight, 40 | (o, v) => { 41 | o.workspaceBackground.set('height', v); 42 | o.workspaceHeight = v; 43 | } 44 | ), 45 | { 46 | decimals: 0 47 | } 48 | ) 49 | ]) 50 | ]); 51 | 52 | view.appendChild(rows.view); 53 | return new RootPropertiesEditor(view); 54 | } 55 | 56 | private constructor(public readonly view: HTMLElement) {} 57 | 58 | public destroy() { 59 | // 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /editor/src/sidebar/properties/shapes/circle-shape-editor.ts: -------------------------------------------------------------------------------- 1 | import { UpdateManager } from '../update-manager'; 2 | import { DestroyableComponent } from '../../../components/component'; 3 | import { commonShapeEditor } from './common-shape-editor'; 4 | import { Circle } from 'mini-canvas-core'; 5 | 6 | export function circleShapeEditor(manager: UpdateManager): DestroyableComponent { 7 | return commonShapeEditor(manager, []); 8 | } 9 | -------------------------------------------------------------------------------- /editor/src/sidebar/properties/shapes/common-shape-editor.ts: -------------------------------------------------------------------------------- 1 | import { FabricObject } from 'mini-canvas-core'; 2 | import { DestroyableComponent } from '../../../components/component'; 3 | import { numberPropertyEditor } from '../editors/number-property-editor'; 4 | import { propertyEditorRow } from '../layout/property-editor-row'; 5 | import { propertyEditorRows } from '../layout/property-editor-rows'; 6 | import { UpdateManager } from '../update-manager'; 7 | 8 | export function commonShapeEditor(manager: UpdateManager, rows: DestroyableComponent[]): DestroyableComponent { 9 | function destroy() { 10 | allRows.forEach(r => r.destroy()); 11 | } 12 | 13 | const row1 = propertyEditorRow([ 14 | numberPropertyEditor( 15 | 'X', 16 | manager.bind( 17 | o => o.left, 18 | (o, v) => o.set('left', v) 19 | ) 20 | ), 21 | numberPropertyEditor( 22 | 'Y', 23 | manager.bind( 24 | o => o.top, 25 | (o, v) => o.set('top', v) 26 | ) 27 | ) 28 | ]); 29 | 30 | const row2 = propertyEditorRow([ 31 | numberPropertyEditor( 32 | 'Angle', 33 | manager.bind( 34 | o => o.angle, 35 | (o, v) => o.set('angle', v) 36 | ) 37 | ) 38 | ]); 39 | 40 | const row3 = propertyEditorRow([ 41 | numberPropertyEditor( 42 | 'Opacity', 43 | manager.bind( 44 | o => Math.round(o.opacity * 100), 45 | (o, v) => o.set('opacity', Math.min(100, Math.max(v, 0)) / 100) 46 | ), 47 | { 48 | step: 2, 49 | decimals: 0, 50 | min: 0, 51 | max: 100 52 | } 53 | ) 54 | ]); 55 | 56 | const allRows = [...rows, row1, row2, row3]; 57 | return { 58 | view: propertyEditorRows(allRows).view, 59 | destroy 60 | }; 61 | } 62 | -------------------------------------------------------------------------------- /editor/src/sidebar/properties/shapes/image-shape-editor.ts: -------------------------------------------------------------------------------- 1 | import { UpdateManager } from '../update-manager'; 2 | import { DestroyableComponent } from '../../../components/component'; 3 | import { commonShapeEditor } from './common-shape-editor'; 4 | import { MceImage } from 'mini-canvas-core'; 5 | import { propertyEditorRow } from '../layout/property-editor-row'; 6 | import { numberPropertyEditor } from '../editors/number-property-editor'; 7 | 8 | export function imageShapeEditor(manager: UpdateManager): DestroyableComponent { 9 | const row1 = propertyEditorRow([ 10 | numberPropertyEditor( 11 | 'W', 12 | manager.bind( 13 | o => o.getScaledWidth(), 14 | (o, v) => { 15 | const scaleX = v / o.getOriginalSize().width; 16 | o.set('scaleX', scaleX); 17 | } 18 | ) 19 | ), 20 | numberPropertyEditor( 21 | 'H', 22 | manager.bind( 23 | o => o.getScaledHeight(), 24 | (o, v) => { 25 | const scaleY = v / o.getOriginalSize().height; 26 | o.set('scaleY', scaleY); 27 | } 28 | ) 29 | ) 30 | ]); 31 | 32 | return commonShapeEditor(manager, [row1]); 33 | } 34 | -------------------------------------------------------------------------------- /editor/src/sidebar/properties/shapes/rect-shape-editor.ts: -------------------------------------------------------------------------------- 1 | import { propertyEditorRow } from '../layout/property-editor-row'; 2 | import { UpdateManager } from '../update-manager'; 3 | import { numberPropertyEditor } from '../editors/number-property-editor'; 4 | import { DestroyableComponent } from '../../../components/component'; 5 | import { commonShapeEditor } from './common-shape-editor'; 6 | import { colorPropertyEditor } from '../editors/color-property-editor'; 7 | import { MceRect } from 'mini-canvas-core'; 8 | 9 | export function rectShapeEditor(manager: UpdateManager): DestroyableComponent { 10 | const row1 = propertyEditorRow([ 11 | numberPropertyEditor( 12 | 'W', 13 | manager.bind( 14 | o => o.getScaledWidth(), 15 | (o, v) => { 16 | o.set('width', v); 17 | o.set('scaleX', 1); 18 | } 19 | ) 20 | ), 21 | numberPropertyEditor( 22 | 'H', 23 | manager.bind( 24 | o => o.getScaledHeight(), 25 | (o, v) => { 26 | o.set('height', v); 27 | o.set('scaleY', 1); 28 | } 29 | ) 30 | ) 31 | ]); 32 | 33 | const row2 = propertyEditorRow([ 34 | numberPropertyEditor( 35 | 'Radius', 36 | manager.bind( 37 | o => o.rx, 38 | (o, v) => { 39 | o.set('rx', v); 40 | o.set('ry', v); 41 | } 42 | ) 43 | ) 44 | ]); 45 | 46 | const row3 = propertyEditorRow([ 47 | colorPropertyEditor( 48 | 'Fill', 49 | manager.bind( 50 | o => o.fill, 51 | (o, v) => o.set('fill', v) 52 | ) 53 | ) 54 | ]); 55 | 56 | const row4 = propertyEditorRow([ 57 | numberPropertyEditor( 58 | 'SW', 59 | manager.bind( 60 | o => o.strokeWidth, 61 | (o, v) => o.set('strokeWidth', v) 62 | ) 63 | ), 64 | colorPropertyEditor( 65 | 'SC', 66 | manager.bind( 67 | o => o.stroke, 68 | (o, v) => o.set('stroke', v) 69 | ) 70 | ) 71 | ]); 72 | 73 | return commonShapeEditor(manager, [row1, row2, row3, row4]); 74 | } 75 | -------------------------------------------------------------------------------- /editor/src/sidebar/properties/shapes/shape-editor-factory.ts: -------------------------------------------------------------------------------- 1 | import { DestroyableComponent } from '../../../components/component'; 2 | import { UpdateManager } from '../update-manager'; 3 | import { textShapeEditor } from './textbox-shape-editor'; 4 | import { rectShapeEditor } from './rect-shape-editor'; 5 | import { circleShapeEditor } from './circle-shape-editor'; 6 | import { imageShapeEditor } from './image-shape-editor'; 7 | import { unknownShapeEditor } from './unknown-shape-editor'; 8 | import { Circle, FabricObject, MceImage, MceRect, MceTextbox, ObjectEvents } from 'mini-canvas-core'; 9 | 10 | export class ShapeEditorFactory { 11 | public static create(type: string, manager: UpdateManager): DestroyableComponent { 12 | switch (type) { 13 | case 'rect': 14 | return rectShapeEditor(manager as UpdateManager); 15 | case 'circle': 16 | return circleShapeEditor(manager as UpdateManager); 17 | case 'textbox': 18 | return textShapeEditor(manager as UpdateManager); 19 | case 'image': 20 | return imageShapeEditor(manager as UpdateManager); 21 | default: 22 | return unknownShapeEditor(manager as UpdateManager); 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /editor/src/sidebar/properties/shapes/textbox-shape-editor.ts: -------------------------------------------------------------------------------- 1 | import { UpdateManager } from '../update-manager'; 2 | import { DestroyableComponent } from '../../../components/component'; 3 | import { commonShapeEditor } from './common-shape-editor'; 4 | import { propertyEditorRow } from '../layout/property-editor-row'; 5 | import { numberPropertyEditor } from '../editors/number-property-editor'; 6 | import { choicePropertyEditor } from '../editors/choice-property-editor'; 7 | import { FillType, colorPropertyEditor } from '../editors/color-property-editor'; 8 | import { MceTextBackground, MceTextbox, MceVerticalAlign } from 'mini-canvas-core'; 9 | 10 | export function textShapeEditor(manager: UpdateManager): DestroyableComponent { 11 | const row1 = propertyEditorRow([ 12 | numberPropertyEditor( 13 | 'Font size', 14 | manager.bind( 15 | o => o.fontSize, 16 | (o, v) => o.set('fontSize', v) 17 | ) 18 | ) 19 | ]); 20 | 21 | const row2 = propertyEditorRow([ 22 | numberPropertyEditor( 23 | 'Line height', 24 | manager.bind( 25 | o => o.lineHeight, 26 | (o, v) => o.set('lineHeight', v) 27 | ), 28 | { 29 | step: 0.05, 30 | decimals: 2 31 | } 32 | ) 33 | ]); 34 | 35 | const row3 = propertyEditorRow([ 36 | colorPropertyEditor( 37 | 'Color', 38 | manager.bind( 39 | o => o.fill, 40 | (o, v) => o.set('fill', v) 41 | ) 42 | ) 43 | ]); 44 | 45 | const row4 = propertyEditorRow([ 46 | choicePropertyEditor( 47 | 'Text align', 48 | { 49 | Left: 'left', 50 | Center: 'center', 51 | Right: 'right', 52 | Justify: 'justify' 53 | }, 54 | manager.bind( 55 | o => o.textAlign, 56 | (o, v) => o.set('textAlign', v) 57 | ) 58 | ) 59 | ]); 60 | 61 | const row5 = propertyEditorRow([ 62 | choicePropertyEditor( 63 | 'Vertical align', 64 | { 65 | Top: MceVerticalAlign.top, 66 | Middle: MceVerticalAlign.middle, 67 | Bottom: MceVerticalAlign.bottom 68 | }, 69 | manager.bind( 70 | o => o.verticalAlign, 71 | (o, v) => o.set('verticalAlign', v) 72 | ) 73 | ) 74 | ]); 75 | 76 | const row6 = propertyEditorRow([ 77 | choicePropertyEditor( 78 | 'Font', 79 | { 80 | // https://www.w3schools.com/cssref/css_websafe_fonts.php 81 | Arial: 'arial', 82 | Verdana: 'verdana', 83 | Tahoma: 'tahoma', 84 | Georgia: 'georgia', 85 | 'Courier New': 'courier new', 86 | Serif: 'serif' 87 | }, 88 | manager.bind( 89 | o => o.fontFamily, 90 | (o, v) => o.set('fontFamily', v) 91 | ) 92 | ) 93 | ]); 94 | 95 | const row7 = propertyEditorRow([ 96 | choicePropertyEditor( 97 | 'Weight', 98 | { 99 | Bold: 'bold', 100 | Normal: 'normal' 101 | }, 102 | manager.bind( 103 | o => o.fontWeight, 104 | (o, v) => o.set('fontWeight', v) 105 | ) 106 | ) 107 | ]); 108 | 109 | const row8 = propertyEditorRow([ 110 | choicePropertyEditor( 111 | 'Bg', 112 | { 113 | Off: MceTextBackground.none, 114 | Behind: MceTextBackground.behind 115 | }, 116 | manager.bind( 117 | o => o.textBackground, 118 | (o, v) => o.set('textBackground', v) 119 | ) 120 | ), 121 | colorPropertyEditor( 122 | 'Color', 123 | manager.bind( 124 | o => o.textBackgroundFill as FillType, 125 | (o, v) => o.set('textBackgroundFill', v) 126 | ) 127 | ) 128 | ]); 129 | 130 | const row9 = propertyEditorRow([ 131 | numberPropertyEditor( 132 | 'SW', 133 | manager.bind( 134 | o => o.strokeWidth, 135 | (o, v) => o.set('strokeWidth', v) 136 | ), 137 | { 138 | min: 0 139 | } 140 | ), 141 | colorPropertyEditor( 142 | 'SC', 143 | manager.bind( 144 | o => o.stroke as FillType, 145 | (o, v) => o.set('stroke', v) 146 | ) 147 | ) 148 | ]); 149 | 150 | return commonShapeEditor(manager, [row1, row2, row3, row4, row5, row6, row7, row8, row9]); 151 | } 152 | -------------------------------------------------------------------------------- /editor/src/sidebar/properties/shapes/unknown-shape-editor.ts: -------------------------------------------------------------------------------- 1 | import { UpdateManager } from '../update-manager'; 2 | import { DestroyableComponent } from '../../../components/component'; 3 | import { commonShapeEditor } from './common-shape-editor'; 4 | import { FabricObject } from 'mini-canvas-core'; 5 | 6 | export function unknownShapeEditor(manager: UpdateManager): DestroyableComponent { 7 | return commonShapeEditor(manager, []); 8 | } 9 | -------------------------------------------------------------------------------- /editor/src/sidebar/properties/update-manager.ts: -------------------------------------------------------------------------------- 1 | import { EditorState } from '../../editor-state'; 2 | import { SimpleEvent } from '../../core/simple-event'; 3 | import { Observable } from 'mini-canvas-core'; 4 | import { PropertyAccessor } from './property-accessor'; 5 | 6 | export class UpdateManager, EventSpec = unknown> { 7 | public static create, ES>(state: EditorState, events: (keyof ES)[], object: O): UpdateManager { 8 | const inst = new UpdateManager(state, object, events); 9 | for (const event of events) { 10 | object.on(event, inst.onExternalChanged); 11 | } 12 | return inst; 13 | } 14 | 15 | private readonly accessors: PropertyAccessor[] = []; 16 | public readonly onChanged = new SimpleEvent(); 17 | 18 | public constructor( 19 | private readonly state: EditorState, 20 | private readonly object: Obj, 21 | private readonly events: (keyof EventSpec)[] 22 | ) {} 23 | 24 | public bind(getValue: (o: Obj) => Val, setValue: (o: Obj, value: Val) => void): PropertyAccessor { 25 | const accessor: PropertyAccessor = { 26 | onExternalChanged: new SimpleEvent(), 27 | lastValue: getValue(this.object), 28 | getValue: () => { 29 | return getValue(this.object); 30 | }, 31 | setValue: v => { 32 | setValue(this.object, v); 33 | accessor.lastValue = v; 34 | this.state.canvas.requestRenderAll(); 35 | this.onChanged.forward(); 36 | } 37 | }; 38 | this.accessors.push(accessor as PropertyAccessor); 39 | return accessor; 40 | } 41 | 42 | private readonly onExternalChanged = () => { 43 | for (const accessor of this.accessors) { 44 | const value = accessor.getValue(); 45 | if (value !== accessor.lastValue) { 46 | accessor.onExternalChanged.forward(value); 47 | } 48 | } 49 | }; 50 | 51 | public destroy() { 52 | for (const event of this.events) { 53 | this.object.off(event, this.onExternalChanged); 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /editor/src/sidebar/sidebar.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '../components/component'; 2 | import { Html } from '../core/html'; 3 | import { Icons } from '../core/icons'; 4 | import { SidebarConfiguration } from '../editor-configuration'; 5 | import { EditorState } from '../editor-state'; 6 | import { LayersPanel } from './layers/layers-panel'; 7 | import { PropertiesPanel } from './properties/properties-panel'; 8 | 9 | export class Sidebar implements Component { 10 | public static create(state: EditorState, configuration: SidebarConfiguration) { 11 | const root = Html.div({ 12 | class: 'mce-sidebar' 13 | }); 14 | 15 | const _switch = Html.div({ 16 | class: 'mce-sidebar-switch' 17 | }); 18 | _switch.appendChild(Icons.createSvg(Icons.menu, 'mce-sidebar-switch-icon')); 19 | const body = Html.div({ 20 | class: 'mce-sidebar-body' 21 | }); 22 | 23 | if (configuration.properties !== false) { 24 | const propertiesPanel = PropertiesPanel.create(state); 25 | body.appendChild(propertiesPanel.view); 26 | } 27 | 28 | if (configuration.layers !== false) { 29 | const layersPanel = LayersPanel.create(state); 30 | body.appendChild(layersPanel.view); 31 | } 32 | 33 | root.appendChild(_switch); 34 | root.appendChild(body); 35 | const sidebar = new Sidebar(root); 36 | _switch.addEventListener('click', sidebar.onSwitchClicked, false); 37 | return sidebar; 38 | } 39 | 40 | private constructor(public readonly view: HTMLElement) {} 41 | 42 | private readonly onSwitchClicked = () => { 43 | this.view.classList.toggle('mce-visible'); 44 | }; 45 | } 46 | -------------------------------------------------------------------------------- /editor/src/toolbar/editors/color-toolbar-input.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '../../components/component'; 2 | import { Html } from '../../core/html'; 3 | import { SimpleEvent } from '../../core/simple-event'; 4 | 5 | export interface ColorToolbarInputComponent extends Component { 6 | readonly onChanged: SimpleEvent; 7 | } 8 | 9 | export function colorToolbarInput(labelText: string, initialValue: string): ColorToolbarInputComponent { 10 | function onInputChanged() { 11 | onChanged.forward(input.value); 12 | } 13 | 14 | const onChanged = new SimpleEvent(); 15 | 16 | const label = Html.element('span', { 17 | class: 'mce-toolbar-label' 18 | }); 19 | label.textContent = labelText; 20 | 21 | const input = Html.element('input', { 22 | class: 'mce-toolbar-color-input', 23 | type: 'color', 24 | value: initialValue 25 | }); 26 | input.addEventListener('change', onInputChanged, false); 27 | 28 | const view = Html.div({ 29 | class: 'mce-toolbar-item' 30 | }); 31 | view.appendChild(label); 32 | view.appendChild(input); 33 | return { 34 | view, 35 | onChanged 36 | }; 37 | } 38 | -------------------------------------------------------------------------------- /editor/src/toolbar/editors/number-toolbar-input.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '../../components/component'; 2 | import { Html } from '../../core/html'; 3 | import { SimpleEvent } from '../../core/simple-event'; 4 | 5 | export interface NumberToolbarInputComponent extends Component { 6 | readonly onChanged: SimpleEvent; 7 | } 8 | 9 | export function numberToolbarInput(labelText: string, initialValue: number): NumberToolbarInputComponent { 10 | function onInputChanged() { 11 | const newValue = Number(input.value); 12 | onChanged.forward(newValue); 13 | } 14 | 15 | const onChanged = new SimpleEvent(); 16 | 17 | const label = Html.element('span', { 18 | class: 'mce-toolbar-label' 19 | }); 20 | label.textContent = labelText; 21 | 22 | const input = Html.element('input', { 23 | class: 'mce-toolbar-number-input', 24 | type: 'number', 25 | min: '0.5', 26 | step: '0.5', 27 | value: String(initialValue) 28 | }); 29 | input.addEventListener('change', onInputChanged, false); 30 | 31 | const view = Html.div({ 32 | class: 'mce-toolbar-item' 33 | }); 34 | view.appendChild(label); 35 | view.appendChild(input); 36 | return { 37 | view, 38 | onChanged 39 | }; 40 | } 41 | -------------------------------------------------------------------------------- /editor/src/toolbar/modes/brush-toolbar-mode.ts: -------------------------------------------------------------------------------- 1 | import { DestroyableComponent } from '../../components/component'; 2 | import { Html } from '../../core/html'; 3 | import { EditorState } from '../../editor-state'; 4 | import { colorToolbarInput } from '../editors/color-toolbar-input'; 5 | import { numberToolbarInput } from '../editors/number-toolbar-input'; 6 | 7 | export class BrushToolbarMode implements DestroyableComponent { 8 | public static create(state: EditorState): BrushToolbarMode { 9 | const view = Html.div({ 10 | class: 'mce-toolbar-brush' 11 | }); 12 | 13 | const sizeInput = numberToolbarInput('Size', state.brush.brushSize); 14 | const colorInput = colorToolbarInput('Color', state.brush.brushColor); 15 | 16 | view.appendChild(sizeInput.view); 17 | view.appendChild(colorInput.view); 18 | const toolbar = new BrushToolbarMode(view, state); 19 | sizeInput.onChanged.subscribe(toolbar.onBrushSizeChanged); 20 | colorInput.onChanged.subscribe(toolbar.onBrushColorChanged); 21 | return toolbar; 22 | } 23 | 24 | private constructor( 25 | public readonly view: HTMLElement, 26 | private readonly state: EditorState 27 | ) {} 28 | 29 | private readonly onBrushSizeChanged = (newSize: number) => { 30 | this.state.brush.brushSize = newSize; 31 | this.state.onBrushConfigurationChanged.forward(); 32 | }; 33 | 34 | private readonly onBrushColorChanged = (newColor: string) => { 35 | this.state.brush.brushColor = newColor; 36 | this.state.onBrushConfigurationChanged.forward(); 37 | }; 38 | 39 | public destroy() { 40 | // 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /editor/src/toolbar/modes/rect-toolbar-mode.ts: -------------------------------------------------------------------------------- 1 | import { DestroyableComponent } from '../../components/component'; 2 | import { Html } from '../../core/html'; 3 | import { EditorState } from '../../editor-state'; 4 | import { colorToolbarInput } from '../editors/color-toolbar-input'; 5 | 6 | export class RectToolbarMode implements DestroyableComponent { 7 | public static create(state: EditorState): RectToolbarMode { 8 | const view = Html.div({ 9 | class: 'mce-toolbar-brush' 10 | }); 11 | 12 | const fillInput = colorToolbarInput('Fill', state.rect.fillColor); 13 | 14 | view.appendChild(fillInput.view); 15 | const toolbar = new RectToolbarMode(view, state); 16 | fillInput.onChanged.subscribe(toolbar.onFillColorChanged); 17 | return toolbar; 18 | } 19 | 20 | private constructor( 21 | public readonly view: HTMLElement, 22 | private readonly state: EditorState 23 | ) {} 24 | 25 | private readonly onFillColorChanged = (newColor: string) => { 26 | this.state.brush.brushColor = newColor; 27 | this.state.onBrushConfigurationChanged.forward(); 28 | }; 29 | 30 | public destroy() { 31 | // 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /editor/src/toolbar/modes/toolbar-mode-factory.ts: -------------------------------------------------------------------------------- 1 | import { EditorState } from '../../editor-state'; 2 | import { EditorMode } from '../../editor-configuration'; 3 | import { DestroyableComponent } from '../../components/component'; 4 | import { RectToolbarMode } from './rect-toolbar-mode'; 5 | import { BrushToolbarMode } from './brush-toolbar-mode'; 6 | 7 | export class ToolbarModeFactory { 8 | public static create(mode: EditorMode, state: EditorState): DestroyableComponent | null { 9 | switch (mode) { 10 | case EditorMode.rect: 11 | return RectToolbarMode.create(state); 12 | case EditorMode.brush: 13 | return BrushToolbarMode.create(state); 14 | } 15 | return null; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /editor/src/toolbar/toolbar.ts: -------------------------------------------------------------------------------- 1 | import { DestroyableComponent } from '../components/component'; 2 | import { Html } from '../core/html'; 3 | import { EditorState } from '../editor-state'; 4 | import { ToolbarModeFactory } from './modes/toolbar-mode-factory'; 5 | 6 | export class Toolbar implements DestroyableComponent { 7 | public static create(state: EditorState): Toolbar { 8 | const view = Html.div({ 9 | class: 'mce-toolbar' 10 | }); 11 | 12 | const toolbar = new Toolbar(view, state); 13 | state.onModeChanged.subscribe(toolbar.reload); 14 | toolbar.reload(); 15 | return toolbar; 16 | } 17 | 18 | private current: DestroyableComponent | null = null; 19 | 20 | private constructor( 21 | public readonly view: HTMLElement, 22 | private readonly state: EditorState 23 | ) {} 24 | 25 | private readonly reload = () => { 26 | if (this.current) { 27 | this.view.removeChild(this.current.view); 28 | this.current = null; 29 | } 30 | const component = ToolbarModeFactory.create(this.state.mode, this.state); 31 | if (component) { 32 | this.current = component; 33 | this.view.appendChild(this.current.view); 34 | this.view.classList.remove('mce-hidden'); 35 | } else { 36 | this.view.classList.add('mce-hidden'); 37 | } 38 | }; 39 | 40 | public destroy() { 41 | if (this.current) { 42 | this.current.destroy(); 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /editor/src/toolbox/actions/open-image-action.ts: -------------------------------------------------------------------------------- 1 | import { MceImage } from 'mini-canvas-core'; 2 | import { EditorState } from '../../editor-state'; 3 | import { openImageFile } from './open-image-file'; 4 | 5 | export async function openImageAction(state: EditorState) { 6 | const rawImage = await openImageFile(); 7 | if (rawImage) { 8 | const scale = Math.max(rawImage.width / state.canvas.workspaceWidth, rawImage.height / state.canvas.workspaceHeight); 9 | const image = new MceImage(rawImage, { 10 | left: 0, 11 | top: 0, 12 | width: rawImage.width, 13 | height: rawImage.height, 14 | scaleX: 1 / scale, 15 | scaleY: 1 / scale 16 | }); 17 | state.add(image); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /editor/src/toolbox/actions/open-image-file.ts: -------------------------------------------------------------------------------- 1 | export function openImageFile(): Promise { 2 | return new Promise(resolve => { 3 | const input = document.createElement('input'); 4 | input.type = 'file'; 5 | input.accept = 'image/*'; 6 | input.addEventListener('change', () => { 7 | if (input.files && input.files.length > 0) { 8 | const file = input.files[0]; 9 | const reader = new FileReader(); 10 | reader.onload = () => { 11 | const img = new Image(); 12 | img.onload = () => { 13 | resolve(img); 14 | }; 15 | img.src = reader.result as string; 16 | }; 17 | reader.readAsDataURL(file); 18 | } else { 19 | resolve(null); 20 | } 21 | }); 22 | input.click(); 23 | }); 24 | } 25 | -------------------------------------------------------------------------------- /editor/src/toolbox/toolbox-item.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '../components/component'; 2 | import { Html } from '../core/html'; 3 | import { Icons } from '../core/icons'; 4 | import { SimpleEvent } from '../core/simple-event'; 5 | import { EditorMode } from '../editor-configuration'; 6 | 7 | export class ToolboxItem implements Component { 8 | public static create(icon: string, title: string, mode: EditorMode | null) { 9 | const view = Html.div({ 10 | class: 'mce-toolbox-item', 11 | title 12 | }); 13 | view.appendChild(Icons.createSvg(icon, 'mce-toolbox-item-icon')); 14 | 15 | const item = new ToolboxItem(view, mode); 16 | view.addEventListener( 17 | 'click', 18 | e => { 19 | e.preventDefault(); 20 | item.onClicked.forward(item); 21 | }, 22 | false 23 | ); 24 | return item; 25 | } 26 | 27 | public readonly onClicked = new SimpleEvent(); 28 | 29 | private constructor( 30 | public readonly view: HTMLElement, 31 | public readonly mode: EditorMode | null 32 | ) {} 33 | 34 | public setIsSelected(isSelected: boolean) { 35 | this.view.classList.toggle('mce-selected', isSelected); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /editor/src/toolbox/toolbox-zoom.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '../components/component'; 2 | import { Html } from '../core/html'; 3 | import { EditorState } from '../editor-state'; 4 | 5 | export class ToolboxZoom implements Component { 6 | public static create(state: EditorState) { 7 | const view = Html.div({ 8 | class: 'mce-toolbox-item', 9 | title: 'Zoom / Center' 10 | }); 11 | const text = Html.div({ 12 | class: 'mce-toolbox-item-zoom' 13 | }); 14 | view.appendChild(text); 15 | 16 | const zoom = new ToolboxZoom(view, text, state); 17 | view.addEventListener('click', zoom.onClick, false); 18 | zoom.reloadZoom(); 19 | state.onZoomChanged.subscribe(zoom.reloadZoom); 20 | return zoom; 21 | } 22 | 23 | private constructor( 24 | public readonly view: HTMLElement, 25 | private readonly text: HTMLElement, 26 | private readonly state: EditorState 27 | ) {} 28 | 29 | private readonly reloadZoom = () => { 30 | const zoom = Math.round(this.state.canvas.getZoom() * 100); 31 | this.text.innerText = `${zoom}%`; 32 | }; 33 | 34 | private readonly onClick = () => { 35 | this.state.center(); 36 | this.reloadZoom(); 37 | }; 38 | } 39 | -------------------------------------------------------------------------------- /editor/src/toolbox/toolbox.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '../components/component'; 2 | import { Html } from '../core/html'; 3 | import { Icons } from '../core/icons'; 4 | import { EditorState } from '../editor-state'; 5 | import { ToolboxItem } from './toolbox-item'; 6 | import { ToolboxZoom } from './toolbox-zoom'; 7 | import { openImageAction } from './actions/open-image-action'; 8 | import { EditorConfiguration, EditorMode } from '../editor-configuration'; 9 | 10 | export class Toolbox implements Component { 11 | public static create(state: EditorState, configuration: EditorConfiguration) { 12 | const view = Html.div({ 13 | class: 'mce-toolbox' 14 | }); 15 | const top = Html.div({ 16 | class: 'mce-toolbox-top' 17 | }); 18 | const bottom = Html.div({ 19 | class: 'mce-toolbox-bottom' 20 | }); 21 | view.appendChild(top); 22 | view.appendChild(bottom); 23 | 24 | let imageItem: ToolboxItem | null = null; 25 | const items = [ 26 | ToolboxItem.create(Icons.cursor, 'Select', EditorMode.select), 27 | configuration.rect !== false && ToolboxItem.create(Icons.rect, 'Rect', EditorMode.rect), 28 | configuration.textbox !== false && ToolboxItem.create(Icons.text, 'Textbox', EditorMode.textbox), 29 | configuration.brush !== false && ToolboxItem.create(Icons.brush, 'Brush', EditorMode.brush), 30 | configuration.image !== false && (imageItem = ToolboxItem.create(Icons.image, 'Image', null)) 31 | ].filter(Boolean) as ToolboxItem[]; 32 | 33 | const toolbox = new Toolbox(view, state, items); 34 | for (const item of items) { 35 | top.appendChild(item.view); 36 | if (item.mode) { 37 | item.onClicked.subscribe(toolbox.onItemClicked); 38 | } 39 | } 40 | 41 | if (imageItem) { 42 | imageItem.onClicked.subscribe(toolbox.onOpenImageClicked); 43 | } 44 | 45 | const zoom = ToolboxZoom.create(state); 46 | bottom.appendChild(zoom.view); 47 | 48 | toolbox.reloadSelection(); 49 | state.onModeChanged.subscribe(toolbox.reloadSelection); 50 | return toolbox; 51 | } 52 | 53 | private constructor( 54 | public readonly view: HTMLElement, 55 | private readonly state: EditorState, 56 | private readonly items: ToolboxItem[] 57 | ) {} 58 | 59 | private readonly onItemClicked = (item: ToolboxItem) => { 60 | if (item.mode) { 61 | this.state.setMode(item.mode); 62 | } 63 | }; 64 | 65 | private readonly onOpenImageClicked = async () => { 66 | openImageAction(this.state); 67 | }; 68 | 69 | private readonly reloadSelection = () => { 70 | this.items.forEach(item => { 71 | item.setIsSelected(item.mode === this.state.mode); 72 | }); 73 | }; 74 | } 75 | -------------------------------------------------------------------------------- /editor/src/workspace/modes/box-painter.ts: -------------------------------------------------------------------------------- 1 | import { FabricObject, TPointerEvent, TPointerEventInfo } from 'mini-canvas-core'; 2 | import { SimpleEvent } from '../../core/simple-event'; 3 | import { SelectionReverter, EditorState } from '../../editor-state'; 4 | 5 | export class BoxPainter { 6 | public static create(state: EditorState, objectFactory: () => FabricObject) { 7 | const selectionReverter = state.disableSelection(); 8 | 9 | const painter = new BoxPainter(state, selectionReverter, objectFactory); 10 | state.canvas.on('mouse:down', painter.onDown); 11 | state.canvas.on('mouse:move', painter.onMove); 12 | state.canvas.on('mouse:up', painter.onUp); 13 | state.canvas.setCursor('crosshair'); 14 | 15 | return painter; 16 | } 17 | 18 | private object: FabricObject | null = null; 19 | private origX = 0; 20 | private origY = 0; 21 | 22 | public readonly onFinished = new SimpleEvent(); 23 | 24 | private constructor( 25 | private readonly state: EditorState, 26 | private readonly selectionReverter: SelectionReverter, 27 | private readonly objectFactory: () => FabricObject 28 | ) {} 29 | 30 | public destroy() { 31 | this.state.canvas.off('mouse:down', this.onDown); 32 | this.state.canvas.off('mouse:move', this.onMove); 33 | this.state.canvas.off('mouse:up', this.onUp); 34 | this.selectionReverter.revert(); 35 | } 36 | 37 | private readonly onDown = (o: TPointerEventInfo) => { 38 | o.e.preventDefault(); 39 | o.e.stopPropagation(); 40 | this.state.canvas.discardActiveObject(); 41 | 42 | const pointer = this.state.canvas.getPointer(o.e); 43 | 44 | this.origX = Math.floor(pointer.x); 45 | this.origY = Math.floor(pointer.y); 46 | 47 | this.object = this.objectFactory(); 48 | this.object.set('left', this.origX); 49 | this.object.set('top', this.origX); 50 | this.state.add(this.object); 51 | }; 52 | 53 | private readonly onMove = (o: TPointerEventInfo) => { 54 | if (!this.object) { 55 | return; 56 | } 57 | 58 | const pointer = this.state.canvas.getPointer(o.e); 59 | 60 | const dx = Math.floor(pointer.x - this.origX); 61 | const dy = Math.floor(pointer.y - this.origY); 62 | const width = Math.abs(dx); 63 | const height = Math.abs(dy); 64 | 65 | this.object.set({ 66 | left: dx > 0 ? this.origX : this.origX + dx, 67 | top: dy > 0 ? this.origY : this.origY + dy, 68 | width, 69 | height 70 | }); 71 | this.state.canvas.renderAll(); 72 | }; 73 | 74 | private readonly onUp = () => { 75 | if (!this.object) { 76 | return; 77 | } 78 | 79 | this.onFinished.forward(this.object); 80 | this.object = null; 81 | }; 82 | } 83 | -------------------------------------------------------------------------------- /editor/src/workspace/modes/brush-workspace-mode.ts: -------------------------------------------------------------------------------- 1 | import { McePath, Path, PencilBrush } from 'mini-canvas-core'; 2 | import { EditorState } from '../../editor-state'; 3 | import { WorkspaceMode } from './workspace-mode'; 4 | 5 | export class BrushWorkspaceMode implements WorkspaceMode { 6 | private readonly brush = new PencilBrush(this.state.canvas); 7 | 8 | public constructor(private readonly state: EditorState) {} 9 | 10 | public init() { 11 | this.reloadBrush(); 12 | 13 | this.state.canvas.freeDrawingBrush = this.brush; 14 | this.state.canvas.isDrawingMode = true; 15 | 16 | this.state.canvas.on('path:created', result => { 17 | const path = result.path as Path; 18 | this.state.canvas.remove(path); 19 | 20 | const newPath = new McePath(path.path, path); 21 | this.state.canvas.add(newPath); 22 | }); 23 | this.state.onBrushConfigurationChanged.subscribe(this.reloadBrush); 24 | } 25 | 26 | public destroy() { 27 | this.state.canvas.freeDrawingBrush = undefined; 28 | this.state.canvas.isDrawingMode = false; 29 | this.state.onBrushConfigurationChanged.unsubscribe(this.reloadBrush); 30 | } 31 | 32 | private readonly reloadBrush = () => { 33 | this.brush.width = this.state.brush.brushSize; 34 | this.brush.color = this.state.brush.brushColor; 35 | }; 36 | } 37 | -------------------------------------------------------------------------------- /editor/src/workspace/modes/rect-workspace-mode.ts: -------------------------------------------------------------------------------- 1 | import { MceRect } from 'mini-canvas-core'; 2 | import { EditorState } from '../../editor-state'; 3 | import { BoxPainter } from './box-painter'; 4 | import { WorkspaceMode } from './workspace-mode'; 5 | import { EditorMode } from '../../editor-configuration'; 6 | 7 | export class RectWorkspaceMode implements WorkspaceMode { 8 | private painter?: BoxPainter; 9 | 10 | public constructor(private readonly state: EditorState) {} 11 | 12 | public init() { 13 | this.painter = BoxPainter.create(this.state, () => { 14 | return new MceRect({ 15 | fill: this.state.rect.fillColor, 16 | stroke: this.state.rect.fillColor, 17 | strokeWidth: 0 18 | }); 19 | }); 20 | this.painter.onFinished.subscribe(rect => { 21 | this.state.canvas.setActiveObject(rect); 22 | this.state.setMode(EditorMode.select); 23 | }); 24 | } 25 | 26 | public destroy() { 27 | if (this.painter) { 28 | this.painter.destroy(); 29 | this.painter = undefined; 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /editor/src/workspace/modes/select-workspace-mode.ts: -------------------------------------------------------------------------------- 1 | import { EditorState } from '../../editor-state'; 2 | import { WorkspaceMode } from './workspace-mode'; 3 | 4 | export class SelectWorkspaceMode implements WorkspaceMode { 5 | public constructor(private readonly state: EditorState) { 6 | // nothing 7 | } 8 | 9 | public init() { 10 | this.state.canvas.setCursor('move'); 11 | this.state.canvas.selection = true; 12 | } 13 | 14 | public destroy() { 15 | // nothing 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /editor/src/workspace/modes/textbox-workspace-mode.ts: -------------------------------------------------------------------------------- 1 | import { MceTextbox, Rect } from 'mini-canvas-core'; 2 | import { EditorState } from '../../editor-state'; 3 | import { BoxPainter } from './box-painter'; 4 | import { WorkspaceMode } from './workspace-mode'; 5 | import { EditorMode } from '../../editor-configuration'; 6 | 7 | const MIN_HEIGHT = 10; 8 | const MAX_FONT_SIZE = 20; 9 | 10 | export class TextboxWorkspaceMode implements WorkspaceMode { 11 | private painter?: BoxPainter; 12 | 13 | public constructor(private readonly state: EditorState) {} 14 | 15 | public init() { 16 | this.painter = BoxPainter.create(this.state, () => { 17 | return new Rect({ 18 | fill: 'transparent', 19 | stroke: '#26aa5a', 20 | strokeWidth: 1 21 | }); 22 | }); 23 | this.painter.onFinished.subscribe(rect => { 24 | const maxHeight = Math.max(rect.getScaledHeight(), MIN_HEIGHT); 25 | const fontSize = Math.min(maxHeight, MAX_FONT_SIZE); 26 | const textbox = new MceTextbox('Text', { 27 | left: rect.left, 28 | top: rect.top, 29 | width: rect.getScaledWidth(), 30 | maxHeight, 31 | fontSize, 32 | fill: '#000000' 33 | }); 34 | 35 | this.state.canvas.remove(rect); 36 | this.state.add(textbox); 37 | this.state.canvas.setActiveObject(textbox); 38 | this.state.setMode(EditorMode.select); 39 | }); 40 | } 41 | 42 | public destroy() { 43 | if (this.painter) { 44 | this.painter.destroy(); 45 | this.painter = undefined; 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /editor/src/workspace/modes/workspace-mode-factory.ts: -------------------------------------------------------------------------------- 1 | import { EditorState } from '../../editor-state'; 2 | import { WorkspaceMode } from './workspace-mode'; 3 | import { SelectWorkspaceMode } from './select-workspace-mode'; 4 | import { RectWorkspaceMode } from './rect-workspace-mode'; 5 | import { TextboxWorkspaceMode } from './textbox-workspace-mode'; 6 | import { BrushWorkspaceMode } from './brush-workspace-mode'; 7 | import { EditorMode } from '../../editor-configuration'; 8 | 9 | export class WorkspaceModeFactory { 10 | public static get(mode: EditorMode, state: EditorState): WorkspaceMode { 11 | switch (mode) { 12 | case EditorMode.select: 13 | return new SelectWorkspaceMode(state); 14 | case EditorMode.rect: 15 | return new RectWorkspaceMode(state); 16 | case EditorMode.brush: 17 | return new BrushWorkspaceMode(state); 18 | case EditorMode.textbox: 19 | return new TextboxWorkspaceMode(state); 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /editor/src/workspace/modes/workspace-mode.ts: -------------------------------------------------------------------------------- 1 | export interface WorkspaceMode { 2 | init(): void; 3 | destroy(): void; 4 | } 5 | -------------------------------------------------------------------------------- /editor/src/workspace/workspace.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '../components/component'; 2 | import { Html } from '../core/html'; 3 | import { EditorConfiguration, EditorMode } from '../editor-configuration'; 4 | import { WorkspaceMode } from './modes/workspace-mode'; 5 | import { EditorState } from '../editor-state'; 6 | import { WorkspaceModeFactory } from './modes/workspace-mode-factory'; 7 | import { CanvasOptions, MceCanvas, MceImageJSON, Point, TOptions, TPointerEventInfo } from 'mini-canvas-core'; 8 | 9 | function createRoot() { 10 | const view = Html.div({ 11 | class: 'mce-workspace' 12 | }); 13 | 14 | const nativeCanvas = document.createElement('canvas'); 15 | nativeCanvas.width = 1; 16 | nativeCanvas.height = 1; 17 | view.appendChild(nativeCanvas); 18 | return { view, nativeCanvas }; 19 | } 20 | 21 | const canvasOptions: TOptions = { 22 | imageSmoothingEnabled: true, 23 | preserveObjectStacking: true, 24 | controlsAboveOverlay: true, 25 | enableRetinaScaling: true, 26 | skipOffscreen: true 27 | }; 28 | 29 | export class Workspace implements Component { 30 | public static createBlank(workspaceWidth: number, workspaceHeight: number, configuration: EditorConfiguration): Workspace { 31 | const { view, nativeCanvas } = createRoot(); 32 | const canvas = MceCanvas.createBlank(workspaceWidth, workspaceHeight, nativeCanvas, canvasOptions); 33 | return Workspace.create(view, canvas, configuration); 34 | } 35 | 36 | public static async createFromJSON(json: MceImageJSON, configuration: EditorConfiguration): Promise { 37 | const { view, nativeCanvas } = createRoot(); 38 | const canvas = await MceCanvas.createFromJSON(json, nativeCanvas, canvasOptions); 39 | return Workspace.create(view, canvas, configuration); 40 | } 41 | 42 | private static create(view: HTMLElement, canvas: MceCanvas, configuration: EditorConfiguration): Workspace { 43 | const initialMode = configuration.initialMode || EditorMode.select; 44 | const state = new EditorState(canvas, initialMode, configuration, view); 45 | 46 | const workspace = new Workspace(view, canvas, state); 47 | canvas.on('mouse:wheel', workspace.onWheel); 48 | state.onModeChanged.subscribe(workspace.reloadMode); 49 | workspace.reloadMode(); 50 | 51 | return workspace; 52 | } 53 | 54 | private currentMode?: WorkspaceMode; 55 | 56 | private constructor( 57 | public readonly view: HTMLElement, 58 | public readonly canvas: MceCanvas, 59 | public readonly state: EditorState 60 | ) {} 61 | 62 | public startAutoLayout(center: boolean) { 63 | setTimeout(() => { 64 | this.reloadLayout(); 65 | if (center) { 66 | this.state.center(); 67 | } 68 | }); 69 | window.addEventListener('resize', this.reloadLayout, false); 70 | } 71 | 72 | public async destroy(): Promise { 73 | if (this.currentMode) { 74 | this.currentMode.destroy(); 75 | } 76 | window.removeEventListener('resize', this.reloadLayout, false); 77 | await this.canvas.dispose(); 78 | } 79 | 80 | private readonly reloadLayout = () => { 81 | if (!this.view.parentElement) { 82 | throw new Error('Workspace is not attached to the DOM'); 83 | } 84 | this.canvas.setWidth(this.view.clientWidth); 85 | this.canvas.setHeight(this.view.clientHeight); 86 | }; 87 | 88 | private readonly onWheel = (opt: TPointerEventInfo) => { 89 | opt.e.preventDefault(); 90 | opt.e.stopPropagation(); 91 | 92 | const delta = opt.e.deltaY; 93 | let zoom = this.canvas.getZoom(); 94 | zoom *= 0.999 ** delta; 95 | if (zoom > 20) { 96 | zoom = 20; 97 | } 98 | if (zoom < 0.01) { 99 | zoom = 0.01; 100 | } 101 | this.canvas.zoomToPoint(new Point(opt.e.offsetX, opt.e.offsetY), zoom); 102 | this.state.onZoomChanged.forward(); 103 | }; 104 | 105 | private readonly reloadMode = () => { 106 | if (this.currentMode) { 107 | this.currentMode.destroy(); 108 | } 109 | this.currentMode = WorkspaceModeFactory.get(this.state.mode, this.state); 110 | this.currentMode.init(); 111 | }; 112 | } 113 | -------------------------------------------------------------------------------- /editor/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./build/", 4 | "noImplicitAny": true, 5 | "target": "es6", 6 | "module": "es2015", 7 | "sourceMap": false, 8 | "strict": true, 9 | "allowJs": false, 10 | "declaration": true, 11 | "declarationDir": "./build/", 12 | "moduleResolution": "node", 13 | "lib": [ 14 | "es2017", 15 | "dom" 16 | ] 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "packageManager": "pnpm@9.9.0", 4 | "scripts": { 5 | "build": "pnpm run -r build", 6 | "prettier": "pnpm run -r prettier", 7 | "prettier:fix": "pnpm run -r prettier:fix", 8 | "eslint": "pnpm run -r eslint", 9 | "test:single": "pnpm run -r test:single", 10 | "serve": "http-server -c-1 -p 4333 ./" 11 | }, 12 | "devDependencies": { 13 | "http-server": "^14.1.1" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - './core' 3 | - './editor' 4 | - './demos/svelte-app' 5 | - './demos/webpack-app' 6 | -------------------------------------------------------------------------------- /scripts/append-ga.cjs: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | 4 | const SCRIPT = 5 | ` 6 | `; 14 | 15 | function appendGa(path) { 16 | let html = fs.readFileSync(path, 'utf8'); 17 | if (html.includes(SCRIPT)) { 18 | console.log(`👌 ${path} already has tracking`); 19 | return; 20 | } 21 | const pos = html.lastIndexOf(''); 22 | if (pos < 0) { 23 | throw new Error('Could not find tag'); 24 | } 25 | html = html.substring(0, pos) + SCRIPT + html.substring(pos); 26 | fs.writeFileSync(path, html, 'utf8'); 27 | console.log(`✅ ${path} updated`); 28 | } 29 | 30 | const directory = process.argv[2]; 31 | if (!directory) { 32 | throw new Error('Please specify a directory'); 33 | } 34 | 35 | const dirPath = path.join(__dirname, '..', directory); 36 | fs.readdir(dirPath, (_, files) => { 37 | files.forEach(file => { 38 | if (file.endsWith('.html')) { 39 | const filePath = path.join(dirPath, file); 40 | appendGa(filePath); 41 | } 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /scripts/publish.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cd "$(dirname "${BASH_SOURCE[0]}")" 4 | 5 | cd ../core 6 | pnpm build 7 | npm publish 8 | 9 | cd ../editor 10 | pnpm build 11 | npm publish 12 | -------------------------------------------------------------------------------- /scripts/set-version.cjs: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | 4 | const version = process.argv[2]; 5 | if (!version || !(/^\d+\.\d+\.\d+$/.test(version))) { 6 | console.log('Usage: node set-version.js 1.2.3'); 7 | return; 8 | } 9 | 10 | const dependencies = [ 11 | 'mini-canvas-core', 12 | 'mini-canvas-editor' 13 | ]; 14 | 15 | function resolvePath(filePath) { 16 | return path.join(__dirname, '..', filePath); 17 | } 18 | 19 | function updateDependencies(deps) { 20 | if (!deps) { 21 | return; 22 | } 23 | for (const name in deps) { 24 | if (dependencies.includes(name)) { 25 | deps[name] = `^${version}`; 26 | } 27 | } 28 | } 29 | 30 | function updatePackage(filePath, setVersion) { 31 | filePath = resolvePath(filePath); 32 | const json = JSON.parse(fs.readFileSync(filePath, 'utf-8')); 33 | 34 | if (setVersion) { 35 | json['version'] = version; 36 | } 37 | updateDependencies(json['dependencies']); 38 | updateDependencies(json['peerDependencies']); 39 | updateDependencies(json['devDependencies']); 40 | 41 | fs.writeFileSync(filePath, JSON.stringify(json, null, '\t'), 'utf-8'); 42 | } 43 | 44 | function updateJsdelivrUrl(filePath) { 45 | filePath = resolvePath(filePath); 46 | let text = fs.readFileSync(filePath, 'utf-8'); 47 | 48 | text = text.replace(/\/\/cdn\.jsdelivr\.net\/npm\/mini-canvas-(editor|core)@\d+\.\d+\.\d+/g, (found) => { 49 | const parts = found.split('@'); 50 | return `${parts[0]}@${version}`; 51 | }); 52 | 53 | fs.writeFileSync(filePath, text, 'utf-8'); 54 | } 55 | 56 | updatePackage('core/package.json', true); 57 | updatePackage('editor/package.json', true); 58 | updatePackage('demos/webpack-app/package.json', false); 59 | updatePackage('demos/svelte-app/package.json', false); 60 | updateJsdelivrUrl('README.md'); 61 | updateJsdelivrUrl('demos/webpack-app/public/vanilla-javascript.html'); 62 | --------------------------------------------------------------------------------