Add intro.js for on boarding.
<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 && readonly && 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 && readonly && 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 && !readonly && mode == 'rect'"
v-bind:paper="paper"
v-bind:stroke-width="strokeWidth"
:readonly="false"></shape-rect>
<shape-free ref="free"
v-show="loaded && !readonly && 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="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>
<button data-intro="Changez de mode" data-position="top" @click="toggleReadonly" type="button" class="btn"
v-show="isAuthenticated">
<i v-show="readonly" class="fa fa-toggle-on" style="transform: rotate(180deg)"></i>
<i v-show="!readonly" class="fa fa-toggle-on"></i>
</button>
<button data-intro="Sélectionnez un rectangle" data-position="top" @click="setMode('rect')" type="button" class="btn"
v-bind:class="{ 'active': !readonly && mode === 'rect', 'disabled': readonly }"
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 && mode === 'free', 'disabled': readonly }"
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>
<zoom-thumbnail
data-intro="Déplacez vous dans l'image zoomée"
data-position="top"
class="controls-center"
ref="thumbnail"
@change="changeViewBox($event)"
@dragstart="hideTooltip"
@dragend="showTooltip"
v-bind:image="thumbnail"
v-bind:viewport="viewport"
v-bind:viewBox="viewBox"
v-bind:imageWidth="imageWidth"
v-bind:imageHeight="imageHeight"></zoom-thumbnail>
<div class="controls-right" 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>
</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;
}
},
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) {
setTimeout(() => {
var 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()
}, 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) {
if (this.readonly) { return; }
this.mode = mode;
},
getCenter: function() {
return {
x: this.viewBox[0] + (this.viewBox[2] / 2),
y: this.viewBox[1] + (this.viewBox[3] / 2)
}
},
changeViewBox: function(e) {
const viewBox = this.viewBox.slice();
viewBox[0] = e.x;
viewBox[1] = e.y;
this.paper.attr({ "viewBox": viewBox });
},
resetZoom: function() {
this.scale = 1;
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: function() {
if (this.scale >= 1.9) { return; }
this.scale = this.scale + (1 * this.zoomFactor);
},
zoomOut: function() {
if (this.scale === 1) { return; }
this.scale = this.scale - (1 * this.zoomFactor);
},
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();
});
});
}
}
}
</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;
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 {
border-top-left-radius: 4px;
border-bottom-left-radius: 4px;
}
.controls .controls-center {
border-radius: 4px;
padding: 8px 8px 4px 8px;
}
.controls .controls-right {
border-top-right-radius: 4px;
border-bottom-right-radius: 4px;
}
</style>