Skip to content

Commit

Permalink
feat: enable to zoom viewport with pinch (#22)
Browse files Browse the repository at this point in the history
* feat: enable to zoom viewport with pinch

* test: fix broken test

* feat: consider scale value to acquire scroll content size

* fix: prevent default wheel event when pinch

* fix: round size and scale value

* fix: tweak the implementation to modify scroll offset
  • Loading branch information
ktsn committed Apr 20, 2018
1 parent b99f861 commit fbf8156
Show file tree
Hide file tree
Showing 11 changed files with 277 additions and 42 deletions.
6 changes: 6 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@
"typescript": "^2.7.2",
"vscode": "^1.1.10",
"vue": "^2.5.13",
"vue-global-events": "^1.0.3",
"vue-jest": "^2.1.0",
"vue-loader": "^15.0.0-rc.2",
"vue-style-loader": "^4.1.0",
Expand Down
4 changes: 4 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ export function range(min: number, max: number): number[] {
)
}

export function minmax(min: number, n: number, max: number): number {
return Math.min(max, Math.max(min, n))
}

export function isObject(value: any): boolean {
return value !== null && typeof value === 'object'
}
8 changes: 6 additions & 2 deletions src/view/components/PageMain.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,22 @@
:document="renderingDocument"
:width="width"
:height="height"
:scale="scale"
@select="select"
@dragover="setDraggingPlace"
@add="applyDraggingElement"
@resize="resize"
@zoom="zoom"
/>
</div>

<div class="page-layout-toolbar">
<Toolbar
:width="width"
:height="height"
:scale="scale"
@resize="resize"
@zoom="zoom"
/>
</div>
</div>
Expand Down Expand Up @@ -100,7 +104,7 @@ export default Vue.extend({
matchedRules: 'matchedRules'
}),
...viewportHelpers.mapState(['width', 'height']),
...viewportHelpers.mapState(['width', 'height', 'scale']),
...projectHelpers.mapGetters({
document: 'currentDocument',
Expand All @@ -127,7 +131,7 @@ export default Vue.extend({
'updateDeclaration'
]),
...viewportHelpers.mapActions(['resize'])
...viewportHelpers.mapActions(['resize', 'zoom'])
}
})
</script>
Expand Down
60 changes: 47 additions & 13 deletions src/view/components/Renderer.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
<template>
<div class="renderer" @click="$emit('select')">
<div class="renderer-scroll-content" :style="scrollContentStyle">
<Viewport :width="width" :height="height" @resize="$emit('resize', arguments[0])">
<Viewport
:width="width"
:height="height"
:scale="scale"
@resize="$emit('resize', arguments[0])"
@zoom="$emit('zoom', arguments[0])"
>
<VueComponent
:uri="document.uri"
:template="document.template"
Expand Down Expand Up @@ -46,6 +52,10 @@ export default Vue.extend({
height: {
type: Number,
required: true
},
scale: {
type: Number,
required: true
}
},
Expand All @@ -54,11 +64,27 @@ export default Vue.extend({
rendererSize: {
width: 0,
height: 0
}
},
/**
* Indicates how much the scroll offset will be changed on after the next render.
* This is needed to retain the viewport position visually even after the scroll content size is changed.
* When scroll content size is changed, it calcurate how much we should modify its scroll position
* and set the value to `deltaScrollOffset`. Then, it will be applied actual DOM element
* after VNode is patched (in updated hook).
*/
deltaScrollOffset: null as { left: number; top: number } | null
}
},
computed: {
scaledSize(): { width: number; height: number } {
return {
width: this.width * this.scale,
height: this.height * this.scale
}
},
scrollContentSize(): { width: number; height: number } {
const renderer = this.rendererSize
const thresholdWidth = Math.max(0, renderer.width - scrollContentPadding)
Expand All @@ -67,19 +93,19 @@ export default Vue.extend({
renderer.height - scrollContentPadding
)
const { width, height } = this.scaledSize
// If the viewport size is enough smaller than renderer size,
// the scroll content size is the same as the renderer size so that the viewport will not be scrollable.
// Otherwise, the scroll content size will be much lager than renderer size to allow scrolling.
// This is similar behavior with Photoshop.
return {
width:
thresholdWidth > this.width
? renderer.width
: this.width + thresholdWidth * 2,
thresholdWidth > width ? renderer.width : width + thresholdWidth * 2,
height:
thresholdHeight > this.height
thresholdHeight > height
? renderer.height
: this.height + thresholdHeight * 2
: height + thresholdHeight * 2
}
},
Expand All @@ -105,14 +131,12 @@ export default Vue.extend({
{ x, y }: { x: number; y: number },
{ x: prevX, y: prevY }: { x: number; y: number }
): void {
const el = this.$el
// Adjust scroll offset after DOM is rerendered
// to avoid flickering viewport
requestAnimationFrame(() => {
el.scrollLeft = el.scrollLeft + (x - prevX)
el.scrollTop = el.scrollTop + (y - prevY)
})
this.deltaScrollOffset = {
left: x - prevX,
top: y - prevY
}
}
},
Expand All @@ -129,6 +153,16 @@ export default Vue.extend({
this.$once('hook:beforeDestroy', () => {
window.removeEventListener('resize', listener)
})
},
updated() {
const delta = this.deltaScrollOffset
if (delta) {
const el = this.$el
el.scrollLeft += delta.left
el.scrollTop += delta.top
this.deltaScrollOffset = null
}
}
})
</script>
Expand Down
77 changes: 62 additions & 15 deletions src/view/components/Toolbar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,41 @@
<input
v-model="dirtyWidth"
type="text"
class="viewport-size-input"
class="toolbar-input viewport-size-input"
@focus="selectAll($event.target)"
@keydown.enter="apply"
@keydown.enter="applySize"
>
<span class="viewport-size-char">x</span>
<span class="toolbar-input-char">x</span>
<input
v-model="dirtyHeight"
type="text"
class="viewport-size-input"
class="toolbar-input viewport-size-input"
@focus="selectAll($event.target)"
@keydown.enter="apply"
@keydown.enter="applySize"
>
</div>

<div class="toolbar-item">
<input
v-model="dirtyScale"
type="text"
class="toolbar-input viewport-scale-input"
@focus="selectAll($event.target)"
@keydown.enter="applyScale"
>
<span class="toolbar-input-char">%</span>
</div>
</div>
</template>

<script lang="ts">
import Vue from 'vue'
const scaleFormatter = new Intl.NumberFormat('latn', {
maximumFractionDigits: 2,
useGrouping: false
})
export default Vue.extend({
name: 'Toolbar',
Expand All @@ -34,13 +50,18 @@ export default Vue.extend({
height: {
type: Number,
required: true
},
scale: {
type: Number,
required: true
}
},
data() {
return {
dirtyWidth: this.width,
dirtyHeight: this.height
dirtyHeight: this.height,
dirtyScale: scaleFormatter.format(this.scale * 100)
}
},
Expand All @@ -50,11 +71,15 @@ export default Vue.extend({
this.dirtyHeight = this.height
},
resetScale(): void {
this.dirtyScale = scaleFormatter.format(this.scale * 100)
},
selectAll(target: HTMLInputElement): void {
target.select()
},
apply(): void {
applySize(): void {
const width = Number(this.dirtyWidth)
const height = Number(this.dirtyHeight)
Expand All @@ -66,12 +91,23 @@ export default Vue.extend({
height
})
}
},
applyScale(): void {
const scale = Number(this.dirtyScale)
if (Number.isNaN(scale)) {
this.resetScale()
} else {
this.$emit('zoom', scale / 100)
}
}
},
watch: {
width: 'resetSize',
height: 'resetSize'
height: 'resetSize',
scale: 'resetScale'
}
})
</script>
Expand All @@ -89,22 +125,33 @@ export default Vue.extend({
}
.toolbar-item {
display: inline-block;
vertical-align: middle;
margin-left: 30px;
font-size: rem(14);
}
.viewport-size-input {
.toolbar-item:first-child {
margin-left: 0;
}
.toolbar-input {
padding: 2px 4px;
width: 3.2em;
border-width: 0;
font-size: inherit;
font-family: inherit;
text-align: center;
}
.viewport-size-input,
.viewport-size-char {
.toolbar-input,
.toolbar-input-char {
vertical-align: middle;
}
.viewport-size-input {
width: 3.2em;
text-align: center;
}
.viewport-scale-input {
width: 4em;
text-align: center;
}
</style>
28 changes: 26 additions & 2 deletions src/view/components/Viewport.vue
Original file line number Diff line number Diff line change
@@ -1,26 +1,32 @@
<template>
<!-- Since viewport is aligned by center, the offset needs to be multiplied by 2 -->
<!-- Since viewport is aligned by center, the offset needs to be multiplied by 2 in default scale -->
<Resizable
class="viewport-wrapper"
:width="width"
:height="height"
:offset-weight="2"
:offset-weight="2 / scale"
:style="viewportStyle"
@resize="$emit('resize', arguments[0])"
>
<div class="viewport">
<slot />
</div>

<!-- To detect mac trackpad's pinch, we need to listen wheel event with ctrl is pressed -->
<GlobalEvents @wheel.ctrl.prevent="onZoom" />
</Resizable>
</template>

<script lang="ts">
import Vue from 'vue'
import GlobalEvents from 'vue-global-events'
import Resizable from './Resizable.vue'
export default Vue.extend({
name: 'Viewport',
components: {
GlobalEvents,
Resizable
},
Expand All @@ -32,6 +38,24 @@ export default Vue.extend({
height: {
type: Number,
required: true
},
scale: {
type: Number,
required: true
}
},
computed: {
viewportStyle(): Record<string, string> {
return {
transform: `scale(${this.scale})`
}
}
},
methods: {
onZoom(event: WheelEvent): void {
this.$emit('zoom', this.scale - event.deltaY * 0.01)
}
}
})
Expand Down
Loading

0 comments on commit fbf8156

Please sign in to comment.