class AnnotationManager { constructor(rootEl, tagsUrl) { this.rootEl = rootEl; this.tagsEl = this.rootEl.querySelector('#tags'); this.annotationsEl = this.rootEl.querySelector('#annotations'); this.selectedAnnotations = []; this.selectedTag = null; this.tagsUrl = tagsUrl; this.loadTags(); } loadTags() { // tags config const request = new Request(this.tagsUrl); return fetch(request) .then(response => response.json()) .then(rootTag => { this.rootTag = Tag.from(rootTag); this.buildTagList() }); } buildTagList() { // build, and rebuild this.tagsEl.innerHTML = ""; const addTag = (tag, parentEl) => { const tagLiEl = document.createElement('li'); const tagSubEl = document.createElement('ul'); const tagEl = document.createElement('div'); tagEl.innerText = `${tag.get_name()} (${tag.annotation_count ?? 0})`; tagEl.addEventListener('click', (ev) => { this.selectTag(tag); }) tagEl.addEventListener('dblclick', (ev) => { this.renameTag(tag); }) const colorEl = document.createElement('input'); colorEl.type = 'color'; colorEl.value = tag.get_color(); colorEl.addEventListener('change', (ev) => { this.setTagColor(tag, ev.target.value); }); tagEl.prepend(colorEl) const rmEl = document.createElement('input'); rmEl.type = 'button'; rmEl.classList.add('rm-tag'); rmEl.value = '🗑'; rmEl.addEventListener('click', (ev) => { ev.stopPropagation(); this.removeTag(tag); }); tagEl.appendChild(rmEl) tagLiEl.classList.add('tag-id-' + tag.id); tagLiEl.appendChild(tagEl); tag.menuLiEl = tagLiEl; tagLiEl.appendChild(tagSubEl); tag.children.forEach((tag) => addTag(tag, tagSubEl)); const tagAddSubEl = document.createElement('li'); tagAddSubEl.classList.add('add-tag') tagAddSubEl.innerText = 'add tag'; tagAddSubEl.addEventListener('click', (ev) => { const name = prompt(`Add a tag under '${tag.get_name()}':`); if (name === null || name.length < 1) return //cancel this.addTag(name, tag); }); tagSubEl.appendChild(tagAddSubEl); parentEl.appendChild(tagLiEl); }; addTag(this.rootTag, this.tagsEl); } selectTag(tag) { this.clearSelectedTag(); this.selectedTag = tag; tag.menuLiEl.classList.add('selected'); this.loadAnnotationsForTag(tag) } clearSelectedTag() { this.selectedTag = null; const selected = this.tagsEl.querySelectorAll('.selected'); selected.forEach((s) => s.classList.remove('selected')); this.resetSelectedAnnotations() //TODO empty annotationEl this.annotationsEl.innerHTML = ""; } selectAnnotation() { throw new Error("Not implemented"); } addTag(name, parentTag) { const tag = new Tag(); tag.id = crypto.randomUUID(); tag.name = name; tag.parent = parentTag; parentTag.children.push(tag); this.buildTagList(); this.saveTags(); } loadAnnotationsForTag(tag) { this.annotationsEl.innerHTML = ""; const request = new Request("/tags/" + tag.id); return fetch(request) .then(response => response.json()) .then(annotations => { this.annotations = annotations; this.buildAnnotationList() }); } buildAnnotationList() { this.annotationsEl.innerHTML = ""; this.actionsEl = document.createElement('div'); this.actionsEl.classList.add('annotations-actions') this.buildAnnotationActions(); this.annotationsEl.appendChild(this.actionsEl); const ulEl = document.createElement('ul'); this.annotations.forEach((annotation, idx) => { const liEl = document.createElement('li'); const playerEl = new AnnotationPlayer(); //document.createElement('annotation-player'); playerEl.setAnnotation(annotation); const selectEl = document.createElement('input'); selectEl.type = 'checkbox'; selectEl.id = 'select-' + annotation.id_hash; selectEl.addEventListener('change', (ev) => { if (ev.target.checked) { this.addAnnotationToSelection(annotation); } else { this.removeAnnotationFromSelection(annotation); } }) const tag = this.rootTag.find_by_id(annotation.tag); console.log(tag) const infoEl = document.createElement('span'); infoEl.classList.add('annotation-info'); infoEl.innerText = `[${tag.get_name()}] ${annotation.comment}`; const downloadEl = document.createElement('a'); downloadEl.href = annotation.url.replace('files', 'export'); downloadEl.innerHTML = '↓'; infoEl.appendChild(downloadEl); liEl.appendChild(playerEl); liEl.appendChild(selectEl); liEl.appendChild(infoEl); ulEl.appendChild(liEl); }); this.annotationsEl.appendChild(ulEl); } resetSelectedAnnotations() { this.selectedAnnotations = []; this.annotationsEl.querySelectorAll("li input[type='checkbox']").forEach((box) => box.checked = false); this.buildAnnotationActions() } addAnnotationToSelection(annotation) { if (this.selectedAnnotations.indexOf(annotation) === -1) { this.selectedAnnotations.push(annotation); } this.annotationsEl.querySelector('#select-' + annotation.id_hash).checked = true; this.buildAnnotationActions() } removeAnnotationFromSelection(annotation) { if (this.selectedAnnotations.indexOf(annotation) !== -1) { this.selectedAnnotations.splice(this.selectedAnnotations.indexOf(annotation), 1) } this.annotationsEl.querySelector('#select-' + annotation.id_hash).checked = false; this.buildAnnotationActions() } /** * Build the form items to select & move the annotations * @returns undefined */ buildAnnotationActions() { if (!this.actionsEl || !this.annotations.length) return this.actionsEl.innerHTML = ""; const selectAllLabelEl = document.createElement('label'); selectAllLabelEl.innerText = `Select all` const selectAllEl = document.createElement('input'); selectAllEl.type = 'checkbox'; selectAllEl.checked = this.annotations.length === this.selectedAnnotations.length; // selectAllEl.innerText = `Select all ${this.annotations.length} items`; selectAllEl.addEventListener('change', (ev) => { if (ev.target.checked) { this.annotations.forEach((a) => this.addAnnotationToSelection(a)); } else { this.resetSelectedAnnotations(); } }); selectAllLabelEl.appendChild(selectAllEl) this.actionsEl.appendChild(selectAllLabelEl) if (!this.selectedAnnotations.length) return; const moveLabelEl = document.createElement('label'); moveLabelEl.innerText = `Change tag for ${this.selectedAnnotations.length} items` const moveSelectEl = document.createElement('select'); this.rootTag.descendants().forEach((tag, i) => { const tagEl = document.createElement('option'); tagEl.value = tag.id; if (tag.id == this.selectedTag.id) { tagEl.selected = true; } tagEl.innerHTML = tag.get_indented_name(); moveSelectEl.appendChild(tagEl); }); moveSelectEl.addEventListener('change', (ev) => { const tag = this.rootTag.find_by_id(ev.target.value); console.log(tag); this.moveSelectedAnnotations(tag); }) moveLabelEl.appendChild(moveSelectEl) this.actionsEl.appendChild(moveLabelEl) } renameTag(tag) { // TODO: add button for this const name = prompt(`Rename tag '${tag.get_name()}':`); if (name === null || name.length < 1) return //cancel tag.name = name; this.saveTags(); this.buildTagList(); } removeTag(tag) { if (!confirm(`Do you want to delete ${tag.get_name()}`)) { return false; } // TODO: add button for this const request = new Request("/tags/" + tag.id); return fetch(request) .then(response => { if (response.status == 404) { return [] // not existing tag surely has no annotations } else { return response.json() } }) .then(annotations => { if (annotations.length) { alert(`Cannot remove '${tag.get_name()}', as it is used for ${annotations.length} annotations.`) } else { // TODO: remove tag tag.parent.children.splice(tag.parent.children.indexOf(tag), 1); tag.parent = null; this.saveTags(); this.buildTagList(); } }) } setTagColor(tag, color) { tag.color = color; this.buildTagList(); this.saveTags(); } async moveSelectedAnnotations(tag) { // TODO: add button for this // alert(`This doesn't work yet! (move to tag ${tag.get_name()})`) await Promise.all(this.selectedAnnotations.map(async (annotation) => { const formData = new FormData(); formData.append('tag_id', tag.id) return await fetch('/annotation/' + annotation.id, { method: 'POST', // headers: { // 'Content-Type': 'application/json' // }, body: formData //JSON.stringify({'tag_id': tag.id}) }).catch((e) => alert('Something went wrong saving the tags')); })); this.loadAnnotationsForTag(this.selectedTag) this.loadTags() //updates the counts } async saveTags() { const json = this.rootTag.export(); console.log('save', json) const response = await fetch('/tags.json', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: json }).catch((e) => alert('Something went wrong saving the tags')); } } class Tag { parent = null; children = []; static from(json_obj) { if (json_obj.hasOwnProperty('children')) { json_obj.children = json_obj.children.map(Tag.from) } else { json_obj.children = []; } json_obj.parent = null; const tag = Object.assign(new Tag(), json_obj); tag.children.map((child) => child.parent = tag); return tag; } is_root() { return this.parent === null; } descendants(inc_self = false) { let tags = this.children.flatMap((t) => t.descendants(true)) if (inc_self) tags.unshift(this) return tags; } find_by_id(tag_id) { const desc = this.descendants().filter((tag) => tag.id == tag_id); if (desc.length) return desc[0]; return null; } depth() { if (this.parent === null) { return 0; } return this.parent.depth() + 1; } root() { if (this.parent !== null) { return this.parent.root(); } return this; } get_name() { if (this.hasOwnProperty('name') && this.name !== null) { return this.name; } return this.id; } get_indented_name() { const name = this.get_name(); return ' '.repeat((this.depth() - 1) * 2) + '- ' + name } get_color() { if (this.hasOwnProperty('color') && this.color !== null) { return this.color; } if (this.parent !== null) { return this.parent.get_color() } return 'black'; } _json_replacer(key, value) { if (key === 'children' && !value.length) { return undefined; } if (key === 'parent' || key === 'menuLiEl') { return undefined; } if (key === 'color' && this.parent !== null && this.parent.get_color() === value) { return undefined; } return value; } export() { return JSON.stringify(this, this._json_replacer) } }