@ammduncan/easel 0.3.2 → 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +5 -0
- package/dist/client/viewer.js +87 -67
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to easel. This project adheres to [Semantic Versioning](https://semver.org/).
|
|
4
4
|
|
|
5
|
+
## 0.3.3 — 2026-05-26
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
- **PNG and PDF export no longer hang when the easel tab isn't the visible foreground tab.** Export rasterised via `html-to-image`'s `toPng`/`toJpeg`, which resolve through the library's internal `createImage()` — and that waits on `requestAnimationFrame`. Chrome freezes rAF in hidden/background tabs, so the rasterize promise never settled: click export, switch back to your terminal, and the button spinner span forever with no error (the viewer had no timeout to recover). Both formats died here because they share the path. The export now stops at `htmlToImage.toSvg()` (no rAF) and rasterises onto a canvas with a plain `Image`, whose `onload` fires even in hidden tabs. Quality is unchanged — the SVG is vector, drawn onto a DPR-4 canvas, so PNG stays lossless and PDF stays JPEG q1.0. The two render paths (`buildDefaultWrapper` and the full-HTML `injectBridge`, which had drifted to capturing `body` vs `documentElement`) now share one `imageExportScript()`. A missing `html-to-image` now posts an error instead of returning silently, and a 30s parent-side watchdog clears the spinner and surfaces a timeout if the iframe never reports back. Covered by `tests/unit/image-export.test.mjs`.
|
|
9
|
+
|
|
5
10
|
## 0.3.2 — 2026-05-26
|
|
6
11
|
|
|
7
12
|
### Fixed
|
package/dist/client/viewer.js
CHANGED
|
@@ -7,6 +7,27 @@
|
|
|
7
7
|
const cardsEl = document.getElementById("cards");
|
|
8
8
|
const emptyEl = document.getElementById("empty-state");
|
|
9
9
|
const countEl = document.getElementById("push-count");
|
|
10
|
+
|
|
11
|
+
// Per-push export watchdog timers. If the iframe never posts back
|
|
12
|
+
// image-ready / image-error (e.g. a render stall), the watchdog clears the
|
|
13
|
+
// button spinner and surfaces an error instead of spinning forever.
|
|
14
|
+
const EXPORT_TIMEOUT_MS = 30000;
|
|
15
|
+
const exportWatchdogs = new Map();
|
|
16
|
+
function clearExportSpinner(pushId) {
|
|
17
|
+
const iframeEl = cardsEl.querySelector(
|
|
18
|
+
'iframe[data-push-id="' + cssEscape(pushId) + '"]',
|
|
19
|
+
);
|
|
20
|
+
const push = iframeEl && iframeEl.closest(".push");
|
|
21
|
+
const ex = push && push.querySelector(".push-export");
|
|
22
|
+
if (ex) delete ex.dataset.loading;
|
|
23
|
+
}
|
|
24
|
+
function clearExportWatchdog(pushId) {
|
|
25
|
+
const t = exportWatchdogs.get(pushId);
|
|
26
|
+
if (t) {
|
|
27
|
+
clearTimeout(t);
|
|
28
|
+
exportWatchdogs.delete(pushId);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
10
31
|
const prunedEl = document.getElementById("pruned-marker");
|
|
11
32
|
const liveDotEl = document.getElementById("live-dot");
|
|
12
33
|
const liveLabelEl = document.getElementById("live-label");
|
|
@@ -276,27 +297,15 @@
|
|
|
276
297
|
}
|
|
277
298
|
if (data.type === "easel:image-error") {
|
|
278
299
|
console.error("[easel] iframe export error", data);
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
);
|
|
282
|
-
if (iframeEl && iframeEl.closest(".push")) {
|
|
283
|
-
const ex = iframeEl.closest(".push").querySelector(".push-export");
|
|
284
|
-
if (ex) delete ex.dataset.loading;
|
|
285
|
-
}
|
|
300
|
+
clearExportWatchdog(data.pushId);
|
|
301
|
+
clearExportSpinner(data.pushId);
|
|
286
302
|
alert("Export failed (" + (data.format || "?") + "): " + (data.message || "unknown"));
|
|
287
303
|
return;
|
|
288
304
|
}
|
|
289
305
|
if (data.type === "easel:image-ready") {
|
|
290
306
|
const format = data.format === "pdf" ? "pdf" : "png";
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
'iframe[data-push-id="' + cssEscape(data.pushId) + '"]',
|
|
294
|
-
);
|
|
295
|
-
if (iframeEl && iframeEl.closest(".push")) {
|
|
296
|
-
const ex = iframeEl.closest(".push").querySelector(".push-export");
|
|
297
|
-
if (ex) delete ex.dataset.loading;
|
|
298
|
-
}
|
|
299
|
-
};
|
|
307
|
+
clearExportWatchdog(data.pushId);
|
|
308
|
+
const clearLoading = () => clearExportSpinner(data.pushId);
|
|
300
309
|
|
|
301
310
|
if (format === "pdf") {
|
|
302
311
|
downloadAsPdf(data.dataUrl, data.filename || "push.pdf")
|
|
@@ -654,6 +663,20 @@
|
|
|
654
663
|
function requestExport(format) {
|
|
655
664
|
exportBtn.dataset.loading = "true";
|
|
656
665
|
|
|
666
|
+
clearExportWatchdog(push.id);
|
|
667
|
+
exportWatchdogs.set(
|
|
668
|
+
push.id,
|
|
669
|
+
setTimeout(() => {
|
|
670
|
+
exportWatchdogs.delete(push.id);
|
|
671
|
+
clearExportSpinner(push.id);
|
|
672
|
+
alert(
|
|
673
|
+
"Export timed out after " +
|
|
674
|
+
EXPORT_TIMEOUT_MS / 1000 +
|
|
675
|
+
"s. Try again with the easel tab in the foreground.",
|
|
676
|
+
);
|
|
677
|
+
}, EXPORT_TIMEOUT_MS),
|
|
678
|
+
);
|
|
679
|
+
|
|
657
680
|
// Match the export bg to what the user sees inside this card:
|
|
658
681
|
// carded → card's elevated surface (--ds-bg-elev)
|
|
659
682
|
// flat → page canvas (--ds-bg) since the iframe body is transparent
|
|
@@ -676,6 +699,7 @@
|
|
|
676
699
|
"*",
|
|
677
700
|
);
|
|
678
701
|
} catch (err) {
|
|
702
|
+
clearExportWatchdog(push.id);
|
|
679
703
|
delete exportBtn.dataset.loading;
|
|
680
704
|
console.error("[easel] export failed", err);
|
|
681
705
|
}
|
|
@@ -769,6 +793,49 @@
|
|
|
769
793
|
);
|
|
770
794
|
}
|
|
771
795
|
|
|
796
|
+
/**
|
|
797
|
+
* In-iframe message listener that turns the rendered push into a PNG/JPEG
|
|
798
|
+
* dataURL on `easel:image` and posts back `easel:image-ready` / `-error`.
|
|
799
|
+
*
|
|
800
|
+
* Deliberately stops at htmlToImage.toSvg() and does the canvas rasterisation
|
|
801
|
+
* by hand. toPng/toJpeg/toCanvas resolve via the library's internal
|
|
802
|
+
* createImage(), which waits on requestAnimationFrame — and Chrome freezes
|
|
803
|
+
* rAF in hidden/background tabs, so the export hung forever whenever the
|
|
804
|
+
* easel tab wasn't the visible one. toSvg has no rAF, and a plain Image's
|
|
805
|
+
* onload fires even in hidden tabs, so this path works regardless of tab
|
|
806
|
+
* visibility. Quality is unchanged: the SVG is vector, drawn onto a
|
|
807
|
+
* PIXEL_RATIO-scaled canvas → still crisp at DPR 4.
|
|
808
|
+
*
|
|
809
|
+
* Shared verbatim by buildDefaultWrapper and injectBridge so the two render
|
|
810
|
+
* paths can't drift.
|
|
811
|
+
*/
|
|
812
|
+
function imageExportScript() {
|
|
813
|
+
return (
|
|
814
|
+
"(function(){var PR=4;" +
|
|
815
|
+
"function fail(id,fmt,err){console.error('[easel] export failed',err);" +
|
|
816
|
+
"parent.postMessage({type:'easel:image-error',pushId:id,format:fmt,message:(err&&err.message)?err.message:String(err)},'*')}" +
|
|
817
|
+
"function rasterize(svgUrl,w,h,bg,fmt){return new Promise(function(resolve,reject){" +
|
|
818
|
+
"var img=new Image();" +
|
|
819
|
+
"img.onload=function(){try{var c=document.createElement('canvas');" +
|
|
820
|
+
"c.width=Math.max(1,Math.round(w*PR));c.height=Math.max(1,Math.round(h*PR));" +
|
|
821
|
+
"var x=c.getContext('2d');x.fillStyle=bg;x.fillRect(0,0,c.width,c.height);" +
|
|
822
|
+
"x.drawImage(img,0,0,c.width,c.height);" +
|
|
823
|
+
"resolve(fmt==='pdf'?c.toDataURL('image/jpeg',1.0):c.toDataURL('image/png'))}catch(e){reject(e)}};" +
|
|
824
|
+
"img.onerror=function(){reject(new Error('SVG snapshot failed to load'))};img.src=svgUrl})}" +
|
|
825
|
+
"function run(d){var id=d.pushId;var fn=d.filename||'push.png';var fmt=d.format==='pdf'?'pdf':'png';" +
|
|
826
|
+
"var bg=d.bgColor||getComputedStyle(document.documentElement).getPropertyValue('--ds-bg-elev').trim()||'#ffffff';" +
|
|
827
|
+
"function render(){if(!window.htmlToImage){fail(id,fmt,new Error('html-to-image not loaded'));return}" +
|
|
828
|
+
"var w=document.documentElement.clientWidth;" +
|
|
829
|
+
"var h=Math.max(document.documentElement.scrollHeight,document.body?document.body.scrollHeight:0);" +
|
|
830
|
+
"window.htmlToImage.toSvg(document.documentElement,{width:w,height:h,cacheBust:true})" +
|
|
831
|
+
".then(function(u){return rasterize(u,w,h,bg,fmt)})" +
|
|
832
|
+
".then(function(u){parent.postMessage({type:'easel:image-ready',pushId:id,dataUrl:u,filename:fn,format:fmt},'*')})" +
|
|
833
|
+
".catch(function(e){fail(id,fmt,e)})}" +
|
|
834
|
+
"if(document.fonts&&document.fonts.ready){document.fonts.ready.then(render).catch(render)}else{render()}}" +
|
|
835
|
+
"window.addEventListener('message',function(e){if(e&&e.data&&e.data.type==='easel:image')run(e.data)})})();"
|
|
836
|
+
);
|
|
837
|
+
}
|
|
838
|
+
|
|
772
839
|
function buildDefaultWrapper(body, theme, preset, pushId, appFidelity) {
|
|
773
840
|
const density = currentDensity();
|
|
774
841
|
// app-fidelity mode: skip presentation defaults (presets, semantic chips,
|
|
@@ -1004,58 +1071,10 @@ ${body}
|
|
|
1004
1071
|
if (e.data.type === "easel:print") {
|
|
1005
1072
|
try { window.print(); } catch(_) {}
|
|
1006
1073
|
}
|
|
1007
|
-
if (e.data.type === "easel:image") {
|
|
1008
|
-
var pushId = e.data.pushId;
|
|
1009
|
-
var filename = e.data.filename || "push.png";
|
|
1010
|
-
var format = e.data.format === "pdf" ? "pdf" : "png";
|
|
1011
|
-
var bgColor =
|
|
1012
|
-
e.data.bgColor ||
|
|
1013
|
-
getComputedStyle(document.documentElement).getPropertyValue("--ds-bg-elev").trim() ||
|
|
1014
|
-
"#ffffff";
|
|
1015
|
-
function render() {
|
|
1016
|
-
if (!window.htmlToImage) {
|
|
1017
|
-
console.error("[easel] html-to-image not loaded");
|
|
1018
|
-
return;
|
|
1019
|
-
}
|
|
1020
|
-
// Capture the html root at full viewport width so fixed/absolute
|
|
1021
|
-
// positioning resolves the same as on screen. Capturing body alone
|
|
1022
|
-
// honours max-width:auto-margins and breaks fixed elements that
|
|
1023
|
-
// anchor to the viewport (modals at left:50% etc).
|
|
1024
|
-
var width = document.documentElement.clientWidth;
|
|
1025
|
-
var height = Math.max(
|
|
1026
|
-
document.documentElement.scrollHeight,
|
|
1027
|
-
document.body ? document.body.scrollHeight : 0,
|
|
1028
|
-
);
|
|
1029
|
-
// PNG target → lossless PNG @ pixelRatio 4 for crisp standalone files.
|
|
1030
|
-
// PDF target → JPEG @ quality 1.0 + pixelRatio 4. PDFs natively use
|
|
1031
|
-
// DCT compression for embedded JPEGs, so even at max quality the PDF
|
|
1032
|
-
// stays in the ~3-8 MB range for a typical card (vs ~300 MB if we
|
|
1033
|
-
// embedded as PNG — see 0.2.15). 1.0 + DPR 4 keeps text razor-sharp
|
|
1034
|
-
// at any zoom level; tuned down to 0.92 + DPR 2 in 0.2.15 dropped to
|
|
1035
|
-
// ~800 KB but at the cost of visible JPEG artefacts on type.
|
|
1036
|
-
var rasterFn = format === "pdf" ? window.htmlToImage.toJpeg : window.htmlToImage.toPng;
|
|
1037
|
-
var rasterOpts = {
|
|
1038
|
-
backgroundColor: bgColor,
|
|
1039
|
-
pixelRatio: 4,
|
|
1040
|
-
cacheBust: true,
|
|
1041
|
-
width: width,
|
|
1042
|
-
height: height,
|
|
1043
|
-
};
|
|
1044
|
-
if (format === "pdf") rasterOpts.quality = 1.0;
|
|
1045
|
-
rasterFn(document.documentElement, rasterOpts).then(function(dataUrl){
|
|
1046
|
-
parent.postMessage({ type: "easel:image-ready", pushId: pushId, dataUrl: dataUrl, filename: filename, format: format }, "*");
|
|
1047
|
-
}).catch(function(err){
|
|
1048
|
-
console.error("[easel] export failed", err);
|
|
1049
|
-
parent.postMessage({ type: "easel:image-error", pushId: pushId, format: format, message: (err && err.message) ? err.message : String(err) }, "*");
|
|
1050
|
-
});
|
|
1051
|
-
}
|
|
1052
|
-
if (document.fonts && document.fonts.ready) {
|
|
1053
|
-
document.fonts.ready.then(render).catch(render);
|
|
1054
|
-
} else { render(); }
|
|
1055
|
-
}
|
|
1056
1074
|
});
|
|
1057
1075
|
})();
|
|
1058
1076
|
</script>
|
|
1077
|
+
<script>${imageExportScript()}</script>
|
|
1059
1078
|
<script>${selfMeasureScript(pushId)}</script>
|
|
1060
1079
|
</body>
|
|
1061
1080
|
</html>`;
|
|
@@ -1066,9 +1085,10 @@ ${body}
|
|
|
1066
1085
|
const configScript =
|
|
1067
1086
|
"<script src='https://cdn.jsdelivr.net/npm/html-to-image@1.11.13/dist/html-to-image.js'></script><script>(function(){function a(c){if(!c)return;if(c.theme==='light'||c.theme==='dark'){document.documentElement.setAttribute('data-theme',c.theme);window.__claudeDisplayTheme=c.theme}if(c.preset==='paper'||c.preset==='aurora'||c.preset==='slate'){document.documentElement.setAttribute('data-preset',c.preset);window.__claudeDisplayPreset=c.preset}if(c.density==='carded'||c.density==='flat'){document.documentElement.setAttribute('data-density',c.density);window.__claudeDisplayDensity=c.density}}a(" +
|
|
1068
1087
|
JSON.stringify({ theme, preset, density }) +
|
|
1069
|
-
");window.addEventListener('message',function(e){if(!e||!e.data)return;if(e.data.type==='easel:config')a(e.data);if(e.data.type==='easel:theme')a({theme:e.data.theme});if(e.data.type==='easel:print'){try{window.print()}catch(_){}}
|
|
1088
|
+
");window.addEventListener('message',function(e){if(!e||!e.data)return;if(e.data.type==='easel:config')a(e.data);if(e.data.type==='easel:theme')a({theme:e.data.theme});if(e.data.type==='easel:print'){try{window.print()}catch(_){}}})})();</script>";
|
|
1089
|
+
const imageScript = "<script>" + imageExportScript() + "</script>";
|
|
1070
1090
|
const measureScript = "<script>" + selfMeasureScript(pushId) + "</script>";
|
|
1071
|
-
const combined = configScript + measureScript;
|
|
1091
|
+
const combined = configScript + imageScript + measureScript;
|
|
1072
1092
|
if (/<\/body>/i.test(html)) return html.replace(/<\/body>/i, combined + "</body>");
|
|
1073
1093
|
return html + combined;
|
|
1074
1094
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ammduncan/easel",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "A live browser tab for every Claude Code (and MCP) session. The push MCP tool appends HTML cards to a scrolling feed you keep open in split-screen.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|