Release v1.26.13: Fix drag-and-drop file opening in tab & improve magazine dropzone reliability

This commit is contained in:
mARTin-B78 2026-09-10 14:31:16 +02:00
parent 11142842c2
commit 30e01a5cec
7 changed files with 152 additions and 93 deletions

View File

@ -5,6 +5,17 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
---
## [1.26.13] — 2026-09-10
### Fixed
- **App-Wide Drag-and-Drop Safety Net & Dropzone Reliability.**
- Installed an eager app-wide capture-phase `dragover` / `drop` safety net in `utils.js` that intercepts file drops on `window` and `document` to guarantee the browser never navigates away to open a dropped PDF in a new tab.
- Improved `#mag-dropzone` drag event handling with nested element drag-depth tracking (`dragCounter`), preventing dragover class flickering when hovering over dropzone titles, icons, or buttons.
- Direct file passing in `magCreate(file)`, removing programmatic `<input>` DataTransfer mutation to eliminate double invocation and race conditions during drag-and-drop.
- Cleaned up duplicate variable scope declarations in `renderReview()` in `magazine.js`.
---
## [1.26.12] — 2026-09-10
### Fixed

View File

@ -1 +1 @@
1.26.12
1.26.13

File diff suppressed because one or more lines are too long

View File

@ -1,11 +1,11 @@
// ── Init ──────────────────────────────────────────────────────────────────
initBenchmarkSampleControls();
if (typeof initBenchmarkSampleControls === 'function') initBenchmarkSampleControls();
// Sync visible preview-text input with hidden benchmark-sample-text
(function syncPreviewInput() {
const visible = $('vl-preview-sample');
const hidden = $('benchmark-sample-text');
if (!visible) return;
if (!visible || typeof BENCHMARK_SAMPLE_STORAGE_KEY === 'undefined') return;
const stored = localStorage.getItem(BENCHMARK_SAMPLE_STORAGE_KEY);
visible.value = stored || DEFAULT_BENCHMARK_SAMPLE_TEXT;
if (hidden) hidden.value = visible.value;
@ -19,12 +19,14 @@ initBenchmarkSampleControls();
(function syncPreviewLangBtn() {
const ta = $('vl-preview-sample');
const btn = $('vl-preview-lang-btn');
if (!ta || !btn) return;
if (!ta || !btn || typeof VL_SAMPLE_TEXTS === 'undefined') return;
const cur = ta.value.trim();
const matched = Object.keys(VL_SAMPLE_TEXTS).find(l => VL_SAMPLE_TEXTS[l] === cur) || 'EN';
const cc = (_VL_LANG_FLAG[matched] || 'gb').toLowerCase();
const flags = typeof _VL_LANG_FLAG !== 'undefined' ? _VL_LANG_FLAG : {};
const labels = typeof LANGUAGE_LABELS !== 'undefined' ? LANGUAGE_LABELS : {};
const cc = (flags[matched] || 'gb').toLowerCase();
btn.innerHTML = `<span class="fi fi-${cc}"></span>`;
btn.title = (LANGUAGE_LABELS[matched] || matched) + ' — click to change sample language';
btn.title = (labels[matched] || matched) + ' — click to change sample language';
btn.dataset.lang = matched;
})();

View File

@ -213,13 +213,15 @@ function magShowDroppedFile(file) {
function installGlobalMagDropGuard() {
if (window._magDropGuardInstalled) return;
window._magDropGuardInstalled = true;
window.addEventListener('dragover', (e) => {
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
});
window.addEventListener('drop', (e) => {
e.preventDefault();
});
const guard = (e) => {
if (e.dataTransfer && Array.from(e.dataTransfer.types || []).includes('Files')) {
e.preventDefault();
if (e.type === 'dragover' && e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
}
};
window.addEventListener('dragenter', guard, true);
window.addEventListener('dragover', guard, true);
window.addEventListener('drop', guard, true);
}
installGlobalMagDropGuard();
@ -239,50 +241,61 @@ function setupMagDropzone() {
}
});
// dragenter AND dragover both need preventDefault() -- some browsers only
// arm the element as a valid drop target once dragenter itself has been
// cancelled, not just the dragover events that follow it.
const armDropTarget = (e) => {
let dragCounter = 0;
zone.addEventListener('dragenter', (e) => {
e.preventDefault();
e.stopPropagation();
dragCounter++;
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
zone.classList.add('dragover');
});
zone.addEventListener('dragover', (e) => {
e.preventDefault();
e.stopPropagation();
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
zone.classList.add('dragover');
};
zone.addEventListener('dragenter', armDropTarget);
zone.addEventListener('dragover', armDropTarget);
});
zone.addEventListener('dragleave', (e) => {
e.preventDefault();
e.stopPropagation();
zone.classList.remove('dragover');
dragCounter = Math.max(0, dragCounter - 1);
if (dragCounter === 0) {
zone.classList.remove('dragover');
}
});
zone.addEventListener('drop', (e) => {
e.preventDefault();
e.stopPropagation();
dragCounter = 0;
zone.classList.remove('dragover');
const file = e.dataTransfer?.files?.[0];
let file = e.dataTransfer?.files?.[0];
if (!file && e.dataTransfer?.items?.[0]) {
file = e.dataTransfer.items[0].getAsFile();
}
if (!file) return;
if (file.type !== 'application/pdf' && !/\.pdf$/i.test(file.name)) {
return say('Only PDF files are supported', 'err');
}
// Route the dropped file through the same hidden <input> magCreate()
// already reads, so drag-and-drop and "Choose file" share one code path.
const dt = new DataTransfer();
dt.items.add(file);
input.files = dt.files;
magShowDroppedFile(file);
magCreate();
magCreate(file);
});
input.addEventListener('change', () => {
const file = input.files?.[0];
if (file) {
magShowDroppedFile(file);
magCreate();
magCreate(file);
}
});
}
async function magCreate() {
const file = $('mag-new-file')?.files?.[0];
async function magCreate(droppedFile = null) {
const file = droppedFile || $('mag-new-file')?.files?.[0];
if (!file) return say('Choose or drop a PDF file first', 'err');
let title = ($('mag-new-title')?.value || '').trim();
@ -675,48 +688,33 @@ function renderReview() {
const included = _magReviewChecked || new Set(a.blocks || []);
const conf = confidenceLabel(a.confidence);
const page = state.reviewPage;
const pageMeta = (state.layout.pages || []).find((p) => p.page === page);
const pageBlocks = page == null ? [] : blocksForPages([page]);
const pageImages = page == null ? [] : (state.layout.images || [])
.filter((img) => img.page === page).sort((x, y) => (x.reading_order || 0) - (y.reading_order || 0));
const pageMeta = page != null ? (state.layout?.pages || []).find((p) => p.page === page) : null;
const ownImages = new Set(a.images || []);
const pageBlocks = page == null ? [] : blocksForPages([page]);
const pageImages = page == null ? [] : (state.layout?.images || [])
.filter((img) => img.page === page).sort((x, y) => (x.reading_order || 0) - (y.reading_order || 0));
// One merged, reading-order-sorted list so numbering on both sides matches
// regardless of whether an item is text or a figure.
const visualBlockIds = new Set(pageImages.map(i => i.source_block).filter(Boolean));
const regions = pageBlocks.filter(b => !visualBlockIds.has(b.id)).map((b) => ({ kind: 'block', id: b.id, ro: b.reading_order, data: b }))
.concat(pageImages.map((img) => ({ kind: 'image', id: img.id, ro: img.reading_order || 0, data: img })))
const regions = pageBlocks.filter(b => !visualBlockIds.has(b.id)).map((b) => ({ kind: 'block', id: b.id, ro: b.reading_order ?? 0, data: b }))
.concat(pageImages.map((img) => ({ kind: 'image', id: img.id, ro: img.reading_order ?? 0, data: img })))
.sort((x, y) => x.ro - y.ro);
const spanIdx = state.reviewSpan.indexOf(page);
const spanIdx = (state.reviewSpan || []).indexOf(page);
const prevPage = spanIdx > 0 ? state.reviewSpan[spanIdx - 1] : null;
const nextPage = spanIdx >= 0 && spanIdx < state.reviewSpan.length - 1 ? state.reviewSpan[spanIdx + 1] : null;
const issuePages = Number(state.layout?.page_count || 0);
const previousIssuePage = page > 1 ? page - 1 : null;
if (!host) return;
const page = state.reviewPage;
const pageMeta = page != null ? (state.layout.pages || []).find((p) => p.page === page) : null;
const article = state.article;
const nextPage = spanIdx >= 0 && spanIdx < (state.reviewSpan || []).length - 1 ? state.reviewSpan[spanIdx + 1] : null;
const issuePages = (state.layout || {}).page_count || '—';
const nextIssuePage = page != null && page < (state.layout || {}).page_count ? page + 1 : null;
const prevIssuePage = page != null && page > 1 ? page - 1 : null;
const included = new Set(article ? article.blocks || [] : []);
const ownImages = new Set(article ? article.images || [] : []);
const pageBlocks = (state.layout.blocks || []).filter((b) => b.page === page);
const pageImages = (state.layout.images || []).filter((i) => i.page === page);
const regions = [
...pageBlocks.map((b) => ({ kind: 'block', id: b.id, data: b, ro: b.reading_order ?? 0 })),
...pageImages.map((i) => ({ kind: 'image', id: i.id, data: i, ro: i.reading_order ?? 0 })),
].sort((a, b) => a.ro - b.ro);
let html = '';
html += '<div class="mag-review-pagenav">' +
'<div class="mag-review-pagenav-group" role="group" aria-label="Review pages">' +
'<button type="button" class="btn-sm" aria-label="Previous page" ' + (state.reviewPageIdx > 0 ? '' : 'disabled') +
' onclick="magReviewStepPage(-1)"><span class="mdi mdi-chevron-left" aria-hidden="true"></span></button>' +
'<strong class="mag-review-page-number">Page ' + (page ?? '—') + '</strong>' +
'<button type="button" class="btn-sm" aria-label="Next page" ' + (state.reviewPageIdx < (article?.pages || []).length - 1 ? '' : 'disabled') +
'<button type="button" class="btn-sm" aria-label="Next page" ' + (state.reviewPageIdx < (a?.pages || []).length - 1 ? '' : 'disabled') +
' onclick="magReviewStepPage(1)"><span class="mdi mdi-chevron-right" aria-hidden="true"></span></button></div>' +
'<div class="mag-review-pagenav-group" role="group" aria-label="Whole magazine pages">' +
'<button type="button" class="btn-sm" aria-label="Previous magazine page" ' + (prevIssuePage ? '' : 'disabled') +
@ -745,7 +743,6 @@ function renderReview() {
'</div>';
html += '<div class="mag-review-fields">';
const a = state.article || {};
html += '<div class="mag-review-field-row">' +
'<label>Title<span class="mag-review-field-control"><input id="mag-rev-title" type="text" value="' + esc(a.title) + '"><button type="button" class="btn-sm" onclick="magReviewToggleDraw(\'title\')"><span class="mdi mdi-vector-square-edit"></span> Pick from page</button></span></label>' +
'<label>Author<span class="mag-review-field-control"><input id="mag-rev-author" type="text" value="' + esc(a.author) + '"><button type="button" class="btn-sm" onclick="magReviewToggleDraw(\'author\')"><span class="mdi mdi-vector-square-edit"></span> Pick from page</button></span></label>' +
@ -753,7 +750,6 @@ function renderReview() {
'<span id="mag-draw-hint" class="muted"></span>';
html += '</div>';
const conf = confidenceLabel(article ? article.confidence : 1.0);
html += '<span class="mag-review-conf ' + conf.cls + '">' + esc(conf.text) + '</span>';
html += '<p class="muted">Untick anything that is not part of this article &mdash; a sidebar, an advert, or a neighbouring piece. Only ticked text is read aloud. Click a numbered region on the page, or its matching entry on the right, to line the two up. A region labelled with another article belongs to a different piece sharing this page. Draw a missing text or image region below.</p>';

View File

@ -1392,6 +1392,24 @@ if (typeof MutationObserver !== 'undefined') {
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => initLangSelects(document));
else initLangSelects(document);
// App-Wide Drag and Drop Safety Net:
// Prevents the browser from navigating away to a dropped file (e.g. opening PDF in a new tab)
// if a drop occurs outside an active dropzone or on elements without custom drop handling.
(function installGlobalAppDropGuard() {
if (root._appDropGuardInstalled) return;
root._appDropGuardInstalled = true;
const preventDefaultDrop = (e) => {
if (e.dataTransfer && Array.from(e.dataTransfer.types || []).includes('Files')) {
e.preventDefault();
if (e.type === 'dragover') {
e.dataTransfer.dropEffect = 'copy';
}
}
};
window.addEventListener('dragover', preventDefaultDrop, true);
window.addEventListener('drop', preventDefaultDrop, true);
})();
Object.assign(root, {
$, avatarColor, toast, confirmDialog, status, ensureStatusBar, updateStatusBar,
escHtml, debounce, logErr, ttvcIsLowMemoryDevice, fmtDuration, loadingMarkup,

View File

@ -218,3 +218,35 @@ test('drawing a chart links the crop and excludes enclosed narration on approval
await page.getByRole('button', { name: 'Approve', exact: true }).click();
expect((await request).postDataJSON()).toMatchObject({ images: ['manual-chart'], blocks: ['b1'] });
});
test('dropping a PDF file onto mag-dropzone triggers creation without browser navigation', async ({ page }) => {
await mockBackend(page);
let uploadCalled = false;
await page.route('**/api/magazine/docs', route => {
uploadCalled = true;
return route.fulfill({ json: { id: DOC_ID } });
});
await page.route(`**/api/reader/docs/${DOC_ID}/source*`, route => route.fulfill({ json: { ok: true } }));
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached' });
await navTo(page, 's-magazine');
const dropzone = page.locator('#mag-dropzone');
await expect(dropzone).toBeVisible();
const dataTransfer = await page.evaluateHandle(() => {
const dt = new DataTransfer();
const file = new File(['%PDF-1.4 dummy pdf content'], 'ct_KI_Wissen_2026.pdf', { type: 'application/pdf' });
dt.items.add(file);
return dt;
});
await dropzone.dispatchEvent('dragenter', { dataTransfer });
await dropzone.dispatchEvent('dragover', { dataTransfer });
await dropzone.dispatchEvent('drop', { dataTransfer });
await page.waitForTimeout(300);
expect(uploadCalled).toBe(true);
});