preview: improve sidebar layout and screenshot export
Validate / Validate (push) Successful in 3m29s
Validate / Validate (push) Successful in 3m29s
This commit is contained in:
1 parent
e51e2e9996
commit
6733e81356
8 files changed
+259
-118
No files matched your search
+174
-63
@@ -29,6 +29,11 @@ body{font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color:v
|
||||
#topbar .btn-group button+button{border-left:none}
|
||||
#topbar .btn-group button.on{background:var(--accent);color:#000;border-color:var(--accent);font-weight:600}
|
||||
#topbar .hint{font-size:10px;color:var(--muted);opacity:.5;white-space:nowrap}
|
||||
#btn-screenshot{display:inline-flex;align-items:center;gap:6px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:var(--rad);padding:6px 12px;font-size:12px;cursor:pointer}
|
||||
#btn-screenshot svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:1.8;flex-shrink:0}
|
||||
#btn-screenshot:hover:not(:disabled){border-color:var(--accent)}
|
||||
#btn-screenshot:disabled{opacity:.45;cursor:wait}
|
||||
button:focus-visible{outline:2px solid var(--accent);outline-offset:3px}
|
||||
|
||||
/* Topbar responsive — progressively compact as width shrinks */
|
||||
@media(max-width:900px){#topbar .hint{display:none}}
|
||||
@@ -49,32 +54,29 @@ body{font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color:v
|
||||
|
||||
/* Main layout */
|
||||
#main{flex:1;display:flex;position:relative;min-height:0;overflow:hidden}
|
||||
#preview-area{flex:1;display:flex;align-items:flex-start;justify-content:center;padding:24px;overflow:auto;background:linear-gradient(135deg,#0b1120,#131c30,#0d1525);transition:padding-right .3s cubic-bezier(.4,0,.2,1)}
|
||||
#preview-area{flex:1;min-width:0;min-height:0;display:flex;align-items:flex-start;justify-content:center;padding:24px;overflow:auto;background:linear-gradient(135deg,#0b1120,#131c30,#0d1525)}
|
||||
|
||||
/* --- Floating panel --- */
|
||||
/* Panel and preview share the available width; the handle occupies its own gutter. */
|
||||
#panel{
|
||||
position:absolute;right:12px;top:12px;bottom:12px;
|
||||
width:var(--panel-w);
|
||||
--panel-w:min(440px,40vw);
|
||||
position:relative;flex:0 0 var(--panel-w);width:var(--panel-w);min-width:0;
|
||||
margin:12px 12px 12px 40px;
|
||||
background:var(--surface);
|
||||
border:1px solid var(--border);
|
||||
border-radius:10px;
|
||||
box-shadow:-4px 4px 32px rgba(0,0,0,.5),0 0 0 1px rgba(255,255,255,.03) inset;
|
||||
z-index:20;
|
||||
display:flex;flex-direction:column;
|
||||
overflow:hidden;
|
||||
transition:transform .35s cubic-bezier(.4,0,.2,1),opacity .35s ease,box-shadow .35s ease;
|
||||
transition:flex-basis .3s ease,width .3s ease;
|
||||
}
|
||||
#panel.collapsed{
|
||||
transform:translateX(calc(var(--panel-w) - 36px));
|
||||
box-shadow:-2px 2px 16px rgba(0,0,0,.35),0 0 0 1px rgba(255,255,255,.03) inset;
|
||||
opacity:.92;
|
||||
flex-basis:0;width:0;border-width:0;background:transparent;box-shadow:none;
|
||||
}
|
||||
#panel.collapsed:hover{opacity:1}
|
||||
#panel.collapsed #panel-body{visibility:hidden}
|
||||
|
||||
/* Panel drag-handle / collapse tab */
|
||||
#panel-handle{
|
||||
position:absolute;left:0;top:50%;transform:translateY(-50%);
|
||||
width:28px;height:64px;
|
||||
position:absolute;left:-40px;top:50%;transform:translateY(-50%);
|
||||
width:40px;height:80px;
|
||||
background:var(--surface);
|
||||
border:1px solid var(--border);
|
||||
border-right:none;
|
||||
@@ -82,17 +84,11 @@ body{font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color:v
|
||||
cursor:pointer;
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
transition:background .15s;
|
||||
z-index:21;
|
||||
/* sit just outside the panel's left edge */
|
||||
margin-left:-1px;
|
||||
}
|
||||
#panel.collapsed #panel-handle{
|
||||
/* when collapsed, handle moves with panel, peeking from right edge */
|
||||
left:1px;
|
||||
color:var(--muted);
|
||||
}
|
||||
#panel-handle:hover{background:var(--bg)}
|
||||
#panel-handle svg{
|
||||
width:14px;height:14px;
|
||||
width:20px;height:20px;
|
||||
stroke:var(--muted);
|
||||
fill:none;stroke-width:2;
|
||||
transition:transform .35s cubic-bezier(.4,0,.2,1),stroke .15s;
|
||||
@@ -101,22 +97,7 @@ body{font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color:v
|
||||
#panel.collapsed #panel-handle svg{transform:rotate(180deg)}
|
||||
|
||||
/* Panel inner scroll */
|
||||
#panel-body{flex:1;overflow-y:auto;overflow-x:hidden}
|
||||
|
||||
/* Panel close button (top-right) */
|
||||
#panel-close{
|
||||
position:absolute;right:8px;top:8px;
|
||||
width:24px;height:24px;
|
||||
background:none;border:none;
|
||||
cursor:pointer;
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
border-radius:4px;
|
||||
color:var(--muted);
|
||||
font-size:16px;line-height:1;
|
||||
transition:background .15s,color .15s;
|
||||
z-index:2;
|
||||
}
|
||||
#panel-close:hover{background:rgba(255,255,255,.06);color:var(--text)}
|
||||
#panel-body{flex:1;min-height:0;overflow-y:auto;overflow-x:hidden;border-radius:10px}
|
||||
|
||||
/* Section */
|
||||
#panel section{padding:12px 14px;border-bottom:1px solid var(--border)}
|
||||
@@ -148,17 +129,12 @@ section.collapsed .section-chevron{transform:rotate(-90deg)}
|
||||
|
||||
/* Collapsible content wrapper */
|
||||
.section-content{
|
||||
overflow:hidden;
|
||||
transition:max-height .3s ease,opacity .25s ease,margin-top .3s ease;
|
||||
max-height:2000px;
|
||||
opacity:1;
|
||||
margin-top:10px;
|
||||
}
|
||||
section.collapsed .section-content{
|
||||
max-height:0;
|
||||
opacity:0;
|
||||
margin-top:0;
|
||||
display:none;
|
||||
}
|
||||
#sec-params .section-content{max-height:50vh;overflow:auto;overscroll-behavior:contain}
|
||||
|
||||
#panel .card{background:var(--bg);border-radius:var(--rad);padding:10px 12px;margin-bottom:8px}
|
||||
#panel .card:last-child{margin-bottom:0}
|
||||
@@ -166,17 +142,19 @@ section.collapsed .section-content{
|
||||
#panel .card .value{font-size:12px;color:var(--text)}
|
||||
#panel table{width:100%;border-collapse:collapse}
|
||||
#panel table td{padding:4px 6px;font-size:11px;border-bottom:1px solid rgba(255,255,255,.03);vertical-align:top}
|
||||
#panel table td:first-child{font-family:monospace;color:var(--accent);font-size:10px;width:0;white-space:nowrap}
|
||||
#panel table td:last-child{color:var(--muted);text-align:right;font-size:10px;word-break:break-all}
|
||||
#panel table td:first-child{font-family:monospace;color:var(--accent);font-size:10px;overflow-wrap:anywhere}
|
||||
#panel table td:nth-child(2){color:var(--muted);font-size:10px;overflow-wrap:anywhere}
|
||||
#panel table td:last-child{text-align:right;width:1%}
|
||||
#panel .badge{display:inline-block;font-size:9px;padding:1px 6px;border-radius:3px;font-weight:600;letter-spacing:.2px;white-space:nowrap}
|
||||
.badge-req{background:rgba(52,211,153,.12);color:var(--green)}
|
||||
.badge-opt{background:rgba(107,125,153,.1);color:var(--muted)}
|
||||
#panel .stat{font-size:10px;color:var(--muted);margin-top:6px}
|
||||
|
||||
/* Viewport */
|
||||
#viewport{border-radius:var(--rad);box-shadow:0 8px 32px rgba(0,0,0,.45);transition:all .3s;flex-shrink:0;overflow:hidden;background:#fff}
|
||||
#viewport.desktop{width:1386px;height:780px}
|
||||
#viewport{border-radius:var(--rad);box-shadow:0 8px 32px rgba(0,0,0,.45);flex-shrink:0;max-width:100%;overflow:hidden;background:#fff}
|
||||
#viewport.desktop{width:100%;max-width:1386px;height:780px}
|
||||
#viewport.mobile{width:390px;height:780px;border-radius:28px;border:3px solid #1e293b;box-shadow:0 8px 32px rgba(0,0,0,.5),inset 0 0 0 1px #0f172a;display:flex;flex-direction:column}
|
||||
#viewport.mobile #preview-frame{flex:1;min-height:0}
|
||||
#viewport.mobile::before{content:'';display:block;height:24px;background:#1e293b;flex-shrink:0}
|
||||
#preview-frame{width:100%;height:100%;border:none;display:block;background:#fff}
|
||||
#viewport.desktop #preview-frame{border-radius:var(--rad)}
|
||||
@@ -193,7 +171,7 @@ section.collapsed .section-content{
|
||||
.message code{background:rgba(0,0,0,0.12);padding:1px 6px;border-radius:3px}
|
||||
|
||||
/* Scrollbar — neutral gray, consistent across all browsers */
|
||||
html,body,#preview-area,#panel-body{
|
||||
html,body,#preview-area,#panel-body,#sec-params .section-content{
|
||||
scrollbar-width:thin;
|
||||
scrollbar-color:rgba(107,125,153,.25) transparent;
|
||||
}
|
||||
@@ -201,27 +179,30 @@ html,body,#preview-area,#panel-body{
|
||||
-ms-overflow-style:-ms-autohiding-scrollbar;
|
||||
}
|
||||
#preview-area::-webkit-scrollbar,
|
||||
#sec-params .section-content::-webkit-scrollbar,
|
||||
#panel-body::-webkit-scrollbar { width:6px; height:6px }
|
||||
#preview-area::-webkit-scrollbar-track,
|
||||
#sec-params .section-content::-webkit-scrollbar-track,
|
||||
#panel-body::-webkit-scrollbar-track { background:transparent }
|
||||
#preview-area::-webkit-scrollbar-thumb,
|
||||
#sec-params .section-content::-webkit-scrollbar-thumb,
|
||||
#panel-body::-webkit-scrollbar-thumb {
|
||||
background-color:rgba(107,125,153,.25);
|
||||
border-radius:3px;
|
||||
border:1px solid rgba(255,255,255,.06);
|
||||
}
|
||||
#preview-area::-webkit-scrollbar-thumb:hover,
|
||||
#sec-params .section-content::-webkit-scrollbar-thumb:hover,
|
||||
#panel-body::-webkit-scrollbar-thumb:hover { background-color:rgba(107,125,153,.45) }
|
||||
#preview-area::-webkit-scrollbar-corner,
|
||||
#sec-params .section-content::-webkit-scrollbar-corner,
|
||||
#panel-body::-webkit-scrollbar-corner { background:transparent }
|
||||
|
||||
@media(max-width:900px){
|
||||
#panel{--panel-w:380px;right:6px;top:6px;bottom:6px}
|
||||
#panel.collapsed{transform:translateX(calc(var(--panel-w) - 36px))}
|
||||
}
|
||||
@media(max-width:700px){
|
||||
#panel{display:none}
|
||||
#panel{--panel-w:50vw;margin-top:6px;margin-bottom:6px;margin-right:6px}
|
||||
#preview-area{padding:12px}
|
||||
}
|
||||
@media(prefers-reduced-motion:reduce){#panel,#panel-handle svg{transition:none}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -256,6 +237,10 @@ html,body,#preview-area,#panel-body{
|
||||
<button id="btn-desk" class="on" onclick="setViewport('desktop')">Desktop</button>
|
||||
<button id="btn-mob" onclick="setViewport('mobile')">Mobile</button>
|
||||
</div>
|
||||
<button id="btn-screenshot" type="button" onclick="capturePreview()" disabled>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5l1.5-2h5L16 5h4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2z"/><circle cx="12" cy="13" r="4"/></svg>
|
||||
<span id="screenshot-label">Screenshot</span>
|
||||
</button>
|
||||
<span class="hint">keys: d/m ←→ tab ↑↓ select p toggle panel</span>
|
||||
</nav>
|
||||
|
||||
@@ -274,12 +259,9 @@ html,body,#preview-area,#panel-body{
|
||||
|
||||
<aside id="panel">
|
||||
<!-- Collapse handle tab on the left edge -->
|
||||
<div id="panel-handle" title="Toggle panel" onclick="togglePanel()">
|
||||
<svg viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"/></svg>
|
||||
</div>
|
||||
|
||||
<!-- Close button (top-right) -->
|
||||
<button id="panel-close" title="Close panel" onclick="togglePanel()">×</button>
|
||||
<button id="panel-handle" type="button" title="Collapse panel" aria-label="Collapse panel" aria-expanded="true" aria-controls="panel-body" onclick="togglePanel()">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
</button>
|
||||
|
||||
<div id="panel-body">
|
||||
<section id="sec-info">
|
||||
@@ -323,6 +305,7 @@ html,body,#preview-area,#panel-body{
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<script src="lib/html2canvas/html2canvas.min.js"></script>
|
||||
<script src="rendered.js"></script>
|
||||
<script>
|
||||
//
|
||||
@@ -337,6 +320,122 @@ let localeRequest = 0;
|
||||
let renderRequest = 0, renderTimer;
|
||||
const localeLoads = new Map();
|
||||
let devMode = false; // true once SSE connects
|
||||
let previewReady = false;
|
||||
let capturing = false;
|
||||
|
||||
function updateCaptureButton() {
|
||||
document.getElementById('btn-screenshot').disabled = !previewReady || capturing;
|
||||
}
|
||||
|
||||
// Capture only the visible viewport, including the mobile frame when selected.
|
||||
async function capturePreview() {
|
||||
if (!previewReady || capturing) return;
|
||||
const label = document.getElementById('screenshot-label');
|
||||
const selection = { ...state };
|
||||
const request = renderRequest;
|
||||
const frame = document.getElementById('preview-frame');
|
||||
const viewport = document.getElementById('viewport');
|
||||
capturing = true;
|
||||
label.textContent = 'Capturing…';
|
||||
updateCaptureButton();
|
||||
document.getElementById('capture-message')?.remove();
|
||||
try {
|
||||
if (typeof html2canvas !== 'function') throw new Error('Screenshot library unavailable; restore preview/lib/html2canvas.');
|
||||
const doc = frame.contentDocument;
|
||||
if (!doc) throw new Error('Preview document is not accessible.');
|
||||
if (doc.fonts) await doc.fonts.ready;
|
||||
await Promise.all(Array.from(doc.images, image => image.decode().catch(() => {})));
|
||||
if (request !== renderRequest || !previewReady) throw new Error('Preview changed; capture again after it finishes loading.');
|
||||
const width = viewport.offsetWidth;
|
||||
const height = viewport.offsetHeight;
|
||||
const frameWidth = frame.clientWidth;
|
||||
const frameHeight = frame.clientHeight;
|
||||
const scrollX = frame.contentWindow.scrollX;
|
||||
const scrollY = frame.contentWindow.scrollY;
|
||||
const viewportBounds = viewport.getBoundingClientRect();
|
||||
const frameBounds = frame.getBoundingClientRect();
|
||||
const frameLeft = frameBounds.left - viewportBounds.left;
|
||||
const frameTop = frameBounds.top - viewportBounds.top;
|
||||
const scale = Math.min(window.devicePixelRatio || 1, 2);
|
||||
// Render in the mail document's own coordinates. Rendering nested iframes
|
||||
// with the outer document's context can shift centered tables and text.
|
||||
const mailCanvas = await html2canvas(doc.documentElement, {
|
||||
backgroundColor: '#fff',
|
||||
scale,
|
||||
width: frameWidth,
|
||||
height: frameHeight,
|
||||
windowWidth: frameWidth,
|
||||
windowHeight: frameHeight,
|
||||
x: scrollX,
|
||||
y: scrollY,
|
||||
scrollX,
|
||||
scrollY,
|
||||
useCORS: true,
|
||||
logging: false
|
||||
});
|
||||
const canvas = await html2canvas(viewport, {
|
||||
backgroundColor: null,
|
||||
scale,
|
||||
width,
|
||||
height,
|
||||
useCORS: true,
|
||||
logging: false,
|
||||
ignoreElements: element => element.id === 'preview-frame',
|
||||
onclone: clone => {
|
||||
const clonedViewport = clone.getElementById('viewport');
|
||||
clonedViewport.style.width = width + 'px';
|
||||
clonedViewport.style.maxWidth = 'none';
|
||||
clonedViewport.style.height = height + 'px';
|
||||
}
|
||||
});
|
||||
if (request !== renderRequest || !previewReady) throw new Error('Preview changed; capture again after it finishes loading.');
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) throw new Error('Could not create the screenshot canvas.');
|
||||
const style = getComputedStyle(viewport);
|
||||
const border = parseFloat(style.borderLeftWidth) || 0;
|
||||
const radius = Math.max(0, (parseFloat(style.borderTopLeftRadius) || 0) - border);
|
||||
// Clip the composite to the container's inner rounded border, including
|
||||
// the mobile frame. No top bar or sidebar enters the exported image.
|
||||
const left = border, top = border;
|
||||
const right = width - border, bottom = height - border;
|
||||
const rounding = Math.min(radius, (right - left) / 2, (bottom - top) / 2);
|
||||
context.save();
|
||||
context.scale(scale, scale);
|
||||
context.beginPath();
|
||||
context.moveTo(left + rounding, top);
|
||||
context.arcTo(right, top, right, bottom, rounding);
|
||||
context.arcTo(right, bottom, left, bottom, rounding);
|
||||
context.arcTo(left, bottom, left, top, rounding);
|
||||
context.arcTo(left, top, right, top, rounding);
|
||||
context.closePath();
|
||||
context.clip();
|
||||
context.drawImage(mailCanvas, frameLeft, frameTop, frameWidth, frameHeight);
|
||||
context.restore();
|
||||
const blob = await new Promise((resolve, reject) => canvas.toBlob(
|
||||
value => value ? resolve(value) : reject(new Error('Could not encode the screenshot.')),
|
||||
'image/png'
|
||||
));
|
||||
const filename = [selection.theme, selection.template, selection.locale]
|
||||
.map(value => value.replace(/[<>:"/\\|?*\x00-\x1f]/g, '-')).join('_') + '.png';
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
// Give the browser time to start reading the download before releasing it.
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60000);
|
||||
} catch (error) {
|
||||
const message = showMessage('', 'error');
|
||||
message.id = 'capture-message';
|
||||
message.textContent = 'Screenshot failed: ' + String(error.message || error);
|
||||
} finally {
|
||||
capturing = false;
|
||||
label.textContent = 'Screenshot';
|
||||
updateCaptureButton();
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Client transforms
|
||||
@@ -371,7 +470,10 @@ function render() {
|
||||
const loading = document.getElementById('loading');
|
||||
if(!rendered) return;
|
||||
|
||||
frame.setAttribute('sandbox', '');
|
||||
// Same-origin DOM access is required for capture; scripts remain disabled.
|
||||
frame.setAttribute('sandbox', 'allow-same-origin');
|
||||
previewReady = false;
|
||||
updateCaptureButton();
|
||||
|
||||
frame.style.display = 'none';
|
||||
loading.style.display = 'flex';
|
||||
@@ -388,6 +490,8 @@ function render() {
|
||||
loading.style.display = 'none';
|
||||
frame.style.display = 'block';
|
||||
frame.onload = null;
|
||||
previewReady = true;
|
||||
updateCaptureButton();
|
||||
}
|
||||
frame.onload = finish;
|
||||
frame.srcdoc = content;
|
||||
@@ -400,7 +504,12 @@ function render() {
|
||||
//
|
||||
function togglePanel() {
|
||||
const panel = document.getElementById('panel');
|
||||
panel.classList.toggle('collapsed');
|
||||
const collapsed = panel.classList.toggle('collapsed');
|
||||
const handle = document.getElementById('panel-handle');
|
||||
handle.setAttribute('aria-expanded', String(!collapsed));
|
||||
handle.setAttribute('aria-label', collapsed ? 'Expand panel' : 'Collapse panel');
|
||||
handle.title = collapsed ? 'Expand panel' : 'Collapse panel';
|
||||
document.getElementById('panel-body').inert = collapsed;
|
||||
}
|
||||
|
||||
function toggleSection(id) {
|
||||
@@ -461,7 +570,7 @@ document.addEventListener('keydown',e=>{
|
||||
// Viewport shortcuts (no modifiers)
|
||||
if(e.key==='d'&&!e.ctrlKey&&!e.metaKey&&!e.altKey){ setViewport('desktop'); return; }
|
||||
if(e.key==='m'&&!e.ctrlKey&&!e.metaKey&&!e.altKey){ setViewport('mobile'); return; }
|
||||
// 'p' to toggle floating panel
|
||||
// 'p' to toggle the side panel
|
||||
if(e.key==='p'&&!e.ctrlKey&&!e.metaKey&&!e.altKey){ togglePanel(); return; }
|
||||
|
||||
// ← → cycle focus between Theme / Template / Client selects
|
||||
@@ -541,6 +650,8 @@ function loadLocale(lang) {
|
||||
async function switchLocale(lang) {
|
||||
if (!manifest?.languages.includes(lang)) return;
|
||||
state.locale=lang; rendered=null;
|
||||
previewReady=false;
|
||||
updateCaptureButton();
|
||||
const request=++localeRequest;
|
||||
document.getElementById('preview-frame').style.display='none';
|
||||
document.getElementById('loading').style.display='flex';
|
||||
|
||||
Reference in new issue
Block a user