screengraft 0.63.5 → 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 +84 -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
|
@@ -605,6 +605,17 @@
|
|
|
605
605
|
.bgroup::after{--ring:var(--group-edge-lum);mix-blend-mode:luminosity;z-index:2}
|
|
606
606
|
.bgroup > .ring{--ring:var(--group-edge-hue);mix-blend-mode:hue;z-index:3}
|
|
607
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}
|
|
608
619
|
.bgroup.row{flex-direction:row}
|
|
609
620
|
.bgroup > .segbtn:first-of-type{border-top-left-radius:var(--r-sm);border-top-right-radius:var(--r-sm)}
|
|
610
621
|
.bgroup > .segbtn:last-of-type{border-bottom-left-radius:var(--r-sm);border-bottom-right-radius:var(--r-sm)}
|
|
@@ -2514,6 +2525,7 @@ $('#resetquad').onclick = () => {
|
|
|
2514
2525
|
// invalidates them (reported 14 Sep 2026: the stroke appeared only after the
|
|
2515
2526
|
// picture was moved). Toggling display forces a fresh paint of each ring.
|
|
2516
2527
|
function refreshRings(){
|
|
2528
|
+
paintRingsFallback();
|
|
2517
2529
|
document.querySelectorAll('.bgroup > .ring, .pillwrap > .ring').forEach(r => {
|
|
2518
2530
|
r.style.display = 'none'; void r.offsetHeight; r.style.display = '';
|
|
2519
2531
|
});
|
|
@@ -2522,6 +2534,74 @@ function refreshRings(){
|
|
|
2522
2534
|
g.style.willChange = 'transform'; void g.offsetHeight; g.style.willChange = '';
|
|
2523
2535
|
});
|
|
2524
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
|
+
}
|
|
2525
2605
|
function syncCornerRail(){
|
|
2526
2606
|
const i = pick && pick.kind === 'corner' ? pick.i : -1;
|
|
2527
2607
|
document.querySelectorAll('#lrail [data-jump]').forEach(b => {
|
|
@@ -2532,6 +2612,7 @@ function syncCornerRail(){
|
|
|
2532
2612
|
}
|
|
2533
2613
|
function draw(){
|
|
2534
2614
|
syncCornerRail();
|
|
2615
|
+
paintRingsFallback();
|
|
2535
2616
|
// The clip follows the quad while it is being dragged: the live layer IS the
|
|
2536
2617
|
// fit, so leaving it on the old corners would make the two panes disagree
|
|
2537
2618
|
// mid-drag — which is the one thing the side-by-side exists to prevent.
|
|
@@ -3343,6 +3424,7 @@ function markStale(){
|
|
|
3343
3424
|
$('#imp').classList.remove('primary');
|
|
3344
3425
|
}
|
|
3345
3426
|
function syncOut(){
|
|
3427
|
+
paintRingsFallback();
|
|
3346
3428
|
// The live layer is a photo plus a transformed video, so it re-places rather
|
|
3347
3429
|
// than being resized: the transform is computed FROM the zoom, and a stale
|
|
3348
3430
|
// matrix puts the clip somewhere the fit is not.
|
|
@@ -3366,6 +3448,7 @@ function syncOut(){
|
|
|
3366
3448
|
paintGizmo();
|
|
3367
3449
|
}
|
|
3368
3450
|
$('#scroller').addEventListener('scroll', () => {
|
|
3451
|
+
paintRingsFallback();
|
|
3369
3452
|
syncOut();
|
|
3370
3453
|
});
|
|
3371
3454
|
function autoPreview(){
|
|
@@ -3619,6 +3702,7 @@ async function renderPreview(){
|
|
|
3619
3702
|
im.onload = () => {
|
|
3620
3703
|
outNat = im.naturalWidth;
|
|
3621
3704
|
refreshRings();
|
|
3705
|
+
paintRingsFallback();
|
|
3622
3706
|
// autoPreview runs on every drag, and it must not yank the pane away from
|
|
3623
3707
|
// a clip that is playing. The still is still rendered and waiting behind.
|
|
3624
3708
|
if ($('#liveWrap').hidden) showResult('still');
|