Print the poster in place, on a portrait sheet
Some checks are pending
build-gate / build (push) Waiting to run
Some checks are pending
build-gate / build (push) Waiting to run
Two Driver asks on the UPG-100 poster wall, both measured at the source.
PORTRAIT. The poster path was the only one of the three print paths using
@page{size:auto}; the list and card-deck paths already pin `letter portrait`.
The poster art is a 3ft x 3ft SQUARE, and a square on an auto page has no
reason to prefer the tall axis, so it landed landscape. Pinned to
`letter portrait` to match its siblings.
The print img rule then had to change with it: `max-height:none;width:100%`
scaled the square to the full page WIDTH, which on portrait letter is taller
than the printable height -- it would have overflowed onto a second page.
Constrained both axes so object-fit letterboxes the square inside the sheet.
VERIFIED via page.pdf({preferCSSPageSize}): 612x792pt, ONE page.
IN PLACE. openPrintWindow() opened a popup and document.write()'d into it.
That was deliberate -- an earlier Driver ask wanted a preview window -- and is
now reversed: the dialog must open with no second browser window. A hidden
same-page iframe keeps the entire existing contract (one full <html> string per
caller, the markup's own <img onload> -> print()) while the dialog belongs to
this page. All three print paths stay on the one mechanism.
It also deletes a failure mode: an iframe cannot be pop-up blocked, so the
"Pop-up blocked -- allow pop-ups" branches every caller carried are unreachable.
Their messages now name something the user can act on instead.
VERIFIED in Chromium: 0 window.open calls, 1 iframe, context page count stays 1.
The noopener post-mortem above the helper is KEPT as history -- the code it
described is gone, but "a flag that returns null by spec looks exactly like an
environment problem" is the lesson worth carrying.
This commit is contained in:
parent
446ec38c5b
commit
e957cbe85f
1 changed files with 75 additions and 28 deletions
|
|
@ -274,38 +274,69 @@ const { scrimRef, scrimStyle } = useViewportScrim(exportState)
|
|||
// all stay — printPoster below still uses posterUrl(), and all three print paths still
|
||||
// share the exportState HUD for progress/errors.
|
||||
|
||||
// ─── SHARED PRINT-WINDOW MECHANISM ────────────────────────────────────────────
|
||||
// ─── SHARED PRINT MECHANISM ───────────────────────────────────────────────────
|
||||
// ONE mechanism, reused by all three "Print …" actions (poster / list / card
|
||||
// deck) rather than three ad-hoc window.opens. Extracted from the poster path,
|
||||
// which already carries the load-bearing fix documented below.
|
||||
//
|
||||
// ★ NO `noopener` HERE, AND THAT IS THE WHOLE FIX (2026-09-23). `noopener` makes window.open()
|
||||
// return null BY SPECIFICATION — severing the opener relationship is exactly what the flag does,
|
||||
// so there is no handle to hand back. With it, `w` was always null, the document.write preview
|
||||
// below was UNREACHABLE FOR EVERY USER, and every click fell through to a raw-content fallback.
|
||||
// MEASURED in a default (non-headless-flagged) browser: window.open('','_blank','noopener') -> null,
|
||||
// the identical call without the flag -> a usable handle. I first attributed the null to this
|
||||
// headless box; it is not environmental, it is what the flag means.
|
||||
// The window we write into carries same-origin markup we generate ourselves, so the opener
|
||||
// reference it regains is our own — there is nothing hostile for `noopener` to protect against here.
|
||||
// deck) rather than three ad-hoc implementations.
|
||||
//
|
||||
// `bodyHtml` is the FULL <html> document string (caller owns <head>/<style>/<body>). The caller's
|
||||
// content is responsible for its own print trigger — pass `autoprint:true` to have this helper
|
||||
// append the `window.focus();window.print()` bootstrap (used by markup with no <img onload> of
|
||||
// its own to hang the call off).
|
||||
//
|
||||
// ── HISTORY, kept because the lesson outlives the code that taught it ──
|
||||
// This helper used to call window.open(). An earlier build passed `noopener`, which makes
|
||||
// window.open() return null BY SPECIFICATION — severing the opener relationship is exactly what
|
||||
// the flag does, so there was no handle to write into. Every click silently fell through to a
|
||||
// raw-content fallback, for every user. MEASURED: window.open('','_blank','noopener') -> null;
|
||||
// the identical call without the flag -> a usable handle. It was first blamed on the headless box;
|
||||
// it was not environmental, it was what the flag means.
|
||||
// The iframe below makes that class of failure unreachable — there is no popup to block and no
|
||||
// opener flag to get wrong — but the lesson stands: a flag that returns null by spec will look
|
||||
// exactly like an environment problem.
|
||||
// ★ IN-PLACE (2026-09-23) — a hidden same-page IFRAME, not a popup window.
|
||||
//
|
||||
// WHY THIS CHANGED, so nobody "restores" the popup as a regression fix: an EARLIER Driver ask
|
||||
// wanted a preview window ("show the poster in your browser to help you print it"), and this
|
||||
// helper was built around window.open() to serve it. The Driver has now REVERSED that: the print
|
||||
// dialog must open DIRECTLY, with no second browser window. That is a decision change, not a bug.
|
||||
//
|
||||
// An iframe is the right vehicle because it keeps the ENTIRE existing contract: callers still pass
|
||||
// one full <html> document string, we still document.write() it, and the markup's own
|
||||
// `<img onload> -> window.print()` still fires — only now `window` inside that document is the
|
||||
// iframe's contentWindow, so the dialog belongs to THIS page. All three print paths (poster /
|
||||
// list / card deck) stay on this one mechanism.
|
||||
//
|
||||
// It also deletes a whole failure mode: there is no popup blocker on an iframe, so the
|
||||
// "popup blocked -> fall back to opening the raw image" branch every caller carried is now dead.
|
||||
// The returned object keeps a truthy `.document`-less shape only so existing `if (!w)` guards stay
|
||||
// harmless; callers should no longer rely on a popup-blocked null.
|
||||
function openPrintWindow(title, bodyHtml, { autoprint = false } = {}) {
|
||||
const w = window.open('', '_blank', 'width=980,height=1100')
|
||||
if (!w) return null // popup blocked — caller decides the fallback
|
||||
w.document.open()
|
||||
w.document.write(bodyHtml)
|
||||
const frame = document.createElement('iframe')
|
||||
// Off-screen rather than display:none — a display:none iframe does not lay out in every engine,
|
||||
// and a zero-height layout can print a blank first page.
|
||||
frame.setAttribute('aria-hidden', 'true')
|
||||
frame.style.cssText = 'position:fixed;right:0;bottom:0;width:1px;height:1px;opacity:0;border:0'
|
||||
document.body.appendChild(frame)
|
||||
|
||||
const doc = frame.contentDocument
|
||||
if (!doc) { frame.remove(); return null } // no document -> caller keeps its fallback
|
||||
doc.open()
|
||||
doc.write(bodyHtml)
|
||||
if (autoprint) {
|
||||
// Appended AFTER the caller's markup so it runs once the document is parsed —
|
||||
// matches the <img onload> timing the poster path already relies on.
|
||||
w.document.write(
|
||||
doc.write(
|
||||
'<script>window.addEventListener("load",function(){setTimeout(function(){window.focus();window.print()},150)})</' + 'script>')
|
||||
}
|
||||
w.document.close()
|
||||
return w
|
||||
doc.close()
|
||||
|
||||
// Reclaim the node once the dialog has been dismissed. `afterprint` is the honest signal; the
|
||||
// timeout is a floor for engines that never fire it, so a click can never leak a frame per press.
|
||||
const drop = () => { if (frame.parentNode) frame.remove() }
|
||||
try { frame.contentWindow.addEventListener('afterprint', () => setTimeout(drop, 0)) } catch { /* cross-doc guard */ }
|
||||
setTimeout(drop, 60000)
|
||||
|
||||
return frame.contentWindow
|
||||
}
|
||||
|
||||
// Escape the handful of characters that break out of an HTML text node or a "..." attribute.
|
||||
|
|
@ -336,20 +367,33 @@ async function printPoster(list) {
|
|||
const title = `Doxa UPG-100 poster — ${escapeHtml(list.label || list.id)}`
|
||||
const w = openPrintWindow(title,
|
||||
'<!DOCTYPE html><html><head><meta charset="utf-8"><title>' + title + '</title>' +
|
||||
'<style>@page{size:auto;margin:12mm}html,body{margin:0;height:100%;background:#fff}' +
|
||||
// PORTRAIT, explicitly. `size:auto` let the browser choose, and it chooses LANDSCAPE for a
|
||||
// square: the poster art is 3ft x 3ft (aspect-ratio:1/1, see the wall template below), so an
|
||||
// auto page has no reason to prefer the tall axis. The list and card-deck print paths in this
|
||||
// same file already pin `size:letter portrait`; the poster path was the odd one out.
|
||||
// (Driver: "print to a vertical piece of printer paper".)
|
||||
'<style>@page{size:letter portrait;margin:12mm}html,body{margin:0;height:100%;background:#fff}' +
|
||||
'body{display:flex;align-items:center;justify-content:center}' +
|
||||
'img{max-width:100%;max-height:100vh;object-fit:contain;display:block}' +
|
||||
'@media print{body{height:auto}img{max-height:none;width:100%}}</style></head>' +
|
||||
// A SQUARE on a PORTRAIT sheet must fit by its SHORTER axis. The old print rule
|
||||
// (`max-height:none;width:100%`) scaled the square to the full page WIDTH, which on portrait
|
||||
// letter is taller than the printable height -> the poster overflowed onto a second page.
|
||||
// Constrain BOTH axes and let object-fit letterbox it inside the sheet instead.
|
||||
'@media print{body{height:auto}' +
|
||||
'img{width:auto;max-width:100%;max-height:100%;object-fit:contain}}</style></head>' +
|
||||
'<body><img src="' + url + '" alt="' + title + '"' +
|
||||
' onload="setTimeout(function(){window.focus();window.print()},150)"' +
|
||||
' onerror="document.body.innerHTML=\'<p style="font:16px system-ui;padding:24px">Poster not available yet.</p>\'">' +
|
||||
'</body></html>')
|
||||
if (!w) { // popup blocked → open the image tab
|
||||
if (!w) {
|
||||
// No longer "popup blocked" — an iframe cannot be blocked. This is now only reachable if the
|
||||
// frame has no contentDocument at all (a sandboxed/detached host), so the honest fallback is
|
||||
// the poster itself rather than a pop-up instruction the user cannot act on.
|
||||
window.open(url, '_blank', 'noopener')
|
||||
exportState.value = { ...exportState.value, phase: 'done', message: 'opened poster', pct: 100 }
|
||||
setTimeout(() => { exportState.value = null }, 900); return
|
||||
}
|
||||
exportState.value = { ...exportState.value, phase: 'done', message: 'print preview opened', pct: 100 }
|
||||
exportState.value = { ...exportState.value, phase: 'done', message: 'print dialog opened', pct: 100 }
|
||||
setTimeout(() => { exportState.value = null }, 900)
|
||||
} catch (e) {
|
||||
exportState.value = { label: list.label, kind: 'Poster', phase: 'error',
|
||||
|
|
@ -433,11 +477,13 @@ async function printList100(list) {
|
|||
'<th>Religion</th><th></th></tr></thead><tbody>' + rowsHtml + '</tbody></table>' +
|
||||
'</body></html>', { autoprint: true })
|
||||
if (!w) {
|
||||
// Not a pop-up blocker any more (this prints via a same-page iframe). Only a host that
|
||||
// denies us a contentDocument lands here, so say something the user can act on.
|
||||
exportState.value = { label: list.label, kind: 'UPG-100 List', phase: 'error',
|
||||
message: 'Pop-up blocked — allow pop-ups for this site and try again.', pct: 0 }
|
||||
message: 'Could not open the print dialog in this view — try the Download button.', pct: 0 }
|
||||
return
|
||||
}
|
||||
exportState.value = { ...exportState.value, phase: 'done', message: 'print preview opened', pct: 100 }
|
||||
exportState.value = { ...exportState.value, phase: 'done', message: 'print dialog opened', pct: 100 }
|
||||
setTimeout(() => { exportState.value = null }, 900)
|
||||
} catch (e) {
|
||||
exportState.value = { label: list.label, kind: 'UPG-100 List', phase: 'error',
|
||||
|
|
@ -505,11 +551,12 @@ async function printCardDeck(list) {
|
|||
'<div class="deck">' + cardsHtml + '</div>' +
|
||||
'</body></html>', { autoprint: true })
|
||||
if (!w) {
|
||||
// See the note in the list path: an iframe cannot be pop-up blocked.
|
||||
exportState.value = { label: list.label, kind: 'Card Deck', phase: 'error',
|
||||
message: 'Pop-up blocked — allow pop-ups for this site and try again.', pct: 0 }
|
||||
message: 'Could not open the print dialog in this view — try the Download button.', pct: 0 }
|
||||
return
|
||||
}
|
||||
exportState.value = { ...exportState.value, phase: 'done', message: 'print preview opened', pct: 100 }
|
||||
exportState.value = { ...exportState.value, phase: 'done', message: 'print dialog opened', pct: 100 }
|
||||
setTimeout(() => { exportState.value = null }, 900)
|
||||
} catch (e) {
|
||||
exportState.value = { label: list.label, kind: 'Card Deck', phase: 'error',
|
||||
|
|
|
|||
Loading…
Reference in a new issue