feat: add configurable A4 page margins
This commit is contained in:
parent
594b6b7221
commit
acb20be569
@ -31,6 +31,11 @@ my-pack.zip
|
||||
"accentColor": "#8b5e34",
|
||||
"borderColor": "#d8c7b3",
|
||||
"borderRadius": 14,
|
||||
"pageMarginTop": 12,
|
||||
"pageMarginRight": 12,
|
||||
"pageMarginBottom": 12,
|
||||
"pageMarginLeft": 12,
|
||||
"pagePadding": 18,
|
||||
"pageBackgroundImage": "asset://paper-bg",
|
||||
"borderImage": "asset://page-border"
|
||||
},
|
||||
@ -107,6 +112,12 @@ Use `variant: "logo"` for compact brand marks and `variant: "hero"` for banners/
|
||||
|
||||
For report pagination, blocks avoid splitting across pages by default. Optional block style flags are `keepWithNext`, `pageBreakBefore`, `pageBreakAfter`, and `allowPageBreak`.
|
||||
|
||||
### Page setup
|
||||
|
||||
Report themes can set A4 print margins independently in millimetres with `pageMarginTop`, `pageMarginRight`, `pageMarginBottom`, and `pageMarginLeft`. Each accepts 0–40 mm and defaults to 12 mm.
|
||||
|
||||
`pagePadding` is separate: it controls the internal spacing inside the rendered document/frame in pixels and defaults to 18 px.
|
||||
|
||||
### Styling
|
||||
|
||||
The constrained theme supports colors, border radius, background imagery and report border imagery. Blocks may also include a `style` object with background/text/border colors, border width/radius, padding and alignment. Arbitrary CSS/HTML/JavaScript is intentionally not supported.
|
||||
|
||||
@ -58,7 +58,11 @@ function themeOf(template) {
|
||||
buttonTextColor: safeHex(theme.buttonTextColor, '#ffffff'),
|
||||
borderColor: safeHex(theme.borderColor, '#dbe2ea'),
|
||||
borderRadius: safeNumber(theme.borderRadius, 12, 0, 32),
|
||||
pagePadding: safeNumber(theme.pagePadding, 18, 0, 40),
|
||||
pagePadding: safeNumber(theme.pagePadding, 18, 0, 80),
|
||||
pageMarginTop: safeNumber(theme.pageMarginTop, 12, 0, 40),
|
||||
pageMarginRight: safeNumber(theme.pageMarginRight, 12, 0, 40),
|
||||
pageMarginBottom: safeNumber(theme.pageMarginBottom, 12, 0, 40),
|
||||
pageMarginLeft: safeNumber(theme.pageMarginLeft, 12, 0, 40),
|
||||
fontFamily: safeFont(theme.fontFamily),
|
||||
pageBackgroundImage: safeUrl(theme.pageBackgroundImage, {}),
|
||||
borderImage: safeUrl(theme.borderImage, {})
|
||||
@ -178,7 +182,7 @@ export function renderReportHtml(template, data = {}) {
|
||||
return `<!doctype html>
|
||||
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<style>
|
||||
@page{size:A4;margin:12mm}*{box-sizing:border-box}html,body{min-height:100%}body{font:${15}px/1.45 ${theme.fontFamily},sans-serif;color:${theme.textColor};margin:0;background:${theme.backgroundColor};background-image:${backgroundImage};background-size:cover;background-position:center;background-repeat:repeat-y;-webkit-print-color-adjust:exact;print-color-adjust:exact}.document{min-height:calc(297mm - 24mm);background:${theme.surfaceColor};padding:${theme.pagePadding}px;border:${theme.borderImage === '#' ? 0 : 10}px solid transparent;border-image:${borderImage};border-radius:${theme.borderRadius}px}.block{margin:0 0 10px;break-inside:avoid-page;page-break-inside:avoid}.block:last-child{margin-bottom:0}.image-logo{margin-bottom:6px}.image-hero{margin-top:2px;margin-bottom:10px}h2{font-size:23px;line-height:1.2;margin:0;color:inherit}p{margin:0}.kpi{border:1px solid ${theme.borderColor};border-radius:${theme.borderRadius}px;padding:12px 14px;display:flex;justify-content:space-between;gap:16px;background:${theme.surfaceColor}}.kpi span{color:${theme.mutedColor}}.kpi strong{font-size:18px}.button{display:inline-block;background:${theme.accentColor};color:${theme.buttonTextColor};text-decoration:none;padding:10px 16px;border-radius:${theme.borderRadius}px}.image{height:auto;vertical-align:middle}hr{border:0;border-top:1px solid ${theme.borderColor};margin:10px 0}.table-wrap{overflow:hidden;border:1px solid ${theme.borderColor};border-radius:${theme.borderRadius}px;break-inside:avoid-page;page-break-inside:avoid}table{border-collapse:collapse;width:100%;font-size:13px}thead{display:table-header-group}tr{break-inside:avoid-page;page-break-inside:avoid}th,td{padding:8px 10px;text-align:left;border-bottom:1px solid ${theme.borderColor}}th{background:${theme.backgroundColor}}
|
||||
@page{size:A4;margin:${theme.pageMarginTop}mm ${theme.pageMarginRight}mm ${theme.pageMarginBottom}mm ${theme.pageMarginLeft}mm}*{box-sizing:border-box}html,body{min-height:100%}body{font:${15}px/1.45 ${theme.fontFamily},sans-serif;color:${theme.textColor};margin:0;background:${theme.backgroundColor};background-image:${backgroundImage};background-size:cover;background-position:center;background-repeat:repeat-y;-webkit-print-color-adjust:exact;print-color-adjust:exact}.document{min-height:calc(297mm - ${theme.pageMarginTop + theme.pageMarginBottom}mm);background:${theme.surfaceColor};padding:${theme.pagePadding}px;border:${theme.borderImage === '#' ? 0 : 10}px solid transparent;border-image:${borderImage};border-radius:${theme.borderRadius}px}.block{margin:0 0 10px;break-inside:avoid-page;page-break-inside:avoid}.block:last-child{margin-bottom:0}.image-logo{margin-bottom:6px}.image-hero{margin-top:2px;margin-bottom:10px}h2{font-size:23px;line-height:1.2;margin:0;color:inherit}p{margin:0}.kpi{border:1px solid ${theme.borderColor};border-radius:${theme.borderRadius}px;padding:12px 14px;display:flex;justify-content:space-between;gap:16px;background:${theme.surfaceColor}}.kpi span{color:${theme.mutedColor}}.kpi strong{font-size:18px}.button{display:inline-block;background:${theme.accentColor};color:${theme.buttonTextColor};text-decoration:none;padding:10px 16px;border-radius:${theme.borderRadius}px}.image{height:auto;vertical-align:middle}hr{border:0;border-top:1px solid ${theme.borderColor};margin:10px 0}.table-wrap{overflow:hidden;border:1px solid ${theme.borderColor};border-radius:${theme.borderRadius}px;break-inside:avoid-page;page-break-inside:avoid}table{border-collapse:collapse;width:100%;font-size:13px}thead{display:table-header-group}tr{break-inside:avoid-page;page-break-inside:avoid}th,td{padding:8px 10px;text-align:left;border-bottom:1px solid ${theme.borderColor}}th{background:${theme.backgroundColor}}
|
||||
</style></head><body><main class="document">${blocks.map(block => blockHtml(block, data, theme)).join('\n')}</main></body></html>`
|
||||
}
|
||||
|
||||
|
||||
@ -64,3 +64,19 @@ test('report blocks avoid page breaks by default and support explicit page contr
|
||||
assert.match(html, /break-after:avoid-page/)
|
||||
assert.match(html, /page-break-inside:avoid/)
|
||||
})
|
||||
|
||||
test('report renderer applies independent page margins in millimetres', () => {
|
||||
const html = renderReportHtml({
|
||||
theme: {
|
||||
pageMarginTop: 8,
|
||||
pageMarginRight: 10,
|
||||
pageMarginBottom: 14,
|
||||
pageMarginLeft: 16,
|
||||
pagePadding: 22
|
||||
},
|
||||
blocks: [{ type: 'text', props: { text: 'Margins' } }]
|
||||
}, {})
|
||||
assert.match(html, /margin:8mm 10mm 14mm 16mm/)
|
||||
assert.match(html, /min-height:calc\(297mm - 22mm\)/)
|
||||
assert.match(html, /padding:22px/)
|
||||
})
|
||||
|
||||
@ -230,6 +230,11 @@ export const templatePackSpec = {
|
||||
accentColor: '#8b5e34',
|
||||
borderColor: '#d8c7b3',
|
||||
borderRadius: 14,
|
||||
pageMarginTop: 12,
|
||||
pageMarginRight: 12,
|
||||
pageMarginBottom: 12,
|
||||
pageMarginLeft: 12,
|
||||
pagePadding: 18,
|
||||
pageBackgroundImage: 'asset://paper-bg',
|
||||
borderImage: 'asset://page-border'
|
||||
},
|
||||
|
||||
63
src/App.tsx
63
src/App.tsx
@ -203,6 +203,18 @@ export function App({ user }: Props) {
|
||||
}))
|
||||
}
|
||||
|
||||
function updateTheme(patch: Record<string, unknown>) {
|
||||
setTemplateDoc(current => ({
|
||||
...current,
|
||||
theme: { ...(current.theme || {}), ...patch }
|
||||
}))
|
||||
}
|
||||
|
||||
function themeNumber(key: string, fallback: number) {
|
||||
const value = Number(templateDoc.theme?.[key])
|
||||
return Number.isFinite(value) ? value : fallback
|
||||
}
|
||||
|
||||
function moveBlock(id: string, direction: -1 | 1) {
|
||||
setTemplateDoc(current => {
|
||||
const blocks = [...current.blocks]
|
||||
@ -513,6 +525,57 @@ export function App({ user }: Props) {
|
||||
</div>
|
||||
<button className="button" onClick={() => void refreshPreview()}>↻ Refresh</button>
|
||||
</div>
|
||||
{active?.type === 'report' && (
|
||||
<section className="page-setup-card">
|
||||
<div className="page-setup-heading">
|
||||
<div>
|
||||
<span className="eyebrow">PAGE SETUP</span>
|
||||
<h3>A4 margins</h3>
|
||||
</div>
|
||||
<span>mm</span>
|
||||
</div>
|
||||
<div className="page-margin-grid">
|
||||
{[
|
||||
['Top', 'pageMarginTop', 12],
|
||||
['Right', 'pageMarginRight', 12],
|
||||
['Bottom', 'pageMarginBottom', 12],
|
||||
['Left', 'pageMarginLeft', 12]
|
||||
].map(([label, key, fallback]) => (
|
||||
<label key={String(key)}>
|
||||
{label}
|
||||
<input
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
min="0"
|
||||
max="40"
|
||||
step="1"
|
||||
value={themeNumber(String(key), Number(fallback))}
|
||||
onChange={event => updateTheme({ [String(key)]: Number(event.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<label className="content-padding-control">
|
||||
Content padding <span>px</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="80"
|
||||
step="1"
|
||||
value={themeNumber('pagePadding', 18)}
|
||||
onChange={event => updateTheme({ pagePadding: Number(event.target.value) })}
|
||||
/>
|
||||
<strong>{themeNumber('pagePadding', 18)} px</strong>
|
||||
</label>
|
||||
<button className="text-button page-reset" onClick={() => updateTheme({
|
||||
pageMarginTop: 12,
|
||||
pageMarginRight: 12,
|
||||
pageMarginBottom: 12,
|
||||
pageMarginLeft: 12,
|
||||
pagePadding: 18
|
||||
})}>Reset page setup</button>
|
||||
</section>
|
||||
)}
|
||||
<div className="segmented preview-segment">
|
||||
<button className={previewDevice === 'mobile' ? 'active' : ''} onClick={() => setPreviewDevice('mobile')}>Phone</button>
|
||||
<button className={previewDevice === 'desktop' ? 'active' : ''} onClick={() => setPreviewDevice('desktop')}>Desktop</button>
|
||||
|
||||
@ -709,3 +709,82 @@ body{
|
||||
margin-left:auto;
|
||||
}
|
||||
}
|
||||
|
||||
.page-setup-card{
|
||||
margin:0 0 14px;
|
||||
padding:14px;
|
||||
border:1px solid var(--line);
|
||||
border-radius:16px;
|
||||
background:white;
|
||||
box-shadow:0 3px 12px rgba(15,23,42,.025);
|
||||
}
|
||||
.page-setup-heading{
|
||||
display:flex;
|
||||
align-items:flex-end;
|
||||
justify-content:space-between;
|
||||
gap:12px;
|
||||
margin-bottom:12px;
|
||||
}
|
||||
.page-setup-heading h3{
|
||||
margin:3px 0 0;
|
||||
font-size:15px;
|
||||
}
|
||||
.page-setup-heading>span{
|
||||
color:#8994a4;
|
||||
font-size:10px;
|
||||
font-weight:800;
|
||||
}
|
||||
.page-margin-grid{
|
||||
display:grid;
|
||||
grid-template-columns:repeat(4,minmax(0,1fr));
|
||||
gap:7px;
|
||||
}
|
||||
.page-margin-grid label{
|
||||
display:grid;
|
||||
gap:5px;
|
||||
min-width:0;
|
||||
color:#7b8797;
|
||||
font-size:9px;
|
||||
font-weight:800;
|
||||
}
|
||||
.page-margin-grid input{
|
||||
width:100%;
|
||||
min-width:0;
|
||||
border:1px solid #d6dde6;
|
||||
border-radius:9px;
|
||||
padding:9px 6px;
|
||||
text-align:center;
|
||||
font-size:13px;
|
||||
font-weight:800;
|
||||
color:#263449;
|
||||
background:#f9fafb;
|
||||
}
|
||||
.content-padding-control{
|
||||
display:grid;
|
||||
grid-template-columns:auto auto 1fr auto;
|
||||
align-items:center;
|
||||
gap:7px;
|
||||
margin-top:12px;
|
||||
color:#59677a;
|
||||
font-size:10px;
|
||||
font-weight:800;
|
||||
}
|
||||
.content-padding-control>span{
|
||||
color:#9aa4b2;
|
||||
font-size:9px;
|
||||
}
|
||||
.content-padding-control input{
|
||||
width:100%;
|
||||
min-width:0;
|
||||
}
|
||||
.content-padding-control strong{
|
||||
min-width:38px;
|
||||
text-align:right;
|
||||
color:#263449;
|
||||
font-size:10px;
|
||||
}
|
||||
.page-reset{
|
||||
margin-top:10px;
|
||||
padding:0;
|
||||
font-size:10px;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user