Showing preview only (304K chars total). Download the full file or copy to clipboard to get everything.
Repository: dulnan/drawmote
Branch: master
Commit: 7a32b33ae628
Files: 100
Total size: 278.6 KB
Directory structure:
gitextract_jk23ff3i/
├── .browserslistrc
├── .eslintrc.js
├── .gitignore
├── .nvmrc
├── .prettierrc.js
├── LICENSE
├── README.md
├── babel.config.js
├── netlify.toml
├── package.json
├── postcss.config.js
├── public/
│ └── index.html
├── src/
│ ├── App.vue
│ ├── assets/
│ │ └── scss/
│ │ ├── components/
│ │ │ ├── _btn.scss
│ │ │ ├── _check.scss
│ │ │ ├── _code.scss
│ │ │ ├── _icon.scss
│ │ │ ├── _no-js-overlay.scss
│ │ │ └── _range.scss
│ │ ├── defaults/
│ │ │ └── _typography.scss
│ │ ├── helpers/
│ │ │ ├── _flex.scss
│ │ │ ├── _helpers.scss
│ │ │ └── _typography.scss
│ │ ├── main.scss
│ │ ├── settings/
│ │ │ ├── _settings.scss
│ │ │ └── _typography.scss
│ │ └── vue_include.scss
│ ├── classes/
│ │ ├── Brush.js
│ │ ├── Canvas/
│ │ │ ├── Action.js
│ │ │ ├── DrawAction.js
│ │ │ └── index.js
│ │ ├── Color.js
│ │ ├── Rectangle.js
│ │ └── Smoothing.js
│ ├── components/
│ │ ├── Common/
│ │ │ ├── Animation/
│ │ │ │ └── Animation.vue
│ │ │ ├── Attribution.vue
│ │ │ ├── BrowserSupport.vue
│ │ │ ├── ConnectionTimeout.vue
│ │ │ ├── Footer/
│ │ │ │ ├── Footer.vue
│ │ │ │ ├── FooterAttribution.vue
│ │ │ │ ├── FooterBrowserSupport.vue
│ │ │ │ ├── FooterConnection.vue
│ │ │ │ ├── FooterCopyright.vue
│ │ │ │ ├── FooterGithub.vue
│ │ │ │ └── FooterLanguage.vue
│ │ │ ├── Logo.vue
│ │ │ ├── RestoreConnection.vue
│ │ │ └── ServerStatus.vue
│ │ ├── Desktop/
│ │ │ ├── Canvas/
│ │ │ │ ├── CanvasDrawing.vue
│ │ │ │ └── CanvasInterface.vue
│ │ │ ├── Drawing.vue
│ │ │ ├── Pairing.vue
│ │ │ └── Toolbar/
│ │ │ ├── Button/
│ │ │ │ ├── Button.vue
│ │ │ │ ├── ButtonClear.vue
│ │ │ │ ├── ButtonColor.vue
│ │ │ │ ├── ButtonRedo.vue
│ │ │ │ └── ButtonUndo.vue
│ │ │ ├── Item.vue
│ │ │ ├── Slider/
│ │ │ │ ├── Slider.vue
│ │ │ │ ├── SliderBrushHardness.vue
│ │ │ │ ├── SliderBrushOpacity.vue
│ │ │ │ ├── SliderBrushRadius.vue
│ │ │ │ ├── SliderDistance.vue
│ │ │ │ └── SliderLazyRadius.vue
│ │ │ └── Toolbar.vue
│ │ ├── Desktop.vue
│ │ ├── Mobile/
│ │ │ ├── Controlling.vue
│ │ │ ├── Pairing.vue
│ │ │ └── TouchHandler.vue
│ │ └── Mobile.vue
│ ├── events/
│ │ └── index.js
│ ├── i18n.js
│ ├── locales/
│ │ ├── de.json
│ │ └── en.json
│ ├── main.js
│ ├── mixins/
│ │ └── PointerEvents.js
│ ├── plugins/
│ │ ├── GymoteRemote.js
│ │ ├── GymoteScreen.js
│ │ ├── PeerSox.js
│ │ ├── Sentry.js
│ │ ├── Settings.js
│ │ └── Track.js
│ ├── settings/
│ │ └── index.js
│ ├── store/
│ │ ├── vuetamin/
│ │ │ ├── actions.js
│ │ │ ├── data.js
│ │ │ ├── index.js
│ │ │ ├── mutations.js
│ │ │ ├── state.js
│ │ │ └── threads.js
│ │ └── vuex/
│ │ └── index.js
│ └── tools/
│ ├── animation/
│ │ ├── app.json
│ │ ├── debug.js
│ │ ├── index.js
│ │ ├── keyframes.js
│ │ └── webglSupport.js
│ ├── canvas.js
│ ├── cookies.js
│ ├── dependencies.js
│ └── helpers.js
└── vue.config.js
================================================
FILE CONTENTS
================================================
================================================
FILE: .browserslistrc
================================================
> 1%
last 3 versions
not ie <= 8
ios >= 9
================================================
FILE: .eslintrc.js
================================================
module.exports = {
root: true,
env: {
node: true
},
extends: ['plugin:vue/essential', '@vue/prettier'],
rules: {
'vue/component-name-in-template-casing': ['error', 'PascalCase'],
'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off'
},
parserOptions: {
parser: 'babel-eslint'
}
}
================================================
FILE: .gitignore
================================================
.DS_Store
node_modules
/dist
# local env files
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw*
stats.json
/cert
================================================
FILE: .nvmrc
================================================
12.18.3
================================================
FILE: .prettierrc.js
================================================
module.exports = {
trailingComma: 'none',
tabWidth: 2,
semi: false,
singleQuote: true
}
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2018 Jan Hug (dulnan)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================

# drawmote
*Draw remotely with your phone*
### **[Try it out on drawmote.app](https://drawmote.app)**
## What is drawmote?
drawmote is a browser app that allows you to use your phone as an input device
to point at and draw on your computer screen. It works by establishing a WebRTC
connection between a phone and computer, using the phone's gyroscope to calculate
where the phone is pointing at on the screen and simulating mouse movement to
draw on a canvas.
## How it's built
Some of the things used to build drawmote:
### Frameworks and libraries
- **[Vue.js](https://github.com/vuejs/vue)**\
as the JavaScript framework
- **[simple-peer](https://github.com/feross/simple-peer)**\
to establish a WebRTC connection
- **[gyronorm](https://github.com/dorukeker/gyronorm.js)**\
for cross-browser reading of a gyroscope
### Custom libraries
During the development of drawmote some functionality has been extracted to
separate repositories and libraries:
| Name | Description | Demo |
| ------------- | ------------- | ------------- |
| **[peersox](https://github.com/dulnan/peersox)** | Client and server to generate pairing codes and hashes, establish WebRTC data connection and WebSocket server as a fallback. | |
| **[Vuetamin](https://github.com/dulnan/vuetamin)** | Combine animation loops from multiple components into a single requestAnimationFrame loop and provide a consistent state. | |
| **[lazy-brush](https://github.com/dulnan/lazy-brush)** | Smooth drawing by pulling the brush with a rope connected to the brush and pointer | [Demo](https://lazybrush.dulnan.net) |
| **[catenary-curve](https://github.com/dulnan/catenary-curve)** | Calculate and draw a cantary curve on a canvas | [Demo](https://lazybrush.dulnan.net) |
| **[gymote](https://github.com/dulnan/gymote)** | Easy way to use a phone as a remote pointing device for a desktop screen. | |
| **[gyro-plane](https://github.com/thormeier/gyro-plane)** | Using alpha and beta angles from a gyroscope, calculate where its pointing at on a screen | |
### History
The app has been fundamentally changed and refactored several times during
development. It started out as a [hacky VanillaJS proof-of-concept](https://github.com/dulnan/drawmote-server/tree/f7fa7327cec66f5647fbd948d3e31eeb5cf8cf02), then got
refactored into an [OOP-style codebase (horrible!)](https://github.com/dulnan/drawmote-server/tree/07334b0e5c2909eb67ef5476e4ac19c4727ec514). After that, a complete rewrite using
Vue.js happened. At first Vuex was used as a way to store and share data.
Pretty soon it was clear that this increases the latency from gyroscope to
canvas draw. So I switched to an event-based approach, with an event bus
notifying components about new orientation data from the gyroscope. That worked
quite well, but was still measurably introducing a lag.
## How low latency was achieved
Until quite late in the project, every component had its own animation loop
using requestAnimationFrame. In total there were 7 loops running at the same
time. The problem was that these loops ran at different speeds, had different
states and sometimes were interfering with each other. The solution was to
completely remove Vuex and manage state manually. A Vue plugin was created that
allows for every component to define an animation function. The plugin (called
[Vuetamin](https://github.com/dulnan/vuetamin)) takes all these functions and
runs them in a single requestAnimationFrame loop.
With this approach, the time passing from when new orientation data is received
and when the last draw function has been done, is on average just 8ms, which is
not really noticeable. If phone and desktop are in the same network, the total
delay from when gyroscope values are read out and the brush is moving on the
screen is higher, but still not close to a range where drawing becomes annoying.
Even when both devices are in seprate networks with good network connetions,
it's still useable.
After a few seconds, our brains can compensate easily for the delay introduced
between what the hand is doing and what the eyes are seeing.
## Run locally
You need both the client and
[drawmote-server](https://github.com/dulnan/drawmote-server) to run it locally.
Install dependencies for the client:
```
npm install
```
### Certificates
Since iOS 12.2 it is required to request permission to access the
DeviceMotionEvent. For this to work the connection can not be insecure and
thus https is required. To get the certificates needed, follow this tutorial
and put the files in ./cert.
https://engineering.circle.com/https-authorized-certs-with-node-js-315e548354a2
Start drawmote-server and set the IP address of the server in `.env.development`.
Then you can start running development mode for the client:
```
npm run serve
```
================================================
FILE: babel.config.js
================================================
module.exports = {
presets: [['@vue/app', { useBuiltIns: 'entry', modules: false, loose: true }]]
}
================================================
FILE: netlify.toml
================================================
[Settings]
ID = "f278814b-e1d8-4982-b6b7-8f8235c7e97a"
[build]
Publish = "dist/"
Functions = ""
[context.develop]
command = "npm run build-develop"
================================================
FILE: package.json
================================================
{
"name": "drawmote",
"version": "1.4.1",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"build-develop": "vue-cli-service build --mode develop",
"lint": "vue-cli-service lint",
"stats": "webpack --profile --json > stats.json --config ./node_modules/@vue/cli-service/webpack.config.js"
},
"dependencies": {
"@sentry/browser": "^5.20.1",
"@sentry/integrations": "^5.20.1",
"animejs": "^3.2.0",
"catenary-curve": "^1.0.1",
"core-js": "^3.6.5",
"dat.gui": "^0.7.7",
"debounced-resize": "^1.1.1",
"gymote": "^1.0.0",
"html-webpack-inline-source-plugin": "0.0.10",
"input-range-scss": "^1.5.2",
"lazy-brush": "^1.0.1",
"peersox": "^0.3.0",
"prerender-spa-plugin": "^3.4.0",
"three": "^0.118.3",
"universal-cookie": "^4.0.3",
"vue": "^2.6.11",
"vue-i18n": "^8.19.0",
"vue-resize": "^0.5.0",
"vuetamin": "^0.0.3",
"vuex": "^3.5.1",
"whatwg-fetch": "^3.2.0"
},
"devDependencies": {
"@vue/cli-plugin-babel": "^4.4.6",
"@vue/cli-plugin-eslint": "^4.4.6",
"@vue/cli-service": "^4.4.6",
"@vue/eslint-config-prettier": "^6.0.0",
"babel-eslint": "^10.1.0",
"eslint": "^7.5.0",
"eslint-plugin-prettier": "^3.1.4",
"eslint-plugin-vue": "^6.2.2",
"prettier": "^2.0.5",
"kanbasu": "^2.5.0",
"sass": "^1.26.10",
"sass-loader": "^9.0.2",
"style-resources-loader": "^1.3.3",
"terser-webpack-plugin": "^3.0.7",
"vue-cli-plugin-i18n": "^1.0.1",
"vue-cli-plugin-style-resources-loader": "^0.1.4",
"vue-svg-loader": "^0.16.0",
"vue-template-compiler": "^2.6.11"
},
"author": "Jan Hug <me@dulnan.net>",
"description": "client application for drawmote"
}
================================================
FILE: postcss.config.js
================================================
module.exports = {
plugins: {
autoprefixer: {}
}
}
================================================
FILE: public/index.html
================================================
<!DOCTYPE html>
<html lang="en" style="background: #34152b">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0,user-scalable=no">
<title>drawmote - draw remotely with your phone</title>
<meta name="keywords" content="draw remote drawing phone webrtc websockets">
<meta name="description" content="Use your phone as a pointing device to remotely draw on your computer screen.">
<meta name="robots" content="all">
<meta property="og:url" content="<%= VUE_APP_URL %>">
<meta property="og:type" content="website">
<meta property="og:title" content="drawmote.app">
<meta property="og:description" content="Use your phone to draw on your computer screen. Visit drawmote.app on your phone and computer (or tablet) to start drawing.">
<meta property="og:image" content="<%= VUE_APP_URL %><%= BASE_URL %>drawmote-teaser.jpg">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:site" content="@dulnan">
<meta name="twitter:creator" content="@dulnan">
<meta name="twitter:image" content="<%= VUE_APP_URL %><%= BASE_URL %>drawmote-teaser-twitter.jpg">
<link rel="apple-touch-icon" sizes="57x57" href="<%= BASE_URL %>apple-icon-57x57.png">
<link rel="apple-touch-icon" sizes="60x60" href="<%= BASE_URL %>apple-icon-60x60.png">
<link rel="apple-touch-icon" sizes="72x72" href="<%= BASE_URL %>apple-icon-72x72.png">
<link rel="apple-touch-icon" sizes="76x76" href="<%= BASE_URL %>apple-icon-76x76.png">
<link rel="apple-touch-icon" sizes="114x114" href="<%= BASE_URL %>apple-icon-114x114.png">
<link rel="apple-touch-icon" sizes="120x120" href="<%= BASE_URL %>apple-icon-120x120.png">
<link rel="apple-touch-icon" sizes="144x144" href="<%= BASE_URL %>apple-icon-144x144.png">
<link rel="apple-touch-icon" sizes="152x152" href="<%= BASE_URL %>apple-icon-152x152.png">
<link rel="apple-touch-icon" sizes="180x180" href="<%= BASE_URL %>apple-icon-180x180.png">
<link rel="icon" type="image/png" sizes="192x192" href="<%= BASE_URL %>android-icon-192x192.png">
<link rel="icon" type="image/png" sizes="32x32" href="<%= BASE_URL %>favicon-32x32.png">
<link rel="icon" type="image/png" sizes="96x96" href="<%= BASE_URL %>favicon-96x96.png">
<link rel="icon" type="image/png" sizes="16x16" href="<%= BASE_URL %>favicon-16x16.png">
<link rel="apple-touch-startup-image" href="<%= BASE_URL %>launch-640x1136.png" media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)">
<link rel="apple-touch-startup-image" href="<%= BASE_URL %>launch-750x1294.png" media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)">
<link rel="apple-touch-startup-image" href="<%= BASE_URL %>launch-1242x2148.png" media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)">
<link rel="apple-touch-startup-image" href="<%= BASE_URL %>launch-1125x2436.png" media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)">
<link rel="apple-touch-startup-image" href="<%= BASE_URL %>launch-1536x2048.png" media="(min-device-width: 768px) and (max-device-width: 1024px) and (-webkit-min-device-pixel-ratio: 2) and (orientation: portrait)">
<link rel="apple-touch-startup-image" href="<%= BASE_URL %>launch-1668x2224.png" media="(min-device-width: 834px) and (max-device-width: 834px) and (-webkit-min-device-pixel-ratio: 2) and (orientation: portrait)">
<link rel="apple-touch-startup-image" href="<%= BASE_URL %>launch-2048x2732.png" media="(min-device-width: 1024px) and (max-device-width: 1024px) and (-webkit-min-device-pixel-ratio: 2) and (orientation: portrait)">
<meta name="msapplication-TileColor" content="#ffffff">
<meta name="msapplication-TileImage" content="/ms-icon-144x144.png">
<meta name="theme-color" content="#ffffff">
<meta name="apple-mobile-web-app-title" content="drawmote">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="format-detection" content="telephone=no">
<link href="https://fonts.googleapis.com/css?family=Barlow:400,600,700" rel="stylesheet">
</head>
<body>
<div id="drawmote"></div>
<noscript>
<div class="no-js-overlay">
<h1>Unfortunately, drawmote doesn't work without JavaScript. :(</h1>
</div>
</noscript>
<!-- Matomo -->
<script type="text/javascript">
if (!window.__PRERENDERING) {
var _paq = _paq || [];
/* tracker methods like "setCustomDimension" should be called before "trackPageView" */
_paq.push(['trackPageView']);
_paq.push(['enableLinkTracking']);
(function() {
var u="https://stats.dulnan.net/";
_paq.push(['setTrackerUrl', u+'piwik.php']);
_paq.push(['setSiteId', '6']);
var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0];
g.type='text/javascript'; g.async=true; g.defer=true; g.src=u+'piwik.js'; s.parentNode.insertBefore(g,s);
})();
}
</script>
<!-- End Matomo Code -->
</body>
</html>
================================================
FILE: src/App.vue
================================================
<template>
<div id="drawmote" class="relative" :class="{ 'is-ready': isReady }">
<Mobile v-if="isMobile" />
<Desktop v-else />
<RestoreConnection />
<TheFooter :is-mobile="isMobile" />
<ConnectionTimeout :is-mobile="isMobile" />
<transition name="appear">
<Attribution v-if="attributionVisible" />
</transition>
</div>
</template>
<script>
import { mapState } from 'vuex'
import PeerSox from 'peersox'
import { BREAKPOINT_REMOTE } from '@/settings'
import Desktop from '@/components/Desktop.vue'
import Mobile from '@/components/Mobile.vue'
import TheFooter from '@/components/Common/Footer/Footer.vue'
import RestoreConnection from '@/components/Common/RestoreConnection.vue'
import ConnectionTimeout from '@/components/Common/ConnectionTimeout.vue'
import Attribution from '@/components/Common/Attribution.vue'
let peersoxHandlers = []
export default {
name: 'App',
components: {
Desktop,
Mobile,
TheFooter,
RestoreConnection,
ConnectionTimeout,
Attribution
},
data() {
return {
isMobile: true,
isReady: false
}
},
computed: {
...mapState(['attributionVisible'])
},
beforeMount() {
this.isMobile = window.innerWidth < BREAKPOINT_REMOTE
},
mounted() {
this.$nextTick(() => {
if (!window.__PRERENDERING) {
peersoxHandlers = new Array(
PeerSox.EVENT_SERVER_READY,
PeerSox.EVENT_CONNECTION_ESTABLISHED,
PeerSox.EVENT_CONNECTION_CLOSED,
PeerSox.EVENT_PEER_CONNECTED,
PeerSox.EVENT_PEER_TIMEOUT,
PeerSox.EVENT_PEER_WEBRTC_CLOSED
).map((event) => {
const fn = () => {
this.$sentry.logInfo('peersox', event)
}
this.$peersox.on(event, fn)
return {
event,
fn
}
})
this.isReady = true
}
document.dispatchEvent(new Event('render-event'))
this.$forceUpdate()
this.$peersox.init().catch((e) => {
this.$store.commit('setServerStatus', e)
})
const mode = this.isMobile ? 'mobile' : 'desktop'
this.$sentry.setMode(mode)
})
},
beforeDestroy() {
peersoxHandlers.forEach((handler) => {
this.$peersox.off(handler.event, handler.fn)
})
}
}
</script>
<style lang="scss">
#drawmote {
background: $alt-color-darker;
opacity: 0;
transition: 0.9s;
&.is-ready {
opacity: 1;
}
@include media('sm') {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 100;
overflow: hidden;
}
}
</style>
================================================
FILE: src/assets/scss/components/_btn.scss
================================================
/*----------------------------------------*\
BUTTON
\*----------------------------------------*/
.btn,
%btn {
display: inline-block;
overflow: hidden;
padding: 0.75rem 1rem;
font-family: $btn-font-family;
color: $btn-color;
text-decoration: none;
text-align: center;
white-space: nowrap;
text-overflow: ellipsis;
vertical-align: middle;
font-weight: 600;
border: $btn-border;
border-radius: $btn-border-radius;
background: $btn-bkg;
cursor: pointer;
// Cleaner font rendering
// <button> doesn’t inherit
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
&:hover,
&:focus,
&:active {
text-decoration: none;
}
&:focus {
outline: none;
}
}
/**
* Variants
*/
// Use all the width available
.btn--block,
%btn--block {
display: block;
width: 100%;
}
// Remove default styling for special buttons
.btn--bare,
%btn--bare {
padding: 0;
border: 0;
border-radius: 0;
}
/**
* Styles
*/
.btn--link {
color: $text-color;
&:hover {
color: $brand-color;
}
}
.btn--default,
.btn--primary {
@include font-values($font-btn);
}
.btn--default,
%btn--default {
color: $alt-color-dark;
background-color: white;
box-shadow: 0 4px 9px rgba($alt-color-light, .24),
0 1px 2px rgba($alt-color-light, .71);
&:hover,
&:focus {
background-color: $btn-default-hover-bkg-color;
}
&:active {
background-color: $btn-default-active-bkg-color;
}
}
.btn--primary,
%btn--primary {
color: lighten($btn-primary-bkg-color, 50%);
background-color: $btn-primary-bkg-color;
box-shadow: 0 1px 1px rgba(darken($brand-color, 40%), .19),
0 3px 7px rgba(darken($brand-color, 70%), .16);
&:hover {
background-color: $btn-primary-hover-bkg-color;
}
&:active, &:focus {
background-color: $btn-primary-bkg-color;
}
}
/**
* States
*/
.btn--disabled,
.btn[disabled],
%btn--disabled {
opacity: .5;
cursor: not-allowed;
}
/**
* Sizes
*/
.btn--small,
%btn--small {
padding: $btn-small-padding;
@include font-values($font-btn-small);
border: $btn-small-border;
border-radius: $btn-small-border-radius;
}
.btn--large,
%btn--large {
padding: $btn-large-padding;
font-size: $btn-large-font-size;
border: $btn-large-border;
border-radius: $btn-large-border-radius;
}
================================================
FILE: src/assets/scss/components/_check.scss
================================================
.check--small {
font-size: .8125rem;
line-height: 1.230769231;
}
.check__title {
font-weight: 700;
position: relative;
&:before {
content: "";
display: block;
width: 10px;
height: 10px;
margin-right: 6px;
margin-top: 0.25em;
border-radius: 100%;
float: left;
background: $alt-color;
.unsupported & {
background: $color-red;
}
.supported & {
background: $color-green;
}
.partial & {
background: $color-yellow;
}
}
}
================================================
FILE: src/assets/scss/components/_code.scss
================================================
$code-size-xs: 4rem;
$code-size-sm: 3rem;
$code-size-md: 4rem;
$code-size-lg: 5rem;
$code-numbers: (
0: ($color-yellow, 20%),
1: ($color-green, 20%),
2: ($color-greendark, 20%),
3: ($color-bluelight, 20%),
4: ($color-bluedark, 40%),
5: ($color-orange, 20%),
6: ($color-lavender, 20%),
7: ($color-turquoise, 25%),
8: ($color-red, 20%),
9: ($color-pink, 20%)
);
.code__content {
display: flex;
}
.code-circle {
color: white;
width: 1em;
height: 1em;
text-transform: uppercase;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
border-radius: 0.05em;
line-height: 1;
text-align: center;
font-weight: 700;
// border: 1px solid;
position: relative;
border-radius: 0.1em;
// background: rgba(white, 0.1);
background: rgba(black, 0.1);
// box-shadow: 0 3px 4px rgba($alt-color, 0.1),
// 0 0px 2px rgba($alt-color, 0.3);
&:before {
content: "";
position: absolute;
bottom: 0.05em;
left: 0.05em;
right: 0.05em;
height: 0.1em;
border-radius: 1em;
transform-origin: top left;
// width: 0.15em;
background: $alt-color-light;
}
&.contains {
// border: 1px solid rgba(white, 0.3);
}
&.contains.invalid {
color: $alt-color-light !important;
border-color: $color-red;
}
@each $index, $color in $code-numbers {
&.code-circle--#{$index} {
color: lighten(nth($color, 1), nth($color, 2));
background: rgba(nth($color, 1), 0.1);
&:before {
background: nth($color, 1);
}
}
}
span {
font-size: 0.50em;
display: inline-block;
margin-top: -0.2em;
}
}
.code {
font-size: $code-size-xs;
@include media('sm') {
font-size: $code-size-sm;
}
@include media('md') {
font-size: $code-size-md;
}
@include media('lg') {
font-size: $code-size-lg;
}
}
.code__item {
margin-right: 0.2em;
@include media('sm') {
margin-right: $code-margin;
}
&:last-child {
margin-right: 0;
}
}
================================================
FILE: src/assets/scss/components/_icon.scss
================================================
.icon {
width: 1em;
height: 1em;
fill: currentColor;
vertical-align: -0.2em;
}
.icon--large {
width: 1.2em;
height: 1.2em;
}
================================================
FILE: src/assets/scss/components/_no-js-overlay.scss
================================================
/*----------------------------------------*\
NO JS OVERLAY
\*----------------------------------------*/
.no-js-overlay {
position: fixed;
z-index: 1000;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: rgba(white, 0.95);
font-weight: bold;
h1 {
max-width: 50rem;
}
}
================================================
FILE: src/assets/scss/components/_range.scss
================================================
$track-color: $alt-color-darkest;
$thumb-color: $alt-color;
$thumb-radius: 4px;
$thumb-height: 12px;
$thumb-width: 12px;
$thumb-shadow-size: 0px;
$thumb-shadow-blur: 0px;
$thumb-shadow-color: rgba(0, 0, 0, 0.2);
$thumb-border-width: 0px;
$thumb-border-color: transparent;
$track-width: 100%;
$track-height: 4px;
$track-shadow-size: 0px;
$track-shadow-blur: 0px;
$track-shadow-color: rgba(0, 0, 0, 0.2);
$track-border-width: 0px;
$track-border-color: $alt-color-light;
$track-radius: 0px;
$contrast: 0%;
$ie-bottom-track-color: darken($track-color, $contrast);
@import 'input-range-scss/_inputrange';
================================================
FILE: src/assets/scss/defaults/_typography.scss
================================================
/*----------------------------------------*\
TYPOGRAPHY SCAFFOLDING
\*----------------------------------------*/
html {
font-family: $font-family-default;
// Use percentage value for root font-size
// see https://github.com/liip/kanbasu/issues/29
font-size: 100%;
line-height: 1.5;
color: $text-color;
font-weight: 400;
background: $alt-color-darker;
// Cleaner font rendering
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
// overflow: hidden;
}
/**
* Links
*/
a {
color: $text-color;
text-decoration: none;
&:hover,
&:focus {
color: $brand-color;
text-decoration: none;
}
}
.link {
border-bottom: 1px solid rgba($alt-color-lighter, 0.6);
}
/**
* Headings
*/
@include headings {
margin: 0;
font-weight: inherit;
line-height: 1.2;
}
h1,
.h1 {
margin-top: 0;
@include font-values($font-h1);
}
h2,
.h2 {
@include font-values($font-h2);
}
h3,
.h3 {
@include font-values($font-h3);
}
h4,
.h4 {
@include font-values($font-h4);
}
h5,
.h5 {
@include font-values($font-h5);
}
h6,
.h6 {
@include font-values($font-h6);
}
/**
* Paragraphs
*/
p {
margin-top: 1em;
margin-bottom: 1.3em;
}
/**
* Preformatted text
*/
pre,
code {
font-family: Monaco, monospace;
font-weight: 300;
tab-size: 4;
background-color: #f5f5f5;
}
pre {
padding: $spacing-unit-default;
margin: 0 0 2em;
overflow: auto;
font-size: rem(14px);
border-radius: $border-radius-default;
}
code {
display: inline-block;
padding: 1px 5px;
pre & {
display: block;
padding: 0;
}
}
/**
* Lists
*/
ul,
ol {
#{$padding-left}: $spacing-unit-default;
margin: 1em 0;
ul,
ol {
margin: 0;
}
}
dl {
@include clearfix;
}
dt {
font-weight: 500;
}
dd {
#{$margin-left}: 0;
margin-bottom: .5em;
}
.dl--inline {
dt {
float: flip(left, right);
width: 100px;
}
dd {
@include clearfix;
#{$margin-left}: calc(100px + #{$spacing-unit-small});
}
}
/**
* Miscellaenous
*/
abbr {
cursor: help;
}
================================================
FILE: src/assets/scss/helpers/_flex.scss
================================================
.flex {
display: flex;
}
.flex--column {
flex-direction: column;
}
.flex--align-stretch {
align-items: stretch !important;
}
.flex-1 {
flex: 1;
}
.flex--center {
align-items: center;
justify-content: center;
}
.mrgla {
margin-left: auto;
}
.w-100 {
width: 100% !important;
}
================================================
FILE: src/assets/scss/helpers/_helpers.scss
================================================
.block {
display: block;
}
.h-100 {
height: 100%;
}
.arrow-after {
&:after {
content: "";
width: 0;
height: 0;
border-style: solid;
border-width: 6px 4px 0 4px;
border-color: currentColor transparent transparent transparent;
display: inline-block;
margin-left: 0.5em;
vertical-align: middle;
}
}
.relative {
position: relative;
}
.fixed {
position: fixed;
top: 0;
left: 0;
}
.absolute {
position: absolute;
top: 0;
left: 0;
}
.overlay {
width: 100%;
height: 100%;
}
================================================
FILE: src/assets/scss/helpers/_typography.scss
================================================
.text-brand {
color: $brand-color;
}
.text-light {
font-weight: 400 !important;
}
.text-bold {
font-weight: 700 !important;
}
.text-heavy {
font-weight: 700;
}
.text-hyphens {
hyphens: auto;
}
.text-white {
color: $text-color;
}
.label {
font-weight: 700;
font-size: rem(12px);
text-transform: uppercase;
letter-spacing: 0.06em;
.is-drawing & {
@include media('sm') {
font-size: rem(12px);
}
@include media('md') {
font-size: rem(14px);
}
@include media('lg') {
font-size: rem(14px);
}
}
}
.mobile-font-size {
font-size: calc((100vw - 4rem) / 7);
}
================================================
FILE: src/assets/scss/main.scss
================================================
/*!
* カンバス KANBASU
* Distributed under the MIT License
* Copyright (c) 2015 Liip AG
*/
/**
* Settings
*/
@import '../../../node_modules/kanbasu/src/scss/settings/settings';
@import 'settings/settings';
/**
* Tools
*/
@import '../../../node_modules/kanbasu/src/scss/tools/functions';
@import '../../../node_modules/kanbasu/src/scss/tools/mixins';
@import '../../../node_modules/kanbasu/src/scss/tools/rtl';
/**
* Vendors
*/
@import '../../../node_modules/kanbasu/src/scss/vendor/normalize';
/**
* Defaults
*/
@import '../../../node_modules/kanbasu/src/scss/defaults/box-model';
@import '../../../node_modules/kanbasu/src/scss/defaults/elements';
@import 'defaults/typography';
// @import '../../../node_modules/kanbasu/src/scss/defaults/table';
@import '../../../node_modules/kanbasu/src/scss/defaults/forms';
/**
* Helpers
*/
@import '../../../node_modules/kanbasu/src/scss/helpers/text';
// @import '../../../node_modules/kanbasu/src/scss/helpers/text-responsive';
// @import '../../../node_modules/kanbasu/src/scss/helpers/float';
@import '../../../node_modules/kanbasu/src/scss/helpers/spacings';
@import '../../../node_modules/kanbasu/src/scss/helpers/spacings-responsive';
// @import '../../../node_modules/kanbasu/src/scss/helpers/images';
@import '../../../node_modules/kanbasu/src/scss/helpers/positionning';
@import '../../../node_modules/kanbasu/src/scss/helpers/display';
// @import '../../../node_modules/kanbasu/src/scss/helpers/align';
// @import '../../../node_modules/kanbasu/src/scss/helpers/align-responsive';
@import 'helpers/typography';
@import 'helpers/helpers';
@import 'helpers/flex';
/**
* Components
*/
// @import '../../../node_modules/kanbasu/src/scss/components/grid';
// @import '../../../node_modules/kanbasu/src/scss/components/widths';
// @import '../../../node_modules/kanbasu/src/scss/components/widths-responsive';
@import 'components/btn';
// @import '../../../node_modules/kanbasu/src/scss/components/box';
// @import '../../../node_modules/kanbasu/src/scss/components/media';
// @import '../../../node_modules/kanbasu/src/scss/components/media-responsive';
@import '../../../node_modules/kanbasu/src/scss/components/list';
@import '../../../node_modules/kanbasu/src/scss/components/list-inline';
// @import '../../../node_modules/kanbasu/src/scss/components/list-stacked';
// @import '../../../node_modules/kanbasu/src/scss/components/embed-responsive';
// @import '../../../node_modules/kanbasu/src/scss/components/container';
// @import '../../../node_modules/kanbasu/src/scss/components/pusher';
// @import '../../../node_modules/kanbasu/src/scss/components/table-responsive';
@import 'components/_range';
@import 'components/_icon';
@import 'components/_no-js-overlay';
================================================
FILE: src/assets/scss/settings/_settings.scss
================================================
@import 'typography';
$color-red: #F06D31;
$color-yellow: #ffd52b;
$color-orange: #f39a2d;
$color-pink: #db50b4;
$color-lavender: #7059d3;
$color-bluelight: #48bec5;
$color-bluedark: #2b67c2;
$color-green: #97d779;
$color-greendark: #5da83a;
$color-turquoise: #478bb3;
$code-margin: 0.2em;
$code-radius: $code-margin;
$index-canvas-main: 100;
$index-canvas-temp: 110;
$index-canvas-interface: 710;
$index-brush: 300;
$index-color-picker: 400;
$index-header: 460;
$index-overlay: 470;
$index-toolbar: 500;
$index-brush-toolbar: 500;
$index-mobile-pairing: 700;
$index-background-animation: 800;
$index-footer: 1200;
$index-drawing: 1100;
$index-pairing: 700;
$index-modal: 1000;
$toolbar-height: 5rem;
$footer-height-xs: 54px;
$toolbar-button-width-xs: 1.75rem;
$toolbar-button-width-sm: 1.75rem;
$toolbar-button-width-md: 2.25rem;
$toolbar-button-width-lg: 4rem;
$shadow-s: 0 3px 10px rgba($alt-color, 0.13),
0 2px 4px 1px rgba($alt-color, 0.1);
/*----------------------------------------*\
GLOBAL SETTINGS
\*----------------------------------------*/
// Use this setting to prefix all the components classes
$namespace: '';
// Flip all left/right properties for right-to-left languages
$rtl: false;
/**
* Colors
*/
$brand-color: #fb6131;
$brand-color-darker: darken($brand-color, 10%);
$alt-color-lightest: #dfdde0;
$alt-color-lighter: #a7a0a8;
$alt-color-light: #867c88;
$alt-color: #554757;
$alt-color-dark: #39293c;
$alt-color-darker: #2a192d;
$alt-color-darkest: #201123;
$color-translucent-dark: rgba($alt-color-darkest, 0.9);
/**
* Typography
*/
$font-family-default: "Barlow", 'Heebo', -apple-system, BlinkMacSystemFont,
'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell',
'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
$font-size-default: 16px;
$font-size-small: .875rem;
$font-size-large: 1.5rem;
$line-height-default: 1.45;
$text-color: $alt-color-lightest;
$link-color: $brand-color;
$muted-color: $alt-color-lighter;
/**
* Spacings
*/
$ratio: 1.61803398875;
$spacing-unit-tiny: 4px;
$spacing-unit-small: 8px;
$spacing-unit-default: 16px;
$spacing-unit-large: 24px;
$spacing-unit-huge: 48px;
$spacings: (
'tight' 0,
'tiny' $spacing-unit-tiny,
'small' $spacing-unit-small,
'large' $spacing-unit-large,
'huge' $spacing-unit-huge
);
/**
* Responsiveness
*/
// Use EM media-queries for better browser consistency
// See http://zellwk.com/blog/media-query-units
$screen-xs-max: 699px / 16px * 1em;
$screen-sm-min: 700px / 16px * 1em;
$screen-sm-max: 1024px / 16px * 1em;
$screen-md-min: 1025px / 16px * 1em;
$screen-md-max: 1440px / 16px * 1em;
$screen-lg-min: 1441px / 16px * 1em;
$screen-lg-max: 1679px / 16px * 1em;
$screen-xl-min: 1680px / 16px * 1em;
$breakpoints-default: (
'sm' '(min-width: #{$screen-sm-min})',
'md' '(min-width: #{$screen-md-min})',
'lg' '(min-width: #{$screen-lg-min})'
);
$breakpoints-desc: (
'xs' '(max-width: #{$screen-xs-max})',
'sm' '(max-width: #{$screen-sm-max})',
'md' '(max-width: #{$screen-md-max})'
);
$breakpoints-extra: (
'xl' '(min-width: #{$screen-xl-min})',
);
/**
* Miscellaneous
*/
$border-radius-default: 4px;
$border-radius-small: 2px;
$border-radius-large: 6px;
/*----------------------------------------*\
COMPONENTS
\*----------------------------------------*/
/**
* Buttons
*/
$btn-padding: 1rem;
$btn-color: inherit;
$btn-font-size: inherit;
$btn-font-family: inherit;
$btn-border: 0;
$btn-bkg: transparent;
$btn-border-radius: $border-radius-default;
$btn-small-padding: $spacing-unit-tiny/$ratio $spacing-unit-tiny;
$btn-small-font-size: $font-size-small;
$btn-small-border: $btn-border;
$btn-small-border-radius: $border-radius-small;
$btn-large-padding: $spacing-unit-default/$ratio $spacing-unit-default;
$btn-large-font-size: $font-size-large;
$btn-large-border: $btn-border;
$btn-large-border-radius: $border-radius-large;
$btn-default-color: $text-color;
$btn-default-bkg-color: $alt-color-lighter;
$btn-default-hover-bkg-color: lighten($btn-default-bkg-color, 3%);
$btn-default-active-bkg-color: darken($btn-default-bkg-color, 5%);
$btn-primary-color: white;
$btn-primary-bkg-color: $brand-color;
$btn-primary-hover-bkg-color: lighten($btn-primary-bkg-color, 8%);
$btn-primary-active-bkg-color: darken($btn-primary-bkg-color, 5%);
/**
* Forms
*/
$field-padding: $spacing-unit-small/$ratio $spacing-unit-small;
$field-color: inherit;
$field-font-size: inherit;
$field-font-family: inherit;
$field-line-height: $line-height-default;
$field-bkg-color: white;
$field-border: 1px solid $alt-color-light;
$field-focus-border-color: $alt-color-darker;
$field-border-radius: $border-radius-default;
$field-disabled-bkg-color: $alt-color-lighter;
$field-disabled-color: $alt-color;
$field-small-padding: $spacing-unit-tiny/$ratio $spacing-unit-tiny;
$field-small-font-size: $font-size-small;
$field-small-border: $field-border;
$field-small-border-radius: $border-radius-small;
$field-large-padding: $spacing-unit-default/$ratio $spacing-unit-default;
$field-large-font-size: $font-size-large;
$field-large-border: $field-border;
$field-large-border-radius: $border-radius-large;
$field-help-color: $alt-color-light;
$form-group-spacing: $spacing-unit-small;
/**
* Lists
*/
$list-separator-style: 1px solid $alt-color-dark;
/**
* Box
*/
$box-default-color: inherit;
$box-default-bkg-color: $alt-color-lighter;
$box-primary-color: white;
$box-primary-bkg-color: $brand-color;
/**
* Widths
*/
$widths-columns: 6,5,4,3,2,1;
$widths-breakpoints: $breakpoints-default;
/**
* Media responsive
*/
$media-collapse: $screen-sm-max;
/**
* Container
*/
$container-gutter-width: $spacing-unit-small;
$container-max-width: 1200px;
/**
* Table responsive
*/
$table-responsive-collapse: $screen-sm-max;
================================================
FILE: src/assets/scss/settings/_typography.scss
================================================
$rhythm-spacing-base: 16;
@mixin font-values($font, $force: null) {
@if ($force == force) {
$force: !important;
}
@each $breakpoint, $values in $font {
$fontsize: #{map-get($values, size) / $rhythm-spacing-base}rem $force;
$lineheight: #{map-get($values, leading)}rem $force;
@if ($breakpoint == xs) {
font-size: $fontsize;
line-height: $lineheight;
} @else {
@include media($breakpoint) {
font-size: $fontsize;
line-height: $lineheight;
}
}
}
}
$rhythm-size-xxxl: (
size: 50,
leading: 4
);
$rhythm-size-xxl: (
size: 42,
leading: 3
);
$rhythm-size-xl: (
size: 36,
leading: 2
);
$rhythm-size-l: (
size: 30,
leading: 2
);
$rhythm-size-m: (
size: 23,
leading: 2
);
$rhythm-size-s: (
size: 21,
leading: 1.75
);
$rhythm-size-xs: (
size: 18,
leading: 1.5
);
$rhythm-size-xxs: (
size: 15,
leading: 1.25
);
$rhythm-size-xxxs: (
size: 13,
leading: 1
);
$font-h1: (
xs: $rhythm-size-m,
sm: $rhythm-size-l,
md: $rhythm-size-xl,
lg: $rhythm-size-xxxl
);
$font-h2: (
xs: $rhythm-size-xs,
sm: $rhythm-size-xxs,
md: $rhythm-size-s,
lg: $rhythm-size-m,
);
$font-h3: (
xs: $rhythm-size-xs,
lg: $rhythm-size-s
);
$font-h4: (
xs: $rhythm-size-xxs,
sm: $rhythm-size-xs
);
$font-h5: (
xs: $rhythm-size-xs,
);
$font-h6: (
xs: $rhythm-size-xs,
);
$font-default: (
xs: $rhythm-size-xs,
);
$font-btn: (
xs: $rhythm-size-xs,
md: $rhythm-size-s,
lg: $rhythm-size-s
);
$font-btn-small: (
xs: $rhythm-size-xxs,
md: $rhythm-size-xs,
);
================================================
FILE: src/assets/scss/vue_include.scss
================================================
@import '../../../node_modules/kanbasu/src/scss/settings/settings';
@import 'settings/settings';
@import '../../../node_modules/kanbasu/src/scss/tools/functions';
@import '../../../node_modules/kanbasu/src/scss/tools/mixins';
@import '../../../node_modules/kanbasu/src/scss/tools/rtl';
================================================
FILE: src/classes/Brush.js
================================================
import {
DEFAULT_COLOR,
RADIUS_DEFAULT,
HARDNESS_DEFAULT,
OPACITY_DEFAULT
} from '@/settings'
import Color from '@/classes/Color'
export default class Brush {
constructor({
color = DEFAULT_COLOR,
radius = RADIUS_DEFAULT,
hardness = HARDNESS_DEFAULT,
opacity = OPACITY_DEFAULT
} = {}) {
this.color = new Color(color)
this.radius = radius
this.hardness = hardness
this.opacity = opacity
this.style = 'smudge'
this.useFilter = false
}
/**
* Return the current brush state. This can be used to instanciate a
* new Brush later.
*
* @returns {Object} The brush properties.
*/
get state() {
return {
color: this.color,
radius: this.radius,
hardness: this.hardness,
opacity: this.opacity,
style: this.style
}
}
/**
* Calculate and return the brush radius based on the amount
* of bluriness is used.
*
* @returns {Number} The radius in pixels.
*/
get canvasRadius() {
if (!this.useFilter) {
return this.radius
}
return (this.hardness / 100 + 1) * this.radius
}
/**
* Calculate and return the blur amount for canvas filters.
*
* @returns {Number} The blur amount in pixels.
*/
get canvasBlur() {
return (1 - this.hardness / 100) * this.radius
}
/**
* Return the brush color as a string to be used for canvas values.
*
* @returns {String} The color as a rgba string.
*/
get canvasColor() {
return this.color.getRgbaString(this.opacity)
}
/**
* Returns the canvas properties required to draw with this brush.
*
* @returns {Object}
*/
get canvasProperties() {
const properties = {
lineJoin: 'round',
lineCap: 'round',
lineWidth: this.canvasRadius * 2,
globalAlpha: 1,
strokeStyle: this.canvasColor,
fillStyle: this.canvasColor
}
if (this.useFilter) {
properties.filter = `blur(${this.canvasBlur}px)`
}
return properties
}
/**
* Sets the brush color.
*
* @param {Color} color The new color.
*/
setColor(color) {
this.color = color
}
/**
* Sets the brush radius.
*
* @param {Number} radius The new radius.
*/
setRadius(radius) {
this.radius = radius
}
/**
* Sets the brush hardness.
*
* @param {Number} hardness The new hardness.
*/
setHardness(hardness) {
this.hardness = hardness
}
/**
* Sets the brush opacity.
*
* @param {Number} opacity The new opacity.
*/
setOpacity(opacity) {
this.opacity = opacity
}
/**
* Sets the brush style.
*
* @param {String} style The new style.
*/
setStyle(style) {
this.style = style
}
/**
* Sets the flag to use canvas filters or not.
*
* @param {Boolean} isSupported Whether canvas filters are supported.
*/
setFilterSupport(isSupported) {
this.useFilter = isSupported
}
}
================================================
FILE: src/classes/Canvas/Action.js
================================================
import { clearCanvas } from '@/tools/canvas'
/**
* A canvas action.
*/
export default class Action {
constructor(type) {
this.type = type
}
/**
* Run the action on a canvas.
*
* @param {HTMLCanvasElement} canvas
* @param {Object} size
*/
do(canvas, size) {
switch (this.type) {
case 'erase':
clearCanvas(canvas, size)
break
}
}
}
================================================
FILE: src/classes/Canvas/DrawAction.js
================================================
import Action from './Action'
import { midPointBetween } from '@/tools/helpers.js'
/**
* A canvas drawing action.
*/
export default class DrawAction extends Action {
constructor(canvasProperties) {
super('stroke')
this.canvasProperties = canvasProperties
this.points = []
}
/**
* Set the properties on the canvas.
*
* @param {Object} props An object with canvas properties and the desired values.
*/
setCanvasProperties(canvas) {
const context = canvas.getContext('2d')
Object.keys(this.canvasProperties).forEach((p) => {
context[p] = this.canvasProperties[p]
})
}
/**
* Draws the action to the canvas.
*
* @param {HTMLCanvasElement} canvas The canvas to draw on to.
*/
do(canvas) {
// First prepare the canvas with the properties from the action.
this.setCanvasProperties(canvas)
const c = canvas.getContext('2d')
let p1 = this.points[0]
let p2 = this.points[1]
c.beginPath()
c.moveTo(p1.x, p1.y)
for (let i = 1; i < this.points.length; i++) {
// we pick the point between pi+1 & pi+2 as the
// end point and p1 as our control point
const midPoint = midPointBetween(p1, p2)
c.quadraticCurveTo(p1.x, p1.y, midPoint.x, midPoint.y)
p1 = this.points[i]
p2 = this.points[i + 1]
}
// Draw last line as a straight line while
// we wait for the next point to be able to calculate
// the bezier control point
c.lineTo(p1.x, p1.y)
c.stroke()
}
}
================================================
FILE: src/classes/Canvas/index.js
================================================
import Action from './Action'
import DrawAction from './DrawAction'
import { clearCanvas } from '@/tools/canvas'
export default class {
/**
* Handle drawing using { x, y } coordinates. Supports various brush
* settings and offers undo and redo functionality.
*
* @param {HTMLCanvasElement} canvasMain The canvas where the drawing is.
* @param {HTMLCanvasElement} canvasTemp Temporary canvas for the current stroke.
*/
constructor(canvasMain, canvasTemp) {
this.actions = []
this._canvasMain = canvasMain
this._canvasTemp = canvasTemp
this._size = {
width: 0,
height: 0
}
this.currentAction = null
this._historyIndex = 0
this.lastActionType = ''
this.init()
}
/**
* Initialize the canvases.
*/
init() {
this.setSizes({ width: window.innerWidth, height: window.innerHeight })
}
/**
* Returns the current state about what actions are available.
* Used to show or hide buttons that interact with CanvasState.
*
* @returns {Object} Current state.
*/
get state() {
const undoPossible = this.actions.length > 0 && this._historyIndex > 0
const redoPossible =
this.actions.length > 0 && this._historyIndex < this.actions.length
const clearPossible =
this.lastActionType !== 'erase' || !this.lastActionType
return { undoPossible, redoPossible, clearPossible }
}
/**
* Set the sizes of the canvas.
*
* @param {Object} size
* @param {Number} size.width
* @param {Number} size.height
*/
setSizes({ width, height }) {
this._size.width = width
this._size.height = height
}
/**
* Update the sizes of the canvas and redraw the current state.
*
* @param {Object} viewport
*/
updateSizes(viewport) {
this.setSizes(viewport)
this.redraw()
}
/**
* Start a new drawing action.
*
* @param {Object} canvasProperties
*/
start(canvasProperties) {
this.currentAction = new DrawAction(canvasProperties)
}
/**
* If an action is being pushed while the undo functionality has been used,
* remove all actions after that. This basically removes the ability to use
* redo at this point, until another undo step is done.
*/
updateHistoryState() {
if (this._historyIndex !== this.actions.length) {
this.actions.splice(this._historyIndex)
}
}
/**
* Push an action to the history stack und update some variables related to
* that. Also update the history index.
*
* @param {DrawAction} action The action to add to the history.
*/
pushAction(action) {
this.updateHistoryState()
this.actions.push(action)
this.lastActionType = action.type
this._historyIndex = this.actions.length
}
/**
* Handles the release of the mouse or touchend.
*/
release() {
this.copy(this._canvasTemp, this._canvasMain)
clearCanvas(this._canvasTemp, this._size)
this.pushAction(this.currentAction)
}
/**
* Push the point to the current action points array and then draw the current
* action to the temporary canvas.
*
* @param {Point} point The x and y coordinates of the next point.
*/
move(point) {
this.currentAction.points.push(point)
clearCanvas(this._canvasTemp, this._size)
this.currentAction.do(this._canvasTemp)
}
/**
* Copy the contents of the source canvas to the target canvas.
*
* @param {HTMLCanvasElement} source The canvas to copy from.
* @param {HTMLCanvasElement} target The canvas to copy the source to.
*/
copy(source, target) {
target
.getContext('2d')
.drawImage(source, 0, 0, this._size.width, this._size.height)
}
/**
* Undo the last action.
*/
undo() {
clearCanvas(this._canvasMain, this._size)
this._historyIndex = Math.max(this._historyIndex - 1, 0)
if (this._historyIndex >= 0) {
this.drawActions()
}
}
/**
* Redo the previous action.
*/
redo() {
this._historyIndex = Math.min(this._historyIndex + 1, this.actions.length)
if (this._historyIndex <= this.actions.length) {
clearCanvas(this._canvasMain, this._size)
this.drawActions()
}
}
/**
* Clear the main canvas and draw all current actions.
*/
redraw() {
clearCanvas(this._canvasMain, this._size)
this.drawActions()
}
/**
* Draw all current actions to the main canvas.
*/
drawActions() {
// First, figure out the range of actions to draw.
// This loop will find the last occurence of an erase action.
let startIndex = 0
for (let i = 0; i < this._historyIndex; i++) {
const action = this.actions[i]
if (action.type === 'erase') {
startIndex = i
}
}
// Draw all actions from the last erase up to the most recent action.
for (let i = startIndex; i < this._historyIndex; i++) {
const action = this.actions[i]
action.do(this._canvasMain, this._size)
this.lastActionType = action.type
}
}
/**
* Erase the contents of the main canvas and push the action for it.
*/
erase() {
if (this.lastActionType === 'erase') {
return
}
this.pushAction(new Action('erase'))
clearCanvas(this._canvasMain, this._size)
}
}
================================================
FILE: src/classes/Color.js
================================================
import { getRgbaString, hexToRgb } from '@/tools/helpers.js'
/**
* Manages a color.
*/
export default class Color {
constructor({ name, rgb = [0, 0, 0], hex } = {}) {
this.name = name
this.rgb = hex ? hexToRgb(hex) : rgb
}
/**
* Set the color.
*
* @param {Array} rgb The rgb values as an array.
*/
setColor(rgb) {
this.rgb = rgb
}
/**
* Build an rgba string given the alpha value.
*
* @param {Number} alpha The alpha value to be used (0 - 100).
*/
getRgbaString(alpha) {
return getRgbaString(this.rgb, alpha / 100)
}
}
================================================
FILE: src/classes/Rectangle.js
================================================
import { Point } from 'lazy-brush'
export default class Rectangle {
constructor(x = 0, y = 0, width = 0, height = 0) {
this.p1 = new Point(x, y)
this.p2 = new Point(x + width, y + height)
}
/**
* @returns {Number} The width of the rectangle.
*/
get width() {
return this.p2.x - this.p1.x
}
/**
* @returns {Number} The height of the rectangle.
*/
get height() {
return this.p2.y - this.p1.y
}
/**
* Set the rectangles points based on the values from a DOMRect.
*
* @param {DOMRect} domRect
*/
setFromDOMRect(domRect) {
this.p1.x = domRect.left
this.p1.y = domRect.top
this.p2.x = domRect.left + domRect.width
this.p2.y = domRect.top + domRect.height
}
/**
* Set the rectangles points based on the width, height and distance to parent.
*
* @param {HTMLElement} element
*/
setFromElement(element) {
this.p1.x = Number(element.offsetLeft)
this.p1.y = Number(element.offsetTop)
this.p2.x = Number(element.offsetLeft + element.offsetWidth)
this.p2.y = Number(element.offsetTop + element.offsetHeight)
}
/**
* Check if the given point is within this rectangle.
*
* @param {Point} point An object containing x and y properties.
* @returns {Boolean}
*/
containsPoint(point) {
return (
this.p1.x <= point.x &&
point.x <= this.p2.x &&
this.p1.y <= point.y &&
point.y <= this.p2.y
)
}
}
================================================
FILE: src/classes/Smoothing.js
================================================
/**
* Slowly ease a value to a new value.
*/
export default class Smoothing {
constructor() {
this.prev = 0
this.prevRaw = 0
this.smoothing = 0.08
}
/**
* Get the next eased value.
*
* @param {Number} input The input value
* @param {Boolean} force If true, the value will be updated immediately.
*/
next(input, force) {
if (Math.abs(input - this.prev) > 0.1 || force) {
const value = this.prev + this.smoothing * (input - this.prev)
this.prev = value
return value
} else {
return this.prev
}
}
}
================================================
FILE: src/components/Common/Animation/Animation.vue
================================================
<template>
<div
class="animation"
:class="{
'is-desktop': isDesktop,
'is-mobile': !isDesktop,
'is-fallback': useFallback
}"
>
<div ref="container" class="three-animation"></div>
<slot></slot>
<div v-if="debug" class="ratio">{{ ratio }}</div>
<div v-if="debug" class="debug-range">
<input
type="range"
min="0"
max="100"
step="0.001"
value="0"
@input="handleRange"
/>
</div>
</div>
</template>
<script>
import { mapState } from 'vuex'
import Drawing from '@/components/Desktop/Drawing.vue'
import ThreeAnimation from '@/tools/animation'
import webglIsSupported from '@/tools/animation/webglSupport'
import { ANIMATION_SCREEN_VIEWPORT } from '@/settings'
import threads from '@/store/vuetamin/threads'
import debouncedResize from 'debounced-resize'
export default {
name: 'Animation',
props: {
isDrawing: Boolean,
isDesktop: Boolean,
desktopAnimation: {
type: Boolean,
default: false
}
},
data() {
return {
isRendered: false,
x: 180,
y: 0,
windowWidth: 958,
windowHeight: 542,
count: 0,
mouseEnabled: true,
sceneVisible: true,
useFallback: false,
debug: false
}
},
computed: {
...mapState(['isConnected', 'isSkipped', 'introPlayed']),
ratio() {
return this.windowWidth / this.windowHeight
}
},
watch: {
x(x) {
this.updateRotation(x, this.y)
},
y(y) {
this.updateRotation(this.x, y)
}
},
destroyed() {
this.destroy()
},
mounted() {
if (!webglIsSupported()) {
this.useFallback = true
this.isRendered = true
this.$store.commit('setIntroPlayed', true)
return
}
const pairingEl = this.$slots.default[0].elm
this.updateSizes()
debouncedResize(() => {
if (this.isDesktop) {
this.updateSizes()
}
})
this.animation = new ThreeAnimation(
this.$refs.container,
ANIMATION_SCREEN_VIEWPORT,
this.desktopAnimation,
this.debug,
pairingEl
)
const screen = this.animation.getScreen()
const DrawingCtor = this.$root.constructor.extend(Drawing)
this.instance = new DrawingCtor({
parent: this,
propsData: {
showToolbar: true,
isDrawing: false
}
}).$mount(screen)
this.animation.setSize(this.windowWidth, this.windowHeight)
window.addEventListener('mousemove', this.onMouseMove)
window.addEventListener('mousedown', this.onMouseDown)
window.addEventListener('mouseup', this.onMouseUp)
window.addEventListener('touchstart', this.onMouseDown)
window.addEventListener('touchend', this.onMouseUp)
window.addEventListener('touchcancel', this.onMouseUp)
if (!this.isDesktop) {
this.loop()
}
if (!this.introPlayed) {
this.animation.animateEnter()
} else {
this.animation.setFinalCameraState()
}
this.animation.on('animationEnd', () => {
this.$vuetamin.trigger(threads.SIZES)
this.$store.commit('setIntroPlayed', true)
})
this.animation.on('slowPerformance', () => {
this.$store.commit('setIntroPlayed', true)
this.$sentry.logInfo('animation', 'slow performance')
this.$track('Animation', 'performance', 'slow')
this.useFallback = true
this.isRendered = true
this.destroy()
const pairingEl = this.$slots.default[0].elm
pairingEl.removeAttribute('style')
})
this.animation.refresh()
this.animation.setSize(this.windowWidth, this.windowHeight)
this.isRendered = true
this.animation.play()
},
methods: {
destroy() {
if (this.instance) {
this.instance.$destroy()
this.instance.$el.remove()
this.instance = null
}
window.removeEventListener('mousemove', this.onMouseMove)
window.removeEventListener('mousedown', this.onMouseDown)
window.removeEventListener('mouseup', this.onMouseUp)
window.removeEventListener('touchstart', this.onMouseDown)
window.removeEventListener('touchend', this.onMouseUp)
window.removeEventListener('touchcancel', this.onMouseUp)
},
loop() {
const orientation = this.$mote.gyroscope.getOrientation(100)
this.animation.setPhoneRotationFromGyro(orientation)
this.getIntersection()
window.requestAnimationFrame(this.loop)
},
handleRange(e) {
this.animation.seekAnimation(e.target.value)
},
updateRotation(x, y) {
this.animation.setPhoneRotationFromMouse(x, y)
this.getIntersection()
},
getIntersection() {
const coordinates = this.animation.getIntersection()
if (!coordinates) return
this.$vuetamin.store.mutate('updatePointer', { coordinates })
},
onMouseMove(e) {
if (!this.mouseEnabled) {
return
}
// e.preventDefault()
this.setOrientation(e.pageX, e.pageY)
},
onMouseDown() {
if (!this.mouseEnabled) {
return
}
// e.preventDefault()
this.$vuetamin.store.mutate('updateIsPressing', { isPressing: true })
},
onMouseUp() {
if (!this.mouseEnabled) {
return
}
// e.preventDefault()
this.$vuetamin.store.mutate('updateIsPressing', { isPressing: false })
},
setOrientation(x, y) {
this.x = x / this.windowWidth
this.y = y / this.windowHeight
},
updateSizes() {
this.windowWidth = window.innerWidth
this.windowHeight = window.innerHeight
if (this.animation) {
this.animation.setSize(this.windowWidth, this.windowHeight)
}
}
}
}
</script>
<style lang="scss">
.debug-range {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 99999;
padding: 1rem;
padding-bottom: 4rem;
// display: none;
}
.animation {
&.is-mobile {
&.component-fade-enter-active,
&.component-fade-leave-active {
transition: 1.5s;
}
&.component-fade-enter,
&.component-fade-leave-to {
opacity: 0;
transform: translateY(-4rem);
}
}
&.is-desktop {
user-select: none;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
&.component-fade-enter-active,
&.component-fade-leave-active {
transition: 1.5s;
}
&.component-fade-enter,
&.component-fade-leave-to {
transform: scale(1.1);
}
}
}
.three-animation {
position: absolute;
top: 0;
left: 0;
overflow: hidden;
z-index: 0;
.is-fallback & {
background-image: url('/fallback-mobile.jpg');
background-size: cover;
background-position: top center;
@include media('lg') {
background-image: url('/fallback-desktop.jpg');
background-position: center;
}
}
.is-desktop & {
width: 100%;
height: 100%;
}
}
.renderer-webgl {
position: relative;
top: 0;
left: 0;
z-index: 10;
}
.renderer-webgl {
z-index: 15;
}
.renderer-css {
&,
canvas {
image-rendering: -moz-crisp-edges;
image-rendering: -webkit-crisp-edges;
image-rendering: pixelated;
image-rendering: crisp-edges;
}
> div {
z-index: 10;
}
}
.dg.ac {
z-index: 999999;
@include media('sm', $breakpoints-desc) {
top: 100vw;
}
}
#screen {
background: red;
}
.screen-wrapper {
transform-origin: top left;
position: relative;
height: 100%;
width: 100%;
}
.screen-container {
z-index: 10;
}
.ratio {
position: fixed;
top: 0;
left: 0;
padding: 2rem;
font-size: 2rem;
font-weight: bold;
z-index: 99999999;
}
</style>
================================================
FILE: src/components/Common/Attribution.vue
================================================
<template>
<div class="attribution">
<div class="attribution-background"></div>
<div class="attribution-overlay pdg md-pdg+ lg-pdg++">
<div class="md-pdg++ attribution-content">
<h2 class="text-heavy h1 mrgb">Attribution</h2>
<p class="h2 mrgb+">
drawmote was only made possible thanks to the contributions of the
following people and projects.
</p>
<template v-for="section in sections">
<div :key="section.name">
<h3 class="h2 text-bold text-uppercase mrgb- mrgt++">
{{ section.name }}
</h3>
<ul class="list">
<li v-for="item in section.items" :key="item.name" class="mrgb">
<div v-if="section.name === 'Media'">
<h4 class="h4 text-bold">
<a :href="item.link" class="link"
>"{{ item.description }}"</a
>
</h4>
by
<a :href="item.linkUser" class="link">{{ item.name }}</a> is
licensed under
<a
href="https://creativecommons.org/licenses/by/4.0/"
class="link"
>CC BY 4.0</a
>
</div>
<div v-else>
<h4 class="text-bold">
<a :href="item.link" class="link">{{ item.name }}</a>
</h4>
<div>{{ item.description }}</div>
</div>
</li>
</ul>
</div>
</template>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'Attribution',
data() {
return {
sections: [
{
name: 'Contributions',
items: [
{
name: 'Pascal Thormeier',
description:
'Figured out and implemented the math for gyro-plane.',
link: 'http://thormeier.github.io/'
},
{
name: 'Sascha Aeppli',
description: 'Support for various math and JavaScript questions.',
link: 'https://github.com/munxar'
}
]
},
{
name: 'Libraries',
items: [
{
name: 'simple-peer',
description: 'Simple WebRTC video/voice and data channels',
link: 'https://github.com/feross/simple-peer'
},
{
name: 'gyronorm.js',
description:
'JavaScript project for accessing and normalizing the accelerometer and gyroscope data on mobile devices',
link: 'https://github.com/dorukeker/gyronorm.js'
},
{
name: 'Vue.js',
description:
'a progressive, incrementally-adoptable JavaScript framework for building UI on the web. ',
link: 'https://github.com/vuejs/vue'
},
{
name: 'three.js',
description: 'JavaScript 3D library',
link: 'https://github.com/mrdoob/three.js/'
}
]
},
{
name: 'Media',
items: [
{
name: 'BenGreen',
linkUser: 'https://sketchfab.com/antonystix',
description: 'Smartphone | Low Poly | Free Download',
link:
'https://sketchfab.com/3d-models/smartphone-low-poly-free-download-36e8a127e2b9481ea7b4ed4187f2b765'
},
{
name: "Daniel O'Neil",
linkUser: 'https://sketchfab.com/doneil',
description: 'Monitor w/Star Wars Animation: Household Props 4',
link:
'https://sketchfab.com/3d-models/monitor-wstar-wars-animation-household-props-4-8ca4aaaf438a44189443e34962a18cd5'
}
]
}
]
}
}
}
</script>
<style lang="scss">
.attribution {
z-index: $index-footer - 2;
position: fixed;
top: 0;
right: 0;
left: 0;
bottom: 0;
&.appear-enter-active,
&.appear-leave-active {
transition: 0.7s ease-in-out;
.attribution-overlay {
transition: 0.7s ease-in-out;
}
}
&.appear-enter,
&.appear-leave-to {
opacity: 0;
.attribution-overlay {
transform: translateY(30%);
@include media('sm') {
transform: translateY(10%);
}
}
}
}
.attribution-background {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.attribution-background {
background: rgba($alt-color-darkest, 0.99);
}
.attribution-overlay {
position: relative;
overflow: auto;
-webkit-overflow-scrolling: touch;
z-index: $index-footer + 1;
padding-bottom: 5rem !important;
height: 100%;
@include media('sm') {
display: flex;
}
> div {
max-width: 80rem;
margin: 0 auto;
@include media('sm') {
overflow: hidden;
}
}
table {
tr {
td {
padding-left: 0;
padding-right: 0;
}
td:first-child {
width: 10rem;
}
}
}
> div {
}
}
.attribution-content {
overflow: auto;
@include media('sm') {
.list {
display: flex;
flex-wrap: wrap;
li {
flex: 0 0 50%;
}
}
}
}
</style>
================================================
FILE: src/components/Common/BrowserSupport.vue
================================================
<template lang="html">
<transition name="appear">
<div class="browser-support" :class="{ done: allDone }">
<div class="browser-support__content pdg relative">
<template v-if="!needsPermission">
<button
class="btn btn--bare browser-support__close"
@click="$emit('close')"
>
<div class="pdg">
<IconClose class="icon block" />
</div>
</button>
<h3 class="label">{{ $t('browserSupport.title') }}</h3>
<ul class="list check-list">
<li
v-for="check in doneChecks"
:key="check.check"
class="check check--small"
:class="check.state"
>
<div class="check__title">
{{ $t(`browserSupport.${check.check}.label`) }}
</div>
<div class="check__notice">
{{ $t(`browserSupport.${check.check}.${check.state}`) }}
</div>
</li>
</ul>
</template>
<div v-if="needsPermission">
<p class="h3">
{{ $t('browserSupport.requestPermission.text') }}
</p>
<button
class="btn btn--default btn--block mrgt"
@click="requestPermission"
>
{{ $t('browserSupport.requestPermission.cta') }}
</button>
</div>
</div>
</div>
</transition>
</template>
<script>
import IconClose from '@/assets/icons/icon-close.svg'
const CHECKS = ['webRTC', 'webSocket', 'gyroscope', 'canvasFilter']
let watchers = []
const SUPPORT_STATE = {
SUPPORTED: 'supported',
UNSUPPORTED: 'unsupported',
PARTIAL: 'partial',
CHECKING: 'checking'
}
const CHECK_STATE = {
TRUE: 'supported',
FALSE: 'unsupported',
NOT_REQUIRED: 'not_required',
WAITING: 'waiting',
CHECKING: 'checking'
}
function getCheckStateFromBoolean(isSupported) {
return isSupported ? CHECK_STATE.TRUE : CHECK_STATE.FALSE
}
function filterRelevantCheck(check) {
return check !== CHECK_STATE.CHECKING && check !== CHECK_STATE.NOT_REQUIRED
}
export default {
name: 'BrowserSupport',
components: {
IconClose
},
props: {
isMobile: {
type: Boolean,
default: false
}
},
data() {
let data = {
needsPermission: false
}
CHECKS.forEach((check) => {
data[check] = CHECK_STATE.CHECKING
})
return data
},
computed: {
/**
* @returns {Array} Return the checks with their support status.
*/
doneChecks() {
return this.relevantChecks.map((check) => {
return {
check: check,
state: this[check]
}
})
},
relevantChecks() {
return CHECKS.filter((check) => filterRelevantCheck(this[check]))
},
allDone() {
return this.relevantChecks.length === this.doneChecks.length
},
supportState() {
if (!this.allDone) {
return SUPPORT_STATE.CHECKING
}
if (
(this.webRTC === CHECK_STATE.FALSE &&
this.webSocket === CHECK_STATE.FALSE) ||
(this.isMobile && this.gyroscope === CHECK_STATE.FALSE)
) {
return SUPPORT_STATE.UNSUPPORTED
}
if (
this.relevantChecks.filter((check) => this[check] === CHECK_STATE.FALSE)
.length > 0
) {
return SUPPORT_STATE.PARTIAL
}
if (this.needsPermission) {
return SUPPORT_STATE.PARTIAL
}
return SUPPORT_STATE.SUPPORTED
}
},
watch: {
supportState(state) {
this.$emit('supportState', state)
}
},
mounted() {
if (!this.$settings.isPrerendering) {
this.$emit('supportState', this.supportState)
watchers = CHECKS.map((check) => {
return this.$watch(check, (checkState) => {
this.$sentry.setSupport(check, checkState)
})
})
this.runCheck()
this.$peersox.on('usingFallback', () => {
this.webRTC = CHECK_STATE.FALSE
})
}
},
beforeDestroy() {
watchers.forEach((watcher) => watcher())
},
methods: {
/**
* Checks if canvas filters are supported. This is needed for the hardness
* property of the brush.
*/
supportsCanvasFilter() {
const ctx = document.createElement('canvas').getContext('2d')
return getCheckStateFromBoolean(typeof ctx.filter !== 'undefined')
},
/**
* Checks if WebSockets are supported.
*/
supportsWebSocket() {
return getCheckStateFromBoolean(
this.$peersox.getDeviceSupport().WEBSOCKET
)
},
/**
* Checks if WebRTC is supported.
*/
supportsWebRTC() {
return getCheckStateFromBoolean(this.$peersox.getDeviceSupport().WEBRTC)
},
/**
* Checks if the device has a gyroscope.
*/
supportsGyroscope() {
return this.$mote.deviceHasGyroscope().then(getCheckStateFromBoolean)
},
/**
* Asynchronously run the checks. Issue tracking events, supportState event
* and modify the Vuetamin store if canvas filters are supported.
*/
runCheck() {
this.webRTC = this.supportsWebRTC()
this.webSocket = this.supportsWebSocket()
// Checks gyroscope availability.
if (this.isMobile) {
if (this.$mote.gyroscope.needsPermission()) {
this.needsPermission = true
} else {
this.supportsGyroscope().then((hasGyroscope) => {
this.gyroscope = hasGyroscope
})
this.$mote.gyroscope.initGyroNorm()
}
} else {
this.gyroscope = CHECK_STATE.NOT_REQUIRED
}
// Checks if canvas filter is available. For mobile this is only relevant
// for the demo inside the 3D screen, that's why the user does not need to
// be informed about that. It is set however in the Vuetamin store, so
// that the canvas inside the demo doesn't attempt to use a canvas filter.
const canvasFilter = this.supportsCanvasFilter()
if (!this.isMobile) {
this.canvasFilter = canvasFilter
}
this.$vuetamin.store.mutate('updateCanvasFilterSupport', canvasFilter)
},
requestPermission() {
this.$mote.gyroscope.requestPermission().then((isGranted) => {
this.needsPermission = false
this.gyroscope = CHECK_STATE.TRUE
})
}
}
}
</script>
<style lang="scss">
@import '@/assets/scss/components/_check.scss';
.browser-support {
display: block;
text-align: left;
position: absolute;
bottom: 100%;
left: 0;
width: 100vw;
max-width: 24rem;
z-index: -1;
background: $color-translucent-dark;
&.appear-enter-active,
&.appear-leave-active {
transition: 0.5s;
.browser-support__content {
transition: 0.2s;
transition-delay: 0.3s;
}
}
&.appear-enter,
&.appear-leave-to {
transform: translateY(130%);
@include media('md') {
transform: translateX(-100%);
}
.browser-support__content {
opacity: 0;
}
}
h3 {
text-transform: uppercase;
}
}
.browser-support__close {
position: absolute;
top: 0;
right: 0;
.icon {
margin-top: rem(9px);
}
}
.browser-support__content {
}
.check-list {
li:not(:last-child) {
margin-bottom: 0.75rem;
}
}
</style>
================================================
FILE: src/components/Common/ConnectionTimeout.vue
================================================
<template>
<transition name="fade">
<div v-if="isVisible" class="connection-timeout">
<div>
<h3 class="h2 text-bold">
{{ $t('connectionTimeout.title') }}
</h3>
<p class="h2 mrgb+">
{{ $t('connectionTimeout.text ') }}
</p>
<ul class="list-inline">
<li>
<button class="btn btn--primary relative" @click="backToPairing">
{{ $t('connectionTimeout.toPairing') }}
</button>
</li>
<li v-if="!isMobile">
<button class="btn btn--default relative" @click="continueDrawing">
{{ $t('connectionTimeout.continueDrawing') }}
</button>
</li>
</ul>
</div>
</div>
</transition>
</template>
<script>
/**
* Provides a way to restore a previously made connection.
*/
export default {
name: 'ConnectionTimeout',
props: {
isMobile: {
type: Boolean,
default: false
}
},
data() {
return {
isVisible: false
}
},
mounted() {
this.$peersox.on('connected', this.handleConnected)
this.$peersox.on('connectionTimeout', this.handleConnectionTimeout)
},
beforeDestroy() {
this.$peersox.off('connected', this.handleConnected)
this.$peersox.off('connectionTimeout', this.handleConnectionTimeout)
},
methods: {
handleConnected() {
this.isVisible = false
},
handleConnectionTimeout() {
this.isVisible = true
},
continueDrawing() {
this.isVisible = false
},
backToPairing() {
this.$mote.disconnect()
}
}
}
</script>
<style lang="scss">
.connection-timeout {
position: fixed;
z-index: 1000;
top: 0;
left: 0;
width: 100%;
height: 100%;
padding: 1rem;
background: rgba($alt-color-lighter, 0.95);
display: flex;
align-items: center;
justify-content: center;
text-align: center;
&.fade-enter-active,
&.fade-leave-active {
&,
> div {
transition: 0.5s;
}
}
&.fade-enter, &.fade-leave-to /* .fade-leave-active below version 2.1.8 */ {
opacity: 0;
> div {
transform: scale(1.1);
}
}
@include media('sm') {
text-align: left;
}
> div {
max-width: 60rem;
}
}
</style>
================================================
FILE: src/components/Common/Footer/Footer.vue
================================================
<template>
<div ref="footer" class="footer">
<div class="footer__content">
<ul class="list-inline list-inline--tight text-small footer__list">
<FooterBrowserSupport :is-mobile="isMobile" />
<FooterLanguage />
<FooterConnection v-if="!isMobile" />
<FooterGithub />
<FooterCopyright />
<FooterAttribution />
</ul>
</div>
</div>
</template>
<script>
import debouncedResize from 'debounced-resize'
import FooterBrowserSupport from '@/components/Common/Footer/FooterBrowserSupport.vue'
import FooterCopyright from '@/components/Common/Footer/FooterCopyright.vue'
import FooterConnection from '@/components/Common/Footer/FooterConnection.vue'
import FooterGithub from '@/components/Common/Footer/FooterGithub.vue'
import FooterLanguage from '@/components/Common/Footer/FooterLanguage.vue'
import FooterAttribution from '@/components/Common/Footer/FooterAttribution.vue'
export default {
name: 'Footer',
components: {
FooterBrowserSupport,
FooterCopyright,
FooterConnection,
FooterGithub,
FooterLanguage,
FooterAttribution
},
props: {
isMobile: {
type: Boolean,
default: false
}
},
mounted() {
this.updateSizes()
debouncedResize(() => {
this.updateSizes()
})
},
methods: {
updateSizes() {
const footer = this.$refs.footer
this.$vuetamin.store.mutate('updateFooterRect', footer)
}
}
}
</script>
<style lang="scss">
.footer {
position: fixed;
right: 0;
left: 0;
bottom: 0;
user-select: none;
z-index: $index-footer;
@include media('xs', $breakpoints-desc) {
&:before {
content: '';
position: absolute;
pointer-events: none;
top: -1.5rem;
left: 0;
width: 100%;
bottom: 0;
background: linear-gradient(
0deg,
$alt-color-darkest,
rgba($alt-color-darkest, 0.9),
rgba($alt-color-darkest, 0)
);
}
}
}
.footer__content {
position: relative;
z-index: $index-footer;
}
.footer__list {
align-items: stretch !important;
@include media('sm') {
.hover {
&:hover {
background: $color-translucent-dark;
}
}
}
}
.footer-text {
@include media('xs', $breakpoints-desc) {
font-size: 11px;
}
}
</style>
================================================
FILE: src/components/Common/Footer/FooterAttribution.vue
================================================
<template>
<li class="footer-attribution">
<button
class="btn btn--bare text-bold pdg lg-pdg h-100 hover footer-text"
@click="$store.dispatch('toggleAttributionVisibility')"
>
<span class="arrow-after">Attribution</span>
</button>
</li>
</template>
<script>
export default {
name: 'FooterAttribution'
}
</script>
<style lang="scss"></style>
================================================
FILE: src/components/Common/Footer/FooterBrowserSupport.vue
================================================
<template>
<li class="relative footer-browser-support">
<button
class="btn btn--bare text-bold check pdg lg-pdg h-100 hover footer-text"
:class="[supportState, { 'is-open': browserSupportVisible }]"
@click="toggleBrowserSupport"
>
<div class="check__title">
<span class="arrow-after">{{
$t(`browserSupport.footer.${supportState}`)
}}</span>
</div>
</button>
<BrowserSupport
v-show="browserSupportVisible"
:is-mobile="isMobile"
@supportState="handleBrowserSupportState"
@close="closeBrowserSupport"
/>
</li>
</template>
<script>
import { mapGetters } from 'vuex'
import BrowserSupport from '@/components/Common/BrowserSupport.vue'
export default {
name: 'FooterBrowserSupport',
components: {
BrowserSupport
},
props: {
isMobile: {
type: Boolean,
default: false
}
},
data() {
return {
supportState: 'checking',
browserSupportVisible: false
}
},
computed: {
...mapGetters(['isDrawing'])
},
watch: {
isDrawing(isDrawing) {
if (isDrawing) {
this.browserSupportVisible = false
}
}
},
methods: {
toggleBrowserSupport() {
this.browserSupportVisible = !this.browserSupportVisible
this.$track('BrowserSupport', 'show', 1)
},
handleBrowserSupportState(state) {
this.supportState = state
if (state !== 'supported') {
this.browserSupportVisible = true
} else {
this.browserSupportVisible = false
}
},
closeBrowserSupport() {
this.browserSupportVisible = false
}
}
}
</script>
<style lang="scss">
@import '@/assets/scss/components/_check.scss';
.footer-browser-support {
flex: 1;
z-index: 1;
> .btn {
width: 100%;
text-align: left;
&.is-open {
background: $color-translucent-dark;
}
}
@include media('sm') {
flex: 0 0 auto;
}
}
</style>
================================================
FILE: src/components/Common/Footer/FooterConnection.vue
================================================
<template>
<li class="footer-connection">
<button
v-if="isConnected"
class="btn btn--bare text-bold check pdg lg-pdg h-100 hover footer-text"
@click="$store.dispatch('disconnect')"
>
{{ $t('footer.disconnect') }}
</button>
<button
v-if="isSkipped"
class="btn btn--bare text-bold check pdg lg-pdg h-100 hover footer-text"
@click="$store.dispatch('unskip')"
>
{{ $t('footer.toPairing') }}
</button>
</li>
</template>
<script>
import { mapState } from 'vuex'
export default {
name: 'FooterConnection',
computed: {
...mapState(['isConnected', 'isSkipped'])
}
}
</script>
<style lang="scss"></style>
================================================
FILE: src/components/Common/Footer/FooterCopyright.vue
================================================
<template>
<li class="text-right hidden-xs-down footer-copyright">
<div class="pdg footer-text">
Made by <a href="http://www.janhug.info" class="text-bold">Jan Hug</a> at
<a href="https://www.liip.ch"><LiipLogo /></a>
</div>
</li>
</template>
<script>
import LiipLogo from '@/assets/icons/liip-logo.svg'
export default {
name: 'FooterCopyright',
components: {
LiipLogo
}
}
</script>
<style lang="scss">
.footer-copyright {
svg {
height: 0.7em;
margin-left: 0.1rem;
fill: currentColor;
}
}
</style>
================================================
FILE: src/components/Common/Footer/FooterGithub.vue
================================================
<template>
<li class="text-bold mrgla hover">
<a
class="pdg block text-white footer-text"
href="https://github.com/dulnan/drawmote"
>
<IconGithub class="icon icon--large" />
<span class="hidden-sm-down">GitHub</span>
</a>
</li>
</template>
<script>
import IconGithub from '@/assets/icons/icon-github.svg'
export default {
name: 'FooterCopyright',
components: {
IconGithub
}
}
</script>
================================================
FILE: src/components/Common/Footer/FooterLanguage.vue
================================================
<template>
<li class="text-bold hover relative language">
<select
v-model="$i18n.locale"
class="language__select"
@change="handleLanguageChange"
>
<option
v-for="(lang, i) in languages"
:key="`Lang${i}`"
:value="lang.key"
>{{ lang.label }}</option
>
</select>
<div class="text-bold pdg h-100 language__button footer-text">
<span class="arrow-after hidden-sm-down">{{
currentLanguage.label
}}</span>
<span class="arrow-after hidden-md-up text-uppercase">{{
currentLanguage.key
}}</span>
</div>
</li>
</template>
<script>
import { setLocale } from '@/tools/cookies'
export default {
name: 'FooterLanguage',
props: {
isMobile: {
type: Boolean,
default: false
}
},
data() {
return {
languages: [
{
key: 'de',
label: 'Deutsch'
},
{
key: 'en',
label: 'English'
}
]
}
},
computed: {
currentLanguage() {
const currentLanguage = this.languages.find(
(l) => l.key === this.$i18n.locale
)
// Return English as a fallback if for some reason i18n doesn't return
// anything.
return currentLanguage || this.languages[1]
}
},
methods: {
handleLanguageChange(e) {
setLocale(e.target.value)
}
}
}
</script>
<style lang="scss">
.language__select {
position: absolute;
top: 0;
left: 0;
cursor: pointer;
width: 100%;
height: 100%;
opacity: 0;
&:focus {
outline: none;
}
}
</style>
================================================
FILE: src/components/Common/Logo.vue
================================================
<template>
<div class="logo mrgt+">
<div class="logo__app">
<img src="drawmote-logo.png" />
</div>
</div>
</template>
<script>
export default {
name: 'Logo'
}
</script>
<style lang="scss">
$logo-base: 768;
@keyframes pulse {
0% {
transform: scale(1);
}
100% {
transform: scale(0.95);
}
}
.logo {
font-size: 9rem;
@include media('sm') {
font-size: 11rem;
}
@include media('md') {
font-size: 8rem;
margin-left: -6rem;
}
@include media('lg') {
// font-size: 16rem;
}
}
.logo__app {
position: relative;
z-index: 1;
width: 1em;
height: 1em;
background: white;
border-radius: 0.22em;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 ((30 / $logo-base) * 1em) ((100 / $logo-base) * 1em)
((7 / $logo-base) * 1em) rgba(0, 0, 0, 0.04),
0 ((1 / $logo-base) * 1em) ((4 / $logo-base) * 1em) ((2 / $logo-base) * 1em)
rgba(0, 0, 0, 0.01);
/* animation: 2s pulse cubic-bezier(1, -0.04, 0.63, 1.01) infinite alternate; */
img {
display: block;
width: 100%;
height: 100%;
}
}
</style>
================================================
FILE: src/components/Common/RestoreConnection.vue
================================================
<template>
<transition name="appear">
<div v-show="isVisible" class="connection pdg lg-pdg+">
<div class="flex-1 flex">
<div class="connection__icon hidden-md-down mrgr lg-mrgr+">
<IconRestore />
</div>
<div>
<h3 class="text-bold">{{ $t('connection.title') }}</h3>
<p class="mrg0 h4 text-light text-hyphens mrgb sm-mrgb0 sm-pdgr-">
{{ $t('connection.text') }}
</p>
</div>
</div>
<div class="connection__buttons flex md-mrgl">
<div>
<button
class="btn btn--default connection__button-clear"
@click="deleteConnection"
>
<span>{{ $t('connection.delete') }}</span>
</button>
</div>
<div class="flex-1 pdgl">
<button
class="btn btn--primary connection__button connection__button--restore relative btn--block"
:class="{ restoring: isRestoring, 'is-restored': isRestored }"
@click="restoreConnection"
>
<span>{{ $t('connection.reconnect') }}</span>
<div class="connection__button-animation">
<IconRestore />
</div>
</button>
</div>
</div>
</div>
</transition>
</template>
<script>
import IconRestore from '@/assets/icons/icon-restore.svg'
import { mapGetters } from 'vuex'
/**
* Provides a way to restore a previously made connection.
*/
export default {
name: 'RestoreConnection',
components: {
IconRestore
},
data() {
return {
connectionRestorable: false,
pairing: {},
isRestoring: false,
isRestored: false,
connectionTimeout: false,
windowTimeout: null
}
},
computed: {
...mapGetters(['isDrawing']),
isVisible() {
return this.connectionRestorable && !this.isDrawing
}
},
mounted() {
this.$peersox.on('connected', this.handleConnected)
this.$peersox
.restorePairing()
.then((pairing) => {
if (pairing) {
this.pairing = pairing
this.isRestoring = false
this.isRestored = false
this.connectionRestorable = true
this.$sentry.logInfo('pairing', 'restoring:found')
}
})
.catch(() => {
this.$sentry.logInfo('pairing', 'restoring:failed')
})
},
beforeDestroy() {
this.$peersox.off('connected', this.handleConnected)
},
methods: {
/**
* Initialize a peering connection given the stored code and hash.
*/
restoreConnection() {
if (this.isRestoring) {
return
}
this.$sentry.logInfo('pairing', 'restoring:start')
this.isRestoring = true
this.$peersox
.close()
.then(this.$peersox.connect(this.pairing))
.catch(() => {
this.$sentry.logInfo('pairing', 'restoring:failed')
})
window.clearTimeout(this.windowTimeout)
this.windowTimeout = window.setTimeout(() => {
this.connectionTimeout = true
this.isRestoring = false
}, 20000)
},
/**
* Delete the stored connection from the history.
*/
deleteConnection() {
this.connectionRestorable = false
this.$peersox.deletePairing()
},
handleConnected() {
this.isRestored = true
this.connectionRestorable = false
this.connectionTimeout = false
}
}
}
</script>
<style lang="scss">
.connection {
position: relative;
position: sticky;
bottom: $footer-height-xs;
z-index: $index-footer - 1;
background: $color-translucent-dark;
margin-top: 4rem;
opacity: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
text-align: left;
@include media('sm') {
position: absolute;
left: 1rem;
right: 1rem;
flex-direction: row;
border-radius: $border-radius-default;
bottom: calc(#{$footer-height-xs} + 0.5rem);
}
&.appear-enter-active,
&.appear-leave-active {
transition: 0.5s;
}
&.appear-enter,
&.appear-leave-to {
transform: translateY(100%);
opacity: 0;
}
}
.connection__icon {
background: $brand-color;
border-radius: $border-radius-default;
font-size: 3.75rem;
flex: 0 0 1em;
width: 1em;
height: 1em;
display: flex;
align-items: center;
justify-content: center;
svg {
height: 0.7em;
width: 0.7em;
fill: white;
display: block;
}
}
@keyframes iconrotate {
0% {
transform: rotate(0);
}
100% {
transform: rotate(1turn);
}
}
.connection__buttons {
align-items: center;
}
.btn.connection__button--restore {
position: relative;
span {
transition: 0.3s;
}
&:before {
content: '';
display: block;
background: rgba($brand-color-darker, 0.7);
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
transform-origin: left;
transform: scaleX(0);
}
&.restoring {
&:before {
transform: none;
transition: 20s linear;
}
span {
opacity: 0;
}
}
}
.connection__button-animation {
display: block;
position: absolute;
top: 50%;
left: 50%;
height: 2rem;
width: 2rem;
transform: translate(-50%, -50%);
transition: 0.3s;
opacity: 0;
.restoring & {
opacity: 1;
}
svg {
fill: white;
display: block;
width: 2rem;
height: 2rem;
animation: 2s iconrotate infinite linear;
}
}
</style>
================================================
FILE: src/components/Common/ServerStatus.vue
================================================
<template>
<div class="server-status">
<div class="server-status__code">{{ serverStatus.status }}</div>
<div class="server-status__text">
<span class="text-bold">{{ serverStatus.statusText }}:</span>
{{ description }}
<a href="https://github.com/dulnan/drawmote/issues/new" class="link">
create an issue on GitHub.
</a>
</div>
</div>
</template>
<script>
import { mapState } from 'vuex'
export default {
name: 'ServerStatus',
computed: {
...mapState(['serverStatus']),
description() {
if (this.serverStatus.status === 429) {
return 'If you think something is fishy about that, '
}
return 'The server might be down or something else went wrong. If you want you can '
}
}
}
</script>
<style lang="scss">
.server-status {
background: rgba($color-red, 0.2);
border: 1px solid $color-red;
width: 7em;
max-width: 100%;
z-index: 10;
border-radius: 0.1em;
display: flex;
align-items: center;
height: 1em;
padding: 0 0.2em;
@include media('sm') {
}
}
.server-status__text {
font-size: 0.25em;
line-height: 1.25;
}
.server-status__code {
font-weight: bold;
font-size: 0.65em;
line-height: 1.2;
margin-top: -0.05em;
margin-right: 0.3em;
}
</style>
================================================
FILE: src/components/Desktop/Canvas/CanvasDrawing.vue
================================================
<template>
<div>
<canvas
ref="canvas_main"
class="absolute overlay canvas canvas--main"
></canvas>
<canvas
ref="canvas_temp"
class="absolute overlay canvas canvas--temp"
></canvas>
</div>
</template>
<script>
import { setupCanvases } from '@/tools/canvas'
import { EventBus } from '@/events'
import Canvas from '@/classes/Canvas'
import { isSamePoint } from '@/tools/helpers.js'
import threads from '@/store/vuetamin/threads'
export default {
name: 'CanvasDrawing',
vuetamin: {
handleSizes: [threads.SIZES],
handlePoint: threads.POINT
},
data() {
return {
wasPressingBefore: false,
previousPoint: {}
}
},
beforeDestroy() {
EventBus.$off('clearCanvas', this.handleErase)
EventBus.$off('undoCanvas', this.handleUndo)
EventBus.$off('redoCanvas', this.handleRedo)
},
mounted() {
this.wasPressingBefore = false
this.setCanvasSizes()
const canvasMain = this.$refs['canvas_main']
const canvasTemp = this.$refs['canvas_temp']
this.canvasState = new Canvas(canvasMain, canvasTemp)
EventBus.$on('clearCanvas', this.handleErase)
EventBus.$on('undoCanvas', this.handleUndo)
EventBus.$on('redoCanvas', this.handleRedo)
},
methods: {
handleSizes(state) {
this.setCanvasSizes()
this.canvasState.updateSizes(state.sizes.viewport)
},
handlePoint(state) {
if (!state.isPressing && this.wasPressingBefore) {
this.canvasState.release()
this.wasPressingBefore = false
this.updateCanvasState()
}
if (
state.isPressing &&
!isSamePoint(state.points.brush, this.previousPoint) &&
!state.pointingAtToolbar
) {
if (!this.wasPressingBefore) {
this.canvasState.start(state.brush.canvasProperties)
this.wasPressingBefore = true
}
this.canvasState.move(state.points.brush)
}
this.previousPoint = state.points.brush
},
handleErase() {
this.canvasState.erase()
this.updateCanvasState()
},
handleUndo() {
this.canvasState.undo()
this.updateCanvasState()
},
handleRedo() {
this.canvasState.redo()
this.updateCanvasState()
},
updateCanvasState() {
const state = this.canvasState.state
EventBus.$emit('canvasState', state)
},
setCanvasSizes() {
setupCanvases(this.$vuetamin.store.getState().sizes.viewport, [
this.$refs.canvas_main,
this.$refs.canvas_temp
])
}
}
}
</script>
<style lang="scss">
.app-drawing-canvas {
background: white;
height: 100%;
width: 100%;
}
.canvas--main {
z-index: $index-canvas-main;
}
.canvas--temp {
z-index: $index-canvas-temp;
}
</style>
================================================
FILE: src/components/Desktop/Canvas/CanvasInterface.vue
================================================
<template>
<canvas
ref="canvas_interface"
class="absolute overlay canvas canvas--interface"
></canvas>
</template>
<script>
/**
* Draws the moving interface parts of the app on a 2D canvas.
*/
import { setupCanvases, clearCanvas } from '@/tools/canvas'
import { Catenary } from 'catenary-curve'
import threads from '@/store/vuetamin/threads'
import { RADIUS_MAX } from '@/settings'
const BRUSH_PREVIEW_PADDING = 30
export default {
name: 'CanvasInterface',
vuetamin: {
setCanvasSizes: [threads.SIZES],
drawToCanvas: [threads.BRUSH, threads.POINT, threads.SLIDE]
},
created() {
this.catenary = new Catenary()
},
methods: {
/**
* Clears the area outside a rectangle.
*
* @param {CanvasRenderingContext2D} context
* @param {Rectangle} rectangle
*/
clearOutside(context, rectangle) {
const x = rectangle.p1.x
const y = rectangle.p1.y
const width = rectangle.p2.x - x
const height = rectangle.p2.y - y
context.beginPath()
context.rect(x, y, width, height)
context.clip()
},
/**
* Call the function to set the width and height of the canvas elements.
*/
setCanvasSizes(state) {
setupCanvases(state.sizes.viewport, [this.$refs.canvas_interface])
},
drawToCanvas(state) {
const canvas = this.$refs.canvas_interface
const context = canvas.getContext('2d')
clearCanvas(canvas, state.sizes.viewport)
// Save current context before clipping.
context.save()
// Clip region outside actual drawing area
this.clearOutside(context, state.sizes.canvasRect)
// Draw brush point
context.beginPath()
context.fillStyle = state.brush.canvasColor
if (state.brush.useFilter) {
context.filter = `blur(${state.brush.canvasBlur}px)`
}
context.arc(
state.points.brush.x,
state.points.brush.y,
state.brush.canvasRadius,
0,
Math.PI * 2,
true
)
context.fill()
if (state.brush.useFilter) {
context.filter = 'none'
}
/**
* Draw the catenary.
*/
context.beginPath()
context.lineWidth = 1
context.lineCap = 'round'
context.strokeStyle = 'rgba(255,255,255,0.8)'
context.setLineDash([2, 4])
this.catenary.drawToCanvas(
context,
state.points.pointer,
state.points.brush,
state.lazyRadius
)
context.stroke()
/**
* Brush anchor
*/
context.beginPath()
context.fillStyle = 'white'
context.arc(
state.points.brush.x,
state.points.brush.y,
2,
0,
Math.PI * 2,
true
)
context.fill()
/**
* Brush preview
*/
if (state.pointingAtToolbar) {
const backgroundRadius = RADIUS_MAX * 2 + 2 * BRUSH_PREVIEW_PADDING
const brushX = state.points.brush.x
const brushY = state.sizes.toolbarRect.height + backgroundRadius + 24
context.beginPath()
context.fillStyle = '#2a192d'
context.strokeStyle = '#39293c'
context.lineWidth = 1
context.setLineDash([])
context.arc(brushX, brushY, backgroundRadius, 0, Math.PI * 2, true)
context.fill()
context.stroke()
context.beginPath()
context.fillStyle = state.brush.canvasColor
if (state.brush.useFilter) {
context.filter = `blur(${state.brush.canvasBlur}px)`
}
context.arc(
brushX,
brushY,
state.brush.canvasRadius,
0,
Math.PI * 2,
true
)
context.fill()
}
// Restore the saved context.
context.restore()
/**
* Pointer cross and dot.
*/
context.beginPath()
context.fillStyle = 'rgba(255,255,255,0.2)'
context.arc(
state.points.pointer.x,
state.points.pointer.y,
4,
0,
Math.PI * 2,
true
)
context.fill()
context.beginPath()
context.strokeStyle = 'rgba(255,255,255,1)'
context.lineWidth = 1
context.moveTo(state.points.pointer.x - 10, state.points.pointer.y)
context.lineTo(state.points.pointer.x + 10, state.points.pointer.y)
context.moveTo(state.points.pointer.x, state.points.pointer.y - 10)
context.lineTo(state.points.pointer.x, state.points.pointer.y + 10)
context.stroke()
/**
* Out of bounds indicator.
*/
// Find out the highest possible x and y coordinates before it's out of view.
const maxX = state.sizes.canvasRect.width
const maxY =
state.sizes.canvasRect.height +
state.sizes.toolbarRect.height -
state.sizes.footerRect.height
const maxXHalf = maxX / 2
const maxYHalf = maxY / 2
// Calculate the min and max coordinates.
const pointerDotX = Math.max(Math.min(state.points.pointer.x, maxX), 0)
const pointerDotY = Math.max(Math.min(state.points.pointer.y, maxY), 0)
// Calculate how much outside the pointer is from the max and min amount.
const offsetX =
Math.max(
Math.max(Math.abs(state.points.pointer.x - maxXHalf) - maxXHalf, 0) -
10,
0
) / 7
const offsetY =
Math.max(
Math.max(Math.abs(state.points.pointer.y - maxYHalf) - maxYHalf, 0) -
10,
0
) / 7
// The radius increases the more the pointer is outside the view.
const radius = Math.min(Math.max(offsetX, offsetY), 30)
// Draw the circle.
context.beginPath()
context.lineWidth = 2
context.strokeStyle = 'rgba(20,20,20,0.2)'
context.fillStyle = 'rgba(50,50,50,0.1)'
context.arc(pointerDotX, pointerDotY, radius, 0, Math.PI * 2, true)
context.fill()
context.stroke()
}
}
}
</script>
<style lang="scss">
.canvas--interface {
z-index: $index-canvas-interface;
top: 0;
left: 0;
pointer-events: none;
}
</style>
================================================
FILE: src/components/Desktop/Drawing.vue
================================================
<template>
<div class="drawing" :class="{ 'is-drawing': isDrawing }">
<Toolbar v-if="showToolbar" ref="toolbar" />
<div
ref="canvasContainer"
class="drawing-area"
:style="drawingAreaStyle"
></div>
<CanvasDrawing />
<CanvasInterface />
<resize-observer @notify="getElementSizes" />
</div>
</template>
<script>
import threads from '@/store/vuetamin/threads'
import Toolbar from '@/components/Desktop/Toolbar/Toolbar.vue'
import CanvasDrawing from '@/components/Desktop/Canvas/CanvasDrawing.vue'
import CanvasInterface from '@/components/Desktop/Canvas/CanvasInterface.vue'
import PointerEvents from '@/mixins/PointerEvents.js'
export default {
name: 'Drawing',
components: {
Toolbar,
CanvasDrawing,
CanvasInterface
},
mixins: [PointerEvents],
props: {
showToolbar: {
type: Boolean,
default: true
},
isDrawing: {
type: Boolean,
default: true
}
},
data() {
return {
toolbarHeight: 0
}
},
computed: {
drawingAreaStyle() {
return {
top: `${this.toolbarHeight}px`
}
}
},
vuetamin: {
getElementSizes: [threads.SIZES]
},
mounted() {
this.getElementSizes()
if (this.$mote.on) {
this.$mote.on('pointermove', this.handlePointerMove)
this.$mote.on('pointerdown', this.handlePointerDown)
this.$mote.on('pointerup', this.handlePointerUp)
this.$mote.on('touch', this.handleTouch)
this.$mote.on('calibrated', this.handleCalibrated)
}
},
beforeDestroy() {
if (this.$mote.on) {
this.$mote.off('pointermove', this.handlePointerMove)
this.$mote.off('pointerdown', this.handlePointerDown)
this.$mote.off('pointerup', this.handlePointerUp)
this.$mote.off('touch', this.handleTouch)
this.$mote.off('calibrated', this.handleCalibrated)
}
},
methods: {
getElementSizes() {
if (this.$refs.canvasContainer) {
const canvasContainer = this.$refs.canvasContainer
this.$vuetamin.store.mutate('updateCanvasRect', canvasContainer)
}
if (this.$refs.toolbar) {
const toolbar = this.$refs.toolbar.$el
this.$vuetamin.store.mutate('updateToolbarRect', toolbar)
this.toolbarHeight = toolbar.offsetHeight
}
},
handlePointerMove(coordinates) {
this.$vuetamin.store.mutate('updatePointer', { coordinates })
},
handlePointerDown() {
this.$vuetamin.store.mutate('updateIsPressing', { isPressing: true })
},
handlePointerUp() {
this.$vuetamin.store.mutate('updateIsPressing', { isPressing: false })
},
handleTouch(touch) {
this.$vuetamin.store.mutate('updateTouch', touch)
},
handleCalibrated() {
this.$vuetamin.store.mutate('updateCalibration')
}
}
}
</script>
<style lang="scss">
.drawing {
overflow: hidden;
position: absolute;
user-select: none;
z-index: $index-drawing;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: $alt-color-darker;
&.component-fade-enter-active,
&.component-fade-leave-active {
transition: 1.5s;
}
&.component-fade-enter,
&.component-fade-leave-to {
transform: scale(1.2);
opacity: 0;
}
}
.drawing-area {
position: absolute;
left: 0;
right: 0;
bottom: 0;
}
</style>
================================================
FILE: src/components/Desktop/Pairing.vue
================================================
<template>
<div
class="overlay pairing-desktop absolute flex"
:style="transformOriginStyle"
:class="{ 'is-desktop-animation': desktopAnimation }"
>
<div class="pairing-container">
<h1 class="text-heavy h1">drawmote</h1>
<p class="h2 text-bold mrgb+ text-muted">{{ $t('subtitle') }}</p>
<p class="text-muted mrgt0 h3 pairing-lead">{{ $t('desktop.lead') }}</p>
<div class="code code--desktop sm-mrgt md-mrgt+">
<ServerStatus v-if="hasServerError" />
<div v-else class="code__content">
<div
v-for="(number, index) in pairingCodeNumbers"
:key="index"
class="code__item"
:class="{ visible: hasCode && introPlayed }"
>
<div class="code-circle contains" :class="'code-circle--' + number">
<span>{{ number }}</span>
</div>
</div>
</div>
</div>
<div class="pairing__actions mrgt">
<p class="text-muted text-light mrgv0 pairing-skip">
<button class="btn btn--bare" @click="skipPairing">
{{ $t('desktop.nophone') }}
</button>
</p>
<p
v-if="isBlocked"
class="text-muted text-light pairing-lead mrg0 text-brand"
>
{{ $t('desktop.tooManyAttempts') }}
</p>
<p
class="code-timeout text-muted text-light mrg0"
:class="{ visible: hasCode && countdown < 60 }"
>
<span
>{{ $t('desktop.countdownPrefix') }}
{{
$tc('desktop.countdownSeconds', countdown, { count: countdown })
}}
{{ $t('desktop.countdownSuffix') }}</span
>
</p>
</div>
</div>
</div>
</template>
<script>
import { mapGetters, mapState } from 'vuex'
import ServerStatus from '@/components/Common/ServerStatus.vue'
const PAIRING_TIMEOUT = 120
let interval = null
let transitionTimeout = null
export default {
name: 'Pairing',
components: {
ServerStatus
},
props: {
pairing: {
type: Object,
required: true
},
isBlocked: {
type: Boolean,
default: false
},
desktopAnimation: {
type: Boolean,
default: false
}
},
data() {
return {
showModal: false,
hasAppeared: false,
showCode: false,
countdown: PAIRING_TIMEOUT,
center: { x: 0, y: 0 }
}
},
computed: {
...mapState(['introPlayed']),
...mapGetters(['hasServerError']),
pairingCodeNumbers: function () {
if (this.isBlocked) {
return new Array(6).fill('•')
}
if (this.hasCode) {
return this.pairing.code.split('')
}
return new Array(6).fill(' ')
},
hasCode: function () {
return (
this.pairing &&
this.pairing.code &&
this.pairing.code.length > 0 &&
this.showCode
)
},
transformOriginStyle() {
return {
transformOrigin: `${this.center.x}px ${this.center.y}px`
}
}
},
watch: {
pairing(pairing) {
if (pairing) {
this.startTimer()
} else {
this.stopTimer()
}
}
},
mounted() {
window.clearTimeout(transitionTimeout)
transitionTimeout = window.setTimeout(() => {
this.showCode = true
}, 1500)
},
beforeDestroy() {
window.clearTimeout(transitionTimeout)
},
methods: {
updateCenter(center) {
this.center = center
},
skipPairing() {
this.$store.dispatch('skip')
this.$track('Pairing', 'skip', 1)
},
startTimer() {
this.stopTimer()
this.countdown = PAIRING_TIMEOUT
interval = window.setInterval(() => {
this.countdown--
if (this.countdown <= 0) {
this.$emit('pairingTimeout')
this.stopTimer()
}
}, 1000)
},
stopTimer() {
if (interval) {
window.clearInterval(interval)
}
}
}
}
</script>
<style lang="scss">
@import '@/assets/scss/components/_code.scss';
.pairing-desktop {
overflow: hidden;
padding: 2rem 3vw;
padding-top: 84vw;
justify-content: center;
text-align: center;
transform-origin: center center;
&.is-desktop-animation {
text-align: left;
padding: 2rem 3vw;
z-index: 800;
align-items: center;
width: 50%;
}
@include media('md') {
padding: 0;
}
@include media('lg') {
padding: 0;
}
}
.pairing-container {
padding-bottom: 7vh;
position: relative;
}
.pairing__actions {
@include media('md') {
display: flex;
align-items: center;
}
}
.code--desktop {
display: flex;
justify-content: flex-start;
.code__item {
opacity: 0;
transform: scale(1.3);
transition: 0.55s cubic-bezier(0.79, -1.26, 0.21, 1.99);
span {
opacity: 0;
transition: 0.4s cubic-bezier(0.64, 0.1, 0.61, 1.18);
transform: scale(0.8);
}
.code-circle:before {
transition: 0.5s cubic-bezier(0.57, -0.26, 0.24, 1.08);
transform-origin: center;
transform: scaleX(0);
}
@for $i from 1 through 6 {
&:nth-child(#{$i}) {
transition-delay: ($i / 8) * 1s;
span {
transition-delay: (($i / 8) * 1s) + 0.32s;
}
.code-circle:before {
transition-delay: (($i / 8) * 1s) + 0.1s;
}
}
}
&.visible {
&,
span,
.code-circle:before {
opacity: 1;
transform: none;
}
}
}
}
.pairing-lead {
max-width: 23rem;
text-align: justify;
@include media('md') {
max-width: 29rem;
}
@include media('lg') {
max-width: 35rem;
margin: 0;
}
}
.code-timeout {
opacity: 0;
transition: 0.3s;
transition-delay: 0.3s;
span {
display: inline-block;
vertical-align: middle;
}
&.visible {
opacity: 1;
}
}
.pairing-skip {
margin-right: auto;
&:hover {
.btn {
color: $brand-color;
}
}
}
</style>
================================================
FILE: src/components/Desktop/Toolbar/Button/Button.vue
================================================
<template>
<button
:class="classes"
class="btn btn--bare pointer-area flex"
@click="handleClick"
>
<div class="toolbar-button" :style="style">
<icon v-if="hasIcon" />
</div>
</button>
</template>
<script>
import { EventBus } from '@/events'
import ToolbarItem from '@/components/Desktop/Toolbar/Item.vue'
export default {
name: 'BrushToolbarTool',
extends: ToolbarItem,
data() {
return {
isActive: false
}
},
created() {
const eventName = 'pointerOver_' + this.itemKey
EventBus.$on(eventName, this.handleClick)
},
methods: {
handleClick() {
// Components extending this component implement this method.
}
}
}
</script>
<style lang="scss">
.toolbar-item {
.toolbar-button {
position: relative;
display: flex;
align-items: center;
justify-content: center;
overflow: visible;
font-size: $toolbar-button-width-xs;
color: $text-color;
width: 1em;
height: 100%;
margin: auto 0;
.is-drawing & {
@include media('sm') {
font-size: $toolbar-button-width-sm;
}
@include media('md') {
font-size: $toolbar-button-width-md;
}
@include media('lg') {
font-size: $toolbar-button-width-lg;
}
}
svg {
width: 0.6em;
max-height: 100%;
fill: currentColor;
.is-drawing & {
@include media('lg') {
width: 0.4em;
}
}
}
}
&.disabled {
opacity: 0.2;
cursor: default;
}
&.hover:not(.disabled):not(.toolbar-item--colors) {
background: $alt-color-dark !important;
}
}
</style>
================================================
FILE: src/components/Desktop/Toolbar/Button/ButtonClear.vue
================================================
<script>
import { EventBus } from '@/events'
import Button from '@/components/Desktop/Toolbar/Button/Button.vue'
import Icon from '@/assets/icons/icon-delete.svg'
export default {
name: 'ButtonClear',
components: {
Icon
},
extends: Button,
data() {
return {
hasIcon: true,
possible: false
}
},
computed: {
additionalClasses() {
return this.possible ? [] : ['disabled']
}
},
beforeDestroy() {
EventBus.$on('canvasState', this.updateCanvasState)
},
mounted() {
EventBus.$on('canvasState', this.updateCanvasState)
},
methods: {
handleClick() {
EventBus.$emit('clearCanvas')
this.$track('Toolbar', 'history', 'clear')
},
updateCanvasState({ clearPossible }) {
this.possible = clearPossible
}
}
}
</script>
================================================
FILE: src/components/Desktop/Toolbar/Button/ButtonColor.vue
================================================
<script>
import Button from '@/components/Desktop/Toolbar/Button/Button.vue'
import { getRgbaString, shadeRgbColor } from '@/tools/helpers.js'
import threads from '@/store/vuetamin/threads'
export default {
name: 'ToolbarButtonColor',
extends: Button,
vuetamin: {
handleColorChange: [threads.BRUSH_COLOR]
},
computed: {
style() {
if (!this.tool.color) {
return {}
}
return {
background: this.tool.color.getRgbaString(100),
color: getRgbaString(shadeRgbColor(this.tool.color.rgb, -0.2), 0.5)
}
},
additionalClasses() {
return ['toolbar-item--color-' + this.tool.color.name]
}
},
methods: {
handleColorChange(state) {
this.isActive = this.tool.color.name === state.brush.color.name
},
handleClick() {
this.$vuetamin.store.mutate('updateBrushColor', this.tool.color)
this.$track('Toolbar', 'setColor', this.tool.color.name)
}
}
}
</script>
<style lang="scss">
.toolbar-item--colors {
overflow: visible;
.toolbar-button {
border-radius: 100%;
width: 0.75em;
height: 0.75em;
@include media('lg') {
width: 0.5em;
height: 0.5em;
}
&:hover,
.hover & {
opacity: 0.75;
}
}
&.toolbar-item--color-black .toolbar-button {
box-shadow: 0 0 0 1px $alt-color;
}
&.active .toolbar-button {
box-shadow: 0 0px 0px 2px $alt-color-darker,
0 0px 0px 4px $alt-color-lighter;
}
}
</style>
================================================
FILE: src/components/Desktop/Toolbar/Button/ButtonRedo.vue
================================================
<script>
import { EventBus } from '@/events'
import Button from '@/components/Desktop/Toolbar/Button/Button.vue'
import Icon from '@/assets/icons/icon-redo.svg'
export default {
name: 'ButtonRedo',
components: {
Icon
},
extends: Button,
data() {
return {
hasIcon: true,
possible: false
}
},
computed: {
additionalClasses() {
return this.possible ? [] : ['disabled']
}
},
beforeDestroy() {
EventBus.$on('canvasState', this.updateCanvasState)
},
mounted() {
EventBus.$on('canvasState', this.updateCanvasState)
},
methods: {
handleClick() {
if (this.possible) {
EventBus.$emit('redoCanvas')
this.$track('Toolbar', 'history', 'redo')
}
},
updateCanvasState({ redoPossible }) {
this.possible = redoPossible
}
}
}
</script>
================================================
FILE: src/components/Desktop/Toolbar/Button/ButtonUndo.vue
================================================
<script>
import { EventBus } from '@/events'
import Button from '@/components/Desktop/Toolbar/Button/Button.vue'
import Icon from '@/assets/icons/icon-undo.svg'
export default {
name: 'ButtonUndo',
components: {
Icon
},
extends: Button,
data() {
return {
hasIcon: true,
possible: false
}
},
computed: {
additionalClasses() {
return this.possible ? [] : ['disabled']
}
},
beforeDestroy() {
EventBus.$on('canvasState', this.updateCanvasState)
},
mounted() {
EventBus.$on('canvasState', this.updateCanvasState)
},
methods: {
handleClick() {
if (this.possible) {
EventBus.$emit('undoCanvas')
this.$track('Toolbar', 'history', 'undo')
}
},
updateCanvasState({ undoPossible }) {
this.possible = undoPossible
}
}
}
</script>
================================================
FILE: src/components/Desktop/Toolbar/Item.vue
================================================
<script>
import Rectangle from '@/classes/Rectangle'
export default {
name: 'ToolbarItem',
props: {
tool: {
type: Object,
required: true
},
action: {
type: String,
required: true
},
hoveredKey: {
type: String,
required: true
},
groupId: {
type: String,
required: true
}
},
data() {
return {
hasIcon: false
}
},
computed: {
itemKey() {
return `${this.action}${this.tool.id}`
},
isHovered() {
return this.itemKey === this.hoveredKey
},
classes() {
return [
{
hover: this.isHovered,
active: this.isActive
},
'toolbar-item',
'toolbar-item--' + this.groupId,
...this.additionalClasses
]
},
additionalClasses() {
return []
},
style() {
return {}
}
},
methods: {
getRectangle() {
const el = this.$el
// Easiest quick solution for getting the left offset in the toolbar.
const group = el.parentElement.parentElement.parentElement
const parentOffsetLeft = group.offsetLeft
const x = el.offsetLeft + parentOffsetLeft
const y = 0
const width = el.offsetWidth
const height = el.offsetHeight
const rectangle = new Rectangle(x, y, width, height)
return {
coords: rectangle,
key: this.itemKey,
el: this.$el
}
}
}
}
</script>
================================================
FILE: src/components/Desktop/Toolbar/Slider/Slider.vue
================================================
<template>
<div
class="btn btn--bare tool-slider pointer-area sm-pdg-- md-pdg- lg-pdg w-100"
:class="classes"
:style="style"
@wheel="handleWheel"
>
<div class="">
<div class="tool-slider__text">
<div class="tool-slider__label label pdg0 tool-slider__label">
{{ $t('tools.' + tool.id) }}
</div>
<span
class="tool-slider__value label flex-1 text-muted text-light pdg0"
>{{ roundedValue }}</span
>
</div>
<input
type="range"
:min="min"
:max="max"
:step="step"
:value="value"
@input="handleInput"
/>
</div>
</div>
</template>
<script>
import ToolbarItem from '@/components/Desktop/Toolbar/Item.vue'
let timeout = null
export default {
name: 'ToolbarSlider',
extends: ToolbarItem,
data() {
return {
min: 0,
max: 100,
value: 0,
step: 1,
multiplier: 0.5
}
},
computed: {
roundedValue() {
return Math.round(this.value)
}
},
methods: {
handleWheel(e) {
const delta = Math.max(Math.min(e.deltaY, 6), -6)
const newValue =
Math.round(
Math.max(
Math.min(this.value - delta * this.multiplier, this.max),
this.min
) * 100
) / 100
this.handleValueChange(newValue)
if (!timeout) {
timeout = window.setTimeout(() => {
this.$track('Toolbar', this.tool.id, this.value)
}, 3000)
}
},
handleInput(e) {
this.handleValueChange(parseFloat(e.target.value))
}
}
}
</script>
<style lang="scss">
.btn.tool-slider {
text-align: left;
transition: 0.15s transform;
position: relative;
overflow: visible;
display: flex;
> div {
flex: 1;
display: flex;
flex-direction: column;
}
input {
display: block;
margin: 0;
height: 4px;
}
}
.tool-slider__text {
margin-bottom: auto;
.is-drawing & {
@include media('lg') {
display: flex;
}
}
}
.tool-slider__value {
.is-drawing & {
@include media('lg') {
text-align: right;
}
@include media('xl', $breakpoints-extra) {
font-size: 1.5rem;
line-height: 1.15;
}
}
}
</style>
================================================
FILE: src/components/Desktop/Toolbar/Slider/SliderBrushHardness.vue
================================================
<script>
import { HARDNESS_MIN, HARDNESS_MAX } from '@/settings'
import threads from '@/store/vuetamin/threads'
import Slider from '@/components/Desktop/Toolbar/Slider/Slider.vue'
export default {
name: 'SliderBrushHardness',
extends: Slider,
vuetamin: {
handleHardnessChange: [threads.BRUSH_HARDNESS]
},
data() {
return {
min: HARDNESS_MIN,
max: HARDNESS_MAX
}
},
methods: {
handleHardnessChange(state) {
if (this.value !== state.brush.hardness) {
this.value = state.brush.hardness
}
},
handleValueChange(value) {
this.$vuetamin.store.mutate('updateBrushHardness', value)
}
}
}
</script>
================================================
FILE: src/components/Desktop/Toolbar/Slider/SliderBrushOpacity.vue
================================================
<script>
import { OPACITY_MIN, OPACITY_MAX } from '@/settings'
import threads from '@/store/vuetamin/threads'
import Slider from '@/components/Desktop/Toolbar/Slider/Slider.vue'
export default {
name: 'SliderBrushOpacity',
extends: Slider,
vuetamin: {
handleOpacityChange: [threads.BRUSH_OPACITY, threads.BRUSH]
},
data() {
return {
min: OPACITY_MIN,
max: OPACITY_MAX
}
},
methods: {
handleOpacityChange(state) {
if (this.value !== state.brush.opacity) {
this.value = state.brush.opacity
}
},
handleStateChange(state) {
if (this.value !== state.brush.opacity) {
this.value = state.brush.opacity
}
},
handleValueChange(value) {
this.$vuetamin.store.mutate('updateBrushOpacity', value)
}
}
}
</script>
================================================
FILE: src/components/Desktop/Toolbar/Slider/SliderBrushRadius.vue
================================================
<script>
import { RADIUS_MIN, RADIUS_MAX } from '@/settings'
import threads from '@/store/vuetamin/threads'
import Slider from '@/components/Desktop/Toolbar/Slider/Slider.vue'
export default {
name: 'SliderBrushRadius',
extends: Slider,
vuetamin: {
handleRadiusChange: [threads.BRUSH_RADIUS]
},
data() {
return {
min: RADIUS_MIN,
max: RADIUS_MAX
}
},
methods: {
handleRadiusChange(state) {
if (this.value !== state.brush.radius) {
this.value = state.brush.radius
}
},
handleValueChange(value) {
this.$vuetamin.store.mutate('updateBrushRadius', value)
}
}
}
</script>
================================================
FILE: src/components/Desktop/Toolbar/Slider/SliderDistance.vue
================================================
<script>
import threads from '@/store/vuetamin/threads'
import Slider from '@/components/Desktop/Toolbar/Slider/Slider.vue'
import { encodeEventMessage } from '@/tools/helpers'
export default {
name: 'SliderDistance',
extends: Slider,
vuetamin: {
handleSizesChange: [threads.SIZES, threads.DISTANCE]
},
data() {
return {
min: 0,
max: 100,
multiplier: 7
}
},
mounted() {
this.$peersox.send(
encodeEventMessage('distance', this.$vuetamin.store.data.gymoteDistance)
)
},
methods: {
handleSizesChange(state) {
this.min = state.sizes.viewport.width / 4
this.max = state.sizes.viewport.width * 2
if (this.value !== state.gymoteDistance) {
this.value = state.gymoteDistance
}
},
handleValueChange(value) {
this.value = value
this.$peersox.send(encodeEventMessage('distance', value))
this.$vuetamin.store.mutate('updateGymoteDistance', value)
}
}
}
</script>
================================================
FILE: src/components/Desktop/Toolbar/Slider/SliderLazyRadius.vue
================================================
<script>
import { LAZY_RADIUS_MIN, LAZY_RADIUS_MAX } from '@/settings'
import threads from '@/store/vuetamin/threads'
import Slider from '@/components/Desktop/Toolbar/Slider/Slider.vue'
export default {
name: 'SliderLazyRadius',
extends: Slider,
vuetamin: {
handleLazyRadiusChange: [threads.LAZYRADIUS]
},
data() {
return {
min: LAZY_RADIUS_MIN,
max: LAZY_RADIUS_MAX,
multiplier: 1
}
},
methods: {
handleLazyRadiusChange(state) {
if (this.value !== state.lazyRadius) {
this.value = state.lazyRadius
}
},
handleValueChange(value) {
this.$vuetamin.store.mutate('updateLazyRadius', value)
}
}
}
</script>
================================================
FILE: src/components/Desktop/Toolbar/Toolbar.vue
================================================
<template>
<div ref="toolbar" class="toolbar">
<ul
class="list-inline list-inline--tight list-inline--divided toolbar-list flex--align-stretch"
>
<li
v-for="group in toolGroups"
:key="group.id"
class="toolbar-group flex h-100"
:class="[
'toolbar-group--' + group.id,
{ 'flex-1': group.id === 'sliders' }
]"
>
<ul
class="list-inline list-inline--tight toolbar-group-list flex--align-stretch"
:class="{
'flex-1 list-inline--divided': group.id === 'sliders'
}"
>
<li
v-for="tool in group.items"
:key="group.action + tool.id"
class="flex flex--align-stretch h-100"
:class="{ 'flex-1': group.id === 'sliders' }"
>
<component
:is="tool.component"
ref="items"
:tool="tool"
:group-id="group.id"
:action="group.action"
:hovered-key="toolBeingHovered"
/>
</li>
</ul>
</li>
</ul>
</div>
</template>
<script>
import ButtonClear from '@/components/Desktop/Toolbar/Button/ButtonClear.vue'
import ButtonUndo from '@/components/Desktop/Toolbar/Button/ButtonUndo.vue'
import ButtonRedo from '@/components/Desktop/Toolbar/Button/ButtonRedo.vue'
import ButtonColor from '@/components/Desktop/Toolbar/Button/ButtonColor.vue'
import SliderBrushRadius from '@/components/Desktop/Toolbar/Slider/SliderBrushRadius.vue'
import SliderBrushOpacity from '@/components/Desktop/Toolbar/Slider/SliderBrushOpacity.vue'
import SliderBrushHardness from '@/components/Desktop/Toolbar/Slider/SliderBrushHardness.vue'
import SliderLazyRadius from '@/components/Desktop/Toolbar/Slider/SliderLazyRadius.vue'
import SliderDistance from '@/components/Desktop/Toolbar/Slider/SliderDistance.vue'
import { COLORS, TOOLBAR_TOOLS, TOOLBAR_SLIDERS } from '@/settings'
import threads from '@/store/vuetamin/threads'
import { mapState } from 'vuex'
import Color from '@/classes/Color'
export default {
name: 'BrushToolbar',
components: {
ButtonColor,
ButtonClear,
ButtonUndo,
ButtonRedo,
SliderBrushRadius,
SliderBrushOpacity,
SliderBrushHardness,
SliderLazyRadius,
SliderDistance
},
vuetamin: {
calculatePointerAreas: [threads.SIZES],
handleToolsChange: [threads.TOOLS]
},
data() {
return {
pointerAreas: [],
toolBeingHovered: '',
lastItemClick: '',
wasPressingBefore: false,
wheelDelta: 0,
canvasFilterSupported: false
}
},
computed: {
...mapState(['isSkipped', 'isConnected']),
toolGroups() {
return [
{
id: 'tools',
type: 'button',
action: 'tool',
items: TOOLBAR_TOOLS
},
{
id: 'colors',
type: 'button',
action: 'color',
items: COLORS.map((color) => {
return {
id: color.name,
component: 'ButtonColor',
color: new Color(color)
}
})
},
{
id: 'sliders',
type: 'slider',
action: 'brush',
items: TOOLBAR_SLIDERS.filter((tool) => {
if (tool.id === 'brushHardness' && !this.canvasFilterSupported) {
return false
}
if (tool.id === 'distance' && !this.isConnected) {
return false
}
return true
})
}
]
}
},
mounted() {
this.calculatePointerAreas()
},
beforeDestroy() {},
methods: {
handleConnection({ connection }) {
this.connectionDevice = connection.device
},
handleToolsChange(state) {
let tool = this.getToolAtPoint(state.points.pointer)
this.toolBeingHovered = tool ? tool.key : ''
if (tool && state.isPressing) {
if (
this.lastItemClick !== this.toolBeingHovered &&
!this.wasPressingBefore
) {
tool.el.click()
this.lastItemClick = tool.key
}
const wheel = state.touch.y - this.wheelDelta
if (wheel !== 0) {
let event = new WheelEvent('wheel', {
bubbles: false,
cancelable: true,
deltaX: 0,
deltaY: wheel / 2
})
tool.el.dispatchEvent(event)
}
this.wheelDelta = state.touch.y
} else {
this.wheelDelta = 0
this.lastItemClick = ''
}
this.wasPressingBefore = state.isPressing
},
getToolAtPoint(point) {
for (let i = 0; i < this.pointerAreas.length; i++) {
const area = this.pointerAreas[i]
if (area.coords.containsPoint(point)) {
return area
}
}
},
calculatePointerAreas() {
this.canvasFilterSupported = this.$vuetamin.store.data.canvasFilterSupported
let items = []
this.$refs.items.forEach((item) => {
items.push(item.getRectangle())
})
this.pointerAreas = items
}
}
}
</script>
<style lang="scss">
.toolbar {
border-bottom: $list-separator-style;
z-index: $index-toolbar;
position: absolute;
left: 0;
top: 0;
right: 0;
height: $toolbar-height - 1rem;
user-select: none;
background: $alt-color-darker;
@include media('md') {
height: $toolbar-height;
}
}
.toolbar-list {
height: 100%;
}
.toolbar-group-list {
position: relative;
}
.toolbar-group--colors {
.is-drawing & {
li {
@include media('sm') {
margin-right: 0.125rem;
}
@include media('md') {
margin-right: 0.5rem;
}
@include media('lg') {
margin-right: 0.5rem;
}
&:first-child {
@include media('sm') {
margin-left: rem(7px);
}
@include media('md') {
margin-left: rem(10px);
}
@include media('lg') {
margin-left: rem(13px);
}
}
&:last-child {
@include media('sm') {
margin-right: rem(7px);
}
@include media('md') {
margin-right: rem(10px);
}
@include media('lg') {
margin-right: rem(13px);
}
}
}
}
}
.toolbar-group--sliders {
li {
max-width: 18rem;
}
}
</style>
================================================
FILE: src/components/Desktop.vue
================================================
<template>
<div class="desktop relative">
<div class="desktop-container relative overlay material">
<transition name="component-fade">
<component
:is="visibleComponent"
:is-desktop="true"
:desktop-animation="desktopAnimation"
>
<Pairing
:pairing="pairing"
v-if="!isDrawing"
:desktop-animation="desktopAnimation"
@pairingTimeout="handleTimeout"
/>
</component>
</transition>
</div>
</div>
</template>
<script>
import debouncedResize from 'debounced-resize'
import { mapState } from 'vuex'
import { BREAKPOINT_REMOTE, ANIMATION_SCREEN_VIEWPORT } from '@/settings'
import Pairing from '@/components/Desktop/Pairing.vue'
import Animation from '@/components/Common/Animation/Animation.vue'
import Drawing from '@/components/Desktop/Drawing.vue'
import { getViewportSize, encodeEventMessage } from '@/tools/helpers'
export default {
name: 'Desktop',
components: {
Pairing,
Drawing,
Animation
},
data() {
return {
pairing: {},
viewportWidth: 700
}
},
computed: {
...mapState(['isConnected', 'isSkipped']),
visibleComponent() {
return this.isDrawing ? 'Drawing' : 'Animation'
},
hasPairing() {
return this.pairing && this.pairing.code && this.pairing.hash
},
isDrawing() {
return this.isConnected || this.isSkipped
},
desktopAnimation() {
return this.viewportWidth >= 1024
}
},
watch: {
isConnected(isConnected) {
this.updateViewport()
if (!isConnected && !this.isSkipped) {
this.pairing = {}
this.getPairingCode()
}
},
isSkipped(isSkipped) {
this.updateViewport()
if (isSkipped && this.$peersox.isConnected()) {
this.$peersox.close()
}
}
},
beforeMount() {
this.viewportWidth = getViewportSize().width
},
mounted() {
this.updateViewport()
debouncedResize(() => {
this.updateViewport()
})
if (!this.$settings.isPrerendering) {
this.$peersox.on('serverReady', this.getPairingCode)
}
this.$peersox.on('peerConnected', this.handleConnected)
this.$peersox.on('peerTimeout', this.handlePeerTimeout)
this.$peersox.on('connectionClosed', this.handleDisconnected)
this.$peersox.onBinary = this.$mote.handleRemoteData.bind(this.$mote)
},
beforeDestroy() {
this.$peersox.off('serverReady', this.getPairingCode)
this.$peersox.off('peerConnected', this.handleConnected)
this.$peersox.off('peerTimeout', this.handlePeerTimeout)
this.$peersox.off('connectionClosed', this.handleDisconnected)
this.$peersox.onBinary = () => {}
this.$peersox.close()
},
methods: {
getPairingCode() {
if (this.hasPairing) {
return
}
if (this.$peersox.isConnected()) {
this.$peersox.close()
}
this.$peersox
.createPairing()
.then((pairing) => {
if (pairing) {
this.pairing = pairing
this.$peersox.connect(pairing).catch((error) => {
this.$store.commit('setServerStatus', error)
this.$sentry.logInfo('pairing', 'connect:failed')
})
} else {
this.pairing = {}
this.$sentry.logInfo('pairing', 'create:failed')
}
this.$store.commit('setServerStatus')
})
.catch((error) => {
this.$store.commit('setServerStatus', error)
this.$sentry.logInfo('pairing', 'create:failed')
})
},
updateViewport() {
let viewport = ANIMATION_SCREEN_VIEWPORT
if (this.isConnected || this.isSkipped) {
viewport = getViewportSize()
}
this.$vuetamin.store.mutate('updateViewport', viewport)
this.$peersox.send(encodeEventMessage('viewport', viewport))
if (!this.$peersox.isConnected()) {
this.isMobile = viewport.width < BREAKPOINT_REMOTE
}
this.$sentry.logInfo('viewport', JSON.stringify(viewport))
},
handleTimeout() {
this.pairing = {}
this.getPairingCode()
},
handlePeerTimeout() {
this.pairing = {}
},
handleConnected({ pairing }) {
this.$store.dispatch('connect')
this.updateViewport()
this.$peersox.storePairing(pairing)
this.$sentry.setUser(pairing.hash)
},
handleDisconnected() {
this.$store.dispatch('disconnect')
},
handleBinary(intArray) {
this.$mote.handleRemoteData(intArray)
}
}
}
</script>
<style lang="scss">
.desktop {
height: 100%;
overflow: hidden;
}
</style>
================================================
FILE: src/components/Mobile/Controlling.vue
================================================
<template>
<transition name="fade">
<div class="controlling">
<TouchHandler />
</div>
</transition>
</template>
<script>
import { DEFAULT_COLOR, RADIUS_DEFAULT } from '@/settings'
import { getViewportSize } from '@/tools/helpers.js'
import TouchHandler from '@/components/Mobile/TouchHandler.vue'
export default {
name: 'Mobile',
components: {
TouchHandler
},
data() {
return {
brushCoordinates: {
x: 0,
y: 0
},
brush: {
color: DEFAULT_COLOR,
radius: RADIUS_DEFAULT
}
}
},
mounted() {
const viewport = getViewportSize()
this.brushCoordinates = {
x: viewport.width / 2,
y: viewport.height / 2
}
}
}
</script>
<style lang="scss">
.controlling {
position: fixed;
top: 0;
left: 0;
bottom: 0;
right: 0;
background: $alt-color-darker;
z-index: $index-footer - 1;
&.component-fade-enter-active,
&.component-fade-leave-active {
transition: 1.5s;
}
&.component-fade-enter,
&.component-fade-leave-to {
opacity: 0;
transform: translateY(6rem);
}
}
</style>
================================================
FILE: src/components/Mobile/Pairing.vue
================================================
<template>
<div class="mobile-pairing">
<div class="mobile-pairing__content relative pdgh">
<h1 class="text-heavy mrgt">drawmote</h1>
<p class="h2 text-bold mrgb text-muted">{{ $t('subtitle') }}</p>
<p
class="h4 text-muted text-light text-hyphens mrgb md-mrgb+ mrgt- md-mrgt"
>
{{ $t('mobile.lead') }}
</p>
<div class="code code--mobile relative">
<ServerStatus v-if="hasServerError" />
<div v-else class="code__circles flex">
<div
v-for="(char, index) in inputChars"
:key="char + index"
class="code__item"
>
<div
class="code-circle"
:class="[
{ contains: char !== ' ', invalid: char.search(/[0-9]/g) },
'code-circle--' + char
]"
>
<span>{{ char }}</span>
</div>
</div>
</div>
<form
v-if="!hasServerError"
class="code__form absolute"
@submit.prevent="onSubmit"
>
<input
ref="pairing_id"
v-model="inputValue"
maxlength="6"
class="code__input absolute"
type="tel"
pattern="[0-9]*"
novalidate
/>
</form>
<button
v-show="inputValue.length === 6"
class="btn btn--primary btn--block mrgt+"
@click.prevent="onSubmit"
>
<span>{{ $t('mobile.pairButton') }}</span>
</button>
<transition name="appear">
<div v-if="codeInvalid" class="code__error">
{{ $t('mobile.codeInvalid') }}
</div>
</transition>
</div>
</div>
</div>
</template>
<script>
import ServerStatus from '@/components/Common/ServerStatus.vue'
import { mapGetters } from 'vuex'
export default {
name: 'Pairing',
components: {
ServerStatus
},
data() {
return {
inputValue: '',
codeInvalid: false
}
},
computed: {
...mapGetters(['hasServerError']),
inputChars: function () {
return String(this.inputValue + ' ')
.slice(0, 6)
.split('')
}
},
watch: {
inputValue: function () {
this.codeInvalid = false
}
},
methods: {
onSubmit() {
const code = this.$refs.pairing_id.value
this.validateCode(code)
},
validateCode(code) {
this.$peersox
.joinPairing(code)
.then((pairing) => {
this.$peersox
.connect(pairing)
.then(() => {
this.codeInvalid = false
this.$track('Pairing', 'valid', '1')
this.$sentry.logInfo('pairing', 'code:valid')
this.$peersox.storePairing(pairing)
this.$store.commit('setServerStatus')
})
.catch((error) => {
this.$store.commit('setServerStatus', error)
this.$sentry.logInfo('pairing', 'connect:failed')
})
})
.catch((error) => {
this.$store.commit('setServerStatus', error)
this.$sentry.logInfo('pairing', 'code:invalid')
this.codeInvalid = true
this.$track('Pairing', 'valid', '0')
})
}
}
}
</script>
<style lang="scss" scoped>
@import '@/assets/scss/components/_code.scss';
.mobile-pairing {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
// text-align: center;
margin-bottom: $footer-height-xs;
user-select: none;
// min-height: calc(100vh - #{$footer-height-xs});
overflow: hidden;
padding-top: 80vw;
}
.mobile-pairing__content {
z-index: $index-mobile-pairing;
margin-bottom: auto;
padding-bottom: 2rem;
}
.code--mobile {
font-size: calc((100vw - 2rem) / 7);
.code__circles {
div {
font-size: 1em;
}
}
.code__form {
height: 1em;
right: -1rem;
}
.code__input {
background: none;
opacity: 0;
font-size: 0.7em;
line-height: 0;
width: 100%;
font-family: monospace;
letter-spacing: 1.12em;
padding-left: 0.1em;
margin-right: -1em;
}
.code__error {
margin-top: 1em;
font-weight: 600;
color: $brand-color;
font-size: 0.875rem;
letter-spacing: 0.5px;
padding: 0.25rem 0;
&.appear-enter-active,
&.appear-leave-active {
transition: 0.5s;
}
&.appear-enter,
&.appear-leave-to {
opacity: 0;
transform: translateY(50%);
}
}
}
</style>
================================================
FILE: src/components/Mobile/TouchHandler.vue
================================================
<template>
<div class="mobile-controller">
<div
class="mobile-touch pdg"
@touchstart="handleMainTouchStart"
@touchmove="handleMainTouchMove"
@touchend="handleMainTouchEnd"
@touchcancel="handleMainTouchCancel"
>
<div class="h3 text-muted">{{ $t('mobile.controllingInfo') }}</div>
<div class="click-area" :class="{ 'is-clicking': isClicking }">
<div class="click-area__circle"></div>
</div>
</div>
<div class="calibration pdg">
<button class="btn btn--primary btn--block" @click="calibrate">
<span>{{ $t('mobile.calibrationButton') }}</span>
</button>
</div>
</div>
</template>
<script>
export default {
name: 'TouchHandler',
data() {
return {
isClicking: false,
touchStartY: 0,
touchStartTime: {},
touchDiffY: 0
}
},
watch: {
isClicking(isClicking) {
this.$mote.updateClick(isClicking)
},
touchDiffY(diffY) {
this.$mote.updateTouch({ x: 0, y: diffY })
}
},
methods: {
handleMainTouchStart(e) {
e.preventDefault()
this.touchDiffY = 0
this.touchStartY = 0
this.touchStartTime = new Date().getTime()
this.isClicking = true
},
handleMainTouchMove(e) {
e.preventDefault()
const diffTime = new Date().getTime() - this.touchStartTime
const touch = e.changedTouches[0]
if (diffTime > 50) {
if (this.touchStartY === 0) {
this.touchStartY = touch.pageY
}
this.touchDiffY = touch.pageY - this.touchStartY
}
},
handleMainTouchEnd(e) {
e.preventDefault()
this.isClicking = false
},
handleMainTouchCancel(e) {
e.preventDefault()
this.isClicking = false
this.touchDiffY = 0
},
calibrate() {
this.$mote.calibrate()
}
}
}
</script>
<style lang="scss">
.mobile-controller {
position: relative;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
padding-bottom: 53px;
}
.mobile-touch {
flex: 1;
display: flex;
flex-direction: column;
}
.calibration {
margin-top: auto;
}
.click-area {
display: flex;
align-items: center;
flex: 1;
}
.click-area__circle {
width: 70vw;
height: 70vw;
padding: 1rem;
margin: 0 auto;
border-radius: 100%;
border: 1px solid rgba($alt-color-darkest, 0.5);
background: linear-gradient(
lighten($alt-color-darkest, 0%),
lighten($alt-color-darkest, 2%)
);
position: relative;
&:before {
content: '';
position: absolute;
top: 1rem;
left: 1rem;
bottom: 1rem;
right: 1rem;
border-radius: inherit;
background: linear-gradient(
0deg,
lighten($alt-color-darkest, 2%),
lighten($alt-color-darker, 4%)
);
box-shadow: inset 0 3px 10px rgba($alt-color-dark, 0.15),
inset 0 4px 2px rgba($alt-color-dark, 0.1);
}
.is-clicking & {
&:before {
background: linear-gradient(
lighten($alt-color-darkest, 1%),
lighten($alt-color-darker, 9%)
);
box-shadow: 0 3px 10px rgba($alt-color-dark, 0.35),
0 4px 3px rgba($alt-color-dark, 0.5),
inset 0 4px 3px rgba($alt-color, 0.5);
}
}
}
</style>
================================================
FILE: src/components/Mobile.vue
================================================
<template>
<div class="mobile h-100">
<transition name="component-fade">
<component :is="visibleComponent">
<Pairing v-if="!isConnected" />
</component>
</transition>
</div>
</template>
<script>
import Pairing from '@/components/Mobile/Pairing.vue'
import Animation from '@/components/Common/Animation/Animation.vue'
import Controlling from '@/components/Mobile/Controlling.vue'
import { mapState } from 'vuex'
import { decodeEventMessage } from '@/tools/helpers'
export default {
name: 'Mobile',
components: {
Pairing,
Animation,
Controlling
},
computed: {
...mapState(['isConnected']),
visibleComponent() {
return this.isConnected ? 'Controlling' : 'Animation'
}
},
mounted() {
this.$peersox.on('peerConnected', this.handleConnected.bind(this))
this.$peersox.on('connectionClosed', this.handleConnectionClosed.bind(this))
this.$mote.start()
this.$peersox.onString = this.handleMessage.bind(this)
this.$mote._onDataChange = this.handleDataChange.bind(this)
},
beforeDestroy() {
this.$peersox.off('peerConnected', this.handleConnected)
this.$peersox.off('connectionClosed', this.handleConnectionClosed)
this.$peersox.onString = () => {}
this.$mote._onDataChange = () => {}
},
methods: {
handleConnected({ pairing }) {
this.$store.dispatch('connect')
this.$sentry.setUser(pairing.hash)
},
handleConnectionClosed() {
this.$store.dispatch('disconnect')
this.$mote.stop()
},
handleMessage(rawMessage) {
if (!this.isConnected) {
return
}
const { event, data } = decodeEventMessage(rawMessage)
if (event === 'viewport') {
this.$mote.updateScreenViewport(data)
}
if (event === 'distance') {
this.$mote.updateScreenDistance(data)
}
},
handleDataChange(data) {
if (this.isConnected) {
this.$peersox.send(data)
}
}
}
}
</script>
================================================
FILE: src/events/index.js
================================================
import Vue from 'vue'
export const EventBus = new Vue()
================================================
FILE: src/i18n.js
================================================
import { getLocale } from '@/tools/cookies'
import Vue from 'vue'
import VueI18n from 'vue-i18n'
Vue.use(VueI18n)
function loadLocaleMessages() {
const locales = require.context(
'./locales',
true,
/[A-Za-z0-9-_,\s]+\.json$/i
)
const messages = {}
locales.keys().forEach((key) => {
const matched = key.match(/([a-z0-9]+)\./i)
if (matched && matched.length > 1) {
const locale = matched[1]
messages[locale] = locales(key)
}
})
return messages
}
function detectLanguage() {
const locale = getLocale()
if (locale) {
return locale
}
return window.navigator.language.split('-')[0] || 'en'
}
export default new VueI18n({
locale: detectLanguage(),
fallbackLocale: 'en',
messages: loadLocaleMessages()
})
================================================
FILE: src/locales/de.json
================================================
{
"subtitle": "Telemalen mit deinem Telefon.",
"desktop": {
"lead": "Benutze dein Smartphone als Fernbedienung um hier zu malen. Öffne drawmote.app auf deinem Smartphone und gib den folgenden Code ein:",
"countdownPrefix": "Code ist noch ",
"countdownSeconds": "0 Sekunden | 1 Sekunde | {count} Sekunden",
"countdownSuffix": " gültig.",
"nophone": "Ohne Smartphone verwenden",
"tooManyAttempts": "Es konnte kein Code generiert werden. Das kann passieren wenn zu viele Anfragen in kurzer Zeit ausgeführt werden. Bitte warte einen Moment und probiere es nochmal."
},
"mobile": {
"lead": "Öffne drawmote.app auf deinem Desktop Browser um einen Verbindungscode zu generieren.",
"pairButton": "Mit Desktop verbinden",
"codeInvalid": "Der eingegebene Code ist leider ungültig :(",
"controllingInfo": "Bewege dein Smartphone und richte es auf deinen Bildschirm. Drücke den Kreis um zu malen. Bewege den Finger nach oben oder unten um Sliderwerte anzupassen.",
"calibrationButton": "Pinsel zentrieren"
},
"tools": {
"brushOpacity": "Deckkraft",
"brushRadius": "Radius",
"brushHardness": "Härte",
"lazyRadius": "Trägheit",
"distance": "Distanz"
},
"browserSupport": {
"title": "Kompatibilität",
"footer": {
"checking": "Prüfe Kompatibilität...",
"supported": "Gerät wird unterstützt",
"partial": "Reduzierte Funktionen",
"unsupported": "Wird nicht unterstützt"
},
"webRTC": {
"label": "WebRTC",
"supported": "Geringe Latenz zwischen beiden Geräten.",
"unsupported": "Dein Browser unterstützt WebRTC nicht. Das ist Voraussetzung für ein optimales Erlebnis."
},
"webSocket": {
"label": "WebSocket",
"supported": "Dies wird als Alternative zu WebRTC verwendet. Die Latenz ist höher, was eine spürbare Verzögerung zur Folge hat.",
"unsupported": "Dein Browser unterstützt keine nativen WebSocket-Verbindungen. Wahrscheinlich wirst du diese App nicht nutzen können."
},
"gyroscope": {
"label": "Gyroskop",
"supported": "Dieses Gerät kann zum Malen auf dem Computer verwendet werden.",
"unsupported": "Kein Gyroskop gefunden. Malen wird nicht möglich sein."
},
"canvasFilter": {
"label": "Canvas Filter",
"supported": "Alle Pinsel können verwendet werden.",
"unsupported": "Dir stehen nicht alle Pinseloptionen zur Verfügung."
},
"requestPermission": {
"cta": "Zugriff auf Gyroskop erlauben...",
"text": "drawmote benötigt Zugriff auf das Gyroskop deines Smartphones. Bitte klicke auf den folgenden Button und erlaube den Zugriff."
}
},
"connection": {
"title": "Letzte Verbindung wiederherstellen",
"text": "Klicke bei beiden Geräten den grossen roten Button um die Verbindung wiederherzustellen.",
"delete": "Entfernen",
"reconnect": "Geräte verbinden"
},
"connectionTimeout": {
"title": "Zeitüberschreitung bei der Verbindung",
"text": "Smartphone und Computer scheinen nicht mehr verbunden zu sein.",
"toPairing": "Neue Verbindung",
"continueDrawing": "Ohne Smartphone fortfahren"
},
"footer": {
"copyright": "Entwickelt von",
"toPairing": "Zurück zum Start",
"disconnect": "Verbindung trennen"
}
}
================================================
FILE: src/locales/en.json
================================================
{
"subtitle": "Draw remotely with your phone",
"desktop": {
"lead": "Visit drawmote.app on your phone, enter the following code and start drawing! For the best experience both devices should be in the same network.",
"countdownPrefix": "New code will be generated in ",
"countdownSeconds": "0 seconds | 1 second | {count} seconds",
"countdownSuffix": ".",
"nophone": "Use without phone",
"tooManyAttempts": "There was an error generating a pairing code. This can happen if too many requests to generate a code were made. Please wait a bit and try again later."
},
"mobile": {
"lead": "Visit drawmote.app on your computer or tablet to get a pairing code and start drawing.",
"pairButton": "Pair with desktop",
"codeInvalid": "The entered code is not valid.",
"controllingInfo": "Point your phone at your screen and press the circle to draw or click a button. Slide up or down to change a slider.",
"calibrationButton": "Reset to center"
},
"tools": {
"brushOpacity": "opacity",
"brushRadius": "radius",
"brushHardness": "hardness",
"lazyRadius": "lazy radius",
"distance": "distance"
},
"browserSupport": {
"title": "Browser support",
"footer": {
"checking": "Checking compatibility...",
"supported": "Device is supported",
"partial": "Reduced functionality",
"unsupported": "Device not supported"
},
"webRTC": {
"label": "WebRTC",
"supported": "Low latency connection between both devices.",
"unsupported": "Unfortunately your browser doesn't support WebRTC. This is required for the best experience."
},
"webSocket": {
"label": "WebSocket",
"supported": "This is used as a fallback to WebRTC. High latency is likely, resulting in a noticeable delay.",
"unsupported": "Your browser doesn't support native WebSocket connections. It's likely that you won't be able to use this app."
},
"gyroscope": {
"label": "Gyroscope",
"supported": "Your phone can be used to draw on a screen.",
"unsupported": "No gyroscope could be detected. Drawing won't be possible."
},
"canvasFilter": {
"label": "Canvas Filter",
"supported": "All brush features can be used.",
"unsupported": "You won't be able to use all brush features."
},
"requestPermission": {
"cta": "Allow access to gyroscope...",
"text": "drawmote needs to access your device's gyroscope in order to word. Please click the button below and grant access."
}
},
"connection": {
"title": "Restore your last connection",
"text": "Click the big red button on both devices to quickly reestablish a connection between them.",
"delete": "Remove",
"reconnect": "Reconnect devices"
},
"connectionTimeout": {
"title": "Connection Timeout",
"text": "Looks like the connection between your phone and computer has timed out.",
"toPairing": "Back to pairing",
"continueDrawing": "Continue drawing"
},
"footer": {
"copyright": "Made by",
"toPairing": "Back to pairing",
"disconnect": "Disconnect"
}
}
================================================
FILE: src/main.js
================================================
import 'es6-promise/auto'
import './assets/scss/main.scss'
import Vue from 'vue'
import App from './App.vue'
import Vuetamin from 'vuetamin'
import VueResize from 'vue-resize'
import Track from './plugins/Track'
import Settings from './plugins/Settings'
import PeerSox from './plugins/PeerSox'
import Sentry from './plugins/Sentry'
import vuetaminStore from './store/vuetamin'
import vuexStore from './store/vuex'
import i18n from './i18n'
import { getServerUrls } from '@/tools/helpers.js'
import { BREAKPOINT_REMOTE } from '@/settings'
function getGymote() {
if (window.innerWidth > BREAKPOINT_REMOTE) {
return import('./plugins/GymoteScreen')
} else {
return import('./plugins/GymoteRemote')
}
}
Vue.use(Sentry)
getGymote().then(({ default: Gymote }) => {
const serverUrls = getServerUrls()
Vue.use(Vuetamin, { store: vuetaminStore })
Vue.use(Gymote)
Vue.use(PeerSox, serverUrls)
Vue.use(Track)
Vue.use(Settings)
Vue.use(VueResize)
Vue.config.productionTip = false
new Vue({
store: vuexStore,
i18n,
render: (h) => h(App)
}).$mount('#drawmote')
})
================================================
FILE: src/mixins/PointerEvents.js
================================================
import { EventBus } from '@/events'
export default {
name: 'PointerEvents',
methods: {
handleWheel(e) {
e.preventDefault()
if (e.deltaY > 0) {
EventBus.$emit('touchUp')
} else {
EventBus.$emit('touchDown')
}
},
handleMouseMove(e) {
this.preventEventIfRequired(e)
this.$vuetamin.store.mutate('updatePointer', {
coordinates: {
x: e.clientX,
y: e.clientY
}
})
},
handleMouseDown() {
this.$vuetamin.store.mutate('updateIsPressing', {
isPressing: true,
fromMouse: true
})
},
handleMouseUp() {
this.$vuetamin.store.mutate('updateIsPressing', {
isPressing: false,
fromMouse: true
})
},
handleTouchStart(e) {
this.preventEventIfRequired(e)
const touch = e.changedTouches[0]
this.$vuetamin.store.mutate('updatePointer', {
both: true,
coordinates: {
x: touch.pageX,
y: touch.pageY
}
})
this.handleMouseDown()
},
handleTouchMove(e) {
this.preventEventIfRequired(e)
const touch = e.changedTouches[0]
this.$vuetamin.store.mutate('updatePointer', {
coordinates: {
x: touch.pageX,
y: touch.pageY
}
})
},
handleTouchEnd(e) {
this.preventEventIfRequired(e)
this.handleMouseUp()
},
preventEventIfRequired(e) {
if (!this.$vuetamin.store.getState().pointingAtToolbar) {
e.preventDefault()
}
}
},
mounted() {
this.$el.addEventListener('wheel', this.handleWheel)
this.$el.addEventListener('mousedown', this.handleMouseDown)
this.$el.addEventListener('mousemove', this.handleMouseMove)
this.$el.addEventListener('mouseup', this.handleMouseUp)
this.$el.addEventListener('touchstart', this.handleTouchStart)
this.$el.addEventListener('touchmove', this.handleTouchMove)
this.$el.addEventListener('touchend', this.handleTouchEnd)
},
destroyed() {
this.$el.removeEventListener('wheel', this.handleWheel)
this.$el.removeEventListener('mousedown', this.handleMouseDown)
this.$el.removeEventListener('mousemove', this.handleMouseMove)
this.$el.removeEventListener('mouseup', this.handleMouseUp)
this.$el.removeEventListener('touchstart', this.handleTouchStart)
this.$el.removeEventListener('touchmove', this.handleTouchMove)
this.$el.removeEventListener('touchend', this.handleTouchEnd)
}
}
================================================
FILE: src/plugins/GymoteRemote.js
================================================
import { GymoteRemote } from 'gymote'
export default {
install(Vue) {
Vue.prototype.$mote = new GymoteRemote()
}
}
================================================
FILE: src/plugins/GymoteScreen.js
================================================
import { GymoteScreen } from 'gymote'
export default {
install(Vue) {
Vue.prototype.$mote = new GymoteScreen()
}
}
================================================
FILE: src/plugins/PeerSox.js
================================================
import 'whatwg-fetch'
import PeerSox from 'peersox'
export default {
install(Vue, { api, wss }) {
Vue.prototype.$peersox = new PeerSox(api, {
debug: process.env.VUE_APP_SERVER_ENV !== 'production',
autoUpgrade: true,
socketServerUrl: wss
})
}
}
================================================
FILE: src/plugins/Sentry.js
================================================
import * as Sentry from '@sentry/browser'
import * as Integrations from '@sentry/integrations'
import dependencies from '@/tools/dependencies'
import { trackUser, trackDimension } from '@/plugins/Track'
const isLocal = process.env.VUE_APP_SERVER_ENV === 'local'
const VERSION = `drawmote@${process.env.PKG_VERSION}`
function log(category, data) {
// eslint-disable-next-line
console.log(`[${category}]`, data)
}
export default {
install(Vue) {
const handler = {
setUser(user) {
log('user', user)
},
setMode(mode) {
log('mode', mode)
},
setSupport(feature, supportState) {
log('support', { feature, supportState })
},
logError(category, message) {
log('error', { category, message })
},
logInfo(category, message) {
log('info', { category, message })
}
}
if (!window.__PRERENDERING && !isLocal) {
Sentry.init({
dsn: 'https://b0df1bd1d041480f9e8e4dd2c3b56ed5@sentry.io/1342499',
release: VERSION,
environment: process.env.VUE_APP_SERVER_ENV,
integrations: [new Integrations.Vue({ Vue, attachProps: true })]
})
trackDimension('version', VERSION)
Sentry.configureScope((scope) => {
Object.keys(dependencies).forEach((key) => {
scope.setTag('library_' + key, dependencies[key])
})
})
handler.setUser = function (id) {
Sentry.configureScope((scope) => {
scope.setUser({ id: id })
})
trackUser(id)
}
handler.setMode = function (mode) {
Sentry.configureScope((scope) => {
scope.setTag('mode', mode)
})
trackDimension('mode', mode)
}
handler.setSupport = function (feature, supportState) {
Sentry.configureScope((scope) => {
scope.setTag('supports_' + feature, supportState)
})
if (feature === 'webRTC') {
trackDimension('supportsWebRTC', supportState)
}
if (feature === 'webSocket') {
trackDimension('supportsWebSocket', supportState)
}
}
handler.logError = function (category, message) {
Sentry.addBreadcrumb({
category: category,
message: message,
level: Sentry.Severity.Error
})
}
handler.logInfo = function (category, message) {
Sentry.addBreadcrumb({
category: category,
message: message,
level: Sentry.Severity.Info
})
}
}
Vue.prototype.$sentry = handler
}
}
================================================
FILE: src/plugins/Settings.js
================================================
export default {
install(Vue) {
Vue.prototype.$settings = {
isPrerendering: window.__PRERENDERING === true
}
}
}
================================================
FILE: src/plugins/Track.js
================================================
const DIMENSIONS = {
mode: 1,
supportsWebRTC: 2,
supportsWebSocket: 3,
version: 4
}
export function trackEvent(category, action, value) {
try {
window._paq.push(['trackEvent', category, action, value])
} catch (e) {
// eslint-disable-next-line
console.log(e)
}
}
export function trackUser(hash) {
try {
window._paq.push(['setUserId', hash])
} catch (e) {
// eslint-disable-next-line
console.log(e)
}
}
export function trackDimension(dimension, value) {
try {
window._paq.push(['setCustomDimension', DIMENSIONS[dimension], value])
} catch (e) {
// eslint-disable-next-line
console.log(e)
}
}
export default {
install(Vue) {
Vue.prototype.$track = trackEvent
}
}
================================================
FILE: src/settings/index.js
================================================
export const COLORS = [
{
name: 'red',
hex: '#F06D31'
},
{
name: 'blue',
hex: '#48bec5'
},
{
name: 'green',
hex: '#97d779'
},
{
name: 'yellow',
hex: '#ffd52b'
},
{
name: 'white',
hex: '#f0f1b7'
},
{
name: 'black',
hex: '#2a192d'
}
]
export const DEFAULT_COLOR = COLORS[3]
export const RADIUS_DEFAULT = 16
export const RADIUS_MIN = 1
export const RADIUS_MAX = 36
export const LAZY_RADIUS_MIN = 0
export const LAZY_RADIUS_MAX = 6 * RADIUS_MAX
export const LAZY_RADIUS_DEFAULT = 20
export const HARDNESS_DEFAULT = 100
export const HARDNESS_MIN = 0
export const HARDNESS_MAX = 100
export const OPACITY_DEFAULT = 100
export const OPACITY_MIN = 1
export const OPACITY_MAX = 100
// export const SMOOTHING_INIT = 0.85
export const SMOOTHING_INIT = 1.3
export const SMUDGE_AMOUNT = 0.25
export const BRUSH_DEFAULT = {
color: DEFAULT_COLOR,
radius: RADIUS_DEFAULT,
hardness: HARDNESS_DEFAULT,
opacity: OPACITY_DEFAULT,
style: 'smudge'
}
export const TOOLBAR_TOOLS = [
{
id: 'canvasClear',
component: 'ButtonClear'
},
{
id: 'undo',
component: 'ButtonUndo'
},
{
id: 'redo',
component: 'ButtonRedo'
}
]
export const TOOLBAR_SLIDERS = [
{
id: 'brushOpacity',
component: 'SliderBrushOpacity',
icon: ''
},
{
id: 'brushRadius',
component: 'SliderBrushRadius',
icon: ''
},
{
id: 'brushHardness',
component: 'SliderBrushHardness',
icon: ''
},
{
id: 'lazyRadius',
component: 'SliderLazyRadius',
icon: ''
},
{
id: 'distance',
component: 'SliderDistance',
icon: ''
}
]
export const BREAKPOINT_REMOTE = 700
export const BREAKPOINT_ANIMATION = 1024
export const ANIMATION_SCREEN_VIEWPORT = {
width: 960,
height: 540,
ratio: 0.75
}
================================================
FILE: src/store/vuetamin/actions.js
================================================
import { setState } from '@/tools/cookies'
export function storeStateCookie({ data }, { noTimeout } = {}) {
const timeout = noTimeout ? 0 : 5000
window.clearTimeout(data.cookieTimout)
data.cookieTimout = window.setTimeout(() => {
setState({
brush: data.brush.state,
lazyRadius: data.lazyBrush.getRadius(),
gymoteDistance: data.gymoteDistance
})
}, timeout)
}
================================================
FILE: src/store/vuetamin/data.js
================================================
import { LazyBrush } from 'lazy-brush'
import { getState } from '@/tools/cookies'
import Rectangle from '@/classes/Rectangle'
import Brush from '@/classes/Brush'
import { ANIMATION_SCREEN_VIEWPORT } from '@/settings'
/**
* Build the Vuetamin data store.
*
* @returns {Object}
*/
export default function () {
const cookieState = getState()
let brushOptions = {}
let lazyRadius = 80
let gymoteDistance = window.innerWidth
if (cookieState) {
brushOptions = cookieState.brush || {}
lazyRadius = cookieState.lazyRadius || lazyRadius
gymoteDistance = cookieState.gymoteDistance || gymoteDistance
}
return {
lazyBrush: new LazyBrush({
radius: lazyRadius
}),
brush: new Brush(brushOptions),
isPressing: false,
touch: {
x: 0,
y: 0
},
viewport: ANIMATION_SCREEN_VIEWPORT,
canvasRect: new Rectangle(0, 0, 0, 0),
toolbarRect: new Rectangle(0, 0, 0, 0),
footerRect: new Rectangle(0, 0, 0, 0),
pointingAtToolbar: false,
hasCalibrated: false,
cookieTimout: null,
canvasFilterSupported: false,
gymoteDistance: gymoteDistance
}
}
================================================
FILE: src/store/vuetamin/index.js
================================================
import data from './data'
import state from './state'
import * as actions from './actions'
import * as mutations from './mutations'
export default {
data,
state,
actions,
mutations
}
================================================
FILE: src/store/vuetamin/mutations.js
================================================
import threads from './threads'
export function updatePointer(
{ data, trigger },
{ coordinates, both = false } = {}
) {
const updateBoth = both || data.hasCalibrated
const hasChanged = data.lazyBrush.update(coordinates, { both: updateBoth })
data.hasCalibrated = false
// TODO VERY HARD
if (hasChanged || updateBoth) {
trigger(threads.POINT)
if (
data.toolbarRect.containsPoint(data.lazyBrush.pointer) &&
(!data.isPressing || data.pointingAtToolbar)
) {
data.pointingAtToolbar = true
trigger(threads.TOOLS)
} else {
if (data.pointingAtToolbar) {
trigger(threads.TOOLS)
}
if (!data.isPressing) {
data.pointingAtToolbar = false
}
}
}
}
export function updateIsPressing(
{ data, trigger },
{ isPressing = false, fromMouse = false } = {}
) {
if (data.isPressing !== isPressing) {
data.isPressing = isPressing
trigger(threads.POINT)
if (!fromMouse) {
trigger(threads.TOOLS)
}
}
}
export function updateTouch({ data, trigger }, touch) {
if (data.touch.y !== touch.y && data.pointingAtToolbar) {
data.touch = touch
trigger(threads.TOOLS)
}
}
export function updateCalibration({ data }) {
data.hasCalibrated = true
}
export function updateCanvasRect({ data, trigger }, element) {
data.canvasRect.setFromElement(element)
trigger(threads.STATE)
}
export function updateToolbarRect({ data, trigger }, element) {
data.toolbarRect.setFromElement(element)
trigger(threads.STATE)
}
export function updateFooterRect({ data, trigger }, element) {
data.footerRect.setFromElement(element)
trigger(threads.STATE)
}
export function updateViewport({ data, trigger }, viewport) {
data.viewport = viewport
trigger(threads.STATE)
trigger(threads.SIZES)
}
export function updateUseLazyBrush({ data }, useLazyBrush) {
if (useLazyBrush) {
data.lazyBrush.enable()
} else {
data.lazyBrush.disable()
}
}
export function updateLazyRadius({ data, trigger, action }, radius) {
data.lazyBrush.setRadius(radius)
trigger(threads.BRUSH)
trigger(threads.LAZYRADIUS)
action('storeStateCookie')
}
export function updateBrushColor({ data, trigger, action }, color) {
data.brush.setColor(color)
trigger(threads.BRUSH)
trigger(threads.BRUSH_COLOR)
action('storeStateCookie')
}
export function updateBrushOpacity({ data, trigger, action }, opacity) {
data.brush.setOpacity(opacity)
trigger(threads.BRUSH)
trigger(threads.BRUSH_OPACITY)
action('storeStateCookie')
}
export function updateBrushRadius({ data, trigger, action }, radius) {
data.brush.setRadius(radius)
trigger(threads.BRUSH)
trigger(threads.BRUSH_RADIUS)
action('storeStateCookie')
}
export function updateBrushHardness({ data, trigger, action }, hardness) {
data.brush.setHardness(hardness)
trigger(threads.BRUSH)
trigger(threads.BRUSH_HARDNESS)
action('storeStateCookie')
}
export function updateGymoteDistance({ data, action }, distance) {
data.gymoteDistance = distance
action('storeStateCookie')
}
export function updateCanvasFilterSupport({ data }, isSupported) {
data.canvasFilterSupported = isSupported
data.brush.setFilterSupport(isSupported)
}
================================================
FILE: src/store/vuetamin/state.js
================================================
/**
* Returns the state from the Vuetamin data.
*
* @param {Object} data Vuetamin data.
* @returns {Object} The state.
*/
export default function (data) {
return {
brush: data.brush,
isPressing: data.isPressing,
lazyRadius: data.lazyBrush.radius,
sizes: {
viewport: data.viewport,
canvasRect: data.canvasRect,
toolbarRect: data.toolbarRect,
footerRect: data.footerRect
},
points: {
brush: data.lazyBrush.brush.toObject(),
pointer: data.lazyBrush.pointer.toObject()
},
touch: data.touch,
pointingAtToolbar: data.pointingAtToolbar,
gymoteDistance: data.gymoteDistance
}
}
================================================
FILE: src/store/vuetamin/threads.js
================================================
/**
* Vuetamin thread names.
*/
export default {
BRUSH: 'brush',
POINT: 'point',
SLIDE: 'slide',
STATE: 'state',
TOOLS: 'tools',
SIZES: 'sizes',
BRUSH_RADIUS: 'brushRadius',
BRUSH_OPACITY: 'brushOpacity',
BRUSH_HARDNESS: 'brushHardness',
BRUSH_COLOR: 'brushColor',
LAZYRADIUS: 'lazyRadius',
DISTANCE: 'distance',
CONNECTION: 'connection'
}
================================================
FILE: src/store/vuex/index.js
================================================
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
export default new Vuex.Store({
state: {
isSkipped: false,
isConnected: false,
serverStatus: {
ok: true,
status: 0,
statusText: ''
},
introPlayed: false,
attributionVisible: false
},
mutations: {
setServerStatus(state, status = {}) {
state.serverStatus.ok = status.ok || true
state.serverStatus.status = status.status || 0
state.serverStatus.statusText = status.statusText || ''
},
setSkipped(state, isSkipped) {
state.isSkipped = isSkipped
},
setConnected(state, isConnected) {
state.isConnected = isConnected
},
setIntroPlayed(state, introPlayed) {
state.introPlayed = introPlayed
},
setAttributionVisible(state, isVisible) {
state.attributionVisible = isVisible
}
},
actions: {
connect({ commit }) {
commit('setSkipped', false)
commit('setConnected', true)
},
disconnect({ commit }) {
commit('setConnected', false)
},
skip({ commit }) {
commit('setSkipped', true)
},
unskip({ commit }) {
commit('setSkipped', false)
},
toggleAttributionVisibility({ state, commit }) {
commit('setAttributionVisible', !state.attributionVisible)
}
},
getters: {
hasServerError(state) {
return state.serverStatus.ok === false
},
isDrawing(state) {
return state.isConnected || state.isSkipped
}
}
})
================================================
FILE: src/tools/animation/app.json
================================================
{
"metadata": {
"type": "App"
},
"project": {
"shadows": true,
"vr": false
},
"camera": {
"metadata": {
"version": 4.5,
"type": "Object",
"generator": "Object3D.toJSON"
},
"object": {
"uuid": "E51C1918-0B8A-42A7-AC88-5DA9A9103C5A",
"type": "PerspectiveCamera",
"name": "Camera",
"layers": 1,
"matrix": [0.778004,0,-0.628258,0,-0.06467,0.99469,-0.080084,0,0.624922,0.102935,0.773872,0,9.725295,0.209866,13.958911,1],
"fov": 50,
"zoom": 1,
"near": 0.36,
"far": 1000,
"focus": 10,
"aspect": 1.469649,
"filmGauge": 35,
"filmOffset": 0
}
},
"scene": {
"metadata": {
"version": 4.5,
"type": "Object",
"generator": "Object3D.toJSON"
},
"geometries": [
{
"uuid": "D7227094-70CB-489B-B20D-E5ACC790A53A",
"type": "PlaneBufferGeometry",
"width": 4096,
"height": 4096,
"widthSegments": 1,
"heightSegments": 1
},
{
"uuid": "FB0A18E0-4EC7-403E-B939-867DBFED1864",
"type": "PlaneBufferGeometry",
"width": 8192,
"height": 8192,
"widthSegments": 1,
"heightSegments": 1
},
{
"uuid": "7199EAA3-8D36-426E-9BB2-5D38D9BFF238",
"type": "CylinderBufferGeometry",
"radiusTop": 0.01,
"radiusBottom": 0.01,
"height": 11.22,
"radialSegments": 30,
"heightSegments": 1,
"openEnded": false
},
{
"uuid": "6FF753BE-ACD3-48F9-8324-819EC5AAAF47",
"type": "BufferGeometry",
"data": {
"attributes": {
"normal": {
"itemSize": 3,
"type": "Float32Array",
"array": [-0.600751,0.78998,-0.122596,-0.600751,0.78998,-0.122596,-0.600751,0.78998,-0.122596,-0.600751,0.78998,-0.122596],
"normalized": false
},
"position": {
"itemSize": 3,
"type": "Float32Array",
"array": [-3.047849,4.350529,74.612076,-2.990534,4.262561,73.764374,-7.832444,0.587624,73.810509,-7.889759,0.675592,74.658211],
"normalized": false
}
},
"index": {
"type": "Uint32Array",
"array": [0,1,2,0,2,3]
},
"boundingSphere": {
"center": [-5.440146,2.469076,74.211292],
"radius": 3.069785
}
}
},
{
"uuid": "676CF2FA-78A4-4A8F-B7C9-00A0243C5EBF",
"type": "BufferGeometry",
"data": {
"attributes": {
"normal": {
"itemSize": 3,
"type": "Float32Array",
"array": [-0.677632,0.723664,-0.13086,-0.948643,0.300427,-0.099091,-0.885243,0.454288,-0.09984,-0.676472,0.730662,-0.092299,-0.976206,-0.209188,-0.057123,-0.997622,-0.050712,-0.046666,-0.852419,-0.522343,-0.023209,-0.891348,-0.453298,0.004323,-0.676407,-0.736435,0.011717,-0.73207,-0.680088,0.039419,-0.316631,-0.946642,0.060111,-0.463351,-0.883258,0.071851,0.20063,-0.973823,0.106844,0.035388,-0.993473,0.108434,0.550787,-0.828134,0.104055,0.47974,-0.866772,0.136218,-0.892548,0.310258,-0.327259,-0.677632,0.723664,-0.13086,-0.87811,-0.136184,-0.458669,-0.732959,-0.460568,-0.500648,-0.56654,-0.67802,-0.468317,-0.274743,-0.873927,-0.400958,0.148276,-0.962373,-0.227709,0.550787,-0.828134,0.104055,-0.728839,0.399214,-0.556257,-0.677632,0.723664,-0.13086,-0.609277,0.016403,-0.792788,-0.419874,-0.278817,-0.863694,-0.252998,-0.495831,-0.830749,0.012336,-0.703646,-0.710443,0.343456,-0.84289,-0.414215,0.550787,-0.828134,0.104055,-0.532455,0.619026,-0.577321,-0.677632,0.723664,-0.13086,-0.32292,0.267575,-0.907814,-0.101942,-0.041678,-0.993917,0.063114,-0.26281,-0.962781,0.281201,-0.498145,-0.82023,0.516384,-0.724446,-0.456646,0.550787,-0.828134,0.104055,-0.518921,0.841264,-0.151646,-0.440264,0.60909,-0.65968,-0.532455,0.619026,-0.577321,-0.677632,0.723664,-0.13086,-0.161541,0.316805,-0.934633,-0.32292,0.267575,-0.907814,0.070429,0.060437,-0.995684,-0.101942,-0.041678,-0.993917,0.235086,-0.160091,-0.9587,0.063114,-0.26281,-0.962781,0.438187,-0.442249,-0.782565,0.281201,-0.498145,-0.82023,0.596529,-0.716129,-0.36237,0.516384,-0.724446,-0.456646,0.709311,-0.696814,0.106434,0.550787,-0.828134,0.104055,-0.272972,0.736084,-0.619409,-0.518921,0.841264,-0.151646,0.092969,0.544061,-0.833879,0.369144,0.319812,-0.872613,0.535902,0.102979,-0.837976,0.709951,-0.168976,-0.683679,0.791333,-0.49393,-0.360313,0.709311,-0.696814,0.106434,-0.102982,0.89372,-0.436645,-0.518921,0.841264,-0.151646,0.338211,0.777743,-0.529839,0.633661,0.576298,-0.516096,0.799883,0.359146,-0.480835,0.933565,0.052145,-0.354594,0.924024,-0.357956,-0.134342,0.709311,-0.696814,0.106434,-0.199629,0.972303,-0.121551,-0.518921,0.841264,-0.151646,0.318796,0.943361,-0.091863,0.678859,0.732719,-0.047686,0.85498,0.518462,-0.014337,0.977841,0.206708,0.033142,0.949096,-0.301109,0.09247,0.709311,-0.696814,0.106434,-0.517636,0.848098,-0.11306,-0.03584,0.994158,-0.101814,-0.199629,0.972303,-0.121551,-0.518921,0.841264,-0.151646,0.461714,0.885736,-0.04787,0.318796,0.943361,-0.091863,0.72951,0.683967,-0.001873,0.678859,0.732719,-0.047686,0.888897,0.457013,0.031646,0.85498,0.518462,-0.014337,0.995457,0.053994,0.078418,0.977841,0.206708,0.033142,0.88424,-0.452767,0.114547,0.949096,-0.301109,0.09247,0.648135,-0.748432,0.140611,0.709311,-0.696814,0.106434,-0.094387,0.987549,0.125853,-0.517636,0.848098,-0.11306,0.305985,0.888521,0.341912,0.567318,0.678385,0.466846,0.732179,0.460207,0.502119,0.846871,0.121591,0.517711,0.838658,-0.335433,0.429114,0.648135,-0.748432,0.140611,-0.258097,0.898591,0.354851,-0.517636,0.848098,-0.11306,0.037151,0.735935,0.676032,0.254233,0.496636,0.82989,0.418639,0.278015,0.864551,0.559792,-0.048698,0.827201,0.643479,-0.454916,0.615618,0.648135,-0.748432,0.140611,-0.524469,0.770815,0.361632,-0.517636,0.848098,-0.11306,-0.297858,0.548739,0.781131,-0.078482,0.278929,0.957099,0.090074,0.061369,0.994043,0.273771,-0.23163,0.933486,0.476697,-0.581445,0.659304,0.648135,-0.748432,0.140611,-0.676472,0.730662,-0.092299,-0.552814,0.696802,0.457017,-0.524469,0.770815,0.361632,-0.517636,0.848098,-0.11306,-0.393428,0.412974,0.821381,-0.297858,0.548739,0.781131,-0.223617,0.141007,0.964423,-0.078482,0.278929,0.957099,-0.054662,-0.077157,0.995519,0.090074,0.061369,0.994043,0.182592,-0.374068,0.909249,0.273771,-0.23163,0.933486,0.460392,-0.673713,0.578057,0.476697,-0.581445,0.659304,0.47974,-0.866772,0.136218,0.648135,-0.748432,0.140611,-0.713959,0.561725,0.418005,-0.676472,0.730662,-0.092299,-0.665097,0.208269,0.717126,-0.534787,-0.101998,0.838808,-0.370264,-0.320797,0.871776,-0.137827,-0.58336,0.800434,0.195601,-0.803875,0.561716,0.47974,-0.866772,0.136218,-0.883959,0.404081,0.235234,-0.676472,0.730662,-0.092299,-0.910338,-0.025404,0.413085,-0.799302,-0.358482,0.482294,-0.634241,-0.576962,0.514639,-0.361438,-0.804482,0.47135,0.06291,-0.93985,0.335744,0.47974,-0.866772,0.136218,-0.885243,0.454288,-0.09984,-0.676472,0.730662,-0.092299,-0.997622,-0.050712,-0.046666,-0.891348,-0.453298,0.004323,-0.73207,-0.680088,0.039419,-0.463351,-0.883258,0.071851,0.035388,-0.993473,0.108434,0.47974,-0.866772,0.136218,0.550787,-0.828134,0.104055,0.709311,-0.696814,0.106434,0.47974,-0.866772,0.136218,-0.518921,0.841264,-0.151646,-0.677632,0.723664,-0.13086,-0.676472,0.730662,-0.092299],
"normalized": false
},
"position": {
"itemSize": 3,
"type": "Float32Array",
"array": [8.655041,12.545126,57.205441,8.523679,12.376444,57.213913,8.341802,12.652069,59.881222,8.473164,12.820751,59.872746,8.494052,12.165463,57.233696,8.312175,12.441089,59.901001,8.5741,11.968718,57.259483,8.392223,12.244344,59.926792,8.683826,11.82443,57.281876,8.501949,12.100055,59.949184,8.852099,11.694635,57.306763,8.670222,11.970262,59.974068,9.06351,11.666573,57.324078,8.881633,11.942198,59.991383,9.26141,11.747759,57.329182,9.079533,12.023384,59.99649,8.552728,12.382585,57.111134,8.655041,12.545126,57.205441,8.544367,12.1761,57.055676,8.632199,11.980999,57.053925,8.741925,11.836711,57.076313,8.902414,11.705272,57.128742,9.092559,11.672713,57.221298,9.26141,11.747759,57.329182,8.618106,12.420555,57.035442,8.655041,12.545126,57.205441,8.657606,12.241868,56.924572,8.762956,12.056942,56.902538,8.872682,11.912653,56.924931,9.015654,11.77104,56.997639,9.157937,11.710685,57.145607,9.26141,11.747759,57.329182,8.702295,12.480184,57.007118,8.655041,12.545126,57.205441,8.803426,12.345146,56.875519,8.931334,12.176197,56.845898,9.04106,12.031908,56.868286,9.161473,11.874318,56.948586,9.242126,11.770312,57.117283,9.26141,11.747759,57.329182,8.920732,12.746753,57.202721,8.967987,12.68181,57.004402,8.702295,12.480184,57.007118,8.655041,12.545126,57.205441,9.069118,12.546773,56.872799,8.803426,12.345146,56.875519,9.197025,12.377824,56.843178,8.931334,12.176197,56.845898,9.306752,12.233536,56.86557,9.04106,12.031908,56.868286,9.427165,12.075945,56.945866,9.161473,11.874318,56.948586,9.507818,11.971939,57.114567,9.242126,11.770312,57.117283,9.527102,11.949386,57.326466,9.26141,11.747759,57.329182,9.048429,12.747117,57.03104,8.920732,12.746753,57.202721,9.208447,12.659887,56.918938,9.357909,12.508438,56.896454,9.467635,12.364149,56.918846,9.566494,12.189059,56.992004,9.58826,12.037246,57.141201,9.527102,11.949386,57.326466,9.103569,12.800604,57.105499,8.920732,12.746753,57.202721,9.303951,12.75253,57.047901,9.468188,12.615412,57.045368,9.577915,12.471123,57.06776,9.661999,12.281702,57.120972,9.6434,12.090733,57.21566,9.527102,11.949386,57.326466,9.118632,12.82794,57.207825,8.920732,12.746753,57.202721,9.330042,12.799876,57.225143,9.498316,12.670083,57.250027,9.608042,12.525794,57.272419,9.688089,12.329048,57.29821,9.658463,12.118069,57.317989,9.527102,11.949386,57.326466,8.738855,13.022378,59.870029,8.936755,13.103565,59.875134,9.118632,12.82794,57.207825,8.920732,12.746753,57.202721,9.148165,13.075501,59.892448,9.330042,12.799876,57.225143,9.316438,12.945708,59.917336,9.498316,12.670083,57.250027,9.426165,12.801419,59.939728,9.608042,12.525794,57.272419,9.506212,12.604673,59.965515,9.688089,12.329048,57.29821,9.476586,12.393694,59.985298,9.658463,12.118069,57.317989,9.345223,12.225012,59.993771,9.527102,11.949386,57.326466,8.907705,13.097425,59.977913,8.738855,13.022378,59.870029,9.09785,13.064866,60.070469,9.25834,12.933427,60.122894,9.368066,12.789138,60.145287,9.455897,12.594038,60.143536,9.447536,12.387553,60.088078,9.345223,12.225012,59.993771,8.842327,13.059453,60.053604,8.738855,13.022378,59.870029,8.984612,12.999098,60.201572,9.127583,12.857485,60.274281,9.237309,12.713196,60.296673,9.342659,12.52827,60.274639,9.382158,12.349582,60.163769,9.345223,12.225012,59.993771,8.758138,12.999825,60.081928,8.738855,13.022378,59.870029,8.838792,12.89582,60.250626,8.959205,12.738229,60.330921,9.068931,12.593941,60.353313,9.196839,12.424992,60.323692,9.297969,12.289954,60.192093,9.345223,12.225012,59.993771,8.473164,12.820751,59.872746,8.492446,12.798199,60.084644,8.758138,12.999825,60.081928,8.738855,13.022378,59.870029,8.5731,12.694193,60.253345,8.838792,12.89582,60.250626,8.693513,12.536602,60.333641,8.959205,12.738229,60.330921,8.803239,12.392313,60.356033,9.068931,12.593941,60.353313,8.931148,12.223365,60.326412,9.196839,12.424992,60.323692,9.032278,12.088327,60.194809,9.297969,12.289954,60.192093,9.079533,12.023384,59.99649,9.345223,12.225012,59.993771,8.412004,12.732892,60.05801,8.473164,12.820751,59.872746,8.433771,12.581078,60.207207,8.53263,12.405989,60.280365,8.642356,12.2617,60.302757,8.791819,12.11025,60.280273,8.951836,12.023021,60.168171,9.079533,12.023384,59.99649,8.356865,12.679404,59.983551,8.473164,12.820751,59.872746,8.338265,12.488436,60.078239,8.42235,12.299014,60.131451,8.532076,12.154726,60.153839,8.696313,12.017608,60.15131,8.896696,11.969533,60.093712,9.079533,12.023384,59.99649,8.341802,12.652069,59.881222,8.473164,12.820751,59.872746,8.312175,12.441089,59.901001,8.392223,12.244344,59.926792,8.501949,12.100055,59.949184,8.670222,11.970262,59.974068,8.881633,11.942198,59.991383,9.079533,12.023384,59.99649,9.26141,11.747759,57.329182,9.527102,11.949386,57.326466,9.079533,12.023384,59.99649,8.920732,12.746753,57.202721,8.655041,12.545126,57.205441,8.473164,12.820751,59.872746],
"normalized": false
}
},
"index": {
"type": "Uint32Array",
"array": [0,1,2,0,2,3,1,4,5,1,5,2,4,6,7,4,7,5,6,8,9,6,9,7,8,10,11,8,11,9,10,12,13,10,13,11,12,14,15,12,15,13,16,1,17,16,18,4,16,4,1,18,19,6,18,6,4,19,20,8,19,8,6,20,21,10,20,10,8,21,22,12,21,12,10,22,23,12,24,16,25,24,26,18,24,18,16,26,27,19,26,19,18,27,28,20,27,20,19,28,29,21,28,21,20,29,30,22,29,22,21,30,31,22,32,24,33,32,34,26,32,26,24,34,35,27,34,27,26,35,36,28,35,28,27,36,37,29,36,29,28,37,38,30,37,30,29,38,39,30,40,41,42,40,42,43,41,44,45,41,45,42,44,46,47,44,47,45,46,48,49,46,49,47,48,50,51,48,51,49,50,52,53,50,53,51,52,54,55,52,55,53,56,41,57,56,58,44,56,44,41,58,59,46,58,46,44,59,60,48,59,48,46,60,61,50,60,50,48,61,62,52,61,52,50,62,63,52,64,56,65,64,66,58,64,58,56,66,67,59,66,59,58,67,68,60,67,60,59,68,69,61,68,61,60,69,70,62,69,62,61,70,71,62,72,64,73,72,74,66,72,66,64,74,75,67,74,67,66,75,76,68,75,68,67,76,77,69,76,69,68,77,78,70,77,70,69,78,79,70,80,81,82,80,82,83,81,84,85,81,85,82,84,86,87,84,87,85,86,88,89,86,89,87,88,90,91,88,91,89,90,92,93,90,93,91,92,94,95,92,95,93,96,81,97,96,98,84,96,84,81,98,99,86,98,86,84,99,100,88,99,88,86,100,101,90,100,90,88,101,102,92,101,92,90,102,103,92,104,96,105,104,106,98,104,98,96,106,107,99,106,99,98,107,108,100,107,100,99,108,109,101,108,101,100,109,110,102,109,102,101,110,111,102,112,104,113,112,114,106,112,106,104,114,115,107,114,107,106,115,116,108,115,108,107,116,117,109,116,109,108,117,118,110,117,110,109,118,119,110,120,121,122,120,122,123,121,124,125,121,125,122,124,126,127,124,127,125,126,128,129,126,129,127,128,130,131,128,131,129,130,132,133,130,133,131,132,134,135,132,135,133,136,121,137,136,138,124,136,124,121,138,139,126,138,126,124,139,140,128,139,128,126,140,141,130,140,130,128,141,142,132,141,132,130,142,143,132,144,136,145,144,146,138,144,138,136,146,147,139,146,139,138,147,148,140,147,140,139,148,149,141,148,141,140,149,150,142,149,142,141,150,151,142,152,144,153,152,154,146,152,146,144,154,155,147,154,147,146,155,156,148,155,148,147,156,157,149,156,149,148,157,158,150,157,150,149,158,159,150,160,161,135,160,135,162,123,163,164,123,164,165]
},
"boundingSphere": {
"center": [9.000132,12.385069,58.599606],
"radius": 1.767446
}
}
},
{
"uuid": "D4790B01-2C63-4B0C-A567-4F5C662B79E0",
"type": "BufferGeometry",
"data": {
"attributes": {
"normal": {
"itemSize": 3,
"type": "Float32Array",
"array": [-0.06331,0.105866,0.992363,-0.063301,0.105862,0.992364,-0.063309,0.105861,0.992364,0.386871,0.91921,-0.073375,0.574866,0.816691,-0.050446,-0.014437,0.994157,-0.106973,-0.227753,0.966586,-0.11764,0.0633,-0.105861,-0.992364,0.063307,-0.105858,-0.992364,0.063313,-0.105861,-0.992363,-0.063315,0.105865,0.992363,0.853724,0.520725,-0.001084,0.944588,0.327278,0.025348,0.063297,-0.10586,-0.992364,-0.063313,0.105854,0.992364,0.994482,-0.076658,0.07162,0.953511,-0.287144,0.091461,0.063297,-0.105863,-0.992364,-0.063307,0.105856,0.992364,0.755384,-0.644758,0.11697,0.598224,-0.791889,0.122638,0.063307,-0.105856,-0.992364,-0.063302,0.105862,0.992364,0.227751,-0.966587,0.117638,0.014437,-0.994157,0.10697,0.063311,-0.105847,-0.992365,-0.06331,0.105866,0.992363,-0.386871,-0.91921,0.073374,-0.574864,-0.816693,0.050446,0.063307,-0.105849,-0.992365,-0.063315,0.105865,0.992363,-0.853723,-0.520727,0.001084,-0.944588,-0.327278,-0.025348,0.06331,-0.105854,-0.992364,-0.063313,0.105854,0.992364,-0.994482,0.076658,-0.071621,-0.95351,0.287146,-0.091461,0.063311,-0.105866,-0.992363,-0.063307,0.105856,0.992364,-0.755382,0.644761,-0.116969,-0.598226,0.791888,-0.122638,0.063317,-0.105866,-0.992362,-0.227753,0.966586,-0.11764,-0.014437,0.994157,-0.106973,-0.598798,0.790886,-0.126257,-0.598798,0.790886,-0.126257,-0.598798,0.790886,-0.126257,0.45236,0.203887,-0.868217,0.583368,0.322696,-0.745352,0.149426,-0.044563,-0.987768,-0.022505,-0.174207,-0.984452,0.598798,-0.790885,0.126259,0.598798,-0.790886,0.126258,0.598797,-0.790886,0.126258,-0.598798,0.790885,-0.126257,0.754443,0.504102,-0.420354,0.7945,0.566698,-0.218228,0.598799,-0.790885,0.126257,-0.598798,0.790886,-0.126258,0.768357,0.611765,0.18807,0.702155,0.59424,0.392247,0.598798,-0.790886,0.126256,-0.598799,0.790885,-0.126258,0.488781,0.48576,0.724659,0.341612,0.394806,0.852895,0.598797,-0.790886,0.126257,-0.598799,0.790885,-0.126257,0.022514,0.174207,0.984452,-0.149413,0.04456,0.98777,0.598798,-0.790886,0.126257,-0.598797,0.790886,-0.126257,-0.452357,-0.203891,0.868217,-0.583374,-0.322702,0.745344,0.598797,-0.790886,0.126257,-0.598797,0.790886,-0.126258,-0.754443,-0.504103,0.420352,-0.7945,-0.566697,0.21823,0.598797,-0.790886,0.126258,-0.598798,0.790885,-0.126258,-0.768358,-0.611764,-0.188073,-0.702158,-0.594236,-0.392247,0.598798,-0.790885,0.126258,-0.598798,0.790885,-0.126257,-0.48879,-0.485755,-0.724656,-0.341613,-0.394803,-0.852895,0.598797,-0.790886,0.126257,-0.022505,-0.174207,-0.984452,0.149426,-0.044563,-0.987768,-0.598797,0.790887,-0.126258,-0.598797,0.790886,-0.126258,-0.598798,0.790886,-0.126258,0.452358,0.203882,-0.868219,0.583375,0.322694,-0.745347,0.149418,-0.044566,-0.987769,-0.022505,-0.174203,-0.984453,0.598798,-0.790885,0.126257,0.598798,-0.790886,0.126258,0.598798,-0.790886,0.126258,-0.598796,0.790887,-0.126259,0.754445,0.504098,-0.420354,0.794502,0.566694,-0.21823,0.598798,-0.790885,0.126258,-0.598797,0.790886,-0.126258,0.768357,0.611765,0.188072,0.702157,0.594239,0.392244,0.598799,-0.790885,0.126258,-0.598797,0.790886,-0.126256,0.488787,0.48576,0.724655,0.341609,0.394802,0.852898,0.598798,-0.790885,0.126258,-0.598797,0.790886,-0.126257,0.022505,0.174203,0.984452,-0.149418,0.044566,0.987769,0.598798,-0.790885,0.126259,-0.598798,0.790886,-0.126257,-0.452358,-0.203883,0.868219,-0.583374,-0.322695,0.745347,0.598798,-0.790885,0.126258,-0.598798,0.790885,-0.126258,-0.754444,-0.5041,0.420354,-0.794501,-0.566697,0.218229,0.598797,-0.790886,0.126256,-0.598799,0.790885,-0.126258,-0.768359,-0.611763,-0.18807,-0.702159,-0.594237,-0.392244,0.598797,-0.790887,0.126257,-0.598798,0.790885,-0.126257,-0.488784,-0.485754,-0.724661,-0.341602,-0.394798,-0.852902,0.598797,-0.790887,0.126258,-0.022505,-0.174203,-0.984453,0.149418,-0.044566,-0.987769,0.59513,-0.791845,0.137121,0.59513,-0.791845,0.137121,0.59513,-0.791845,0.13712,0.188247,0.30095,0.934875,0.274233,0.358574,0.892312,0.009865,0.174845,0.984546,-0.081678,0.106878,0.990912,-0.59513,0.791845,-0.13712,-0.59513,0.791845,-0.13712,-0.59513,0.791845,-0.13712,0.595129,-0.791845,0.137121,0.430219,0.456893,0.778563,0.500214,0.49778,0.70852,-0.59513,0.791845,-0.13712,0.59513,-0.791845,0.137121,0.618387,0.560162,0.551195,0.667274,0.582283,0.464426,-0.595129,0.791845,-0.137121,0.595129,-0.791845,0.13712,0.74187,0.607754,0.283311,0.768345,0.611619,0.188593,-0.59513,0.791845,-0.13712,0.59513,-0.791845,0.13712,0.798485,0.602013,-0.001591,0.802453,0.588623,-0.097942,-0.59513,0.791845,-0.13712,0.59513,-0.791845,0.13712,0.787764,0.54557,-0.285976,0.768772,0.51552,-0.378455,-0.59513,0.791845,-0.13712,0.59513,-0.791845,0.13712,0.707867,0.439972,-0.552584,0.665162,0.393852,-0.634381,-0.59513,0.791845,-0.13712,0.595129,-0.791845,0.13712,0.557402,0.288034,-0.778678,0.491652,0.22794,-0.84043,-0.59513,0.791845,-0.137121,0.595129,-0.791845,0.13712,0.341926,0.09901,-0.934496,0.258022,0.030423,-0.96566,-0.59513,0.791845,-0.13712,0.59513,-0.791845,0.13712,0.081679,-0.106883,-0.990911,-0.00986,-0.174848,-0.984546,-0.595129,0.791845,-0.13712,0.59513,-0.791845,0.13712,-0.188243,-0.300952,-0.934876,-0.274231,-0.358575,-0
gitextract_jk23ff3i/ ├── .browserslistrc ├── .eslintrc.js ├── .gitignore ├── .nvmrc ├── .prettierrc.js ├── LICENSE ├── README.md ├── babel.config.js ├── netlify.toml ├── package.json ├── postcss.config.js ├── public/ │ └── index.html ├── src/ │ ├── App.vue │ ├── assets/ │ │ └── scss/ │ │ ├── components/ │ │ │ ├── _btn.scss │ │ │ ├── _check.scss │ │ │ ├── _code.scss │ │ │ ├── _icon.scss │ │ │ ├── _no-js-overlay.scss │ │ │ └── _range.scss │ │ ├── defaults/ │ │ │ └── _typography.scss │ │ ├── helpers/ │ │ │ ├── _flex.scss │ │ │ ├── _helpers.scss │ │ │ └── _typography.scss │ │ ├── main.scss │ │ ├── settings/ │ │ │ ├── _settings.scss │ │ │ └── _typography.scss │ │ └── vue_include.scss │ ├── classes/ │ │ ├── Brush.js │ │ ├── Canvas/ │ │ │ ├── Action.js │ │ │ ├── DrawAction.js │ │ │ └── index.js │ │ ├── Color.js │ │ ├── Rectangle.js │ │ └── Smoothing.js │ ├── components/ │ │ ├── Common/ │ │ │ ├── Animation/ │ │ │ │ └── Animation.vue │ │ │ ├── Attribution.vue │ │ │ ├── BrowserSupport.vue │ │ │ ├── ConnectionTimeout.vue │ │ │ ├── Footer/ │ │ │ │ ├── Footer.vue │ │ │ │ ├── FooterAttribution.vue │ │ │ │ ├── FooterBrowserSupport.vue │ │ │ │ ├── FooterConnection.vue │ │ │ │ ├── FooterCopyright.vue │ │ │ │ ├── FooterGithub.vue │ │ │ │ └── FooterLanguage.vue │ │ │ ├── Logo.vue │ │ │ ├── RestoreConnection.vue │ │ │ └── ServerStatus.vue │ │ ├── Desktop/ │ │ │ ├── Canvas/ │ │ │ │ ├── CanvasDrawing.vue │ │ │ │ └── CanvasInterface.vue │ │ │ ├── Drawing.vue │ │ │ ├── Pairing.vue │ │ │ └── Toolbar/ │ │ │ ├── Button/ │ │ │ │ ├── Button.vue │ │ │ │ ├── ButtonClear.vue │ │ │ │ ├── ButtonColor.vue │ │ │ │ ├── ButtonRedo.vue │ │ │ │ └── ButtonUndo.vue │ │ │ ├── Item.vue │ │ │ ├── Slider/ │ │ │ │ ├── Slider.vue │ │ │ │ ├── SliderBrushHardness.vue │ │ │ │ ├── SliderBrushOpacity.vue │ │ │ │ ├── SliderBrushRadius.vue │ │ │ │ ├── SliderDistance.vue │ │ │ │ └── SliderLazyRadius.vue │ │ │ └── Toolbar.vue │ │ ├── Desktop.vue │ │ ├── Mobile/ │ │ │ ├── Controlling.vue │ │ │ ├── Pairing.vue │ │ │ └── TouchHandler.vue │ │ └── Mobile.vue │ ├── events/ │ │ └── index.js │ ├── i18n.js │ ├── locales/ │ │ ├── de.json │ │ └── en.json │ ├── main.js │ ├── mixins/ │ │ └── PointerEvents.js │ ├── plugins/ │ │ ├── GymoteRemote.js │ │ ├── GymoteScreen.js │ │ ├── PeerSox.js │ │ ├── Sentry.js │ │ ├── Settings.js │ │ └── Track.js │ ├── settings/ │ │ └── index.js │ ├── store/ │ │ ├── vuetamin/ │ │ │ ├── actions.js │ │ │ ├── data.js │ │ │ ├── index.js │ │ │ ├── mutations.js │ │ │ ├── state.js │ │ │ └── threads.js │ │ └── vuex/ │ │ └── index.js │ └── tools/ │ ├── animation/ │ │ ├── app.json │ │ ├── debug.js │ │ ├── index.js │ │ ├── keyframes.js │ │ └── webglSupport.js │ ├── canvas.js │ ├── cookies.js │ ├── dependencies.js │ └── helpers.js └── vue.config.js
SYMBOL INDEX (182 symbols across 27 files)
FILE: src/classes/Brush.js
class Brush (line 10) | class Brush {
method constructor (line 11) | constructor({
method state (line 32) | get state() {
method canvasRadius (line 48) | get canvasRadius() {
method canvasBlur (line 60) | get canvasBlur() {
method canvasColor (line 69) | get canvasColor() {
method canvasProperties (line 78) | get canvasProperties() {
method setColor (line 100) | setColor(color) {
method setRadius (line 109) | setRadius(radius) {
method setHardness (line 118) | setHardness(hardness) {
method setOpacity (line 127) | setOpacity(opacity) {
method setStyle (line 136) | setStyle(style) {
method setFilterSupport (line 145) | setFilterSupport(isSupported) {
FILE: src/classes/Canvas/Action.js
class Action (line 6) | class Action {
method constructor (line 7) | constructor(type) {
method do (line 17) | do(canvas, size) {
FILE: src/classes/Canvas/DrawAction.js
class DrawAction (line 7) | class DrawAction extends Action {
method constructor (line 8) | constructor(canvasProperties) {
method setCanvasProperties (line 19) | setCanvasProperties(canvas) {
method do (line 32) | do(canvas) {
FILE: src/classes/Canvas/index.js
method constructor (line 13) | constructor(canvasMain, canvasTemp) {
method init (line 34) | init() {
method state (line 44) | get state() {
method setSizes (line 61) | setSizes({ width, height }) {
method updateSizes (line 71) | updateSizes(viewport) {
method start (line 81) | start(canvasProperties) {
method updateHistoryState (line 90) | updateHistoryState() {
method pushAction (line 102) | pushAction(action) {
method release (line 112) | release() {
method move (line 125) | move(point) {
method copy (line 139) | copy(source, target) {
method undo (line 148) | undo() {
method redo (line 160) | redo() {
method redraw (line 172) | redraw() {
method drawActions (line 180) | drawActions() {
method erase (line 202) | erase() {
FILE: src/classes/Color.js
class Color (line 6) | class Color {
method constructor (line 7) | constructor({ name, rgb = [0, 0, 0], hex } = {}) {
method setColor (line 17) | setColor(rgb) {
method getRgbaString (line 26) | getRgbaString(alpha) {
FILE: src/classes/Rectangle.js
class Rectangle (line 3) | class Rectangle {
method constructor (line 4) | constructor(x = 0, y = 0, width = 0, height = 0) {
method width (line 12) | get width() {
method height (line 19) | get height() {
method setFromDOMRect (line 28) | setFromDOMRect(domRect) {
method setFromElement (line 40) | setFromElement(element) {
method containsPoint (line 53) | containsPoint(point) {
FILE: src/classes/Smoothing.js
class Smoothing (line 4) | class Smoothing {
method constructor (line 5) | constructor() {
method next (line 17) | next(input, force) {
FILE: src/i18n.js
function loadLocaleMessages (line 8) | function loadLocaleMessages() {
function detectLanguage (line 25) | function detectLanguage() {
FILE: src/main.js
function getGymote (line 21) | function getGymote() {
FILE: src/mixins/PointerEvents.js
method handleWheel (line 7) | handleWheel(e) {
method handleMouseMove (line 17) | handleMouseMove(e) {
method handleMouseDown (line 28) | handleMouseDown() {
method handleMouseUp (line 35) | handleMouseUp() {
method handleTouchStart (line 42) | handleTouchStart(e) {
method handleTouchMove (line 57) | handleTouchMove(e) {
method handleTouchEnd (line 70) | handleTouchEnd(e) {
method preventEventIfRequired (line 75) | preventEventIfRequired(e) {
method mounted (line 82) | mounted() {
method destroyed (line 94) | destroyed() {
FILE: src/plugins/GymoteRemote.js
method install (line 4) | install(Vue) {
FILE: src/plugins/GymoteScreen.js
method install (line 4) | install(Vue) {
FILE: src/plugins/PeerSox.js
method install (line 5) | install(Vue, { api, wss }) {
FILE: src/plugins/Sentry.js
constant VERSION (line 8) | const VERSION = `drawmote@${process.env.PKG_VERSION}`
function log (line 10) | function log(category, data) {
method install (line 16) | install(Vue) {
FILE: src/plugins/Settings.js
method install (line 2) | install(Vue) {
FILE: src/plugins/Track.js
constant DIMENSIONS (line 1) | const DIMENSIONS = {
function trackEvent (line 8) | function trackEvent(category, action, value) {
function trackUser (line 17) | function trackUser(hash) {
function trackDimension (line 26) | function trackDimension(dimension, value) {
method install (line 36) | install(Vue) {
FILE: src/settings/index.js
constant COLORS (line 1) | const COLORS = [
constant DEFAULT_COLOR (line 28) | const DEFAULT_COLOR = COLORS[3]
constant RADIUS_DEFAULT (line 29) | const RADIUS_DEFAULT = 16
constant RADIUS_MIN (line 30) | const RADIUS_MIN = 1
constant RADIUS_MAX (line 31) | const RADIUS_MAX = 36
constant LAZY_RADIUS_MIN (line 33) | const LAZY_RADIUS_MIN = 0
constant LAZY_RADIUS_MAX (line 34) | const LAZY_RADIUS_MAX = 6 * RADIUS_MAX
constant LAZY_RADIUS_DEFAULT (line 35) | const LAZY_RADIUS_DEFAULT = 20
constant HARDNESS_DEFAULT (line 37) | const HARDNESS_DEFAULT = 100
constant HARDNESS_MIN (line 38) | const HARDNESS_MIN = 0
constant HARDNESS_MAX (line 39) | const HARDNESS_MAX = 100
constant OPACITY_DEFAULT (line 41) | const OPACITY_DEFAULT = 100
constant OPACITY_MIN (line 42) | const OPACITY_MIN = 1
constant OPACITY_MAX (line 43) | const OPACITY_MAX = 100
constant SMOOTHING_INIT (line 46) | const SMOOTHING_INIT = 1.3
constant SMUDGE_AMOUNT (line 48) | const SMUDGE_AMOUNT = 0.25
constant BRUSH_DEFAULT (line 50) | const BRUSH_DEFAULT = {
constant TOOLBAR_TOOLS (line 58) | const TOOLBAR_TOOLS = [
constant TOOLBAR_SLIDERS (line 73) | const TOOLBAR_SLIDERS = [
constant BREAKPOINT_REMOTE (line 101) | const BREAKPOINT_REMOTE = 700
constant BREAKPOINT_ANIMATION (line 102) | const BREAKPOINT_ANIMATION = 1024
constant ANIMATION_SCREEN_VIEWPORT (line 104) | const ANIMATION_SCREEN_VIEWPORT = {
FILE: src/store/vuetamin/actions.js
function storeStateCookie (line 3) | function storeStateCookie({ data }, { noTimeout } = {}) {
FILE: src/store/vuetamin/mutations.js
function updatePointer (line 3) | function updatePointer(
function updateIsPressing (line 32) | function updateIsPressing(
function updateTouch (line 46) | function updateTouch({ data, trigger }, touch) {
function updateCalibration (line 53) | function updateCalibration({ data }) {
function updateCanvasRect (line 57) | function updateCanvasRect({ data, trigger }, element) {
function updateToolbarRect (line 62) | function updateToolbarRect({ data, trigger }, element) {
function updateFooterRect (line 67) | function updateFooterRect({ data, trigger }, element) {
function updateViewport (line 72) | function updateViewport({ data, trigger }, viewport) {
function updateUseLazyBrush (line 78) | function updateUseLazyBrush({ data }, useLazyBrush) {
function updateLazyRadius (line 86) | function updateLazyRadius({ data, trigger, action }, radius) {
function updateBrushColor (line 93) | function updateBrushColor({ data, trigger, action }, color) {
function updateBrushOpacity (line 100) | function updateBrushOpacity({ data, trigger, action }, opacity) {
function updateBrushRadius (line 107) | function updateBrushRadius({ data, trigger, action }, radius) {
function updateBrushHardness (line 114) | function updateBrushHardness({ data, trigger, action }, hardness) {
function updateGymoteDistance (line 121) | function updateGymoteDistance({ data, action }, distance) {
function updateCanvasFilterSupport (line 126) | function updateCanvasFilterSupport({ data }, isSupported) {
FILE: src/store/vuex/index.js
method setServerStatus (line 19) | setServerStatus(state, status = {}) {
method setSkipped (line 25) | setSkipped(state, isSkipped) {
method setConnected (line 29) | setConnected(state, isConnected) {
method setIntroPlayed (line 33) | setIntroPlayed(state, introPlayed) {
method setAttributionVisible (line 37) | setAttributionVisible(state, isVisible) {
method connect (line 43) | connect({ commit }) {
method disconnect (line 48) | disconnect({ commit }) {
method skip (line 52) | skip({ commit }) {
method unskip (line 56) | unskip({ commit }) {
method toggleAttributionVisibility (line 60) | toggleAttributionVisibility({ state, commit }) {
method hasServerError (line 66) | hasServerError(state) {
method isDrawing (line 70) | isDrawing(state) {
FILE: src/tools/animation/debug.js
constant THREE (line 5) | const THREE = window.THREE
class AnimationDebug (line 11) | class AnimationDebug {
method constructor (line 12) | constructor(rectLight, cameraAnimation, phoneAnimation) {
method seekAnimation (line 65) | seekAnimation(step) {
method addOrbitControls (line 71) | addOrbitControls() {
method addTargetSphere (line 84) | addTargetSphere() {
method updateGui (line 93) | updateGui() {
method addGui (line 105) | addGui() {
method dispose (line 171) | dispose() {
FILE: src/tools/animation/index.js
class ThreeAnimation (line 42) | class ThreeAnimation extends EventEmitter {
method constructor (line 43) | constructor(container, viewport, isDesktop, debug, pairingEl) {
method load (line 112) | load(json) {
method initPhone (line 134) | initPhone() {
method init (line 171) | init() {
method addCssObject (line 238) | addCssObject(name, source) {
method getScreen (line 285) | getScreen() {
method updateCamera (line 289) | updateCamera() {
method updatePhone (line 317) | updatePhone() {
method setPhoneRotationFromGyro (line 329) | setPhoneRotationFromGyro({ alpha, beta }) {
method setPhoneRotationFromMouse (line 336) | setPhoneRotationFromMouse(x, y) {
method getIntersection (line 356) | getIntersection() {
method setFinalCameraState (line 389) | setFinalCameraState() {
method setSize (line 401) | setSize(width, height) {
method animate (line 426) | animate(t) {
method play (line 474) | play() {
method stop (line 480) | stop() {
method animateEnter (line 484) | animateEnter() {
method setObjectValues (line 539) | setObjectValues(object, values) {
method setCameraValues (line 545) | setCameraValues({ values }) {
method setPhoneValues (line 549) | setPhoneValues({ values }) {
method addToTimeline (line 553) | addToTimeline(target, timeline, frames) {
method refresh (line 575) | refresh() {
method dispose (line 582) | dispose() {
FILE: src/tools/animation/keyframes.js
constant CAMERA_MOBILE (line 1) | const CAMERA_MOBILE = [
constant PHONE_MOBILE (line 58) | const PHONE_MOBILE = [
constant CAMERA (line 97) | const CAMERA = [
constant PHONE (line 136) | const PHONE = [
FILE: src/tools/canvas.js
function setupCanvases (line 11) | function setupCanvases({ width, height }, canvases) {
function clearCanvas (line 42) | function clearCanvas(
FILE: src/tools/cookies.js
function setState (line 5) | function setState(state) {
function getState (line 9) | function getState() {
function setLocale (line 13) | function setLocale(locale) {
function getLocale (line 17) | function getLocale() {
FILE: src/tools/helpers.js
function getViewportSize (line 1) | function getViewportSize() {
function midPointBetween (line 16) | function midPointBetween(p1, p2) {
function getRgbaString (line 23) | function getRgbaString(rgb, alpha) {
function hexToRgb (line 33) | function hexToRgb(hex) {
function shadeRgbColor (line 44) | function shadeRgbColor(rgb, percent) {
function eraseCookie (line 59) | function eraseCookie(name) {
function isSamePoint (line 63) | function isSamePoint(p1, p2) {
function buildDevServerUrl (line 67) | function buildDevServerUrl(hostname, port) {
function getServerUrls (line 71) | function getServerUrls() {
function encodeEventMessage (line 85) | function encodeEventMessage(event, data) {
function decodeEventMessage (line 89) | function decodeEventMessage(message) {
function scaleRange (line 103) | function scaleRange(input, inputRange, outputRange) {
FILE: vue.config.js
method postProcess (line 27) | postProcess(context) {
Condensed preview — 100 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (314K chars).
[
{
"path": ".browserslistrc",
"chars": 41,
"preview": "> 1%\nlast 3 versions\nnot ie <= 8\nios >= 9"
},
{
"path": ".eslintrc.js",
"chars": 404,
"preview": "module.exports = {\n root: true,\n env: {\n node: true\n },\n extends: ['plugin:vue/essential', '@vue/prettier'],\n ru"
},
{
"path": ".gitignore",
"chars": 232,
"preview": ".DS_Store\nnode_modules\n/dist\n\n# local env files\n.env.local\n.env.*.local\n\n# Log files\nnpm-debug.log*\nyarn-debug.log*\nyarn"
},
{
"path": ".nvmrc",
"chars": 8,
"preview": "12.18.3\n"
},
{
"path": ".prettierrc.js",
"chars": 96,
"preview": "module.exports = {\n trailingComma: 'none',\n tabWidth: 2,\n semi: false,\n singleQuote: true\n}\n"
},
{
"path": "LICENSE",
"chars": 1073,
"preview": "MIT License\n\nCopyright (c) 2018 Jan Hug (dulnan)\n\nPermission is hereby granted, free of charge, to any person obtaining "
},
{
"path": "README.md",
"chars": 4814,
"preview": "\n# drawmote\n*Draw remotely with your phone*\n\n### **[Try it out on drawmote.app](https"
},
{
"path": "babel.config.js",
"chars": 102,
"preview": "module.exports = {\n presets: [['@vue/app', { useBuiltIns: 'entry', modules: false, loose: true }]]\n}\n"
},
{
"path": "netlify.toml",
"chars": 158,
"preview": "[Settings]\n ID = \"f278814b-e1d8-4982-b6b7-8f8235c7e97a\"\n\n[build]\n Publish = \"dist/\"\n Functions = \"\"\n\n[context.develop"
},
{
"path": "package.json",
"chars": 1777,
"preview": "{\n \"name\": \"drawmote\",\n \"version\": \"1.4.1\",\n \"private\": true,\n \"scripts\": {\n \"serve\": \"vue-cli-service serve\",\n "
},
{
"path": "postcss.config.js",
"chars": 59,
"preview": "module.exports = {\n plugins: {\n autoprefixer: {}\n }\n}\n"
},
{
"path": "public/index.html",
"chars": 5449,
"preview": "<!DOCTYPE html>\n<html lang=\"en\" style=\"background: #34152b\">\n <head>\n <meta charset=\"utf-8\">\n <meta http-equiv=\"X"
},
{
"path": "src/App.vue",
"chars": 2610,
"preview": "<template>\n <div id=\"drawmote\" class=\"relative\" :class=\"{ 'is-ready': isReady }\">\n <Mobile v-if=\"isMobile\" />\n <D"
},
{
"path": "src/assets/scss/components/_btn.scss",
"chars": 2348,
"preview": "/*----------------------------------------*\\\n BUTTON\n\\*----------------------------------------*/\n\n.btn,\n%btn {\n\n disp"
},
{
"path": "src/assets/scss/components/_check.scss",
"chars": 506,
"preview": ".check--small {\n font-size: .8125rem;\n line-height: 1.230769231;\n}\n\n.check__title {\n font-weight: 700;\n position: re"
},
{
"path": "src/assets/scss/components/_code.scss",
"chars": 2021,
"preview": "$code-size-xs: 4rem;\n$code-size-sm: 3rem;\n$code-size-md: 4rem;\n$code-size-lg: 5rem;\n\n$code-numbers: (\n 0: ($color-yello"
},
{
"path": "src/assets/scss/components/_icon.scss",
"chars": 138,
"preview": ".icon {\n width: 1em;\n height: 1em;\n fill: currentColor;\n vertical-align: -0.2em;\n}\n\n.icon--large {\n width: 1.2em;\n "
},
{
"path": "src/assets/scss/components/_no-js-overlay.scss",
"chars": 367,
"preview": "/*----------------------------------------*\\\n NO JS OVERLAY\n\\*----------------------------------------*/\n\n.no-js-overla"
},
{
"path": "src/assets/scss/components/_range.scss",
"chars": 605,
"preview": "$track-color: $alt-color-darkest;\n$thumb-color: $alt-color;\n\n$thumb-radius: 4px;\n$thumb-height: 12px;\n$thumb-width: 12px"
},
{
"path": "src/assets/scss/defaults/_typography.scss",
"chars": 2096,
"preview": "/*----------------------------------------*\\\n TYPOGRAPHY SCAFFOLDING\n\\*----------------------------------------*/\n\nhtml"
},
{
"path": "src/assets/scss/helpers/_flex.scss",
"chars": 296,
"preview": ".flex {\n display: flex;\n}\n\n.flex--column {\n flex-direction: column;\n}\n\n.flex--align-stretch {\n align-items: stretch !"
},
{
"path": "src/assets/scss/helpers/_helpers.scss",
"chars": 533,
"preview": ".block {\n display: block;\n}\n\n.h-100 {\n height: 100%;\n}\n\n.arrow-after {\n &:after {\n content: \"\";\n width: 0;\n "
},
{
"path": "src/assets/scss/helpers/_typography.scss",
"chars": 624,
"preview": ".text-brand {\n color: $brand-color;\n}\n\n.text-light {\n font-weight: 400 !important;\n}\n\n.text-bold {\n font-weight: 700 "
},
{
"path": "src/assets/scss/main.scss",
"chars": 2749,
"preview": "/*!\n * カンバス KANBASU\n * Distributed under the MIT License\n * Copyright (c) 2015 Liip AG\n */\n\n/**\n * Settings\n */\n\n@imp"
},
{
"path": "src/assets/scss/settings/_settings.scss",
"chars": 8451,
"preview": "@import 'typography';\n\n$color-red: #F06D31;\n$color-yellow: #ffd52b;\n$color-orange: "
},
{
"path": "src/assets/scss/settings/_typography.scss",
"chars": 1575,
"preview": "$rhythm-spacing-base: 16;\n\n@mixin font-values($font, $force: null) {\n @if ($force == force) {\n $force: !important;\n "
},
{
"path": "src/assets/scss/vue_include.scss",
"chars": 286,
"preview": "@import '../../../node_modules/kanbasu/src/scss/settings/settings';\n@import 'settings/settings';\n@import '../../../node_"
},
{
"path": "src/classes/Brush.js",
"chars": 2918,
"preview": "import {\n DEFAULT_COLOR,\n RADIUS_DEFAULT,\n HARDNESS_DEFAULT,\n OPACITY_DEFAULT\n} from '@/settings'\n\nimport Color from"
},
{
"path": "src/classes/Canvas/Action.js",
"chars": 393,
"preview": "import { clearCanvas } from '@/tools/canvas'\n\n/**\n * A canvas action.\n */\nexport default class Action {\n constructor(ty"
},
{
"path": "src/classes/Canvas/DrawAction.js",
"chars": 1508,
"preview": "import Action from './Action'\nimport { midPointBetween } from '@/tools/helpers.js'\n\n/**\n * A canvas drawing action.\n */\n"
},
{
"path": "src/classes/Canvas/index.js",
"chars": 5235,
"preview": "import Action from './Action'\nimport DrawAction from './DrawAction'\nimport { clearCanvas } from '@/tools/canvas'\n\nexport"
},
{
"path": "src/classes/Color.js",
"chars": 580,
"preview": "import { getRgbaString, hexToRgb } from '@/tools/helpers.js'\n\n/**\n * Manages a color.\n */\nexport default class Color {\n "
},
{
"path": "src/classes/Rectangle.js",
"chars": 1446,
"preview": "import { Point } from 'lazy-brush'\n\nexport default class Rectangle {\n constructor(x = 0, y = 0, width = 0, height = 0) "
},
{
"path": "src/classes/Smoothing.js",
"chars": 573,
"preview": "/**\n * Slowly ease a value to a new value.\n */\nexport default class Smoothing {\n constructor() {\n this.prev = 0\n "
},
{
"path": "src/components/Common/Animation/Animation.vue",
"chars": 7639,
"preview": "<template>\n <div\n class=\"animation\"\n :class=\"{\n 'is-desktop': isDesktop,\n 'is-mobile': !isDesktop,\n "
},
{
"path": "src/components/Common/Attribution.vue",
"chars": 5381,
"preview": "<template>\n <div class=\"attribution\">\n <div class=\"attribution-background\"></div>\n <div class=\"attribution-overla"
},
{
"path": "src/components/Common/BrowserSupport.vue",
"chars": 7292,
"preview": "<template lang=\"html\">\n <transition name=\"appear\">\n <div class=\"browser-support\" :class=\"{ done: allDone }\">\n <"
},
{
"path": "src/components/Common/ConnectionTimeout.vue",
"chars": 2254,
"preview": "<template>\n <transition name=\"fade\">\n <div v-if=\"isVisible\" class=\"connection-timeout\">\n <div>\n <h3 clas"
},
{
"path": "src/components/Common/Footer/Footer.vue",
"chars": 2312,
"preview": "<template>\n <div ref=\"footer\" class=\"footer\">\n <div class=\"footer__content\">\n <ul class=\"list-inline list-inlin"
},
{
"path": "src/components/Common/Footer/FooterAttribution.vue",
"chars": 379,
"preview": "<template>\n <li class=\"footer-attribution\">\n <button\n class=\"btn btn--bare text-bold pdg lg-pdg h-100 hover foo"
},
{
"path": "src/components/Common/Footer/FooterBrowserSupport.vue",
"chars": 1962,
"preview": "<template>\n <li class=\"relative footer-browser-support\">\n <button\n class=\"btn btn--bare text-bold check pdg lg-"
},
{
"path": "src/components/Common/Footer/FooterConnection.vue",
"chars": 686,
"preview": "<template>\n <li class=\"footer-connection\">\n <button\n v-if=\"isConnected\"\n class=\"btn btn--bare text-bold ch"
},
{
"path": "src/components/Common/Footer/FooterCopyright.vue",
"chars": 552,
"preview": "<template>\n <li class=\"text-right hidden-xs-down footer-copyright\">\n <div class=\"pdg footer-text\">\n Made by <a "
},
{
"path": "src/components/Common/Footer/FooterGithub.vue",
"chars": 439,
"preview": "<template>\n <li class=\"text-bold mrgla hover\">\n <a\n class=\"pdg block text-white footer-text\"\n href=\"https:"
},
{
"path": "src/components/Common/Footer/FooterLanguage.vue",
"chars": 1607,
"preview": "<template>\n <li class=\"text-bold hover relative language\">\n <select\n v-model=\"$i18n.locale\"\n class=\"langua"
},
{
"path": "src/components/Common/Logo.vue",
"chars": 1121,
"preview": "<template>\n <div class=\"logo mrgt+\">\n <div class=\"logo__app\">\n <img src=\"drawmote-logo.png\" />\n </div>\n </d"
},
{
"path": "src/components/Common/RestoreConnection.vue",
"chars": 5428,
"preview": "<template>\n <transition name=\"appear\">\n <div v-show=\"isVisible\" class=\"connection pdg lg-pdg+\">\n <div class=\"fl"
},
{
"path": "src/components/Common/ServerStatus.vue",
"chars": 1275,
"preview": "<template>\n <div class=\"server-status\">\n <div class=\"server-status__code\">{{ serverStatus.status }}</div>\n <div c"
},
{
"path": "src/components/Desktop/Canvas/CanvasDrawing.vue",
"chars": 2775,
"preview": "<template>\n <div>\n <canvas\n ref=\"canvas_main\"\n class=\"absolute overlay canvas canvas--main\"\n ></canvas>"
},
{
"path": "src/components/Desktop/Canvas/CanvasInterface.vue",
"chars": 6084,
"preview": "<template>\n <canvas\n ref=\"canvas_interface\"\n class=\"absolute overlay canvas canvas--interface\"\n ></canvas>\n</tem"
},
{
"path": "src/components/Desktop/Drawing.vue",
"chars": 3329,
"preview": "<template>\n <div class=\"drawing\" :class=\"{ 'is-drawing': isDrawing }\">\n <Toolbar v-if=\"showToolbar\" ref=\"toolbar\" />"
},
{
"path": "src/components/Desktop/Pairing.vue",
"chars": 5972,
"preview": "<template>\n <div\n class=\"overlay pairing-desktop absolute flex\"\n :style=\"transformOriginStyle\"\n :class=\"{ 'is-"
},
{
"path": "src/components/Desktop/Toolbar/Button/Button.vue",
"chars": 1637,
"preview": "<template>\n <button\n :class=\"classes\"\n class=\"btn btn--bare pointer-area flex\"\n @click=\"handleClick\"\n >\n <"
},
{
"path": "src/components/Desktop/Toolbar/Button/ButtonClear.vue",
"chars": 819,
"preview": "<script>\nimport { EventBus } from '@/events'\n\nimport Button from '@/components/Desktop/Toolbar/Button/Button.vue'\nimport"
},
{
"path": "src/components/Desktop/Toolbar/Button/ButtonColor.vue",
"chars": 1479,
"preview": "<script>\nimport Button from '@/components/Desktop/Toolbar/Button/Button.vue'\n\nimport { getRgbaString, shadeRgbColor } fr"
},
{
"path": "src/components/Desktop/Toolbar/Button/ButtonRedo.vue",
"chars": 850,
"preview": "<script>\nimport { EventBus } from '@/events'\n\nimport Button from '@/components/Desktop/Toolbar/Button/Button.vue'\nimport"
},
{
"path": "src/components/Desktop/Toolbar/Button/ButtonUndo.vue",
"chars": 851,
"preview": "<script>\nimport { EventBus } from '@/events'\n\nimport Button from '@/components/Desktop/Toolbar/Button/Button.vue'\nimport"
},
{
"path": "src/components/Desktop/Toolbar/Item.vue",
"chars": 1469,
"preview": "<script>\nimport Rectangle from '@/classes/Rectangle'\n\nexport default {\n name: 'ToolbarItem',\n\n props: {\n tool: {\n "
},
{
"path": "src/components/Desktop/Toolbar/Slider/Slider.vue",
"chars": 2259,
"preview": "<template>\n <div\n class=\"btn btn--bare tool-slider pointer-area sm-pdg-- md-pdg- lg-pdg w-100\"\n :class=\"classes\"\n"
},
{
"path": "src/components/Desktop/Toolbar/Slider/SliderBrushHardness.vue",
"chars": 677,
"preview": "<script>\nimport { HARDNESS_MIN, HARDNESS_MAX } from '@/settings'\nimport threads from '@/store/vuetamin/threads'\n\nimport "
},
{
"path": "src/components/Desktop/Toolbar/Slider/SliderBrushOpacity.vue",
"chars": 817,
"preview": "<script>\nimport { OPACITY_MIN, OPACITY_MAX } from '@/settings'\nimport threads from '@/store/vuetamin/threads'\n\nimport Sl"
},
{
"path": "src/components/Desktop/Toolbar/Slider/SliderBrushRadius.vue",
"chars": 655,
"preview": "<script>\nimport { RADIUS_MIN, RADIUS_MAX } from '@/settings'\nimport threads from '@/store/vuetamin/threads'\n\nimport Slid"
},
{
"path": "src/components/Desktop/Toolbar/Slider/SliderDistance.vue",
"chars": 990,
"preview": "<script>\nimport threads from '@/store/vuetamin/threads'\n\nimport Slider from '@/components/Desktop/Toolbar/Slider/Slider."
},
{
"path": "src/components/Desktop/Toolbar/Slider/SliderLazyRadius.vue",
"chars": 696,
"preview": "<script>\nimport { LAZY_RADIUS_MIN, LAZY_RADIUS_MAX } from '@/settings'\nimport threads from '@/store/vuetamin/threads'\n\ni"
},
{
"path": "src/components/Desktop/Toolbar/Toolbar.vue",
"chars": 6373,
"preview": "<template>\n <div ref=\"toolbar\" class=\"toolbar\">\n <ul\n class=\"list-inline list-inline--tight list-inline--divide"
},
{
"path": "src/components/Desktop.vue",
"chars": 4679,
"preview": "<template>\n <div class=\"desktop relative\">\n <div class=\"desktop-container relative overlay material\">\n <transit"
},
{
"path": "src/components/Mobile/Controlling.vue",
"chars": 1115,
"preview": "<template>\n <transition name=\"fade\">\n <div class=\"controlling\">\n <TouchHandler />\n </div>\n </transition>\n</"
},
{
"path": "src/components/Mobile/Pairing.vue",
"chars": 4552,
"preview": "<template>\n <div class=\"mobile-pairing\">\n <div class=\"mobile-pairing__content relative pdgh\">\n <h1 class=\"text-"
},
{
"path": "src/components/Mobile/TouchHandler.vue",
"chars": 3233,
"preview": "<template>\n <div class=\"mobile-controller\">\n <div\n class=\"mobile-touch pdg\"\n @touchstart=\"handleMainTouchS"
},
{
"path": "src/components/Mobile.vue",
"chars": 1999,
"preview": "<template>\n <div class=\"mobile h-100\">\n <transition name=\"component-fade\">\n <component :is=\"visibleComponent\">\n"
},
{
"path": "src/events/index.js",
"chars": 56,
"preview": "import Vue from 'vue'\nexport const EventBus = new Vue()\n"
},
{
"path": "src/i18n.js",
"chars": 769,
"preview": "import { getLocale } from '@/tools/cookies'\n\nimport Vue from 'vue'\nimport VueI18n from 'vue-i18n'\n\nVue.use(VueI18n)\n\nfun"
},
{
"path": "src/locales/de.json",
"chars": 3286,
"preview": "{\n \"subtitle\": \"Telemalen mit deinem Telefon.\",\n \"desktop\": {\n \"lead\": \"Benutze dein Smartphone als Fernbedienung u"
},
{
"path": "src/locales/en.json",
"chars": 3132,
"preview": "{\n \"subtitle\": \"Draw remotely with your phone\",\n \"desktop\": {\n \"lead\": \"Visit drawmote.app on your phone, enter the"
},
{
"path": "src/main.js",
"chars": 1105,
"preview": "import 'es6-promise/auto'\nimport './assets/scss/main.scss'\n\nimport Vue from 'vue'\nimport App from './App.vue'\n\nimport Vu"
},
{
"path": "src/mixins/PointerEvents.js",
"chars": 2531,
"preview": "import { EventBus } from '@/events'\n\nexport default {\n name: 'PointerEvents',\n\n methods: {\n handleWheel(e) {\n "
},
{
"path": "src/plugins/GymoteRemote.js",
"chars": 124,
"preview": "import { GymoteRemote } from 'gymote'\n\nexport default {\n install(Vue) {\n Vue.prototype.$mote = new GymoteRemote()\n "
},
{
"path": "src/plugins/GymoteScreen.js",
"chars": 124,
"preview": "import { GymoteScreen } from 'gymote'\n\nexport default {\n install(Vue) {\n Vue.prototype.$mote = new GymoteScreen()\n "
},
{
"path": "src/plugins/PeerSox.js",
"chars": 276,
"preview": "import 'whatwg-fetch'\nimport PeerSox from 'peersox'\n\nexport default {\n install(Vue, { api, wss }) {\n Vue.prototype.$"
},
{
"path": "src/plugins/Sentry.js",
"chars": 2586,
"preview": "import * as Sentry from '@sentry/browser'\nimport * as Integrations from '@sentry/integrations'\n\nimport dependencies from"
},
{
"path": "src/plugins/Settings.js",
"chars": 131,
"preview": "export default {\n install(Vue) {\n Vue.prototype.$settings = {\n isPrerendering: window.__PRERENDERING === true\n "
},
{
"path": "src/plugins/Track.js",
"chars": 735,
"preview": "const DIMENSIONS = {\n mode: 1,\n supportsWebRTC: 2,\n supportsWebSocket: 3,\n version: 4\n}\n\nexport function trackEvent("
},
{
"path": "src/settings/index.js",
"chars": 1822,
"preview": "export const COLORS = [\n {\n name: 'red',\n hex: '#F06D31'\n },\n {\n name: 'blue',\n hex: '#48bec5'\n },\n {\n "
},
{
"path": "src/store/vuetamin/actions.js",
"chars": 395,
"preview": "import { setState } from '@/tools/cookies'\n\nexport function storeStateCookie({ data }, { noTimeout } = {}) {\n const tim"
},
{
"path": "src/store/vuetamin/data.js",
"chars": 1134,
"preview": "import { LazyBrush } from 'lazy-brush'\nimport { getState } from '@/tools/cookies'\nimport Rectangle from '@/classes/Recta"
},
{
"path": "src/store/vuetamin/index.js",
"chars": 192,
"preview": "import data from './data'\nimport state from './state'\nimport * as actions from './actions'\nimport * as mutations from '."
},
{
"path": "src/store/vuetamin/mutations.js",
"chars": 3216,
"preview": "import threads from './threads'\n\nexport function updatePointer(\n { data, trigger },\n { coordinates, both = false } = {"
},
{
"path": "src/store/vuetamin/state.js",
"chars": 655,
"preview": "/**\n * Returns the state from the Vuetamin data.\n *\n * @param {Object} data Vuetamin data.\n * @returns {Object} The stat"
},
{
"path": "src/store/vuetamin/threads.js",
"chars": 368,
"preview": "/**\n * Vuetamin thread names.\n */\nexport default {\n BRUSH: 'brush',\n POINT: 'point',\n SLIDE: 'slide',\n STATE: 'state"
},
{
"path": "src/store/vuex/index.js",
"chars": 1499,
"preview": "import Vue from 'vue'\nimport Vuex from 'vuex'\n\nVue.use(Vuex)\n\nexport default new Vuex.Store({\n state: {\n isSkipped: "
},
{
"path": "src/tools/animation/app.json",
"chars": 86948,
"preview": "{\n\t\"metadata\": {\n\t\t\"type\": \"App\"\n\t},\n\t\"project\": {\n\t\t\"shadows\": true,\n\t\t\"vr\": false\n\t},\n\t\"camera\": {\n\t\t\"metadata\": {\n\t\t\t"
},
{
"path": "src/tools/animation/debug.js",
"chars": 4714,
"preview": "import * as dat from 'dat.gui'\n// require('three/examples/js/controls/OrbitControls')\nimport { CAMERA, PHONE } from './k"
},
{
"path": "src/tools/animation/index.js",
"chars": 14574,
"preview": "import EventEmitter from 'eventemitter3'\nimport anime from 'animejs'\n\nimport { ANIMATION_SCREEN_VIEWPORT } from '@/setti"
},
{
"path": "src/tools/animation/keyframes.js",
"chars": 2873,
"preview": "export const CAMERA_MOBILE = [\n {\n offset: 0,\n options: {\n duration: 2000,\n delay: 0,\n elasticity:"
},
{
"path": "src/tools/animation/webglSupport.js",
"chars": 360,
"preview": "/**\n * @author alteredq / http://alteredqualia.com/\n * @author mr.doob / http://mrdoob.com/\n */\n\nexport default function"
},
{
"path": "src/tools/canvas.js",
"chars": 1462,
"preview": "import { BREAKPOINT_ANIMATION } from '@/settings'\n\n/**\n * Sets the given size and scaling of the canvases.\n *\n * @param "
},
{
"path": "src/tools/cookies.js",
"chars": 359,
"preview": "import Cookies from 'universal-cookie'\n\nconst cookie = new Cookies()\n\nexport function setState(state) {\n cookie.set('st"
},
{
"path": "src/tools/dependencies.js",
"chars": 203,
"preview": "// DEPENDENCIES is defined in vue.config.js as a webpack plugin and contains\n// version numbers for a few main dependenc"
},
{
"path": "src/tools/helpers.js",
"chars": 2656,
"preview": "export function getViewportSize() {\n return {\n width: Math.max(\n document.documentElement.clientWidth,\n wi"
},
{
"path": "vue.config.js",
"chars": 3325,
"preview": "const path = require('path')\nconst fs = require('fs')\nconst webpack = require('webpack')\n\nconst PrerenderSpaPlugin = req"
}
]
About this extraction
This page contains the full source code of the dulnan/drawmote GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 100 files (278.6 KB), approximately 98.2k tokens, and a symbol index with 182 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.