Introduce refactored components using Vue.js.
authorAlexandre Segura <mex.zktk@gmail.com>
Wed, 15 Feb 2017 16:42:06 +0100
changeset 320 81945eedc63f
parent 319 bca3e4b1d0f1
child 321 f8ee375445e6
Introduce refactored components using Vue.js.
src_js/iconolab-bundle/editor.html
src_js/iconolab-bundle/src/components/editor/Annotation.vue
src_js/iconolab-bundle/src/components/editor/Canvas.vue
src_js/iconolab-bundle/src/components/editor/ShapeFree.vue
src_js/iconolab-bundle/src/components/editor/ShapeRect.vue
src_js/iconolab-bundle/src/components/editor/Tooltip.vue
src_js/iconolab-bundle/src/components/editor/index.js
src_js/iconolab-bundle/src/components/editor/mixins/save.js
src_js/iconolab-bundle/src/components/editor/mixins/tooltip.js
src_js/iconolab-bundle/src/components/tagform/ColorButtons.vue
src_js/iconolab-bundle/src/components/tagform/TagList.vue
src_js/iconolab-bundle/src/components/tagform/TagListItem.vue
src_js/iconolab-bundle/src/components/tagform/Typeahead.vue
src_js/iconolab-bundle/src/components/tagform/typeahead.css
src_js/iconolab-bundle/src/main.js
src_js/iconolab-bundle/tagform.html
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src_js/iconolab-bundle/editor.html	Wed Feb 15 16:42:06 2017 +0100
@@ -0,0 +1,59 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="utf-8">
+    <title>Iconolab</title>
+    <link rel="stylesheet" href="/static/iconolab/css/iconolab.css">
+    <style>
+    body {
+        margin-top: 40px;
+    }
+    </style>
+  </head>
+  <body>
+
+    <div class="container-fluid">
+      <div class="row">
+        <div class="col-md-9">
+          <div id="wrapper">
+            <image-annotator ref="annotator" image="img/main-image.jpg"></image-annotator>
+          </div>
+        </div>
+        <div class="col-md-3">
+          <div class="alert alert-success" style="display: none;">Annotation saved in LocalStorage!</div>
+          <pre id="annotation"></pre>
+        </div>
+      </div>
+    </div>
+
+    <script src="/static/iconolab/js/vendor.js"></script>
+    <script src="/static/iconolab/js/iconolab.js"></script>
+    <script>
+
+      function showAlert() {
+        $('.alert-success').show();
+        setTimeout(function() {
+          $('.alert-success').hide();
+        }, 3000);
+      }
+
+      var vm = new Vue({
+        el: '#wrapper'
+      });
+
+      var data = localStorage.getItem('annotation');
+      if (data) {
+        var annotation = JSON.parse(data);
+        vm.$refs.annotator.setAnnotation(annotation);
+        $('#annotation').text(JSON.stringify(annotation, null, 2));
+      }
+
+      vm.$refs.annotator.$on('save', function(data) {
+        console.log('Saving in localStorage', JSON.stringify(data));
+        showAlert();
+        localStorage.setItem('annotation', JSON.stringify(data));
+      });
+
+    </script>
+  </body>
+</html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src_js/iconolab-bundle/src/components/editor/Annotation.vue	Wed Feb 15 16:42:06 2017 +0100
@@ -0,0 +1,27 @@
+<script>
+
+    export default {
+        props: [
+            'title',
+            'description',
+            'fragment',
+            'tags'
+        ],
+        mounted() {
+
+            var tags = [];
+            if (this.tags) {
+                tags = JSON.parse(this.tags);
+            }
+
+            this.$parent.setAnnotation({
+                title: this.title,
+                description: this.description,
+                fragment: this.fragment,
+                tags: tags
+            });
+        },
+        render: function(createElement) {}
+    }
+
+</script>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src_js/iconolab-bundle/src/components/editor/Canvas.vue	Wed Feb 15 16:42:06 2017 +0100
@@ -0,0 +1,452 @@
+<template>
+    <div class="zoom">
+        <div>
+            <svg ref="svg"
+                v-bind:class="{ 'cut-canvas': true, 'canvas--rect': mode === 'rect', 'canvas--free': mode === 'free' }"
+                xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+                <image ref="image" v-if="loaded" xmlns:xlink="http://www.w3.org/1999/xlink" v-bind:xlink:href="image" x="0" y="0" />
+                <!-- This slot may contain annotation data -->
+                <slot></slot>
+                <shape-rect ref="rect" v-show="loaded &amp;&amp; mode == 'rect'"
+                    v-bind:paper="paper" v-bind:original-annotation="annotation"></shape-rect>
+                <shape-free ref="free" v-show="loaded &amp;&amp; mode == 'free'"
+                    v-bind:paper="paper" v-bind:original-annotation="annotation"></shape-free>
+            </svg>
+        </div>
+        <div class="zoomer">
+            <div class="btn-group-vertical" role="group" aria-label="...">
+                <button @click="zoomIn" type="button" class="btn btn-default"><i class="fa fa-plus" aria-hidden="true"></i></button>
+                <button @click="zoomOut" type="button" class="btn btn-default"><i class="fa fa-minus" aria-hidden="true"></i></button>
+            </div>
+        </div>
+        <div class="mode-controls">
+            <div class="btn-group" role="group" aria-label="...">
+                <button @click="mode = 'rect'" type="button" v-bind:class="{ btn: true, 'btn-default': true, 'btn-primary': mode === 'rect'}">
+                    <svg width="24" 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 @click="mode = 'free'" type="button" v-bind:class="{ btn: true, 'btn-default': true, 'btn-primary': mode === 'free'}">
+                    <svg width="24" 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>
+            </path>
+        </div>
+    </div>
+</template>
+
+<script>
+
+    import Snap from 'snapsvg'
+    import ShapeRect from './ShapeRect.vue'
+    import ShapeFree from './ShapeFree.vue'
+
+    export default {
+        props: [
+            'image',
+        ],
+        components: {
+            shapeRect: ShapeRect,
+            shapeFree: ShapeFree
+        },
+        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,
+                annotation: null
+            }
+        },
+        watch: {
+            mode: function(mode) {
+                this.reset();
+                if (mode === 'free') {
+                    this.handleDrawFree();
+                }
+                if (mode === 'rect') {
+                    this.handleDrawRect();
+                }
+            },
+            loaded: function(loaded) {
+                if (!loaded) { return; }
+
+                if (this.annotation) {
+
+                    var pieces = this.annotation.fragment.split(';');
+                    var path = pieces[0];
+                    var mode = pieces[1].toLowerCase();
+
+                    this.mode = mode;
+
+                    this.$nextTick(() => {
+                        path = this.denormalizePath(path);
+                        if (mode === 'free') {
+                            this.$refs.free.fromSVGPath(path);
+                        }
+                        if (mode === 'rect') {
+                            this.$refs.rect.fromSVGPath(path);
+                        }
+                    });
+
+                } else {
+
+                    if (this.mode === 'free') {
+                        this.handleDrawFree();
+                    }
+                    if (this.mode === 'rect') {
+                        this.handleDrawRect();
+                    }
+                }
+            }
+        },
+        mounted() {
+
+            var img = new Image();
+            img.onload = (e) => {
+
+                this.paper = new Snap(this.$refs.svg);
+
+                this.imgMinSize = Math.min(img.width, img.height);
+
+                // FIXME
+                // Image is actually NOT loaded at this step
+                setTimeout(() => {
+
+                    console.log('Viewport: %s x %s',
+                        this.paper.node.clientWidth, this.paper.node.clientHeight);
+
+                    var viewBox = [0 , 0, img.width, img.height];
+                    var viewport = {
+                        width: this.paper.node.clientWidth,
+                        height: this.paper.node.clientHeight
+                    };
+
+                    Object.assign(this, {
+                        imageWidth: img.width,
+                        imageHeight: img.height,
+                        viewBox: viewBox,
+                        viewport: viewport,
+                    });
+
+                    var handlerSize = 15 * Math.min(viewBox[2], viewBox[3]) / viewport.width;
+
+                    this.$refs.rect.handlerSize = handlerSize;
+                    this.$refs.free.handlerRadius = handlerSize / 2;
+
+                    this.paper.attr({"viewBox": this.viewBox});
+
+                    this.loaded = true;
+
+                }, 100);
+
+            }
+            img.src = this.image;
+
+        },
+        methods: {
+
+            reset: function() {
+                // Clear shapes
+                this.$refs.rect.clear();
+                this.$refs.free.clear();
+
+                // Remove event handlers
+                this.paper.unmousedown();
+                this.paper.unmousemove();
+                this.paper.unmouseup();
+                this.paper.unclick();
+            },
+
+            setAnnotation: function(annotation) {
+                this.annotation = annotation;
+            },
+
+            getCenter: function() {
+                return {
+                    x: this.viewBox[0] + (this.viewBox[2] / 2),
+                    y: this.viewBox[1] + (this.viewBox[3] / 2)
+                }
+            },
+
+            resetZoom: function() {
+                this.scale = 1;
+                this.viewBox = [0, 0, this.imageWidth, this.imageHeight];
+                this.paper.attr({ "viewBox": this.viewBox });
+            },
+
+            zoomIn: function() {
+
+                if ( this.scale === 9) { this.scale--; return; }
+
+                var center = this.getCenter();
+                var scaleFactor = this.zoomFactor * this.scale;
+
+                var viewBoxW = this.imgMinSize - (this.imgMinSize * scaleFactor);
+                var viewBoxH = viewBoxW;
+
+                const viewBoxPrev = this.viewBox.slice(0);
+
+                this.viewBox[0] = center.x - viewBoxW / 2;
+                this.viewBox[1] = center.y - viewBoxH / 2;
+                this.viewBox[2] = viewBoxW;
+                this.viewBox[3] = viewBoxH;
+
+                this.scale++;
+
+                // this.paper.attr({ "viewBox": this.viewBox });
+
+                this.$refs.rect.hideTooltip();
+                Snap.animate(
+                    viewBoxPrev, this.viewBox,
+                    (viewBox) => this.paper.attr({ "viewBox": viewBox }),
+                    300, mina.easeinout,
+                    () => this.$refs.rect.showTooltip()
+                );
+            },
+
+            zoomOut: function() {
+
+                if (this.scale === 1) { this.resetZoom(); return; }
+
+                var center = this.getCenter();
+                var scaleFactor = this.zoomFactor * (this.scale - 1);
+
+                var viewBoxW = this.imgMinSize - (this.imgMinSize * scaleFactor);
+                var viewBoxH = viewBoxW;
+
+                var topX = center.x - viewBoxW / 2;
+                var topY = center.y - viewBoxH / 2;
+
+                const viewBoxPrev = this.viewBox.slice(0);
+
+                this.viewBox[0] = topX; //deal with X and Y
+                this.viewBox[1] = topY;
+                this.viewBox[2] = viewBoxW;
+                this.viewBox[3] = viewBoxH;
+
+                this.scale--;
+
+                // this.paper.attr({ "viewBox": this.viewBox });
+
+                this.$refs.rect.hideTooltip();
+                Snap.animate(
+                    viewBoxPrev, this.viewBox,
+                    (viewBox) => this.paper.attr({ "viewBox": viewBox }),
+                    300, mina.easeinout,
+                    () => this.$refs.rect.showTooltip()
+                );
+            },
+
+            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 };
+            },
+
+            computeHandlerSize: function(e) {
+                return 60 * Math.min(this.viewBox[2], this.viewBox[3]) / this.imageWidth;
+            },
+
+            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;
+            },
+
+            handleDrawFree: function() {
+
+                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() {
+
+                var startPosition = { x: 0, y: 0 };
+                var currentPosition = { x: 0, y: 0 };
+                var canDraw = false;
+
+                var computeOffset = (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 };
+                }
+
+                this.paper.mousedown((e) => {
+
+                    if (this.$refs.rect.width > 0 && this.$refs.rect.height > 0) { return; }
+
+                    startPosition = computeOffset(e);
+                    canDraw = true;
+                });
+
+                this.paper.mousemove((e) => {
+
+                    if (!canDraw) { return; }
+
+                    var x, y;
+                    currentPosition = 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 = computeOffset(e);
+                        Object.assign(this.$refs.rect, {
+                            x: currentPosition.x,
+                            y: currentPosition.y,
+                            width: this.imgMinSize / 4,
+                            height: this.imgMinSize / 4,
+                        });
+                    }
+
+                    this.$nextTick(() => {
+                        this.$refs.rect.addResizeHandlers();
+                        this.$refs.rect.addTooltip();
+                    });
+
+                });
+            }
+        }
+    }
+
+</script>
+
+<style scoped>
+.zoom {
+    position: relative;
+}
+.zoomer {
+    position: absolute;
+    bottom: 30px;
+    right: 30px;
+}
+.cut-canvas {
+    width: 100%;
+    height: 800px;
+}
+.canvas--rect:hover {
+    cursor: crosshair;
+}
+.canvas--free:hover {
+    cursor: pointer;
+}
+.mode-controls {
+    position: absolute;
+    top: 15px;
+    left: 15px;
+}
+.mode-controls .btn > svg {
+    margin-top: 4px;
+}
+.mode-controls .btn-primary > svg {
+    fill: #fff;
+}
+
+</style>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src_js/iconolab-bundle/src/components/editor/ShapeFree.vue	Wed Feb 15 16:42:06 2017 +0100
@@ -0,0 +1,192 @@
+<template>
+    <g>
+        <path ref="path" v-bind:d="path" stroke="#000000" fill="#bdc3c7" style="stroke-width: 10; stroke-dasharray: 20, 20; opacity: 0.6;"></path>
+        <circle
+            v-for="(point, key) in points"
+            :key="key"
+            v-bind:data-key="key"
+            ref="handlers"
+            v-bind:cx="point.x"
+            v-bind:cy="point.y"
+            v-bind:r="handlerRadius"
+            stroke="#000000" stroke-width="10"
+            style="opacity: 0.9;"
+            v-bind:class="{ handler: true, 'handler--first': key === 0 &amp;&amp; !closed }"></circle>
+    </g>
+</template>
+
+<script>
+
+    import Snap from 'snapsvg'
+    import tooltip from './mixins/tooltip'
+    import save from './mixins/save'
+
+    export default {
+        mixins: [ tooltip, save ],
+        props: [
+            'paper',
+            'original-annotation',
+        ],
+        data() {
+            return {
+                path: '',
+                closed: false,
+                points: [],
+                handlerRadius: 30
+            }
+        },
+        mounted() {
+
+        },
+        watch: {
+            closed: function(closed) {
+                if (closed) {
+                    this.path += ' Z';
+                    setTimeout(() => this.addTooltip(), 50);
+                }
+            },
+            // Redraw the path when the points have changed
+            points: function(points) {
+
+                var path = "M";
+
+                if (points.length <= 1) {
+                    return;
+                }
+
+                path += points[0].x + ',' + points[0].y;
+
+                for (var i = 0; i < points.length; i++) {
+                    if (i == 0) continue;
+
+                    var pointInfos = points[i];
+                    var lPath = "L" + pointInfos.x + "," + pointInfos.y;
+                    path += " " + lPath;
+                }
+
+                if (this.closed) { path += ' Z'; }
+
+                this.path = path;
+            }
+        },
+        methods: {
+
+            addPoint: function(x, y) {
+
+                this.points.push({ x: x, y: y });
+
+                // Attach events to last point once DOM has been refreshed
+                // @link https://vuejs.org/v2/guide/reactivity.html
+                this.$nextTick(() => {
+                    var handler = this.$refs.handlers[this.$refs.handlers.length - 1];
+                    this.addResizeHandler(handler);
+                });
+            },
+
+            clear: function() {
+                this.destroyTooltip();
+                Object.assign(this, {
+                    points: [],
+                    closed: false,
+                    path: ''
+                });
+            },
+
+            getTooltipTarget: function() {
+                return this.$refs.path;
+            },
+
+            fromSVGPath: function(pathString) {
+
+                var segments = Snap.parsePathString(pathString);
+                var points = [];
+
+                // Don't use this.addPoint to avoid
+                // race condition when registering events
+                segments.map((segment) => {
+                    if (segment[0] !== 'Z') {
+                        points.push({ x: segment[1], y: segment[2] });
+                    }
+                });
+
+                this.points = points;
+                this.closed = true;
+
+                this.$nextTick(() => {
+                    this.addResizeHandlers();
+                });
+            },
+
+            toSVGPath: function() {
+                return this.$parent.normalizePath(this.path) + ';FREE'
+            },
+
+            addResizeHandler: function(handler) {
+
+                var circle = new Snap(handler);
+
+                var isDragged = false;
+
+                circle.click(function(e) {
+                    var key = parseInt(this.attr('data-key'), 10);
+                    if (key === 0 && self.points.length > 2) {
+                        self.closed = true;
+                    }
+                });
+
+                // Remove point on double click
+                circle.dblclick((e) => {
+                    var circle = new Snap(e.target);
+                    var key = parseInt(circle.attr('data-key'), 10);
+                    this.points.splice(key, 1);
+                });
+
+                var self = this;
+
+                var dragEvents = {
+                    onMove: function(dx, dy, x, y, e) {
+
+                        isDragged = true;
+
+                        var offset = self.$parent.computeOffset(e);
+                        this.attr({ cx: offset.x, cy: offset.y });
+
+                        var key = parseInt(this.attr('data-key'), 10);
+
+                        // Must use splice for reactivity to work
+                        // @see https://vuejs.org/v2/guide/list.html#Mutation-Methods
+                        self.points.splice(key, 1, { x: offset.x, y: offset.y });
+                    },
+                    onStart: () => this.hideTooltip(),
+                    onEnd: function(e) {
+                        if (!isDragged) { return; }
+
+                        isDragged = false;
+                        self.showTooltip();
+                    }
+                }
+
+                circle.drag(dragEvents.onMove, dragEvents.onStart, dragEvents.onEnd);
+            },
+
+            addResizeHandlers: function() {
+                this.$refs.handlers.forEach((handler) => this.addResizeHandler(handler));
+            }
+        }
+    }
+
+</script>
+
+<style scoped>
+.handler {
+    fill: #fff;
+}
+/*.handler:hover {
+    cursor: -webkit-grab;
+    cursor: -moz-grab;
+    cursor: grab;
+}*/
+.handler--first {
+    fill: yellow;
+}
+</style>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src_js/iconolab-bundle/src/components/editor/ShapeRect.vue	Wed Feb 15 16:42:06 2017 +0100
@@ -0,0 +1,208 @@
+<template>
+<g ref="g" v-bind:transform="transform">
+    <rect
+        ref="shape"
+        x="0" y="0"
+        v-bind:width="width" v-bind:height="height"
+        fill="#bdc3c7"
+        stroke="#000"
+        v-bind:stroke-width="handlerSize / 5"
+        v-bind:stroke-dasharray="(handlerSize / 5) + ',' + (handlerSize / 5)"
+        style="opacity: 0.6;"
+        class="shape"></rect>
+    <rect
+        ref="topLeft"
+        v-show="width > 0 &amp;&amp; height > 0"
+        v-bind:x="(handlerSize / 2) * -1" v-bind:y="(handlerSize / 2) * -1"
+        v-bind:width="handlerSize" v-bind:height="handlerSize"
+        fill="#ffffff"
+        stroke="#000000" v-bind:stroke-width="handlerSize / 5" class="handler-rect handler-top-left"></rect>
+    <rect
+        ref="bottomRight"
+        v-show="width > 0 &amp;&amp; height > 0"
+        v-bind:x="width - (handlerSize / 2)" v-bind:y="height - (handlerSize / 2)"
+        v-bind:width="handlerSize" v-bind:height="handlerSize"
+        fill="#ffffff"
+        stroke="#000000" v-bind:stroke-width="handlerSize / 5" class="handler-rect handler-bottom-right"></rect>
+</g>
+</template>
+
+<script>
+
+    import Snap from 'snapsvg'
+    import tooltip from './mixins/tooltip'
+    import save from './mixins/save'
+
+    export default {
+        mixins: [ tooltip, save ],
+        props: [
+            'paper',
+            'original-annotation',
+        ],
+        data() {
+            return {
+                transform: 'translate(0, 0)',
+                isResizing: false,
+                x: 0, y: 0,
+                width: 0, height: 0,
+                handlerSize: 60
+            }
+        },
+        mounted() {
+
+            var self = this;
+
+            var groupEvents = {
+                onMove: function(dx, dy) {
+                    if (self.isResizing) { return; }
+
+                    var snapInvMatrix = this.transform().diffMatrix.invert();
+                    var tdx = snapInvMatrix.x(dx, dy);
+                    var tdy = snapInvMatrix.y(dx, dy);
+
+                    var transformValue = this.data('origTransform') + (this.data('origTransform') ? "T" : "t") + [tdx, tdy];
+                    this.transform(transformValue);
+                },
+                onStart: function() {
+                    this.data('origTransform', this.transform().local);
+                    self.$emit('drag:start');
+                },
+                onEnd: () => self.$emit('drag:end')
+            }
+
+            var g = new Snap(this.$refs.g);
+            g.drag(groupEvents.onMove, groupEvents.onStart, groupEvents.onEnd);
+        },
+        watch: {
+            x: function (val, oldVal) {
+                this.transform = 'translate(' + this.x + ', ' + this.y + ')';
+            },
+            y: function (val, oldVal) {
+                this.transform = 'translate(' + this.x + ', ' + this.y + ')';
+            },
+        },
+        methods: {
+
+            clear: function() {
+
+                var shape = new Snap(this.$refs.shape);
+                var topLeftHandler = new Snap(this.$refs.topLeft);
+                var bottomRightHandler = new Snap(this.$refs.bottomRight);
+
+                shape.node.removeAttribute('transform');
+                topLeftHandler.node.removeAttribute('transform');
+                bottomRightHandler.node.removeAttribute('transform');
+
+                this.destroyTooltip();
+
+                Object.assign(this, {
+                    transform: 'translate(0, 0)',
+                    x: 0, y: 0,
+                    width: 0, height: 0,
+                });
+            },
+
+            getTooltipTarget: function() {
+                return this.$refs.shape;
+            },
+
+            addResizeHandlers: function() {
+
+                var self = this;
+
+                var shape = new Snap(this.$refs.shape);
+                var topLeftHandler = new Snap(this.$refs.topLeft);
+                var bottomRightHandler = new Snap(this.$refs.bottomRight);
+
+                var handlerEvents = {
+                    onMove: function(dx, dy) {
+
+                        var snapInvMatrix = this.transform().diffMatrix.invert();
+                        snapInvMatrix.e = snapInvMatrix.f = 0;
+                        var tdx = snapInvMatrix.x(dx, dy);
+                        var tdy = snapInvMatrix.y(dx, dy);
+
+                        this.transform( "t" + [ tdx, tdy ] + this.data("origTransform") );
+
+                        // Update shape
+
+                        var newWidth = bottomRightHandler.getBBox().x - topLeftHandler.getBBox().x;
+                        var newHeight = bottomRightHandler.getBBox().y - topLeftHandler.getBBox().y;
+
+                        var attr = {
+                            width: newWidth,
+                            height: newHeight
+                        };
+                        if (this === topLeftHandler) {
+                            attr.transform = shape.data('origTransform') + (shape.data('origTransform') ? "T" : "t") + [tdx, tdy];
+                        }
+
+                        shape.attr(attr);
+                    },
+                    onStart: function() {
+                        self.isResizing = true;
+                        shape.data("origTransform", shape.transform().local);
+                        this.data('origTransform', this.transform().local);
+                    },
+                    onEnd: function() {
+                        self.isResizing = false;
+                    }
+                }
+
+                topLeftHandler.drag(handlerEvents.onMove, handlerEvents.onStart, handlerEvents.onEnd);
+                bottomRightHandler.drag(handlerEvents.onMove, handlerEvents.onStart, handlerEvents.onEnd);
+            },
+
+            fromSVGPath: function(pathString, imageWidth, imageHeight) {
+                var bBox = Snap.path.getBBox(pathString);
+
+                Object.assign(this, {
+                    x: bBox.x, y: bBox.y,
+                    width: bBox.width, height: bBox.height
+                });
+
+                setTimeout(() => {
+                    this.addResizeHandlers();
+                    this.addTooltip();
+                }, 50);
+            },
+
+            toSVGPath: function() {
+
+                var shape = new Snap(this.$refs.shape);
+
+                var shapePath;
+                var bBox = shape.getBBox();
+                var transform = shape.transform();
+
+                if (!transform.global.length) {
+                    shapePath = shape.getBBox().path;
+                } else {
+                    var shapeX = shape.node.getAttribute('x');
+                    var shapeY = shape.node.getAttribute('y');
+                    var transformMatrix = transform.totalMatrix;
+                    var fakeShape = this.paper.rect(transformMatrix.x(shapeX, shapeY),transformMatrix.y(shapeX, shapeY), bBox.width, bBox.height);
+                    shapePath = fakeShape.getBBox().path;
+                    fakeShape.remove();
+                }
+
+                var path = Snap.path.toAbsolute(shapePath).toString();
+
+                return this.$parent.normalizePath(path) + ';RECT';
+            }
+        }
+    }
+
+</script>
+
+<style scoped>
+.shape:hover {
+    cursor: move;
+}
+.handler-top-left:hover {
+    cursor: nw-resize;
+}
+.handler-bottom-right:hover {
+    cursor: se-resize;
+}
+</style>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src_js/iconolab-bundle/src/components/editor/Tooltip.vue	Wed Feb 15 16:42:06 2017 +0100
@@ -0,0 +1,105 @@
+<template>
+
+    <form>
+        <button @click="close" type="button" class="close" data-dismiss="alert" aria-label="Close">
+            <span aria-hidden="true">&times;</span>
+        </button>
+        <div class="form-group" v-bind:class="titleFormGroup" style="clear: both;">
+            <label class="control-label">Titre</label>
+            <input name="title" v-model="title" type="text" class="form-control input-sm" placeholder="Donnez un titre court">
+        </div>
+        <div class="form-group">
+            <label class="control-label">Description</label>
+            <textarea name="description" v-model="description" class="form-control input-sm" placeholder="Décrivez ce que vous voyez"></textarea>
+        </div>
+        <div class="form-group">
+            <label class="control-label">Mots-clé</label>
+            <tag-list ref="taglist" v-bind:original-tags="originalTags"></tag-list>
+        </div>
+        <button @click="save" class="btn btn-block btn-sm btn-primary">Valider</button>
+    </form>
+
+</template>
+
+<script>
+
+    import TagList from '../tagform/TagList.vue'
+
+    export default {
+        props: [
+            'original-title',
+            'original-description',
+            'original-tags'
+        ],
+        components: {
+            'tag-list': TagList
+        },
+        data() {
+            return {
+                title: '',
+                description: '',
+                tags: [],
+                error: false
+            }
+        },
+        computed: {
+            titleFormGroup: function() {
+                return {
+                    'has-error': this.error
+                }
+            }
+        },
+        mounted() {
+            if (this.originalTitle) {
+                this.title = this.originalTitle;
+            }
+            if (this.originalDescription) {
+                this.description = this.originalDescription;
+            }
+            this.$on('error', (err) => {
+                if (err.title) {
+                    this.error = true;
+                }
+            });
+        },
+        methods: {
+            close: function(e) {
+                e.preventDefault();
+                this.$emit('close');
+            },
+            save: function(e) {
+                e.preventDefault();
+
+                this.error = false;
+                if (this.title.trim().length === 0) {
+                    this.$emit('error', {
+                        title: true
+                    });
+                    return;
+                }
+
+                this.$emit('save', {
+                    title: this.title,
+                    description: this.description,
+                    tags: this.$refs.taglist.tags
+                });
+            }
+        }
+    }
+
+</script>
+
+<style>
+.popover {
+    min-width: 300px;
+}
+.popover-content .form-group {
+    margin-bottom: 10px;
+}
+.popover-content .form-group label {
+    font-size: 12px;
+}
+.popover-content .taglist {
+    margin: 10px 0 15px;
+}
+</style>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src_js/iconolab-bundle/src/components/editor/index.js	Wed Feb 15 16:42:06 2017 +0100
@@ -0,0 +1,7 @@
+import Canvas from './Canvas.vue'
+import Annotation from './Annotation.vue'
+
+export default {
+    Canvas: Canvas,
+    Annotation: Annotation,
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src_js/iconolab-bundle/src/components/editor/mixins/save.js	Wed Feb 15 16:42:06 2017 +0100
@@ -0,0 +1,12 @@
+export default {
+    methods: {
+        save: function(data) {
+
+            Object.assign(data, {
+                fragment: this.toSVGPath()
+            });
+
+            this.$parent.$emit('save', data);
+        }
+    }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src_js/iconolab-bundle/src/components/editor/mixins/tooltip.js	Wed Feb 15 16:42:06 2017 +0100
@@ -0,0 +1,80 @@
+import Snap from 'snapsvg'
+import Tooltip from '../Tooltip.vue'
+
+var popoverOptions = {
+    placement: 'auto',
+    container: 'body',
+    trigger: 'manual',
+    html: true,
+    title: '',
+    content: '',
+}
+
+export default {
+    methods: {
+        addTooltip: function() {
+
+            var vm = new Vue(Tooltip);
+
+            if (this.originalAnnotation) {
+                vm.originalTitle = this.originalAnnotation.title;
+                vm.originalDescription = this.originalAnnotation.description;
+                vm.originalTags = this.originalAnnotation.tags;
+            }
+
+            vm.$mount(jQuery('<div>').get(0));
+
+            vm.$on('close', () => {
+                this.clear();
+            });
+            vm.$on('save', (data) => {
+                this.save(data);
+            });
+
+            popoverOptions.content = vm.$el;
+
+            var target = new Snap(this.getTooltipTarget());
+
+            var $el = $(target.node);
+            $el
+                .popover(popoverOptions)
+                .popover('show');
+
+            this.$on('drag:start', function() {
+                $el.popover('hide');
+            });
+            this.$on('drag:end', function() {
+                $el.popover('show');
+            });
+
+            $el.on('shown.bs.popover', (e) => {
+                var $tip = $el.data('bs.popover').$tip;
+                $tip.find('input[name="title"]').focus();
+            });
+        },
+        destroyTooltip: function() {
+            var target = new Snap(this.getTooltipTarget());
+
+            var $el = $(target.node);
+            if ($el.data('bs.popover')) {
+                $el.popover('destroy');
+            }
+        },
+        hideTooltip: function() {
+            var target = new Snap(this.getTooltipTarget());
+
+            var $el = $(target.node);
+            if ($el.data('bs.popover')) {
+                $el.popover('hide');
+            }
+        },
+        showTooltip: function() {
+            var target = new Snap(this.getTooltipTarget());
+
+            var $el = $(target.node);
+            if ($el.data('bs.popover')) {
+                $el.popover('show');
+            }
+        },
+    }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src_js/iconolab-bundle/src/components/tagform/ColorButtons.vue	Wed Feb 15 16:42:06 2017 +0100
@@ -0,0 +1,139 @@
+<template>
+    <div>
+        <div class="btn-group" data-toggle="buttons">
+            <label
+                class="btn btn-default"
+                v-bind:class="{ 'btn--highlight': value &amp;&amp; i <= value }"
+                ref="buttons"
+                v-for="i in 5"
+                v-bind:data-value="i"
+                v-on:mouseenter="onMouseEnter"
+                v-on:mouseleave="onMouseLeave"
+                v-on:click="onClick">
+                <input type="radio" name="options" autocomplete="off">
+                <span class="sr-only">{{ i }}</span>
+            </label>
+        </div>
+    </div>
+</template>
+
+<script>
+
+    export default {
+
+        props: [ 'original-value' ],
+
+        data() {
+            return {
+                value: null,
+            }
+        },
+
+        mounted() {
+            if (this.originalValue) {
+                this.value = this.originalValue;
+            }
+        },
+
+        methods: {
+
+            animate: function() {
+
+                var timeout;
+                var times = 0;
+                var direction = 1;
+
+                var increment = function() {
+                    if (this.value === 5) {
+                        direction = -1;
+                    }
+                    if (this.value === 1) {
+                        direction = 1;
+                    }
+
+                    this.value += direction;
+
+                    if (++times === 10) {
+                        clearTimeout(timeout);
+                        if (this.originalValue === null) {
+                            this.value = null;
+                        }
+                        return;
+                    }
+
+                    timeout = setTimeout(increment.bind(this), 100)
+                }
+
+                increment.apply(this);
+            },
+
+            onMouseEnter: function(e) {
+                var value = $(e.target).data('value');
+                this.$refs.buttons.forEach((button) => {
+                    if ($(button).data('value') <= value) {
+                        $(button).addClass('btn--highlight');
+                    } else {
+                        $(button).removeClass('btn--highlight');
+                    }
+                });
+            },
+
+            onMouseLeave: function(e) {
+                if (!this.value) {
+                    this.$refs.buttons.forEach((button) => {
+                        $(button).removeClass('btn--highlight');
+                    });
+                } else {
+                    this.$refs.buttons.forEach((button) => {
+                        if ($(button).data('value') <= this.value) {
+                            $(button).addClass('btn--highlight');
+                        } else {
+                            $(button).removeClass('btn--highlight');
+                        }
+                    });
+                }
+            },
+
+            onClick: function(e) {
+                var value = parseInt($(e.target).data('value'));
+                this.value = value;
+                this.$emit('change', { value: value });
+            }
+
+        }
+
+    }
+
+</script>
+
+<style scoped>
+
+.btn-group {
+    margin-bottom: 10px;
+}
+
+.btn--highlight {
+    box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);
+}
+
+.btn--highlight[data-value="1"] {
+    background-color: #fc5f62;
+}
+
+.btn--highlight[data-value="2"] {
+    background-color: #f7c136;
+}
+
+.btn--highlight[data-value="3"] {
+    background-color: #f7e53b;
+}
+
+.btn--highlight[data-value="4"] {
+    background-color: #ebf63d;
+}
+
+.btn--highlight[data-value="5"] {
+    background-color: #b9e78b;
+}
+
+</style>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src_js/iconolab-bundle/src/components/tagform/TagList.vue	Wed Feb 15 16:42:06 2017 +0100
@@ -0,0 +1,69 @@
+<template>
+    <div>
+        <div class="tag-list">
+            <tag-list-item ref="items"
+                v-for="(tag, index) in tags" v-if="tags.length > 0"
+                v-bind:label="tag.tag_label"
+                v-bind:index="index"
+                v-bind:original-accuracy="tag.accuracy"
+                v-bind:original-relevancy="tag.relevancy"></tag-list-item>
+        </div>
+        <typeahead ref="typeahead" placeholder="Rechercher"></typeahead>
+    </div>
+</template>
+
+<script>
+
+    import TagListItem from './TagListItem.vue'
+    import Typeahead from './Typeahead.vue'
+
+    export default {
+        props: ['original-tags'],
+        components: {
+            "typeahead": Typeahead,
+            "tag-list-item": TagListItem
+        },
+        data() {
+            return {
+                tags: []
+            }
+        },
+        watch: {
+            tags: function(tags) {
+                this.$emit('change', { tags: tags });
+            }
+        },
+        mounted() {
+            if (this.originalTags) {
+                this.tags = this.originalTags;
+            }
+            this.$refs.typeahead.$on('selected', (tag) => {
+                this.tags.push(tag);
+            });
+        },
+
+        methods: {
+            hideAll: function() {
+                this.$refs.items.forEach((item) => item.hide());
+            },
+            replaceItemAt: function(index, data) {
+                const tag = this.tags[index];
+                Object.assign(tag, data);
+                this.tags.splice(index, 1, tag);
+            },
+            removeItemAt: function(index) {
+                this.tags.$remove(this.tags[index]);
+            },
+        }
+
+    }
+
+</script>
+
+<style scoped>
+
+.tag-list {
+    margin-bottom: 15px;
+}
+
+</style>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src_js/iconolab-bundle/src/components/tagform/TagListItem.vue	Wed Feb 15 16:42:06 2017 +0100
@@ -0,0 +1,202 @@
+<template>
+    <div class="tag-container">
+        <div @click="toggle" class="tag-item">
+            <span class="tag-title">{{ label }}</span>
+            <div class="tag-item-buttons">
+                <div class="tag-item-btn tag-item-accuracy" v-bind:data-value="accuracy">
+                    <button class="btn btn-default">{{ accuracy || '?' }}</button>
+                </div>
+                <div class="tag-item-btn tag-item-relevancy" v-bind:data-value="relevancy">
+                    <button class="btn btn-default">{{ relevancy || '?' }}</button>
+                </div>
+                <div class="tag-item-btn tag-item-delete">
+                    <button class="btn btn-default"
+                        @click="remove"><i class="fa fa-times" aria-hidden="true"></i></button>
+                </div>
+            </div>
+        </div>
+        <div class="collapse">
+            <div class="tag-item-form">
+                <div>
+                    <label>Fiabilité</label>
+                    <small>Êtes-vous sûr de votre tag ?</small>
+                    <color-buttons ref="accuracy"
+                        @change="accuracy = $event.value"
+                        v-bind:original-value="accuracy"></color-buttons>
+                </div>
+                <div>
+                    <label>Pertinence</label>
+                    <small>Votre tag est-il indispensable ?</small>
+                    <color-buttons ref="relevancy"
+                        @change="relevancy = $event.value"
+                        v-bind:original-value="relevancy"></color-buttons>
+                </div>
+            </div>
+        </div>
+    </div>
+</template>
+
+<script>
+
+    import ColorButtons from './ColorButtons.vue'
+    import Typeahead from './Typeahead.vue'
+
+    export default {
+        props: [
+            'index',
+            'label',
+            'original-accuracy',
+            'original-relevancy'
+        ],
+        components: {
+            "typeahead": Typeahead,
+            "color-buttons": ColorButtons
+        },
+        data() {
+            return {
+                accuracy: null,
+                relevancy: null,
+                isNew: true,
+            }
+        },
+        watch: {
+            accuracy: function(accuracy) {
+                this.onChange();
+            },
+            relevancy: function(relevancy) {
+                this.onChange();
+            }
+        },
+        mounted() {
+
+            this.accuracy = this.originalAccuracy;
+            this.relevancy = this.originalRelevancy;
+            this.isNew = (!this.originalAccuracy && !this.originalRelevancy);
+
+            this.$refs.accuracy.value = this.originalAccuracy;
+            this.$refs.relevancy.value = this.originalRelevancy;
+
+            $(this.$el).find('.collapse').collapse({ toggle: false });
+
+            if (this.isNew) {
+                this.$parent.hideAll();
+                this.show();
+                this.$refs.accuracy.animate();
+            }
+        },
+
+        methods: {
+            isComplete: function() {
+                return this.accuracy && this.relevancy;
+            },
+            onChange: function() {
+                this.$parent.replaceItemAt(this.index, {
+                    accuracy: this.accuracy,
+                    relevancy: this.relevancy
+                });
+                if (this.isNew && this.isComplete()) {
+                    this.isNew = false;
+                    this.hide();
+                }
+            },
+            show: function() {
+                $(this.$el).find('.collapse').collapse('show');
+            },
+            hide: function() {
+                $(this.$el).find('.collapse').collapse('hide');
+            },
+            toggle: function(e) {
+                e.preventDefault();
+                this.$parent.hideAll();
+                $(this.$el).find('.collapse').collapse('toggle');
+            },
+            remove: function(e) {
+                e.preventDefault();
+                e.stopPropagation();
+                this.$parent.removeItemAt(this.index);
+            },
+        }
+
+    }
+
+</script>
+
+<style scoped>
+
+.tag-item-btn {
+    float: left;
+}
+
+.tag-item-btn button {
+    border: none;
+    background-color: transparent;
+}
+
+.tag-item-btn[data-value="1"] {
+    background-color: #fc5f62;
+}
+
+.tag-item-btn[data-value="2"] {
+    background-color: #f7c136;
+}
+
+.tag-item-btn[data-value="3"] {
+    background-color: #f7e53b;
+}
+
+.tag-item-btn[data-value="4"] {
+    background-color: #ebf63d;
+}
+
+.tag-item-btn[data-value="5"] {
+    background-color: #b9e78b;
+}
+
+.tag-title {
+    padding: 5px;
+    max-width: 140px;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    overflow: hidden;
+}
+
+.tag-item-delete {
+    /* padding-left: 15px; */
+}
+
+.tag-item-form {
+    padding: 15px;
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    flex-wrap: wrap;
+}
+
+.tag-item-form label,
+.tag-item-form small {
+    display: block;
+    margin-bottom: 5px;
+}
+
+.tag-container {
+    border: 1px solid #ccc;
+    border-bottom: none;
+}
+
+.tag-list .tag-container:last-of-type {
+    border-bottom: 1px solid #ccc;
+}
+
+.tag-item {
+    cursor: pointer;
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    border-bottom: 1px solid #ccc;
+}
+.tag-item:hover {
+    background-color: #f5f5f5;
+}
+
+</style>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src_js/iconolab-bundle/src/components/tagform/Typeahead.vue	Wed Feb 15 16:42:06 2017 +0100
@@ -0,0 +1,160 @@
+<template>
+    <div>
+        <input type="text"
+            class="form-control"
+            v-bind:placeholder="placeholder"
+            autocomplete="off"
+            v-model="query"
+            v-on:keyup.8="checkQuery"
+            @keydown.down="down"
+            @keydown.up="up"
+            @keydown.enter="hit($event)"
+            @keydown.esc="reset"
+            @keyup="update" />
+        <ul v-show="hasItems">
+            <li v-for="(item, index) in items" :class="activeClass(index)" @mousedown="hit" @mousemove="setActive(index)">
+                <span v-text="item.tag_label"></span>
+            </li>
+        </ul>
+    </div>
+</template>
+
+<style scoped src="./typeahead.css"></style>
+
+<script>
+
+    import typeahead from 'vue-typeahead'
+
+    var autoCompletePath = "https://lookup.dbpedia.org/api/search/PrefixSearch?MaxHits=5";
+    var wikipediaPath = "https://fr.wikipedia.org/w/api.php"
+    var parentsMethods = {
+        reset: typeahead.methods.reset
+    };
+
+
+    var get = function (url, data) {
+        var dfd = jQuery.Deferred();
+        var promise = jQuery.getJSON(url, data).done( function (response) {
+            var envelope = {};
+            envelope.data = response;
+            dfd.resolve(envelope);
+        }).fail(dfd.reject);
+        return dfd.promise();
+    }
+
+    export default {
+        mixins: [typeahead],
+
+        props: ['placeholder'],
+
+        mounted() {
+        },
+
+        data() {
+            return {
+                src: autoCompletePath,
+                limit: 7,
+                minChars: 2,
+                showAddButton: false,
+                datasource: "wikipedia",
+                selectedTags: "[]",
+                items: [],
+                queryParamName: "QueryString",
+            }
+        },
+
+        methods: {
+
+            checkQuery () {
+                if (this.query.length === 0) {
+                    this.reset();
+                }
+            },
+
+            fetch() {
+                if (this.datasource === "wikipedia") {
+                    return this.fetchWikiPedia();
+                }
+
+                else {
+                    var request = {};
+                    request[this.queryParamName] = this.query;
+                    return get(this.src, query);
+                }
+            },
+
+            fetchWikiPedia () {
+                this.src = wikipediaPath;
+                var self = this;
+                var request = {
+                    'action': 'opensearch',
+                    'format': 'json',
+                    'search': this.query
+                };
+
+                /* make request */
+                var dfd = jQuery.Deferred();
+                jQuery.ajax({
+                    url: this.src,
+                    data: request,
+                    dataType: "jsonp",
+                    success: function (response) {
+                        var envelope = {};
+                        envelope.data = response;
+                        dfd.resolve(envelope);
+                    }
+                });
+                return dfd.promise();
+            },
+
+            reset () {
+                this.showAddButton = false;
+                parentsMethods.reset.call(this);
+            },
+
+            prepareWikipediaResponse (data) {
+                var results = [];
+                if (data.length !== 4) { return results; }
+                var labelsList = data[1];
+                var urlsList = data[3];
+
+                if (labelsList.length !== urlsList.length) {
+                    return;
+                }
+
+                labelsList.map(function(item, index) {
+                    var tagItem = {};
+                    tagItem.tag_label = item;
+                    var link = urlsList[index];
+                    link = link.replace("https://fr.wikipedia.org/wiki/", "http://fr.dbpedia.org/resource/");
+                    tagItem.tag_link = decodeURI(link);
+                    tagItem.accuracy = null;
+                    tagItem.relevancy = null;
+                    results.push(tagItem);
+                });
+
+                return results;
+            },
+
+            prepareResponseData (data) {
+                var responseData = (typeof data === 'string') ? JSON.parse(data): data;
+
+                if(this.datasource === "wikipedia") {
+                    responseData = this.prepareWikipediaResponse(responseData);
+                }
+
+                if (Array.isArray(responseData) && !responseData.length) {
+                    this.showAddButton = true;
+                }
+                return responseData;
+            },
+
+            onHit (selected) {
+                this.$emit('selected', selected);
+                this.reset();
+            }
+        }
+
+    }
+
+</script>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src_js/iconolab-bundle/src/components/tagform/typeahead.css	Wed Feb 15 16:42:06 2017 +0100
@@ -0,0 +1,70 @@
+Typeahead {
+  position: relative;
+}
+.selected-tags { border: 1px solid red; width: 200px !important; }
+.selected-tags select {display: inline-block;}
+
+.Typeahead__input {
+  width: 100%;
+  font-size: 14px;
+  color: #2c3e50;
+  line-height: 1.42857143;
+  box-shadow: inset 0 1px 4px rgba(0,0,0,.4);
+  -webkit-transition: border-color ease-in-out .15s,-webkit-box-shadow ease-in-out .15s;
+  transition: border-color ease-in-out .15s,box-shadow ease-in-out .15s;
+  font-weight: 300;
+  padding: 12px 26px;
+  border: none;
+  border-radius: 22px;
+  letter-spacing: 1px;
+  box-sizing: border-box;
+}
+.Typeahead__input:focus {
+  border-color: #4fc08d;
+  outline: 0;
+  box-shadow: inset 0 1px 1px rgba(0,0,0,.075),0 0 8px #4fc08d;
+}
+.tag-item {border: 1px solid red;}
+.fa-times {
+  cursor: pointer;
+}
+
+ul {
+  padding: 0;
+  margin-top: 8px;
+  min-width: 100%;
+  background-color: #fff;
+  list-style: none;
+  border-radius: 4px;
+  box-shadow: 0 0 10px rgba(0,0,0, 0.25);
+  z-index: 1000;
+}
+li {
+  padding: 10px 16px;
+  border-bottom: 1px solid #ccc;
+  cursor: pointer;
+}
+li:first-child {
+  border-radius: 4px 4px 0 0;
+}
+li:last-child {
+  border-radius: 0 0 4px 4px;
+  border-bottom: 0;
+}
+span {
+  display: block;
+  color: #2c3e50;
+}
+.active {
+  background-color: #3aa373;
+}
+.active span {
+  color: white;
+}
+.name {
+  font-weight: 700;
+  font-size: 18px;
+}
+.screen-name {
+  font-style: italic;
+}
--- a/src_js/iconolab-bundle/src/main.js	Thu Feb 09 16:57:05 2017 +0100
+++ b/src_js/iconolab-bundle/src/main.js	Wed Feb 15 16:42:06 2017 +0100
@@ -12,6 +12,9 @@
 import DescriptionViewer from './components/collectionhome/descriptionviewer/DescriptionViewer.vue'
 import DiffViewer from './components/diffviewer/diffviewer.vue'
 import jsondiffpatch from 'jsondiffpatch'
+import Editor from './components/editor'
+import ColorButtons from './components/tagform/ColorButtons.vue'
+import TagList from './components/tagform/TagList.vue'
 
 const Diff = require('diff')
 Vue.config.ignoredElements = ["mask"];
@@ -26,10 +29,17 @@
         Typeahead: Typeahead,
         MergeTool: MergeTool,
         Zoomview: Zoomview,
-        DiffViewer: DiffViewer
+        DiffViewer: DiffViewer,
+        Editor: Editor,
+        ColorButtons: ColorButtons,
+        TagList: TagList
     }
 };
 
+Vue.component('color-buttons', ColorButtons);
+Vue.component('image-annotator', Editor.Canvas);
+Vue.component('annotation', Editor.Annotation);
+
 if (!window.iconolab) {
     window.iconolab = iconolab;
 }
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src_js/iconolab-bundle/tagform.html	Wed Feb 15 16:42:06 2017 +0100
@@ -0,0 +1,82 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="utf-8">
+    <title>Iconolab</title>
+    <link rel="stylesheet" href="/static/iconolab/css/iconolab.css">
+    <style>
+    body {
+      margin-top: 40px;
+    }
+    </style>
+  </head>
+  <body>
+    <div class="container">
+      <div class="row">
+        <div class="col-md-6">
+          <div id="tagform">
+            <color-buttons ref="buttons" v-for="i in [null, 1, 2, 3, 4, 5]" v-bind:original-value="i"></color-buttons>
+          </div>
+          <hr>
+          <button id="animate" class="btn btn-default">Animate</button>
+        </div>
+        <div class="col-md-6">
+          <div id="taglist"></div>
+          <hr>
+          <pre id="tags-json"></pre>
+        </div>
+      </div>
+    </div>
+
+    <script src="/static/iconolab/js/vendor.js"></script>
+    <script src="/static/iconolab/js/iconolab.js"></script>
+    <script>
+
+      var vm = new Vue({
+        el: '#tagform'
+      });
+
+      $('#animate').on('click', function(e) {
+        $.each(vm.$refs.buttons, function() {
+          this.animate();
+        });
+      })
+      //
+
+
+      function refreshTags() {
+        $('#tags-json').html(JSON.stringify(taglist.tags, null, 2));
+      }
+
+      var TagList = iconolab.VueComponents.TagList;
+      var taglist = new Vue(TagList);
+      taglist.tags = [
+        {
+          "tag_label": "Football",
+          "tag_input": "http://fr.dbpedia.org/resource/Football",
+          "accuracy": 5,
+          "relevancy": 2
+        }, {
+          "tag_label": "Sexion d'Assaut",
+          "tag_input": "http://fr.dbpedia.org/resource/Sexion_d'Assaut",
+          "accuracy": 3,
+          "relevancy": 1
+        },
+        {
+          "tag_label": "Fillon",
+          "tag_input": "http://fr.dbpedia.org/resource/Fillon",
+          "accuracy": 4,
+          "relevancy": 3
+        }
+      ];
+      taglist.$mount('#taglist');
+
+      taglist.$on('change', function(e) {
+        refreshTags()
+      });
+
+      refreshTags();
+
+    </script>
+  </body>
+</html>