Vue pointer events

 from Red Blob Games
5 May 2022

I wanted to try out pointer events with Vue.

Demo: mark a rectangle, or drag the circle

The idea is that on pointerdown I capture pointer events. It will automatically release the capture on pointerup. While the pointer is down I want pointermove to update the rectangle position.

The @touchstart.prevent will prevent the drag triggering scrolling on mobile devices. If the drag handle has text, you might want @pointerdown.prevent to prevent double click from selecting the text. You may also want a few other things, such as @dragstart.prevent and CSS user-select:none, depending on the situation. See my draggable object guide.

Vue's computed with setters are useful here. I can go from model coordinates to svg/canvas coordinates using the get() part of the computed, and then in reverse, svg/canvas coordinates to model coordinates using the set() part of the computed.

I tried with canvas and svg because the pointer position code is slightly different for the two. vue-pointerevents.js :

const app = createApp({
    data() { return {p: {x: 0, y: 0}}; },
    methods: {
        clamp(value, lo, hi) {
            if (value < lo) value = lo;
            if (value > hi) value = hi;
            return value;
        },
    },
});

// Here's an example with <canvas>
app.component('mark-rectangle', {
    props: {
        width: Number,
        height: Number,
    },
    data() {
        return {
            dragging: false,
            begin: {x: 100, y: 100},
            end: {x: 200, y: 200},
        };
    },
    template: `<canvas v-bind:width="width" v-bind:height="height"
                       v-on:touchstart.prevent="" v-on:pointerdown="pointerdown"
                       v-on:pointerup="pointerup" v-on:pointercancel="pointerup"
                       v-on="dragging? {pointermove} : {}">
               </canvas>`,
    mounted() {
        this.redraw();
    },
    methods: {
        redraw() {
            let ctx = this.$el.getContext('2d');
            ctx.clearRect(0, 0, this.width, this.height);
            ctx.fillStyle = this.dragging ? "hsl(200 50% 50% / 30%)" : "hsl(200 50% 50% / 50%)";
            ctx.strokeStyle = "black";
            ctx.beginPath();
            ctx.rect(this.begin.x, this.begin.y,
                     this.end.x - this.begin.x, this.end.y - this.begin.y);
            ctx.fill();
            ctx.stroke();
            ctx.fillStyle = "black";
            ctx.fillRect(this.begin.x - 1, this.begin.y - 1, 3, 3);
            ctx.fillRect(this.end.x - 2, this.end.y - 2, 3, 3);
        },
        eventToCanvasCoordinate(event) {
            // need to use getBoundingClientRect for responsive <canvas> sizing
            // NOTE: if you use transforms, see
            // https://stackoverflow.com/a/59259174
            // to invert transform matrix
            let canvas = this.$el;
            let bounds = canvas.getBoundingClientRect();
            let x = event.x - bounds.left;
            let y = event.y - bounds.top;
            x = x / bounds.width * canvas.width;
            y = y / bounds.height * canvas.height;
            return {x, y};
        },
        pointermove(event) {
            this.end = this.eventToCanvasCoordinate(event);
            this.redraw();
        },
        pointerdown(event) {
            const target = event.target;
            // we want all the events until the pointer is released
            target.setPointerCapture(event.pointerId);
            this.dragging = true;
            this.begin = this.eventToCanvasCoordinate(event);
            this.redraw();
        },
        pointerup(event) {
            this.pointermove(event);
            this.dragging = false;
            this.redraw();
        },

    },
    
});

// Here's an example with <svg>
app.component('a-point', {
    props: ['at'],
    emits: ['move'],
    template: `<circle v-bind:cx="at.x" v-bind:cy="at.y" v-bind:r="5" fill="red"
                v-bind:style="{cursor: dragging? 'grab' : 'grabbing'}"
                v-on:touchstart.prevent="" v-on:pointerdown="pointerdown"
                v-on:pointerup="pointerup" v-on:pointercancel="pointerup"
                v-on="dragging? {pointermove} : {}" />`,
    data() { return {dragging: false}; },
    methods: {
        eventToCanvasCoordinate(event) {
            const svg = this.$el.ownerSVGElement;
            // NOTE: svg.getScreenCTM already factors in the bounding rect
            // so there's no need to subtract rect, or even call getBoundingClientRect
            let point = svg.createSVGPoint();
            point.x = event.clientX;
            point.y = event.clientY;
            let coords = point.matrixTransform(svg.getScreenCTM().inverse());
            return coords;
        },
        pointermove(event) {
            this.$emit('move', this.eventToCanvasCoordinate(event));
        },
        pointerdown(event) {
            const target = event.target;
            // we want all the events until the pointer is released
            target.setPointerCapture(event.pointerId);
            this.dragging = true;
            this.pointermove(event);
        },
        pointerup(event) {
            this.pointermove(event);
            this.dragging = false;
        },
    },
});


app.mount('figure');

Email me , or tweet @redblobgames, or comment: