├── .browserslistrc ├── .eslintrc.js ├── .gitignore ├── .prettierrc ├── README.md ├── babel.config.js ├── index.html ├── jest.config.js ├── package-lock.json ├── package.json ├── public ├── CNAME ├── favicon.ico └── favicon.png ├── publish_to_gh_pages.sh ├── src ├── App.vue ├── about-page │ └── AboutPage.vue ├── assets │ └── logo.png ├── common │ ├── geometry.js │ ├── layers2css.js │ ├── shapes2css.js │ ├── shapes2css.spec.js │ ├── ui.js │ └── utils.js ├── components │ ├── AddButton.vue │ ├── CloseButton.vue │ ├── ColorPicker.vue │ ├── CopyButton │ │ ├── CopyButton.vue │ │ └── copy-button-shapes.json │ ├── CornerCloseButton.vue │ ├── CssOutput.vue │ ├── DraggableListMixin.js │ ├── EdgeCloseButton.vue │ ├── ExportToCodePenButton │ │ ├── ExportToCodePenButton.vue │ │ └── export-to-codepen-button-shape.json │ ├── HamburgerButton.vue │ ├── IconButton.vue │ ├── JsonOutput.vue │ ├── ProjectOutput.vue │ ├── TopNav.vue │ └── draggable-indicator.css ├── editor │ ├── EditorCanvas.vue │ ├── EditorWorkspace.vue │ ├── LayerPropsFormFields.vue │ ├── LayerSubtree.vue │ ├── ProjectEditor.vue │ ├── ProjectLayers.vue │ ├── PropsForm.vue │ ├── ShapeNameInput.vue │ ├── ShapeOverlay.vue │ ├── ShapeOverlays.vue │ ├── ShapePropsFormFields.vue │ ├── ShapeResizeHandles.vue │ ├── StopsEditor.vue │ ├── shapes-store.js │ └── shapes.js ├── favicon.clip ├── gallery │ ├── GalleryThumbnail.vue │ ├── ProjectGallery.vue │ ├── projects-store.js │ └── projects-store.spec.js ├── logo-128px.clip ├── logo-128px.png ├── main.js ├── persistence.js ├── react-to-keyboard.js ├── router │ └── index.js ├── snap │ ├── snap-store.js │ ├── snap.js │ └── snap.spec.js ├── store │ ├── index.js │ └── ui.js ├── toolbar │ ├── CurrentColorPicker.vue │ ├── LineThicknessPicker.vue │ ├── ShapeButton.vue │ ├── ShapeButtonGroup.vue │ ├── Toolbar.vue │ ├── ToolbarButton.vue │ ├── ToolbarSnapOptions.vue │ └── toolbar-button-shapes.js ├── undo-redo │ ├── RedoButton.vue │ ├── UndoButton.vue │ ├── redo-button-shapes.json │ ├── undo-button-shapes.json │ ├── undo-redo-store.js │ ├── undo-redo-store.spec.js │ └── undo-shape.json └── warn.js ├── vite.config.js ├── vue.config.js └── zerodivs-sample-03-x10.gif /.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | es2021: true 5 | }, 6 | extends: ["plugin:vue/essential", "@vue/prettier"], 7 | rules: { 8 | "no-console": process.env.NODE_ENV === "production" ? "error" : "off", 9 | "no-debugger": process.env.NODE_ENV === "production" ? "error" : "off" 10 | }, 11 | overrides: [ 12 | { 13 | files: ["**/*.spec.{j,t}s?(x)"], 14 | env: { 15 | jest: true 16 | } 17 | } 18 | ] 19 | }; 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | # local env files 6 | .env.local 7 | .env.*.local 8 | 9 | # Log files 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | 14 | # Editor directories and files 15 | .idea 16 | .vscode 17 | *.suo 18 | *.ntvs* 19 | *.njsproj 20 | *.sln 21 | *.sw? 22 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jperals/zerodivs/d272e296d7384d3a5ec172c54264aa28382bfa30/.prettierrc -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ZeroDivs 2 | 3 | UI Editor for CSS Illustrations. 4 | 5 | Saves to [localStorage](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage) and exports to [CodePen](https://codepen.io/). 6 | 7 | Live app: https://zerodivs.com 8 | 9 | More information: https://perals.io/projects/zerodivs/ 10 | 11 | ![Animated Gif showing ZeroDivs in action. The word "CSS" is created by dragging and editing shapes into a canvas](./zerodivs-sample-03-x10.gif) 12 | 13 | # Basic usage 14 | 15 | The app follows usual UI/UX patterns for vector graphics drawing tools, such as drag-and-drop, layers, or color picking. Zooming is done by scrolling with the trackpad or mouse. The following keyboard shortucts can also be used: 16 | 17 | | Key | Function | 18 | | - | - | 19 | | a | Select all shapes | 20 | | d | Duplicate shapes | 21 | | Shift | Keep pressed to select multiple shapes | 22 | | Esc | Unselect shapes | 23 | | Backspace | Remove shapes | 24 | | Delete | Remove shapes | 25 | | Arrow keys | Move shapes by 1px | 26 | | Meta+c | Copy shapes | 27 | | Meta+x | Cut shapes | 28 | | Meta+v | Paste shapes | 29 | | Meta+z | Undo | 30 | | Meta+Shift+z | Redo | 31 | 32 | ## Local project setup 33 | ``` 34 | npm install 35 | ``` 36 | 37 | ### Compiles and hot-reloads for development 38 | ``` 39 | npm run serve 40 | ``` 41 | 42 | ### Compiles and minifies for production 43 | ``` 44 | npm run build 45 | ``` 46 | 47 | ### Run your unit tests 48 | ``` 49 | npm run test:unit 50 | ``` 51 | 52 | ### Lints and fixes files 53 | ``` 54 | npm run lint 55 | ``` 56 | 57 | ### Customize configuration 58 | See [Configuration Reference](https://cli.vuejs.org/config/). 59 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ["@vue/cli-plugin-babel/preset"] 3 | }; 4 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | zerodivs.com 9 | 10 | 11 | 12 | 18 |
19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: "@vue/cli-plugin-unit-jest", 3 | testMatch: ["**/__tests__/**/*.[jt]s?(x)", "**/?(*.)+(spec|test).[jt]s?(x)"] 4 | }; 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zerodivs", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "vite", 7 | "build": "vite build", 8 | "serve": "vite preview", 9 | "lint": "eslint --ext .js,.vue --ignore-path .gitignore --fix src" 10 | }, 11 | "dependencies": { 12 | "@ckpack/vue-color": "^1.2.0", 13 | "@codemirror/lang-css": "^6.0.0", 14 | "@codemirror/lang-json": "^6.0.0", 15 | "@codemirror/theme-one-dark": "^6.1.0", 16 | "@vitejs/plugin-vue": "^3.2.0", 17 | "codemirror": "^6.0.1", 18 | "convert-css-color-name-to-hex": "^0.1.1", 19 | "lodash": "^4.17.21", 20 | "uuid": "^3.3.3", 21 | "validate-color": "^1.0.8", 22 | "vite": "^3.2.3", 23 | "vue": "^3.2.40", 24 | "vue-codemirror": "^6.1.1", 25 | "vue-router": "^4.0.0", 26 | "vue3-clipboard": "^1.0.0", 27 | "vuex": "^4.0.0" 28 | }, 29 | "devDependencies": { 30 | "@vue/compiler-sfc": "^3.1.0", 31 | "@vue/eslint-config-prettier": "^5.0.0", 32 | "eslint": "^8.27.0", 33 | "eslint-plugin-prettier": "^3.1.1", 34 | "eslint-plugin-vue": "^8.7.1", 35 | "node-sass": "^4.14.1", 36 | "pinch-zoom-element": "^1.1.1", 37 | "prettier": "^1.19.1", 38 | "sass": "^1.56.0" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /public/CNAME: -------------------------------------------------------------------------------- 1 | zerodivs.com -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jperals/zerodivs/d272e296d7384d3a5ec172c54264aa28382bfa30/public/favicon.ico -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jperals/zerodivs/d272e296d7384d3a5ec172c54264aa28382bfa30/public/favicon.png -------------------------------------------------------------------------------- /publish_to_gh_pages.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # abort on errors 4 | set -e 5 | 6 | # build 7 | npm run build 8 | 9 | # navigate into the build output directory 10 | cd dist 11 | 12 | # if you are deploying to a custom domain 13 | # echo 'www.example.com' > CNAME 14 | 15 | git init 16 | git add -A 17 | git commit -m 'deploy' 18 | 19 | # if you are deploying to https://.github.io/ 20 | git push -f git@github.com:jperals/deepdiv.git master:gh-pages 21 | 22 | cd - -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 22 | 23 | 212 | -------------------------------------------------------------------------------- /src/about-page/AboutPage.vue: -------------------------------------------------------------------------------- 1 | 90 | 91 | 106 | -------------------------------------------------------------------------------- /src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jperals/zerodivs/d272e296d7384d3a5ec172c54264aa28382bfa30/src/assets/logo.png -------------------------------------------------------------------------------- /src/common/geometry.js: -------------------------------------------------------------------------------- 1 | export function transformCoords({ x, y, viewportTransform }) { 2 | return { 3 | x: (x - viewportTransform.x) / viewportTransform.scale, 4 | y: (y - viewportTransform.y) / viewportTransform.scale 5 | }; 6 | } 7 | -------------------------------------------------------------------------------- /src/common/layers2css.js: -------------------------------------------------------------------------------- 1 | import shapes2css from "@/common/shapes2css"; 2 | 3 | export default function layers2css({ layers, selector = "body" }) { 4 | let cssStr = `html { 5 | height: 100%; 6 | width: 100%; 7 | } 8 | `; 9 | for (const layerName of ["main", "before", "after"]) { 10 | if (isLayerActive({ layerName, layers })) { 11 | cssStr += layerName === "main" ? selector : selector + ":" + layerName; 12 | cssStr += " {\n"; 13 | const customStyle = layerExtraStyles({ layerName, layers }); 14 | cssStr += customStyle; 15 | const shapes = getLayerShapes({ layerName, layers }); 16 | cssStr += shapes2css(shapes, " "); 17 | cssStr += "}\n"; 18 | } 19 | } 20 | return cssStr; 21 | } 22 | 23 | function getLayerShapes({ layerName, layers }) { 24 | return layers[layerName].shapes; 25 | } 26 | 27 | function isLayerActive({ layerName, layers }) { 28 | return layers[layerName].active; 29 | } 30 | 31 | function layerExtraStyles({ layerName, layers }) { 32 | const rawString = layers[layerName].extraStyles || ""; 33 | return ( 34 | " " + 35 | rawString.replace(/\n[^$]/g, (match, char) => "\n " + rawString[char + 1]) 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /src/common/shapes2css.js: -------------------------------------------------------------------------------- 1 | export default function shapes2css(shapes, padding = "") { 2 | const reversed = [...shapes].reverse(); 3 | if (!reversed || reversed.length < 1) { 4 | return ``; 5 | } 6 | return `\ 7 | ${padding}background-image: ${formatImages(reversed)}; 8 | ${padding}background-position: ${formatPositions(reversed)}; 9 | ${padding}background-size: ${formatSizes(reversed)}; 10 | ${padding}background-repeat: ${formatRepeats(reversed)}; 11 | `; 12 | } 13 | 14 | function formatStop(stop) { 15 | return stop.position === undefined 16 | ? stop.color 17 | : [stop.color, stop.position].join(" "); 18 | } 19 | 20 | function formatStops(stops) { 21 | return stops.map(stop => formatStop(stop)).join(", "); 22 | } 23 | 24 | function formatImages(shapes) { 25 | return shapes.map(shape => formatImage(shape)).join(", "); 26 | } 27 | 28 | export function formatImage(shape) { 29 | const formattedStops = formatStops(shape.stops); 30 | const items = shape.direction 31 | ? [shape.direction, formattedStops] 32 | : [formattedStops]; 33 | return `${formatType(shape.type)}(${items.join(", ")})`; 34 | } 35 | 36 | function formatType(type) { 37 | return `${type}-gradient`; 38 | } 39 | 40 | function formatPositions(shapes) { 41 | return shapes.map(shape => formatPosition(shape)).join(", "); 42 | } 43 | 44 | function formatPosition(shape) { 45 | return `${shape.left.value}${shape.left.units} ${shape.top.value}${shape.top.units}`; 46 | } 47 | 48 | function formatSizes(shapes) { 49 | return shapes.map(shape => formatSize(shape)).join(", "); 50 | } 51 | 52 | function formatSize(shape) { 53 | return `${shape.width.value}${shape.width.units} ${shape.height.value}${shape.height.units}`; 54 | } 55 | 56 | function formatRepeats(shapes) { 57 | if (!someRepeat(shapes)) { 58 | return "no-repeat"; 59 | } 60 | return shapes 61 | .map(shape => (isRepeat(shape.repeat) ? shape.repeat : "no-repeat")) 62 | .join(", "); 63 | } 64 | 65 | function someRepeat(shapes) { 66 | for (const shape of shapes) { 67 | if (isRepeat(shape.repeat)) { 68 | return true; 69 | } 70 | } 71 | return false; 72 | } 73 | 74 | function isRepeat(str) { 75 | return str && str !== "no-repeat"; 76 | } 77 | -------------------------------------------------------------------------------- /src/common/shapes2css.spec.js: -------------------------------------------------------------------------------- 1 | import shapes2css from "@/common/shapes2css"; 2 | 3 | describe("shapes2css", () => { 4 | it("Converts shapes to CSS", () => { 5 | const shapes = [ 6 | { 7 | width: { 8 | value: 200, 9 | units: "px" 10 | }, 11 | height: { 12 | value: 300, 13 | units: "px" 14 | }, 15 | top: { 16 | value: 300, 17 | units: "px" 18 | }, 19 | left: { 20 | value: 300, 21 | units: "px" 22 | }, 23 | type: "linear", 24 | direction: "230deg", 25 | stops: [ 26 | { 27 | color: "transparent" 28 | }, 29 | { 30 | color: "transparent", 31 | position: "15%" 32 | }, 33 | { 34 | color: "#e66465", 35 | position: "15%" 36 | }, 37 | { 38 | color: "#e66465", 39 | position: "65%" 40 | }, 41 | { 42 | color: "transparent", 43 | position: "65%" 44 | }, 45 | { 46 | color: "transparent" 47 | } 48 | ] 49 | }, 50 | { 51 | width: { 52 | value: 300, 53 | units: "px" 54 | }, 55 | height: { 56 | value: 300, 57 | units: "px" 58 | }, 59 | top: { 60 | value: 300, 61 | units: "px" 62 | }, 63 | left: { 64 | value: 700, 65 | units: "px" 66 | }, 67 | type: "radial", 68 | direction: "circle at bottom", 69 | stops: [ 70 | { 71 | color: "transparent" 72 | }, 73 | { color: "transparent", position: "15%" }, 74 | { 75 | color: "#e66465", 76 | position: "15%" 77 | }, 78 | { 79 | color: "#9198e5", 80 | position: "45%" 81 | }, 82 | { 83 | color: "transparent", 84 | position: "45%" 85 | }, 86 | { 87 | color: "transparent" 88 | } 89 | ] 90 | } 91 | ]; 92 | const expectedCss = `\ 93 | background-image: radial-gradient(circle at bottom, transparent, transparent 15%, #e66465 15%, #9198e5 45%, transparent 45%, transparent), linear-gradient(230deg, transparent, transparent 15%, #e66465 15%, #e66465 65%, transparent 65%, transparent); 94 | background-position: 700px 300px, 300px 300px; 95 | background-size: 300px 300px, 200px 300px; 96 | background-repeat: no-repeat; 97 | `; 98 | const computedCss = shapes2css(shapes); 99 | expect(computedCss).toEqual(expectedCss); 100 | }); 101 | }); 102 | -------------------------------------------------------------------------------- /src/common/ui.js: -------------------------------------------------------------------------------- 1 | export function isNotWriting(event) { 2 | return ( 3 | (event.target.tagName !== "INPUT" && event.target.tagName !== "TEXTAREA") || 4 | event.target.classList.contains("fake-focus") 5 | ); 6 | } 7 | -------------------------------------------------------------------------------- /src/common/utils.js: -------------------------------------------------------------------------------- 1 | export function deepCopy(object) { 2 | return JSON.parse(JSON.stringify(object)); 3 | } 4 | -------------------------------------------------------------------------------- /src/components/AddButton.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 49 | 50 | 56 | -------------------------------------------------------------------------------- /src/components/CloseButton.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 12 | 13 | 49 | -------------------------------------------------------------------------------- /src/components/ColorPicker.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 177 | 178 | 246 | -------------------------------------------------------------------------------- /src/components/CopyButton/CopyButton.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 21 | 22 | 34 | -------------------------------------------------------------------------------- /src/components/CopyButton/copy-button-shapes.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "Rectangle", 4 | "type": "linear", 5 | "direction": "to bottom", 6 | "stops": [ 7 | { 8 | "color": "#9e9e9e", 9 | "position": "100%", 10 | "id": "db9b8482-a515-11ea-aee9-d1a64d00b111" 11 | }, 12 | { 13 | "color": "#9e9e9e", 14 | "position": "" 15 | } 16 | ], 17 | "width": { 18 | "units": "px", 19 | "value": 7 20 | }, 21 | "height": { 22 | "units": "px", 23 | "value": 2 24 | }, 25 | "left": { 26 | "units": "px", 27 | "value": 8 28 | }, 29 | "top": { 30 | "units": "px", 31 | "value": 5 32 | }, 33 | "id": "db9b8480-a515-11ea-aee9-d1a64d00b111" 34 | }, 35 | { 36 | "name": "Rectangle", 37 | "type": "linear", 38 | "direction": "to bottom", 39 | "stops": [ 40 | { 41 | "color": "#9e9e9e", 42 | "position": "100%", 43 | "id": "401960d0-a516-11ea-aee9-d1a64d00b111" 44 | }, 45 | { 46 | "color": "#9e9e9e", 47 | "position": "", 48 | "id": "401960d1-a516-11ea-aee9-d1a64d00b111" 49 | } 50 | ], 51 | "width": { 52 | "units": "px", 53 | "value": 7 54 | }, 55 | "height": { 56 | "units": "px", 57 | "value": 2 58 | }, 59 | "left": { 60 | "units": "px", 61 | "value": 12 62 | }, 63 | "top": { 64 | "units": "px", 65 | "value": 9 66 | }, 67 | "id": "401939c1-a516-11ea-aee9-d1a64d00b111" 68 | }, 69 | { 70 | "name": "Rectangle", 71 | "type": "linear", 72 | "direction": "to bottom", 73 | "stops": [ 74 | { 75 | "color": "#9e9e9e", 76 | "position": "100%", 77 | "id": "43401c42-a516-11ea-aee9-d1a64d00b111" 78 | }, 79 | { 80 | "color": "#9e9e9e", 81 | "position": "", 82 | "id": "43401c43-a516-11ea-aee9-d1a64d00b111" 83 | } 84 | ], 85 | "width": { 86 | "units": "px", 87 | "value": 2 88 | }, 89 | "height": { 90 | "units": "px", 91 | "value": 11 92 | }, 93 | "left": { 94 | "units": "px", 95 | "value": 5 96 | }, 97 | "top": { 98 | "units": "px", 99 | "value": 8 100 | }, 101 | "id": "43401c41-a516-11ea-aee9-d1a64d00b111" 102 | }, 103 | { 104 | "name": "Rectangle", 105 | "type": "linear", 106 | "direction": "to bottom", 107 | "stops": [ 108 | { 109 | "color": "#9e9e9e", 110 | "position": "100%", 111 | "id": "4c3a1582-a516-11ea-aee9-d1a64d00b111" 112 | }, 113 | { 114 | "color": "#9e9e9e", 115 | "position": "", 116 | "id": "4c3a1583-a516-11ea-aee9-d1a64d00b111" 117 | } 118 | ], 119 | "width": { 120 | "units": "px", 121 | "value": 2 122 | }, 123 | "height": { 124 | "units": "px", 125 | "value": 11 126 | }, 127 | "left": { 128 | "units": "px", 129 | "value": 9 130 | }, 131 | "top": { 132 | "units": "px", 133 | "value": 12 134 | }, 135 | "id": "4c3a1581-a516-11ea-aee9-d1a64d00b111" 136 | }, 137 | { 138 | "name": "Rectangle", 139 | "type": "linear", 140 | "direction": "to bottom", 141 | "stops": [ 142 | { 143 | "color": "#9e9e9e", 144 | "position": "100%", 145 | "id": "586662f2-a516-11ea-aee9-d1a64d00b111" 146 | }, 147 | { 148 | "color": "#9e9e9e", 149 | "position": "", 150 | "id": "586662f3-a516-11ea-aee9-d1a64d00b111" 151 | } 152 | ], 153 | "width": { 154 | "units": "px", 155 | "value": 2 156 | }, 157 | "height": { 158 | "units": "px", 159 | "value": 11 160 | }, 161 | "left": { 162 | "units": "px", 163 | "value": 20 164 | }, 165 | "top": { 166 | "units": "px", 167 | "value": 12 168 | }, 169 | "id": "586662f1-a516-11ea-aee9-d1a64d00b111" 170 | }, 171 | { 172 | "name": "Bottom left arc", 173 | "type": "radial", 174 | "direction": "at top right", 175 | "stops": [ 176 | { 177 | "color": "transparent", 178 | "position": "1px", 179 | "id": "6a1cfe51-a516-11ea-aee9-d1a64d00b111" 180 | }, 181 | { 182 | "color": "#9e9e9e", 183 | "position": "1px", 184 | "id": "6a1cfe52-a516-11ea-aee9-d1a64d00b111" 185 | }, 186 | { 187 | "color": "#9e9e9e", 188 | "position": "3px", 189 | "id": "6a1cfe53-a516-11ea-aee9-d1a64d00b111" 190 | }, 191 | { 192 | "color": "transparent", 193 | "position": "3px", 194 | "id": "6a1cfe54-a516-11ea-aee9-d1a64d00b111" 195 | } 196 | ], 197 | "repeat": "no-repeat", 198 | "width": { 199 | "units": "px", 200 | "value": 3 201 | }, 202 | "height": { 203 | "units": "px", 204 | "value": 3 205 | }, 206 | "left": { 207 | "units": "px", 208 | "value": 9 209 | }, 210 | "top": { 211 | "units": "px", 212 | "value": 23 213 | }, 214 | "id": "6a1cfe50-a516-11ea-aee9-d1a64d00b111" 215 | }, 216 | { 217 | "name": "Rectangle", 218 | "type": "linear", 219 | "direction": "to bottom", 220 | "stops": [ 221 | { 222 | "color": "#9e9e9e", 223 | "position": "100%", 224 | "id": "6c1b6662-a516-11ea-aee9-d1a64d00b111" 225 | }, 226 | { 227 | "color": "#9e9e9e", 228 | "position": "", 229 | "id": "6c1b6663-a516-11ea-aee9-d1a64d00b111" 230 | } 231 | ], 232 | "width": { 233 | "units": "px", 234 | "value": 7 235 | }, 236 | "height": { 237 | "units": "px", 238 | "value": 2 239 | }, 240 | "left": { 241 | "units": "px", 242 | "value": 12 243 | }, 244 | "top": { 245 | "units": "px", 246 | "value": 24 247 | }, 248 | "id": "6c1b6661-a516-11ea-aee9-d1a64d00b111" 249 | }, 250 | { 251 | "name": "Bottom left arc", 252 | "type": "radial", 253 | "direction": "at top left", 254 | "stops": [ 255 | { 256 | "color": "transparent", 257 | "position": "1px", 258 | "id": "cf9f2b41-a516-11ea-aee9-d1a64d00b111" 259 | }, 260 | { 261 | "color": "#9e9e9e", 262 | "position": "1px", 263 | "id": "cf9f2b42-a516-11ea-aee9-d1a64d00b111" 264 | }, 265 | { 266 | "color": "#9e9e9e", 267 | "position": "3px", 268 | "id": "cf9f2b43-a516-11ea-aee9-d1a64d00b111" 269 | }, 270 | { 271 | "color": "transparent", 272 | "position": "3px", 273 | "id": "cf9f2b44-a516-11ea-aee9-d1a64d00b111" 274 | } 275 | ], 276 | "repeat": "no-repeat", 277 | "width": { 278 | "units": "px", 279 | "value": 3 280 | }, 281 | "height": { 282 | "units": "px", 283 | "value": 3 284 | }, 285 | "left": { 286 | "units": "px", 287 | "value": 19 288 | }, 289 | "top": { 290 | "units": "px", 291 | "value": 23 292 | }, 293 | "id": "cf9f2b40-a516-11ea-aee9-d1a64d00b111" 294 | }, 295 | { 296 | "name": "Bottom left arc", 297 | "type": "radial", 298 | "direction": "at bottom left", 299 | "stops": [ 300 | { 301 | "color": "transparent", 302 | "position": "1px", 303 | "id": "d9c1c601-a516-11ea-aee9-d1a64d00b111" 304 | }, 305 | { 306 | "color": "#9e9e9e", 307 | "position": "1px", 308 | "id": "d9c1c602-a516-11ea-aee9-d1a64d00b111" 309 | }, 310 | { 311 | "color": "#9e9e9e", 312 | "position": "3px", 313 | "id": "d9c1c603-a516-11ea-aee9-d1a64d00b111" 314 | }, 315 | { 316 | "color": "transparent", 317 | "position": "3px", 318 | "id": "d9c1c604-a516-11ea-aee9-d1a64d00b111" 319 | } 320 | ], 321 | "repeat": "no-repeat", 322 | "width": { 323 | "units": "px", 324 | "value": 3 325 | }, 326 | "height": { 327 | "units": "px", 328 | "value": 3 329 | }, 330 | "left": { 331 | "units": "px", 332 | "value": 19 333 | }, 334 | "top": { 335 | "units": "px", 336 | "value": 9 337 | }, 338 | "id": "d9c1c600-a516-11ea-aee9-d1a64d00b111" 339 | }, 340 | { 341 | "name": "Bottom left arc", 342 | "type": "radial", 343 | "direction": "at bottom right", 344 | "stops": [ 345 | { 346 | "color": "transparent", 347 | "position": "1px", 348 | "id": "e4e248c2-a516-11ea-aee9-d1a64d00b111" 349 | }, 350 | { 351 | "color": "#9e9e9e", 352 | "position": "1px", 353 | "id": "e4e248c3-a516-11ea-aee9-d1a64d00b111" 354 | }, 355 | { 356 | "color": "#9e9e9e", 357 | "position": "3px", 358 | "id": "e4e248c4-a516-11ea-aee9-d1a64d00b111" 359 | }, 360 | { 361 | "color": "transparent", 362 | "position": "3px", 363 | "id": "e4e248c5-a516-11ea-aee9-d1a64d00b111" 364 | } 365 | ], 366 | "repeat": "no-repeat", 367 | "width": { 368 | "units": "px", 369 | "value": 3 370 | }, 371 | "height": { 372 | "units": "px", 373 | "value": 3 374 | }, 375 | "left": { 376 | "units": "px", 377 | "value": 9 378 | }, 379 | "top": { 380 | "units": "px", 381 | "value": 9 382 | }, 383 | "id": "e4e248c1-a516-11ea-aee9-d1a64d00b111" 384 | }, 385 | { 386 | "name": "Bottom left arc", 387 | "type": "radial", 388 | "direction": "at bottom right", 389 | "stops": [ 390 | { 391 | "color": "transparent", 392 | "position": "1px", 393 | "id": "eef30932-a516-11ea-aee9-d1a64d00b111" 394 | }, 395 | { 396 | "color": "#9e9e9e", 397 | "position": "1px", 398 | "id": "eef30933-a516-11ea-aee9-d1a64d00b111" 399 | }, 400 | { 401 | "color": "#9e9e9e", 402 | "position": "3px", 403 | "id": "eef30934-a516-11ea-aee9-d1a64d00b111" 404 | }, 405 | { 406 | "color": "transparent", 407 | "position": "3px", 408 | "id": "eef30935-a516-11ea-aee9-d1a64d00b111" 409 | } 410 | ], 411 | "repeat": "no-repeat", 412 | "width": { 413 | "units": "px", 414 | "value": 3 415 | }, 416 | "height": { 417 | "units": "px", 418 | "value": 3 419 | }, 420 | "left": { 421 | "units": "px", 422 | "value": 5 423 | }, 424 | "top": { 425 | "units": "px", 426 | "value": 5 427 | }, 428 | "id": "eef30931-a516-11ea-aee9-d1a64d00b111" 429 | }, 430 | { 431 | "name": "Bottom left arc", 432 | "type": "radial", 433 | "direction": "at top right", 434 | "stops": [ 435 | { 436 | "color": "transparent", 437 | "position": "1px", 438 | "id": "242644a2-a517-11ea-aee9-d1a64d00b111" 439 | }, 440 | { 441 | "color": "#9e9e9e", 442 | "position": "1px", 443 | "id": "242644a3-a517-11ea-aee9-d1a64d00b111" 444 | }, 445 | { 446 | "color": "#9e9e9e", 447 | "position": "3px", 448 | "id": "242644a4-a517-11ea-aee9-d1a64d00b111" 449 | }, 450 | { 451 | "color": "transparent", 452 | "position": "3px", 453 | "id": "242644a5-a517-11ea-aee9-d1a64d00b111" 454 | } 455 | ], 456 | "repeat": "no-repeat", 457 | "width": { 458 | "units": "px", 459 | "value": 3 460 | }, 461 | "height": { 462 | "units": "px", 463 | "value": 3 464 | }, 465 | "left": { 466 | "units": "px", 467 | "value": 5 468 | }, 469 | "top": { 470 | "units": "px", 471 | "value": 19 472 | }, 473 | "id": "242644a1-a517-11ea-aee9-d1a64d00b111" 474 | }, 475 | { 476 | "name": "Bottom left arc", 477 | "type": "radial", 478 | "direction": "at bottom left", 479 | "stops": [ 480 | { 481 | "color": "transparent", 482 | "position": "1px", 483 | "id": "2bbf4e52-a517-11ea-aee9-d1a64d00b111" 484 | }, 485 | { 486 | "color": "#9e9e9e", 487 | "position": "1px", 488 | "id": "2bbf4e53-a517-11ea-aee9-d1a64d00b111" 489 | }, 490 | { 491 | "color": "#9e9e9e", 492 | "position": "3px", 493 | "id": "2bbf4e54-a517-11ea-aee9-d1a64d00b111" 494 | }, 495 | { 496 | "color": "transparent", 497 | "position": "3px", 498 | "id": "2bbf4e55-a517-11ea-aee9-d1a64d00b111" 499 | } 500 | ], 501 | "repeat": "no-repeat", 502 | "width": { 503 | "units": "px", 504 | "value": 3 505 | }, 506 | "height": { 507 | "units": "px", 508 | "value": 3 509 | }, 510 | "left": { 511 | "units": "px", 512 | "value": 15 513 | }, 514 | "top": { 515 | "units": "px", 516 | "value": 5 517 | }, 518 | "id": "2bbf4e51-a517-11ea-aee9-d1a64d00b111" 519 | } 520 | ] 521 | -------------------------------------------------------------------------------- /src/components/CornerCloseButton.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 12 | 13 | 62 | -------------------------------------------------------------------------------- /src/components/CssOutput.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 49 | -------------------------------------------------------------------------------- /src/components/DraggableListMixin.js: -------------------------------------------------------------------------------- 1 | import { isNotWriting } from "@/common/ui"; 2 | import store from "@/store"; 3 | export default { 4 | data() { 5 | return { 6 | elementHeight: null, 7 | elementIndex: null, 8 | initialMousePosition: null, 9 | offset: null, 10 | itemBeingDragged: null, 11 | indexMovedDown: null, 12 | indexMovedUp: null 13 | }; 14 | }, 15 | methods: { 16 | isItemMovedDown(index) { 17 | return this.indexMovedDown === index; 18 | }, 19 | isItemMovedUp(index) { 20 | return this.indexMovedUp === index; 21 | }, 22 | onItemMouseDown(index, event) { 23 | if (isNotWriting(event)) { 24 | const listItem = event.target.closest("li.draggable"); 25 | if (listItem) { 26 | this.elementHeight = parseInt( 27 | listItem.getBoundingClientRect().height 28 | ); 29 | const item = this.items[index]; 30 | this.itemBeingDragged = item; 31 | this.initialMousePosition = event.y; 32 | this.elementIndex = index; 33 | } 34 | } 35 | }, 36 | async onItemMouseMove(event) { 37 | if (this.itemBeingDragged) { 38 | event.preventDefault(); 39 | this.offset = event.y - this.initialMousePosition; 40 | while ( 41 | this.elementHeight / 2 < this.offset && 42 | this.elementIndex < this.items.length - 1 43 | ) { 44 | await this.swapItems(this.elementIndex, this.elementIndex + 1); 45 | this.initialMousePosition += this.elementHeight; 46 | this.offset -= this.elementHeight; 47 | this.indexMovedDown = null; 48 | this.indexMovedUp = this.elementIndex; 49 | this.elementIndex += 1; 50 | } 51 | while (this.offset < -this.elementHeight / 2 && 0 < this.elementIndex) { 52 | this.swapItems(this.elementIndex, this.elementIndex - 1); 53 | this.initialMousePosition -= this.elementHeight; 54 | this.offset += this.elementHeight; 55 | this.indexMovedUp = null; 56 | this.indexMovedDown = this.elementIndex; 57 | this.elementIndex -= 1; 58 | } 59 | } 60 | }, 61 | onItemMouseUp() { 62 | this.itemBeingDragged = null; 63 | this.initialMousePosition = null; 64 | this.offset = null; 65 | store.dispatch("commitChange"); 66 | }, 67 | listItemStyle(item) { 68 | if (item === this.itemBeingDragged && this.offset !== null) { 69 | return { 70 | position: "relative", 71 | transform: `translateY(${this.offset}px)`, 72 | zIndex: 10 73 | }; 74 | } else { 75 | return {}; 76 | } 77 | } 78 | } 79 | }; 80 | -------------------------------------------------------------------------------- /src/components/EdgeCloseButton.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 12 | 13 | 59 | -------------------------------------------------------------------------------- /src/components/ExportToCodePenButton/ExportToCodePenButton.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 40 | 41 | 65 | -------------------------------------------------------------------------------- /src/components/ExportToCodePenButton/export-to-codepen-button-shape.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "Cropped stripe, bottom right to top left", 4 | "type": "linear", 5 | "direction": "to top left", 6 | "stops": [ 7 | { 8 | "color": "transparent", 9 | "id": "12baa591-6709-11ea-9f06-cd94d7526e2a" 10 | }, 11 | { 12 | "color": "transparent", 13 | "position": "43%", 14 | "id": "12baa592-6709-11ea-9f06-cd94d7526e2a" 15 | }, 16 | { 17 | "color": "white", 18 | "position": "43%", 19 | "id": "12baa593-6709-11ea-9f06-cd94d7526e2a" 20 | }, 21 | { 22 | "color": "white", 23 | "position": "57%", 24 | "id": "12baa594-6709-11ea-9f06-cd94d7526e2a" 25 | }, 26 | { 27 | "color": "transparent", 28 | "position": "57%", 29 | "id": "12baa595-6709-11ea-9f06-cd94d7526e2a" 30 | } 31 | ], 32 | "width": { 33 | "units": "px", 34 | "value": 13 35 | }, 36 | "height": { 37 | "units": "px", 38 | "value": 8 39 | }, 40 | "left": { 41 | "units": "px", 42 | "value": 0 43 | }, 44 | "top": { 45 | "units": "px", 46 | "value": 0 47 | }, 48 | "id": "12baa590-6709-11ea-9f06-cd94d7526e2a" 49 | }, 50 | { 51 | "name": "Rectangle", 52 | "type": "linear", 53 | "direction": "to bottom", 54 | "stops": [ 55 | { 56 | "color": "white", 57 | "position": "0%", 58 | "id": "b1446e80-6709-11ea-9f06-cd94d7526e2a" 59 | }, 60 | { 61 | "color": "white", 62 | "position": "100%", 63 | "id": "b1446e81-6709-11ea-9f06-cd94d7526e2a" 64 | } 65 | ], 66 | "width": { 67 | "units": "px", 68 | "value": 2 69 | }, 70 | "height": { 71 | "units": "px", 72 | "value": 5 73 | }, 74 | "left": { 75 | "units": "px", 76 | "value": 11 77 | }, 78 | "top": { 79 | "units": "px", 80 | "value": 2 81 | }, 82 | "id": "b1444770-6709-11ea-9f06-cd94d7526e2a" 83 | }, 84 | { 85 | "name": "Cropped stripe, bottom right to top left", 86 | "type": "linear", 87 | "direction": "to top right", 88 | "stops": [ 89 | { 90 | "color": "transparent", 91 | "id": "dc7a06e1-670a-11ea-9f06-cd94d7526e2a" 92 | }, 93 | { 94 | "color": "transparent", 95 | "position": "43%", 96 | "id": "dc7a06e2-670a-11ea-9f06-cd94d7526e2a" 97 | }, 98 | { 99 | "color": "white", 100 | "position": "43%", 101 | "id": "dc7a06e3-670a-11ea-9f06-cd94d7526e2a" 102 | }, 103 | { 104 | "color": "white", 105 | "position": "57%", 106 | "id": "dc7a06e4-670a-11ea-9f06-cd94d7526e2a" 107 | }, 108 | { 109 | "color": "transparent", 110 | "position": "57%", 111 | "id": "dc7a06e5-670a-11ea-9f06-cd94d7526e2a" 112 | } 113 | ], 114 | "width": { 115 | "units": "px", 116 | "value": 13 117 | }, 118 | "height": { 119 | "units": "px", 120 | "value": 8 121 | }, 122 | "left": { 123 | "units": "px", 124 | "value": 11 125 | }, 126 | "top": { 127 | "units": "px", 128 | "value": 0 129 | }, 130 | "id": "dc7a06e0-670a-11ea-9f06-cd94d7526e2a" 131 | }, 132 | { 133 | "name": "Rectangle", 134 | "type": "linear", 135 | "direction": "to bottom", 136 | "stops": [ 137 | { 138 | "color": "white", 139 | "position": "0%", 140 | "id": "0d5ea8b0-670b-11ea-9f06-cd94d7526e2a" 141 | }, 142 | { 143 | "color": "white", 144 | "position": "100%", 145 | "id": "0d5ea8b1-670b-11ea-9f06-cd94d7526e2a" 146 | } 147 | ], 148 | "width": { 149 | "units": "px", 150 | "value": 2 151 | }, 152 | "height": { 153 | "units": "px", 154 | "value": 7 155 | }, 156 | "left": { 157 | "units": "px", 158 | "value": 0 159 | }, 160 | "top": { 161 | "units": "px", 162 | "value": 8 163 | }, 164 | "id": "0d5e81a1-670b-11ea-9f06-cd94d7526e2a" 165 | }, 166 | { 167 | "name": "Rectangle", 168 | "type": "linear", 169 | "direction": "to bottom", 170 | "stops": [ 171 | { 172 | "color": "white", 173 | "position": "0%", 174 | "id": "138e8572-670b-11ea-9f06-cd94d7526e2a" 175 | }, 176 | { 177 | "color": "white", 178 | "position": "100%", 179 | "id": "138e8573-670b-11ea-9f06-cd94d7526e2a" 180 | } 181 | ], 182 | "width": { 183 | "units": "px", 184 | "value": 2 185 | }, 186 | "height": { 187 | "units": "px", 188 | "value": 6 189 | }, 190 | "left": { 191 | "units": "px", 192 | "value": 22 193 | }, 194 | "top": { 195 | "units": "px", 196 | "value": 8 197 | }, 198 | "id": "138e8571-670b-11ea-9f06-cd94d7526e2a" 199 | }, 200 | { 201 | "name": "Cropped stripe, bottom right to top left", 202 | "type": "linear", 203 | "direction": "to top right", 204 | "stops": [ 205 | { 206 | "color": "transparent", 207 | "id": "2d684211-670b-11ea-9f06-cd94d7526e2a" 208 | }, 209 | { 210 | "color": "transparent", 211 | "position": "43%", 212 | "id": "2d684212-670b-11ea-9f06-cd94d7526e2a" 213 | }, 214 | { 215 | "color": "white", 216 | "position": "43%", 217 | "id": "2d684213-670b-11ea-9f06-cd94d7526e2a" 218 | }, 219 | { 220 | "color": "white", 221 | "position": "57%", 222 | "id": "2d684214-670b-11ea-9f06-cd94d7526e2a" 223 | }, 224 | { 225 | "color": "transparent", 226 | "position": "57%", 227 | "id": "2d684215-670b-11ea-9f06-cd94d7526e2a" 228 | } 229 | ], 230 | "width": { 231 | "units": "px", 232 | "value": 13 233 | }, 234 | "height": { 235 | "units": "px", 236 | "value": 8 237 | }, 238 | "left": { 239 | "units": "px", 240 | "value": 11 241 | }, 242 | "top": { 243 | "units": "px", 244 | "value": 7 245 | }, 246 | "id": "2d684210-670b-11ea-9f06-cd94d7526e2a" 247 | }, 248 | { 249 | "name": "Cropped stripe, bottom right to top left", 250 | "type": "linear", 251 | "direction": "to top left", 252 | "stops": [ 253 | { 254 | "color": "transparent", 255 | "id": "2ff1ac12-670b-11ea-9f06-cd94d7526e2a" 256 | }, 257 | { 258 | "color": "transparent", 259 | "position": "43%", 260 | "id": "2ff1ac13-670b-11ea-9f06-cd94d7526e2a" 261 | }, 262 | { 263 | "color": "white", 264 | "position": "43%", 265 | "id": "2ff1ac14-670b-11ea-9f06-cd94d7526e2a" 266 | }, 267 | { 268 | "color": "white", 269 | "position": "57%", 270 | "id": "2ff1ac15-670b-11ea-9f06-cd94d7526e2a" 271 | }, 272 | { 273 | "color": "transparent", 274 | "position": "57%", 275 | "id": "2ff1ac16-670b-11ea-9f06-cd94d7526e2a" 276 | } 277 | ], 278 | "width": { 279 | "units": "px", 280 | "value": 13 281 | }, 282 | "height": { 283 | "units": "px", 284 | "value": 8 285 | }, 286 | "left": { 287 | "units": "px", 288 | "value": 0 289 | }, 290 | "top": { 291 | "units": "px", 292 | "value": 7 293 | }, 294 | "id": "2ff1ac11-670b-11ea-9f06-cd94d7526e2a" 295 | }, 296 | { 297 | "name": "Cropped stripe, bottom right to top left", 298 | "type": "linear", 299 | "direction": "to top right", 300 | "stops": [ 301 | { 302 | "color": "transparent", 303 | "id": "4447d182-670b-11ea-9f06-cd94d7526e2a" 304 | }, 305 | { 306 | "color": "transparent", 307 | "position": "43%", 308 | "id": "4447d183-670b-11ea-9f06-cd94d7526e2a" 309 | }, 310 | { 311 | "color": "white", 312 | "position": "43%", 313 | "id": "4447d184-670b-11ea-9f06-cd94d7526e2a" 314 | }, 315 | { 316 | "color": "white", 317 | "position": "57%", 318 | "id": "4447d185-670b-11ea-9f06-cd94d7526e2a" 319 | }, 320 | { 321 | "color": "transparent", 322 | "position": "57%", 323 | "id": "4447d186-670b-11ea-9f06-cd94d7526e2a" 324 | } 325 | ], 326 | "width": { 327 | "units": "px", 328 | "value": 13 329 | }, 330 | "height": { 331 | "units": "px", 332 | "value": 8 333 | }, 334 | "left": { 335 | "units": "px", 336 | "value": 0 337 | }, 338 | "top": { 339 | "units": "px", 340 | "value": 7 341 | }, 342 | "id": "4447d181-670b-11ea-9f06-cd94d7526e2a" 343 | }, 344 | { 345 | "name": "Cropped stripe, bottom right to top left", 346 | "type": "linear", 347 | "direction": "to top left", 348 | "stops": [ 349 | { 350 | "color": "transparent", 351 | "id": "6a1406e2-670b-11ea-9f06-cd94d7526e2a" 352 | }, 353 | { 354 | "color": "transparent", 355 | "position": "43%", 356 | "id": "6a1406e3-670b-11ea-9f06-cd94d7526e2a" 357 | }, 358 | { 359 | "color": "white", 360 | "position": "43%", 361 | "id": "6a1406e4-670b-11ea-9f06-cd94d7526e2a" 362 | }, 363 | { 364 | "color": "white", 365 | "position": "57%", 366 | "id": "6a1406e5-670b-11ea-9f06-cd94d7526e2a" 367 | }, 368 | { 369 | "color": "transparent", 370 | "position": "57%", 371 | "id": "6a1406e6-670b-11ea-9f06-cd94d7526e2a" 372 | } 373 | ], 374 | "width": { 375 | "units": "px", 376 | "value": 13 377 | }, 378 | "height": { 379 | "units": "px", 380 | "value": 8 381 | }, 382 | "left": { 383 | "units": "px", 384 | "value": 11 385 | }, 386 | "top": { 387 | "units": "px", 388 | "value": 7 389 | }, 390 | "id": "6a1406e1-670b-11ea-9f06-cd94d7526e2a" 391 | }, 392 | { 393 | "name": "Cropped stripe, bottom right to top left", 394 | "type": "linear", 395 | "direction": "to top left", 396 | "stops": [ 397 | { 398 | "color": "transparent", 399 | "id": "6d2c4361-670b-11ea-9f06-cd94d7526e2a" 400 | }, 401 | { 402 | "color": "transparent", 403 | "position": "43%", 404 | "id": "6d2c4362-670b-11ea-9f06-cd94d7526e2a" 405 | }, 406 | { 407 | "color": "white", 408 | "position": "43%", 409 | "id": "6d2c4363-670b-11ea-9f06-cd94d7526e2a" 410 | }, 411 | { 412 | "color": "white", 413 | "position": "57%", 414 | "id": "6d2c4364-670b-11ea-9f06-cd94d7526e2a" 415 | }, 416 | { 417 | "color": "transparent", 418 | "position": "57%", 419 | "id": "6d2c4365-670b-11ea-9f06-cd94d7526e2a" 420 | } 421 | ], 422 | "width": { 423 | "units": "px", 424 | "value": 13 425 | }, 426 | "height": { 427 | "units": "px", 428 | "value": 8 429 | }, 430 | "left": { 431 | "units": "px", 432 | "value": 11 433 | }, 434 | "top": { 435 | "units": "px", 436 | "value": 15 437 | }, 438 | "id": "6d2c4360-670b-11ea-9f06-cd94d7526e2a" 439 | }, 440 | { 441 | "name": "Cropped stripe, bottom right to top left", 442 | "type": "linear", 443 | "direction": "to top right", 444 | "stops": [ 445 | { 446 | "color": "transparent", 447 | "id": "705ee5b2-670b-11ea-9f06-cd94d7526e2a" 448 | }, 449 | { 450 | "color": "transparent", 451 | "position": "43%", 452 | "id": "705ee5b3-670b-11ea-9f06-cd94d7526e2a" 453 | }, 454 | { 455 | "color": "white", 456 | "position": "43%", 457 | "id": "705ee5b4-670b-11ea-9f06-cd94d7526e2a" 458 | }, 459 | { 460 | "color": "white", 461 | "position": "57%", 462 | "id": "705ee5b5-670b-11ea-9f06-cd94d7526e2a" 463 | }, 464 | { 465 | "color": "transparent", 466 | "position": "57%", 467 | "id": "705ee5b6-670b-11ea-9f06-cd94d7526e2a" 468 | } 469 | ], 470 | "width": { 471 | "units": "px", 472 | "value": 13 473 | }, 474 | "height": { 475 | "units": "px", 476 | "value": 8 477 | }, 478 | "left": { 479 | "units": "px", 480 | "value": 0 481 | }, 482 | "top": { 483 | "units": "px", 484 | "value": 15 485 | }, 486 | "id": "705ee5b1-670b-11ea-9f06-cd94d7526e2a" 487 | }, 488 | { 489 | "name": "Rectangle", 490 | "type": "linear", 491 | "direction": "to bottom", 492 | "stops": [ 493 | { 494 | "color": "white", 495 | "position": "0%", 496 | "id": "7719eb71-670b-11ea-9f06-cd94d7526e2a" 497 | }, 498 | { 499 | "color": "white", 500 | "position": "100%", 501 | "id": "7719eb72-670b-11ea-9f06-cd94d7526e2a" 502 | } 503 | ], 504 | "width": { 505 | "units": "px", 506 | "value": 2 507 | }, 508 | "height": { 509 | "units": "px", 510 | "value": 6 511 | }, 512 | "left": { 513 | "units": "px", 514 | "value": 11 515 | }, 516 | "top": { 517 | "units": "px", 518 | "value": 15 519 | }, 520 | "id": "7719eb70-670b-11ea-9f06-cd94d7526e2a" 521 | } 522 | ] -------------------------------------------------------------------------------- /src/components/HamburgerButton.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 24 | 25 | 108 | -------------------------------------------------------------------------------- /src/components/IconButton.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 19 | 20 | 33 | -------------------------------------------------------------------------------- /src/components/JsonOutput.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 40 | -------------------------------------------------------------------------------- /src/components/ProjectOutput.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 31 | 32 | 74 | 75 | 103 | -------------------------------------------------------------------------------- /src/components/TopNav.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 43 | 44 | 104 | -------------------------------------------------------------------------------- /src/components/draggable-indicator.css: -------------------------------------------------------------------------------- 1 | .draggable-indicator { 2 | display: inline-block; 3 | width: 1rem; 4 | height: 0.75rem; 5 | margin-left: -0.25rem; 6 | margin-right: 0.25rem; 7 | cursor: grab; 8 | } 9 | .draggable-indicator:after { 10 | display: block; 11 | content: ""; 12 | border-top: 1px solid var(--gray-400); 13 | border-bottom: 1px solid var(--gray-400); 14 | margin: 0.25rem; 15 | height: 0.25rem; 16 | width: 0.5rem; 17 | } 18 | .list-node.selected .draggable-indicator:after { 19 | border-top-color: white; 20 | border-bottom-color: white; 21 | } 22 | -------------------------------------------------------------------------------- /src/editor/EditorCanvas.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 135 | 136 | 142 | -------------------------------------------------------------------------------- /src/editor/EditorWorkspace.vue: -------------------------------------------------------------------------------- 1 | 53 | 54 | 400 | 401 | 454 | -------------------------------------------------------------------------------- /src/editor/LayerPropsFormFields.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 42 | 43 | 50 | 51 | 56 | -------------------------------------------------------------------------------- /src/editor/LayerSubtree.vue: -------------------------------------------------------------------------------- 1 | 38 | 39 | 118 | 119 | 202 | 203 | 211 | -------------------------------------------------------------------------------- /src/editor/ProjectEditor.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 75 | 76 | 106 | -------------------------------------------------------------------------------- /src/editor/ProjectLayers.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 17 | 18 | 31 | -------------------------------------------------------------------------------- /src/editor/PropsForm.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 27 | 28 | 40 | -------------------------------------------------------------------------------- /src/editor/ShapeNameInput.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 57 | 58 | 84 | -------------------------------------------------------------------------------- /src/editor/ShapeOverlay.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 35 | 36 | 62 | -------------------------------------------------------------------------------- /src/editor/ShapeOverlays.vue: -------------------------------------------------------------------------------- 1 | 49 | 50 | 107 | 108 | 130 | -------------------------------------------------------------------------------- /src/editor/ShapePropsFormFields.vue: -------------------------------------------------------------------------------- 1 | 60 | 61 | 157 | 158 | 184 | -------------------------------------------------------------------------------- /src/editor/ShapeResizeHandles.vue: -------------------------------------------------------------------------------- 1 | 53 | 54 | 254 | 255 | 303 | -------------------------------------------------------------------------------- /src/editor/StopsEditor.vue: -------------------------------------------------------------------------------- 1 | 53 | 54 | 103 | 104 | 172 | 173 | 181 | -------------------------------------------------------------------------------- /src/editor/shapes.js: -------------------------------------------------------------------------------- 1 | export function move({ left, shape, top }) { 2 | const moved = { 3 | left: { ...shape.left }, 4 | top: { ...shape.top }, 5 | width: { ...shape.width }, 6 | height: { ...shape.height } 7 | }; 8 | if (left !== undefined) { 9 | moved.left.value = left.value; 10 | } 11 | if (top !== undefined) { 12 | moved.top.value = top.value; 13 | } 14 | return moved; 15 | } 16 | 17 | export function moveBy({ left, shape, top }) { 18 | const moved = { 19 | left: { ...shape.left }, 20 | top: { ...shape.top }, 21 | width: { ...shape.width }, 22 | height: { ...shape.height } 23 | }; 24 | if ( 25 | typeof left === "object" && 26 | (left.units === moved.left.units || !left.units) 27 | ) { 28 | moved.left.value += left.value; 29 | } 30 | if ( 31 | typeof top === "object" && 32 | (top.units === moved.top.units || !top.units) 33 | ) { 34 | moved.top.value += top.value; 35 | } 36 | return moved; 37 | } 38 | -------------------------------------------------------------------------------- /src/favicon.clip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jperals/zerodivs/d272e296d7384d3a5ec172c54264aa28382bfa30/src/favicon.clip -------------------------------------------------------------------------------- /src/gallery/GalleryThumbnail.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 114 | 115 | 121 | -------------------------------------------------------------------------------- /src/gallery/ProjectGallery.vue: -------------------------------------------------------------------------------- 1 | 68 | 69 | 132 | 133 | 308 | -------------------------------------------------------------------------------- /src/gallery/projects-store.js: -------------------------------------------------------------------------------- 1 | import uuid from "uuid/v1"; 2 | import { get } from "lodash"; 3 | import { warn } from "@/warn"; 4 | import persistence from "@/persistence"; 5 | 6 | const projects = { 7 | state: { 8 | currentProject: null, 9 | projects: [] 10 | }, 11 | getters: { 12 | currentProject: state => state.currentProject, 13 | projectById: state => id => 14 | state.projects.find(project => project.id === id), 15 | projects: state => state.projects, 16 | shapesLayersByProjectId: (state, getters) => id => 17 | get(getters.projectById(id), "shapes") 18 | }, 19 | mutations: { 20 | createNewProject(state, id) { 21 | state.projects = state.projects.concat([ 22 | { 23 | id 24 | } 25 | ]); 26 | }, 27 | removeProject(state, project) { 28 | const index = state.projects.findIndex(item => item.id === project.id); 29 | if (-1 < index) { 30 | state.projects.splice(index, 1); 31 | } else { 32 | warn("Project to be removed not found"); 33 | } 34 | }, 35 | setCurrentProject(state, project) { 36 | state.currentProject = project; 37 | }, 38 | setProjects(state, projects) { 39 | state.projects = projects; 40 | }, 41 | updateProject(state, { project, newProps = {} }) { 42 | for (const propName in newProps) { 43 | project[propName] = newProps[propName]; 44 | } 45 | } 46 | }, 47 | actions: { 48 | async createNewProject({ commit, getters }) { 49 | const id = uuid(); 50 | commit("createNewProject", id); 51 | await persistence.set("divs", getters.projects); 52 | return id; 53 | }, 54 | async duplicateProject({ commit, dispatch, getters }, project) { 55 | const newId = await dispatch("createNewProject"); 56 | const newProject = getters.projectById(newId); 57 | const layers = getters.shapesLayersByProjectId(project.id); 58 | commit("updateProject", { 59 | project: newProject, 60 | newProps: { shapes: layers } 61 | }); 62 | return persistence.set("divs", getters.projects); 63 | }, 64 | loadProjectById({ dispatch, getters }, id) { 65 | return dispatch("loadProjects").then(() => { 66 | const project = getters.projectById(id); 67 | const projectShapes = get(project, "shapes"); 68 | dispatch("setCurrentProject", project); 69 | return dispatch("setShapes", projectShapes); 70 | }); 71 | }, 72 | loadProjects({ commit }) { 73 | return persistence 74 | .get("divs") 75 | .then(projects => commit("setProjects", projects || [])); 76 | }, 77 | updateProject({ commit, getters }, options) { 78 | const project = get(options, "project", getters.currentProject); 79 | const newProps = get(options, "newProps", { shapes: getters.allLayers }); 80 | commit("updateProject", { project, newProps }); 81 | return persistence.set("divs", getters.projects); 82 | }, 83 | removeProject({ commit, getters }, project) { 84 | commit("removeProject", project); 85 | return persistence.set("divs", getters.projects); 86 | }, 87 | setCurrentProject({ commit }, project) { 88 | return commit("setCurrentProject", project); 89 | } 90 | } 91 | }; 92 | 93 | export default projects; 94 | -------------------------------------------------------------------------------- /src/gallery/projects-store.spec.js: -------------------------------------------------------------------------------- 1 | import projectsStore from "@/gallery/projects-store"; 2 | import Vue from "vue"; 3 | import Vuex from "vuex"; 4 | 5 | Vue.use(Vuex); 6 | 7 | const store = new Vuex.Store(projectsStore); 8 | 9 | describe("Projects store", () => { 10 | it("remove a project", () => { 11 | store.commit("createNewProject"); 12 | store.commit("createNewProject"); 13 | store.commit("createNewProject"); 14 | const projects = store.getters.projects; 15 | const projectsCopy = [...projects]; 16 | store.commit("removeProject", projects[1]); 17 | expect(store.getters.projects.length).toBe(2); 18 | expect(store.getters.projects).toStrictEqual([ 19 | projectsCopy[0], 20 | projectsCopy[2] 21 | ]); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/logo-128px.clip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jperals/zerodivs/d272e296d7384d3a5ec172c54264aa28382bfa30/src/logo-128px.clip -------------------------------------------------------------------------------- /src/logo-128px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jperals/zerodivs/d272e296d7384d3a5ec172c54264aa28382bfa30/src/logo-128px.png -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import { createApp } from "vue"; 2 | import App from "./App.vue"; 3 | import router from "./router"; 4 | import store from "./store"; 5 | import VueClipboard from "vue3-clipboard"; 6 | 7 | const app = createApp(App); 8 | app.use(store); 9 | app.use(router); 10 | app.use(VueClipboard, { 11 | autoSetContainer: true 12 | }); 13 | 14 | app.config.productionTip = false; 15 | 16 | app.mount("#app"); 17 | -------------------------------------------------------------------------------- /src/persistence.js: -------------------------------------------------------------------------------- 1 | const namespace = "deepdiv"; 2 | 3 | function get(key) { 4 | const item = JSON.parse(localStorage.getItem(addNamespace(key))); 5 | return Promise.resolve(item); 6 | } 7 | 8 | function set(key, item) { 9 | localStorage.setItem(addNamespace(key), JSON.stringify(item)); 10 | return Promise.resolve(item); 11 | } 12 | 13 | function remove(key) { 14 | localStorage.removeItem(key); 15 | return Promise.resolve(addNamespace(key)); 16 | } 17 | 18 | function addNamespace(key) { 19 | return `${namespace}.${key}`; 20 | } 21 | 22 | export default { 23 | get, 24 | set, 25 | remove 26 | }; 27 | -------------------------------------------------------------------------------- /src/react-to-keyboard.js: -------------------------------------------------------------------------------- 1 | import { isNotWriting } from "@/common/ui"; 2 | import store from "@/store"; 3 | 4 | export default function reactToKeyboard(event) { 5 | switch (event.key) { 6 | case "a": 7 | if (isNotWriting(event) && event.metaKey) { 8 | event.preventDefault(); 9 | store.dispatch("selectAllShapes"); 10 | } 11 | break; 12 | case "Backspace": 13 | case "Delete": 14 | if (isNotWriting(event)) { 15 | store.dispatch("removeSelectedShapes"); 16 | } 17 | break; 18 | case "ArrowLeft": 19 | if (isNotWriting(event)) { 20 | store.dispatch("moveShapesBy", { 21 | shapes: store.getters.selectedShapes, 22 | left: { value: -1, units: "px" } 23 | }); 24 | } 25 | break; 26 | case "ArrowUp": 27 | if (isNotWriting(event)) { 28 | store.dispatch("moveShapesBy", { 29 | shapes: store.getters.selectedShapes, 30 | top: { value: -1, units: "px" } 31 | }); 32 | } 33 | break; 34 | case "ArrowRight": 35 | if (isNotWriting(event)) { 36 | store.dispatch("moveShapesBy", { 37 | shapes: store.getters.selectedShapes, 38 | left: { value: 1, units: "px" } 39 | }); 40 | } 41 | break; 42 | case "ArrowDown": 43 | if (isNotWriting(event)) { 44 | store.dispatch("moveShapesBy", { 45 | shapes: store.getters.selectedShapes, 46 | top: { value: 1, units: "px" } 47 | }); 48 | } 49 | break; 50 | case "c": 51 | if (isNotWriting(event) && event.metaKey) { 52 | store.dispatch("copyShapes"); 53 | } 54 | break; 55 | case "d": 56 | if (isNotWriting(event)) { 57 | store.dispatch("duplicateSelectedShapes"); 58 | } 59 | break; 60 | case "Escape": 61 | if (isNotWriting(event)) { 62 | store.dispatch("setShapeToBeAdded", null); 63 | } 64 | break; 65 | case "v": 66 | if (isNotWriting(event) && event.metaKey) { 67 | store.dispatch("pasteShapes"); 68 | } 69 | break; 70 | case "x": 71 | if (isNotWriting(event) && event.metaKey) { 72 | store.dispatch("cutShapes"); 73 | } 74 | break; 75 | case "z": 76 | if (isNotWriting(event) && event.metaKey) { 77 | if (event.shiftKey) { 78 | store.dispatch("redo"); 79 | } else { 80 | store.dispatch("undo"); 81 | } 82 | } 83 | break; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/router/index.js: -------------------------------------------------------------------------------- 1 | import { createRouter, createWebHistory } from "vue-router"; 2 | import Gallery from "@/gallery/ProjectGallery.vue"; 3 | import Editor from "@/editor/ProjectEditor.vue"; 4 | import About from "@/about-page/AboutPage.vue"; 5 | import ProjectOutput from "@/components/ProjectOutput.vue"; 6 | import CssOutput from "@/components/CssOutput.vue"; 7 | import JsonOutput from "@/components/JsonOutput.vue"; 8 | 9 | const routes = [ 10 | { 11 | path: "/", 12 | name: "gallery", 13 | component: Gallery 14 | }, 15 | { 16 | path: "/about", 17 | name: "about", 18 | component: About 19 | }, 20 | { 21 | path: "/i/:id", 22 | name: "editor", 23 | component: Editor, 24 | children: [ 25 | { 26 | path: "result", 27 | component: ProjectOutput, 28 | name: "result", 29 | redirect: { 30 | name: "css-result" 31 | }, 32 | children: [ 33 | { 34 | path: "css", 35 | component: CssOutput, 36 | name: "css-result" 37 | }, 38 | { 39 | path: "json", 40 | component: JsonOutput, 41 | name: "json-result" 42 | } 43 | ] 44 | } 45 | ] 46 | } 47 | ]; 48 | 49 | const router = createRouter({ 50 | history: createWebHistory(), 51 | routes 52 | }); 53 | 54 | export default router; 55 | -------------------------------------------------------------------------------- /src/snap/snap-store.js: -------------------------------------------------------------------------------- 1 | import { findClosestSnap, generateSnapPoints } from "./snap"; 2 | 3 | export default { 4 | state: { 5 | currentSnaps: {}, 6 | snap: true, 7 | snapPoints: { x: [], y: [] }, 8 | snapThreshold: 5 9 | }, 10 | getters: { 11 | currentSnaps: state => state.currentSnaps, 12 | snap: state => state.snap, 13 | snapPoint: state => point => 14 | findClosestSnap({ snapPointsSorted: state.snapPoints, point }), 15 | snapPoints: state => state.snapPoints, 16 | snapThreshold: state => state.snapThreshold 17 | }, 18 | mutations: { 19 | generateSnapPoints(state, { shapes, excluded }) { 20 | state.snapPoints = generateSnapPoints({ shapes, excluded }); 21 | }, 22 | setCurrentSnaps(state, snaps) { 23 | state.currentSnaps = snaps; 24 | }, 25 | setSnapThreshold(state, value) { 26 | state.snapThreshold = value; 27 | }, 28 | toggleSnap(state, value = !state.snap) { 29 | state.snap = value; 30 | } 31 | }, 32 | actions: { 33 | generateSnapPoints({ commit, getters }) { 34 | commit("generateSnapPoints", { 35 | shapes: getters.shapes, 36 | excluded: getters.selectedShape 37 | }); 38 | }, 39 | setCurrentProject({ dispatch }) { 40 | dispatch("generateSnapPoints"); 41 | }, 42 | setCurrentSnaps({ commit }, snaps) { 43 | commit("setCurrentSnaps", snaps); 44 | }, 45 | setSnapThreshold({ commit }, value) { 46 | commit("setSnapThreshold", value); 47 | }, 48 | toggleSnap({ commit }, value) { 49 | commit("toggleSnap", value); 50 | }, 51 | updateProject({ dispatch }) { 52 | dispatch("generateSnapPoints"); 53 | } 54 | } 55 | }; 56 | -------------------------------------------------------------------------------- /src/snap/snap.js: -------------------------------------------------------------------------------- 1 | import { move, moveBy } from "@/editor/shapes"; 2 | 3 | export function findClosestSnap({ snapPointsSorted, point }) { 4 | return { 5 | x: findClosestSnapInAxis({ 6 | snapPointsSorted: snapPointsSorted.x, 7 | point: point.x 8 | }), 9 | y: findClosestSnapInAxis({ 10 | snapPointsSorted: snapPointsSorted.y, 11 | point: point.y 12 | }) 13 | }; 14 | } 15 | 16 | // Performs binary search 17 | export function findClosestSnapInAxis({ 18 | snapPointsSorted = [], 19 | point, 20 | start = 0, 21 | end = snapPointsSorted.length - 1 22 | }) { 23 | if (end < start) { 24 | return; 25 | } 26 | if (start == end) { 27 | return snapPointsSorted[start]; 28 | } else if (end - start === 1) { 29 | const first = snapPointsSorted[start]; 30 | const second = snapPointsSorted[end]; 31 | const distanceFirst = snapDistance({ snapPoint: first, number: point }); 32 | const distanceSecond = snapDistance({ snapPoint: second, number: point }); 33 | return distanceFirst <= distanceSecond ? first : second; 34 | } 35 | const indexAtLeftOfHalf = start + Math.floor((end - start) / 2); 36 | const indexAtRightOfHalf = indexAtLeftOfHalf + 1; 37 | const pointAtLeft = snapPointsSorted[indexAtLeftOfHalf]; 38 | const pointAtRight = snapPointsSorted[indexAtRightOfHalf]; 39 | const distanceToLeft = snapDistance({ 40 | snapPoint: pointAtLeft, 41 | number: point 42 | }); 43 | const distanceToRight = snapDistance({ 44 | snapPoint: pointAtRight, 45 | number: point 46 | }); 47 | const [newStart, newEnd] = 48 | distanceToLeft <= distanceToRight 49 | ? [start, indexAtLeftOfHalf] 50 | : [indexAtRightOfHalf, end]; 51 | return findClosestSnapInAxis({ 52 | snapPointsSorted, 53 | point, 54 | start: newStart, 55 | end: newEnd 56 | }); 57 | } 58 | 59 | export function generateSnapPoints({ shapes, excluded }) { 60 | const snapPoints = { 61 | x: [], 62 | y: [] 63 | }; 64 | for (const shape of shapes) { 65 | if (shape === excluded) { 66 | continue; 67 | } 68 | snapPoints.x.push({ shape, value: shape.left.value, property: "left" }); 69 | snapPoints.x.push({ 70 | shape, 71 | value: shape.left.value + shape.width.value, 72 | property: "right" 73 | }); 74 | snapPoints.y.push({ shape, value: shape.top.value, property: "top" }); 75 | snapPoints.y.push({ 76 | shape, 77 | value: shape.top.value + shape.height.value, 78 | property: "bottom" 79 | }); 80 | } 81 | snapPoints.x.sort((pointA, pointB) => pointA.value - pointB.value); 82 | snapPoints.y.sort((pointA, pointB) => pointA.value - pointB.value); 83 | return snapPoints; 84 | } 85 | 86 | export function getSnaps({ shape, snapPoints, threshold }) { 87 | return { 88 | x: getSnapX({ shape, snapPoints: snapPoints.x, threshold }), 89 | y: getSnapY({ shape, snapPoints: snapPoints.y, threshold }) 90 | }; 91 | } 92 | 93 | function getSnapX({ shape, snapPoints, threshold }) { 94 | if (!snapPoints || !snapPoints.length) { 95 | return; 96 | } 97 | const closestToLeft = findClosestSnapInAxis({ 98 | snapPointsSorted: snapPoints, 99 | point: shape.left.value 100 | }); 101 | const distanceLeft = Math.abs(shape.left.value - closestToLeft.value); 102 | const closestToRight = findClosestSnapInAxis({ 103 | snapPointsSorted: snapPoints, 104 | point: shape.left.value + shape.width.value 105 | }); 106 | const distanceRight = Math.abs( 107 | shape.left.value + shape.width.value - closestToRight.value 108 | ); 109 | if (distanceLeft <= distanceRight) { 110 | if (distanceLeft <= threshold) { 111 | return { location: "left", point: closestToLeft }; 112 | } 113 | } else if (distanceRight <= threshold) { 114 | return { location: "right", point: closestToRight }; 115 | } 116 | } 117 | 118 | function getSnapY({ shape, snapPoints, threshold }) { 119 | if (!snapPoints || !snapPoints.length) { 120 | return; 121 | } 122 | const closestToTop = findClosestSnapInAxis({ 123 | snapPointsSorted: snapPoints, 124 | point: shape.top.value 125 | }); 126 | const distanceTop = Math.abs(shape.top.value - closestToTop.value); 127 | const closestToBottom = findClosestSnapInAxis({ 128 | snapPointsSorted: snapPoints, 129 | point: shape.top.value + shape.height.value 130 | }); 131 | const distanceBottom = Math.abs( 132 | shape.top.value + shape.height.value - closestToBottom.value 133 | ); 134 | if (distanceTop <= distanceBottom) { 135 | if (distanceTop <= threshold) { 136 | return { location: "top", point: closestToTop }; 137 | } 138 | } else if (distanceBottom <= threshold) { 139 | return { location: "bottom", point: closestToBottom }; 140 | } 141 | } 142 | 143 | export function moveAndSnap({ left, shape, snapPoints, threshold, top }) { 144 | const moved = move({ left, shape, top }); 145 | return moveSnap({ left, shape: moved, snapPoints, threshold }); 146 | } 147 | 148 | export function moveByAndSnap({ left, shape, snapPoints, threshold, top }) { 149 | const moved = moveBy({ left, shape, top }); 150 | return moveSnap({ shape: moved, snapPoints, threshold }); 151 | } 152 | 153 | export function moveSnap({ shape, snapPoints, threshold }) { 154 | const snaps = getSnaps({ shape, snapPoints, threshold }); 155 | return moveToSnaps({ shape, snaps }); 156 | } 157 | 158 | function moveToSnap({ shape, snap }) { 159 | const moved = { ...shape }; 160 | if (snap) { 161 | switch (snap.location) { 162 | case "top": 163 | moved.top.value = snap.point.value; 164 | break; 165 | case "right": 166 | moved.left.value = snap.point.value - shape.width.value; 167 | break; 168 | case "bottom": 169 | moved.top.value = snap.point.value - shape.height.value; 170 | break; 171 | case "left": 172 | moved.left.value = snap.point.value; 173 | break; 174 | } 175 | } 176 | return moved; 177 | } 178 | 179 | export function moveToSnaps({ shape, snaps }) { 180 | const snappedX = moveToSnap({ shape, snap: snaps.x }); 181 | return moveToSnap({ shape: snappedX, snap: snaps.y }); 182 | } 183 | 184 | function snapDistance({ snapPoint, number }) { 185 | return Math.abs(snapPoint.value - number); 186 | } 187 | -------------------------------------------------------------------------------- /src/snap/snap.spec.js: -------------------------------------------------------------------------------- 1 | import { 2 | findClosestSnap, 3 | findClosestSnapInAxis, 4 | generateSnapPoints, 5 | moveByAndSnap 6 | } from "@/snap/snap"; 7 | 8 | describe("findClosestSnap", () => { 9 | it("finds closest snap", () => { 10 | const snapPoints = { 11 | x: [ 12 | { value: 3 }, 13 | { value: 10 }, 14 | { value: 16 }, 15 | { value: 17 }, 16 | { value: 34 }, 17 | { value: 34 } 18 | ], 19 | y: [ 20 | { value: 3 }, 21 | { value: 10 }, 22 | { value: 16 }, 23 | { value: 17 }, 24 | { value: 34 }, 25 | { value: 34 } 26 | ] 27 | }; 28 | const point = { x: 10, y: 24 }; 29 | const closestSnap = findClosestSnap({ 30 | snapPointsSorted: snapPoints, 31 | point 32 | }); 33 | expect(closestSnap).toEqual({ x: { value: 10 }, y: { value: 17 } }); 34 | }); 35 | it("finds closest snap", () => { 36 | const snapPoints = { 37 | x: [ 38 | { value: 3 }, 39 | { value: 10 }, 40 | { value: 16 }, 41 | { value: 17 }, 42 | { value: 34 }, 43 | { value: 34 } 44 | ], 45 | y: [ 46 | { value: 3 }, 47 | { value: 10 }, 48 | { value: 16 }, 49 | { value: 17 }, 50 | { value: 34 }, 51 | { value: 34 } 52 | ] 53 | }; 54 | const point = { x: 1, y: 24 }; 55 | const closestSnap = findClosestSnap({ 56 | snapPointsSorted: snapPoints, 57 | point 58 | }); 59 | expect(closestSnap).toEqual({ x: { value: 3 }, y: { value: 17 } }); 60 | }); 61 | it("finds closest snap", () => { 62 | const snapPoints = { 63 | x: [ 64 | { value: 3 }, 65 | { value: 10 }, 66 | { value: 16 }, 67 | { value: 17 }, 68 | { value: 34 }, 69 | { value: 34 } 70 | ], 71 | y: [ 72 | { value: 3 }, 73 | { value: 10 }, 74 | { value: 16 }, 75 | { value: 17 }, 76 | { value: 34 }, 77 | { value: 34 } 78 | ] 79 | }; 80 | const point = { x: 10, y: 40 }; 81 | const closestSnap = findClosestSnap({ 82 | snapPointsSorted: snapPoints, 83 | point 84 | }); 85 | expect(closestSnap).toEqual({ x: { value: 10 }, y: { value: 34 } }); 86 | }); 87 | }); 88 | 89 | describe("findClosestSnapInAxis", () => { 90 | it("Finds closest snap in y axis", () => { 91 | const snapPointsSorted = [ 92 | { value: 10 }, 93 | { value: 20 }, 94 | { value: 70 }, 95 | { value: 90 }, 96 | { value: 180 } 97 | ]; 98 | const point = 23; 99 | const snap = findClosestSnapInAxis({ 100 | point, 101 | snapPointsSorted 102 | }); 103 | expect(snap).toEqual({ 104 | value: 20 105 | }); 106 | }); 107 | }); 108 | 109 | describe("generateSnapPoints", () => { 110 | it("generates snap points from shapes", () => { 111 | const shapes = [ 112 | { 113 | left: { value: 50 }, 114 | top: { value: 43 }, 115 | width: { value: 34 }, 116 | height: { value: 10 } 117 | }, 118 | { 119 | left: { value: 10 }, 120 | top: { value: -10 }, 121 | width: { value: 100 }, 122 | height: { value: 30 } 123 | } 124 | ]; 125 | const snapPoints = generateSnapPoints({ shapes }); 126 | const snapPointValues = { 127 | x: snapPoints.x.map(point => point.value), 128 | y: snapPoints.y.map(point => point.value) 129 | }; 130 | expect(snapPointValues).toEqual({ 131 | x: [10, 50, 84, 110], 132 | y: [-10, 20, 43, 53] 133 | }); 134 | }); 135 | }); 136 | 137 | describe("moveAndSnap", () => { 138 | const snapPoints = { 139 | x: [ 140 | { value: 10 }, 141 | { value: 20 }, 142 | { value: 50 }, 143 | { value: 70 }, 144 | { value: 180 } 145 | ], 146 | y: [ 147 | { value: 10 }, 148 | { value: 20 }, 149 | { value: 70 }, 150 | { value: 90 }, 151 | { value: 180 } 152 | ] 153 | }; 154 | let shape; 155 | const delta = { 156 | left: { value: 15 }, 157 | top: { value: -17 } 158 | }; 159 | beforeEach(() => { 160 | shape = { 161 | left: { 162 | value: 25 163 | }, 164 | top: { 165 | value: 40 166 | }, 167 | width: { 168 | value: 50 169 | }, 170 | height: { 171 | value: 30 172 | } 173 | }; 174 | }); 175 | it("keeps a shape at the same position when no threshold is met", () => { 176 | const newProps = moveByAndSnap({ 177 | shape, 178 | snapPoints, 179 | threshold: 2, 180 | ...delta 181 | }); 182 | expect(newProps).toEqual({ 183 | ...shape, 184 | left: { ...shape.left, value: shape.left.value + delta.left.value }, 185 | top: { ...shape.top, value: shape.top.value + delta.top.value } 186 | }); 187 | }); 188 | it("moves a shape vertically when the threshold is met vertically", () => { 189 | const newProps = moveByAndSnap({ 190 | shape, 191 | snapPoints, 192 | threshold: 5, 193 | ...delta 194 | }); 195 | expect(newProps.left.value).toEqual(shape.left.value + delta.left.value); 196 | expect(newProps.top.value).toEqual(20); 197 | }); 198 | it("moves a shape horizontally and vertically when the threshold is met in both axes", () => { 199 | const newProps = moveByAndSnap({ 200 | shape, 201 | snapPoints, 202 | threshold: 10, 203 | ...delta 204 | }); 205 | expect(newProps.left.value).toEqual(50); 206 | expect(newProps.top.value).toEqual(20); 207 | }); 208 | }); 209 | -------------------------------------------------------------------------------- /src/store/index.js: -------------------------------------------------------------------------------- 1 | import { createStore } from "vuex"; 2 | import router from "../router"; 3 | import projects from "../gallery/projects-store"; 4 | import shapes from "../editor/shapes-store"; 5 | import snap from "../snap/snap-store"; 6 | import ui from "./ui"; 7 | import undoRedo from "../undo-redo/undo-redo-store"; 8 | 9 | export default createStore({ 10 | get route() { 11 | return router.currentRoute.value; 12 | }, 13 | state: {}, 14 | mutations: {}, 15 | getters: {}, 16 | actions: { 17 | commitChange({ dispatch }) { 18 | dispatch("addSnapshot"); 19 | return dispatch("updateProject"); 20 | } 21 | }, 22 | modules: { projects, shapes, snap, ui, undoRedo } 23 | }); 24 | -------------------------------------------------------------------------------- /src/store/ui.js: -------------------------------------------------------------------------------- 1 | import { deepCopy } from "@/common/utils"; 2 | 3 | const ui = { 4 | state: { 5 | colorPickerPosition: null, 6 | copiedShapes: [], 7 | currentColor: "black", 8 | lineThickness: "4px", 9 | openColorPickerId: null, 10 | pressedKeys: new Set(), 11 | selectedLayer: "main", 12 | selectedShape: null, 13 | selectedShapes: new Set(), 14 | showOutput: false 15 | }, 16 | mutations: { 17 | addPressedKey(state, key) { 18 | state.pressedKeys.add(key); 19 | state.pressedKeys = new Set(state.pressedKeys); // Workaround Vue's lack of first-class support reactivity for Sets 20 | }, 21 | copyShapes(state, shapes) { 22 | state.copiedShapes = shapes.map(shape => deepCopy(shape)); 23 | }, 24 | removePressedKey(state, key) { 25 | state.pressedKeys.delete(key); 26 | state.pressedKeys = new Set(state.pressedKeys); // Workaround Vue's lack of first-class support reactivity for Sets 27 | }, 28 | selectColor(state, color) { 29 | state.currentColor = color; 30 | }, 31 | selectLayer(state, layerId) { 32 | state.selectedLayer = layerId; 33 | }, 34 | selectShape(state, { shape, keepSelection }) { 35 | if (keepSelection) { 36 | if (state.selectedShapes.has(shape)) { 37 | state.selectedShapes.delete(shape); 38 | } else { 39 | state.selectedShapes.add(shape); 40 | } 41 | } else { 42 | state.selectedShapes = new Set(); 43 | if (shape) { 44 | state.selectedShapes.add(shape); 45 | } 46 | } 47 | state.selectedShapes = new Set(state.selectedShapes); // Workaround Vue's lack of first-class support reactivity for Sets 48 | }, 49 | selectShapes(state, { shapes, keepSelection = false }) { 50 | if (!keepSelection) { 51 | state.selectedShapes = new Set(); 52 | } 53 | for (const shape of shapes) { 54 | state.selectedShapes.add(shape); 55 | } 56 | state.selectedShapes = new Set(state.selectedShapes); // Workaround Vue's lack of first-class support reactivity for Sets 57 | }, 58 | setColorPickerPosition(state, position) { 59 | state.colorPickerPosition = position; 60 | }, 61 | setLineThickness(state, thickness) { 62 | state.lineThickness = thickness; 63 | }, 64 | setOpenColorPickerId(state, id) { 65 | state.openColorPickerId = id; 66 | }, 67 | toggleOutput(state, value) { 68 | state.showOutput = value === undefined ? !state.showOutput : value; 69 | }, 70 | unselectShape(state, shape) { 71 | state.selectedShapes.delete(shape); 72 | } 73 | }, 74 | getters: { 75 | colorPickerPosition: state => state.colorPickerPosition, 76 | copiedShapes: state => state.copiedShapes, 77 | currentColor: state => state.currentColor, 78 | isKeyPressed: state => key => state.pressedKeys.has(key), 79 | isShapeSelected: state => shape => state.selectedShapes.has(shape), 80 | lineThickness: state => state.lineThickness, 81 | openColorPickerId: state => state.openColorPickerId, 82 | selectedLayer: state => state.selectedLayer, 83 | selectedShape: state => 84 | state.selectedShapes.size === 1 85 | ? Array.from(state.selectedShapes)[0] 86 | : undefined, 87 | selectedShapes: state => Array.from(state.selectedShapes), 88 | selectMultiple: state => state.pressedKeys.has("Shift"), 89 | showOutput: state => state.showOutput 90 | }, 91 | actions: { 92 | addPressedKey({ commit }, key) { 93 | commit("addPressedKey", key); 94 | }, 95 | copyShapes({ commit, getters }, shapes = getters.selectedShapes) { 96 | commit("copyShapes", shapes); 97 | }, 98 | cutShapes({ commit, dispatch, getters }, shapes = getters.selectedShapes) { 99 | commit("copyShapes", shapes); 100 | dispatch("removeSelectedShapes"); 101 | }, 102 | pasteShapes({ dispatch, getters }) { 103 | dispatch("addShapes", { 104 | shapes: getters.copiedShapes 105 | }).then(newShapes => { 106 | dispatch("commitChange"); 107 | dispatch("selectShapes", { shapes: newShapes }); 108 | }); 109 | }, 110 | removePressedKey({ commit }, key) { 111 | commit("removePressedKey", key); 112 | }, 113 | setColorPickerPosition({ commit }, position) { 114 | commit("setColorPickerPosition", position); 115 | }, 116 | selectAllShapes({ commit, getters }) { 117 | const selectedLayerId = getters.selectedLayer; 118 | if (getters.isLayerActive(selectedLayerId)) { 119 | const shapes = getters.layerShapes(selectedLayerId); 120 | commit("selectShapes", { shapes }); 121 | } 122 | }, 123 | selectColor({ commit }, color) { 124 | commit("selectColor", color); 125 | }, 126 | selectLayer({ commit }, layerId) { 127 | commit("selectLayer", layerId); 128 | }, 129 | selectShape({ commit, getters, dispatch }, { shape, keepSelection }) { 130 | commit("selectShape", { shape, keepSelection }); 131 | const layerId = getters.layerIdFromShape(shape); 132 | return dispatch("selectLayer", layerId); 133 | }, 134 | selectShapes({ commit }, { shapes, keepSelection }) { 135 | commit("selectShapes", { shapes, keepSelection }); 136 | }, 137 | setLineThickness({ commit }, value) { 138 | commit("setLineThickness", value); 139 | }, 140 | setOpenColorPickerId({ commit }, value) { 141 | commit("setOpenColorPickerId", value); 142 | }, 143 | toggleOutput({ commit }, value) { 144 | commit("toggleOutput", value); 145 | }, 146 | unselectShape({ commit }) { 147 | commit("selectShape", { shape: null }); 148 | } 149 | } 150 | }; 151 | 152 | export default ui; 153 | -------------------------------------------------------------------------------- /src/toolbar/CurrentColorPicker.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 28 | 29 | 37 | -------------------------------------------------------------------------------- /src/toolbar/LineThicknessPicker.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 23 | -------------------------------------------------------------------------------- /src/toolbar/ShapeButton.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /src/toolbar/ShapeButtonGroup.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 55 | 56 | 77 | -------------------------------------------------------------------------------- /src/toolbar/Toolbar.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 58 | 59 | 100 | -------------------------------------------------------------------------------- /src/toolbar/ToolbarButton.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 12 | 13 | 26 | -------------------------------------------------------------------------------- /src/toolbar/ToolbarSnapOptions.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 41 | 42 | 57 | -------------------------------------------------------------------------------- /src/toolbar/toolbar-button-shapes.js: -------------------------------------------------------------------------------- 1 | import store from "@/store"; 2 | 3 | const circleEdge = "71%"; 4 | 5 | export default [ 6 | [ 7 | function(color) { 8 | const lineWidth = store.getters.lineThickness; 9 | return { 10 | name: "Line, to bottom right", 11 | type: "linear", 12 | direction: "to bottom right", 13 | stops: [ 14 | { 15 | color: "transparent", 16 | position: `calc(50% - ${lineWidth} / 2)` 17 | }, 18 | { 19 | color, 20 | position: `calc(50% - ${lineWidth} / 2)` 21 | }, 22 | { 23 | color, 24 | position: `calc(50% + ${lineWidth} / 2)` 25 | }, 26 | { 27 | color: "transparent", 28 | position: `calc(50% + ${lineWidth} / 2)` 29 | } 30 | ] 31 | }; 32 | }, 33 | function(color) { 34 | const lineWidth = store.getters.lineThickness; 35 | return { 36 | name: "Line, to bottom left", 37 | type: "linear", 38 | direction: "to bottom left", 39 | stops: [ 40 | { 41 | color: "transparent", 42 | position: `calc(50% - ${lineWidth} / 2)` 43 | }, 44 | { 45 | color, 46 | position: `calc(50% - ${lineWidth} / 2)` 47 | }, 48 | { 49 | color, 50 | position: `calc(50% + ${lineWidth} / 2)` 51 | }, 52 | { 53 | color: "transparent", 54 | position: `calc(50% + ${lineWidth} / 2)` 55 | } 56 | ] 57 | }; 58 | } 59 | ], 60 | [ 61 | function(color) { 62 | return { 63 | name: "Rectangle", 64 | type: "linear", 65 | direction: "to bottom", 66 | stops: [ 67 | { 68 | color 69 | }, 70 | { 71 | color 72 | } 73 | ] 74 | }; 75 | } 76 | ], 77 | [ 78 | function(color) { 79 | return { 80 | name: "Bottom right triangle", 81 | type: "linear", 82 | direction: "to bottom right", 83 | stops: [ 84 | { 85 | color: "transparent", 86 | position: "50%" 87 | }, 88 | { 89 | color, 90 | position: "50%" 91 | } 92 | ] 93 | }; 94 | }, 95 | function(color) { 96 | return { 97 | name: "Top left triangle", 98 | type: "linear", 99 | direction: "to top left", 100 | stops: [ 101 | { 102 | color: "transparent", 103 | position: "50%" 104 | }, 105 | { 106 | color, 107 | position: "50%" 108 | } 109 | ] 110 | }; 111 | }, 112 | function(color) { 113 | return { 114 | name: "Bottom left triangle", 115 | type: "linear", 116 | direction: "to bottom left", 117 | stops: [ 118 | { 119 | color: "transparent", 120 | position: "50%" 121 | }, 122 | { 123 | color, 124 | position: "50%" 125 | } 126 | ] 127 | }; 128 | }, 129 | function(color) { 130 | return { 131 | name: "Top right triangle", 132 | type: "linear", 133 | direction: "to top right", 134 | stops: [ 135 | { 136 | color: "transparent", 137 | position: "50%" 138 | }, 139 | { 140 | color, 141 | position: "50%" 142 | } 143 | ] 144 | }; 145 | } 146 | ], 147 | [ 148 | function(color) { 149 | return { 150 | name: "Circle", 151 | type: "radial", 152 | direction: "at center", 153 | stops: [ 154 | { 155 | color, 156 | position: circleEdge 157 | }, 158 | { 159 | color: "transparent", 160 | position: circleEdge 161 | } 162 | ], 163 | repeat: "no-repeat" 164 | }; 165 | }, 166 | function(color) { 167 | const lineWidth = store.getters.lineThickness; 168 | return { 169 | name: "Empty circle", 170 | type: "radial", 171 | direction: "at center", 172 | stops: [ 173 | { 174 | color: "transparent", 175 | position: `calc(${circleEdge} - ${lineWidth})` 176 | }, 177 | { 178 | color, 179 | position: `calc(${circleEdge} - ${lineWidth})` 180 | }, 181 | { 182 | color, 183 | position: circleEdge 184 | }, 185 | { 186 | color: "transparent", 187 | position: circleEdge 188 | } 189 | ], 190 | repeat: "no-repeat" 191 | }; 192 | } 193 | ], 194 | [ 195 | function(color) { 196 | return { 197 | name: "Bottom right circle section", 198 | type: "radial", 199 | direction: "at top left", 200 | stops: [ 201 | { 202 | color, 203 | position: circleEdge 204 | }, 205 | { 206 | color: "transparent", 207 | position: circleEdge 208 | } 209 | ], 210 | repeat: "no-repeat" 211 | }; 212 | }, 213 | function(color) { 214 | return { 215 | name: "Bottom left circle section", 216 | type: "radial", 217 | direction: "at top right", 218 | stops: [ 219 | { 220 | color, 221 | position: circleEdge 222 | }, 223 | { 224 | color: "transparent", 225 | position: circleEdge 226 | } 227 | ], 228 | repeat: "no-repeat" 229 | }; 230 | }, 231 | function(color) { 232 | return { 233 | name: "Top left circle section", 234 | type: "radial", 235 | direction: "at bottom right", 236 | stops: [ 237 | { 238 | color, 239 | position: circleEdge 240 | }, 241 | { 242 | color: "transparent", 243 | position: circleEdge 244 | } 245 | ], 246 | repeat: "no-repeat" 247 | }; 248 | }, 249 | function(color) { 250 | return { 251 | name: "Top right circle section", 252 | type: "radial", 253 | direction: "at bottom left", 254 | stops: [ 255 | { 256 | color, 257 | position: circleEdge 258 | }, 259 | { 260 | color: "transparent", 261 | position: circleEdge 262 | } 263 | ], 264 | repeat: "no-repeat" 265 | }; 266 | } 267 | ], 268 | [ 269 | function(color) { 270 | const lineWidth = store.getters.lineThickness; 271 | return { 272 | name: "Bottom right arc", 273 | type: "radial", 274 | direction: "at top left", 275 | stops: [ 276 | { 277 | color: "transparent", 278 | position: `calc(${circleEdge} - ${lineWidth})` 279 | }, 280 | { 281 | color, 282 | position: `calc(${circleEdge} - ${lineWidth})` 283 | }, 284 | { 285 | color, 286 | position: circleEdge 287 | }, 288 | { 289 | color: "transparent", 290 | position: circleEdge 291 | } 292 | ] 293 | }; 294 | }, 295 | function(color) { 296 | const lineWidth = store.getters.lineThickness; 297 | return { 298 | name: "Bottom left arc", 299 | type: "radial", 300 | direction: "at top right", 301 | stops: [ 302 | { 303 | color: "transparent", 304 | position: `calc(${circleEdge} - ${lineWidth})` 305 | }, 306 | { 307 | color, 308 | position: `calc(${circleEdge} - ${lineWidth})` 309 | }, 310 | { 311 | color, 312 | position: circleEdge 313 | }, 314 | { 315 | color: "transparent", 316 | position: circleEdge 317 | } 318 | ], 319 | repeat: "no-repeat" 320 | }; 321 | }, 322 | function(color) { 323 | const lineWidth = store.getters.lineThickness; 324 | return { 325 | name: "Top left arc", 326 | type: "radial", 327 | direction: "at bottom right", 328 | stops: [ 329 | { 330 | color: "transparent", 331 | position: `calc(${circleEdge} - ${lineWidth})` 332 | }, 333 | { 334 | color, 335 | position: `calc(${circleEdge} - ${lineWidth})` 336 | }, 337 | { 338 | color, 339 | position: circleEdge 340 | }, 341 | { 342 | color: "transparent", 343 | position: circleEdge 344 | } 345 | ], 346 | repeat: "no-repeat" 347 | }; 348 | }, 349 | function(color) { 350 | const lineWidth = store.getters.lineThickness; 351 | return { 352 | name: "Top right arc", 353 | type: "radial", 354 | direction: "at bottom left", 355 | stops: [ 356 | { 357 | color: "transparent", 358 | position: `calc(${circleEdge} - ${lineWidth})` 359 | }, 360 | { 361 | color, 362 | position: `calc(${circleEdge} - ${lineWidth})` 363 | }, 364 | { 365 | color, 366 | position: circleEdge 367 | }, 368 | { 369 | color: "transparent", 370 | position: circleEdge 371 | } 372 | ], 373 | repeat: "no-repeat" 374 | }; 375 | } 376 | ], 377 | [ 378 | function(color) { 379 | return { 380 | name: "4 Tiles #1", 381 | type: "conic", 382 | stops: [ 383 | { 384 | color, 385 | position: "25%" 386 | }, 387 | { 388 | color: "transparent", 389 | position: "25%" 390 | }, 391 | { 392 | color: "transparent", 393 | position: "50%" 394 | }, 395 | { 396 | color, 397 | position: "50%" 398 | }, 399 | { 400 | color, 401 | position: "75%" 402 | }, 403 | { 404 | color: "transparent", 405 | position: "75%" 406 | } 407 | ], 408 | repeat: "no-repeat" 409 | }; 410 | }, 411 | function(color) { 412 | return { 413 | name: "4 Tiles #2", 414 | type: "conic", 415 | stops: [ 416 | { 417 | color: "transparent", 418 | position: "25%" 419 | }, 420 | { 421 | color, 422 | position: "25%" 423 | }, 424 | { 425 | color, 426 | position: "50%" 427 | }, 428 | { 429 | color: "transparent", 430 | position: "50%" 431 | }, 432 | { 433 | color: "transparent", 434 | position: "75%" 435 | }, 436 | { 437 | color, 438 | position: "75%" 439 | } 440 | ], 441 | repeat: "no-repeat" 442 | }; 443 | } 444 | ] 445 | ]; 446 | -------------------------------------------------------------------------------- /src/undo-redo/RedoButton.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 31 | 32 | 59 | -------------------------------------------------------------------------------- /src/undo-redo/UndoButton.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 31 | 32 | 59 | -------------------------------------------------------------------------------- /src/undo-redo/redo-button-shapes.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "Top right arc", 4 | "type": "radial", 5 | "direction": "at bottom left", 6 | "stops": [ 7 | { 8 | "color": "transparent", 9 | "position": "45%", 10 | "id": "3ea80a82-624f-11ea-8b9e-fd8e6004a4d6" 11 | }, 12 | { 13 | "color": "gray", 14 | "position": "45%", 15 | "id": "3ea80a83-624f-11ea-8b9e-fd8e6004a4d6" 16 | }, 17 | { 18 | "color": "gray", 19 | "position": "71%", 20 | "id": "3ea80a84-624f-11ea-8b9e-fd8e6004a4d6" 21 | }, 22 | { 23 | "color": "transparent", 24 | "position": "71%", 25 | "id": "3ea80a85-624f-11ea-8b9e-fd8e6004a4d6" 26 | } 27 | ], 28 | "repeat": "no-repeat", 29 | "width": { 30 | "units": "px", 31 | "value": 10 32 | }, 33 | "height": { 34 | "units": "px", 35 | "value": 10 36 | }, 37 | "left": { 38 | "units": "px", 39 | "value": 11 40 | }, 41 | "top": { 42 | "units": "px", 43 | "value": 2 44 | }, 45 | "id": "3ea80a81-624f-11ea-8b9e-fd8e6004a4d6" 46 | }, 47 | { 48 | "name": "Bottom right arc", 49 | "type": "radial", 50 | "direction": "at top right", 51 | "stops": [ 52 | { 53 | "color": "transparent", 54 | "position": "45%", 55 | "id": "44c8a502-624f-11ea-8b9e-fd8e6004a4d6" 56 | }, 57 | { 58 | "color": "gray", 59 | "position": "45%", 60 | "id": "44c8a503-624f-11ea-8b9e-fd8e6004a4d6" 61 | }, 62 | { 63 | "color": "gray", 64 | "position": "71%", 65 | "id": "44c8a504-624f-11ea-8b9e-fd8e6004a4d6" 66 | }, 67 | { 68 | "color": "transparent", 69 | "position": "71%", 70 | "id": "44c8a505-624f-11ea-8b9e-fd8e6004a4d6" 71 | } 72 | ], 73 | "width": { 74 | "units": "px", 75 | "value": 10 76 | }, 77 | "height": { 78 | "units": "px", 79 | "value": 10 80 | }, 81 | "left": { 82 | "units": "px", 83 | "value": 1 84 | }, 85 | "top": { 86 | "units": "px", 87 | "value": 12 88 | }, 89 | "id": "44c8a501-624f-11ea-8b9e-fd8e6004a4d6" 90 | }, 91 | { 92 | "name": "Bottom left arc", 93 | "type": "radial", 94 | "direction": "at bottom right", 95 | "stops": [ 96 | { 97 | "color": "transparent", 98 | "position": "45%", 99 | "id": "49ff0692-624f-11ea-8b9e-fd8e6004a4d6" 100 | }, 101 | { 102 | "color": "gray", 103 | "position": "45%", 104 | "id": "49ff0693-624f-11ea-8b9e-fd8e6004a4d6" 105 | }, 106 | { 107 | "color": "gray", 108 | "position": "71%", 109 | "id": "49ff0694-624f-11ea-8b9e-fd8e6004a4d6" 110 | }, 111 | { 112 | "color": "transparent", 113 | "position": "71%", 114 | "id": "49ff0695-624f-11ea-8b9e-fd8e6004a4d6" 115 | } 116 | ], 117 | "repeat": "no-repeat", 118 | "width": { 119 | "units": "px", 120 | "value": 10 121 | }, 122 | "height": { 123 | "units": "px", 124 | "value": 10 125 | }, 126 | "left": { 127 | "units": "px", 128 | "value": 1 129 | }, 130 | "top": { 131 | "units": "px", 132 | "value": 2 133 | }, 134 | "id": "49ff0691-624f-11ea-8b9e-fd8e6004a4d6" 135 | }, 136 | { 137 | "name": "Bottom right triangle", 138 | "type": "linear", 139 | "direction": "to bottom right", 140 | "stops": [ 141 | { 142 | "color": "transparent", 143 | "id": "28319d22-624f-11ea-8b9e-fd8e6004a4d6" 144 | }, 145 | { 146 | "color": "transparent", 147 | "position": "50%", 148 | "id": "28319d23-624f-11ea-8b9e-fd8e6004a4d6" 149 | }, 150 | { 151 | "color": "gray", 152 | "position": "50%", 153 | "id": "28319d24-624f-11ea-8b9e-fd8e6004a4d6" 154 | } 155 | ], 156 | "width": { 157 | "units": "px", 158 | "value": 10 159 | }, 160 | "height": { 161 | "units": "px", 162 | "value": 10 163 | }, 164 | "left": { 165 | "units": "px", 166 | "value": 12 167 | }, 168 | "top": { 169 | "units": "px", 170 | "value": 2 171 | }, 172 | "id": "28319d21-624f-11ea-8b9e-fd8e6004a4d6" 173 | } 174 | ] 175 | -------------------------------------------------------------------------------- /src/undo-redo/undo-button-shapes.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "Top right arc", 4 | "type": "radial", 5 | "direction": "at bottom left", 6 | "stops": [ 7 | { 8 | "color": "transparent", 9 | "position": "45%", 10 | "id": "3ea80a82-624f-11ea-8b9e-fd8e6004a4d6" 11 | }, 12 | { 13 | "color": "gray", 14 | "position": "45%", 15 | "id": "3ea80a83-624f-11ea-8b9e-fd8e6004a4d6" 16 | }, 17 | { 18 | "color": "gray", 19 | "position": "71%", 20 | "id": "3ea80a84-624f-11ea-8b9e-fd8e6004a4d6" 21 | }, 22 | { 23 | "color": "transparent", 24 | "position": "71%", 25 | "id": "3ea80a85-624f-11ea-8b9e-fd8e6004a4d6" 26 | } 27 | ], 28 | "repeat": "no-repeat", 29 | "width": { 30 | "units": "px", 31 | "value": 10 32 | }, 33 | "height": { 34 | "units": "px", 35 | "value": 10 36 | }, 37 | "left": { 38 | "units": "px", 39 | "value": 11 40 | }, 41 | "top": { 42 | "units": "px", 43 | "value": 2 44 | }, 45 | "id": "3ea80a81-624f-11ea-8b9e-fd8e6004a4d6" 46 | }, 47 | { 48 | "name": "Bottom right arc", 49 | "type": "radial", 50 | "direction": "at top left", 51 | "stops": [ 52 | { 53 | "color": "transparent", 54 | "position": "45%", 55 | "id": "44c8a502-624f-11ea-8b9e-fd8e6004a4d6" 56 | }, 57 | { 58 | "color": "gray", 59 | "position": "45%", 60 | "id": "44c8a503-624f-11ea-8b9e-fd8e6004a4d6" 61 | }, 62 | { 63 | "color": "gray", 64 | "position": "71%", 65 | "id": "44c8a504-624f-11ea-8b9e-fd8e6004a4d6" 66 | }, 67 | { 68 | "color": "transparent", 69 | "position": "71%", 70 | "id": "44c8a505-624f-11ea-8b9e-fd8e6004a4d6" 71 | } 72 | ], 73 | "width": { 74 | "units": "px", 75 | "value": 10 76 | }, 77 | "height": { 78 | "units": "px", 79 | "value": 10 80 | }, 81 | "left": { 82 | "units": "px", 83 | "value": 11 84 | }, 85 | "top": { 86 | "units": "px", 87 | "value": 12 88 | }, 89 | "id": "44c8a501-624f-11ea-8b9e-fd8e6004a4d6" 90 | }, 91 | { 92 | "name": "Bottom left arc", 93 | "type": "radial", 94 | "direction": "at bottom right", 95 | "stops": [ 96 | { 97 | "color": "transparent", 98 | "position": "45%", 99 | "id": "49ff0692-624f-11ea-8b9e-fd8e6004a4d6" 100 | }, 101 | { 102 | "color": "gray", 103 | "position": "45%", 104 | "id": "49ff0693-624f-11ea-8b9e-fd8e6004a4d6" 105 | }, 106 | { 107 | "color": "gray", 108 | "position": "71%", 109 | "id": "49ff0694-624f-11ea-8b9e-fd8e6004a4d6" 110 | }, 111 | { 112 | "color": "transparent", 113 | "position": "71%", 114 | "id": "49ff0695-624f-11ea-8b9e-fd8e6004a4d6" 115 | } 116 | ], 117 | "repeat": "no-repeat", 118 | "width": { 119 | "units": "px", 120 | "value": 10 121 | }, 122 | "height": { 123 | "units": "px", 124 | "value": 10 125 | }, 126 | "left": { 127 | "units": "px", 128 | "value": 1 129 | }, 130 | "top": { 131 | "units": "px", 132 | "value": 2 133 | }, 134 | "id": "49ff0691-624f-11ea-8b9e-fd8e6004a4d6" 135 | }, 136 | { 137 | "name": "Bottom right triangle", 138 | "type": "linear", 139 | "direction": "to bottom left", 140 | "stops": [ 141 | { 142 | "color": "transparent", 143 | "id": "28319d22-624f-11ea-8b9e-fd8e6004a4d6" 144 | }, 145 | { 146 | "color": "transparent", 147 | "position": "50%", 148 | "id": "28319d23-624f-11ea-8b9e-fd8e6004a4d6" 149 | }, 150 | { 151 | "color": "gray", 152 | "position": "50%", 153 | "id": "28319d24-624f-11ea-8b9e-fd8e6004a4d6" 154 | } 155 | ], 156 | "width": { 157 | "units": "px", 158 | "value": 10 159 | }, 160 | "height": { 161 | "units": "px", 162 | "value": 10 163 | }, 164 | "left": { 165 | "units": "px", 166 | "value": 0 167 | }, 168 | "top": { 169 | "units": "px", 170 | "value": 2 171 | }, 172 | "id": "28319d21-624f-11ea-8b9e-fd8e6004a4d6" 173 | } 174 | ] 175 | -------------------------------------------------------------------------------- /src/undo-redo/undo-redo-store.js: -------------------------------------------------------------------------------- 1 | import { deepCopy } from "@/common/utils"; 2 | 3 | export default { 4 | state: { 5 | snapshots: [], 6 | snapshotIndex: -1 7 | }, 8 | getters: { 9 | canUndo: state => 0 < state.snapshotIndex && 0 < state.snapshots.length, 10 | canRedo: state => state.snapshotIndex < state.snapshots.length - 1, 11 | currentSnapshot: state => state.snapshots[state.snapshotIndex], 12 | nextSnapshot: state => state.snapshots[state.snapshotIndex + 1], 13 | previousSnapshot: state => state.snapshots[state.snapshotIndex - 1], 14 | snapshots: state => state.snapshots 15 | }, 16 | mutations: { 17 | addSnapshot(state, layers) { 18 | state.snapshots.push(deepCopy(layers)); 19 | state.snapshotIndex += 1; 20 | }, 21 | undo(state) { 22 | state.snapshotIndex -= 1; 23 | }, 24 | redo(state) { 25 | state.snapshotIndex += 1; 26 | }, 27 | resetSnapshots(state) { 28 | state.snapshots = []; 29 | state.snapshotIndex = -1; 30 | }, 31 | trimSnaphots(state) { 32 | state.snapshots = state.snapshots.slice(0, state.snapshotIndex + 1); 33 | } 34 | }, 35 | actions: { 36 | addSnapshot({ commit, getters }) { 37 | if (getters.canRedo) { 38 | commit("trimSnaphots"); 39 | } 40 | commit("addSnapshot", getters.allLayers); 41 | }, 42 | undo({ commit, dispatch, getters }) { 43 | if (getters.canUndo) { 44 | dispatch("setLayers", deepCopy(getters.previousSnapshot)); 45 | commit("undo"); 46 | } 47 | }, 48 | redo({ commit, dispatch, getters }) { 49 | if (getters.canRedo) { 50 | dispatch("setLayers", deepCopy(getters.nextSnapshot)); 51 | commit("redo"); 52 | } 53 | }, 54 | resetSnapshots({ commit }) { 55 | commit("resetSnapshots"); 56 | } 57 | } 58 | }; 59 | -------------------------------------------------------------------------------- /src/undo-redo/undo-redo-store.spec.js: -------------------------------------------------------------------------------- 1 | import undoRedo from "@/undo-redo/undo-redo-store"; 2 | import shapes from "../editor/shapes-store"; 3 | import Vue from "vue"; 4 | import Vuex from "vuex"; 5 | import { deepCopy } from "@/common/utils"; 6 | 7 | Vue.use(Vuex); 8 | 9 | const store = new Vuex.Store({ 10 | modules: { 11 | shapes, 12 | undoRedo 13 | } 14 | }); 15 | 16 | beforeEach(() => { 17 | store.dispatch("resetSnapshots"); 18 | }); 19 | 20 | describe("Undo / redo store", () => { 21 | beforeEach(() => { 22 | store.dispatch("resetSnapshots"); 23 | store.dispatch("setShapes", []); 24 | }); 25 | it("save a snapshot", () => { 26 | expect(store.getters.latestDelta).toBeUndefined(); 27 | store.dispatch("setLayers", [{ dummyObject: Date.now() }]); 28 | store.dispatch("addSnapshot"); 29 | expect(store.getters.currentSnapshot).toBeDefined(); 30 | }); 31 | it("undo and redo", () => { 32 | const layersBefore = deepCopy(store.getters.allLayers); 33 | store.dispatch("addSnapshot"); 34 | store.dispatch("setLayers", { ...layersBefore, dummyObject: Date.now() }); 35 | const layersAfter = deepCopy(store.getters.allLayers); 36 | store.dispatch("addSnapshot"); 37 | store.dispatch("undo"); 38 | expect(store.getters.allLayers).toEqual(layersBefore); 39 | store.dispatch("redo"); 40 | expect(store.getters.allLayers).toEqual(layersAfter); 41 | }); 42 | it("undo and redo several times", () => { 43 | const layersBefore = store.getters.allLayers; 44 | store.dispatch("addSnapshot"); 45 | store.dispatch("setLayers", { dummyObject: 1 }); 46 | store.dispatch("addSnapshot"); 47 | store.dispatch("setLayers", { dummyObject: 2 }); 48 | store.dispatch("addSnapshot"); 49 | const layersAfter = store.getters.allLayers; 50 | store.dispatch("undo"); 51 | store.dispatch("undo"); 52 | expect(store.getters.allLayers).toEqual(layersBefore); 53 | store.dispatch("redo"); 54 | store.dispatch("redo"); 55 | expect(store.getters.allLayers).toEqual(layersAfter); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /src/undo-redo/undo-shape.json: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jperals/zerodivs/d272e296d7384d3a5ec172c54264aa28382bfa30/src/undo-redo/undo-shape.json -------------------------------------------------------------------------------- /src/warn.js: -------------------------------------------------------------------------------- 1 | export function warn(message) { 2 | // eslint-disable-next-line 3 | console.warn(message); 4 | } 5 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import vue from "@vitejs/plugin-vue"; 3 | import path from "node:path"; 4 | 5 | const customElements = new Set(["pinch-zoom"]); 6 | 7 | // https://vitejs.dev/config/ 8 | export default defineConfig({ 9 | plugins: [ 10 | vue({ 11 | template: { 12 | compilerOptions: { 13 | isCustomElement: tag => customElements.has(tag) 14 | } 15 | } 16 | }) 17 | ], 18 | resolve: { 19 | alias: { 20 | "@": path.resolve(__dirname, "./src") 21 | } 22 | } 23 | }); 24 | -------------------------------------------------------------------------------- /vue.config.js: -------------------------------------------------------------------------------- 1 | const customElements = new Set(["pinch-zoom"]); 2 | 3 | module.exports = { 4 | chainWebpack: config => { 5 | config.module 6 | .rule("vue") 7 | .use("vue-loader") 8 | .tap(options => { 9 | options.compilerOptions = { 10 | ...options.compilerOptions, 11 | isCustomElement: tag => customElements.has(tag) 12 | }; 13 | return options; 14 | }); 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /zerodivs-sample-03-x10.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jperals/zerodivs/d272e296d7384d3a5ec172c54264aa28382bfa30/zerodivs-sample-03-x10.gif --------------------------------------------------------------------------------