Roughly implement annotation navigator.
authorAlexandre Segura <mex.zktk@gmail.com>
Mon, 20 Feb 2017 17:29:55 +0100
changeset 323 55c024fc7c60
parent 322 1da12bcb9894
child 324 8f19067caeec
Roughly implement annotation navigator.
src/iconolab/forms/annotations.py
src/iconolab/forms/comments.py
src/iconolab/models.py
src/iconolab/settings/dev.py.tmpl
src/iconolab/templates/iconolab/detail_image.html
src/iconolab/templates/partials/comment_form.html
src/iconolab/templatetags/iconolab_tags.py
src/iconolab/urls.py
src/iconolab/views/comments.py
src/iconolab/views/objects.py
src/requirements/base.txt
src_js/iconolab-bundle/src/components/editor/AnnotationForm.vue
src_js/iconolab-bundle/src/components/editor/Canvas.vue
src_js/iconolab-bundle/src/components/editor/Comment.vue
src_js/iconolab-bundle/src/components/editor/CommentList.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/index.js
src_js/iconolab-bundle/src/components/editor/mixins/tooltip.js
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/iconolab.scss
src_js/iconolab-bundle/src/main.js
--- a/src/iconolab/forms/annotations.py	Mon Feb 20 17:14:08 2017 +0100
+++ b/src/iconolab/forms/annotations.py	Mon Feb 20 17:29:55 2017 +0100
@@ -8,28 +8,27 @@
     pass
 
 class AnnotationRevisionForm(forms.ModelForm):
-    tags = forms.CharField()
-    comment = forms.CharField(widget=forms.Textarea)
-    
+    description = forms.CharField(required=False)
+    fragment = forms.CharField(required=False)
+    tags = forms.CharField(widget=forms.Textarea, required=False)
+    comment = forms.CharField(widget=forms.Textarea, required=False)
+
     class Meta:
         model = AnnotationRevision
         fields = ('title', 'description', 'fragment')
         widgets = {
-            'fragment': forms.HiddenInput(),
-            'tags': forms.HiddenInput()
+            'fragment': forms.Textarea(),
+            'tags': forms.Textarea()
         }
-        
+
     def __init__(self, *args, **kwargs):
         super(AnnotationRevisionForm, self).__init__(*args, **kwargs)
-        for key in self.fields:
-            if key != "comment":
-                self.fields[key].required = False
-        
+
     def clean(self, *args, **kwargs):
         cleaned_data = super(AnnotationRevisionForm, self).clean(*args, **kwargs)
         if not (cleaned_data.get("title", "") or cleaned_data.get("description", "") or json.loads(cleaned_data.get("tags", "[]"))):
             raise forms.ValidationError("Au moins un de ces champs doit être renseigné: description, titre ou mot-clé", code="missing_fields")
-    
+
     def tags_json(self):
         if self.instance:
             tags_list = []
@@ -41,4 +40,4 @@
                 })
             return json.dumps(tags_list)
         else:
-            return '[]' 
\ No newline at end of file
+            return '[]'
--- a/src/iconolab/forms/comments.py	Mon Feb 20 17:14:08 2017 +0100
+++ b/src/iconolab/forms/comments.py	Mon Feb 20 17:29:55 2017 +0100
@@ -19,17 +19,15 @@
 class IconolabCommentForm(XtdCommentForm):
     email = forms.EmailField(required=False)
     metacategories = MetaCategoryMultipleChoiceFields(widget=forms.CheckboxSelectMultiple, queryset=None, required=False)
-        
+
     def __init__(self, *args, **kwargs):
         super(IconolabCommentForm, self).__init__(*args, **kwargs)
         self.collection = self.target_object.image.item.collection
-        logger.debug(self.fields)
         self.fields["metacategories"].queryset = self.collection.metacategories.all()
-        logger.debug(self.fields["metacategories"].queryset)
         self.fields.pop('email')
-    
+
     def get_comment_create_data(self):
         self.cleaned_data['email'] = ''
         data = super(IconolabCommentForm, self).get_comment_create_data()
         data.update({'user_email': ''})
-        return data 
\ No newline at end of file
+        return data
--- a/src/iconolab/models.py	Mon Feb 20 17:14:08 2017 +0100
+++ b/src/iconolab/models.py	Mon Feb 20 17:29:55 2017 +0100
@@ -9,6 +9,7 @@
 from django.contrib.auth.models import User
 from django.contrib.contenttypes.fields import GenericRelation
 from django.contrib.contenttypes.models import ContentType
+from rest_framework import serializers
 from django.db import models, transaction
 from django.utils.text import slugify
 from django_comments_xtd.models import XtdComment
@@ -146,6 +147,10 @@
         return self.item.metadatas.measurements
 
     @property
+    def latest_annotations(self):
+        return self.annotations.all().order_by('-created')
+
+    @property
     def tag_labels(self):
         tag_list = []
         for annotation in self.annotations.all():
@@ -287,7 +292,6 @@
                 }
                 for revision in latest_revision_on_annotations
             ]
-        logger.debug(contributed_list)
         return contributed_list
 
     @transaction.atomic
@@ -310,9 +314,7 @@
         user_comments = IconolabComment.objects.filter(
             user=user, content_type__app_label='iconolab', content_type__model='annotation').order_by('-submit_date')
         if ignore_revisions_comments:
-            logger.debug(user_comments.count())
             user_comments = user_comments.filter(revision__isnull=True)
-            logger.debug(user_comments.count())
         all_user_comments_data = [
             (comment.object_pk, comment.submit_date) for comment in user_comments]
         unique_ordered_comments_data = []
@@ -328,7 +330,6 @@
             'image__item__collection'
         ).distinct()
         sorted_annotations_list = []
-        logger.debug(unique_ordered_comments_data)
         for comment_data in unique_ordered_comments_data:
             annotation_obj = commented_annotations.get(
                 id=comment_data["annotation_id"])
@@ -677,6 +678,7 @@
             pass
         for tag_data in tags_dict:
             tag_string = tag_data.get("tag_input")
+            tag_label = tag_data.get("tag_label")
             tag_accuracy = tag_data.get("accuracy", 0)
             tag_relevancy = tag_data.get("relevancy", 0)
 
@@ -688,6 +690,7 @@
                 else:
                     tag_obj = Tag.objects.create(
                         link=tag_string,
+                        label=tag_label
                     )
             else:
                 new_tag_link = settings.BASE_URL + '/' + slugify(tag_string)
@@ -710,6 +713,7 @@
                 relevancy=tag_relevancy
             )
 
+    # FIXME Avoid calling DBPedia all the time
     def get_tags_json(self):
         """
             This method returns the json data that will be sent to the js to display
@@ -796,6 +800,27 @@
                     pass
         return json.dumps(final_list)
 
+class AnnotationRevisionSerializer(serializers.ModelSerializer):
+    tags = serializers.SerializerMethodField('get_normalized_tags')
+    annotation_guid = serializers.SerializerMethodField()
+
+    def get_normalized_tags(self, obj):
+        tags = []
+        for tagging_info in obj.tagginginfo_set.all():
+            tags.append({
+                "tag_label": tagging_info.tag.label,
+                "tag_link": tagging_info.tag.link,
+                "accuracy": tagging_info.accuracy,
+                "relevancy": tagging_info.relevancy,
+            })
+        return tags
+
+    def get_annotation_guid(self, obj):
+        return obj.annotation.annotation_guid
+
+    class Meta:
+        model = AnnotationRevision
+        fields = ('annotation_guid', 'title', 'description', 'fragment', 'tags')
 
 class Tag(models.Model):
     """
@@ -814,7 +839,7 @@
         return self.link.startswith(settings.INTERNAL_TAGS_URL)
 
     def __str__(self):
-        return self.label_slug + ":" + self.label
+        return "Tag:" + self.label
 
 
 class TaggingInfo(models.Model):
@@ -829,7 +854,7 @@
     relevancy = models.IntegerField()
 
     def __str__(self):
-        return str(str(self.tag.label_slug) + ":to:" + str(self.revision.revision_guid))
+        return str(str(self.tag.label) + ":to:" + str(self.revision.revision_guid))
 
 
 class IconolabComment(XtdComment):
@@ -869,6 +894,12 @@
         ).filter(thread_id__gte=self.thread_id).filter(order__lte=self.order).count() + 1) // settings.COMMENTS_PER_PAGE_DEFAULT + 1
 
 
+class IconolabCommentSerializer(serializers.ModelSerializer):
+    class Meta:
+        model = IconolabComment
+        fields = '__all__'
+
+
 class MetaCategory(models.Model):
     """
         Metacategories are objects that can be linked to a comment to augment it
--- a/src/iconolab/settings/dev.py.tmpl	Mon Feb 20 17:14:08 2017 +0100
+++ b/src/iconolab/settings/dev.py.tmpl	Mon Feb 20 17:29:55 2017 +0100
@@ -36,7 +36,7 @@
 
 
 
-BASE_URL = ''
+BASE_URL = 'http://localhost:8000'
 if JS_DEV_MODE:
     STATIC_URL = 'http://localhost:8001/static/'
 else:
@@ -73,7 +73,8 @@
     'haystack',
     'iconolab.apps.IconolabApp',
     'sorl.thumbnail',
-    'notifications'
+    'notifications',
+    'rest_framework',
 ]
 
 
--- a/src/iconolab/templates/iconolab/detail_image.html	Mon Feb 20 17:14:08 2017 +0100
+++ b/src/iconolab/templates/iconolab/detail_image.html	Mon Feb 20 17:29:55 2017 +0100
@@ -1,26 +1,160 @@
 {% extends 'iconolab_base.html' %}
 
 {% load staticfiles %}
-
 {% load thumbnail %}
-
 {% load iconolab_tags %}
 
-{% block content %}
-<div class="row">
-	<div class="col-md-10 col-md-offset-1 text-center">
-          <a class="btn btn-default btn-sm" href="{% url 'collection_home' collection_name %}"><i class="fa fa-list"></i> Retour à la liste d'objets</a>
-          <a class="btn btn-default btn-sm" href="{% url 'item_detail' collection_name image.item.item_guid %}"><i class="fa fa-eye" aria-hidden="true"></i> Voir l'objet de cette image</a>
-  		  <a class="btn btn-default btn-sm" href="{% url 'annotation_create' collection_name image_guid %}"><i class="fa fa-plus"></i> Annoter l'image</a>
-        <br><br>
-    
-		{% thumbnail image.media "800x800" crop=False as im %}
-			<img src="{{ im.url }}" width="{{ im.width }}" height="{{ im.height }}">
-		{% endthumbnail %}
-        <br>
-	</div>
+{% block content_fluid %}
+
+<div class="annotation-navigator">
+  <div class="annotation-navigator-list">
+    <div id="annotations-{{image.image_guid}}" class="image-annotations-list">
+      {% if image.annotations %}
+        <div class="list-group">
+        {% for annotation in image.latest_annotations %}
+          <a href="#{{ annotation.annotation_guid }}" class="list-group-item"
+            data-annotation-id="{{ annotation.annotation_guid }}"
+            data-revision-id="{{ annotation.current_revision.revision_guid }}">
+            <button type="button" class="close" data-dismiss="alert" aria-label="Close"><span aria-hidden="true">&times;</span></button>
+            <h4 class="list-group-item-heading">{{ annotation.current_revision.title }}</h4>
+            <p class="list-group-item-text">{{ annotation.current_revision.description }}</p>
+            {% for tagging_info in annotation.current_revision.tagginginfo_set.all %}
+              <span class="label label-warning">{{ tagging_info.tag.label }}</span>
+            {% endfor %}
+          </a>
+        {% endfor %}
+        </div>
+      {% else %}
+        Pas d'annotation pour cette image
+      {% endif %}
+    </div>
+  </div>
+  <div class="annotation-navigator-canvas">
+    {% with image.media as img %}
+    <image-annotator ref="annotator" image="{{ img.url }}"></image-annotator>
+    {% endwith %}
+    <form id="form-annotation" action="{% url 'annotation_create' collection_name image.image_guid %}" method="POST">
+      {% csrf_token %}
+      <input type="hidden" name="{{ form.title.name }}">
+      <input type="hidden" name="{{ form.description.name }}">
+      <input type="hidden" name="{{ form.fragment.name }}">
+      <input type="hidden" name="{{ form.tags.name }}">
+    </form>
+  </div>
+  <div class="annotation-navigator-panel">
+    <div class="panel panel-default">
+      <div class="panel-body">
+        <annotation-form ref="panel"
+          action="{% url 'annotation_edit' collection_name image_guid ':annotation_guid' %}">
+          {% csrf_token %}
+        </annotation-form>
+        <comment-list ref="commentList"></comment-list>
+        <form id="form-comment">
+            <div class="form-group">
+              <textarea class="form-control input-sm" placeholder="Ajouter mon commentaire..." disabled></textarea>
+            </div>
+            <button type="submit" class="btn btn-block btn-sm btn-primary" disabled>Commenter</button>
+        </form>
+      </div>
+    </div>
+  </div>
+
 </div>
+{% endblock %}
 
-{% include "partials/image_annotations_list.html" with annotation_list=image.annotations.all header="Annotations de l'image" %}
+{% block footer_js %}
+<script>
+
+  var currentPath = "{{ request.path }}";
+  var annotationEditURL = "{% url 'annotation_edit' collection_name image_guid ':annotation_guid' %}";
+
+  var vm = new Vue({
+    el: '.annotation-navigator'
+  });
+
+  var annotations = {};
+  {% if image.annotations %}
+    {% for annotation in image.annotations.all %}
+  annotations["{{ annotation.current_revision.revision_guid }}"] = {{ annotation.current_revision|json|safe }}
+    {% endfor %}
+  {% endif %}
+
+  function displayAnnotation($el) {
+
+    var annotation = $el.data('annotation-id');
+    var revision = $el.data('revision-id');
+
+    // Load the form for selected annotation via AJAX
+    $.get('/comments/annotation/' + annotation + '/comment-form', { next: currentPath + '#' + annotation })
+      .then(function(form) {
+        $.getJSON('/comments/annotation/' + annotation + '/comments.json')
+          .then(function(comments) {
+
+            $('#annotation-panel form:last').replaceWith(form);
+
+            $('.list-group a[data-annotation-id]').removeClass('active');
+            $el.addClass('active');
+
+            vm.$refs.annotator.annotation = annotations[revision];
+            vm.$refs.panel.setAnnotation(annotations[revision]);
+            vm.$refs.commentList.comments = comments;
+
+            location.hash = '#' + annotation;
+
+          });
+        });
+  }
 
-{% endblock %}
\ No newline at end of file
+  $('.list-group a[data-annotation-id]').on('click', function(e) {
+    e.preventDefault();
+    var $el = $(e.currentTarget);
+    displayAnnotation($el);
+  });
+
+  vm.$refs.annotator.$on('save', function(data) {
+    $('#form-annotation').find('input[name="title"]').val(data.title);
+    $('#form-annotation').find('input[name="description"]').val(data.description);
+    $('#form-annotation').find('input[name="fragment"]').val(data.fragment);
+
+    var tags = data.tags.map(function(tag) {
+      return {
+        tag_input: (typeof tag.tag_link === "string" && tag.tag_link.length) ? tag.tag_link : tag.tag_label,
+        tag_label: tag.tag_label,
+        accuracy: tag.accuracy,
+        relevancy: tag.relevancy
+      }
+    })
+    $('#form-annotation').find('input[name="tags"]').val(JSON.stringify(tags));
+    $('#form-annotation').submit();
+  })
+
+  $('.list-group a[data-annotation-id] .close').on('click', function(e) {
+    e.preventDefault();
+    e.stopPropagation();
+
+    var $target = $(e.currentTarget);
+    $target.closest('.list-group-item').removeClass('active');
+
+    vm.$refs.annotator.annotation = null;
+    vm.$refs.panel.reset();
+    vm.$refs.commentList.comments = [];
+
+    $('#annotation-panel form:last').find('textarea, [type="submit"]').attr('disabled', true);
+
+    location.hash = '';
+  });
+
+  function router () {
+    var url = location.hash.slice(1);
+    if (url.length) {
+      var annotation = url;
+      var $el = $('.list-group a[data-annotation-id="'+annotation+'"]');
+      displayAnnotation($el);
+    }
+  }
+  // window.addEventListener('hashchange', router);
+  window.addEventListener('load', router);
+
+</script>
+{% endblock %}
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/iconolab/templates/partials/comment_form.html	Mon Feb 20 17:29:55 2017 +0100
@@ -0,0 +1,67 @@
+<form action="{% url 'post_comment' %}" method="POST">
+  {% csrf_token %}
+  {{ comment_form.content_type }}
+  {{ comment_form.object_pk }}
+  {{ comment_form.timestamp }}
+  {{ comment_form.security_hash }}
+
+  <input type="hidden" name="next" value="{{ next }}">
+  <input type="hidden" name="reply_to" value="0"></input>
+
+  <fieldset class="form-group {% if comment_form.comment.errors %}has-error{% endif %}">
+    {% if comment_form.comment.errors %}
+      <div class="alert alert-danger" role="alert">
+        <span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true"></span>
+        <span class="sr-only">Erreur :</span>
+          {{ comment_form.comment.errors | striptags }}
+      </div>
+    {% endif %}
+    <textarea class="form-control input-sm"
+      name="{{ comment_form.comment.name }}"
+      id="id_{{ comment_form.comment.name }}"
+      placeholder="Ajouter mon commentaire..."></textarea>
+  </fieldset>
+  <input class="btn btn-block btn-sm btn-primary" type="submit" name="post" class="submit-post" value="Commenter">
+
+  {% comment %}
+  {#
+  <input id="reply-to-field" type="hidden" name="reply_to" value="0"></input>
+  <input type="hidden" name="next" value="{% url 'annotation_detail' collection_name image_guid annotation_guid %}">
+  <h4> Commenter ({{user.username}}) </h4>
+  <div class="reply-to-container">
+    <label id="form-reply-to-label" class="control-label reply-to-label" for="id_{{ comment_form.comment.name }}">En réponse à : </label>
+    <div class="alert alert-info reply-to-alert">
+       <p class="reply-to-content comment-content"></p>
+       <hr class="comment-separator">
+       <div class="reply-to-subtext comment-subtext"></div>
+       <div class="reply-to-label-list comment-metacategories"></div>
+    </div>
+    <a class="btn btn-default btn-sm reply-to-cancel">Annuler et répondre sur le fil principal</a><br><br>
+  </div>
+  <fieldset class="form-group {% if comment_form.comment.errors %}has-error{% endif %}">
+    {% if comment_form.comment.errors %}
+      <div class="alert alert-danger" role="alert">
+        <span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true"></span>
+        <span class="sr-only">Erreur :</span>
+          {{ comment_form.comment.errors | striptags }}
+      </div>
+    {% endif %}
+    <textarea class="form-control"
+      name="{{ comment_form.comment.name }}"
+      id="id_{{ comment_form.comment.name }}" ></textarea>
+  </fieldset>
+  <fieldset class="form-group {% if comment_form.metacategories.errors %}has-error{% endif %}">
+    <div class="input-group">
+      {% for metacategory in comment_form.metacategories %}
+        <label class="checkbox-inline" for="{{metacategory.id_for_label}}">
+            {{ metacategory.tag }} {{metacategory.choice_label}}
+        </label>
+      {% endfor %}
+    </div>
+  </fieldset>
+  <p class="submit">
+    <input class="btn btn-default" type="submit" name="post" class="submit-post" value="Publier"/>
+  </p>
+  #}
+  {% endcomment %}
+</form>
--- a/src/iconolab/templatetags/iconolab_tags.py	Mon Feb 20 17:14:08 2017 +0100
+++ b/src/iconolab/templatetags/iconolab_tags.py	Mon Feb 20 17:29:55 2017 +0100
@@ -1,5 +1,8 @@
 from django.template import Library
-import sys
+from django.core.serializers import serialize
+from iconolab.models import AnnotationRevision, AnnotationRevisionSerializer
+from rest_framework.renderers import JSONRenderer
+
 from iconolab import __version__
 
 register = Library()
@@ -25,7 +28,7 @@
 	else:
 		path_infos = path.split(";")
 		if len(path_infos) > 0 :
-			result = path_infos[0] 
+			result = path_infos[0]
 	return result
 
 
@@ -48,9 +51,16 @@
 @register.filter
 def get_item(dictionary, key):
     return dictionary.get(key)
-   
-   
+
+
 @register.filter
 def addstr(arg1, arg2):
     """concatenate arg1 & arg2"""
-    return str(arg1) + str(arg2)
\ No newline at end of file
+    return str(arg1) + str(arg2)
+
+@register.filter(name='json')
+def json_dumps(data):
+    if isinstance(data, AnnotationRevision) :
+        serializer = AnnotationRevisionSerializer(data)
+        return JSONRenderer().render(serializer.data)
+    return serialize('json', [data])
--- a/src/iconolab/urls.py	Mon Feb 20 17:14:08 2017 +0100
+++ b/src/iconolab/urls.py	Mon Feb 20 17:29:55 2017 +0100
@@ -64,6 +64,8 @@
     url(r'^search/', include('iconolab.search_indexes.urls', namespace='search_indexes')),
     url(r'^comments/', include('django_comments_xtd.urls')),
     url(r'^comments/annotation/post', views.comments.post_comment_iconolab, name="post_comment"),
+    url(r'^comments/annotation/(?P<annotation_guid>[^/]+)/comment-form$', views.comments.get_comment_form, name="get_comment_form"),
+    url(r'^comments/annotation/(?P<annotation_guid>[^/]+)/comments.json$', views.comments.get_annotation_comments_json, name="get_annotation_comments_json"),
 
     url(r'^compare/$', views.objects.TestView.as_view(), name="compare_view")
     #url(r'^search/', include('haystack.urls'), name="search_iconolab"),
--- a/src/iconolab/views/comments.py	Mon Feb 20 17:14:08 2017 +0100
+++ b/src/iconolab/views/comments.py	Mon Feb 20 17:29:55 2017 +0100
@@ -1,19 +1,22 @@
 from django.apps import apps
+from django.http import HttpResponse
 from django.views.decorators.csrf import csrf_protect
-from django.views.decorators.http import require_POST
+from django.views.decorators.http import require_POST, require_GET
 from django.core.exceptions import ObjectDoesNotExist, ValidationError
+from django.shortcuts import render
 import datetime
 import django_comments
 from django_comments import signals
 from django_comments.views.utils import next_redirect, confirmation_view
 from django_comments.views.comments import CommentPostBadRequest
-from iconolab.models import MetaCategoryInfo
+from iconolab.models import MetaCategoryInfo, Annotation, IconolabComment, IconolabCommentSerializer
+from rest_framework.renderers import JSONRenderer
 
 @csrf_protect
 @require_POST
 def post_comment_iconolab(request, next=None, using=None):
     '''
-    Rewriting of a django_comments method to link Iconolab metacategories on comment posting 
+    Rewriting of a django_comments method to link Iconolab metacategories on comment posting
     '''
     # Fill out some initial data fields from an authenticated user, if present
     data = request.POST.copy()
@@ -90,13 +93,13 @@
 
     # Save the comment and signal that it was saved
     comment.save()
-    
+
     signals.comment_was_posted.send(
         sender=comment.__class__,
         comment=comment,
         request=request
     )
-    
+
     # Creating metacategories here as apparently there is no way to make it work easily woth django_comments_xtd
     for metacategory in form.cleaned_data.get("metacategories", []):
         if 'xtd_comment' in comment:
@@ -106,4 +109,23 @@
             )
 
     return next_redirect(request, fallback=next or 'comments-comment-done',
-                         c=comment._get_pk_val())
\ No newline at end of file
+                         c=comment._get_pk_val())
+
+@require_GET
+def get_comment_form(request, annotation_guid):
+    # TODO Manage 404
+    annotation = Annotation.objects.get(annotation_guid=annotation_guid)
+    next_url = request.GET.get('next')
+    form = django_comments.get_form()(annotation)
+    return render(request, 'partials/comment_form.html', {
+        'comment_form': form,
+        'next': next_url
+    })
+
+@require_GET
+def get_annotation_comments_json(request, annotation_guid):
+    # TODO Manage 404
+    annotation = Annotation.objects.get(annotation_guid=annotation_guid)
+    comments = IconolabComment.objects.for_app_models('iconolab.annotation').filter(object_pk = annotation.pk).order_by('thread_id', '-order')
+    serializer = IconolabCommentSerializer(comments, many=True)
+    return HttpResponse(JSONRenderer().render(serializer.data), content_type="application/json")
--- a/src/iconolab/views/objects.py	Mon Feb 20 17:14:08 2017 +0100
+++ b/src/iconolab/views/objects.py	Mon Feb 20 17:29:55 2017 +0100
@@ -12,8 +12,9 @@
 from django.contrib.sites.models import Site
 from django.conf import settings
 from notifications.models import Notification
-from iconolab.models import Annotation, AnnotationRevision, Collection, Item, Image, IconolabComment, MetaCategory, MetaCategoryInfo
+from iconolab.models import Annotation, AnnotationRevision, AnnotationRevisionSerializer, Collection, Item, Image, IconolabComment, MetaCategory, MetaCategoryInfo
 from iconolab.forms.annotations import AnnotationRevisionForm
+from rest_framework import serializers
 import logging
 
 logger = logging.getLogger(__name__)
@@ -325,6 +326,7 @@
         """
 
         success, result = self.check_kwargs(kwargs)
+
         if success:
             (collection, item) = result
         else:
@@ -386,6 +388,7 @@
         context['image_guid'] = self.kwargs.get('image_guid', '')
         context['collection'] = collection
         context['image'] = image
+        context['form'] = AnnotationRevisionForm()
         return render(request, 'iconolab/detail_image.html', context)
 
 class CreateAnnotationView(View, ContextMixin, IconolabObjectView):
@@ -427,18 +430,8 @@
             fragment = annotation_form.cleaned_data['fragment']
             tags_json = annotation_form.cleaned_data['tags']
             new_annotation = Annotation.objects.create_annotation(author, image, title=title, description=description, fragment=fragment, tags_json=tags_json)
-            revision_comment = annotation_form.cleaned_data['comment']
-            IconolabComment.objects.create(
-                comment = revision_comment,
-                revision = new_annotation.current_revision,
-                content_type = ContentType.objects.get(app_label='iconolab', model='annotation'),
-                content_object = new_annotation,
-                site = Site.objects.get(id=settings.SITE_ID),
-                object_pk = new_annotation.id,
-                user = request.user,
-                user_name = request.user.username
-            )
-            return RedirectView.as_view(url=reverse('annotation_detail', kwargs={'collection_name': collection_name, 'image_guid': image_guid, 'annotation_guid': new_annotation.annotation_guid}))(request)
+            redirect_url = reverse('image_detail', kwargs={'collection_name': collection_name, 'image_guid': image_guid})
+            return RedirectView.as_view(url=redirect_url)(request)
         context = self.get_context_data(**kwargs)
         context['image'] = image
         context['form'] = annotation_form
@@ -611,7 +604,8 @@
                 user = request.user,
                 user_name = request.user.username
             )
-            return RedirectView.as_view(url=reverse('annotation_detail', kwargs={'collection_name': collection_name, 'image_guid': image_guid, 'annotation_guid': annotation_guid}))(request)
+            redirect_url = reverse('image_detail', kwargs={'collection_name': collection_name, 'image_guid': image_guid})
+            return RedirectView.as_view(url=redirect_url)(request)
         context = self.get_context_data(**kwargs)
         context['image'] = image
         context['form'] = annotation_form
--- a/src/requirements/base.txt	Mon Feb 20 17:14:08 2017 +0100
+++ b/src/requirements/base.txt	Mon Feb 20 17:29:55 2017 +0100
@@ -5,6 +5,7 @@
 django-haystack==2.6.0
 django-model-utils==2.6.1
 django-notifications-hq==1.2
+djangorestframework==3.5.4
 elasticsearch==5.1.0
 jsonfield==1.0.3
 olefile==0.44
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src_js/iconolab-bundle/src/components/editor/AnnotationForm.vue	Mon Feb 20 17:29:55 2017 +0100
@@ -0,0 +1,99 @@
+<template>
+    <form v-bind:action="formAction" method="post">
+        <slot>
+            <!-- CSRF token -->
+        </slot>
+        <input type="hidden" name="fragment" v-model="fragment">
+        <div v-if="!annotation" class="alert alert-warning">
+            Aucune annotation sélectionnée.
+        </div>
+        <div v-if="annotation" class="form-group form-group-sm">
+            <label class="control-label">Titre</label>
+            <input type="text" class="form-control" name="title" v-model="title">
+        </div>
+        <div v-if="annotation" class="form-group form-group-sm">
+            <label class="control-label">Description</label>
+            <textarea class="form-control" name="description" v-model="description"></textarea>
+        </div>
+        <div v-if="annotation" class="form-group form-group-sm">
+            <label class="control-label">Mots-clé</label>
+            <tag-list ref="taglist" v-bind:tags="tags" @change="onTagsChange($event.tags)"></tag-list>
+            <input type="hidden" name="tags" v-model="serializedTags">
+        </div>
+        <button type="submit" v-if="annotation" v-bind:class="{ disabled: !hasChanged() }"
+            class="btn btn-block btn-sm btn-primary">Sauvegarder</button>
+    </form>
+</template>
+
+<script>
+
+    import TagList from '../tagform/TagList.vue'
+
+    export default {
+        props: ['action'],
+        components: {
+            'tag-list': TagList
+        },
+        data() {
+            return {
+                'title': '',
+                'description': '',
+                'fragment': '',
+                'tags': [],
+                'annotation': null,
+            }
+        },
+        computed: {
+            formAction: function() {
+                if (this.annotation) {
+                    return this.action.replace(':annotation_guid', this.annotation.annotation_guid);
+                }
+            },
+            serializedTags: function() {
+                var tags = this.tags.map(function(tag) {
+                    return {
+                        tag_input: (typeof tag.tag_link === "string" && tag.tag_link.length) ? tag.tag_link : tag.tag_label,
+                        tag_label: tag.tag_label,
+                        accuracy: tag.accuracy,
+                        relevancy: tag.relevancy
+                    }
+                });
+
+                return JSON.stringify(tags);
+            }
+        },
+        methods: {
+            setAnnotation: function(annotation) {
+                const clone = annotation;
+                this.annotation = clone;
+
+                Object.assign(this, annotation);
+            },
+            onTagsChange: function(tags) {
+                this.tags = tags;
+            },
+            reset: function() {
+                Object.assign(this, {
+                    'title': '',
+                    'description': '',
+                    'tags': [],
+                    'annotation': null
+                });
+            },
+            hasChanged: function() {
+                if (!this.annotation) { return false; }
+
+                return this.title !== this.annotation.title
+                    || this.description !== this.annotation.description
+                    || this.tags !== this.annotation.tags;
+            }
+        }
+    }
+
+</script>
+
+<style scoped>
+form {
+    margin-bottom: 20px;
+}
+</style>
--- a/src_js/iconolab-bundle/src/components/editor/Canvas.vue	Mon Feb 20 17:14:08 2017 +0100
+++ b/src_js/iconolab-bundle/src/components/editor/Canvas.vue	Mon Feb 20 17:29:55 2017 +0100
@@ -39,9 +39,15 @@
     import ShapeFree from './ShapeFree.vue'
 
     export default {
-        props: [
-            'image',
-        ],
+        props: {
+            image: String,
+            tooltip: {
+                type: Boolean,
+                default: function () {
+                    return false;
+                }
+            },
+        },
         components: {
             shapeRect: ShapeRect,
             shapeFree: ShapeFree
@@ -75,25 +81,8 @@
                 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);
-                        }
-                    });
-
+                    this.loadAnnotation();
                 } else {
-
                     if (this.mode === 'free') {
                         this.handleDrawFree();
                     }
@@ -101,6 +90,11 @@
                         this.handleDrawRect();
                     }
                 }
+            },
+            annotation: function(annotation) {
+                this.reset();
+                if (!this.annotation) { return; }
+                this.loadAnnotation();
             }
         },
         mounted() {
@@ -154,7 +148,17 @@
                 this.$refs.rect.clear();
                 this.$refs.free.clear();
 
-                // Remove event handlers
+                this.removeEventHandlers();
+
+                if (this.mode === 'free') {
+                    this.handleDrawFree();
+                }
+                if (this.mode === 'rect') {
+                    this.handleDrawRect();
+                }
+            },
+
+            removeEventHandlers: function() {
                 this.paper.unmousedown();
                 this.paper.unmousemove();
                 this.paper.unmouseup();
@@ -165,6 +169,26 @@
                 this.annotation = annotation;
             },
 
+            loadAnnotation: function() {
+                if (this.annotation.fragment.length > 0) {
+                    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, this.tooltip);
+                        }
+                        if (mode === 'rect') {
+                            this.$refs.rect.fromSVGPath(path, this.tooltip);
+                        }
+                    });
+                }
+            },
+
             getCenter: function() {
                 return {
                     x: this.viewBox[0] + (this.viewBox[2] / 2),
@@ -301,8 +325,19 @@
                 return path;
             },
 
+            toSVGPath: function() {
+                if (this.mode === 'free') {
+                    this.$refs.free.toSVGPath();
+                }
+                if (this.mode === 'rect') {
+                    this.$refs.rect.toSVGPath();
+                }
+            },
+
             handleDrawFree: function() {
 
+                this.removeEventHandlers();
+
                 var clickTimeout;
 
                 var clickHandler = function (offsetX, offsetY) {
@@ -325,6 +360,8 @@
 
             handleDrawRect: function() {
 
+                this.removeEventHandlers();
+
                 var startPosition = { x: 0, y: 0 };
                 var currentPosition = { x: 0, y: 0 };
                 var canDraw = false;
@@ -429,7 +466,7 @@
 }
 .cut-canvas {
     width: 100%;
-    height: 800px;
+    height: 600px;
 }
 .canvas--rect:hover {
     cursor: crosshair;
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src_js/iconolab-bundle/src/components/editor/Comment.vue	Mon Feb 20 17:29:55 2017 +0100
@@ -0,0 +1,49 @@
+<template>
+    <div class="comment">
+        {{ comment }}
+        <div class="comment-footer">
+            <span class="comment-author">{{ username }}</span>
+            <span class="comment-date">{{ dateFormatted }}</span>
+        </div>
+    </div>
+</template>
+
+<script>
+
+    export default {
+        props: [
+            'comment',
+            'username',
+            'email',
+            'date'
+        ],
+        computed: {
+            dateFormatted: function () {
+
+                var date = new Date(this.date);
+
+                var day = date.getDate();
+                var month = date.getMonth() + 1;
+                var year = date.getFullYear();
+
+                return day + '/' + month + '/' + year;
+            }
+        }
+    }
+
+</script>
+
+<style scoped>
+.comment {
+    font-size: 12px;
+    padding: 5px 0;
+    border-bottom: 1px solid #ccc;
+}
+.comment-footer {
+    margin-top: 5px;
+    color: #ccc;
+}
+.comment-date {
+    float: right;
+}
+</style>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src_js/iconolab-bundle/src/components/editor/CommentList.vue	Mon Feb 20 17:29:55 2017 +0100
@@ -0,0 +1,27 @@
+<template>
+    <div v-show="comments.length > 0">
+        <label class="form-label">Commentaires</label>
+        <comment ref="comments"
+            v-for="comment in comments"
+            v-bind:comment="comment.comment"
+            v-bind:username="comment.user_name"
+            v-bind:date="comment.submit_date"></comment>
+    </div>
+</template>
+
+<script>
+
+    import Comment from './Comment.vue'
+
+    export default {
+        components: {
+            Comment
+        },
+        data() {
+            return {
+                'comments': []
+            }
+        },
+    }
+
+</script>
--- a/src_js/iconolab-bundle/src/components/editor/ShapeFree.vue	Mon Feb 20 17:14:08 2017 +0100
+++ b/src_js/iconolab-bundle/src/components/editor/ShapeFree.vue	Mon Feb 20 17:29:55 2017 +0100
@@ -1,6 +1,8 @@
 <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>
+        <path ref="path" v-bind:d="path" stroke="#000000" fill="#bdc3c7"
+            v-bind:stroke-width="handlerRadius / 2"
+            v-bind:stroke-dasharray="(handlerRadius / 3) + ',' + (handlerRadius / 3)" style="opacity: 0.6;"></path>
         <circle
             v-for="(point, key) in points"
             :key="key"
@@ -9,7 +11,8 @@
             v-bind:cx="point.x"
             v-bind:cy="point.y"
             v-bind:r="handlerRadius"
-            stroke="#000000" stroke-width="10"
+            stroke="#000000"
+            v-bind:stroke-width="handlerRadius / 2"
             style="opacity: 0.9;"
             v-bind:class="{ handler: true, 'handler--first': key === 0 &amp;&amp; !closed }"></circle>
     </g>
@@ -42,7 +45,6 @@
             closed: function(closed) {
                 if (closed) {
                     this.path += ' Z';
-                    setTimeout(() => this.addTooltip(), 50);
                 }
             },
             // Redraw the path when the points have changed
@@ -96,7 +98,7 @@
                 return this.$refs.path;
             },
 
-            fromSVGPath: function(pathString) {
+            fromSVGPath: function(pathString, tooltip) {
 
                 var segments = Snap.parsePathString(pathString);
                 var points = [];
@@ -114,6 +116,10 @@
 
                 this.$nextTick(() => {
                     this.addResizeHandlers();
+                    if (tooltip) {
+                        // FIXME Race condition with destroy
+                        setTimeout(() => this.addTooltip(), 250);
+                    }
                 });
             },
 
@@ -123,14 +129,20 @@
 
             addResizeHandler: function(handler) {
 
+                var self = this;
+
                 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;
+                circle.click((e) => {
+                    var key = parseInt(circle.attr('data-key'), 10);
+                    if (key === 0 && this.points.length > 2) {
+                        this.closed = true;
+                        this.$nextTick(() => {
+                            // FIXME Race condition with destroy
+                            setTimeout(() => this.addTooltip(), 250);
+                        });
                     }
                 });
 
@@ -141,8 +153,6 @@
                     this.points.splice(key, 1);
                 });
 
-                var self = this;
-
                 var dragEvents = {
                     onMove: function(dx, dy, x, y, e) {
 
--- a/src_js/iconolab-bundle/src/components/editor/ShapeRect.vue	Mon Feb 20 17:14:08 2017 +0100
+++ b/src_js/iconolab-bundle/src/components/editor/ShapeRect.vue	Mon Feb 20 17:29:55 2017 +0100
@@ -38,6 +38,7 @@
         props: [
             'paper',
             'original-annotation',
+            'tooltip'
         ],
         data() {
             return {
@@ -153,7 +154,7 @@
                 bottomRightHandler.drag(handlerEvents.onMove, handlerEvents.onStart, handlerEvents.onEnd);
             },
 
-            fromSVGPath: function(pathString, imageWidth, imageHeight) {
+            fromSVGPath: function(pathString, tooltip) {
                 var bBox = Snap.path.getBBox(pathString);
 
                 Object.assign(this, {
@@ -161,10 +162,13 @@
                     width: bBox.width, height: bBox.height
                 });
 
-                setTimeout(() => {
+                this.$nextTick(() => {
                     this.addResizeHandlers();
-                    this.addTooltip();
-                }, 50);
+                    if (tooltip) {
+                        // FIXME Race condition with destroy
+                        setTimeout(() => this.addTooltip(), 250);
+                    }
+                });
             },
 
             toSVGPath: function() {
--- a/src_js/iconolab-bundle/src/components/editor/index.js	Mon Feb 20 17:14:08 2017 +0100
+++ b/src_js/iconolab-bundle/src/components/editor/index.js	Mon Feb 20 17:29:55 2017 +0100
@@ -1,7 +1,11 @@
 import Canvas from './Canvas.vue'
 import Annotation from './Annotation.vue'
+import AnnotationForm from './AnnotationForm.vue'
+import CommentList from './CommentList.vue'
 
 export default {
     Canvas: Canvas,
     Annotation: Annotation,
+    AnnotationForm: AnnotationForm,
+    CommentList: CommentList,
 }
--- a/src_js/iconolab-bundle/src/components/editor/mixins/tooltip.js	Mon Feb 20 17:14:08 2017 +0100
+++ b/src_js/iconolab-bundle/src/components/editor/mixins/tooltip.js	Mon Feb 20 17:29:55 2017 +0100
@@ -7,6 +7,7 @@
     trigger: 'manual',
     html: true,
     title: '',
+    viewport: '.cut-canvas',
     content: '',
 }
 
--- a/src_js/iconolab-bundle/src/components/tagform/TagList.vue	Mon Feb 20 17:14:08 2017 +0100
+++ b/src_js/iconolab-bundle/src/components/tagform/TagList.vue	Mon Feb 20 17:29:55 2017 +0100
@@ -2,11 +2,13 @@
     <div>
         <div class="tag-list">
             <tag-list-item ref="items"
-                v-for="(tag, index) in tags" v-if="tags.length > 0"
+                key="tag.tag_label + tag.accuracy + tag.relevancy"
+                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>
+                v-bind:accuracy="tag.accuracy"
+                v-bind:relevancy="tag.relevancy"></tag-list-item>
         </div>
         <typeahead ref="typeahead" placeholder="Rechercher"></typeahead>
     </div>
@@ -18,25 +20,24 @@
     import Typeahead from './Typeahead.vue'
 
     export default {
-        props: ['original-tags'],
+        props: {
+            tags: {
+                type: Array,
+                default: function () {
+                    return [];
+                }
+            },
+        },
         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);
             });
--- a/src_js/iconolab-bundle/src/components/tagform/TagListItem.vue	Mon Feb 20 17:14:08 2017 +0100
+++ b/src_js/iconolab-bundle/src/components/tagform/TagListItem.vue	Mon Feb 20 17:29:55 2017 +0100
@@ -21,14 +21,14 @@
                     <label>Fiabilité</label>
                     <small>Êtes-vous sûr de votre tag ?</small>
                     <color-buttons ref="accuracy"
-                        @change="accuracy = $event.value"
+                        @change="onChange('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"
+                        @change="onChange('relevancy', $event.value)"
                         v-bind:original-value="relevancy"></color-buttons>
                 </div>
             </div>
@@ -45,8 +45,8 @@
         props: [
             'index',
             'label',
-            'original-accuracy',
-            'original-relevancy'
+            'accuracy',
+            'relevancy'
         ],
         components: {
             "typeahead": Typeahead,
@@ -54,27 +54,29 @@
         },
         data() {
             return {
-                accuracy: null,
-                relevancy: null,
                 isNew: true,
             }
         },
         watch: {
             accuracy: function(accuracy) {
-                this.onChange();
+                if (this.isNew && this.isComplete()) {
+                    this.isNew = false;
+                    this.hide();
+                }
             },
             relevancy: function(relevancy) {
-                this.onChange();
+                if (this.isNew && this.isComplete()) {
+                    this.isNew = false;
+                    this.hide();
+                }
             }
         },
         mounted() {
 
-            this.accuracy = this.originalAccuracy;
-            this.relevancy = this.originalRelevancy;
-            this.isNew = (!this.originalAccuracy && !this.originalRelevancy);
+            this.isNew = (!this.accuracy && !this.relevancy);
 
-            this.$refs.accuracy.value = this.originalAccuracy;
-            this.$refs.relevancy.value = this.originalRelevancy;
+            this.$refs.accuracy.value = this.accuracy;
+            this.$refs.relevancy.value = this.relevancy;
 
             $(this.$el).find('.collapse').collapse({ toggle: false });
 
@@ -89,15 +91,11 @@
             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();
-                }
+            onChange: function(key, value) {
+                var data = {}
+                data[key] = value;
+
+                this.$parent.replaceItemAt(this.index, data);
             },
             show: function() {
                 $(this.$el).find('.collapse').collapse('show');
--- a/src_js/iconolab-bundle/src/components/tagform/Typeahead.vue	Mon Feb 20 17:14:08 2017 +0100
+++ b/src_js/iconolab-bundle/src/components/tagform/Typeahead.vue	Mon Feb 20 17:29:55 2017 +0100
@@ -1,7 +1,7 @@
 <template>
     <div>
         <input type="text"
-            class="form-control"
+            class="form-control input-sm"
             v-bind:placeholder="placeholder"
             autocomplete="off"
             v-model="query"
--- a/src_js/iconolab-bundle/src/iconolab.scss	Mon Feb 20 17:14:08 2017 +0100
+++ b/src_js/iconolab-bundle/src/iconolab.scss	Mon Feb 20 17:29:55 2017 +0100
@@ -302,3 +302,40 @@
         padding: 30px 0;
     }
 }
+
+.annotation-navigator {
+    display: flex;
+    margin-bottom: 30px;
+    .annotation-navigator-list {
+        width: 20%;
+        max-height: 600px;
+        overflow: scroll;
+    }
+    .annotation-navigator-canvas {
+        width: 60%;
+        padding: 0 15px;
+    }
+    .annotation-navigator-panel {
+        width: 20%;
+        display: flex;
+        > div, .panel-body {
+            display: flex;
+            flex: 1;
+        }
+        .panel {
+            margin-bottom: 0;
+        }
+        .panel-body {
+            flex-direction: column;
+            justify-content: space-between;
+            > * {
+                flex: 1;
+            }
+            form:last-of-type {
+                display: flex;
+                flex-direction: column;
+                justify-content: flex-end;
+            }
+        }
+    }
+}
--- a/src_js/iconolab-bundle/src/main.js	Mon Feb 20 17:14:08 2017 +0100
+++ b/src_js/iconolab-bundle/src/main.js	Mon Feb 20 17:29:55 2017 +0100
@@ -35,6 +35,8 @@
 Vue.component('color-buttons', ColorButtons);
 Vue.component('image-annotator', Editor.Canvas);
 Vue.component('annotation', Editor.Annotation);
+Vue.component('annotation-form', Editor.AnnotationForm);
+Vue.component('comment-list', Editor.CommentList);
 
 if (!window.iconolab) {
     window.iconolab = iconolab;