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.
This commit is contained in:
parent
aa500452d5
commit
f544ca6334
6 changed files with 74 additions and 13 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}</style></head>'
|
||||
+ `<body><img src="${url}" alt="${esc(alt || title)}"></body></html>`
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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-<size>in.svg to disk beside the
|
||||
// Print button. A poster action opens the PRINT DIALOG and never downloads a file, and there was no
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
'<!DOCTYPE html><html lang="' + uiLocale.value + '" dir="' + uiDir.value + '"><head><meta charset="utf-8"><title>' + esc(title) + '</title>' +
|
||||
'<style>' +
|
||||
// letter portrait explicitly -- size:auto lets the browser pick landscape (the same
|
||||
|
|
@ -523,9 +525,9 @@ async function printInternal(ids) {
|
|||
'.points{font-size:9.5px;line-height:1.5;color:#6d766e;margin:4px 0 0}' +
|
||||
'.footer{margin-top:4px;font-size:9px;color:#6d766e;border-top:1px solid #d1d4cd;padding-top:4px}' +
|
||||
'</style></head><body>' + pages + '</body></html>',
|
||||
{ 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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@
|
|||
<script>
|
||||
import { WallCanvas, groupLink } from '../wallCanvas.js'
|
||||
import { buildBlessPosterHtml, screenPoster, PRESETS, DEFAULT_PRESET } from '../blessPoster.js'
|
||||
import { openPrintFrame } from '@map/composables/printInPlace.js'
|
||||
import { openPrintFrame, printDocument, isPhonePrint } from '@map/composables/printInPlace.js'
|
||||
import { watchPrintFrame } from '../printWatch.js'
|
||||
import { useHostedHelp } from '@map/composables/useHostedHelp.js'
|
||||
// Wall of People Groups: a zoomable canvas wall (wallCanvas.js), Bless or Social cards, and the poster print.
|
||||
|
|
@ -167,6 +167,13 @@ export default {
|
|||
if (!groups.length) return;
|
||||
this.printing = true;
|
||||
const texts = this.posterTexts(groups.length);
|
||||
// A PHONE PRINTS THE PAGE (printInPlace.printDocument): there is no frame to watch, and printDocument
|
||||
// itself waits for the photos before it prints.
|
||||
if (isPhonePrint()) {
|
||||
printDocument(buildBlessPosterHtml(groups, this.posterPreset, texts), { labels: { print: this.$t('wall.printPoster') } })
|
||||
.finally(() => setTimeout(() => { this.printing = false; this.printProgress = null; }, 300));
|
||||
return;
|
||||
}
|
||||
const win = openPrintFrame(buildBlessPosterHtml(groups, this.posterPreset, texts), { autoprint: true });
|
||||
// Busy until the photos have loaded and the print dialog is up (the frame prints on its own 'load',
|
||||
// which waits for every photo). Never silently incomplete: the button counts the photos as they
|
||||
|
|
|
|||
Loading…
Reference in a new issue