Compare commits

...

2 Commits

9 changed files with 206 additions and 137 deletions

View File

@ -5,6 +5,26 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
---
## [1.26.14] — 2026-09-10
### Added
- **Studio-Style Side-by-Side Magazine Upload Layout.**
- Re-designed the Magazine upload card to mirror Studio mode's side-by-side layout: left panel contains optional Title & Issue metadata inputs plus a prominent **Durchsuchen** button (`<span class="mdi mdi-folder-open-outline"></span> Durchsuchen`), and right panel contains the clean `.reader-dropzone`.
- Removed the redundant `+ Add magazine` button for a clean, direct, one-action upload flow.
---
## [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.14

View File

@ -180,29 +180,17 @@
color: var(--subtext);
font-size: 12px;
}
#s-reader .reader-source-grid {
#s-reader .reader-source-grid,
#s-caststudio .reader-source-grid,
#s-magazine .reader-source-grid {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(260px, .8fr);
gap: 12px;
align-items: stretch;
}
#s-caststudio .reader-source-grid {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(260px, .8fr);
gap: 12px;
align-items: stretch;
}
#s-reader .reader-paste-panel {
min-width: 0;
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface);
}
#s-caststudio .reader-paste-panel {
#s-reader .reader-paste-panel,
#s-caststudio .reader-paste-panel,
#s-magazine .reader-paste-panel {
min-width: 0;
display: flex;
flex-direction: column;
@ -409,8 +397,9 @@
}
@media (max-width: 860px) {
#s-reader .reader-source-grid { grid-template-columns: 1fr; }
#s-caststudio .reader-source-grid { grid-template-columns: 1fr; }
#s-reader .reader-source-grid,
#s-caststudio .reader-source-grid,
#s-magazine .reader-source-grid { grid-template-columns: 1fr; }
#s-reader .reader-tuning-row { grid-template-columns: 1fr; gap: 5px; }
#s-caststudio .reader-tuning-row { grid-template-columns: 1fr; gap: 5px; }
}

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

@ -205,7 +205,7 @@ function magShowDroppedFile(file) {
} else {
if (dropzone) dropzone.classList.remove('is-selected', 'is-uploading');
if (noteEl) noteEl.textContent = MAG_DROPZONE_DEFAULT_NOTE;
if (titleEl) titleEl.textContent = 'Drop a PDF or click to browse';
if (titleEl) titleEl.textContent = 'Drop a PDF document';
if (iconEl) iconEl.className = 'mdi mdi-cloud-upload-outline reader-dropzone-icon';
}
}
@ -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();
@ -293,6 +306,7 @@ async function magCreate() {
const issue = ($('mag-new-issue')?.value || '').trim();
const addBtn = $('mag-add-btn');
const browseBtn = $('mag-browse-btn');
const dropzone = $('mag-dropzone');
const titleInp = $('mag-new-title');
const issueInp = $('mag-new-issue');
@ -312,6 +326,7 @@ async function magCreate() {
}
if (progress) progress.hidden = false;
if (pickBtn) pickBtn.hidden = true;
if (browseBtn) browseBtn.hidden = true;
if (iconEl) iconEl.className = 'mdi mdi-loading mdi-spin reader-dropzone-icon';
if (titleEl) titleEl.textContent = 'Uploading & Ingesting PDF...';
if (noteEl) noteEl.textContent = `Processing "${file.name}" (${sizeMb} MB). Transferring source file and extracting layout...`;
@ -328,6 +343,7 @@ async function magCreate() {
}
if (progress) progress.hidden = true;
if (pickBtn) pickBtn.hidden = false;
if (browseBtn) browseBtn.hidden = false;
if (addBtn) {
addBtn.disabled = false;
addBtn.innerHTML = origBtnText;
@ -675,48 +691,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 +746,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 +753,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

@ -10,30 +10,39 @@
<div id="mag-view-library">
<div class="card mag-new">
<h3>Add a magazine or paper</h3>
<div class="mag-new-grid">
<label for="mag-new-title">Title <span class="muted">(optional)</span>
<input id="mag-new-title" type="text" placeholder="Auto-filled from PDF filename if blank" autocomplete="off">
</label>
<label for="mag-new-issue">Issue <span class="muted">(optional)</span>
<input id="mag-new-issue" type="text" placeholder="September 2026" autocomplete="off">
</label>
</div>
<div class="reader-dropzone" id="mag-dropzone" style="cursor: pointer;">
<span class="mdi mdi-cloud-upload-outline reader-dropzone-icon" id="mag-dropzone-icon"></span>
<strong id="mag-dropzone-title">Drop a PDF or click to browse</strong>
<span class="note" id="mag-dropzone-note">Scanned issues work too &mdash; pages without selectable text are read with OCR.</span>
<div class="mag-dropzone-progress" id="mag-dropzone-progress" hidden>
<div class="mag-dropzone-progress-bar"></div>
<div class="reader-source-grid">
<div class="reader-paste-panel">
<div class="reader-source-method">
<span class="mdi mdi-file-document-edit-outline"></span>
<div><strong>Import &amp; Metadata</strong><span>Select a PDF file or configure optional title and issue details.</span></div>
</div>
<div class="mag-new-grid" style="margin-top: 4px; margin-bottom: 4px;">
<label for="mag-new-title">Title <span class="muted">(optional)</span>
<input id="mag-new-title" type="text" placeholder="Auto-filled from PDF filename if blank" autocomplete="off">
</label>
<label for="mag-new-issue">Issue <span class="muted">(optional)</span>
<input id="mag-new-issue" type="text" placeholder="e.g. September 2026" autocomplete="off">
</label>
</div>
<div class="reader-paste-actions" style="margin-top: auto; padding-top: 6px;">
<label class="btn-primary btn-sm reader-file-pick" id="mag-browse-btn" title="Choose a PDF file from your computer">
<span class="mdi mdi-folder-open-outline"></span> Durchsuchen
<input type="file" id="mag-new-file" accept="application/pdf" style="display:none">
</label>
</div>
</div>
<div class="reader-dropzone" id="mag-dropzone" style="cursor: pointer;">
<span class="mdi mdi-cloud-upload-outline reader-dropzone-icon" id="mag-dropzone-icon"></span>
<strong id="mag-dropzone-title">Drop a PDF document</strong>
<span class="note" id="mag-dropzone-note">Scanned issues work too &mdash; pages without selectable text are read with OCR.</span>
<div class="mag-dropzone-progress" id="mag-dropzone-progress" hidden>
<div class="mag-dropzone-progress-bar"></div>
</div>
<label class="btn-secondary btn-sm reader-file-pick" id="mag-dropzone-pick-btn" title="Choose a PDF file" onclick="event.stopPropagation()">
<span class="mdi mdi-file-upload-outline"></span> Import PDF
</label>
</div>
<label class="btn-secondary btn-sm reader-file-pick" id="mag-dropzone-pick-btn" title="Choose a PDF file" onclick="event.stopPropagation()">
<span class="mdi mdi-file-upload-outline"></span> Choose file
<input type="file" id="mag-new-file" accept="application/pdf" style="display:none">
</label>
</div>
<div class="mag-new-actions">
<button type="button" class="btn primary" id="mag-add-btn" onclick="magCreate()">
<span class="mdi mdi-plus"></span> Add magazine
</button>
</div>
</div>

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