screengraft 0.63.4 → 0.63.6
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/package.json +1 -1
- package/ui/index.html +105 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "screengraft",
|
|
3
|
-
"version": "0.63.
|
|
3
|
+
"version": "0.63.6",
|
|
4
4
|
"description": "Put a UI screenshot or screen recording onto a photographed device screen with the perspective exactly right \u2014 a homography you confirm by hand, not a generative guess.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mockup",
|
package/ui/index.html
CHANGED
|
@@ -558,6 +558,11 @@
|
|
|
558
558
|
.pane{min-width:0;min-height:0;display:grid;grid-template-rows:minmax(0,1fr);
|
|
559
559
|
grid-template-columns:minmax(0,1fr);
|
|
560
560
|
position:relative; /* the frame the rails are anchored to */
|
|
561
|
+
/* An isolated group: the blend rings on the rails and pills composite
|
|
562
|
+
against THIS pane's content -- the well, the canvas, the result
|
|
563
|
+
image -- as one unit, rather than against whatever layer the
|
|
564
|
+
compositor happened to have on hand at first paint. */
|
|
565
|
+
isolation:isolate;
|
|
561
566
|
border-right:1px solid var(--line)}
|
|
562
567
|
.panehead{display:flex;align-items:center;gap:var(--s2);padding:var(--s2) var(--s4);background:var(--card);
|
|
563
568
|
box-shadow:inset 0 -1px 0 var(--line);font-size:12px;flex-wrap:wrap;min-height:48px}
|
|
@@ -600,6 +605,17 @@
|
|
|
600
605
|
.bgroup::after{--ring:var(--group-edge-lum);mix-blend-mode:luminosity;z-index:2}
|
|
601
606
|
.bgroup > .ring{--ring:var(--group-edge-hue);mix-blend-mode:hue;z-index:3}
|
|
602
607
|
.bgroup > .ring.lift{--ring:var(--group-edge-lift);mix-blend-mode:soft-light;z-index:4}
|
|
608
|
+
/* WebKit does not blend against an accelerated <canvas> (or, reliably, an
|
|
609
|
+
<img>), so under html.noblend the four rings collapse to ONE plain ring
|
|
610
|
+
whose four side colours are computed in JS from the pixels under each
|
|
611
|
+
edge, through the same four blends (paintRingsFallback). Chrome keeps
|
|
612
|
+
the live per-pixel blend. */
|
|
613
|
+
.noblend .bgroup::before,.noblend .bgroup::after,.noblend .bgroup > .ring:not(.lift),
|
|
614
|
+
.noblend .pillwrap > .ring:not(.lift){display:none}
|
|
615
|
+
.noblend .bgroup > .ring.lift{box-shadow:none;mix-blend-mode:normal;
|
|
616
|
+
inset:calc(-1 * var(--group-bw));border:var(--group-bw) solid transparent;
|
|
617
|
+
border-radius:calc(var(--r-sm) + var(--group-bw))}
|
|
618
|
+
.noblend .pillwrap > .ring.lift{mix-blend-mode:normal;border-color:transparent}
|
|
603
619
|
.bgroup.row{flex-direction:row}
|
|
604
620
|
.bgroup > .segbtn:first-of-type{border-top-left-radius:var(--r-sm);border-top-right-radius:var(--r-sm)}
|
|
605
621
|
.bgroup > .segbtn:last-of-type{border-bottom-left-radius:var(--r-sm);border-bottom-right-radius:var(--r-sm)}
|
|
@@ -2121,6 +2137,7 @@ async function maybeStart(){
|
|
|
2121
2137
|
const ready = !!st.shot; // the screenshot gates the fit, not the photo
|
|
2122
2138
|
img.onload = () => {
|
|
2123
2139
|
computeFit(); setCanvasZoom(fitScale);
|
|
2140
|
+
refreshRings();
|
|
2124
2141
|
if (!ready){
|
|
2125
2142
|
// Photo only: show it immediately so choosing one visibly does
|
|
2126
2143
|
// something. No quad yet — there is nothing to fit into it.
|
|
@@ -2502,6 +2519,89 @@ $('#resetquad').onclick = () => {
|
|
|
2502
2519
|
draw(); drawStrip(); autoPreview();
|
|
2503
2520
|
};
|
|
2504
2521
|
|
|
2522
|
+
// The blend rings on the rails and pills are painted against what is under
|
|
2523
|
+
// them at the time; when a NEW picture lands in the canvas or the result pane
|
|
2524
|
+
// they can keep a result blended against the empty well until something else
|
|
2525
|
+
// invalidates them (reported 14 Sep 2026: the stroke appeared only after the
|
|
2526
|
+
// picture was moved). Toggling display forces a fresh paint of each ring.
|
|
2527
|
+
function refreshRings(){
|
|
2528
|
+
paintRingsFallback();
|
|
2529
|
+
document.querySelectorAll('.bgroup > .ring, .pillwrap > .ring').forEach(r => {
|
|
2530
|
+
r.style.display = 'none'; void r.offsetHeight; r.style.display = '';
|
|
2531
|
+
});
|
|
2532
|
+
// ::before / ::after rings ride on the group; nudge the group itself.
|
|
2533
|
+
document.querySelectorAll('.bgroup').forEach(g => {
|
|
2534
|
+
g.style.willChange = 'transform'; void g.offsetHeight; g.style.willChange = '';
|
|
2535
|
+
});
|
|
2536
|
+
}
|
|
2537
|
+
// ---- WebKit fallback for the blended strokes -------------------------------
|
|
2538
|
+
// The W3C compositing blends (the same maths mix-blend-mode uses), applied in
|
|
2539
|
+
// JS to the average backdrop under each side of a group, so Safari shows the
|
|
2540
|
+
// same adaptive hairline Chrome gets from the compositor. Paints are
|
|
2541
|
+
// [colour, opacity, mode] bottom to top, as in the design file.
|
|
2542
|
+
const IS_WEBKIT = /AppleWebKit/.test(navigator.userAgent) && !/Chrome|Chromium|CriOS|Edg/.test(navigator.userAgent);
|
|
2543
|
+
if (IS_WEBKIT) document.documentElement.classList.add('noblend');
|
|
2544
|
+
const RING_PAINTS = [[[15,107,255],.2,'saturation'],[[255,70,70],.15,'luminosity'],[[126,126,126],.2,'hue'],[[255,255,255],.4,'soft-light']];
|
|
2545
|
+
const _lum = c => .3*c[0] + .59*c[1] + .11*c[2];
|
|
2546
|
+
function _clipColor(c){ const l = _lum(c), n = Math.min(...c), x = Math.max(...c);
|
|
2547
|
+
if (n < 0) c = c.map(v => l + (v - l) * l / (l - n));
|
|
2548
|
+
if (x > 1) c = c.map(v => l + (v - l) * (1 - l) / (x - l)); return c; }
|
|
2549
|
+
const _setLum = (c, l) => { const d = l - _lum(c); return _clipColor(c.map(v => v + d)); };
|
|
2550
|
+
const _sat = c => Math.max(...c) - Math.min(...c);
|
|
2551
|
+
function _setSat(c, s){ const i = [0,1,2].sort((a,b) => c[a]-c[b]); const [mn, md, mx] = i; const o = [0,0,0];
|
|
2552
|
+
if (c[mx] > c[mn]){ o[md] = (c[md]-c[mn]) * s / (c[mx]-c[mn]); o[mx] = s; } return o; }
|
|
2553
|
+
function _blend(mode, b, s){
|
|
2554
|
+
if (mode === 'saturation') return _setLum(_setSat(b, _sat(s)), _lum(b));
|
|
2555
|
+
if (mode === 'luminosity') return _setLum(b, _lum(s));
|
|
2556
|
+
if (mode === 'hue') return _setLum(_setSat(s, _sat(b)), _lum(b));
|
|
2557
|
+
if (mode === 'soft-light') return b.map((cb, i) => { const cs = s[i];
|
|
2558
|
+
if (cs <= .5) return cb - (1 - 2*cs) * cb * (1 - cb);
|
|
2559
|
+
const d = cb <= .25 ? ((16*cb - 12) * cb + 4) * cb : Math.sqrt(cb);
|
|
2560
|
+
return cb + (2*cs - 1) * (d - cb); });
|
|
2561
|
+
return s;
|
|
2562
|
+
}
|
|
2563
|
+
function ringColour(rgb){ let b = rgb.map(v => v/255);
|
|
2564
|
+
for (const [col, a, mode] of RING_PAINTS){ const s = col.map(v => v/255); const r = _blend(mode, b, s); b = b.map((v, i) => v*(1-a) + r[i]*a); }
|
|
2565
|
+
return 'rgb(' + b.map(v => Math.round(Math.max(0, Math.min(1, v))*255)).join(',') + ')'; }
|
|
2566
|
+
// One small copy of each pane's picture to sample from (a readback per draw,
|
|
2567
|
+
// not per pixel). Fit pane: the canvas; result pane: the composite <img>.
|
|
2568
|
+
const _sampleCv = document.createElement('canvas'), _sampleCx = _sampleCv.getContext('2d', {willReadFrequently: true});
|
|
2569
|
+
function _backdropSampler(source, rect){
|
|
2570
|
+
const W = 256, H = Math.max(1, Math.round(256 * rect.height / Math.max(rect.width, 1)));
|
|
2571
|
+
_sampleCv.width = W; _sampleCv.height = H;
|
|
2572
|
+
try { _sampleCx.drawImage(source, 0, 0, W, H); } catch (e) { return null; }
|
|
2573
|
+
const data = _sampleCx.getImageData(0, 0, W, H).data;
|
|
2574
|
+
return (x, y) => { const px = Math.max(0, Math.min(W-1, Math.round((x - rect.left) * W / rect.width)));
|
|
2575
|
+
const py = Math.max(0, Math.min(H-1, Math.round((y - rect.top) * H / rect.height)));
|
|
2576
|
+
const i = (py*W + px) * 4; return data[i+3] ? [data[i], data[i+1], data[i+2]] : null; };
|
|
2577
|
+
}
|
|
2578
|
+
function _sideColours(sample, r, out){
|
|
2579
|
+
const N = 9, sides = {top: [], right: [], bottom: [], left: []};
|
|
2580
|
+
for (let k = 0; k < N; k++){ const t = (k + .5) / N;
|
|
2581
|
+
sides.top.push(sample(r.left + r.width*t, r.top - 1)); sides.bottom.push(sample(r.left + r.width*t, r.bottom + 1));
|
|
2582
|
+
sides.left.push(sample(r.left - 1, r.top + r.height*t)); sides.right.push(sample(r.right + 1, r.top + r.height*t)); }
|
|
2583
|
+
for (const [side, pts] of Object.entries(sides)){ const ok = pts.filter(Boolean);
|
|
2584
|
+
const avg = ok.length ? [0,1,2].map(i => ok.reduce((a, p) => a + p[i], 0) / ok.length) : [11,11,11];
|
|
2585
|
+
out.style['border' + side[0].toUpperCase() + side.slice(1) + 'Color'] = ringColour(avg); }
|
|
2586
|
+
}
|
|
2587
|
+
let _ringsQueued = false;
|
|
2588
|
+
function paintRingsFallback(){
|
|
2589
|
+
if (!IS_WEBKIT || _ringsQueued) return;
|
|
2590
|
+
_ringsQueued = true;
|
|
2591
|
+
requestAnimationFrame(() => { _ringsQueued = false;
|
|
2592
|
+
const panes = [[$('#c'), $('.pane')], [$('#outImg'), $('#outPane')]];
|
|
2593
|
+
for (const [src, pane] of panes){
|
|
2594
|
+
if (!src || !pane) continue;
|
|
2595
|
+
const ok = src.tagName === 'CANVAS' ? src.width > 0 : (!src.hidden && src.naturalWidth > 0);
|
|
2596
|
+
const rect = src.getBoundingClientRect();
|
|
2597
|
+
const sample = ok && rect.width > 0 ? _backdropSampler(src, rect) : null;
|
|
2598
|
+
pane.querySelectorAll('.bgroup > .ring.lift, .pillwrap > .ring.lift').forEach(ring => {
|
|
2599
|
+
const r = ring.parentElement.getBoundingClientRect();
|
|
2600
|
+
if (sample) _sideColours(sample, r, ring); else ['Top','Right','Bottom','Left'].forEach(sd => ring.style['border' + sd + 'Color'] = ringColour([11,11,11]));
|
|
2601
|
+
});
|
|
2602
|
+
}
|
|
2603
|
+
});
|
|
2604
|
+
}
|
|
2505
2605
|
function syncCornerRail(){
|
|
2506
2606
|
const i = pick && pick.kind === 'corner' ? pick.i : -1;
|
|
2507
2607
|
document.querySelectorAll('#lrail [data-jump]').forEach(b => {
|
|
@@ -2512,6 +2612,7 @@ function syncCornerRail(){
|
|
|
2512
2612
|
}
|
|
2513
2613
|
function draw(){
|
|
2514
2614
|
syncCornerRail();
|
|
2615
|
+
paintRingsFallback();
|
|
2515
2616
|
// The clip follows the quad while it is being dragged: the live layer IS the
|
|
2516
2617
|
// fit, so leaving it on the old corners would make the two panes disagree
|
|
2517
2618
|
// mid-drag — which is the one thing the side-by-side exists to prevent.
|
|
@@ -3323,6 +3424,7 @@ function markStale(){
|
|
|
3323
3424
|
$('#imp').classList.remove('primary');
|
|
3324
3425
|
}
|
|
3325
3426
|
function syncOut(){
|
|
3427
|
+
paintRingsFallback();
|
|
3326
3428
|
// The live layer is a photo plus a transformed video, so it re-places rather
|
|
3327
3429
|
// than being resized: the transform is computed FROM the zoom, and a stale
|
|
3328
3430
|
// matrix puts the clip somewhere the fit is not.
|
|
@@ -3346,6 +3448,7 @@ function syncOut(){
|
|
|
3346
3448
|
paintGizmo();
|
|
3347
3449
|
}
|
|
3348
3450
|
$('#scroller').addEventListener('scroll', () => {
|
|
3451
|
+
paintRingsFallback();
|
|
3349
3452
|
syncOut();
|
|
3350
3453
|
});
|
|
3351
3454
|
function autoPreview(){
|
|
@@ -3598,6 +3701,8 @@ async function renderPreview(){
|
|
|
3598
3701
|
const im = $('#outImg');
|
|
3599
3702
|
im.onload = () => {
|
|
3600
3703
|
outNat = im.naturalWidth;
|
|
3704
|
+
refreshRings();
|
|
3705
|
+
paintRingsFallback();
|
|
3601
3706
|
// autoPreview runs on every drag, and it must not yank the pane away from
|
|
3602
3707
|
// a clip that is playing. The still is still rendered and waiting behind.
|
|
3603
3708
|
if ($('#liveWrap').hidden) showResult('still');
|