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);