mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-22 10:33:27 +00:00
feat(web): print header + footer for item detail pages (TASK-623) (#154)
Add a rendered header and footer that repeat on every printed page.
Replaces the browser's default print chrome (localhost URL, page
title, date).
Header (top of each page):
{Workspace name} · {Collection icon + name} · {Issue ID}
Footer (bottom of each page):
Printed {date} · {full URL} · Page {n}
Implementation
- Two new `<div>`s rendered inside the item detail page template:
`.print-header` and `.print-footer`. Both carry `aria-hidden` and
are `display: none` on screen, so they never affect the live UI.
- A `@media print` block shows them as `position: fixed` elements at
top: 0 / bottom: 0. In Chromium this causes the browser to repeat
them on every page of the print output. Firefox and Safari render
them only on the first page -- documented as a known limitation
since Chromium is the expected print target.
- `@page { margin: 1.1in 0.6in 0.9in 0.6in }` carves out space for the
header and footer strips so body content doesn't overlap them. This
overrides the default 0.75in margin set in app.css (TASK-621).
- Page number uses `.print-page-num::after { content: counter(page); }`.
`counter(page)` in a pseudo-element evaluates to the current printed
page number in all modern browsers.
- Print date and URL are captured via a `beforeprint` listener so the
values reflect the moment of print, not page-load time. Falls back
to onMount values if `beforeprint` doesn't fire (older browsers).
Users should uncheck "Headers and footers" in the browser print dialog
for the cleanest result -- there's no CSS to suppress the browser's
default print chrome.
Parent: PLAN-620.
This commit is contained in:
@@ -24,6 +24,7 @@
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { starredStore } from '$lib/stores/starred.svelte';
|
||||
import { titleStore } from '$lib/stores/title.svelte';
|
||||
import { workspaceStore } from '$lib/stores/workspace.svelte';
|
||||
|
||||
type RelationshipEntry = {
|
||||
key: string;
|
||||
@@ -118,8 +119,30 @@
|
||||
|
||||
// Sync coordinator — refresh item data on tab resume
|
||||
let unsubscribeSync: (() => void) | null = null;
|
||||
let unsubscribeBeforePrint: (() => void) | null = null;
|
||||
|
||||
// Print header/footer state (PLAN-620 / TASK-623). Initialized on mount
|
||||
// and refreshed on `beforeprint` so the printed page reflects the
|
||||
// actual moment of print, not whenever the page last loaded.
|
||||
let printDate = $state('');
|
||||
let printUrl = $state('');
|
||||
|
||||
function refreshPrintMeta() {
|
||||
if (typeof window === 'undefined') return;
|
||||
printDate = new Date().toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
printUrl = window.location.href;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
refreshPrintMeta();
|
||||
const handler = () => refreshPrintMeta();
|
||||
window.addEventListener('beforeprint', handler);
|
||||
unsubscribeBeforePrint = () => window.removeEventListener('beforeprint', handler);
|
||||
|
||||
unsubscribeSync = syncService.onSync(async (result) => {
|
||||
if (!wsSlug || !itemSlug || !item) return;
|
||||
// Don't refresh if the user is actively editing
|
||||
@@ -163,6 +186,7 @@
|
||||
|
||||
onDestroy(() => {
|
||||
unsubscribeSync?.();
|
||||
unsubscribeBeforePrint?.();
|
||||
editorStore.resetForDoc();
|
||||
collectionStore.setActiveItem(null);
|
||||
});
|
||||
@@ -621,6 +645,22 @@
|
||||
{:else if error}
|
||||
<div class="center-message">{error}</div>
|
||||
{:else if item && collection}
|
||||
<!-- Print-only header/footer (PLAN-620 / TASK-623). Hidden on screen,
|
||||
fixed-positioned in print so they repeat on every page. Page number
|
||||
injected via CSS `counter(page)` in ::after. -->
|
||||
<div class="print-header" aria-hidden="true">
|
||||
<span class="print-header-ws">{workspaceStore.current?.name ?? ''}</span>
|
||||
<span class="print-sep">·</span>
|
||||
<span class="print-header-coll">{collection.icon ?? ''} {collection.name}</span>
|
||||
<span class="print-sep">·</span>
|
||||
<span class="print-header-ref">{formatItemRef(item) || item.title}</span>
|
||||
</div>
|
||||
<div class="print-footer" aria-hidden="true">
|
||||
<span class="print-footer-date">Printed {printDate}</span>
|
||||
<span class="print-footer-url">{printUrl}</span>
|
||||
<span class="print-footer-page">Page <span class="print-page-num"></span></span>
|
||||
</div>
|
||||
|
||||
<div class="item-page">
|
||||
<!-- Breadcrumb -->
|
||||
<div class="sticky-header">
|
||||
@@ -1724,6 +1764,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Print header + footer (PLAN-620 / TASK-623) — hidden on screen,
|
||||
fixed-positioned and visible only in print. Shown in @media print
|
||||
block below. */
|
||||
.print-header,
|
||||
.print-footer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* =============================================================
|
||||
PRINT — item detail page (PLAN-620 / TASK-622).
|
||||
Layout-level chrome is hidden by app.css's base @media print
|
||||
@@ -1970,5 +2018,85 @@
|
||||
color: #000 !important;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------
|
||||
TASK-623 — rendered header / footer on every printed page.
|
||||
@page margins carve out top + bottom space; fixed-positioned
|
||||
divs sit in those strips and repeat on every page (well
|
||||
supported in Chromium; Firefox and Safari may render them
|
||||
only on the first page). Page number is injected by CSS via
|
||||
`counter(page)` on a pseudo-element.
|
||||
------------------------------------------------------------- */
|
||||
@page {
|
||||
margin: 1.1in 0.6in 0.9in 0.6in;
|
||||
}
|
||||
|
||||
.print-header,
|
||||
.print-footer {
|
||||
display: flex;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
box-sizing: border-box;
|
||||
padding: 10pt 0.6in;
|
||||
background: #fff;
|
||||
color: #555;
|
||||
font-size: 9pt;
|
||||
line-height: 1.35;
|
||||
gap: 6pt;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.print-header {
|
||||
top: 0;
|
||||
border-bottom: 1px solid #ccc;
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.print-header-ws {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
.print-header-coll {
|
||||
color: #555;
|
||||
}
|
||||
.print-header-ref {
|
||||
margin-left: auto;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.print-sep {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.print-footer {
|
||||
bottom: 0;
|
||||
border-top: 1px solid #ccc;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.print-footer-date,
|
||||
.print-footer-page {
|
||||
white-space: nowrap;
|
||||
color: #555;
|
||||
}
|
||||
.print-footer-url {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 0 8pt;
|
||||
color: #666;
|
||||
font-size: 8pt;
|
||||
word-break: break-all;
|
||||
/* Clamp to a single line so long URLs don't push the footer
|
||||
height, which would otherwise overlap the body content. */
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.print-page-num::after {
|
||||
content: counter(page);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user