From 71629ad06fbaa4a0de950f790d35d39134a56212 Mon Sep 17 00:00:00 2001 From: Toby Milner-Gulland <49916680+tmgulland@users.noreply.github.com> Date: Mon, 13 Apr 2020 12:13:56 +0100 Subject: [PATCH] continuing to sketch out node/connection interface --- app/package.json | 3 +- app/src/components/ControlsLayer.vue | 12 +- app/src/components/ListLayer.vue | 2 +- app/src/components/Navigation.vue | 12 +- app/src/components/NodesLayer.vue | 16 +- app/src/components/OnBoard.vue | 19 +- app/src/components/OtherListlayer.vue | 6 +- app/src/components/OtherNodeslayer.vue | 30 ++- app/src/experimental/ModeToolbar.vue | 72 ++++++ app/src/experimental/PanZoomContainer.vue | 243 ++++++------------ app/src/experimental/ViewToolbar.vue | 47 ++++ app/src/experimental/constants/color.js | 56 ++++ app/src/experimental/constants/sizes.js | 15 ++ app/src/experimental/constants/window.js | 5 + app/src/experimental/editorStore.js | 13 - app/src/experimental/geometry.js | 25 +- app/src/experimental/icons/Icon.vue | 60 +++++ .../experimental/icons/library/addNode.vue | 12 + .../experimental/icons/library/connection.vue | 22 ++ app/src/experimental/icons/library/draw.vue | 10 + app/src/experimental/icons/library/idea.vue | 22 ++ app/src/experimental/icons/library/index.js | 12 + .../experimental/icons/library/magnify.vue | 10 + app/src/experimental/icons/library/minus.vue | 3 + app/src/experimental/icons/library/move.vue | 10 + app/src/experimental/icons/library/person.vue | 14 + app/src/experimental/icons/library/plus.vue | 6 + app/src/experimental/icons/library/select.vue | 8 + .../experimental/makePassiveEventOption.js | 26 +- app/src/experimental/modes.js | 54 ++++ app/src/experimental/uiStore.js | 55 ++++ app/src/experimental/uiText.js | 22 ++ app/src/experimental/utils/canvas.js | 79 ++++++ app/src/experimental/utils/dom.js | 79 ++++++ app/src/experimental/utils/helpers.js | 58 +++++ app/src/experimental/utils/nodes.js | 235 +++++++++++++++++ app/src/experimental/utils/numbers.js | 65 +++++ app/src/experimental/utils/parse.js | 31 +++ app/src/experimental/utils/svg.js | 40 +++ app/src/main.js | 5 +- app/src/store/index.js | 78 +++--- app/src/views/Sandbox.vue | 61 +++-- 42 files changed, 1359 insertions(+), 294 deletions(-) create mode 100644 app/src/experimental/ModeToolbar.vue create mode 100644 app/src/experimental/ViewToolbar.vue create mode 100644 app/src/experimental/constants/color.js create mode 100644 app/src/experimental/constants/sizes.js create mode 100644 app/src/experimental/constants/window.js delete mode 100644 app/src/experimental/editorStore.js create mode 100644 app/src/experimental/icons/Icon.vue create mode 100644 app/src/experimental/icons/library/addNode.vue create mode 100644 app/src/experimental/icons/library/connection.vue create mode 100644 app/src/experimental/icons/library/draw.vue create mode 100644 app/src/experimental/icons/library/idea.vue create mode 100644 app/src/experimental/icons/library/index.js create mode 100644 app/src/experimental/icons/library/magnify.vue create mode 100644 app/src/experimental/icons/library/minus.vue create mode 100644 app/src/experimental/icons/library/move.vue create mode 100644 app/src/experimental/icons/library/person.vue create mode 100644 app/src/experimental/icons/library/plus.vue create mode 100644 app/src/experimental/icons/library/select.vue create mode 100644 app/src/experimental/modes.js create mode 100644 app/src/experimental/uiStore.js create mode 100644 app/src/experimental/uiText.js create mode 100644 app/src/experimental/utils/canvas.js create mode 100644 app/src/experimental/utils/dom.js create mode 100644 app/src/experimental/utils/helpers.js create mode 100644 app/src/experimental/utils/nodes.js create mode 100644 app/src/experimental/utils/numbers.js create mode 100644 app/src/experimental/utils/parse.js create mode 100644 app/src/experimental/utils/svg.js diff --git a/app/package.json b/app/package.json index 966a1bc..3111bc2 100644 --- a/app/package.json +++ b/app/package.json @@ -5,7 +5,8 @@ "scripts": { "serve": "vue-cli-service serve", "build": "vue-cli-service build", - "lint": "vue-cli-service lint" + "lint": "vue-cli-service lint", + "lint:fix": "vue-cli-service lint --fix" }, "dependencies": { "core-js": "^3.6.4", diff --git a/app/src/components/ControlsLayer.vue b/app/src/components/ControlsLayer.vue index b2ce6f1..7401fae 100644 --- a/app/src/components/ControlsLayer.vue +++ b/app/src/components/ControlsLayer.vue @@ -1,9 +1,15 @@ <template> <div class="controls"> <div class="btn-row"> - <BaseButton buttonClass="action" @click="addNode()">Create Node</BaseButton> - <BaseButton buttonClass="action" @click="listView()">Switch View</BaseButton> - <BaseButton buttonClass="action" @click="removeLocal()">Join another microcosm</BaseButton> + <BaseButton buttonClass="action" @click="addNode()" + >Create Node</BaseButton + > + <BaseButton buttonClass="action" @click="listView()" + >Switch View</BaseButton + > + <BaseButton buttonClass="action" @click="removeLocal()" + >Join another microcosm</BaseButton + > <!-- <BaseButton @click="exportStorage()">Export my contributions</BaseButton> <BaseButton buttonClass="danger" v-on:click="deleteClient"> Delete my contributions (inc. attachments) permanently diff --git a/app/src/components/ListLayer.vue b/app/src/components/ListLayer.vue index 8dc4ef4..91e4de6 100644 --- a/app/src/components/ListLayer.vue +++ b/app/src/components/ListLayer.vue @@ -36,4 +36,4 @@ export default { li { margin-bottom: -15px; } -</style> \ No newline at end of file +</style> diff --git a/app/src/components/Navigation.vue b/app/src/components/Navigation.vue index b607757..a56b5c2 100644 --- a/app/src/components/Navigation.vue +++ b/app/src/components/Navigation.vue @@ -1,6 +1,8 @@ <template> - <nav> - <router-link v-for="route in routes" :key="route.name" :to="route.path">{{route.name}}</router-link> + <nav class="navigation"> + <router-link v-for="route in routes" :key="route.name" :to="route.path">{{ + route.name + }}</router-link> </nav> </template> @@ -16,8 +18,8 @@ export default { } </script> -<style> -nav { +<style scoped> +nav.navigation { position: fixed; top: 0; left: 0; @@ -41,4 +43,4 @@ nav a { nav a.router-link-exact-active { color: #42b983; } -</style> \ No newline at end of file +</style> diff --git a/app/src/components/NodesLayer.vue b/app/src/components/NodesLayer.vue index 3fcadd9..532d52d 100644 --- a/app/src/components/NodesLayer.vue +++ b/app/src/components/NodesLayer.vue @@ -37,18 +37,26 @@ </div> <!-- FIXME: What is this doing below now ? Looks old --> <div v-else> - <p :id="nodeid" :inner-html.prop="nodetext | marked">{{ nodeid }}</p> + <p :id="nodeid" :inner-html.prop="nodetext | marked"> + {{ nodeid }} + </p> </div> <h3>Reactions</h3> <div v-for="(emojis, index) in configEmoji" :key="index"> - <p class="allemoji" v-if="nodeid == emojis.node_id">{{ emojis.emoji_text }}</p> + <p class="allemoji" v-if="nodeid == emojis.node_id"> + {{ emojis.emoji_text }} + </p> </div> <p class="info">*markdown supported</p> <div class="btn-row"> - <BaseButton buttonClass="danger" @click="deleteFlag()">Delete</BaseButton> - <BaseButton class="read" buttonClass="action" @click="readFlag()">{{ mode }}</BaseButton> + <BaseButton buttonClass="danger" @click="deleteFlag()" + >Delete</BaseButton + > + <BaseButton class="read" buttonClass="action" @click="readFlag()">{{ + mode + }}</BaseButton> </div> </form> </vue-draggable-resizable> diff --git a/app/src/components/OnBoard.vue b/app/src/components/OnBoard.vue index 2aba6ed..d5aa8b0 100644 --- a/app/src/components/OnBoard.vue +++ b/app/src/components/OnBoard.vue @@ -4,9 +4,7 @@ nodenogg.in is a <span>work in progress</span> collaborative co-creation research and design thinking tool, read more details and links in the - <a - href="/about" - >about</a> section. + <a href="/about">about</a> section. </p> <form v-show="parta" onsubmit="return false;"> @@ -28,7 +26,9 @@ autofocus v-on:keyup.enter="createMicrocosm(), setFocus()" /> - <BaseButton buttonClass="onboard" @click="createMicrocosm(), setFocus()">+</BaseButton> + <BaseButton buttonClass="onboard" @click="createMicrocosm(), setFocus()" + >+</BaseButton + > </form> <form v-show="partb" onsubmit="return false;"> @@ -47,11 +47,18 @@ ref="objectname" v-on:keyup.enter="setClient(), setFocusTwo()" /> - <BaseButton buttonClass="onboard" @click="setClient(), setFocusTwo()">+</BaseButton> + <BaseButton buttonClass="onboard" @click="setClient(), setFocusTwo()" + >+</BaseButton + > </form> <form v-show="partc"> - <input class="start" type="text" v-on:keyup.enter="letsGo()" ref="objectnametwo" /> + <input + class="start" + type="text" + v-on:keyup.enter="letsGo()" + ref="objectnametwo" + /> <h2>3</h2> <h3>start</h3> <BaseButton buttonClass="onboard" @click="letsGo()">+</BaseButton> diff --git a/app/src/components/OtherListlayer.vue b/app/src/components/OtherListlayer.vue index 76e2f3d..c2c267f 100644 --- a/app/src/components/OtherListlayer.vue +++ b/app/src/components/OtherListlayer.vue @@ -5,7 +5,9 @@ class="dataeach" v-if="nodeid == value.node_id" :inner-html.prop="value.node_text | marked" - >{{ nodeid }}</li> + > + {{ nodeid }} + </li> </ul> </div> </template> @@ -36,4 +38,4 @@ export default { li { margin-bottom: -15px; } -</style> \ No newline at end of file +</style> diff --git a/app/src/components/OtherNodeslayer.vue b/app/src/components/OtherNodeslayer.vue index 3d056a5..c874492 100644 --- a/app/src/components/OtherNodeslayer.vue +++ b/app/src/components/OtherNodeslayer.vue @@ -18,13 +18,20 @@ <p :id="nodeid" :inner-html.prop="nodetext | marked">{{ nodeid }}</p> <h3>Reactions</h3> <div v-for="(emojis, index) in configEmoji" :key="index"> - <p class="allemoji" v-if="nodeid == emojis.node_id">{{ emojis.emoji_text }}</p> + <p class="allemoji" v-if="nodeid == emojis.node_id"> + {{ emojis.emoji_text }} + </p> </div> <div class="react" v-if="nodeid != undefined"> <h2>React</h2> <div class="eeee"> <input :value="nodeid" name="id" readonly hidden /> - <input id="emojifield" class="regular-input" v-model="input" readonly /> + <input + id="emojifield" + class="regular-input" + v-model="input" + readonly + /> <emoji-picker @emoji="append" :search="search"> <div @@ -33,7 +40,12 @@ slot-scope="{ events: { click: clickEvent } }" @click.stop="clickEvent" > - <svg height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"> + <svg + height="24" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > <path d="M0 0h24v24H0z" fill="none" /> <path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm3.5-9c.83 0 1.5-.67 1.5-1.5S16.33 8 15.5 8 14 8.67 14 9.5s.67 1.5 1.5 1.5zm-7 0c.83 0 1.5-.67 1.5-1.5S9.33 8 8.5 8 7 8.67 7 9.5 7.67 11 8.5 11zm3.5 6.5c2.33 0 4.31-1.46 5.11-3.5H6.89c.8 2.04 2.78 3.5 5.11 3.5z" @@ -49,7 +61,10 @@ <input type="text" v-model="search" v-focus /> </div> <div> - <div v-for="(emojiGroup, category) in emojis" :key="category"> + <div + v-for="(emojiGroup, category) in emojis" + :key="category" + > <h5>{{ category }}</h5> <div class="emojis"> <span @@ -57,14 +72,17 @@ :key="emojiName" @click="insert(emoji)" :title="emojiName" - >{{ emoji }}</span> + >{{ emoji }}</span + > </div> </div> </div> </div> </div> </emoji-picker> - <BaseButton buttonClass="action" @click="sentReact()">Send Reaction</BaseButton> + <BaseButton buttonClass="action" @click="sentReact()" + >Send Reaction</BaseButton + > </div> </div> </vue-draggable-resizable> diff --git a/app/src/experimental/ModeToolbar.vue b/app/src/experimental/ModeToolbar.vue new file mode 100644 index 0000000..98484e3 --- /dev/null +++ b/app/src/experimental/ModeToolbar.vue @@ -0,0 +1,72 @@ +<template> + <nav> + <button + v-for="mode in allModes" + v-on:click="() => setMode(mode.name)" + v-bind:key="mode.name" + v-bind:class="isActive(mode) ? 'active' : 'inactive'" + > + <Icon v-bind:type="mode.icon" v-bind:theme="isActive(mode) ? 'light' : 'dark'" /> + </button> + </nav> +</template> + +<script> +import { mapState, mapGetters } from 'vuex' + +import * as allModes from '@/experimental/modes' + +export default { + computed: { + ...mapState({ + mode: state => state.ui.mode + }), + ...mapGetters({ + activeMode: 'ui/activeMode' + }) + }, + methods: { + setMode(mode) { + this.$store.commit('ui/setMode', mode) + }, + isActive(mode) { + return this.mode === mode.name + } + }, + data() { + return { + allModes + } + } +} +</script> + +<style scoped> +nav { + position: absolute; + bottom: 20px; + left: 20px; + display: flex; + align-items: center; + justify-content: flex-start; +} +button { + border: none; + width: 50px; + height: 50px; + padding: 0; + margin: 0; + background: white; + border-radius: 25px; + display: flex; + align-items: center; + justify-content: center; + color: white; + outline: none; + box-shadow: 0px 0px 0px 2px rgba(0, 0, 0, 0.1); + margin-right: 10px; +} +button.active { + background: rgb(30, 30, 30); +} +</style> diff --git a/app/src/experimental/PanZoomContainer.vue b/app/src/experimental/PanZoomContainer.vue index 70995d4..a5d4be7 100644 --- a/app/src/experimental/PanZoomContainer.vue +++ b/app/src/experimental/PanZoomContainer.vue @@ -4,18 +4,14 @@ width: 100%; position: relative; overflow: hidden; - touch-action: none; - -ms-touch-action: none; - cursor: all-scroll; - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - border: 5px solid red; + background-color: rgb(245, 245, 245); } .inner { width: 2000px; height: 2000px; - position: relative; + position: absolute; + top: 0; + left: 0; transform-origin: 0 0; background-size: 40px 40px; background-color: rgb(245, 245, 245); @@ -25,46 +21,40 @@ rgba(0, 0, 0, 0) 1px ); } -.indicator { - position: absolute; - z-index: 4; - top: 20px; - right: 20px; - font-size: 12px; - color: white; - padding: 10px; - border-radius: 3px; - background: rgb(50, 50, 50); +.inner.active { + touch-action: none; + -ms-touch-action: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; } </style> <template> <div - ref="vueref0" + ref="container" class="outer" - v-bind:style="containerStyle" - v-on:click.capture=" - { - handleEventCapture - } - " - v-on:touchend.capture=" - { - handleEventCapture - } - " + v-on:wheel.capture="onWheel" + v-on:touchstart.passive="onTouchStart" + v-on:mousedown="onMouseDown" + v-on:touchmove.passive="onTouchMove" + v-on:mousemove="onMouseMove" + v-on:touchend.passive.capture="onTouchEnd" + v-on:mouseup.passive.capture="onMouseUp" + v-on:click.capture="handleEventCapture" + v-on:touchend.capture="handleEventCapture" > <div - class="inner" + ref="innerContainer" + v-bind:class="{ inner: true, active: pan }" v-bind:style="{ width: `${width}px`, height: `${height}px`, transform: `translate(${translation.x}px, ${translation.y}px) scale(${scale})` }" > - <slot></slot> + <slot /> </div> - <div class="indicator">{{ scalePercentage }}</div> </div> </template> <script> @@ -75,57 +65,33 @@ import { touchDistance, coordChange } from './geometry' -// import makePassiveEventOption from './makePassiveEventOption' - +// import { mapRange } from '@/experimental/utils/numbers' export default { name: 'map-interaction', data() { - const { - scale, - defaultScale, - translation, - defaultTranslation, - minScale, - maxScale - } = this - - let desiredScale - if (scale != undefined) { - desiredScale = scale - } else if (defaultScale != undefined) { - desiredScale = defaultScale - } else { - desiredScale = 1 - } - return { - containerStyle: { - height: '100%', - width: '100%', - position: 'relative', - touchAction: 'none' - }, - scale: clamp(minScale, desiredScale, maxScale), - translation: translation || defaultTranslation || { x: 0, y: 0 }, - shouldPreventTouchEndDefault: false, - x: 0, - y: 0 + shouldPreventTouchEndDefault: false } }, props: { - defaultScale: Number, - defaultTranslation: Object, - disableZoom: Boolean, - disablePan: Boolean, - onChange: Function, translationBounds: { type: Object, default() { return { xMin: -500, xMax: 500, yMin: -500, yMax: 500 } } }, + translation: Object, + scale: Number, width: Number, height: Number, + pan: { + type: Boolean, + default: true + }, + zoom: { + type: Boolean, + default: true + }, minScale: { type: Number, default: 0.3 @@ -135,37 +101,26 @@ export default { default: 2.0 } }, - computed: { - scalePercentage() { - return `${(this.scale * 100).toFixed(0)}%` - } - }, methods: { handleEventCapture(e) { if (this.shouldPreventTouchEndDefault) { e.preventDefault() this.shouldPreventTouchEndDefault = false } - }, - defaultProps() { - return { - minScale: 0.05, - maxScale: 3, - translationBounds: {}, - disableZoom: false, - disablePan: false - } - }, - updateParent() { - if (!this.onChange) { - return - } - const { scale, translation } = this - this.onChange({ - scale, - translation - }) + const rect = this.$refs.container.getBoundingClientRect() + + const x = e.clientX - parseInt(rect.left, 10) + const y = e.clientY - parseInt(rect.top, 10) + + const result = { + relative: { x, y }, + canvas: { + x: parseInt((x + -this.translation.x) / this.scale, 10), + y: parseInt((y + -this.translation.y) / this.scale, 10) + } + } + console.log(result) }, onMouseDown(e) { e.preventDefault() @@ -182,7 +137,7 @@ export default { this.setPointerState(e.touches) }, onMouseMove(e) { - if (!this.startPointerInfo || this.disablePan) { + if (!this.startPointerInfo || !this.pan) { return } @@ -195,17 +150,12 @@ export default { } e.preventDefault() - const { disablePan, disableZoom } = this const isPinchAction = e.touches.length == 2 && this.startPointerInfo.pointers.length > 1 - if (isPinchAction && !disableZoom) { + if (isPinchAction && this.zoom) { this.scaleFromMultiTouch(e) - } else if ( - e.touches.length === 1 && - this.startPointerInfo && - !disablePan - ) { + } else if (e.touches.length === 1 && this.startPointerInfo && this.pan) { this.onDrag(e.touches[0]) } }, @@ -218,12 +168,15 @@ export default { x: translation.x + dragX, y: translation.y + dragY } - this.translation = this.clampTranslation(newTranslation) + + this.$store.commit( + 'ui/setTranslation', + this.clampTranslation(newTranslation) + ) this.shouldPreventTouchEndDefault = true - this.$nextTick(() => this.updateParent()) }, onWheel(e) { - if (this.disableZoom) { + if (!this.zoom) { return } @@ -266,7 +219,7 @@ export default { } }, translatedOrigin(translation = this.translation) { - const clientOffset = this.getContainerNode().getBoundingClientRect() + const clientOffset = this.$refs.container.getBoundingClientRect() return { x: clientOffset.left + translation.x, y: clientOffset.top + translation.y @@ -290,9 +243,12 @@ export default { x: translation.x - focalPtDelta.x, y: translation.y - focalPtDelta.y } - this.scale = newScale - this.translation = this.clampTranslation(newTranslation) - this.$nextTick(() => this.updateParent()) + this.$store.commit('ui/setScale', newScale) + + this.$store.commit( + 'ui/setTranslation', + this.clampTranslation(newTranslation) + ) }, scaleFromMultiTouch(e) { const startTouches = this.startPointerInfo.pointers @@ -330,9 +286,13 @@ export default { x: this.startPointerInfo.translation.x - focalPtDelta.x + dragDelta.x, y: this.startPointerInfo.translation.y - focalPtDelta.y + dragDelta.y } - this.scale = newScale - this.translation = this.clampTranslation(newTranslation) - this.$nextTick(() => this.updateParent()) + + this.$store.commit('ui/setScale', newScale) + + this.$store.commit( + 'ui/setTranslation', + this.clampTranslation(newTranslation) + ) }, discreteScaleStepSize() { const { minScale, maxScale } = this @@ -343,7 +303,7 @@ export default { const targetScale = this.scale + delta const { minScale, maxScale } = this const scale = clamp(minScale, targetScale, maxScale) - const rect = this.getContainerNode().getBoundingClientRect() + const rect = this.$refs.container.getBoundingClientRect() const x = rect.left + rect.width / 2 const y = rect.top + rect.height / 2 const focalPoint = this.clientPosToTranslatedPos({ @@ -351,66 +311,13 @@ export default { y }) this.scaleFromPoint(scale, focalPoint) - }, - getContainerNode() { - return this.containerNode } }, - created() { - this.startPointerInfo = undefined - }, mounted() { - this.containerNode = this.$refs.vueref0 - // const passiveOption = makePassiveEventOption(false) - const passiveOption = { passive: false } - this.getContainerNode().addEventListener( - 'wheel', - this.onWheel, - passiveOption - ) - - /* - Setup events for the gesture lifecycle: start, move, end touch - */ - // start gesture - this.getContainerNode().addEventListener( - 'touchstart', - this.onTouchStart, - passiveOption - ) - this.getContainerNode().addEventListener( - 'mousedown', - this.onMouseDown, - passiveOption - ) - // move gesture - window.addEventListener('touchmove', this.onTouchMove, passiveOption) - window.addEventListener('mousemove', this.onMouseMove, passiveOption) - // end gesture - const touchAndMouseEndOptions = { - capture: true, - ...passiveOption - } - window.addEventListener( - 'touchend', - this.onTouchEnd, - touchAndMouseEndOptions - ) - window.addEventListener('mouseup', this.onMouseUp, touchAndMouseEndOptions) + this.containerNode = this.$refs.container }, - destroyed() { - this.getContainerNode().removeEventListener('wheel', this.onWheel) - // Remove touch events - this.getContainerNode().removeEventListener('touchstart', this.onTouchStart) - window.removeEventListener('touchmove', this.onTouchMove) - window.removeEventListener('touchend', this.onTouchEnd) - // Remove mouse events - this.getContainerNode().removeEventListener('mousedown', this.onMouseDown) - window.removeEventListener('mousemove', this.onMouseMove) - window.removeEventListener('mouseup', this.onMouseUp) - }, - updated() { - this.containerNode = this.$refs.vueref0 + created() { + this.startPointerInfo = undefined } } </script> diff --git a/app/src/experimental/ViewToolbar.vue b/app/src/experimental/ViewToolbar.vue new file mode 100644 index 0000000..3c2ebc6 --- /dev/null +++ b/app/src/experimental/ViewToolbar.vue @@ -0,0 +1,47 @@ +<template> + <button v-on:click="resetScale"> + <Icon type="magnify" v-bind:size="20" theme="dark" /> + {{ scalePercentage }} + </button> +</template> + +<script> +import { mapGetters } from 'vuex' +export default { + computed: { + ...mapGetters({ + scalePercentage: 'ui/scalePercentage', + isScaled: 'ui/isScaled', + isTranslated: 'ui/isTranslated' + }) + }, + methods: { + resetScale() { + this.$store.commit('ui/setScale', 1.0) + }, + resetTranslation() { + this.$store.commit('ui/setTranslation', { x: 0, y: 0 }) + } + } +} +</script> + +<style scoped> +button { + outline: none; + position: absolute; + bottom: 20px; + right: 20px; + height: 40px; + width: 105px; + border-radius: 20px; + padding: 0 15px 0 10px; + display: flex; + align-items: center; + justify-content: space-between; + box-shadow: 0px 0px 0px 2px rgba(0, 0, 0, 0.1); + font-size: 14px; + border: none; + outline: none; +} +</style> diff --git a/app/src/experimental/constants/color.js b/app/src/experimental/constants/color.js new file mode 100644 index 0000000..f40ba96 --- /dev/null +++ b/app/src/experimental/constants/color.js @@ -0,0 +1,56 @@ +export const palette = { + coral: { + light: '#FFD8E1', + dark: '#F56789' + }, + lime: { + light: '#EEFFBC', + dark: '#B9DF4E' + }, + blue: { + light: '#CDEAFF', + dark: '#4EB4FF' + }, + orange: { + light: '#FFD8CC', + dark: '#FF8762' + }, + purple: { + light: '#DAD9FE', + dark: '#8A80F6' + }, + pink: { + light: '#FBE9FF', + dark: '#E47EFD' + }, + yellow: { + light: '#FFF3CA', + dark: '#FFD84F' + }, + mono: { + light: '#FFFFFF', + dark: '#A3A3A3' + } +} + +/** + * Selects a HEX color value depending on a supplied key reference, + * providing a default (mono) value if none is found. + * + * @param {string} color - Target palette (from above) + * @param {string} type - Target color type (light or dark) + * @returns {string} hex color value + * + * */ +export const getPalette = (color = 'mono', type) => + palette[color] ? palette[color][type] : palette.mono[type] + +export const paletteArray = () => { + const result = [] + Object.keys(palette).forEach(p => { + Object.keys(palette[p]).forEach(h => { + result.push({ name: `${p}-${h}`, value: palette[p][h] }) + }) + }) + return result +} diff --git a/app/src/experimental/constants/sizes.js b/app/src/experimental/constants/sizes.js new file mode 100644 index 0000000..38422e8 --- /dev/null +++ b/app/src/experimental/constants/sizes.js @@ -0,0 +1,15 @@ +export const nodeSizes = { + min: { + width: 100, + height: 100 + }, + max: { + width: 600, + height: 600 + } +} + +export const defaultNode = { + width: 200, + height: 150 +} diff --git a/app/src/experimental/constants/window.js b/app/src/experimental/constants/window.js new file mode 100644 index 0000000..2915737 --- /dev/null +++ b/app/src/experimental/constants/window.js @@ -0,0 +1,5 @@ +import { isClient } from '@/experimental/utils/helpers' + +export const width = isClient ? window.innerWidth : 1000 + +export const height = isClient ? window.innerHeight : 1000 diff --git a/app/src/experimental/editorStore.js b/app/src/experimental/editorStore.js deleted file mode 100644 index b162000..0000000 --- a/app/src/experimental/editorStore.js +++ /dev/null @@ -1,13 +0,0 @@ -const store = new Vuex.Store({ - namespaced: true, - - state: { - count: 1 - }, - mutations: { - increment (state) { - // mutate state - state.count++ - } - } -}) diff --git a/app/src/experimental/geometry.js b/app/src/experimental/geometry.js index 0bb98d1..45ab1e0 100644 --- a/app/src/experimental/geometry.js +++ b/app/src/experimental/geometry.js @@ -1,31 +1,30 @@ export const clamp = (min, value, max) => { - return Math.max(min, Math.min(value, max)); + return Math.max(min, Math.min(value, max)) } export const distance = (p1, p2) => { - const dx = p1.x - p2.x; - const dy = p1.y - p2.y; - return Math.sqrt(Math.pow(dx, 2) + Math.pow(dy, 2)); + const dx = p1.x - p2.x + const dy = p1.y - p2.y + return Math.sqrt(Math.pow(dx, 2) + Math.pow(dy, 2)) } export const midpoint = (p1, p2) => { return { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 - }; + } } -export const touchPt = (touch) => { - return { x: touch.clientX, y: touch.clientY }; +export const touchPt = touch => { + return { x: touch.clientX, y: touch.clientY } } export const touchDistance = (t0, t1) => { - const p0 = touchPt(t0); - const p1 = touchPt(t1); - return distance(p0, p1); + const p0 = touchPt(t0) + const p1 = touchPt(t1) + return distance(p0, p1) } - export const coordChange = (coordinate, scaleRatio) => { - return (scaleRatio * coordinate) - coordinate; -}; + return scaleRatio * coordinate - coordinate +} diff --git a/app/src/experimental/icons/Icon.vue b/app/src/experimental/icons/Icon.vue new file mode 100644 index 0000000..230149c --- /dev/null +++ b/app/src/experimental/icons/Icon.vue @@ -0,0 +1,60 @@ +<template> + <svg + v-bind:width="size" + v-bind:height="size" + v-bind:class="theme" + viewBox="0 0 50 50" + fill="none" + xmlns="http://www.w3.org/2000/svg" + > + <component v-if="hasComponent" v-bind:is="iconComponent" /> + </svg> +</template> + +<script> +import * as iconLibrary from '@/experimental/icons/library' + +export default { + name: 'Icon', + props: { + size: { + type: Number, + default: 40 + }, + theme: { + type: String, + default: 'light' + }, + type: String + }, + + computed: { + style() { + return { + width: `${this.size}px`, + height: `${this.size}px` + } + }, + hasComponent() { + return !!iconLibrary[this.type] + }, + iconComponent() { + return iconLibrary[this.type] + } + } +} +</script> + +<style scoped> +svg { + width: 40px; + height: 40px; +} + +svg > * { + fill: black; +} +svg.light > * { + fill: white; +} +</style> diff --git a/app/src/experimental/icons/library/addNode.vue b/app/src/experimental/icons/library/addNode.vue new file mode 100644 index 0000000..fa6ea56 --- /dev/null +++ b/app/src/experimental/icons/library/addNode.vue @@ -0,0 +1,12 @@ +<template> + <g> + <path + fill-rule="evenodd" + clip-rule="evenodd" + d="M37 15H17.1154V30.8636H37V15ZM15.1154 13V32.8636H39V13H15.1154Z" + /> + <path fill-rule="evenodd" clip-rule="evenodd" d="M26 27L26 19L28 19L28 27L26 27Z" /> + <path fill-rule="evenodd" clip-rule="evenodd" d="M31 24H23V22H31V24Z" /> + <path fill-rule="evenodd" clip-rule="evenodd" d="M16 16.1364H12V36H35.8846V32H16V16.1364Z" /> + </g> +</template> \ No newline at end of file diff --git a/app/src/experimental/icons/library/connection.vue b/app/src/experimental/icons/library/connection.vue new file mode 100644 index 0000000..b1e0253 --- /dev/null +++ b/app/src/experimental/icons/library/connection.vue @@ -0,0 +1,22 @@ +<template> + <g> + <path + fill-rule="evenodd" + clip-rule="evenodd" + d="M28.2955 18.1664C27.2995 19.3045 26.6217 21.2073 26.1616 24.9098C25.8433 27.4712 25.1242 29.9039 23.7129 31.8781C22.2843 33.8766 20.1924 35.3393 17.2696 36.011L16.8217 34.0618C19.2903 33.4945 20.9538 32.2987 22.0859 30.715C23.2352 29.1073 23.8822 27.0345 24.1769 24.6631C24.6433 20.9095 25.3698 18.4727 26.7905 16.8493C28.2298 15.2046 30.2389 14.5616 32.7304 13.989L33.1783 15.9382C30.6931 16.5093 29.2729 17.0496 28.2955 18.1664Z" + /> + <path + fill-rule="evenodd" + clip-rule="evenodd" + d="M35.4302 12.3426C34.3537 12.5899 33.6816 13.6632 33.9289 14.7397C34.1763 15.8162 35.2495 16.4883 36.3261 16.241C37.4026 15.9936 38.0747 14.9204 37.8273 13.8439C37.58 12.7673 36.5067 12.0952 35.4302 12.3426ZM31.9797 15.1876C31.485 13.0346 32.8293 10.8881 34.9823 10.3934C37.1353 9.89862 39.2818 11.2429 39.7765 13.3959C40.2713 15.549 38.927 17.6954 36.774 18.1902C34.6209 18.6849 32.4745 17.3406 31.9797 15.1876Z" + /> + <path + d="M17.0457 35.0364C17.4167 36.6511 16.4085 38.261 14.7937 38.632C13.179 39.0031 11.5691 37.9949 11.1981 36.3801C10.827 34.7653 11.8352 33.1555 13.45 32.7844C15.0648 32.4134 16.6746 33.4216 17.0457 35.0364Z" + /> + <path + fill-rule="evenodd" + clip-rule="evenodd" + d="M13.618 33.5154C12.4069 33.7937 11.6507 35.0011 11.929 36.2121C12.2073 37.4232 13.4147 38.1794 14.6258 37.9011C15.8368 37.6228 16.593 36.4154 16.3147 35.2043C16.0364 33.9933 14.829 33.2371 13.618 33.5154ZM10.4671 36.5481C10.0033 34.5296 11.2636 32.5173 13.282 32.0535C15.3005 31.5897 17.3128 32.8499 17.7766 34.8684C18.2404 36.8869 16.9802 38.8991 14.9617 39.363C12.9432 39.8268 10.9309 38.5665 10.4671 36.5481Z" + /> + </g> +</template> diff --git a/app/src/experimental/icons/library/draw.vue b/app/src/experimental/icons/library/draw.vue new file mode 100644 index 0000000..a7e806d --- /dev/null +++ b/app/src/experimental/icons/library/draw.vue @@ -0,0 +1,10 @@ +<template> + <g> + <path + fill-rule="evenodd" + clip-rule="evenodd" + d="M32.8995 13.9289L15.8284 31L19.9706 35.1421L37.0416 18.0711L32.8995 13.9289ZM13 31L19.9706 37.9706L39.8701 18.0711L32.8995 11.1005L13 31Z" + /> + <path d="M13 38V31L20 38H13Z" /> + </g> +</template> diff --git a/app/src/experimental/icons/library/idea.vue b/app/src/experimental/icons/library/idea.vue new file mode 100644 index 0000000..85778e7 --- /dev/null +++ b/app/src/experimental/icons/library/idea.vue @@ -0,0 +1,22 @@ +<template> + <g> + <path + fill-rule="evenodd" + clip-rule="evenodd" + d="M28.4097 31.6851L29.2655 31.0882C30.9991 29.879 32.1266 27.8762 32.1266 25.6105C32.1266 21.9253 29.1392 18.9379 25.454 18.9379C21.7689 18.9379 18.7815 21.9253 18.7815 25.6105C18.7815 27.8762 19.9089 29.8789 21.6424 31.0881L22.4982 31.6851V36H28.4097V31.6851ZM30.4097 32.7286C32.6565 31.1614 34.1266 28.5576 34.1266 25.6105C34.1266 20.8207 30.2437 16.9379 25.454 16.9379C20.6643 16.9379 16.7815 20.8207 16.7815 25.6105C16.7815 28.5575 18.2515 31.1613 20.4982 32.7285V38H30.4097V32.7286Z" + /> + <path fill-rule="evenodd" clip-rule="evenodd" d="M10 25H15V27H10V25Z" /> + <path fill-rule="evenodd" clip-rule="evenodd" d="M36 25H41V27H36V25Z" /> + <path fill-rule="evenodd" clip-rule="evenodd" d="M26 10V15H24V10H26Z" /> + <path + fill-rule="evenodd" + clip-rule="evenodd" + d="M37.9 15.0436L34.8338 18.1098L33.4196 16.6956L36.4858 13.6294L37.9 15.0436Z" + /> + <path + fill-rule="evenodd" + clip-rule="evenodd" + d="M14.0196 13.6294L17.0858 16.6956L15.6716 18.1098L12.6054 15.0436L14.0196 13.6294Z" + /> + </g> +</template> \ No newline at end of file diff --git a/app/src/experimental/icons/library/index.js b/app/src/experimental/icons/library/index.js new file mode 100644 index 0000000..18ae6eb --- /dev/null +++ b/app/src/experimental/icons/library/index.js @@ -0,0 +1,12 @@ +import addNode from './addNode' +import connection from './connection' +import draw from './draw' +import idea from './idea' +import magnify from './magnify' +import minus from './minus' +import move from './move' +import person from './person' +import plus from './plus' +import select from './select' + +export { addNode, connection, draw, idea, magnify, minus, move, person, plus, select } diff --git a/app/src/experimental/icons/library/magnify.vue b/app/src/experimental/icons/library/magnify.vue new file mode 100644 index 0000000..33d4c63 --- /dev/null +++ b/app/src/experimental/icons/library/magnify.vue @@ -0,0 +1,10 @@ +<template> + <g> + <rect x="11" y="36.4246" width="10.5" height="4" transform="rotate(-45 11 36.4246)" /> + <path + fill-rule="evenodd" + clip-rule="evenodd" + d="M27.3334 32.3333C32.6721 32.3333 37 28.0054 37 22.6667C37 17.3279 32.6721 13 27.3334 13C21.9946 13 17.6667 17.3279 17.6667 22.6667C17.6667 28.0054 21.9946 32.3333 27.3334 32.3333ZM27.3334 34.3333C33.7767 34.3333 39 29.11 39 22.6667C39 16.2233 33.7767 11 27.3334 11C20.89 11 15.6667 16.2233 15.6667 22.6667C15.6667 29.11 20.89 34.3333 27.3334 34.3333Z" + /> + </g> +</template> \ No newline at end of file diff --git a/app/src/experimental/icons/library/minus.vue b/app/src/experimental/icons/library/minus.vue new file mode 100644 index 0000000..61d1fc0 --- /dev/null +++ b/app/src/experimental/icons/library/minus.vue @@ -0,0 +1,3 @@ +<template> + <rect x="13" y="24" width="23" height="2"/> +</template> diff --git a/app/src/experimental/icons/library/move.vue b/app/src/experimental/icons/library/move.vue new file mode 100644 index 0000000..c4cb6ea --- /dev/null +++ b/app/src/experimental/icons/library/move.vue @@ -0,0 +1,10 @@ +<template> + <g> + <rect x="13" y="24" width="23" height="2" /> + <rect x="23.5" y="36.5" width="23" height="2" transform="rotate(-90 23.5 36.5)" /> + <path d="M24.5 40L28 35L21 35L24.5 40Z" /> + <path d="M24.5 10L21 15H28L24.5 10Z" /> + <path d="M39.5 25L34.5 21.5L34.5 28.5L39.5 25Z" /> + <path d="M9.5 25L14.5 28.5L14.5 21.5L9.5 25Z" /> + </g> +</template> \ No newline at end of file diff --git a/app/src/experimental/icons/library/person.vue b/app/src/experimental/icons/library/person.vue new file mode 100644 index 0000000..f6d5212 --- /dev/null +++ b/app/src/experimental/icons/library/person.vue @@ -0,0 +1,14 @@ +<template> + <g> + <path + fill-rule="evenodd" + clip-rule="evenodd" + d="M17.5901 25.2985C19.4942 23.5731 22.1882 23 25.0152 23C29.1842 23 31.8695 24.1035 33.404 26.1991C34.8903 28.2289 35.0984 30.9439 34.9671 33.6684L34.9409 34.2138L34.4678 34.4866C26.5733 39.039 18.5871 36.3901 15.5298 34.4667L15.1077 34.2012L15.0658 33.7043C14.7385 29.8215 15.6465 27.0598 17.5901 25.2985ZM17.0232 33.027C19.8893 34.6238 26.4448 36.5263 32.9906 33.0199C33.0577 30.6185 32.7664 28.7136 31.7904 27.3807C30.7889 26.013 28.8679 25 25.0152 25C22.3927 25 20.2988 25.5429 18.9331 26.7805C17.6725 27.9229 16.8478 29.8302 17.0232 33.027Z" + /> + <path + fill-rule="evenodd" + clip-rule="evenodd" + d="M24.5 15C22.6635 15 21 16.6891 21 19C21 21.3109 22.6635 23 24.5 23C26.3365 23 28 21.3109 28 19C28 16.6891 26.3365 15 24.5 15ZM19 19C19 15.788 21.3659 13 24.5 13C27.6341 13 30 15.788 30 19C30 22.212 27.6341 25 24.5 25C21.3659 25 19 22.212 19 19Z" + /> + </g> +</template> diff --git a/app/src/experimental/icons/library/plus.vue b/app/src/experimental/icons/library/plus.vue new file mode 100644 index 0000000..b76c69b --- /dev/null +++ b/app/src/experimental/icons/library/plus.vue @@ -0,0 +1,6 @@ +<template> + <g> + <rect x="13" y="24" width="23" height="2" /> + <rect x="23.5" y="36.5" width="23" height="2" transform="rotate(-90 23.5 36.5)" /> + </g> +</template> diff --git a/app/src/experimental/icons/library/select.vue b/app/src/experimental/icons/library/select.vue new file mode 100644 index 0000000..4e910b1 --- /dev/null +++ b/app/src/experimental/icons/library/select.vue @@ -0,0 +1,8 @@ + +<template> + <path + fill-rule="evenodd" + clip-rule="evenodd" + d="M15.7225 12.4585C16.3175 11.9652 17.1443 11.8601 17.8453 12.1887L36.0247 20.7103C36.7467 21.0487 37.1998 21.7833 37.1781 22.58C37.1564 23.3768 36.6641 24.0837 35.9248 24.3794L28.049 27.5298L23.8492 34.8793C23.4455 35.5857 22.6536 35.976 21.8469 35.866C21.0401 35.756 20.3796 35.1677 20.1766 34.3786L15.0637 14.4949C14.8708 13.7449 15.1274 12.9519 15.7225 12.4585ZM26.6578 25.9302L35.1794 22.5216L17 14L22.113 33.8837L26.6578 25.9302Z" + /> +</template> diff --git a/app/src/experimental/makePassiveEventOption.js b/app/src/experimental/makePassiveEventOption.js index 0fc8828..8b32c88 100644 --- a/app/src/experimental/makePassiveEventOption.js +++ b/app/src/experimental/makePassiveEventOption.js @@ -1,21 +1,19 @@ -// We want to make event listeners non-passive, and to do so have to check -// that browsers support EventListenerOptions in the first place. -// https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#Safely_detecting_option_support let passiveSupported = false try { - const options = { - get passive() { - passiveSupported = true - } - }; - window.addEventListener("test", options, options) - window.removeEventListener("test", options, options) + const options = { + /* eslint-disable getter-return */ + get passive() { + passiveSupported = true + } + } + window.addEventListener('test', options, options) + window.removeEventListener('test', options, options) } catch { - passiveSupported = false + passiveSupported = false } -const makePassiveEventOption = (passive) => { - return passiveSupported ? { passive } : passive +const makePassiveEventOption = passive => { + return passiveSupported ? { passive } : passive } -export default makePassiveEventOption \ No newline at end of file +export default makePassiveEventOption diff --git a/app/src/experimental/modes.js b/app/src/experimental/modes.js new file mode 100644 index 0000000..e87115e --- /dev/null +++ b/app/src/experimental/modes.js @@ -0,0 +1,54 @@ +export const select = { + name: 'select', + view: { + pan: false, + zoom: true + }, + icon: 'select', + cursor: 'initial', + shortcut: false +} + +export const addNode = { + name: 'addNode', + view: { + pan: false, + zoom: false + }, + icon: 'addNode', + cursor: 'copy', + shortcut: false +} + +export const move = { + name: 'move', + view: { + pan: true, + zoom: true + }, + icon: 'move', + cursor: 'all-scroll', + shortcut: false +} + +export const connect = { + name: 'connect', + view: { + pan: false, + zoom: false + }, + icon: 'connection', + cursor: 'crosshair', + shortcut: false +} + +export const draw = { + name: 'draw', + view: { + pan: false, + zoom: false + }, + icon: 'draw', + cursor: 'crosshair', + shortcut: false +} diff --git a/app/src/experimental/uiStore.js b/app/src/experimental/uiStore.js new file mode 100644 index 0000000..0726226 --- /dev/null +++ b/app/src/experimental/uiStore.js @@ -0,0 +1,55 @@ +import * as allModes from '@/experimental/modes' + +const store = { + namespaced: true, + state: { + interaction: { + active: false, + origin: null, + shape: null + }, + selection: { + links: [], + nodes: [] + }, + mode: 'move', + scale: 1, + translation: { + x: 0, + y: 0 + } + }, + getters: { + isScaled: state => !(state.scale === 1.0), + isTranslated: state => + !(state.translation.x === 0 && state.translation.y === 0), + activeMode: state => { + return allModes[state.mode] + }, + modeContainerStyle: state => { + return { + cursor: allModes[state.mode].cursor || 'initial' + } + }, + scalePercentage: state => { + return `${(state.scale * 100).toFixed(0)}%` + } + }, + mutations: { + setMode(state, mode) { + if (allModes[mode]) { + state.mode = mode + } else { + console.warn(`${mode} is not a valid mode`) + } + }, + setScale(state, scale) { + state.scale = scale + }, + setTranslation(state, { x, y }) { + state.translation = { x, y } + } + } +} + +export default store diff --git a/app/src/experimental/uiText.js b/app/src/experimental/uiText.js new file mode 100644 index 0000000..d0008e2 --- /dev/null +++ b/app/src/experimental/uiText.js @@ -0,0 +1,22 @@ +export const uiText = { + en: { + modes: { + move: { + title: 'Move', + description: 'Move your view of the board' + }, + select: { + title: 'Select', + description: 'Select and edit items on the board' + }, + connect: { + title: 'Connect', + description: 'Make connections between the nodes' + }, + draw: { + title: 'Draw', + description: 'Sketch and annotate around your nodes' + } + } + } +} diff --git a/app/src/experimental/utils/canvas.js b/app/src/experimental/utils/canvas.js new file mode 100644 index 0000000..c6dc3d5 --- /dev/null +++ b/app/src/experimental/utils/canvas.js @@ -0,0 +1,79 @@ +import * as win from '@/experimental/constants/window' +import { generateLinkHandles } from '@/experimental/utils/nodes' +import { mapRange, distance, angleBetween } from '@/experimental/utils/numbers' + +////////////////////////////////////////////////////////////////////// +// CANVAS UTILITIES +////////////////////////////////////////////////////////////////////// +// Because these helpers directly manipulate the drawing context, +// their methods don't need to return anything + +/** + * Adds a bezier curve to the drawing context + * + * @param {CanvasRenderingContext2D} context - 2D rendering context + * @param {Node} from - Origin node + * @param {Node} to - Target node + * @param {number} tension - Tension of the curve to be drawn + * + * */ +export const drawLinkBezierCurve = (context, from, to, tension) => { + const [fromHandle, toHandle] = generateLinkHandles(from, to) + + // This is a simple way to adjust the link tension depending + // on the distance the link covers + const adjustedTension = mapRange( + distance(fromHandle, toHandle), + 0, + win.width, + tension * 0.01, + tension * 2 + ) + + context.beginPath() + context.moveTo(fromHandle.x, fromHandle.y) + context.bezierCurveTo( + fromHandle.x * (1 + adjustedTension), + fromHandle.y, + toHandle.x * (1 - adjustedTension), + toHandle.y, + toHandle.x, + toHandle.y + ) +} + +/** + * Adds an arrow head to the end of a link + * + * @param {CanvasRenderingContext2D} context - 2D rendering context + * @param {Point} from - Origin point + * @param {Point} to - Target point + * @param {number} size - Arrowhead size + * + * */ +export const drawArrowHead = (context, from, to, size = 10) => { + const [fromHandle, toHandle] = generateLinkHandles(from, to) + const angle = angleBetween(fromHandle, toHandle) + + context.setLineDash([0, 0]) + context.setTransform(1, 0, 0, 1, toHandle.x - size, toHandle.y) + + context.rotate(angle * 0.65) + + context.moveTo(-size, -size / 1.25) + context.lineTo(0, 0) + context.lineTo(-size, size / 1.25) + context.setTransform(1, 0, 0, 1, 0, 0) +} + +/** + * Clears the drawing context + * + * @param {CanvasRenderingContext2D} context - 2D rendering context + * @param {number} width + * @param {number} height + * + * */ +export const clear = (context, width, height) => { + context.clearRect(0, 0, width, height) +} diff --git a/app/src/experimental/utils/dom.js b/app/src/experimental/utils/dom.js new file mode 100644 index 0000000..f74ad77 --- /dev/null +++ b/app/src/experimental/utils/dom.js @@ -0,0 +1,79 @@ +import { isFunction } from '@/experimental/utils/helpers' + +////////////////////////////////////////////////////////////////////// +// DOM UTILITIES +////////////////////////////////////////////////////////////////////// + +/** + * !! INCOMPLETE !! + * Normalises mouse/touch interaction events + * + * @param {HTMLElement} target - base element for event + * @param {Event} event + * @returns {Point} + * + * */ +export const getInteractionPoint = (target, event) => { + const rect = target.getBoundingClientRect() + + return { + x: parseInt( + ((event.clientX - rect.left) / (rect.right - rect.left)) * + target.offsetWidth + ), + y: parseInt( + ((event.clientY - rect.top) / (rect.bottom - rect.top)) * + target.offsetHeight + ) + } +} + +const matches = [ + 'matches', + 'webkitMatchesSelector', + 'mozMatchesSelector', + 'msMatchesSelector', + 'oMatchesSelector' +] + +export const matchesSelectorToParentElements = (el, selector, baseNode) => { + let node = el + + const matchesSelectorFunc = matches.find(func => isFunction(node[func])) + + if (!isFunction(node[matchesSelectorFunc])) return false + + do { + if (node[matchesSelectorFunc](selector)) return true + if (node === baseNode) return false + node = node.parentNode + } while (node) + + return false +} + +export const addEvent = (el, event, handler) => { + if (!el) { + return + } + if (el.attachEvent) { + el.attachEvent('on' + event, handler) + } else if (el.addEventListener) { + el.addEventListener(event, handler, true) + } else { + el['on' + event] = handler + } +} + +export const removeEvent = (el, event, handler) => { + if (!el) { + return + } + if (el.detachEvent) { + el.detachEvent('on' + event, handler) + } else if (el.removeEventListener) { + el.removeEventListener(event, handler, true) + } else { + el['on' + event] = null + } +} diff --git a/app/src/experimental/utils/helpers.js b/app/src/experimental/utils/helpers.js new file mode 100644 index 0000000..9f5898d --- /dev/null +++ b/app/src/experimental/utils/helpers.js @@ -0,0 +1,58 @@ +////////////////////////////////////////////////////////////////////// +// UTILITIES AND HELPERS +////////////////////////////////////////////////////////////////////// + +/** + * Expresses an array length in text + */ +export const pluralize = (arr, noun, plural) => { + const num = arr.length + const nounPlural = plural || `${noun}s` + return `${num > 0 ? num : 'No'} ${num > 1 ? nounPlural : noun}` +} + +/** + * Filter to retrieve unique values from array + */ +export const shallowUnique = (value, index, item) => + item.indexOf(value) === index + +/** + * Removes item from an array + */ +export const shallowRemoveFromArray = (arr, item) => { + const newArray = [...arr] + const index = arr.indexOf(item) + if (index > -1) { + newArray.splice(index, 1) + } + return newArray +} + +/** + * Utility method to group array of objects by key value + * from https://gist.github.com/JamieMason/0566f8412af9fe6a1d470aa1e089a752 + */ +export const groupBy = key => array => + array.reduce((objectsByKeyValue, obj) => { + const value = obj[key] + objectsByKeyValue[value] = (objectsByKeyValue[value] || []).concat(obj) + return objectsByKeyValue + }, {}) + +/** + * Helper to check if the code is running in a browser + * This helps avoid errors in components that depend on + * window when building static site in CI/node.js) + */ +export const isClient = typeof window === 'object' + +/** + * Checks if variable is a function + */ +export function isFunction(func) { + return ( + typeof func === 'function' || + Object.prototype.toString.call(func) === '[object Function]' + ) +} diff --git a/app/src/experimental/utils/nodes.js b/app/src/experimental/utils/nodes.js new file mode 100644 index 0000000..11ff05b --- /dev/null +++ b/app/src/experimental/utils/nodes.js @@ -0,0 +1,235 @@ +////////////////////////////////////////////////////////////////////// +// TYPE DEFINITIONS +////////////////////////////////////////////////////////////////////// + +/** + * A representation of a point + * @typedef {Object} Point + * @property {number} x - Horizontal (x) position in pixels + * @property {number} y - Vertical (y) position in pixels + */ + +/** + * A representation of an area + * @typedef {Object} Area + * @property {number} x1 - Top left (x) position in pixels + * @property {number} y1 - Top left (y) position in pixels + * @property {number} x2 - Bottom right (x) position in pixels + * @property {number} y2 - Bottom right (y) position in pixels + */ + +/** + * A representation of a node + * @typedef {Object} Node + * @property {number} x - Horizontal (x) position in pixels + * @property {number} y - Vertical (y) position in pixels + * @property {number} width - Node width in pixels + * @property {number} height - Node height in pixels + */ + +////////////////////////////////////////////////////////////////////// +// NODES +////////////////////////////////////////////////////////////////////// +// Helpers for working with nodes, points and areas + +/** + * Converts a @Node into the @Area it covers + * + * @param {Node} node - Origin node + * @param {number} threshold - Additional threshold area (optional) + * @return {Area} Area covered by box, including threshold + * + * */ +export const nodeToArea = (node, threshold = 0.0) => { + return { + x1: node.x * (1.0 - threshold), + y1: node.y * (1.0 - threshold), + x2: (node.x + node.width) * (1.0 + threshold), + y2: (node.y + node.height) * (1.0 + threshold) + } +} + +/** + * Converts an @Area into a node + * + * @param {Area} area - Origin node + * @return {Node} + * + * */ +export const areaToNode = area => { + return { + x: area.x1, + y: area.y1, + width: Math.abs(area.x2 - area.x1), + height: Math.abs(area.y2 - area.y1) + } +} + +/** + * Returns whether one @Area completely contains another @Area + * + * @param {Area} a + * @param {Area} b + * @return {boolean} + * + * */ +export const areaContains = (a, b) => + !(b.x1 < a.x1 || b.y1 < a.y1 || b.x2 > a.x2 || b.y2 > a.y2) + +/** + * Returns whether one @Area overlaps another @Area + * + * @param {Area} a + * @param {Area} b + * @return {boolean} + * + * */ +export const areaOverlaps = (a, b) => { + if (a.x1 >= b.x2 || b.x1 >= a.x2) return false + if (a.y1 >= b.y2 || b.y1 >= a.y2) return false + + return true +} + +/** + * Returns anchor positions for drawing links between two Nodes. + * + * @param {Node} from - Origin node + * @param {Node} from - Target node + * @return {array} Array of {x,y} positions + * + * */ +export const generateLinkHandles = (from, to) => { + // const toLeft = to.x > from.x + return [ + { + x: from.x + from.width, + y: from.y + from.height / 2 + }, + { + // x: to.x + to.width / 2, + x: to.x + 10, + y: to.y + to.height / 2 + } + ] +} + +/** + * Returns a list of nodes which overlap with a given {x1,y1,x2,y2} @Area + * + * @param {Area} area - Origin node + * @param {array} node - List of nodes to check against + * @param {number} threshold - Additional threshold area (optional) + * @return {array} Array of @Node objects which match + * + * */ +export const areaNodeIntersections = (targetNode, nodes, threshold = 0) => { + return nodes + .filter(node => + areaOverlaps(nodeToArea(targetNode), nodeToArea(node, threshold)) + ) + .map(({ id }) => id) +} + +/** + * Returns a list of nodes which overlap with a given {x,y} @Point + * + * @param {Point} point + * @param {array} node - List of nodes to check against + * @return {array} Array of @Node objects which match + * + * */ +export const pointNodeIntersections = (point, nodes) => { + return nodes.filter(node => pointWithinNode(point, node)).map(({ id }) => id) +} + +/** + * Checks whether a @Point is within a @Node + * + * @param {Point} point + * @param {Node} node + * @return {boolean} + * + * */ +export const pointWithinNode = (point, node) => { + const { x1, y1, x2, y2 } = nodeToArea(node) + return x1 <= point.x && point.x <= x2 && y1 <= point.y && point.y <= y2 +} + +/** + * Converts an array of nodes into a single large @Area + * + * @param {array} node - List of nodes + * @return {Area} + * + * */ +export const generateAreaFromNodes = nodes => { + const sum = {} + + for (let node of nodes) { + const area = nodeToArea(node) + if (!sum.x1 || area.x1 < sum.x1) { + sum.x1 = area.x1 + } + if (!sum.y1 || area.y1 < sum.y1) { + sum.y1 = area.y1 + } + if (!sum.x2 || area.x2 > sum.x2) { + sum.x2 = area.x2 + } + if (!sum.y2 || area.y2 > sum.y2) { + sum.y2 = area.y2 + } + } + return sum +} + +/** + * Generates a node shape from two given points + * */ +export const generateNode = (origin, point) => { + const minusX = origin.x > point.x + const minusY = origin.y > point.y + const width = Math.abs(point.x - origin.x) + const height = Math.abs(point.y - origin.y) + + return { + x: minusX ? origin.x - width : origin.x, + y: minusY ? origin.y - height : origin.y, + width, + height + } +} + +/** + * Transforms a grouped set of nodes based on a base transformation + * + * @param {Node[]} nodes - List of nodes + * @param {Point} transform – transform + * @return {Node[]} + * */ +export const transformNodeSelection = (nodes, transform) => { + const baseCoordinates = {} + + // iterate through all nodes to get underlying coordinates of group + for (let node of nodes) { + if (!baseCoordinates.x || node.x < baseCoordinates.x) { + baseCoordinates.x = node.x + } + if (!baseCoordinates.y || node.y < baseCoordinates.y) { + baseCoordinates.y = node.y + } + } + + return nodes.map(node => { + const relativeTransform = { + x: node.x - baseCoordinates.x, + y: node.y - baseCoordinates.y + } + + return Object.assign({}, node, { + x: transform.x + relativeTransform.x, + y: transform.y + relativeTransform.y + }) + }) +} diff --git a/app/src/experimental/utils/numbers.js b/app/src/experimental/utils/numbers.js new file mode 100644 index 0000000..2f5843d --- /dev/null +++ b/app/src/experimental/utils/numbers.js @@ -0,0 +1,65 @@ +////////////////////////////////////////////////////////////////////// +// MATHS AND NUMBERS +////////////////////////////////////////////////////////////////////// + +/** + * Returns the distance between two {x,y} points + * + * @param {Node} from - Origin node + * @param {Node} from - Target node + * @return {number} Distance between points + * + * */ +export const distance = (from, to) => Math.hypot(to.x - from.x, to.y - from.y) + +/** + * Returns an interpolated value between two numbers + * + * @param {number} from + * @param {number} to + * @param {number} extent - between 0.0 and 1.0 + * @return {number} + * + * */ +export const lerp = (from, to, extent) => from * (1 - extent) + to * extent + +/** + * Returns an interpolated value between two points + * + * @param {Point} from + * @param {Point} to + * @param {number} extent - between 0.0 and 1.0 + * @return {Point} + * + * */ +export const lerpPoint = (from, to, extent) => { + return { + x: lerp(from.x, to.x, extent), + y: lerp(from.y, to.y, extent) + } +} + +/** + * Map a @number from one range to another + * + * @param {number} value - value to map + * @param {number} from1 + * @param {number} to1 + * @param {number} from2 + * @param {number} to2 + * @return {number} + * + * */ +export const mapRange = (value, from1, to1, from2, to2) => + ((value - from1) * (to2 - from2)) / (to1 - from1) + from2 + +/** + * Calculates the angle between two points + * + * @param {Point} point1 + * @param {Point} point2 + * @return {number} + * + * */ +export const angleBetween = (point1, point2) => + Math.atan2(point2.y - point1.y, point2.x - point1.x) diff --git a/app/src/experimental/utils/parse.js b/app/src/experimental/utils/parse.js new file mode 100644 index 0000000..02b2e3b --- /dev/null +++ b/app/src/experimental/utils/parse.js @@ -0,0 +1,31 @@ +export const parseHex = hexColor => { + const parser = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hexColor) + if (parser) { + return [ + parseInt(parser[1], 16) / 255.0, + parseInt(parser[2], 16) / 255.0, + parseInt(parser[3], 16) / 255.0 + ] + } else { + throw new Error(`${hexColor} is not a valid hex color code`) + } +} + +export const parseNumberToHex = num => { + const c = num * 255.0 + const hex = c.toString(16) + return hex.length === 1 ? '0' + hex : hex +} + +// derived from https://github.com/mrdoob/three.js/blob/dev/src/math/Color.js +export const parseRGBString = str => { + /* eslint-disable no-useless-escape */ + const components = /^((?:rgb|hsl)a?)\(\s*([^\)]*)\)/.exec(str) + const result = components[2].split(',') + + if (result.length === 3 || result.length === 4) { + return result.map(n => parseFloat(n) / 255) + } else { + return new Error(`Invalid color string ${str}`) + } +} diff --git a/app/src/experimental/utils/svg.js b/app/src/experimental/utils/svg.js new file mode 100644 index 0000000..dcf070c --- /dev/null +++ b/app/src/experimental/utils/svg.js @@ -0,0 +1,40 @@ +import * as win from '@/experimental/constants/window' +import { generateLinkHandles } from '@/experimental/utils/nodes' +import { mapRange, distance } from '@/experimental/utils/numbers' + +export const generateBezierCurve = (from, to, tension) => { + if (from && to) { + const [fromHandle, toHandle] = generateLinkHandles(from, to) + + // This is a simple way to adjust the link tension depending + // on the distance the link covers + const adjustedTension = mapRange( + distance(fromHandle, toHandle), + 0, + win.width, + tension * 0.01, + tension * 2 + ) + + return `M${fromHandle.x},${fromHandle.y} C${fromHandle.x * + (1 + adjustedTension)},${fromHandle.y} ${toHandle.x * + (1 - adjustedTension)},${toHandle.y} ${toHandle.x},${toHandle.y}` + } +} + +export const makeBezier = (fromHandle, toHandle, tension) => { + console.log(fromHandle, toHandle) + // This is a simple way to adjust the link tension depending + // on the distance the link covers + const adjustedTension = mapRange( + distance(fromHandle, toHandle), + 0, + win.width, + tension * 0.01, + tension * 2 + ) + + return `M${fromHandle.x},${fromHandle.y} C${fromHandle.x * + (1 + adjustedTension)},${fromHandle.y} ${toHandle.x * + (1 - adjustedTension)},${toHandle.y} ${toHandle.x},${toHandle.y}` +} diff --git a/app/src/main.js b/app/src/main.js index ecef915..5e07ab6 100644 --- a/app/src/main.js +++ b/app/src/main.js @@ -6,10 +6,11 @@ import store from './store' // FIXME: Probably update this to the global import code from Vue // https://vuejs.org/v2/guide/components-registration.html#Automatic-Global-Registration-of-Base-Components import BaseButton from './components/BaseButton.vue' -Vue.component('BaseButton', BaseButton) +import Icon from '@/experimental/icons/Icon' +Vue.component('BaseButton', BaseButton) +Vue.component('Icon', Icon) -console.log(process.env) Vue.config.productionTip = false new Vue({ diff --git a/app/src/store/index.js b/app/src/store/index.js index 1601f77..e8e439e 100644 --- a/app/src/store/index.js +++ b/app/src/store/index.js @@ -5,6 +5,8 @@ PouchDB.plugin(require('pouchdb-find')) import VueDraggableResizable from 'vue-draggable-resizable' import 'vue-draggable-resizable/dist/VueDraggableResizable.css' +import uiStore from '@/experimental/uiStore' + import Router from '@/router' Vue.use(Vuex) @@ -70,7 +72,7 @@ const store = new Vuex.Store({ CREATE_MICROCOSM(state, doc) { const urldevice = Router.currentRoute.params.device const urlmicrocosm = Router.currentRoute.params.microcosm - pouchdb.close().then(function () { + pouchdb.close().then(function() { if (urlmicrocosm != undefined) { myclient = urldevice microcosm = urlmicrocosm @@ -97,12 +99,12 @@ const store = new Vuex.Store({ include_docs: true, attachments: true }) - .then(function (doc) { + .then(function(doc) { state.microcosm = microcosm state.allNodes = doc.rows store.commit('SET_OTHER_NODES') }) - .catch(function (err) { + .catch(function(err) { console.log(err) }) }, @@ -153,7 +155,7 @@ const store = new Vuex.Store({ GET_MY_NODES(state) { pouchdb .get(state.myclient) - .then(function (doc) { + .then(function(doc) { var i for (i = 0; i < Object.keys(doc.nodes).length; i++) { if (doc.nodes[i].deleted == true) { @@ -162,7 +164,7 @@ const store = new Vuex.Store({ state.myNodes = doc.nodes } }) - .catch(function (err) { + .catch(function(err) { if (err.status == 404) { var uniqueid = Math.random() @@ -196,10 +198,10 @@ const store = new Vuex.Store({ GET_POSITIONS(state) { pouchdb .get(state.global_pos_name) - .then(function (doc) { + .then(function(doc) { state.configPositions = doc.positions }) - .catch(function (err) { + .catch(function(err) { console.log(err) if (err.status == 404) { return pouchdb.put({ @@ -224,7 +226,7 @@ const store = new Vuex.Store({ pouchdb .get(state.global_pos_name) - .then(function (doc) { + .then(function(doc) { // console.log(doc) // put the store into pouchdb return pouchdb.bulkDocs([ @@ -235,12 +237,12 @@ const store = new Vuex.Store({ } ]) }) - .then(function () { - return pouchdb.get(state.global_pos_name).then(function (doc) { + .then(function() { + return pouchdb.get(state.global_pos_name).then(function(doc) { state.configPositions = doc.positions }) }) - .catch(function (err) { + .catch(function(err) { if (err.status == 404) { // pouchdb.put({ }) } @@ -263,7 +265,7 @@ const store = new Vuex.Store({ .substring(2, 15) state.localnodeid = uniqueid - pouchdb.get(state.myclient).then(function (doc) { + pouchdb.get(state.myclient).then(function(doc) { if (e == undefined) { doc.nodes.push({ node_id: uniqueid, @@ -282,8 +284,8 @@ const store = new Vuex.Store({ _attachments: doc._attachments, nodes: doc.nodes }) - .then(function () { - return pouchdb.get(state.myclient).then(function (doc) { + .then(function() { + return pouchdb.get(state.myclient).then(function(doc) { state.myNodes = doc.nodes var end = Object.keys(state.myNodes).length - 1 const newNode = { @@ -294,13 +296,13 @@ const store = new Vuex.Store({ state.activeNode = newNode }) }) - .catch(function (err) { + .catch(function(err) { if (err.status == 404) { // pouchdb.put({ }) } }) }) - pouchdb.get(state.global_pos_name).then(function (doc) { + pouchdb.get(state.global_pos_name).then(function(doc) { doc.positions.push({ node_id: uniqueid, x_pos: 50, @@ -315,7 +317,7 @@ const store = new Vuex.Store({ _rev: doc._rev, positions: doc.positions }) - .catch(function (err) { + .catch(function(err) { console.log(err) }) }) @@ -330,7 +332,7 @@ const store = new Vuex.Store({ } pouchdb .get(state.myclient) - .then(function (doc) { + .then(function(doc) { // put the store into pouchdb return pouchdb.bulkDocs([ @@ -342,12 +344,12 @@ const store = new Vuex.Store({ } ]) }) - .then(function () { - return pouchdb.get(state.myclient).then(function (doc) { + .then(function() { + return pouchdb.get(state.myclient).then(function(doc) { state.myNodes = doc.nodes }) }) - .catch(function (err) { + .catch(function(err) { if (err.status == 404) { // pouchdb.put({ }) } @@ -363,7 +365,7 @@ const store = new Vuex.Store({ } pouchdb .get(state.myclient) - .then(function (doc) { + .then(function(doc) { // put the store into pouchdb return pouchdb.bulkDocs([ { @@ -374,12 +376,12 @@ const store = new Vuex.Store({ } ]) }) - .then(function () { - return pouchdb.get(state.myclient).then(function (doc) { + .then(function() { + return pouchdb.get(state.myclient).then(function(doc) { state.myNodes = doc.nodes }) }) - .catch(function (err) { + .catch(function(err) { if (err.status == 404) { // pouchdb.put({ }) } @@ -389,10 +391,10 @@ const store = new Vuex.Store({ console.log pouchdb .get(state.global_emoji_name) - .then(function (doc) { + .then(function(doc) { state.configEmoji = doc.emojis }) - .catch(function (err) { + .catch(function(err) { console.log(err) if (err.status == 404) { return pouchdb.put({ @@ -410,7 +412,7 @@ const store = new Vuex.Store({ Math.random() .toString(36) .substring(2, 15) - pouchdb.get(state.global_emoji_name).then(function (doc) { + pouchdb.get(state.global_emoji_name).then(function(doc) { doc.emojis.push({ emoji_id: uniqueid, node_id: e.nodeid, @@ -422,7 +424,7 @@ const store = new Vuex.Store({ _rev: doc._rev, emojis: doc.emojis }) - .catch(function (err) { + .catch(function(err) { console.log(err) }) }) @@ -437,7 +439,7 @@ const store = new Vuex.Store({ }, syncDB: () => { - pouchdb.replicate.from(remote).on('complete', function () { + pouchdb.replicate.from(remote).on('complete', function() { store.commit('GET_ALL_NODES') store.commit('GET_MY_NODES') store.commit('GET_POSITIONS') @@ -445,29 +447,29 @@ const store = new Vuex.Store({ // turn on two-way, continuous, retriable sync pouchdb .sync(remote, { live: true, retry: true, attachments: true }) - .on('change', function () { + .on('change', function() { // pop info into function to find out more store.commit('GET_ALL_NODES') store.commit('GET_MY_NODES') store.commit('GET_POSITIONS') store.commit('GET_EMOJI') }) - .on('paused', function () { + .on('paused', function() { // replication paused (e.g. replication up to date, user went offline) // console.log('replication paused') }) - .on('active', function () { + .on('active', function() { // replicate resumed (e.g. new changes replicating, user went back online) //console.log('back active') }) - .on('denied', function () { + .on('denied', function() { // a document failed to replicate (e.g. due to permissions) }) - .on('complete', function () { + .on('complete', function() { // handle complete //console.log('complete') }) - .on('error', function (err) { + .on('error', function(err) { console.log(err) }) }) @@ -504,7 +506,9 @@ const store = new Vuex.Store({ }) } }, - modules: {} + modules: { + ui: uiStore + } }) export default store diff --git a/app/src/views/Sandbox.vue b/app/src/views/Sandbox.vue index 4d3215c..b283c54 100644 --- a/app/src/views/Sandbox.vue +++ b/app/src/views/Sandbox.vue @@ -1,34 +1,57 @@ <template> - <pzc v-bind:width="2000" v-bind:height="2000"> - <h1>Inner</h1> - </pzc> + <div class="wrapper" v-bind:style="modeContainerStyle"> + <PanZoomContainer + v-bind:width="width" + v-bind:height="height" + v-bind:scale="scale" + v-bind:translation="translation" + v-bind:pan="activeMode.view.pan" + v-bind:zoom="activeMode.view.zoom" + > + <h1>Nodes</h1> + </PanZoomContainer> + <ModeToolbar /> + <ViewToolbar /> + </div> </template> <script> -import { mapState } from 'vuex' -import PanZoomContainer from '../experimental/PanZoomContainer' +import PanZoomContainer from '@/experimental/PanZoomContainer' +import ModeToolbar from '@/experimental/ModeToolbar' +import ViewToolbar from '@/experimental/ViewToolbar' +import { mapGetters, mapState } from 'vuex' + export default { name: 'Sandbox', data: function() { - return {} + return { + width: 2000, + height: 2000 + } }, - components: { - pzc: PanZoomContainer + computed: { + ...mapState({ + scale: state => state.ui.scale, + translation: state => state.ui.translation + }), + ...mapGetters({ + activeMode: 'ui/activeMode', + modeContainerStyle: 'ui/modeContainerStyle' + }) }, - computed: mapState({ - myVersion: state => state.version - }) + components: { + ModeToolbar, + ViewToolbar, + PanZoomContainer + } } </script> <style scoped> -.container { - width: 600px; - height: 600px; -} -.board { - background: grey; - width: 1200px; - height: 1200px; +.wrapper { + height: calc(100vh - 120px); + width: calc(100%-80px); + margin: 40px; + position: relative; } </style> -- GitLab