├── .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 |
2 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/src/components/workspace/Workspace.vue:
--------------------------------------------------------------------------------
1 |
2 |
6 |
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 |
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 |
2 |
3 |
13 |
14 | {{this.orientation}}
15 | Orientation
16 | ↻
17 |
18 |
19 |
20 |
21 |
44 |
45 |
--------------------------------------------------------------------------------
/src/components/workspace/toolbar/ColorPicker.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
8 |
9 |
10 |
11 |
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 |
2 |
3 |
4 |
5 |
6 |
7 |
14 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
67 |
68 |
--------------------------------------------------------------------------------
/src/components/launcher/workspaceBrowser/WorkspaceBrowser.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
Choose your saved workspace
7 |
8 |
9 |
10 | |
11 | {{index.name}}
12 | updated {{index.date | timeAgo}}
13 | |
14 |
15 | ¶
16 | |
17 |
18 | ×
19 | |
20 |
21 |
22 |
23 |
24 |
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 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
45 |
46 |
--------------------------------------------------------------------------------
/assets/vectors/triangulart_logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/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 |
2 |
3 |
4 |
8 |
9 |
10 |
11 |
12 |
13 |
76 |
77 |
102 |
--------------------------------------------------------------------------------
/src/components/launcher/intro/Intro.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
triangulart [v2 beta]
7 |
Graphic editor to create isometric illustrations, it's like pixel art but with triangles.
8 | Don't forget to share your creation with #triangulart
9 |
10 | by / on
11 |
12 |
13 |
14 |
15 |
16 |
17 | Upload project
18 |
19 |
20 |
21 | Start a new canvas
22 |
23 |
24 |
25 | Load previous project
26 |
27 |
28 |
29 |
30 |
31 |
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 |
2 |
69 |
70 |
71 |
147 |
148 |
--------------------------------------------------------------------------------
/assets/vectors/icons.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
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 |
39 |
40 |
41 |
45 |
46 |
47 |
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 |
282 |
--------------------------------------------------------------------------------