Rewrote tags code in annotate (with help from OpenCode)

This commit is contained in:
Tris Forster 2026-07-08 10:54:19 +10:00
parent fefa0a28b6
commit 4a22264d3f
4 changed files with 389 additions and 115 deletions

View File

@ -366,8 +366,8 @@ class Work(models.Model):
def music_tags(self): def music_tags(self):
tags = dict(self.orchestration.as_list()) tags = dict(self.orchestration.as_list())
for section in Section.objects.filter(doc__work_id=self.pk): # for section in Section.objects.filter(doc__work_id=self.pk):
tags.setdefault(section.tag, section.name) # tags.setdefault(section.tag, section.name)
return tags.items() return tags.items()

View File

@ -4,9 +4,10 @@ import re
GENERAL = """ GENERAL = """
mvmt Movement mvmt Movement
ex Excerpt ex Excerpt
sec Section sect Section
pce Piece pce Piece
no Number no No.
page Page
""" """
# taken from https://imslp.org/wiki/IMSLP:Abbreviations_for_MusicTags # taken from https://imslp.org/wiki/IMSLP:Abbreviations_for_MusicTags

View File

@ -14,42 +14,97 @@
{% block media %} {% block media %}
<style> <style>
#unassigned-area {
max-width: 200px;
}
#unassigned-area .tag {
cursor: pointer;
}
#area-left h3 {
margin-top: 10px;
text-align: center;
}
.tag-grid { .tag-grid {
display: flex; display: flex;
margin-bottom: 5px;
overflow-y: auto;
} }
.grid-column { .grid-column {
margin-left: 1em; margin-left: 0;
margin-right: 1em; margin-right: 0;
} }
.grid-page { .grid-page {
height: 25px; height: 25px;
margin-bottom: 5px; border: none;
border: 1px solid #999; border-top: 1px solid #DDD;
border-radius: 5px;
text-align: center; text-align: center;
width: 50px; width: 50px;
cursor: pointer; cursor: pointer;
} }
.grid-page:last-child {
border-bottom: 1px solid #DDD;
height: 26px;
}
.grid-page.is-active { .grid-page.is-active {
background-color: var(--primary); /*background-color: var(--bulma-link-light);*/
color: white; color: var(--bulma-link);
background-color: #EEE;
border-radius: 0px 6px 6px 0px;
} }
.grid-tag { .grid-tag {
border: 2px solid var(--primary); background: #fff;
background-color: rgba(240, 240, 240, 0.5); border: 1px solid #DDD;
border-radius: 5px; border-radius: 0px 4px 4px 0px;
padding-left: 1em; border-left: none;
padding-right: 1em; padding-left: 0.5em;
margin-bottom: 5px; min-width: 100px;
min-width: 200px;
position: absolute; position: absolute;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: space-between; justify-content: space-between;
align-items: start; align-items: top;
z-index: 1;
margin: 0;
box-shadow: 4px 0 6px -4px rgba(0,0,0,0.12);
} }
.grid-tag:hover {
z-index: 100 !important;
}
.tag-handle {
position: relative;
width: 20px;
background-color: var(--bulma-warning-light);
margin-left: 10px;
cursor: move;
}
.tag-name {
flex: 1;
text-align: right;
}
.tag-handle-resize {
position: absolute;
height: 10px;
width: 10px;
bottom: 0px;
right: 0px;
border-radius: 3px 0px 0px 0px;
background-color: var(--bulma-warning);
cursor: ns-resize;
}
{% for tag, colour in tag_colours %}
.tag-type-{{ tag }} .tag-handle {
background-color: var(--bulma-{{ colour }}-light);
}
.tag-type-{{ tag }} .tag-handle-resize {
background-color: var(--bulma-{{ colour }});
}
{% endfor %}
#tag-area { #tag-area {
min-width: 220px; min-width: 220px;
position: relative;
font-size: 9pt;
} }
.instrument { .instrument {
display: list-item !important; display: list-item !important;
@ -64,32 +119,34 @@
{% block page %} {% block page %}
<h3 class="subtitle"><a href="{% url 'work_detail' collection.pk document.work_id %}">{{ document.work.name }}</a></h3> <h3 class="subtitle"><a href="{% url 'work_detail' collection.pk document.work_id %}">{{ document.work.name }}</a></h3>
<div id="annotation-area" class="columns is-centered"> <div id="annotation-area" class="columns is-centered">
<div class="column is-narrow"> <div class="column is-narrow" id="area-left">
<h3>Page control</h3>
<div class="columns"> <div class="columns">
<div class="column is-narrow"> <div class="column is-narrow">
<button class="button is-small is-primary" id="prev">&lt;</button> <button class="button is-small is-primary" id="prev">&lt;</button>
</div> </div>
<div class="column"> <div class="column has-text-centered">
<span id="page-num">-</span> / <span id="page-count">-</span> <span id="page-num">-</span> / <span id="page-count">-</span>
</div> </div>
<div class="column is-narrow"> <div class="column is-narrow">
<button class="button is-small is-primary" id="next">&gt;</button> <button class="button is-small is-primary" id="next">&gt;</button>
</div> </div>
</div> </div>
<h3>Quick Tags</h3>
<ul id="unassigned-area"> <ul id="unassigned-area">
{% for tag, inst in document.work.music_tags %} {% for tag, inst in document.work.music_tags %}
<li> <li class="tag is-warning" onclick="assignTag('{{tag}}', this)">{{ inst }}</li>
<span class="is-clickable" onclick="assignInstrument('{{tag}}', this)")>{{ inst }}</span>
&nbsp;
<span class="is-clickable" onclick="addNumberedInstrument('{{tag}}', this)">...</span>
</li>
{% endfor %} {% endfor %}
</ul> </ul>
<a onclick="document.getElementById('add-modal').classList.add('is-active')">Add Tag</a> <h3>Actions</h3>
<div>
<a class="button is-small is-primary" onclick="showAddModal()">New Tag</a>
<button class="button is-small is-primary" onclick="expandEntries()">Expand Tags</button>
</div>
</div> </div>
<div class="column is-narrow"> <div class="column is-narrow">
<div class="has-text-centered"> <div class="has-text-centered">
<div class="box" style="display: inline-block;"> <div class="box" style="display: inline-block; padding: 0px;">
<canvas id="inline-viewer" style="width: 500px;"></canvas> <canvas id="inline-viewer" style="width: 500px;"></canvas>
</div> </div>
</div> </div>
@ -102,7 +159,6 @@
<div class="grid-column" id="tag-area"> <div class="grid-column" id="tag-area">
</div> </div>
</div> </div>
<button class="button is-primary" style="width: 100%" onclick="expandEntries()">Expand</button>
</div> </div>
</div> </div>
@ -111,24 +167,46 @@
<div class="modal-background" onclick="closeAddModal()"></div> <div class="modal-background" onclick="closeAddModal()"></div>
<div class="modal-card"> <div class="modal-card">
<header class="modal-card-head"> <header class="modal-card-head">
<p class="modal-card-title">Add Instrument</p> <p class="modal-card-title">Add Tag</p>
<button class="delete" aria-label="close" onclick="closeAddModal()"></button> <button class="delete" aria-label="close" onclick="closeAddModal()"></button>
</header> </header>
<section class="modal-card-body"> <section class="modal-card-body">
<div class="field has-addons"> <div class="field has-addons is-justify-content-center">
<span class="control"> <span class="control">
<input type="text" class="input" list="instrument-list" id="add-instrument-name"/> <span class="select">
<select onChange="changeTagType()" id="tag-type-selection">
<option value="inst">Instrument</option>
<option value="mvmt">Movement</option>
<option value="sect">Section</option>
<option value="no">Number</option>
<option value="page">Page</option>
</select>
</span>
</span>
<span class="control add-tag-option enable-page">
<span class="select">
<select id="add-tag-page">
<option>Left</option>
<option>Right</option>
</select>
</span>
</span>
<span class="control add-tag-option enable-sect">
<input type="text" class="input" id="add-tag-name"/>
</span>
<span class="control add-tag-option enable-inst">
<input type="text" class="input" list="instrument-list" id="add-tag-inst"/>
<datalist id="instrument-list"> <datalist id="instrument-list">
{% for inst in json_data.instruments.values %} {% for inst in json_data.instruments.values %}
<option value="{{inst}}"/> <option value="{{inst}}"/>
{% endfor %} {% endfor %}
</datalist> </datalist>
</span> </span>
<span class="control"> <span class="control add-tag-option enable-inst enable-mvmt enable-no">
<input type="number" class="input" max="4" min="1" size="3" id="add-instrument-variant"/> <input type="number" class="input" min="1" size="3" id="add-tag-ordinal"/>
</span> </span>
<span class="control"> <span class="control">
<button class="button is-primary" onclick="addInstrument();">Add</button> <button class="button is-primary" onclick="createNewTag();">Add</button>
</span> </span>
</div> </div>
</section> </section>
@ -137,6 +215,15 @@
<p>{{ document.upload.name }}</p> <p>{{ document.upload.name }}</p>
<template id="blank-tag">
<div class="grid-tag">
<span class="tag-name"></span>
<div class="tag-handle">
<div class="tag-handle-resize"></div>
</div>
</div>
</template>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
@ -166,7 +253,11 @@
pageNumPending = null, pageNumPending = null,
scale = 1, scale = 1,
canvas = document.getElementById('inline-viewer'), canvas = document.getElementById('inline-viewer'),
ctx = canvas.getContext('2d'); ctx = canvas.getContext('2d'),
draggedTag = null,
dragMode = null,
dragOrigStart = null,
dragOrigEnd = null;
/** /**
* Get page info from document, resize canvas accordingly, and render page. * Get page info from document, resize canvas accordingly, and render page.
@ -251,6 +342,7 @@
pdfDoc = pdfDoc_; pdfDoc = pdfDoc_;
const pageList = document.getElementById('page-list'); const pageList = document.getElementById('page-list');
const tagGrid = document.querySelector('.tag-grid');
pageList.innerHTML = ''; pageList.innerHTML = '';
for (var i=0; i<pdfDoc.numPages; i++) { for (var i=0; i<pdfDoc.numPages; i++) {
let page = i+1; let page = i+1;
@ -264,10 +356,47 @@
pageList.appendChild(el); pageList.appendChild(el);
} }
tagGrid.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (draggedTag) {
const rect = pageList.getBoundingClientRect();
const y = e.clientY - rect.top;
const page = Math.min(Math.max(Math.floor(y / 25) + 1, 1), pdfDoc.numPages);
if (dragMode === 'resize') {
if (page >= parseInt(draggedTag.dataset.start) && page !== parseInt(draggedTag.dataset.end)) {
draggedTag.dataset.end = page;
updateTag(draggedTag);
resolveOverlaps();
}
} else {
const length = dragOrigEnd - dragOrigStart;
if (page !== parseInt(draggedTag.dataset.start)) {
draggedTag.dataset.start = page;
draggedTag.dataset.end = Math.min(page + length, pdfDoc.numPages);
updateTag(draggedTag);
resolveOverlaps();
}
}
}
});
tagGrid.addEventListener('drop', (e) => {
e.preventDefault();
if (draggedTag) {
draggedTag = null;
dragMode = null;
}
resolveOverlaps();
});
document.getElementById('page-count').textContent = pdfDoc.numPages; document.getElementById('page-count').textContent = pdfDoc.numPages;
// Initial/first page rendering // Initial/first page rendering
renderPage(pageNum); renderPage(pageNum);
setTimeout(() => {
const previewBox = document.querySelector('.box');
tagGrid.style.maxHeight = previewBox.offsetHeight + 'px';
}, 100);
tagArea.innerHTML = ''; tagArea.innerHTML = '';
for (let pageTag of data.pageTags) { for (let pageTag of data.pageTags) {
@ -276,10 +405,13 @@
dirty = false; dirty = false;
}); });
function showAddModal() {
document.getElementById("add-modal").classList.add("is-active");
changeTagType();
}
function closeAddModal() { function closeAddModal() {
document.getElementById('add-modal').classList.remove('is-active'); document.getElementById('add-modal').classList.remove('is-active');
document.getElementById('add-instrument-name').value = "";
document.getElementById('add-instrument-variant').value = "";
} }
function addNumberedInstrument(tag, e) { function addNumberedInstrument(tag, e) {
@ -292,11 +424,27 @@
modal.classList.add('is-active'); modal.classList.add('is-active');
} }
function addInstrument() { function changeTagType() {
let name = document.getElementById('add-instrument-name'); const tag_select = document.querySelector("#tag-type-selection");
let variant = document.getElementById('add-instrument-variant'); const selected = tag_select.value;
document.querySelectorAll(".add-tag-option").forEach((x) => {x.classList.add("is-hidden")});
document.querySelectorAll(".add-tag-option input").forEach((x) => {x.value=""});
document.querySelectorAll(".enable-" + selected).forEach((x) => {x.classList.remove("is-hidden")});
tag_select.focus();
let inst_name = name.value; }
function generateNewTag() {
const selected = document.querySelector("#tag-type-selection").value;
const n = document.querySelector("#add-tag-ordinal").value;
switch (selected) {
case "mvmt":
case "no":
return selected + "-" + n;
case "sect":
return "sect-" + document.querySelector("#add-tag-name").value;
case "inst":
const inst_name = document.querySelector("#add-tag-inst").value;
var tag = null; var tag = null;
for (let key in data.instruments) { for (let key in data.instruments) {
if (data.instruments[key] == inst_name) { if (data.instruments[key] == inst_name) {
@ -306,83 +454,172 @@
} }
if (!tag) { if (!tag) {
alert("Unknown tag: " + name); tag = inst_name;
return;
} }
if (variant.value) { if (n) {
tag += "-" + variant.value; tag += "-" + n;
}
return tag;
case "page":
return "page-" + document.querySelector("#add-tag-page").value;
}
} }
variant.value = '';
name.value = '';
let li = document.createElement('li'); function createNewTag() {
li.classList.add("is-clickable");
li.addEventListener('click', () => assignInstrument(tag, li)); const tag = generateNewTag()
li.innerHTML = get_instrument(tag);
document.getElementById('unassigned-area').appendChild(li);
addTag(tag, pageNum, pageNum); addTag(tag, pageNum, pageNum);
closeAddModal(); closeAddModal();
} }
function assignInstrument(tag, el) { function assignTag(tag, el) {
addTag(tag, pageNum, pageNum); addTag(tag, pageNum, pageNum);
//el.remove(); //el.remove();
} }
function addTag(tag, start, end) { function addTag(tag, start, end) {
console.log("addTag", tag, start, end);
const el = document.createElement('div');
el.className = 'grid-tag'; //console.log("addTag", tag, start, end);
el.dataset.tag = tag; const fragment = document.importNode(document.querySelector("#blank-tag").content, true);
const el = fragment.querySelector("div");
const tagName = el.querySelector(".tag-name");
tagName.innerHTML=get_tag_name(tag);
//el.className = 'grid-tag';
el.dataset.start = start; el.dataset.start = start;
el.dataset.end = end; el.dataset.end = end;
el.dataset.tag = tag;
let setStart = document.createElement('span'); const parts = tag.split("-")
setStart.className = "icon is-action"; el.classList.add("tag-type-" + parts[0]);
setStart.innerHTML = '<span class="material-symbols-outlined" title="Set start page">vertical_align_top</span>';
setStart.addEventListener('click', () => setTagStart(el));
el.appendChild(setStart);
let label = document.createElement('span'); const handle = el.querySelector('.tag-handle');
handle.draggable = true;
let name = document.createElement('b'); handle.addEventListener('dragstart', (e) => {
name.innerHTML = get_instrument(tag); draggedTag = el;
label.appendChild(name); dragMode = 'move';
el.appendChild(label); dragOrigStart = parseInt(el.dataset.start);
dragOrigEnd = parseInt(el.dataset.end);
let del = document.createElement('span'); el.style.zIndex = 1000;
del.className = "icon is-action"; e.dataTransfer.effectAllowed = 'move';
del.innerHTML = '<span class="material-symbols-outlined" title="Remove this tag">delete</span>'; const blankImg = new Image();
del.addEventListener('click', () => { blankImg.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
e.dataTransfer.setDragImage(blankImg, 0, 0);
});
handle.addEventListener('dragend', () => {
if (draggedTag === el && dragMode === 'move') {
el.dataset.start = dragOrigStart;
el.dataset.end = dragOrigEnd;
updateTag(el);
el.remove(); el.remove();
dirty = true; dirty = true;
} else if (draggedTag === el) {
el.dataset.start = dragOrigStart;
el.dataset.end = dragOrigEnd;
updateTag(el);
el.style.zIndex = '';
}
resolveOverlaps();
draggedTag = null;
dragMode = null;
}); });
el.appendChild(del)
let setEnd = document.createElement('span'); const resize = el.querySelector('.tag-handle-resize');
setEnd.className = "icon is-action"; resize.draggable = true;
setEnd.innerHTML = '<span class="material-symbols-outlined" title="Set end page">vertical_align_bottom</span>'; resize.addEventListener('dragstart', (e) => {
setEnd.addEventListener('click', () => setTagEnd(el)); draggedTag = el;
el.appendChild(setEnd); dragMode = 'resize';
dragOrigEnd = parseInt(el.dataset.end);
el.style.zIndex = 1000;
e.dataTransfer.effectAllowed = 'move';
const blankImg2 = new Image();
blankImg2.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
e.dataTransfer.setDragImage(blankImg2, 0, 0);
e.stopPropagation();
});
resize.addEventListener('dragend', () => {
if (draggedTag === el) {
el.dataset.end = dragOrigEnd;
updateTag(el);
el.style.zIndex = '';
}
resolveOverlaps();
draggedTag = null;
dragMode = null;
});
updateTag(el); updateTag(el);
tagArea.appendChild(el); tagArea.appendChild(el);
resolveOverlaps();
} }
function updateTag(tag) { function updateTag(tag) {
let start = tag.dataset.start; let start = tag.dataset.start;
let end = tag.dataset.end; let end = tag.dataset.end;
let span = end-start+1; let span = end-start+1;
let height = span * 25 + (span-1) * 5; let height = span * 25 + 1;
let top = (start-1) * 30; let top = (start-1) * 25;
tag.style.height = height + 'px'; tag.style.height = height + 'px';
tag.style.marginTop = top + 'px'; tag.style.marginTop = top + 'px';
dirty = true; dirty = true;
const name = tag.querySelector('.tag-name');
const handle = tag.querySelector('.tag-handle');
if (name) {
if (span >= 3) {
name.style.position = 'absolute';
name.style.top = '4px';
name.style.right = '20px';
name.style.writingMode = 'vertical-rl';
tag.style.justifyContent = 'flex-end';
if (handle) handle.style.marginLeft = 'auto';
} else {
name.style.position = '';
name.style.top = '';
name.style.right = '';
name.style.writingMode = '';
tag.style.justifyContent = '';
if (handle) handle.style.marginLeft = '';
}
}
}
function resolveOverlaps() {
const tags = Array.from(tagArea.children);
tags.sort((a, b) => parseInt(a.dataset.start) - parseInt(b.dataset.start));
const groups = [];
let current = [tags[0]];
for (let i = 1; i < tags.length; i++) {
const prevEnd = parseInt(tags[i-1].dataset.end);
const currStart = parseInt(tags[i].dataset.start);
if (currStart <= prevEnd) {
current.push(tags[i]);
} else {
groups.push(current);
current = [tags[i]];
}
}
if (current.length) groups.push(current);
for (let group of groups) {
group.sort((a, b) => {
const spanA = parseInt(a.dataset.end) - parseInt(a.dataset.start);
const spanB = parseInt(b.dataset.end) - parseInt(b.dataset.start);
return spanB - spanA;
});
const n = group.length;
group.forEach((tag, i) => {
tag.style.minWidth = (120 + (n - 1 - i) * 70) + 'px';
tag.style.zIndex = i;
});
}
} }
function setTagStart(el) { function setTagStart(el) {
@ -404,6 +641,10 @@
} }
function expandEntries() { function expandEntries() {
const answer = confirm("This will expand all tags to fill up available pages");
if (!answer) return;
const entries = Array.from(tagArea.children); const entries = Array.from(tagArea.children);
entries.sort((a, b) => a.dataset.start-b.dataset.start); entries.sort((a, b) => a.dataset.start-b.dataset.start);
const c = entries.length; const c = entries.length;
@ -416,6 +657,33 @@
updateTag(entries[c-1]); updateTag(entries[c-1]);
} }
const body = document.querySelector("body");
function keymap(e) {
if (e.target != body) return;
switch(e.key) {
case "ArrowRight":
case "l":
case "ArrowDown":
case "j":
onNextPage();
break;
case "ArrowLeft":
case "h":
case "ArrowUp":
case "k":
onPrevPage();
break;
case "n":
showAddModal();
break;
default:
return;
}
e.preventDefault();
}
body.addEventListener("keydown", keymap)
function saveTags() { function saveTags() {
const pageTags = []; const pageTags = [];
for (let pageTag of tagArea.children ) { for (let pageTag of tagArea.children ) {
@ -444,9 +712,9 @@
dirty = false; dirty = false;
} }
function get_instrument(s) { function get_tag_name(s) {
let parts = s.split('-'); let parts = s.split('-');
let instrument = data.instruments[parts[0]]; let instrument = data.instruments[parts[0]] || parts[0];
if (parts.length == 2) { if (parts.length == 2) {
return instrument + " " + parts[1]; return instrument + " " + parts[1];
} }

View File

@ -35,7 +35,6 @@ class ProjectItemListView(ProjectMixin, ListView):
model = models.ProjectItem model = models.ProjectItem
def post(self, request: HttpRequest, **kwargs): def post(self, request: HttpRequest, **kwargs):
project_works = self.project.works.all() project_works = self.project.works.all()
print(request.POST) print(request.POST)
@ -597,6 +596,12 @@ class DocumentAnnotateView(DocumentMixin, DetailView):
collection=data["collection"].pk, collection=data["collection"].pk,
pk=data["document"].pk, pk=data["document"].pk,
) )
data["tag_colours"] = [
("page", "danger"),
("no", "link"),
("mvmt", "link"),
("sect", "success"),
]
data["json_data"] = {"pageTags": pages, "instruments": dict(MUSIC_TAGS)} data["json_data"] = {"pageTags": pages, "instruments": dict(MUSIC_TAGS)}
return data return data