diff --git a/app/frontend/static/css/style.css b/app/frontend/static/css/style.css index c53253d..cc446dc 100644 --- a/app/frontend/static/css/style.css +++ b/app/frontend/static/css/style.css @@ -1116,6 +1116,42 @@ input[type="file"]#image-upload { padding: 2px 4px; font-size: 11px; } +.layer-class-input.tagify { + --tag-pad: 0.2em 0.6em; + --tag-bg: #0052bf; + --tag-text-color: #fff; + --tags-border-color: silver; + --tag-remove-bg: #ce0078; + --tag-remove-btn-color: white; + min-width: 0; + border: none; +} +.layer-class-input.tagify .tagify__tag { + margin-top: 0; +} +.layer-class-input.tagify .tagify__input { + display: none; +} +.layer-class-input.tagify .tagify__tag__removeBtn { + font-size: 9px; + margin-left: 4px; + cursor: pointer; +} +.layer-add-tag-btn { + color: #0052bf; + font: bold 1em/1.4 Arial; + border: 0; + background: none; + box-shadow: 0 0 0 2px inset currentColor; + border-radius: 50%; + width: 1.2em; + height: 1.2em; + cursor: pointer; + transition: 0.1s ease-out; +} +.layer-add-tag-btn:hover { + box-shadow: 0 0 0 4px inset currentColor; +} .layer-status-tag { font-size: 11px; padding: 2px 4px; diff --git a/app/frontend/static/js/layerViewController.js b/app/frontend/static/js/layerViewController.js index 8f8f57b..0738dd2 100644 --- a/app/frontend/static/js/layerViewController.js +++ b/app/frontend/static/js/layerViewController.js @@ -10,9 +10,53 @@ class LayerViewController { this.stateManager = stateManager; this.layers = []; this.selectedLayerIds = []; + this.tagifyMap = new Map(); + this.globalLabelPool = []; this.Utils = window.Utils || { dispatchCustomEvent: (n,d)=>document.dispatchEvent(new CustomEvent(n,{detail:d})) }; } + async _refreshGlobalLabels() { + const projectId = this.stateManager && this.stateManager.getActiveProjectId && this.stateManager.getActiveProjectId(); + if (!projectId || !window.apiClient) { + this.globalLabelPool = []; + return; + } + try { + const res = await window.apiClient.getProjectLabels(projectId); + if (res && Array.isArray(res.labels)) { + this.globalLabelPool = res.labels + .flatMap(l => l.split(',').map(t => t.trim())) + .filter(Boolean); + } else { + this.globalLabelPool = []; + } + } catch (e) { + this.globalLabelPool = []; + } + } + + _gatherLabelPool() { + const set = new Set(); + this.layers.forEach(l => { + if (l.classLabel) { + l.classLabel.split(',').forEach(t => { + t = t.trim(); + if (t) set.add(t); + }); + } + }); + return Array.from(set); + } + + _updateTagifyWhitelists() { + const pool = new Set([...this.globalLabelPool, ...this._gatherLabelPool()]); + const arr = Array.from(pool); + this.tagifyMap.forEach(t => { + t.settings.whitelist = arr; + if (t.dropdown) t.dropdown.refilter(); + }); + } + setLayers(layers) { // Clone incoming array so external mutations (e.g., from ActiveImageState) // do not directly modify our internal list and cause double updates. @@ -76,8 +120,11 @@ class LayerViewController { } } - render() { + async render() { if (!this.containerEl) return; + await this._refreshGlobalLabels(); + this.tagifyMap.forEach(t => t.destroy()); + this.tagifyMap.clear(); this.containerEl.innerHTML = ''; const listEl = document.createElement('ul'); listEl.className = 'layer-list'; @@ -142,22 +189,20 @@ class LayerViewController { classInput.className = 'layer-class-input'; classInput.type = 'text'; classInput.placeholder = 'label'; - classInput.value = layer.classLabel || ''; classInput.title = 'Class label'; classInput.addEventListener('mousedown', (e) => e.stopPropagation()); classInput.addEventListener('click', (e) => e.stopPropagation()); - classInput.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { - e.preventDefault(); - classInput.blur(); - classInput.dispatchEvent(new Event('change', { bubbles: true })); - } - }); - classInput.addEventListener('change', (e) => { - e.stopPropagation(); - layer.classLabel = classInput.value.trim(); - this.Utils.dispatchCustomEvent('layer-class-changed', { layerId: layer.layerId, classLabel: layer.classLabel }); - }); + + const addTagBtn = document.createElement('button'); + addTagBtn.type = 'button'; + addTagBtn.className = 'layer-add-tag-btn'; + addTagBtn.textContent = '+'; + + const tagifyConfig = { + whitelist: Array.from(new Set([...this.globalLabelPool, ...this._gatherLabelPool()])), + dropdown: { maxItems: 20, enabled: 0, closeOnSelect: false }, + editTags: { keepInvalid: false } + }; const statusTag = document.createElement('span'); statusTag.className = `layer-status-tag ${layer.status || ''}`; @@ -175,6 +220,9 @@ class LayerViewController { }); li.addEventListener('click', (e) => { + if (e.target.closest('.tagify__tag__removeBtn')) { + return; // allow Tagify to handle deletion + } const additive = e.shiftKey; this.selectLayer(layer.layerId, additive); }); @@ -184,12 +232,38 @@ class LayerViewController { li.appendChild(colorInput); li.appendChild(nameInput); li.appendChild(classInput); + li.appendChild(addTagBtn); li.appendChild(statusTag); li.appendChild(deleteBtn); + + const tagify = new Tagify(classInput, tagifyConfig); + if (layer.classLabel) { + tagify.addTags(layer.classLabel.split(',').map(t => t.trim()).filter(Boolean)); + } + addTagBtn.addEventListener('click', (e) => { + e.stopPropagation(); + tagify.addEmptyTag(); + }); + const updateFromTagify = () => { + layer.classLabel = tagify.value.map(t => t.value).join(','); + this.Utils.dispatchCustomEvent('layer-class-changed', { layerId: layer.layerId, classLabel: layer.classLabel }); + this._updateTagifyWhitelists(); + }; + tagify.on('add', updateFromTagify); + tagify.on('remove', updateFromTagify); + tagify.on('edit:updated', (e) => { + if (e.detail && e.detail.data && !e.detail.data.value.trim()) { + tagify.removeTag(e.detail.tag); + } + updateFromTagify(); + }); + this.tagifyMap.set(layer.layerId, tagify); + listEl.appendChild(li); }); this.containerEl.appendChild(listEl); + this._updateTagifyWhitelists(); } } diff --git a/docs/annotation_workflow_progress.md b/docs/annotation_workflow_progress.md index 7f6d287..2b0bfdd 100644 --- a/docs/annotation_workflow_progress.md +++ b/docs/annotation_workflow_progress.md @@ -40,6 +40,19 @@ It will be updated as new sprints add functionality. - **Status Toggles**: The annotation view now has "Ready" and "Skip" switches to update image status, dispatching refresh events. Switches are automatically updated when a new image loads and disabled when no image is active. - **Unified Change Handler**: A new `onImageDataChange()` function synchronizes the layer view, caches and status toggles whenever image or layer data changes. - **Inline Layer Editing**: Mask name and label fields accept Enter to save changes without deselecting the text field, and edits trigger the unified change handler. +- **Tagify Labels**: Class labels in the layer view appear as Tagify tags + directly on each layer. Each tag shows a small "×" remove button and the + "+" button adds new tags. Suggestions come from all labels used across the + project and update dynamically as tags change. +- **Tagify Layout Fix**: Labels now appear in-line after the name field, fully + replacing the old text input without shifting the visibility toggle. +- **Tag Removal Fix**: The "×" remove button stops event propagation without + cancelling the default Tagify behaviour so tags delete correctly and layer + selection is unaffected. +- **Label Pool Parsing**: Suggestions split comma-separated labels returned by + the backend so autocompletion lists each label individually. +- **Layer Click Handling Fix**: Clicking the remove icon no longer selects the + layer, ensuring tag deletions persist on reload. - **Layer Persistence**: Editing a mask's name or class now sends an update to the backend so changes are saved in the project database. - **Mask Edit Persistence**: Saving edits to a mask now updates its RLE data and status in the database so changes survive reloads. - **Color Persistence**: Layer colors are stored in the database, including the randomly assigned color when a layer is first created, and can be updated through the layer view.