src_js/iconolab-bundle/src/components/editor/Canvas.vue
author ymh <ymh.work@gmail.com>
Thu, 02 Aug 2018 16:15:39 +0200
changeset 593 f8310b7ddef0
parent 511 3fd34eef6516
permissions -rw-r--r--
Added tag 0.1.10 for changeset a87ffe8e08e5

<template>
    <div class="wrapper">
        <div>
            <svg ref="svg"
                class="cut-canvas"
                v-bind:class="canvasClass"
                xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
                <image xmlns:xlink="http://www.w3.org/1999/xlink"
                    ref="image"
                    v-if="loaded"
                    v-bind:xlink:href="image"
                    x="0" y="0"
                    v-bind:width="imageWidth"
                    v-bind:height="imageHeight" />

                <!-- These are the existing fragments -->

                <!-- FIXME using <component :is="..."> does not work -->
                <shape-rect
                    v-for="annotation in normalizedAnnotations"
                    :key="annotation.annotation_guid"
                    v-if="loaded &amp;&amp; readonly &amp;&amp; annotation.mode == 'rect'"
                    v-bind:paper="paper"
                    v-bind:original-annotation="annotation"
                    v-bind:original-path="annotation.path"
                    v-bind:readonly="readonly"
                    v-on:click="onAnnotationClick(annotation)"
                    v-bind:stroke-width="strokeWidth"></shape-rect>
                <shape-free
                    v-for="annotation in normalizedAnnotations"
                    :key="annotation.annotation_guid"
                    v-if="loaded &amp;&amp; readonly &amp;&amp; annotation.mode == 'free'"
                    v-bind:paper="paper"
                    v-bind:original-annotation="annotation"
                    v-bind:original-path="annotation.path"
                    v-bind:readonly="readonly"
                    v-bind:stroke-width="strokeWidth"
                    v-on:click="onAnnotationClick(annotation)"></shape-free>

                <!-- These are the new fragments -->

                <shape-rect ref="rect"
                    v-show="loaded &amp;&amp; !readonly &amp;&amp; mode == 'rect'"
                    v-bind:paper="paper"
                    v-bind:stroke-width="strokeWidth"
                    :readonly="false"></shape-rect>
                <shape-free ref="free"
                    v-show="loaded &amp;&amp; !readonly &amp;&amp; mode == 'free'"
                    v-bind:paper="paper"
                    v-bind:stroke-width="strokeWidth"
                    :readonly="false"></shape-free>

                <defs>
                    <filter id="shadow" width="200%" height="200%">
                        <feOffset result="offOut" in="SourceAlpha" dx="0" dy="0"/>
                        <feGaussianBlur result="blurOut" in="offOut" stdDeviation="10"/>
                        <feBlend in="SourceGraphic" in2="blurOut" mode="normal"/>
                    </filter>
                </defs>
            </svg>
        </div>
        <div class="overlay" v-show="showOverlay"></div>
        <div class="controls" v-show="loaded">
            <div class="controls-left">
                <button
                    data-intro="Visualisez toutes les annotations"
                    data-position="top"
                    @click="toggleReadonly" type="button" class="btn"
                    v-bind:class="{ 'active': readonly }"
                    v-show="isAuthenticated">
                    <i class="fa fa-eye"></i>
                </button>
            </div>
            <div class="controls-draw">
                <button data-intro="Sélectionnez un rectangle" data-position="top" @click="setMode('rect')" type="button" class="btn"
                    v-bind:class="{ 'active': !readonly &amp;&amp; mode === 'rect' }"
                    v-show="isAuthenticated">
                    <svg width="14" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" x="0px" y="0px" viewBox="0 0 512 512" enable-background="new 0 0 512 512" xml:space="preserve"><g><rect x="352" y="432" width="64" height="48"/><polygon points="416,352 416,96 176,96 176,160 352,160 352,352 160,352 160,32 96,32 96,96 32,96 32,160 96,160 96,416 480,416 480,352"/></g><text x="0" y="527" fill="#000000" font-size="5px" font-weight="bold" font-family="'Helvetica Neue', Helvetica, Arial-Unicode, Arial, Sans-serif">Created by Bluetip Design</text><text x="0" y="532" fill="#000000" font-size="5px" font-weight="bold" font-family="'Helvetica Neue', Helvetica, Arial-Unicode, Arial, Sans-serif">from the Noun Project</text></svg>
                </button>
                <button data-intro="Sélectionnez un polygone" data-position="top" @click="setMode('free')" type="button" class="btn"
                    v-bind:class="{ 'active': !readonly &amp;&amp; mode === 'free' }"
                    v-show="isAuthenticated">
                    <svg width="14" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" x="0px" y="0px" viewBox="0 0 30 30" xml:space="preserve"><g transform="translate(-450 -380)"><g xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><path d="M453,395c0,2.209,1.79,4,4,4c1.307,0,2.455-0.635,3.186-1.604l7.121,4.069C467.11,401.938,467,402.456,467,403    c0,2.209,1.79,4,4,4c2.209,0,4-1.791,4-4s-1.791-4-4-4c-1.307,0-2.455,0.635-3.186,1.604l-7.121-4.069    c0.196-0.473,0.307-0.99,0.307-1.534s-0.11-1.062-0.307-1.534l7.121-4.069c0.73,0.969,1.879,1.604,3.186,1.604    c2.209,0,4-1.791,4-4s-1.791-4-4-4c-2.21,0-4,1.791-4,4c0,0.544,0.11,1.062,0.307,1.534l-7.121,4.069    c-0.73-0.969-1.879-1.604-3.186-1.604C454.79,391,453,392.791,453,395z M471,400c1.654,0,3,1.346,3,3s-1.346,3-3,3s-3-1.346-3-3    S469.346,400,471,400z M471,384c1.654,0,3,1.346,3,3s-1.346,3-3,3s-3-1.346-3-3S469.346,384,471,384z M460,395    c0,1.654-1.346,3-3,3s-3-1.346-3-3s1.346-3,3-3S460,393.346,460,395z"/></g></g><text x="0" y="45" fill="#000000" font-size="5px" font-weight="bold" font-family="'Helvetica Neue', Helvetica, Arial-Unicode, Arial, Sans-serif">Created by Hea Poh Lin</text><text x="0" y="50" fill="#000000" font-size="5px" font-weight="bold" font-family="'Helvetica Neue', Helvetica, Arial-Unicode, Arial, Sans-serif">from the Noun Project</text></svg>
                </button>
                <a class="btn" v-bind:href="loginUrl" v-show="!isAuthenticated">
                    <i class="fa fa-sign-in"></i> <small>Connexion</small>
                </a>
            </div>
            <div class="controls-center">
                <zoom-thumbnail
                    data-intro="Déplacez vous dans l'image zoomée"
                    data-position="top"
                    ref="thumbnail"
                    @move="changeViewBox($event)"
                    @moveend="syncViewBox()"
                    @dragstart="hideTooltip"
                    @dragend="showTooltip"
                    v-if="loaded"
                    v-bind:image="thumbnail"
                    v-bind:viewport="viewport"
                    v-bind:parentViewBox="viewBox"
                    v-bind:imageWidth="imageWidth"
                    v-bind:imageHeight="imageHeight"></zoom-thumbnail>
            </div>
            <div class="controls-zoom" data-intro="Zoomez dans l'image" data-position="top">
                <button @click="zoomOut" type="button" class="btn"
                    v-bind:class="{ disabled: scale === 1 }">
                    <i class="fa fa-minus" aria-hidden="true"></i>
                </button>
                <button @click="zoomIn" type="button" class="btn"
                    v-bind:class="{ disabled: scale >= 1.9 }">
                    <i class="fa fa-plus" aria-hidden="true"></i>
                </button>
            </div>
            <div class="controls-right" v-show="isAuthenticated">
                <button @click="addAnnotationWithoutFragment" type="button" class="btn"
                    title="Annoter toute l'image" data-intro="Annoter toute l'image" data-position="top">
                    <i class="fa fa-plus-square" aria-hidden="true"></i>
                </button>
            </div>
        </div>
        <div class="help">
            <a href="#" class="btn btn-default" v-on:click.stop.prevent="showOnboarding"><i class="fa fa-question-circle"></i></a>
        </div>
    </div>
</template>

<script>

    import Snap from 'snapsvg'
    import ShapeRect from './ShapeRect.vue'
    import ShapeFree from './ShapeFree.vue'
    import ZoomThumbnail from './ZoomThumbnail.vue'
    import introJs from 'intro.js'
    import _ from 'lodash'
    import Cookies from 'js-cookie'

    export default {
        props: {
            image: String,
            thumbnail: String,
            annotation: {
                type: Object,
                default: null
            },
            tooltip: {
                type: Boolean,
                default: false
            },
            isAuthenticated: {
                type: Boolean,
                default: false
            },
            annotations: {
                type: Array,
                default: []
            },
            loginUrl: String
        },
        components: {
            shapeRect: ShapeRect,
            shapeFree: ShapeFree,
            zoomThumbnail: ZoomThumbnail,
        },
        data() {
            return {
                paper: null,
                loaded: false,
                mode: 'rect',
                viewport: { width: 0, height: 0 },
                viewBox: [0 , 0, 0, 0],
                zoomFactor: 0.1,
                scale: 1,
                imgMinSize: 0,
                imageWidth: 0,
                imageHeight: 0,
                readonly: false,
                strokeWidth: 15,
                imageRatio: 1
            }
        },
        computed: {
            canvasClass: function() {
                return {
                    'canvas--rect': !this.readonly && this.isAuthenticated && this.mode === 'rect',
                    'canvas--free': !this.readonly && this.isAuthenticated && this.mode === 'free'
                }
            },
            normalizedAnnotations: function() {
                var normalizedAnnotations = _.map(this.annotations, (annotation) => {
                    if (annotation.fragment.length > 0) {
                        var pieces = annotation.fragment.split(';');
                        var path = this.denormalizePath(pieces[0]);
                        var mode = pieces[1].toLowerCase();

                        Object.assign(annotation, {
                            mode: mode,
                            path: path,
                        });
                    }

                    return annotation;
                });

                if (this.annotation) {
                    return _.filter(normalizedAnnotations, (annotation) => {
                        return annotation === this.annotation;
                    });
                }

                return normalizedAnnotations;
            },
            showOverlay: function() {
                return this.annotation && !this.annotation.annotation_guid
            }
        },
        watch: {
            mode: function(mode) {
                this.reset();
                if (this.readonly) { return; }
                this.handleDraw();
            },
            loaded: function(loaded) {
                if (!loaded) { return; }

                var bcr = this.paper.node.getBoundingClientRect();

                console.log('Viewport: %s x %s', bcr.width, bcr.height);

                var viewBox = [0 , 0, this.imageWidth, this.imageHeight];
                var viewport = {
                    width: bcr.width,
                    height: bcr.height
                };

                var imageRatio = 1;
                if (this.imageWidth > this.imageHeight) {
                    imageRatio = viewport.width / this.imageWidth;
                }
                if (this.imageHeight > this.imageWidth) {
                    imageRatio = viewport.height / this.imageHeight;
                }

                var strokeWidth = imageRatio > 1 ? (2 * imageRatio) : (2 / imageRatio);

                Object.assign(this, {
                    viewBox: viewBox,
                    viewport: viewport,
                    imageRatio: imageRatio,
                    strokeWidth: strokeWidth
                });

                this.paper.attr({"viewBox": this.viewBox});

                if (_.size(this.annotations) > 0) {
                    this.readonly = true;
                }

                const onboarding = Cookies.get('__iconolab_onboarding');

                if (!onboarding && this.isAuthenticated) {
                    setTimeout(() => {
                        this.startOnboarding();
                    }, 1000);
                }
            },
            annotation: function(annotation) {
                if (this.isAuthenticated) {
                    this.readonly = !!annotation;
                }
            },
            scale: function(scale) {
                var factor = 0;
                if (scale > 1) {
                    factor = scale - 1;
                }

                if (scale === 1) {
                    this.resetViewBox();
                } else {
                    var center = this.getCenter();

                    var viewBoxW = this.imageWidth - (this.imageWidth * factor);
                    var viewBoxH = this.imageHeight - (this.imageHeight * factor);

                    var viewBox = [
                        center.x - viewBoxW / 2,
                        center.y - viewBoxH / 2,
                        viewBoxW,
                        viewBoxH
                    ];

                    this.hideTooltip();
                    this.animateViewBox(viewBox, () => this.showTooltip());
                }
            },
            readonly: function(readonly, previous) {
                this.reset();

                if (!readonly) {
                    this.handleDraw();
                    if (this.annotation) {
                        this.$emit('close:annotation');
                    }
                }
            }
        },
        mounted() {

            var img = new Image();
            img.onload = (e) => {

                this.paper = new Snap(this.$refs.svg);

                console.log('Image: %s x %s', img.width, img.height);

                Object.assign(this, {
                    imageWidth: img.width,
                    imageHeight: img.height,
                    imgMinSize: Math.min(img.width, img.height),
                    loaded: true
                });

            }
            img.src = this.image;

        },
        methods: {
            onAnnotationClick: function(annotation) {
                this.$emit('click:annotation', annotation);
            },
            toggleReadonly: function() {
                if (!this.isAuthenticated) { return; }
                this.readonly = !this.readonly;
            },
            hideTooltip: function() {
                this.$refs.free.hideTooltip();
                this.$refs.rect.hideTooltip();
            },
            showTooltip: function() {
                if (this.mode === 'free') {
                    this.$refs.free.showTooltip();
                }
                if (this.mode === 'rect') {
                    this.$refs.rect.showTooltip();
                }
            },
            reset: function() {

                this.$refs.rect.clear();
                this.$refs.free.clear();

                this.removeEventHandlers();
                this.resetZoom();
                this.resetViewBox();
            },

            removeEventHandlers: function() {
                this.paper.unmousedown();
                this.paper.unmousemove();
                this.paper.unmouseup();
                this.paper.unclick();
            },

            setMode: function(mode) {
                Object.assign(this, {
                    readonly: false,
                    mode: mode
                });
            },

            getCenter: function() {

                if (!this.$refs.thumbnail) {
                    return {
                        x: this.viewBox[0] + (this.viewBox[2] / 2),
                        y: this.viewBox[1] + (this.viewBox[3] / 2)
                    }
                }

                return this.$refs.thumbnail.getCenter()
            },

            changeViewBox: function(e) {
                const viewBox = this.viewBox.slice();

                viewBox[0] = e.x;
                viewBox[1] = e.y;

                this.paper.attr({ "viewBox": viewBox });
            },

            syncViewBox: function() {
                const viewBox = this.paper.attr('viewBox');
                this.viewBox = [
                    viewBox.x, viewBox.y,
                    viewBox.width, viewBox.height
                ]
            },

            resetZoom: function() {
                this.scale = 1;
                if (this.$refs.thumbnail) {
                    this.$refs.thumbnail.reset();
                }
            },

            animateViewBox: function(viewBox, cb) {
                const viewBoxPrev = this.viewBox.slice();

                Snap.animate(
                    viewBoxPrev, viewBox,
                    (viewBox) => this.paper.attr({ "viewBox": viewBox }),
                    350, mina.easeinout,
                    () => {
                        this.viewBox = viewBox;
                        if (cb) { cb(); }
                    }
                );
            },

            resetViewBox: function() {
                this.animateViewBox([0, 0, this.imageWidth, this.imageHeight]);
            },

            zoomIn: _.throttle(function() {
                console.log('zoomIn', arguments)
                if (this.scale >= 1.9) { return; }
                this.scale = this.scale + (1 * this.zoomFactor);
            }, 400),

            zoomOut: _.throttle(function() {
                if (this.scale === 1) { return; }
                this.scale = this.scale - (1 * this.zoomFactor);
            }, 400),

            zoomOffset: function() {
                return {
                    x: this.viewport.width / this.viewBox[2],
                    y: this.viewport.height / this.viewBox[3]
                };
            },

            computeOffset: function(e) {
                var rect = this.$refs.image.getBoundingClientRect();
                var zoomOffset = this.zoomOffset();
                var offsetX = (e.clientX - rect.left) / Math.min(zoomOffset.x, zoomOffset.y);
                var offsetY  = (e.clientY - rect.top) / Math.min(zoomOffset.x, zoomOffset.y);

                return { x: offsetX, y: offsetY };
            },

            normalizePath: function(path) {

                var xRatio = 100 / this.imageWidth;
                var yRatio = 100 / this.imageHeight;

                if (isNaN(xRatio) || isNaN(yRatio)) {
                    throw new Error('Ratio should be a number.');
                }

                var normalizeMatrix = Snap.matrix(xRatio, 0, 0, yRatio, 0, 0);

                path = Snap.path.map(path, normalizeMatrix).toString();

                if (path.search(/[z|Z]/gi) === -1) {
                    path += " Z";
                }

                return path;
            },

            denormalizePath: function(path) {

                var xRatio = this.imageWidth / 100;
                var yRatio = this.imageHeight / 100;

                if (isNaN(xRatio) || isNaN(yRatio)) {
                    throw new Error('Ratio should be a number.');
                }

                var transformMatrix = Snap.matrix(xRatio, 0, 0, yRatio, 0, 0);

                path = Snap.path.map(path, transformMatrix).toString();

                if (path.search(/[z|Z]/gi) === -1) {
                    path += " Z";
                }

                return path;
            },

            toSVGPath: function() {
                if (this.mode === 'free') {
                    this.$refs.free.toSVGPath();
                }
                if (this.mode === 'rect') {
                    this.$refs.rect.toSVGPath();
                }
            },

            handleDraw: function() {
                if (this.mode === 'free') {
                    this.handleDrawFree();
                }
                if (this.mode === 'rect') {
                    this.handleDrawRect();
                }
            },

            handleDrawFree: function() {

                if (!this.isAuthenticated) { return; }

                this.removeEventHandlers();

                var clickTimeout;

                var clickHandler = function (offsetX, offsetY) {
                    clickTimeout = null;
                    this.$refs.free.addPoint(offsetX, offsetY);
                }

                this.paper.click((e) => {
                    if (clickTimeout) { return; }
                    if (!$(e.target).is('image')) { return; }
                    if (this.$refs.free.closed) { return; }

                    var offset = this.computeOffset(e);
                    var offsetX = offset.x;
                    var offsetY = offset.y;
                    clickTimeout = setTimeout(clickHandler.bind(this, offsetX, offsetY), 190);
                });

            },

            handleDrawRect: function() {

                if (!this.isAuthenticated) { return; }

                this.removeEventHandlers();

                var startPosition = { x: 0, y: 0 };
                var currentPosition = { x: 0, y: 0 };
                var canDraw = false;

                this.paper.mousedown((e) => {

                    if (this.$refs.rect.width > 0 && this.$refs.rect.height > 0) { return; }

                    startPosition = this.computeOffset(e);
                    canDraw = true;
                });

                this.paper.mousemove((e) => {

                    if (!canDraw) { return; }

                    var x, y;
                    currentPosition = this.computeOffset(e);

                    /* bas -> droite */
                    var width = Math.abs(currentPosition.x - startPosition.x);
                    var height = Math.abs(startPosition.y - currentPosition.y);

                    if (currentPosition.y > startPosition.y && currentPosition.x > startPosition.x) {
                        x = startPosition.x;
                        y = startPosition.y;
                    }

                    /* haut -> droite */
                    if (currentPosition.y < startPosition.y && currentPosition.x > startPosition.x) {
                        x = currentPosition.x - width;
                        y = currentPosition.y;
                    }

                    /* haut -> gauche */
                    if (currentPosition.y < startPosition.y && currentPosition.x < startPosition.x) {
                        x = currentPosition.x;
                        y = currentPosition.y;
                    }

                    /* bas -> gauche */
                    if (currentPosition.y > startPosition.y && currentPosition.x < startPosition.x) {
                        x = currentPosition.x
                        y = currentPosition.y - height;
                    }

                    if(!x || !y) { return; }

                    Object.assign(this.$refs.rect, {
                        x: x,
                        y: y,
                        width: width,
                        height: height,
                    });
                });

                this.paper.mouseup((e) => {

                    if (!canDraw) { return; }

                    canDraw = false;

                    if (this.$refs.rect.width === 0 && this.$refs.rect.height === 0) {
                        var currentPosition = this.computeOffset(e);
                        var size = this.imgMinSize / 4;
                        Object.assign(this.$refs.rect, {
                            x: currentPosition.x - (size / 2),
                            y: currentPosition.y - (size / 2),
                            width: size,
                            height: size,
                        });
                    }

                    this.$nextTick(() => {
                        this.$refs.rect.addResizeHandlers();
                        this.$refs.rect.addTooltip();
                    });

                });
            },

            showOnboarding: function(e) {
                this.startOnboarding();
            },

            startOnboarding: function() {
                const intro = introJs.introJs();

                intro.setOptions({
                    nextLabel: 'Suivant',
                    prevLabel: 'Précédent',
                    skipLabel: 'J\'ai compris',
                    doneLabel: 'Terminé'
                })
                intro.onexit(function() {
                    Cookies.set('__iconolab_onboarding', 'true', { expires: 365 });
                })
                intro.start()
            },

            addAnnotationWithoutFragment: function() {
                this.$emit('add:annotation')
            },
        }
    }

</script>

<style scoped>
.wrapper {
    position: relative;
}
.cut-canvas {
    width: 100%;
    height: 700px;
    border: 1px solid #ddd;
}
.canvas--rect:hover {
    cursor: crosshair;
}
.canvas--free:hover {
    cursor: crosshair;
}

.mode-controls .btn > svg {
    margin-top: 4px;
}
.mode-controls .btn-primary > svg {
    fill: #fff;
}

.controls {
    position: absolute;
    transform: translateX(-50%);
    left: 50%;
    bottom: 15px;
    display: flex;
    flex-wrap: nowrap;
    align-items: center;
}

    .controls > * {
        background-color: rgba(34, 34, 34, 0.9);
    }

    .controls .btn {
        background-color: transparent;
        color: #ccc;
        fill: #ccc;
        float: left;
    }
    .controls .btn:active,
    .controls .btn.active {
        color: #fff;
        fill: #fff;
        background-color: #111;
    }
    .controls .btn:hover {
        background-color: #222;
    }
    .controls .controls-left {
        margin-right: 5px;
        border-radius: 4px;
    }
    .controls .controls-draw {
        border-top-left-radius: 4px;
        border-bottom-left-radius: 4px;
    }
    .controls .controls-center {
        border-radius: 4px;
        padding: 8px;
    }
    .controls .controls-zoom {
        border-top-right-radius: 4px;
        border-bottom-right-radius: 4px;
    }
    .controls .controls-right {
        margin-left: 5px;
        border-radius: 4px;
    }

.help {
    position: absolute;
    top: 10px;
    right: 10px;
}

.overlay {
    position: absolute;
    background-color: #c5f2ff;
    opacity: 0.25;
    top: 0;
    right: 0;
    bottom: 0;
    left: 0;
}

</style>