preview: remove screenshot export and restore UI animations
Validate / Validate (push) Successful in 2m52s

This commit is contained in:
KenanZhu committed 2026-10-11 16:59:00 +08:00
1 parent 6733e81356
commit bcf1b87e82
8 files changed
+70 -237

No files matched your search

+69 -169
View File
@@ -29,10 +29,6 @@ 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 */
@@ -129,12 +125,15 @@ section.collapsed .section-chevron{transform:rotate(-90deg)}
/* Collapsible content wrapper */
.section-content{
margin-top:10px;
display:grid;grid-template-rows:1fr;opacity:1;margin-top:10px;
transition:grid-template-rows .3s ease,opacity .25s ease,margin-top .3s ease;
}
.section-content-inner{min-height:0;overflow:hidden}
section.collapsed .section-content{
display:none;
grid-template-rows:0fr;opacity:0;margin-top:0;
}
#sec-params .section-content{max-height:50vh;overflow:auto;overscroll-behavior:contain}
#sec-params .section-content-inner{max-height:50vh;overflow:auto;overscroll-behavior:contain}
section.collapsed .section-content-inner{overflow:hidden}
#panel .card{background:var(--bg);border-radius:var(--rad);padding:10px 12px;margin-bottom:8px}
#panel .card:last-child{margin-bottom:0}
@@ -151,12 +150,12 @@ section.collapsed .section-content{
#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);flex-shrink:0;max-width:100%;overflow:hidden;background:#fff}
#viewport{border:0 solid #1e293b;border-radius:var(--rad);box-shadow:0 8px 32px rgba(0,0,0,.45);flex-shrink:0;max-width:100%;overflow:hidden;background:#fff;display:flex;flex-direction:column;transition:width .3s ease,border-radius .3s ease,border-width .3s ease,box-shadow .3s ease}
#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::before{content:'';display:block;height:0;background:#1e293b;flex-shrink:0;transition:height .3s ease}
#viewport.mobile::before{height:24px}
#preview-frame{width:100%;height:100%;flex:1;min-height:0;border:none;display:block;background:#fff}
#viewport.desktop #preview-frame{border-radius:var(--rad)}
/* Loading */
@@ -171,7 +170,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,#sec-params .section-content{
html,body,#preview-area,#panel-body,#sec-params .section-content-inner{
scrollbar-width:thin;
scrollbar-color:rgba(107,125,153,.25) transparent;
}
@@ -179,30 +178,30 @@ html,body,#preview-area,#panel-body,#sec-params .section-content{
-ms-overflow-style:-ms-autohiding-scrollbar;
}
#preview-area::-webkit-scrollbar,
#sec-params .section-content::-webkit-scrollbar,
#sec-params .section-content-inner::-webkit-scrollbar,
#panel-body::-webkit-scrollbar { width:6px; height:6px }
#preview-area::-webkit-scrollbar-track,
#sec-params .section-content::-webkit-scrollbar-track,
#sec-params .section-content-inner::-webkit-scrollbar-track,
#panel-body::-webkit-scrollbar-track { background:transparent }
#preview-area::-webkit-scrollbar-thumb,
#sec-params .section-content::-webkit-scrollbar-thumb,
#sec-params .section-content-inner::-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,
#sec-params .section-content-inner::-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,
#sec-params .section-content-inner::-webkit-scrollbar-corner,
#panel-body::-webkit-scrollbar-corner { background:transparent }
@media(max-width:700px){
#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}}
@media(prefers-reduced-motion:reduce){#panel,#panel-handle svg,.section-content,.section-chevron,#viewport,#viewport::before{transition:none}}
</style>
</head>
<body>
@@ -237,10 +236,6 @@ html,body,#preview-area,#panel-body,#sec-params .section-content{
<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 &nbsp; &larr;&rarr; tab &nbsp; &uarr;&darr; select &nbsp; p toggle panel</span>
</nav>
@@ -270,15 +265,17 @@ html,body,#preview-area,#panel-body,#sec-params .section-content{
<svg class="section-chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="section-content">
<div class="card">
<div class="label">Type</div>
<div class="value" id="panel-name">-</div>
<div class="section-content-inner">
<div class="card">
<div class="label">Type</div>
<div class="value" id="panel-name">-</div>
</div>
<div class="card">
<div class="label">Description</div>
<div class="value" id="panel-desc" style="font-size:11px;line-height:1.5">-</div>
</div>
<div class="stat" id="panel-stats"></div>
</div>
<div class="card">
<div class="label">Description</div>
<div class="value" id="panel-desc" style="font-size:11px;line-height:1.5">-</div>
</div>
<div class="stat" id="panel-stats"></div>
</div>
</section>
@@ -288,7 +285,9 @@ html,body,#preview-area,#panel-body,#sec-params .section-content{
<svg class="section-chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="section-content">
<table id="panel-params"><tr><td colspan="2" style="color:var(--muted)">-</td></tr></table>
<div class="section-content-inner">
<table id="panel-params"><tr><td colspan="2" style="color:var(--muted)">-</td></tr></table>
</div>
</div>
</section>
@@ -298,14 +297,15 @@ html,body,#preview-area,#panel-body,#sec-params .section-content{
<svg class="section-chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="section-content">
<div class="card" style="font-family:monospace;font-size:10px;color:var(--muted);word-break:break-all" id="panel-path">-</div>
<div class="section-content-inner">
<div class="card" style="font-family:monospace;font-size:10px;color:var(--muted);word-break:break-all" id="panel-path">-</div>
</div>
</div>
</section>
</div>
</aside>
</div>
<script src="lib/html2canvas/html2canvas.min.js"></script>
<script src="rendered.js"></script>
<script>
//
@@ -320,122 +320,6 @@ 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
@@ -470,10 +354,7 @@ function render() {
const loading = document.getElementById('loading');
if(!rendered) return;
// Same-origin DOM access is required for capture; scripts remain disabled.
frame.setAttribute('sandbox', 'allow-same-origin');
previewReady = false;
updateCaptureButton();
frame.setAttribute('sandbox', '');
frame.style.display = 'none';
loading.style.display = 'flex';
@@ -490,8 +371,6 @@ function render() {
loading.style.display = 'none';
frame.style.display = 'block';
frame.onload = null;
previewReady = true;
updateCaptureButton();
}
frame.onload = finish;
frame.srcdoc = content;
@@ -514,7 +393,8 @@ function togglePanel() {
function toggleSection(id) {
const sec = document.getElementById(id);
sec.classList.toggle('collapsed');
const collapsed = sec.classList.toggle('collapsed');
sec.querySelector('.section-content').inert = collapsed;
}
//
@@ -556,11 +436,11 @@ function switchTheme(v) { state.theme=v; render(); }
function switchTemplate(v) { state.template=v; switchLocale(state.locale); }
function switchClient(v) { state.client=v; render(); }
function setViewport(v) {
if (state.viewport === v) return;
state.viewport=v;
document.getElementById('btn-desk').classList.toggle('on',v==='desktop');
document.getElementById('btn-mob').classList.toggle('on',v==='mobile');
document.getElementById('viewport').className=v;
render();
}
//
@@ -602,6 +482,37 @@ function fillSelect(id, entries, selected) {
select.value=selected;
}
// Stable native names also cover browsers with incomplete ICU locale data.
const LOCALE_NAMES = {
'cs-CZ':'čeština (Česko)', 'de-DE':'Deutsch (Deutschland)',
'el-GR':'Ελληνικά (Ελλάδα)', 'en-US':'English (United States)',
'es-ES':'español (España)', 'fa-IR':'فارسی (ایران)',
'fi-FI':'suomi (Suomi)', 'fr-FR':'français (France)',
'ga-IE':'Gaeilge (Éire)', 'hu-HU':'magyar (Magyarország)',
'id-ID':'Indonesia (Indonesia)', 'is-IS':'íslenska (Ísland)',
'it-IT':'italiano (Italia)', 'ja-JP':'日本語 (日本)',
'ko-KR':'한국어 (대한민국)', 'lv-LV':'latviešu (Latvija)',
'nl-NL':'Nederlands (Nederland)', 'pl-PL':'polski (Polska)',
'pt-BR':'português (Brasil)', 'pt-PT':'português (Portugal)',
'ru-RU':'русский (Россия)', 'si-LK':'සිංහල (ශ්‍රී ලංකාව)',
'sk-SK':'slovenčina (Slovensko)',
'sv-SE':'svenska (Sverige)', 'tr-TR':'Türkçe (Türkiye)',
'uk-UA':'українська (Україна)', 'zh-CN':'中文 (简体,中国)',
'zh-TW':'中文 (繁體,台灣)'
};
function localeLabel(locale) {
let name = LOCALE_NAMES[locale];
if (!name) {
try {
if (Intl.DisplayNames.supportedLocalesOf([locale]).length) {
name = new Intl.DisplayNames([locale], {type:'language', fallback:'none'}).of(locale);
}
} catch (_) { /* Unknown languages remain selectable by their locale code. */ }
}
return name && name !== locale ? locale + ' — ' + name : locale;
}
function applyManifest(data) {
if (!data || data.schema !== 1 || !data.languages.length || !data.themes.length) {
throw new Error('Invalid preview manifest; regenerate with go run . preview all');
@@ -612,16 +523,7 @@ function applyManifest(data) {
if (!data.languages.includes(state.locale)) state.locale=data.languages.includes('en-US') ? 'en-US' : data.languages[0];
fillSelect('sel-theme',data.themes.map(t=>[t,t.charAt(0).toUpperCase()+t.slice(1)]),state.theme);
fillSelect('sel-template',Object.entries(REGISTRY).map(([id,t])=>[id,t.name]),state.template);
fillSelect('sel-locale',data.languages.map(locale=>{
let name;
try {
// Show each language in its own language, independent of the browser UI.
if (Intl.DisplayNames.supportedLocalesOf([locale]).length) {
name=new Intl.DisplayNames([locale],{type:'language',fallback:'none'}).of(locale);
}
} catch (_) { /* Older browsers can still select languages by locale code. */ }
return [locale,name && name!==locale ? locale+' — '+name : locale];
}),state.locale);
fillSelect('sel-locale',data.languages.map(locale=>[locale,localeLabel(locale)]),state.locale);
document.querySelectorAll('#sel-locale option').forEach(option=>{
option.lang=option.value;
option.dir='auto';
@@ -650,8 +552,6 @@ 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';