diff --git a/app/templates/documents/detail.html b/app/templates/documents/detail.html index d3339f4..afb9a4f 100644 --- a/app/templates/documents/detail.html +++ b/app/templates/documents/detail.html @@ -1933,6 +1933,8 @@ document.addEventListener("DOMContentLoaded", () => { + + @@ -2513,6 +2515,16 @@ const HANDLE_SIZE_PX = 14; let selectedId = null; let selectedIds = new Set(); let multiSelectMode = false; + + function getSelectedWords() { + if (selectedIds && selectedIds.size) return words.filter(w => selectedIds.has(String(w.id))); + const w = getSelectedWord(); + return w ? [w] : []; + } + + let touchMultiAnchor = false; + let touchMultiTimer = null; + const activePointers = new Set(); let tool = "select"; let zoom = 1; let dragState = null; @@ -3205,8 +3217,9 @@ function refreshSelectionUI(opts = {}) { } function applyEditorValues(push = true) { + const selectedWords = getSelectedWords(); const w = getSelectedWord(); - if (!w) return null; + if (!selectedWords.length || !w) return null; if (push) pushHistory(); const readValue = (el, fallback) => { @@ -3231,23 +3244,25 @@ function refreshSelectionUI(opts = {}) { const nextColor = readValue(colorSource, w.text_color_guess || "#000000"); w.text = nextText; - w.font_family_guess = nextFamily; - w.font_size_guess = nextSize; - w.text_color_guess = nextColor; + for (const item of selectedWords) { + item.font_family_guess = nextFamily; + item.font_size_guess = nextSize; + item.text_color_guess = nextColor; - w.override_style = w.override_style && typeof w.override_style === "object" ? w.override_style : {}; - w.override_style.font_family = nextFamily; - w.override_style.font_size = nextSize; - w.override_style.text_color = nextColor; + item.override_style = item.override_style && typeof item.override_style === "object" ? item.override_style : {}; + item.override_style.font_family = nextFamily; + item.override_style.font_size = nextSize; + item.override_style.text_color = nextColor; - w.resolved_style = w.resolved_style && typeof w.resolved_style === "object" ? w.resolved_style : {}; - w.resolved_style.font_family = nextFamily; - w.resolved_style.font_size = nextSize; - w.resolved_style.text_color = nextColor; + item.resolved_style = item.resolved_style && typeof item.resolved_style === "object" ? item.resolved_style : {}; + item.resolved_style.font_family = nextFamily; + item.resolved_style.font_size = nextSize; + item.resolved_style.text_color = nextColor; - w.manual_flags = w.manual_flags && typeof w.manual_flags === "object" ? w.manual_flags : {}; - w.manual_flags.text_edited = true; - w.manual_flags.style_edited = true; + item.manual_flags = item.manual_flags && typeof item.manual_flags === "object" ? item.manual_flags : {}; + item.manual_flags.style_edited = true; + if (String(item.id) === String(w.id)) item.manual_flags.text_edited = true; + } if (textInput) textInput.value = nextText; if (popoverTextInput) popoverTextInput.value = nextText; @@ -3270,7 +3285,7 @@ function refreshSelectionUI(opts = {}) { refreshSelectionUI({ forceText: true }); syncReviewedTextarea(); renderCanvas(); - setStatus("Applied changes: " + nextFamily + " " + nextSize); + setStatus("Applied style to " + selectedWords.length + " selected"); return w; } @@ -3316,34 +3331,40 @@ function refreshSelectionUI(opts = {}) { } function nudge(dx, dy) { - const w = getSelectedWord(); - if (!w) return; + const selectedWords = getSelectedWords(); + if (!selectedWords.length) return; pushHistory(); - w.bbox = normalizeBBox([ - Number(w.bbox[0]) + dx, - Number(w.bbox[1]) + dy, - Number(w.bbox[2]) + dx, - Number(w.bbox[3]) + dy, - ]); - w.bbox = snapBBox(w.bbox, w.id); + for (const item of selectedWords) { + item.bbox = normalizeBBox([ + Number(item.bbox[0]) + dx, + Number(item.bbox[1]) + dy, + Number(item.bbox[2]) + dx, + Number(item.bbox[3]) + dy, + ]); + item.bbox = snapBBox(item.bbox, item.id); + item.manual_flags = item.manual_flags && typeof item.manual_flags === "object" ? item.manual_flags : {}; + item.manual_flags.geometry_edited = true; + } refreshSelectionUI({ geometryOnly: true }); syncReviewedTextarea(); renderCanvas(); - setStatus("Nudged selection"); + setStatus("Nudged " + selectedWords.length + " selected"); } function deleteSelectedWord() { - const w = getSelectedWord(); - if (!w) return; + const selectedWords = getSelectedWords(); + if (!selectedWords.length) return; pushHistory(); - words = words.filter(item => String(item.id) !== String(selectedId)); + const deleteIds = new Set(selectedWords.map(w => String(w.id))); + words = words.filter(item => !deleteIds.has(String(item.id))); + selectedIds.clear(); selectedId = null; nextWordId = Math.max(0, ...words.map(item => Number(item.id || 0))) + 1; snapGuides = null; refreshSelectionUI({ forceText: true }); syncReviewedTextarea(); renderCanvas(); - setStatus("Deleted selected box"); + setStatus("Deleted " + deleteIds.size + " selected"); } function buildLayoutReviewPayload() { @@ -3540,6 +3561,14 @@ function refreshSelectionUI(opts = {}) { } function handlePointerDown(ev) { + activePointers.add(ev.pointerId); + if (ev.pointerType === "touch" && activePointers.size >= 2) { + multiSelectMode = true; + document.body.classList.add("layout-multi-select-active"); + document.getElementById("layout-multi-select")?.classList.add("active"); + hidePopover(); + setStatus("Touch multi-select on"); + } if (tool === "pan") { ev.preventDefault(); beginPan(ev); @@ -3573,13 +3602,29 @@ function refreshSelectionUI(opts = {}) { return; } - // Click selects immediately. Drag starts only if pointer moves enough. - ev.preventDefault(); - selectedId = String(hit.word.id); - refreshSelectionUI({ forceText: true }); - renderCanvas(); + // Click selects immediately. Drag starts only if pointer moves enough. + ev.preventDefault(); - dragState = { + const id = String(hit.word.id); + const additiveSelect = multiSelectMode || ev.ctrlKey || ev.metaKey || ev.shiftKey || activePointers.size >= 2 || touchMultiAnchor; + + if (additiveSelect) { + if (selectedIds.has(id)) selectedIds.delete(id); + else selectedIds.add(id); + selectedId = Array.from(selectedIds).at(-1) || null; + hidePopover(); + refreshSelectionUI({ forceText: true }); + renderCanvas(); + setStatus("Selected: " + selectedIds.size); + return; + } + + selectedIds = new Set([id]); + selectedId = id; + refreshSelectionUI({ forceText: true }); + renderCanvas(); + + dragState = { mode: "pending-move", wordId: hit.word.id, startX: ev.clientX, @@ -3684,7 +3729,8 @@ function refreshSelectionUI(opts = {}) { document.body.classList.remove("layout-review-dragging"); } - function endDrag() { + function endDrag(ev) { + if (ev && ev.pointerId != null) activePointers.delete(ev.pointerId); if (dragState && dragState.mode === "pending-move") { dragState = null; snapGuides = null; @@ -3752,6 +3798,35 @@ function refreshSelectionUI(opts = {}) { renderCanvas(); setStatus("Selection cleared"); }); + + document.getElementById("layout-invert-selection")?.addEventListener("click", () => { + const next = new Set(); + for (const w of words) { + const id = String(w.id); + if (!selectedIds.has(id)) next.add(id); + } + selectedIds = next; + selectedId = Array.from(selectedIds).at(-1) || null; + refreshSelectionUI({ forceText: true }); + renderCanvas(); + setStatus("Inverted selection: " + selectedIds.size); + }); + + document.getElementById("layout-select-line")?.addEventListener("click", () => { + const w = getSelectedWord(); + if (!w) return; + const b = normalizeBBox(w.bbox || [0,0,0,0]); + const cy = (b[1] + b[3]) / 2; + selectedIds = new Set(words.filter(item => { + const ib = normalizeBBox(item.bbox || [0,0,0,0]); + const icy = (ib[1] + ib[3]) / 2; + return Math.abs(icy - cy) <= LINE_GROUP_TOLERANCE; + }).map(item => String(item.id))); + selectedId = Array.from(selectedIds).at(-1) || null; + refreshSelectionUI({ forceText: true }); + renderCanvas(); + setStatus("Selected line: " + selectedIds.size); + }); document.getElementById("layout-multi-select")?.addEventListener("click", () => { multiSelectMode = !multiSelectMode; document.getElementById("layout-multi-select")?.classList.toggle("active", multiSelectMode);