87 lines
5 KiB
JavaScript
87 lines
5 KiB
JavaScript
/**
|
|
* printInPlace.js — SHARED (@map/composables/printInPlace.js).
|
|
*
|
|
* ★ THE ONE print mechanism, graduated VERBATIM from my-upg-100-list/poster-wall.vue
|
|
* openPrintWindow() (commit e957cbe "Print the poster in place, on a portrait sheet") on the
|
|
* the agent's ruling that killed the pop-up path: print must happen IN PLACE — no new browser
|
|
* window, no pop-up blocker, the dialog rises over the page the user is looking at.
|
|
*
|
|
* WHY IT GRADUATES (library high bar: reusable + parameterized, 2+ consumers): the
|
|
* prayer-walk-planner's print still used window.open(), and that is not just the rejected UX --
|
|
* it is AGENT-HOSTILE. window.open() without a user gesture is pop-up-blocked, so
|
|
* PrayerWalkPlanner.print() called from an agent (the agent: "the agent can also create
|
|
* polygons and trigger the print function") silently did nothing. An iframe cannot be blocked
|
|
* and needs no gesture, which is what makes the same call work for a human click AND an agent.
|
|
*
|
|
* Mechanics preserved from the poster-wall original, each for a measured reason:
|
|
* - OFF-SCREEN 1x1, not display:none — a display:none iframe does not lay out in every
|
|
* engine, and a zero-height layout can print a blank first page.
|
|
* - autoprint appends the print bootstrap AFTER the caller's markup so it runs once the
|
|
* document is parsed (matches <img onload> timing).
|
|
* - afterprint + a 60s floor reclaim the frame, so repeated prints never leak nodes.
|
|
*
|
|
* Returns the frame's contentWindow, or null when the host is sandboxed/detached (caller
|
|
* keeps its own fallback).
|
|
*/
|
|
export function openPrintFrame(bodyHtml, { autoprint = false } = {}) {
|
|
const frame = document.createElement('iframe')
|
|
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 }
|
|
doc.open()
|
|
doc.write(bodyHtml)
|
|
if (autoprint) {
|
|
doc.write(
|
|
'<script>window.addEventListener("load",function(){setTimeout(function(){window.focus();window.print()},150)})</' + 'script>')
|
|
}
|
|
doc.close()
|
|
|
|
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
|
|
}
|
|
|
|
/**
|
|
* Print a rendered poster IMAGE, at its real paper size, through the same in-place frame.
|
|
*
|
|
* ★ THE DRIVER'S RULE (2026-09-28): an "Export poster" button opens the PRINT DIALOG with a preview.
|
|
* It never hands over a file. Simple Research was rendering a PNG and calling a download helper, so
|
|
* the reader got doxa-prayer-poster.png in their downloads folder and no preview, no paper size, and
|
|
* no way to print it without opening it somewhere else first. The dialog IS the preview: the sheet the
|
|
* browser shows is the poster, at the size the spec asked for.
|
|
*
|
|
* @page carries the spec's own inches so the preview is not letter-shaped by default — a 8.5x11
|
|
* poster previewed on A4 is a different document from the one that was rendered. margin:0 because the
|
|
* renderer already composed the margins into the image; a second set would scale the poster down.
|
|
*
|
|
* The object URL is revoked on afterprint AND on the frame's 60s reclaim, since afterprint does not
|
|
* fire in every engine when the dialog is dismissed. Revoking early would blank the preview, so the
|
|
* image element is kept alive by the frame itself rather than by a timer here.
|
|
*
|
|
* Returns true when the dialog was opened, false when the host blocked the frame — the caller decides
|
|
* what to do about it, because a silent no-op is exactly the failure this replaced.
|
|
*/
|
|
export function printImageBlob(blob, { widthIn = 8.5, heightIn = 11, title = '', alt = '' } = {}) {
|
|
if (!blob || typeof URL === 'undefined' || !URL.createObjectURL) return false
|
|
const url = URL.createObjectURL(blob)
|
|
const esc = (v) => String(v).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]))
|
|
// The image is sized by the PAGE, not by its pixels: object-fit keeps a poster rendered at any DPI
|
|
// on its own sheet without cropping. The <img> also gates the print — openPrintFrame's bootstrap
|
|
// waits for load, which is what keeps the first page from printing blank.
|
|
const html = `<!doctype html><html><head><meta charset="utf-8"><title>${esc(title)}</title>`
|
|
+ `<style>@page{size:${widthIn}in ${heightIn}in;margin:0}`
|
|
+ 'html,body{margin:0;padding:0;background:#fff}'
|
|
+ 'img{display:block;width:100%;height:100%;object-fit:contain}</style></head>'
|
|
+ `<body><img src="${url}" alt="${esc(alt || title)}"></body></html>`
|
|
const win = openPrintFrame(html, { autoprint: true })
|
|
if (!win) { URL.revokeObjectURL(url); return false }
|
|
const revoke = () => { try { URL.revokeObjectURL(url) } catch { /* already gone */ } }
|
|
try { win.addEventListener('afterprint', () => setTimeout(revoke, 0)) } catch { /* cross-doc guard */ }
|
|
setTimeout(revoke, 60000)
|
|
return true
|
|
}
|