├── .babelrc ├── assets ├── favicon.ico ├── icons │ ├── icon-128x128.png │ ├── icon-144x144.png │ ├── icon-152x152.png │ ├── icon-192x192.png │ ├── icon-256x256.png │ └── ios-180x180.png └── vectors │ ├── triangulart_logo.svg │ ├── icons.svg │ └── triangulart_icon.svg ├── .gitignore ├── .editorconfig ├── src ├── services │ ├── downloader.js │ ├── fullscreenHelper.js │ ├── backStack.js │ ├── storage.js │ ├── keybinding.js │ └── triangulr.js ├── components │ ├── launcher │ │ ├── intro │ │ │ ├── fileLoad │ │ │ │ └── FileLoad.vue │ │ │ └── Intro.vue │ │ ├── newCanvasForm │ │ │ ├── gridOrientationPicker │ │ │ │ └── GridOrientationPicker.vue │ │ │ └── NewCanvasForm.vue │ │ ├── workspaceBrowser │ │ │ └── WorkspaceBrowser.vue │ │ └── Launcher.vue │ └── workspace │ │ ├── Workspace.vue │ │ └── toolbar │ │ ├── ColorPicker.vue │ │ └── Toolbar.vue ├── main.js └── App.vue ├── manifest.json ├── package.json ├── LICENSE ├── README.md ├── webpack.config.js ├── service-worker.js ├── style.css └── index.html /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["env", { "modules": false }] 4 | ] 5 | } -------------------------------------------------------------------------------- /assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxwellito/triangulart/HEAD/assets/favicon.ico -------------------------------------------------------------------------------- /assets/icons/icon-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxwellito/triangulart/HEAD/assets/icons/icon-128x128.png -------------------------------------------------------------------------------- /assets/icons/icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxwellito/triangulart/HEAD/assets/icons/icon-144x144.png -------------------------------------------------------------------------------- /assets/icons/icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxwellito/triangulart/HEAD/assets/icons/icon-152x152.png -------------------------------------------------------------------------------- /assets/icons/icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxwellito/triangulart/HEAD/assets/icons/icon-192x192.png -------------------------------------------------------------------------------- /assets/icons/icon-256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxwellito/triangulart/HEAD/assets/icons/icon-256x256.png -------------------------------------------------------------------------------- /assets/icons/ios-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxwellito/triangulart/HEAD/assets/icons/ios-180x180.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | dist/build.js.map 4 | npm-debug.log 5 | yarn-error.log 6 | 7 | # Editor directories and files 8 | .idea 9 | *.suo 10 | *.ntvs* 11 | *.njsproj 12 | *.sln -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | end_of_line = lf 9 | insert_final_newline = true 10 | 11 | # Matches multiple files with brace expansion notation 12 | # Set default charset 13 | [*.{js,html,css}] 14 | charset = utf-8 15 | indent_style = space 16 | indent_size = 2 17 | -------------------------------------------------------------------------------- /src/services/downloader.js: -------------------------------------------------------------------------------- 1 | let downloadAnchor = document.createElement('a') 2 | downloadAnchor.style.display = 'none' 3 | document.body.appendChild(downloadAnchor) 4 | 5 | /** 6 | * From http://jsfiddle.net/koldev/cw7w5/ 7 | * >> +1 Good Job! 8 | */ 9 | let downloader = function (data, fileName) { 10 | let blob = new Blob([data], {type: 'octet/stream'}), 11 | url = window.URL.createObjectURL(blob) 12 | downloadAnchor.href = url 13 | downloadAnchor.download = fileName 14 | downloadAnchor.click() 15 | window.setTimeout(function () { 16 | window.URL.revokeObjectURL(url) 17 | }, 10) 18 | } 19 | 20 | export default downloader -------------------------------------------------------------------------------- /src/components/launcher/intro/fileLoad/FileLoad.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | -------------------------------------------------------------------------------- /src/components/workspace/Workspace.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 29 | 30 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Triangulart", 3 | "short_name": "Triangulart", 4 | "icons": [{ 5 | "src": "./assets/icons/icon-128x128.png", 6 | "sizes": "128x128", 7 | "type": "image/png" 8 | }, { 9 | "src": "./assets/icons/icon-144x144.png", 10 | "sizes": "144x144", 11 | "type": "image/png" 12 | }, { 13 | "src": "./assets/icons/icon-152x152.png", 14 | "sizes": "152x152", 15 | "type": "image/png" 16 | }, { 17 | "src": "./assets/icons/icon-192x192.png", 18 | "sizes": "192x192", 19 | "type": "image/png" 20 | }, { 21 | "src": "./assets/icons/icon-256x256.png", 22 | "sizes": "256x256", 23 | "type": "image/png" 24 | }], 25 | "start_url": "./", 26 | "display": "standalone", 27 | "orientation": "any", 28 | "background_color": "#f9f9f9", 29 | "theme_color": "#f9f9f9" 30 | } 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "triangulart", 3 | "description": "Dummy graphic editor to make isometric illustrations", 4 | "version": "1.0.0", 5 | "author": "maxwellito", 6 | "license": "MIT", 7 | "scripts": { 8 | "dev": "cross-env NODE_ENV=development webpack-dev-server --open --hot", 9 | "build": "cross-env NODE_ENV=production webpack --progress --hide-modules" 10 | }, 11 | "dependencies": { 12 | "vue": "^2.3.3", 13 | "vue-color": "^2.4.3" 14 | }, 15 | "devDependencies": { 16 | "babel-core": "^6.0.0", 17 | "babel-loader": "^6.0.0", 18 | "babel-preset-env": "^1.5.1", 19 | "cross-env": "^3.0.0", 20 | "css-loader": "^0.25.0", 21 | "file-loader": "^0.9.0", 22 | "node-sass": "^4.5.0", 23 | "sass-loader": "^5.0.1", 24 | "vue-loader": "^12.1.0", 25 | "vue-template-compiler": "^2.3.3", 26 | "webpack": "^2.6.1", 27 | "webpack-dev-server": "^2.4.5" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import App from './App.vue' 3 | 4 | new Vue({ 5 | el: '#app', 6 | render: h => h(App) 7 | }) 8 | 9 | Vue.filter('timeAgo', function (value) { 10 | let gap = (+(new Date()) - value) / 1000 11 | if (gap < 2) { 12 | return 'just now' 13 | } 14 | else if (gap < 60) { 15 | return Math.floor(gap) + 's ago' 16 | } 17 | else if (gap < 3600) { 18 | return Math.floor(gap/60) + 'min ago' 19 | } 20 | else if (gap < (3600 * 24)) { 21 | return Math.floor(gap/3600) + 'h ago' 22 | } 23 | else if (gap < (3600 * 24 * 30)) { 24 | return Math.floor(gap/(3600*24)) + ' day(s) ago' 25 | } 26 | else if (gap < (3600 * 24 * 365)) { 27 | return Math.floor(gap/(3600*24*30)) + ' month(s) ago' 28 | } 29 | else { 30 | return Math.floor(gap/(3600*24*365)) + ' year(s) ago' 31 | } 32 | }) 33 | 34 | //# TEMP: enable the unload warning 35 | // Prevent quit 36 | // window.onbeforeunload = function() { 37 | // return "All current work will be destroyed and lost. Be sure you have saved or exported your work."; 38 | // } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 maxwellito 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/services/fullscreenHelper.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Fullscreen polyfills 3 | */ 4 | 5 | let fullscreenHelper = { 6 | 7 | /** 8 | * Checks if the browser is already in fullscreen mode 9 | */ 10 | isFullscreen: function () { 11 | return (document.fullScreenElement && document.fullScreenElement !== null) || 12 | (document.mozFullScreen || document.webkitIsFullScreen); 13 | }, 14 | 15 | /** 16 | * Enter in fullscreen mode 17 | */ 18 | enterFullscreen: function () { 19 | var docElm = document.documentElement; 20 | 21 | if (docElm.requestFullscreen) { 22 | docElm.requestFullscreen(); 23 | } 24 | else if (docElm.mozRequestFullScreen) { 25 | docElm.mozRequestFullScreen(); 26 | } 27 | else if (docElm.webkitRequestFullScreen) { 28 | docElm.webkitRequestFullScreen(); 29 | } 30 | }, 31 | 32 | /** 33 | * Exit the fullscreen mode 34 | */ 35 | exitFullscreen: function () { 36 | if (document.documentElement.exitFullscreen) { 37 | document.documentElement.exitFullscreen(); 38 | } 39 | else if (document.mozCancelFullScreen) { 40 | document.mozCancelFullScreen(); 41 | } 42 | else if (document.webkitExitFullscreen) { 43 | document.webkitExitFullscreen(); 44 | } 45 | }, 46 | 47 | /** 48 | * Toggle the fullscreen mode 49 | */ 50 | toggle: function () { 51 | if (!fullscreenHelper.isFullscreen()) { 52 | fullscreenHelper.enterFullscreen(); 53 | } 54 | else { 55 | fullscreenHelper.exitFullscreen(); 56 | } 57 | } 58 | } 59 | 60 | export default fullscreenHelper -------------------------------------------------------------------------------- /src/services/backStack.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Object to keep trace of modifications applied to the 3 | * workspace to rollback later. 4 | */ 5 | function BackStack () { 6 | this.reset() 7 | } 8 | 9 | /** 10 | * Reset the entire stack to initial state 11 | */ 12 | BackStack.prototype.reset = function () { 13 | this.stack = [] 14 | this.currentAction = null 15 | } 16 | 17 | /** 18 | * Start collecting for a new action in progress 19 | */ 20 | BackStack.prototype.startAction = function () { 21 | this.currentAction = [] 22 | } 23 | 24 | /** 25 | * Add modifications applied to the current action. 26 | * Every modification is based on 2 things: position and value 27 | * If a position has already been set in the current action 28 | * it will be ignored. 29 | * 30 | * @param position number Triangle index to modify 31 | * @param position string Old value of the triangle 32 | */ 33 | BackStack.prototype.actionStack = function (position, oldValue) { 34 | if (!this.currentAction.find(x => x[0] === position)) { 35 | this.currentAction.push([position, oldValue]) 36 | } 37 | } 38 | 39 | /** 40 | * Ends the current action and stack it 41 | */ 42 | BackStack.prototype.endAction = function () { 43 | if (!this.currentAction || !this.currentAction.length) { 44 | return 45 | } 46 | this.stack.push(this.currentAction) 47 | this.currentAction = null 48 | } 49 | 50 | /** 51 | * Extract the last action from the stack and return it 52 | * @return array Action object 53 | */ 54 | BackStack.prototype.popLastAction = function () { 55 | return this.stack.pop() 56 | } 57 | 58 | export default BackStack -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # triangulart (v2 beta) 2 | 3 | Dummy graphic editor to make isometric illustrations. It's like pixel art, but with triangles. 4 | 5 | Try it on [maxwellito.github.io/triangulart](https://maxwellito.github.io/triangulart) 6 | 7 | This version is still on beta and not fully stable. 8 | 9 | ## File format 10 | 11 | On the v2, we are making things simpler, one unique file format : the SVG. On the first version the editable format was in JSON, and I think it was a bad design choice. Having one format make it easier for the final user. 12 | 13 | Here are the details 14 | 15 | ```xml 16 | 17 | 18 | 19 | 20 | 21 | 22 | ... 23 | 24 | ``` 25 | 26 | The SVG first child node is a comment containing the JSON of the basic details of the artwork. It contains the orientation, width, height, and the palette. 27 | Then every path got the `rel` attribute to contain the triangle index. 28 | 29 | ## for v2.1 (aka neverland) 30 | 31 | - Auto save (but better, with little signal) 32 | - Responsive layout (the menu and nav is clunky) 33 | - Better care of error cases 34 | - MASSIVE PERF ISSUES ON BIG WORKSPACE : MOVE TO CANVAS (and light the weight of triangulart class) 35 | - Zoom in/out 36 | - Clipboard! -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | var path = require('path') 2 | var webpack = require('webpack') 3 | 4 | module.exports = { 5 | entry: './src/main.js', 6 | output: { 7 | path: path.resolve(__dirname, './dist'), 8 | publicPath: '/dist/', 9 | filename: 'build.js' 10 | }, 11 | module: { 12 | rules: [ 13 | { 14 | test: /\.vue$/, 15 | loader: 'vue-loader', 16 | options: { 17 | loaders: { 18 | // Since sass-loader (weirdly) has SCSS as its default parse mode, we map 19 | // the "scss" and "sass" values for the lang attribute to the right configs here. 20 | // other preprocessors should work out of the box, no loader config like this necessary. 21 | 'scss': 'vue-style-loader!css-loader!sass-loader', 22 | 'sass': 'vue-style-loader!css-loader!sass-loader?indentedSyntax' 23 | } 24 | // other vue-loader options go here 25 | } 26 | }, 27 | { 28 | test: /\.js$/, 29 | loader: 'babel-loader', 30 | exclude: /node_modules/ 31 | }, 32 | { 33 | test: /\.(png|jpg|gif|svg)$/, 34 | loader: 'file-loader', 35 | options: { 36 | name: '[name].[ext]?[hash]' 37 | } 38 | } 39 | ] 40 | }, 41 | resolve: { 42 | alias: { 43 | 'vue$': 'vue/dist/vue.esm.js' 44 | } 45 | }, 46 | devServer: { 47 | historyApiFallback: true, 48 | noInfo: true 49 | }, 50 | performance: { 51 | hints: false 52 | }, 53 | devtool: '#eval-source-map' 54 | } 55 | 56 | if (process.env.NODE_ENV === 'production') { 57 | module.exports.devtool = '#source-map' 58 | // http://vue-loader.vuejs.org/en/workflow/production.html 59 | module.exports.plugins = (module.exports.plugins || []).concat([ 60 | new webpack.DefinePlugin({ 61 | 'process.env': { 62 | NODE_ENV: '"production"' 63 | } 64 | }), 65 | new webpack.optimize.UglifyJsPlugin({ 66 | sourceMap: true, 67 | compress: { 68 | warnings: false 69 | } 70 | }), 71 | new webpack.LoaderOptionsPlugin({ 72 | minimize: true 73 | }) 74 | ]) 75 | } -------------------------------------------------------------------------------- /src/components/launcher/newCanvasForm/gridOrientationPicker/GridOrientationPicker.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 44 | 45 | -------------------------------------------------------------------------------- /src/components/workspace/toolbar/ColorPicker.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 61 | 62 | -------------------------------------------------------------------------------- /service-worker.js: -------------------------------------------------------------------------------- 1 | var APP_NAME = 'triangulart', 2 | APP_VERSION = 3, 3 | CACHE_NAME = APP_NAME + '_' + APP_VERSION; 4 | var filesToCache = [ 5 | './', 6 | './style.css', 7 | './dist/build.js', 8 | './assets/vectors/triangulart_logo.svg' 9 | ]; 10 | 11 | // Service worker from Google Documentation 12 | 13 | self.addEventListener('install', function(event) { 14 | // Perform install steps 15 | event.waitUntil( 16 | caches.open(CACHE_NAME) 17 | .then(function(cache) { 18 | return cache.addAll(filesToCache); 19 | }) 20 | ); 21 | }); 22 | 23 | self.addEventListener('activate', function(event) { 24 | event.waitUntil( 25 | caches.keys().then(function(cacheNames) { 26 | return Promise.all( 27 | cacheNames.map(function(cacheName) { 28 | if (cacheName.indexOf(APP_NAME) === 0 && CACHE_NAME !== cacheName) { 29 | return caches.delete(cacheName); 30 | } 31 | }) 32 | ); 33 | }) 34 | ); 35 | }); 36 | 37 | self.addEventListener('fetch', function(event) { 38 | event.respondWith( 39 | caches.match(event.request) 40 | .then(function(response) { 41 | // Cache hit - return response 42 | if (response) { 43 | return response; 44 | } 45 | 46 | // IMPORTANT: Clone the request. A request is a stream and 47 | // can only be consumed once. Since we are consuming this 48 | // once by cache and once by the browser for fetch, we need 49 | // to clone the response. 50 | var fetchRequest = event.request.clone(); 51 | 52 | return fetch(fetchRequest).then( 53 | function(response) { 54 | // Check if we received a valid response 55 | if(!response || response.status !== 200 || response.type !== 'basic') { 56 | return response; 57 | } 58 | 59 | // IMPORTANT: Clone the response. A response is a stream 60 | // and because we want the browser to consume the response 61 | // as well as the cache consuming the response, we need 62 | // to clone it so we have two streams. 63 | var responseToCache = response.clone(); 64 | 65 | caches.open(CACHE_NAME) 66 | .then(function(cache) { 67 | cache.put(event.request, responseToCache); 68 | }); 69 | 70 | return response; 71 | } 72 | ); 73 | }) 74 | ); 75 | }); -------------------------------------------------------------------------------- /src/components/launcher/newCanvasForm/NewCanvasForm.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 67 | 68 | -------------------------------------------------------------------------------- /src/components/launcher/workspaceBrowser/WorkspaceBrowser.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 63 | 64 | -------------------------------------------------------------------------------- /src/services/storage.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Storage syntax 3 | * 4 | * canvas_index = [{id: 1, name: 'Love', date: 1254483840, {id: 2, name: 'Love', date: 1254483840, ...] 5 | * canvas_lastid = 5 6 | * canvas_1 = {...} 7 | * 8 | */ 9 | 10 | const INDEX_KEY = 'workspace_index'; 11 | const INDEX_NEXT_ID = 'workspace_nextid'; 12 | const INDEX_BASE_KEY = 'workspace_'; 13 | 14 | var storage = { 15 | 16 | indexes: null, 17 | 18 | loadIndexes: function () { 19 | if (!this.indexes) { 20 | try { 21 | this.indexes = JSON.parse(localStorage.getItem(INDEX_KEY)) || [] 22 | } 23 | catch (e) { 24 | throw new Error('Triangulart | storage::loadIndexes | Error while retrieving the indexes from the local storage') 25 | } 26 | } 27 | return this.indexes 28 | }, 29 | 30 | saveIndexes: function () { 31 | if (this.indexes) { 32 | try { 33 | localStorage.setItem(INDEX_KEY, JSON.stringify(this.indexes)) 34 | } 35 | catch (e) { 36 | throw new Error('Triangulart | storage::saveIndexes | Error while saving the indexes on the local storage') 37 | } 38 | } 39 | return this.indexes 40 | }, 41 | 42 | getIndex: function (id) { 43 | var item = this.loadIndexes().find(x => x.id === id) 44 | if (!item) { 45 | throw new Error('Triangulart | storage::getIndex | Error while retrieving an item from its index') 46 | } 47 | return item 48 | }, 49 | 50 | getItem: function (id) { 51 | var item = JSON.parse(localStorage.getItem(INDEX_BASE_KEY + id)) 52 | if (!item) { 53 | throw new Error('Triangulart | storage::getItem | Trying to retrieve unexisting data') 54 | } 55 | return item 56 | }, 57 | 58 | renameItem: function (id, name) { 59 | var item = this.getIndex(id) 60 | item.name = name 61 | this.saveIndexes() 62 | }, 63 | 64 | updateItem: function (id, data) { 65 | var item = this.getIndex(id) 66 | item.date = +(new Date()) 67 | localStorage.setItem(INDEX_BASE_KEY + id, JSON.stringify(data)) 68 | this.saveIndexes() 69 | }, 70 | 71 | createItem: function (name) { 72 | let id = +(localStorage.getItem(INDEX_NEXT_ID)) || 0 73 | localStorage.setItem(INDEX_NEXT_ID, id + 1) 74 | 75 | var item = { 76 | id, 77 | name, 78 | date: +(new Date()) 79 | } 80 | this.loadIndexes().push(item) 81 | this.saveIndexes() 82 | return item 83 | }, 84 | 85 | deleteItem: function (id) { 86 | var itemIndex = this.loadIndexes().findIndex(x => x.id === id) 87 | if (!~itemIndex) { 88 | throw new Error('Triangulart | storage::deleteItem | Couldn\'t find the item to delete') 89 | } 90 | this.indexes.splice(itemIndex, 1) 91 | this.saveIndexes() 92 | } 93 | } 94 | 95 | export default storage -------------------------------------------------------------------------------- /src/components/launcher/Launcher.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 45 | 46 | -------------------------------------------------------------------------------- /assets/vectors/triangulart_logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 76 | -------------------------------------------------------------------------------- /style.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Generic 3 | */ 4 | html, body { 5 | font-family: sans-serif; 6 | margin: 0; 7 | } 8 | 9 | html, body, input { 10 | color: #333935; 11 | } 12 | 13 | h1, h2, h3, h4, h5, h6 { 14 | font-weight: 300; 15 | } 16 | 17 | a { 18 | color: inherit; 19 | } 20 | 21 | small { 22 | font-size: .75em; 23 | opacity: .75; 24 | } 25 | 26 | svg path { 27 | user-select: none; 28 | -moz-user-select: none; 29 | -khtml-user-select: none; 30 | -webkit-user-select: none; 31 | -o-user-select: none; 32 | } 33 | 34 | .hidden { 35 | display: none; 36 | } 37 | 38 | .center { 39 | text-align: center; 40 | } 41 | 42 | 43 | /** 44 | * Component 45 | */ 46 | 47 | .icon { 48 | width: 1rem; 49 | height: 1rem; 50 | } 51 | 52 | .notif { 53 | display: inline-block; 54 | min-width: .6em; 55 | height: 1em; 56 | border-radius: .7em; 57 | padding: .2em .4em; 58 | line-height: 1.2; 59 | color: #fff; 60 | background-color: #f00; 61 | text-align: center; 62 | } 63 | 64 | 65 | .table { 66 | width: 100%; 67 | border-collapse: collapse; 68 | } 69 | 70 | .table td { 71 | padding: .5rem 0; 72 | } 73 | 74 | .table tr + tr td { 75 | border-top: 1px solid currentColor; 76 | } 77 | 78 | 79 | .button { 80 | padding: 0.25rem .375rem; 81 | color: inherit; 82 | background: none; 83 | line-height: 1; 84 | cursor: pointer; 85 | } 86 | 87 | .button:active { 88 | background-color: rgba(0,0,0,.25); 89 | } 90 | .button:active, .button:focus { 91 | outline: none; 92 | } 93 | 94 | .button-group { 95 | display: inline-block; 96 | } 97 | 98 | .button, .button-group { 99 | border: 1px solid currentColor; 100 | border-radius: 4px; 101 | } 102 | 103 | .button-group { 104 | min-width: 1.5rem; 105 | vertical-align: top; 106 | } 107 | 108 | .button-group > * { 109 | border: none; 110 | padding: .25em; 111 | font-size: 1rem; 112 | border-left: 1px solid currentColor; 113 | border-radius: 0; 114 | } 115 | 116 | .button-group > *:first-child { 117 | border-left: none; 118 | } 119 | 120 | .colorlist .button { 121 | display: inline-block; 122 | height: 1rem; 123 | width: 1rem; 124 | margin: .25rem 0 .25rem .25rem; 125 | border: 1px solid gray; 126 | border-radius: 50%; 127 | } 128 | 129 | 130 | /** 131 | * Layout 132 | */ 133 | 134 | @media (min-width: 481px) { 135 | .row { 136 | display: flex; 137 | flex-direction: row; 138 | margin: 1rem 0; 139 | width: 100%; 140 | } 141 | 142 | .row-inline { 143 | align-items: center; 144 | } 145 | 146 | .row-balanced { 147 | justify-content: space-around; 148 | } 149 | 150 | .row > * { 151 | flex-grow: 1; 152 | } 153 | } 154 | 155 | 156 | /** 157 | * Transitions 158 | */ 159 | 160 | .fade-enter-active{ 161 | transition: opacity .25s linear .25s; 162 | } 163 | .fade-leave-active { 164 | transition: opacity .25s 165 | } 166 | .fade-enter, .fade-leave-to { 167 | opacity: 0 168 | } -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 76 | 77 | 102 | -------------------------------------------------------------------------------- /src/components/launcher/intro/Intro.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 66 | 67 | -------------------------------------------------------------------------------- /src/services/keybinding.js: -------------------------------------------------------------------------------- 1 | class Keybinding { 2 | /** 3 | * Init the object by listeneing to the key events 4 | */ 5 | constructor() { 6 | this.listeners = {} 7 | this.isOptionPressed = false 8 | this.keyupListener = this.onKeyUp.bind(this) 9 | this.keydownListener = this.onKeyDown.bind(this) 10 | window.addEventListener('keyup', this.keyupListener) 11 | window.addEventListener('keydown', this.keydownListener) 12 | } 13 | 14 | /** 15 | * Public method to listen keybind 16 | * @param string eventName Event to listen to 17 | * @param function listener Listener 18 | * @return function Executable to remove the listener 19 | */ 20 | on(eventName, listener) { 21 | if (!EVENTS[eventName]) { 22 | throw new Error ('Ask to listen for a non existing keybinding') 23 | } 24 | this.listeners[eventName] = this.listeners[eventName] || [] 25 | this.listeners[eventName].push(listener) 26 | return () => { 27 | let listenerIndex = this.listeners[eventName].indexOf(listener) 28 | if (~listenerIndex) { 29 | this.listeners[eventName].splice(listenerIndex, 1) 30 | } 31 | } 32 | } 33 | 34 | /** 35 | * Listener for key up 36 | * @param event event Event object from window 37 | */ 38 | onKeyUp(event) { 39 | if (event.keyCode === OPTION_KEYCODE) { 40 | this.isOptionPressed = false 41 | } 42 | } 43 | 44 | /** 45 | * Listener for key down 46 | * @param event event Event object from window 47 | */ 48 | onKeyDown(event) { 49 | if (event.keyCode === OPTION_KEYCODE) { 50 | this.isOptionPressed = true 51 | } 52 | let eventSpecs, areSpecsPassing 53 | for (let eventName in EVENTS) { 54 | eventSpecs = EVENTS[eventName] 55 | areSpecsPassing = true 56 | for (let prop in eventSpecs) { 57 | if (event[prop] !== eventSpecs[prop] && !(prop === 'ctrlKey' && eventSpecs[prop] && this.isOptionPressed)) { 58 | areSpecsPassing = false 59 | } 60 | } 61 | if (areSpecsPassing) { 62 | for (let listenerIndex in this.listeners[eventName]) { 63 | this.listeners[eventName][listenerIndex]() 64 | } 65 | // Prevent default event behavior. 66 | // Except for the 'delete' on inputs/textareas 67 | if (eventName !== 'delete' || !~['INPUT', 'TEXTAREA'].indexOf(event.target.nodeName)) { 68 | event.preventDefault() 69 | } 70 | } 71 | } 72 | } 73 | 74 | /** 75 | * Public method to remove all events 76 | * to make the instance killable 77 | */ 78 | destroy() { 79 | window.removeEventListener('keyup', this.keyupListener) 80 | window.removeEventListener('keydown', this.keydownListener) 81 | } 82 | } 83 | 84 | const OPTION_KEYCODE = 91 85 | 86 | const EVENTS = { 87 | undo: { 88 | keyCode: 90, 89 | ctrlKey: true 90 | }, 91 | delete: { 92 | keyCode: 8 93 | }, 94 | cut: { 95 | keyCode: 88, 96 | ctrlKey: true 97 | }, 98 | copy: { 99 | keyCode: 67, 100 | ctrlKey: true 101 | }, 102 | paste: { 103 | keyCode: 86, 104 | ctrlKey: true 105 | }, 106 | } 107 | 108 | export default Keybinding -------------------------------------------------------------------------------- /src/components/workspace/toolbar/Toolbar.vue: -------------------------------------------------------------------------------- 1 | 70 | 71 | 147 | 148 | -------------------------------------------------------------------------------- /assets/vectors/icons.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 10 | 11 | 13 | 14 | 16 | 17 | 18 | 19 | 20 | 21 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 44 | 47 | 48 | 49 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 68 | 69 | 70 | 72 | 73 | 74 | 75 | 77 | 79 | 81 | 83 | 85 | 87 | 89 | 90 | 91 | 92 | 93 | 97 | 98 | 99 | 100 | 103 | 104 | 105 | 106 | 107 | 109 | 110 | 111 | 113 | 114 | 115 | 117 | 118 | 119 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 129 | 130 | 131 | 132 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | triangulart - isometric graphic editor 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 |
37 | 38 |
39 | 40 | 41 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 172 | 173 | 174 | 175 | -------------------------------------------------------------------------------- /src/services/triangulr.js: -------------------------------------------------------------------------------- 1 | import storage from './storage.js' 2 | import BackStack from './backStack.js' 3 | 4 | const SVG_NAMESPACE = 'http://www.w3.org/2000/svg' 5 | 6 | /** 7 | * Triangulr class 8 | * The magic artbord to manipulate the grid of pixels 9 | */ 10 | function Triangulr () {} 11 | 12 | Triangulr.prototype.BGR_COLOR = '#FFFFFF' 13 | Triangulr.prototype.DEFAULT_FILL_COLOR = '#000000' 14 | Triangulr.prototype.BLANK_COLOR = 'none' 15 | Triangulr.prototype.AUTOSAVE_TIMER = 5000 16 | Triangulr.prototype.TRIANGLE_WIDTH = 30 17 | 18 | Triangulr.prototype.ACTION_FILL = 1 19 | Triangulr.prototype.ACTION_ERASE = 2 20 | Triangulr.prototype.ACTION_MOVE = 3 21 | Triangulr.prototype.ACTION_SELECT = 4 22 | 23 | /** 24 | * Sets the container of the artboard 25 | * 26 | * @param {DOMElement|string} container Container DOM or it's ID 27 | */ 28 | Triangulr.prototype.setContainer = function (container) { 29 | if (container.constructor === String) { 30 | container = document.getElementById(container) 31 | } 32 | if (!container) { 33 | throw new Error ('Triangulr container "' + containerId + '" does not exists.') 34 | } 35 | this.container = container 36 | } 37 | 38 | /** 39 | * Set the canvas properties. 40 | * This will reset the entire instance. 41 | * 42 | * @param int width Map width 43 | * @param int height Map height 44 | * @param boolean isLandscape Grid rientation 45 | */ 46 | Triangulr.prototype.setCanvas = function (width, height, isLandscape) { 47 | // Save input 48 | this.isLandscape = isLandscape 49 | this.mapWidth = parseInt(width, 10) 50 | this.mapHeight = parseInt(height, 10) 51 | 52 | this.triangleWidth = this.TRIANGLE_WIDTH 53 | this.triangleHeight = Math.sqrt(Math.pow(this.triangleWidth, 2) - Math.pow(this.triangleWidth / 2, 2)) 54 | this.triangleHeight = Math.round(this.triangleHeight) 55 | 56 | this.blockWidth = (this.triangleWidth / 2) 57 | this.blockRatio = this.blockWidth / this.triangleHeight 58 | this.lineLength = (this.isLandscape ? this.mapWidth : this.mapHeight) * 2 - 1 59 | 60 | this.lines = [] 61 | this.exportData = [] 62 | this.palette = [] 63 | this.selection = null 64 | this.backStack = new BackStack() 65 | 66 | this.lineMapping() 67 | this.createTriangles() 68 | this.generateDom() 69 | 70 | window.debugPlayground = this //# DEV : kill this 71 | } 72 | 73 | /** 74 | * lineMapping 75 | * generate this.lines from the contructor info 76 | * 77 | */ 78 | Triangulr.prototype.lineMapping = function () { 79 | 80 | let x, y, line, 81 | parity = this.triangleWidth / 4, 82 | gap = parity 83 | 84 | if (this.isLandscape) { 85 | for (y = 0; y<=this.mapHeight; y++) { 86 | line = [] 87 | for (x = 0; x<=this.mapWidth; x++) { 88 | line.push({ 89 | x: x * this.triangleWidth + parity + gap, 90 | y: y * this.triangleHeight 91 | }) 92 | } 93 | this.lines.push(line) 94 | parity *= -1 95 | } 96 | } 97 | else { 98 | for (y = 0; y<=this.mapWidth; y++) { 99 | line = [] 100 | for (x = 0; x<=this.mapHeight; x++) { 101 | line.push({ 102 | x: y * this.triangleHeight, 103 | y: x * this.triangleWidth + parity + gap 104 | }) 105 | } 106 | this.lines.push(line) 107 | parity *= -1 108 | } 109 | } 110 | } 111 | 112 | /** 113 | * createTriangles 114 | * use points form this.lines to generate triangles 115 | * and put them into this.exportData 116 | * 117 | */ 118 | Triangulr.prototype.createTriangles = function () { 119 | 120 | let x, parity, lineA, lineB, aIndex, bIndex, points, poly, pointsList, 121 | counter = 0, 122 | lineParite = true 123 | this.exportData = [] 124 | 125 | for (x = 0; x { 179 | if (this.action === this.ACTION_SELECT && 180 | this.selection && this.selection.coordinates && 181 | childOf(e.target, this.selection.selectArea)) { 182 | this.selection.dragStart = this.coordinatorFromEvent(e) 183 | } 184 | else { 185 | moveListener(e) 186 | } 187 | } 188 | 189 | var moveListener = (e) => { 190 | let position = this.coordinatorFromEvent(e) 191 | if (!position || position.index === pos) { 192 | return 193 | } 194 | pos = position.index 195 | 196 | switch (this.action) { 197 | case this.ACTION_FILL: 198 | this.fillTriangle(pos, this.color) 199 | break 200 | 201 | case this.ACTION_ERASE: 202 | this.fillTriangle(pos) 203 | break 204 | 205 | case this.ACTION_MOVE: 206 | break 207 | 208 | case this.ACTION_SELECT: 209 | if (this.selection && this.selection.dragStart) { 210 | this.updateSelectionDrag(position) 211 | } 212 | else { 213 | this.updateSelection(position) 214 | } 215 | break 216 | } 217 | } 218 | 219 | var endActionListener = (e) => { 220 | if (this.action === this.ACTION_SELECT) { 221 | e.preventDefault() 222 | if (this.selection.coordinates) { 223 | this.endSelectionDrag() 224 | } 225 | else { 226 | this.endSelection() 227 | } 228 | this.selection.dragStart = null 229 | } 230 | this.backStack.endAction() 231 | this.saveTimeout() 232 | } 233 | 234 | // Mouse listeners 235 | svgTag.addEventListener('mousedown', (e) => { 236 | this.backStack.startAction() 237 | startActionListener(e) 238 | let mouseUpListener = (e) => { 239 | svgTag.removeEventListener('mousemove', moveListener) 240 | window.removeEventListener('mouseup', mouseUpListener) 241 | endActionListener(e) 242 | } 243 | svgTag.addEventListener('mousemove', moveListener) 244 | window.addEventListener('mouseup', mouseUpListener) 245 | }) 246 | 247 | // Touch listeners 248 | svgTag.addEventListener('touchstart', (e) => { 249 | if (this.action === this.ACTION_MOVE) { 250 | return 251 | } 252 | this.backStack.startAction() 253 | startActionListener(e) 254 | 255 | let touchEndListener = (e) => { 256 | svgTag.removeEventListener('touchmove', moveListener) 257 | window.removeEventListener('touchend', touchEndListener) 258 | endActionListener(e) 259 | } 260 | svgTag.addEventListener('touchmove', moveListener) 261 | window.addEventListener('touchend', touchEndListener) 262 | }) 263 | 264 | // Set the SVG 265 | this.svgTag = svgTag 266 | this.container.appendChild(svgTag) 267 | return svgTag 268 | } 269 | 270 | /** 271 | * Call the coordinator from an event 272 | * @param event e Mouse or touch event on the svgTag 273 | * @return object Triangle information 274 | */ 275 | Triangulr.prototype.coordinatorFromEvent = function (e) { 276 | if (~e.type.indexOf('mouse')) { 277 | return this.coordinator(e.pageX - 16, e.pageY - 16) 278 | } 279 | else { 280 | e.preventDefault(); 281 | return this.coordinator(e.touches[0].pageX - 16, e.touches[0].pageY - 16) 282 | } 283 | } 284 | 285 | /** 286 | * Return the info about a triangle available 287 | * at a specific position. 288 | * 289 | * If a triangle coordinate are avalable, the method will 290 | * return a following object 291 | * { 292 | * x: (int) column index, 293 | * y: (int) line index, 294 | * index: (int) triangle index 295 | * } 296 | * 297 | * Or null if no triangle is at these coordinates 298 | * 299 | * @param int x X position in pixels 300 | * @param int y Y position in pixels 301 | * @return object Triangle informations 302 | */ 303 | Triangulr.prototype.coordinator = function (x, y) { 304 | 305 | if (!this.isLandscape) { 306 | [x, y] = [y, x] 307 | } 308 | 309 | let line = Math.floor(y / this.triangleHeight), 310 | isEvenLine = line % 2 === 0, 311 | blockIndex = Math.floor(x / this.blockWidth), 312 | isEvenBlock = blockIndex % 2 === 0, 313 | blockX = x % this.blockWidth, 314 | blockY = y % this.triangleHeight 315 | 316 | if (isEvenBlock && isEvenLine || (!isEvenBlock && !isEvenLine)) { 317 | if ((blockX / (this.triangleHeight - blockY)) < this.blockRatio) { 318 | blockIndex-- 319 | } 320 | } 321 | else { 322 | if ((blockX / blockY) < this.blockRatio) { 323 | blockIndex-- 324 | } 325 | } 326 | 327 | if (blockIndex < 0 || blockIndex >= this.lineLength) { 328 | return null 329 | } 330 | else { 331 | return { 332 | x: blockIndex, 333 | y: line, 334 | index: this.lineLength * line + blockIndex 335 | } 336 | } 337 | } 338 | 339 | /** 340 | * Update the selection in progress. 341 | * This method is called during the drag 342 | * between the user mouse down and up. 343 | * This will update the selection rectangle. 344 | * 345 | */ 346 | Triangulr.prototype.updateSelection = function (position) { 347 | if (this.selection && this.selection.coordinates) { 348 | this.clearSelection() 349 | } 350 | if (!this.selection) { 351 | this.selection = { 352 | start: position, 353 | selectArea: document.createElementNS(SVG_NAMESPACE, 'rect') 354 | } 355 | this.selection.selectArea.setAttribute('class', 'selector-rect') 356 | this.svgTag.appendChild(this.selection.selectArea) 357 | } 358 | this.selection.end = position 359 | 360 | let start = this.selection.start, 361 | end = this.selection.end, 362 | rect = this.selection.selectArea, 363 | minX = Math.min(start.x, end.x) * this.blockWidth, 364 | maxX = (Math.max(start.x, end.x) + 2) * this.blockWidth, 365 | minY = Math.min(start.y, end.y) * this.triangleHeight, 366 | maxY = (Math.max(start.y, end.y) + 1) * this.triangleHeight 367 | 368 | if (this.isLandscape) { 369 | rect.setAttribute('x', minX) 370 | rect.setAttribute('y', minY) 371 | rect.setAttribute('width', maxX - minX) 372 | rect.setAttribute('height', maxY - minY) 373 | } 374 | else { 375 | rect.setAttribute('x', minY) 376 | rect.setAttribute('y', minX) 377 | rect.setAttribute('width', maxY - minY) 378 | rect.setAttribute('height', maxX - minX) 379 | } 380 | } 381 | 382 | /** 383 | * Ends the selection in progress. 384 | * It will finalise the selection rectangle and 385 | * set the `coordinates` properties to the 386 | * `selection` object to provide all the required 387 | * information about the selection. 388 | * 389 | * width selection width 390 | * height selection height 391 | * offsetX origin position X of the selection 392 | * offsetY origin position XY of the selection 393 | * moveX drag X coordinates 394 | * moveY drag Y coordinates 395 | * 396 | */ 397 | Triangulr.prototype.endSelection = function () { 398 | if (!this.selection) { 399 | return 400 | } 401 | 402 | let blank, 403 | blankArea = document.createElementNS(SVG_NAMESPACE, 'g'), 404 | clones = document.createElementNS(SVG_NAMESPACE, 'g'), 405 | start = this.selection.start, 406 | end = this.selection.end, 407 | offsetX = Math.min(start.x, end.x), 408 | width = Math.max(start.x, end.x) - offsetX + 1, 409 | offsetY = Math.min(start.y, end.y), 410 | height = Math.max(start.y, end.y) - offsetY + 1 411 | 412 | this.selection.coordinates = { 413 | width, 414 | height, 415 | offsetX, 416 | offsetY, 417 | moveX: 0, 418 | moveY: 0 419 | } 420 | 421 | this.indexesFromCoordinates(offsetX, offsetY, width, height) 422 | .map(index => { 423 | blank = this.svgTag.childNodes[index].cloneNode() 424 | clones.appendChild(blank.cloneNode()) 425 | blank.setAttribute('fill', this.BGR_COLOR) 426 | blankArea.appendChild(blank) 427 | }) 428 | 429 | clones.appendChild(this.selection.selectArea) 430 | clones.setAttribute('class', 'movable') 431 | this.selection.selectArea = clones 432 | this.selection.blankArea = blankArea 433 | this.svgTag.appendChild(blankArea) 434 | this.svgTag.appendChild(clones) 435 | } 436 | 437 | /** 438 | * Bulk method to return a list of triangle 439 | * indexes from a map area. 440 | * If the coordinates are invalid, 441 | * the method will throw an error. 442 | * 443 | * (2, 0, 1, 2) 444 | * > [4, 5, 132, 133] 445 | */ 446 | Triangulr.prototype.indexesFromCoordinates = function (x, y, width, height) { 447 | if (x < 0 || y < 0 || width < 0 || height < 0 || (x+width) > this.lineLength || (y+height) > this.mapHeight) { 448 | throw new Error ('Try to get indexes from invalid coordinates') 449 | } 450 | let output = [] 451 | for (let yPos = 0; yPos < height; yPos++) { 452 | for (let xPos = 0; xPos < width; xPos++) { 453 | output.push((yPos + y) * this.lineLength + xPos + x) 454 | } 455 | } 456 | return output 457 | } 458 | 459 | /** 460 | * Update the dragging of the selection 461 | * @param object position Position object from coordinator method 462 | */ 463 | Triangulr.prototype.updateSelectionDrag = function (position) { 464 | let coor = this.selection.coordinates, 465 | dragX = Math.round((position.x - this.selection.dragStart.x)/2) * 2, 466 | dragY = Math.round((position.y - this.selection.dragStart.y)/2) * 2 467 | 468 | if (this.isLandscape) { 469 | let newX = dragX + coor.offsetX + coor.moveX, 470 | newY = dragY + coor.offsetY + coor.moveY 471 | 472 | if (newX >= 0 && (newX + coor.width) <= this.lineLength && newY >= 0 && (newY + coor.height) <= this.mapHeight) { 473 | coor.dragX = dragX 474 | coor.dragY = dragY 475 | this.selection.selectArea.style.transform = `translate(${(coor.moveX+dragX)*this.blockWidth}px,${(coor.moveY+dragY)*this.triangleHeight}px)` 476 | } 477 | } 478 | else { 479 | [dragX, dragY] = [dragY, dragX] 480 | let newX = dragY + coor.offsetX + coor.moveY, 481 | newY = dragX + coor.offsetY + coor.moveX 482 | 483 | if (newX >= 0 && (newX + coor.width) <= this.lineLength && newY >= 0 && (newY + coor.height) <= this.mapHeight) { 484 | coor.dragX = dragX 485 | coor.dragY = dragY 486 | this.selection.selectArea.style.transform = `translate(${(coor.moveX+dragX)*this.triangleHeight}px,${(coor.moveY+dragY)*this.blockWidth}px)` 487 | } 488 | } 489 | } 490 | 491 | /** 492 | * Method called at the en od a drag 493 | * move on a selection. (:when you move 494 | * a selection from a point A to B) 495 | */ 496 | Triangulr.prototype.endSelectionDrag = function () { 497 | let coordinates = this.selection.coordinates 498 | coordinates.moveX += coordinates.dragX 499 | coordinates.moveY += coordinates.dragY 500 | } 501 | 502 | /** 503 | * Apply the current seelction if exists (and moved) 504 | * and clear the related items to the selection. 505 | */ 506 | Triangulr.prototype.clearSelection = function () { 507 | if (!this.selection) { 508 | return 509 | } 510 | this.applySelection() 511 | if (this.selection.blankArea) { 512 | this.svgTag.removeChild(this.selection.blankArea) 513 | } 514 | this.svgTag.removeChild(this.selection.selectArea) 515 | this.selection = null 516 | } 517 | 518 | /** 519 | * Apply the current selection 520 | */ 521 | Triangulr.prototype.applySelection = function () { 522 | if (!this.selection || !this.selection.coordinates || 523 | (!this.selection.coordinates.moveX && !this.selection.coordinates.moveY && !this.selection.action)) { 524 | return 525 | } 526 | this.backStack.startAction() 527 | let c = this.selection.coordinates, 528 | action = this.selection.action || {}, 529 | colors = this.indexesFromCoordinates( 530 | c.offsetX, 531 | c.offsetY, 532 | c.width, 533 | c.height 534 | ).map(index => { 535 | let cc = this.exportData[index].color 536 | this.fillTriangle(index) 537 | return cc 538 | }) 539 | 540 | if (!action.erase) { 541 | this.indexesFromCoordinates( 542 | c.offsetX + (this.isLandscape ? c.moveX : c.moveY), 543 | c.offsetY + (this.isLandscape ? c.moveY : c.moveX), 544 | c.width, 545 | c.height 546 | ) 547 | .forEach((pointIndex, index) => { 548 | this.fillTriangle(pointIndex, action.fill || colors[index]) 549 | }) 550 | } 551 | this.backStack.endAction() 552 | } 553 | 554 | /** 555 | * Fill the current selection with a color 556 | * It will update manually the selection DOM 557 | * and the color to fill for `applySelection` method. 558 | * 559 | * @param string color New color to set 560 | */ 561 | Triangulr.prototype.fillSelection = function (color) { 562 | if (!this.selection || !this.selection.coordinates) { 563 | return 564 | } 565 | 566 | this.selection.selectArea 567 | .querySelectorAll('path') 568 | .forEach(path => path.setAttribute('fill', color || this.BGR_COLOR)) 569 | this.selection.action = {fill: color} 570 | } 571 | 572 | /** 573 | * Erase the current selection and clear it. 574 | */ 575 | Triangulr.prototype.eraseSelection = function () { 576 | if (!this.selection || !this.selection.coordinates) { 577 | return 578 | } 579 | this.selection.action = {erase: true} 580 | this.clearSelection() 581 | } 582 | 583 | /** 584 | * Set the new color to a triangle. 585 | * This method will apply all the necessary steps: 586 | * - Add the update to the backstack 587 | * - Update the color in the export data 588 | * - Update the SVG 589 | * 590 | * @param number pos Index position of the triangle 591 | * @param color string New color to set (undefined for erasing) 592 | */ 593 | Triangulr.prototype.fillTriangle = function (pos, color) { 594 | this.backStack.actionStack(pos, this.exportData[pos].color) 595 | this.exportData[pos].color = color === undefined ? null : (color || this.exportData[pos].color) 596 | this.svgTag.childNodes[pos].setAttribute('fill', this.exportData[pos].color || this.BLANK_COLOR) 597 | } 598 | 599 | /** 600 | * Generate the SVG map from the information 601 | * of the instance. An optional boolean is available 602 | * to generate a clean SVG to produce a lightweight 603 | * SVG (used for export) 604 | * 605 | * @param boolean isClean To produce a clean SVG 606 | * @return SVGDOMElement The artwork 607 | */ 608 | Triangulr.prototype.generateSVG = function (isClean) { 609 | let i, data, points, polygon, 610 | svgTag = document.createElementNS(SVG_NAMESPACE, 'svg') 611 | 612 | svgTag.setAttribute('version', '1.1') 613 | svgTag.setAttribute('preserveAspectRatio', 'xMinYMin slice') 614 | svgTag.setAttribute('xmlns', SVG_NAMESPACE) 615 | if (this.isLandscape) { 616 | svgTag.setAttribute('width', this.mapWidth * this.triangleWidth) 617 | svgTag.setAttribute('height', this.mapHeight * this.triangleHeight) 618 | svgTag.setAttribute('viewBox', '0 0 ' + (this.mapWidth * this.triangleWidth) + ' ' + (this.mapHeight * this.triangleHeight)) 619 | } 620 | else { 621 | svgTag.setAttribute('width', this.mapWidth * this.triangleHeight) 622 | svgTag.setAttribute('height', this.mapHeight * this.triangleWidth) 623 | svgTag.setAttribute('viewBox', '0 0 ' + (this.mapWidth * this.triangleHeight) + ' ' + (this.mapHeight * this.triangleWidth)) 624 | } 625 | 626 | // Metadata 627 | if (isClean) { 628 | svgTag.appendChild(document.createComment(JSON.stringify({ 629 | isLandscape: this.isLandscape, 630 | mapWidth: this.mapWidth, 631 | mapHeight: this.mapHeight, 632 | palette: this.palette 633 | }))) 634 | } 635 | 636 | for (i in this.exportData) { 637 | data = this.exportData[i] 638 | if (isClean && !data.color) { 639 | continue 640 | } 641 | polygon = document.createElementNS(SVG_NAMESPACE,'path') 642 | points = 'M' + data.points[0].x + ' ' + data.points[0].y + ' ' 643 | points += 'L' + data.points[1].x + ' ' + data.points[1].y + ' ' 644 | points += 'L' + data.points[2].x + ' ' + data.points[2].y + ' Z' 645 | polygon.setAttribute('d', points) 646 | if (!isClean || data.color !== this.DEFAULT_FILL_COLOR) { 647 | polygon.setAttribute('fill', data.color || this.BLANK_COLOR) 648 | } 649 | polygon.setAttribute('rel', i) 650 | svgTag.appendChild(polygon) 651 | } 652 | return svgTag 653 | } 654 | 655 | /** 656 | * Get the output clean SVG for user consumption 657 | * @return string SVG data 658 | */ 659 | Triangulr.prototype.exportSVG = function () { 660 | return this.generateSVG(true).outerHTML 661 | } 662 | 663 | /** 664 | * Reset the instance with data provided in paramater. 665 | * The data object contain: 666 | * 667 | * mapWidth number 668 | * mapHeight number 669 | * mapData array (Map of colors) 670 | * isLandscape boolean 671 | * palette array (List color palette) 672 | */ 673 | Triangulr.prototype.import = function (data) { 674 | this.setCanvas( 675 | data.mapWidth, 676 | data.mapHeight, 677 | data.isLandscape 678 | ) 679 | 680 | this.palette = data.palette || [] 681 | this.backStack.reset() 682 | 683 | for (var i in data.mapData) { 684 | this.exportData[i].color = data.mapData[i] 685 | } 686 | 687 | for (var i = 0; i < this.svgTag.childNodes.length; i++) { 688 | this.svgTag.childNodes[i].setAttribute('fill', this.exportData[i].color || this.BLANK_COLOR) 689 | } 690 | } 691 | 692 | /** 693 | * Export workspace data 694 | * @return object Workspace config 695 | */ 696 | Triangulr.prototype.export = function () { 697 | return { 698 | isLandscape: this.isLandscape, 699 | mapWidth: this.mapWidth, 700 | mapHeight: this.mapHeight, 701 | mapData: this.exportData.map(function (e) {return e.color || null}), 702 | palette: this.palette 703 | } 704 | } 705 | 706 | /** 707 | * Load config from file. 708 | * The input is always a string. The format can be 709 | * JSON (for legacy systems) or SVG for v2 users. 710 | * If the parsing or the format is invalid, an error 711 | * will be triggered. 712 | * 713 | * @param string data Input data 714 | */ 715 | Triangulr.prototype.loadWorkspaceFromFile = function (data) { 716 | // Check data input (JSON or SVG) 717 | let config 718 | if (data[0] === '{') { 719 | config = JSON.parse(data) 720 | config.playground.palette = config.palette 721 | config = config.playground 722 | } 723 | else { 724 | let container = document.createElement('div') 725 | container.innerHTML = data 726 | let svg = container.querySelector('svg') 727 | if (!svg || !svg.childNodes[0] || svg.childNodes[0].nodeType !== 8) { 728 | throw new Error ('Invalid file format') 729 | } 730 | config = JSON.parse(svg.childNodes[0].textContent) 731 | config.mapData = [] 732 | svg.querySelectorAll('path').forEach(path => { 733 | config.mapData[parseInt(path.getAttribute('rel'), 10)] = path.getAttribute('fill') || this.DEFAULT_FILL_COLOR 734 | }) 735 | } 736 | 737 | this.import(config) 738 | this.workspace = storage.createItem('imported file') 739 | storage.updateItem(this.workspace.id, this.export()) 740 | return this.workspace 741 | } 742 | 743 | /** 744 | * Load a workspace from the storage ID 745 | * @param number id Workspace index 746 | */ 747 | Triangulr.prototype.loadWorkspaceFromStorage = function (id) { 748 | this.workspace = {id} 749 | this.import(storage.getItem(id)) 750 | return this.workspace 751 | } 752 | 753 | /** 754 | * Create and set a new workspace from settings 755 | * provided in options. 756 | * 757 | * width number 758 | * height number 759 | * isLandscape boolean 760 | * 761 | * @param object data Options to set the new workspace 762 | */ 763 | Triangulr.prototype.newWorkspace = function (data) { 764 | if (!data.isLandscape) { 765 | [data.width, data.height] = [data.height, data.width] 766 | } 767 | this.setCanvas(data.width, data.height, data.isLandscape); 768 | this.workspace = storage.createItem(data.name || 'untitled') 769 | storage.updateItem(this.workspace.id, this.export()) 770 | return this.workspace 771 | } 772 | 773 | /** 774 | * Save the workspace in the local storage 775 | */ 776 | Triangulr.prototype.save = function () { 777 | storage.updateItem(this.workspace.id, this.export()) 778 | } 779 | 780 | /** 781 | * Set a timeout to save the workspace. 782 | * Any new call will reset the timer. 783 | */ 784 | Triangulr.prototype.saveTimeout = function () { 785 | //# DO NOT FORGET TO CLEAR THIS WHEN LEAVING THE WORKSPACE 786 | if (this.saveTimer) { 787 | clearTimeout(this.saveTimer) 788 | } 789 | this.saveTimer = setTimeout(() => this.save(), this.AUTOSAVE_TIMER) 790 | } 791 | 792 | /* Controls 793 | */ 794 | 795 | /** 796 | * togglePreview 797 | * toggle the class preview to the SVG 798 | * To show/hide the strokes 799 | * 800 | */ 801 | Triangulr.prototype.togglePreview = function () { 802 | if (!this.svgTag) { 803 | return 804 | } 805 | this.svgTag.classList.toggle('preview') 806 | } 807 | 808 | /** 809 | * Set a new mode to the editor between the following 810 | * actions: 811 | * 812 | * ACTION_FILL 813 | * ACTION_ERASE 814 | * ACTION_MOVE 815 | * ACTION_SELECT 816 | * 817 | * @param action number Action index (from triangulr consts) 818 | */ 819 | Triangulr.prototype.setMode = function (action) { 820 | // No effects if the new action is the existing one 821 | if (this.action === action) { 822 | return 823 | } 824 | // Apply the selection if there's a seection 825 | if (this.action === this.ACTION_SELECT) { 826 | this.clearSelection() 827 | } 828 | this.action = action 829 | } 830 | 831 | /** 832 | * Check the current action set 833 | * @param number action Action ID from class consts 834 | * @return boolean True is currently set 835 | */ 836 | Triangulr.prototype.isOnMode = function (action) { 837 | return this.action === action 838 | } 839 | 840 | /** 841 | * Set the current color 842 | * @param string color New pencil color 843 | */ 844 | Triangulr.prototype.setColor = function (color) { 845 | this.color = color 846 | if (this.isOnMode(this.ACTION_SELECT)) { 847 | this.fillSelection(color) 848 | } 849 | } 850 | 851 | /** 852 | * Add a color to the existing palette. 853 | * If the color is already in, it won't be added. 854 | * @param string color Color to add 855 | */ 856 | Triangulr.prototype.addColor = function (color) { 857 | if (!color || this.palette.indexOf(color) !== -1) { 858 | return 859 | } 860 | this.palette.push(color) 861 | } 862 | 863 | /** 864 | * Reverse the last action applied to the workspace 865 | */ 866 | Triangulr.prototype.undo = function () { 867 | let fill, backAction = this.backStack.popLastAction() 868 | for (let fillIndex in backAction) { 869 | fill = backAction[fillIndex] 870 | this.exportData[fill[0]].color = fill[1] 871 | this.svgTag.childNodes[fill[0]].setAttribute('fill', fill[1] || 'none') 872 | } 873 | } 874 | 875 | /** 876 | * Found at 877 | * https://stackoverflow.com/questions/2234979/how-to-check-in-javascript-if-one-element-is-contained-within-another 878 | * +1 kudo for gitaarLab 879 | * @param {DOMElement} c Child node 880 | * @param {DOMElement} p Parent Node 881 | * @return boolean True if child of 882 | */ 883 | function childOf(c, p) { 884 | while((c=c.parentNode)&&c!==p); 885 | return !!c; 886 | } 887 | 888 | export default Triangulr 889 | -------------------------------------------------------------------------------- /assets/vectors/triangulart_icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 279 | 280 | 281 | 282 | --------------------------------------------------------------------------------