Support bulk layout review selection edits

This commit is contained in:
Sean McElwain 2026-05-27 00:00:51 -05:00
parent 1e55035f09
commit e4d129cc06
1 changed files with 112 additions and 37 deletions

View File

@ -1933,6 +1933,8 @@ document.addEventListener("DOMContentLoaded", () => {
<button type="button" class="layout-tool-btn" id="layout-select-all">Select All</button>
<button type="button" class="layout-tool-btn" id="layout-multi-select">Multi</button>
<button type="button" class="layout-tool-btn" id="layout-clear-selection">Clear</button>
<button type="button" class="layout-tool-btn" id="layout-invert-selection">Invert</button>
<button type="button" class="layout-tool-btn" id="layout-select-line">Line</button>
<button type="button" class="layout-tool-btn" id="layout-tool-pan">Pan</button>
<button type="button" class="layout-tool-btn" id="layout-tool-add">Add</button>
<button type="button" class="layout-tool-btn danger" id="layout-delete-word">Delete</button>
@ -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);