From f544ca6334324a2f4cf72a3efca2c4440a7591f5 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Wed, 30 Sep 2026 07:00:20 +0000 Subject: [PATCH] Print on phones, everywhere: the map's poster dialog, research-map, the gospel tree, the planner and the wall Every other caller of the in-place print moves onto the phone path the poster wall already uses: printImageBlob (the map's poster/download dialog and research-map) prints the page itself on a phone, and the gospel tree, the prayer-walk planner and the wall of people groups call printDocument. A caller without its own progress dialog gets a shared Print bar when the tap has expired, labelled through the caller's own translator. Desktop keeps the in-place frame unchanged. Source only; the bundles are rebuilt when it lands. --- library/components/poster/PosterDialog.vue | 3 +- library/composables/printInPlace.js | 57 +++++++++++++++++-- .../doxa-research-map/research-map.vue | 1 + .../gospel-internet/src/components/GiTree.vue | 7 ++- .../prayer-walk-planner.vue | 10 ++-- .../src/components/PeopleGroupsWall.vue | 9 ++- 6 files changed, 74 insertions(+), 13 deletions(-) diff --git a/library/components/poster/PosterDialog.vue b/library/components/poster/PosterDialog.vue index b4b8d63a8..730ad0d49 100644 --- a/library/components/poster/PosterDialog.vue +++ b/library/components/poster/PosterDialog.vue @@ -266,7 +266,8 @@ async function onRender() { if (props.action === 'download') _download(blob, `${name}.${ext}`); else if (ext === 'png') { const s = getPosterSize(sizeId.value); - const opened = printImageBlob(blob, { widthIn: s?.inches?.w || 8.5, heightIn: s?.inches?.h || 11, title: titleText.value || 'poster', alt: titleText.value || '' }); + const opened = printImageBlob(blob, { labels: { print: T('wall.hud.printNow', 'Print'), close: T('buttons.close', 'Close') }, + widthIn: s?.inches?.w || 8.5, heightIn: s?.inches?.h || 11, title: titleText.value || 'poster', alt: titleText.value || '' }); if (!opened) _download(blob, `${name}.${ext}`); // the host blocked the print frame: a file beats nothing } else { const url = URL.createObjectURL(blob); diff --git a/library/composables/printInPlace.js b/library/composables/printInPlace.js index f368dfda9..2db2d457d 100644 --- a/library/composables/printInPlace.js +++ b/library/composables/printInPlace.js @@ -24,6 +24,7 @@ * 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') @@ -70,18 +71,21 @@ export function openPrintFrame(bodyHtml, { autoprint = false } = {}) { * A PHONE PRINT NEEDS A RECENT TAP. When preparing the document took long enough for the tap's activation to * expire (navigator.userActivation.isActive is false, e.g. after rasterising a poster), onNeedsTap(printNow, * cancel) hands the caller a printNow to call from a FRESH tap (a "Print" button) instead of a print() the browser - * may ignore, and a cancel that removes the document if the reader walks away. Without onNeedsTap it prints anyway. + * may ignore, and a cancel that removes the document if the reader walks away. A caller without its own progress UI + * passes none and gets the shared Print bar (showTapToPrint). * * @returns Promise<'frame' | 'page' | 'needs-tap' | 'blocked'> */ -export async function printDocument(html, { onNeedsTap = null, imageWaitMs = 20000 } = {}) { +export async function printDocument(html, { onNeedsTap = null, imageWaitMs = 20000, labels = null } = {}) { if (!isPhonePrint()) return openPrintFrame(html, { autoprint: true }) ? 'frame' : 'blocked' const job = mountPrintRoot(html) if (!job) return 'blocked' await job.imagesLoaded(imageWaitMs) const act = typeof navigator !== 'undefined' ? navigator.userActivation : null - if (act && act.isActive === false && typeof onNeedsTap === 'function') { - onNeedsTap(() => { if (job.live()) window.print() }, job.drop) + if (act && act.isActive === false) { + const printNow = () => { if (job.live()) window.print() } + if (typeof onNeedsTap === 'function') onNeedsTap(printNow, job.drop) + else showTapToPrint(printNow, job.drop, labels || {}) return 'needs-tap' } window.print() @@ -99,6 +103,39 @@ export function isPhonePrint() { return /Android|iPhone|iPad|iPod/i.test(ua) || (/Macintosh/.test(ua) && (navigator.maxTouchPoints || 0) > 1) } +/** + * ★ THE SHARED PRINT BAR, for a caller with no progress UI of its own (the map's poster dialog, research-map, the + * gospel tree, the prayer-walk planner, the wall of people groups). A small bar fixed at the bottom of the page with + * one Print button (44 px) and a close, labelled by the CALLER (labels: { print, close }, from its own translator: + * this file stays free of the i18n module, because gates load it in plain node and as a blob module); it sits outside + * the print document, so it never prints. The poster wall shows the same button inside its own HUD instead (onNeedsTap). + */ +export const TAP_ID = 'dxm-print-tap' +function showTapToPrint(printNow, cancel, labels = {}) { + if (typeof document === 'undefined' || !document.body) { printNow(); return } + const old = document.getElementById(TAP_ID); if (old) old.remove() + const bar = document.createElement('div') + bar.id = TAP_ID + bar.setAttribute('role', 'dialog') + bar.setAttribute('aria-live', 'polite') + bar.style.cssText = 'position:fixed;left:50%;bottom:max(16px,env(safe-area-inset-bottom));transform:translateX(-50%);' + + 'z-index:2147483647;display:flex;gap:8px;align-items:center;padding:8px;border-radius:14px;background:#fff;' + + 'box-shadow:0 6px 24px rgba(0,0,0,.25);font:16px/1.2 system-ui,sans-serif' + const go = document.createElement('button') + go.type = 'button' + go.textContent = labels.print || 'Print' + go.style.cssText = 'min-height:44px;min-width:96px;padding:0 18px;border:0;border-radius:10px;background:#3b463d;color:#fff;font:inherit;font-weight:600' + const x = document.createElement('button') + x.type = 'button' + x.textContent = '\u00d7' + x.setAttribute('aria-label', labels.close || 'Close') + x.style.cssText = 'min-height:44px;min-width:44px;border:0;border-radius:10px;background:#eef0ec;color:#3b463d;font:inherit;font-size:22px' + go.addEventListener('click', () => { bar.remove(); printNow() }) + x.addEventListener('click', () => { bar.remove(); cancel() }) + bar.append(go, x) + document.body.appendChild(bar) +} + /** The id of the one in-page print document; a new print replaces the previous one. */ export const PRINT_ROOT_ID = 'dxm-print-root' let currentJob = null @@ -182,7 +219,7 @@ export const FRAME_CEILING_MS = 600000 * 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 = '' } = {}) { +export function printImageBlob(blob, { widthIn = 8.5, heightIn = 11, title = '', alt = '', onNeedsTap = null, labels = null } = {}) { if (!blob || typeof URL === 'undefined' || !URL.createObjectURL) return false const url = URL.createObjectURL(blob) const esc = (v) => String(v).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])) @@ -194,6 +231,16 @@ export function printImageBlob(blob, { widthIn = 8.5, heightIn = 11, title = '', + 'html,body{margin:0;padding:0;background:#fff}' + 'img{display:block;width:100%;height:100%;object-fit:contain}' + `${esc(alt || title)}` + // A PHONE PRINTS THE PAGE (printDocument). The object URL lives until the page has printed, or the ceiling. + if (isPhonePrint()) { + let clock = null + const onAfter = () => setTimeout(release, 0) + const release = () => { clearTimeout(clock); window.removeEventListener('afterprint', onAfter); try { URL.revokeObjectURL(url) } catch { /* gone */ } } + window.addEventListener('afterprint', onAfter) + clock = setTimeout(release, FRAME_CEILING_MS) + printDocument(html, { onNeedsTap, labels }).then((how) => { if (how === 'blocked') release() }, release) + return true + } const win = openPrintFrame(html, { autoprint: true }) if (!win) { URL.revokeObjectURL(url); return false } let ceiling = null diff --git a/plugin-designs/doxa-research-map/research-map.vue b/plugin-designs/doxa-research-map/research-map.vue index 679f97278..dd76e6f21 100644 --- a/plugin-designs/doxa-research-map/research-map.vue +++ b/plugin-designs/doxa-research-map/research-map.vue @@ -1466,6 +1466,7 @@ async function openPoster() { // The same in-place frame the earlier ruling settled on: no pop-up, no blocker, and it // works when an agent triggers it without a user gesture. const printed = printImageBlob(blob, { + labels: { print: T('wall.hud.printNow', 'Print'), close: T('buttons.close', 'Close') }, widthIn: spec.widthIn ?? 8.5, heightIn: spec.heightIn ?? 11, title, diff --git a/plugin-designs/gospel-internet/src/components/GiTree.vue b/plugin-designs/gospel-internet/src/components/GiTree.vue index 617bb1f0f..62ec89f44 100644 --- a/plugin-designs/gospel-internet/src/components/GiTree.vue +++ b/plugin-designs/gospel-internet/src/components/GiTree.vue @@ -38,7 +38,8 @@ import { useGiT } from '../useGiT.js' import { createWhiteboardMap, reducedMotion } from '@map/maps/whiteboard-map.js' import { treeScope, TREE_COLOR } from '../model.js' import { buildTreePoster, posterPrintHtml } from '../treePoster.js' -import { openPrintFrame } from '@map/composables/printInPlace.js' +import { printDocument } from '@map/composables/printInPlace.js' +import { tStatic } from '@map/i18n/index.js' import { listLabel } from '@map/lists/peopleGroupList.js' const T = useGiT() @@ -310,7 +311,9 @@ function makePoster() { } function printPoster() { const p = makePoster() - if (p) openPrintFrame(posterPrintHtml(p.svg, p.size, T('gospelInternet.tree.posterTitle', 'Gospel access, language family by language family')), { autoprint: true }) + // printDocument: the in-place frame on desktop, the page itself on a phone (printInPlace.js says why). + if (p) printDocument(posterPrintHtml(p.svg, p.size, T('gospelInternet.tree.posterTitle', 'Gospel access, language family by language family')), + { labels: { print: tStatic('wall.hud.printNow') || 'Print', close: tStatic('buttons.close') || 'Close' } }) } // ★ NO DOWNLOAD BUTTON HERE. This used to write gospel-language-tree-in.svg to disk beside the // Print button. A poster action opens the PRINT DIALOG and never downloads a file, and there was no diff --git a/plugin-designs/prayer-walk-planner/prayer-walk-planner.vue b/plugin-designs/prayer-walk-planner/prayer-walk-planner.vue index daa591493..b9f8607fd 100644 --- a/plugin-designs/prayer-walk-planner/prayer-walk-planner.vue +++ b/plugin-designs/prayer-walk-planner/prayer-walk-planner.vue @@ -51,7 +51,8 @@ import { labelFont } from '@map/maps/label-font.js' import { ref, shallowRef, reactive, computed, onMounted, onBeforeUnmount, nextTick, inject, watch } from 'vue' import { useMapInstance } from '@map/composables/useMapInstance.js' import { useShadowStyles } from '@map/composables/useShadowStyles.js' -import { openPrintFrame } from '@map/composables/printInPlace.js' +import { printDocument } from '@map/composables/printInPlace.js' +import { tStatic } from '@map/i18n/index.js' import ShareButton from '@map/components/map-controls/ShareButton.vue' import FullscreenButton from '@map/components/map-controls/FullscreenButton.vue' import HelpButton from '@map/components/map-controls/HelpButton.vue' @@ -500,7 +501,8 @@ async function printInternal(ids) { }).join('') const title = targets.length === 1 ? tr('docTitleOne') : tr('docTitleMany', { n: targets.length }) - const w = openPrintFrame( + // printDocument: the in-place frame on desktop, the page itself on a phone (printInPlace.js says why). + const how = await printDocument( '' + esc(title) + '' + '' + pages + '', - { autoprint: true } + { labels: { print: tStatic('wall.hud.printNow') || 'Print', close: tStatic('buttons.close') || 'Close' } }, ) - if (!w) { + if (how === 'blocked') { statusMsg.value = tr('printUnavailable') return { opened: false, count: 0 } } diff --git a/plugin-designs/wall-of-people-groups/src/components/PeopleGroupsWall.vue b/plugin-designs/wall-of-people-groups/src/components/PeopleGroupsWall.vue index 4c7fef398..cf74ae4fd 100644 --- a/plugin-designs/wall-of-people-groups/src/components/PeopleGroupsWall.vue +++ b/plugin-designs/wall-of-people-groups/src/components/PeopleGroupsWall.vue @@ -53,7 +53,7 @@