class AnnotationPlayer extends HTMLElement { constructor() { super(); // We don't use constructor() because an element's attributes // are unavailable until connected to the DOM. } connectedCallback() { // Create a shadow root this.attachShadow({ mode: "open" }); const imgEl = document.createElement('img'); const playerEl = document.createElement('div'); imgEl.src = `/annotation/${this.annotation.id}.svg`; imgEl.addEventListener('click', () => { imgEl.style.display = 'none'; new Annotator( playerEl, "tags.json", this.annotation.url, { is_player: true, crop_to_fit: true, autoplay: true } ); }) playerEl.classList.add('play'); const styleEl = document.createElement('style'); styleEl.textContent = ` :host{ overflow: hidden; padding: 10px; background: white; } svg, img { width: 100%; height: 100%; } .play:not(.loading) .controls { visibility: hidden; } :host(:hover) .controls { visibility: visible !important; } .controls--playback { /* display:flex; */ position: relative; } .timecode { position: absolute; right: 100%; width: 5%; font-size: 8px; } .controls--playback input[type='range'] { /* position: absolute; z-index: 100; bottom: 0; left: 0; right: 0; */ width: 100%; } .controls button.paused, .controls button.playing { position: absolute; left: 100%; width: 30px; height: 30px; } .controls button.paused::before { content: '⏵'; } .controls button.playing::before { content: '⏸'; } .loading .controls button:is(.playing, .paused)::before { content: '↺'; display: inline-block; animation: rotate 1s infinite; } @keyframes rotate { 0% { transform: rotate(359deg) } 100% { transform: rotate(0deg) } } .controls { position: absolute !important; z-index: 100; bottom: 10px; left: 5%; right: 0; width: 90%; } svg .background { fill: white } path { fill: none; stroke: gray; stroke-width: 1mm; stroke-linecap: round; } g.before path { opacity: 0.5; stroke: gray !important; } g.after path, path.before_in { opacity: .1; stroke: gray !important; } .gray { position: absolute; background: rgba(255, 255, 255, 0.7); } `; this.shadowRoot.appendChild(styleEl); this.shadowRoot.appendChild(imgEl); this.shadowRoot.appendChild(playerEl); } setAnnotation(annotation) { this.annotation = annotation; } } window.customElements.define('annotation-player', AnnotationPlayer); 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}`; 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) } }