screengraft 0.54.6 → 0.54.8

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/ui/index.html +77 -17
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "screengraft",
3
- "version": "0.54.6",
3
+ "version": "0.54.8",
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
@@ -643,11 +643,11 @@
643
643
  /* Dash lengths are set inline in photo px scaled by the zoom (SVG dashes
644
644
  are viewBox units; non-scaling-stroke does not cover them), so they read
645
645
  as 6/5 on screen at any zoom -- the fit overlay's GUIDE_DASH. */
646
- #dofGizmo .h{fill:#f5623d;stroke:#fff;stroke-width:1.2;vector-effect:non-scaling-stroke;pointer-events:auto;cursor:move}
646
+ #dofGizmo .h{fill:none;stroke:#f5623d;stroke-width:2;vector-effect:non-scaling-stroke;pointer-events:auto;cursor:move}
647
+ #dofGizmo .hcase{fill:none;stroke:rgba(0,0,0,.35);stroke-width:4;vector-effect:non-scaling-stroke;pointer-events:none}
647
648
  #dofGizmo .h.pip{cursor:url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='22' height='22' viewBox='0 0 22 22'%3E%3Cpath d='M17.5 11a6.5 6.5 0 1 1-2.2-4.9' fill='none' stroke='rgba(0,0,0,.85)' stroke-width='4.2' stroke-linecap='round'/%3E%3Cpath d='M17.5 11a6.5 6.5 0 1 1-2.2-4.9' fill='none' stroke='%23fff' stroke-width='2' stroke-linecap='round'/%3E%3Cpath d='M15.6 2.6l0.4 4.2-4.2 0.3' fill='none' stroke='rgba(0,0,0,.85)' stroke-width='4.2' stroke-linecap='round' stroke-linejoin='round'/%3E%3Cpath d='M15.6 2.6l0.4 4.2-4.2 0.3' fill='none' stroke='%23fff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") 11 11, crosshair}
648
649
  #dofGizmo .h.thumb{cursor:ew-resize}
649
- #dofGizmo .h.hollow{fill:#1a1a1a}
650
- #dofGizmo .h.pip{fill:#f5623d}
650
+ #dofGizmo .h.pip{fill:#f5623d;stroke:#fff;stroke-width:1.2}
651
651
  #dofGizmo .h.thumb{fill:#fff;stroke:#f5623d;stroke-width:2}
652
652
  #dofGizmo .h:active{cursor:grabbing}
653
653
  #dofGizmo text{font:600 11px system-ui,-apple-system,sans-serif;fill:#fff;paint-order:stroke;stroke:rgba(0,0,0,.75);stroke-width:3px;stroke-linejoin:round;pointer-events:none}
@@ -1508,6 +1508,17 @@ function cased(c, path, coreW, coreColor, dash){
1508
1508
  /* A FILLED handle, still cased. cased() strokes twice; a disc has to fill the
1509
1509
  core and put the dark casing around its rim, or the handle loses the
1510
1510
  readability guarantee that makes markers survive an arbitrary photograph. */
1511
+ /* A HOLLOW handle: 8px circle, 2px stroke, nothing inside, so the pixel
1512
+ under the handle stays visible; the casing sits outside the ring so the
1513
+ ring itself reads on any photograph (13 Sep 2026, on request). */
1514
+ function casedRing(c, x, y, r, coreColor){
1515
+ c.save();
1516
+ c.strokeStyle = CASE_A; c.lineWidth = 2 + CASE_PAD;
1517
+ c.beginPath(); c.arc(x, y, r, 0, Math.PI*2); c.stroke();
1518
+ c.strokeStyle = coreColor; c.lineWidth = 2;
1519
+ c.beginPath(); c.arc(x, y, r, 0, Math.PI*2); c.stroke();
1520
+ c.restore();
1521
+ }
1511
1522
  function casedDisc(c, x, y, r, coreColor){
1512
1523
  c.save();
1513
1524
  c.strokeStyle = CASE_A; c.lineWidth = 2.0;
@@ -2299,7 +2310,12 @@ function draw(){
2299
2310
  // hollow; the pivot pips went 2.5 -> 3.5 and are filled too, staying visibly
2300
2311
  // smaller than a corner so the two never read as the same control. The gap the
2301
2312
  // edge leaves for each corner grows with it, or the disc sits on the line.
2302
- const HANDLE_R = 5.5, PIVOT_R = 3.5, HANDLE_GAP = 7.5;
2313
+ // Corner handle: an 8px ring (r to the stroke centre = 4) with a 2px stroke
2314
+ // and the casing outside it, so its visible outer edge is at r + 1 + CASE_PAD/2;
2315
+ // the edge breaks 1px short of that, which is the gap that reads as
2316
+ // "handle, then line" rather than a bead on a wire.
2317
+ const HANDLE_R = 4, PIVOT_R = 3.5;
2318
+ const HANDLE_GAP = HANDLE_R + 1 + CASE_PAD / 2 + 1;
2303
2319
 
2304
2320
  // Guide: the active edge extended to infinity, so you can see the line it
2305
2321
  // is really on. Thinner and dashed — it is a reference, not a handle.
@@ -2345,7 +2361,7 @@ function draw(){
2345
2361
  ctx.font='600 11px system-ui'; ctx.textBaseline='alphabetic';
2346
2362
  P.forEach(([x,y],i) => {
2347
2363
  const on = active && active.kind==='corner' && active.i===i;
2348
- casedDisc(ctx, x, y, on ? HANDLE_R + 1 : HANDLE_R, CORE_QUAD);
2364
+ casedRing(ctx, x, y, on ? HANDLE_R + 1 : HANDLE_R, CORE_QUAD);
2349
2365
  casedText(ctx, LBL[i], x+13, y-11, 'rgba(255,255,255,.95)');
2350
2366
  });
2351
2367
  }
@@ -2676,6 +2692,7 @@ $('#dofMeasure').onclick = measureDof;
2676
2692
  the SVG's viewBox is the photo so it stays aligned at every zoom for free. */
2677
2693
  const gz = $('#dofGizmo');
2678
2694
  const SIGMA_FULL_FRAC = 0.02; // mirrors dof.DOF_MAX_FRAC -- keep in step
2695
+ const HANDLE_BREAK = 4 + 1 + 2 + 1; // ring radius + half its stroke + casing + the 1px gap, screen px
2679
2696
  let dofEnd = parseFloat(recall('dofEnd','1')); if (!(dofEnd > 0)) dofEnd = 1;
2680
2697
  // Where along the lines the handles sit (0..1 across the screen's extent).
2681
2698
  // Page-only: it changes nothing in the render. Turning re-derives it so the
@@ -2727,7 +2744,8 @@ function paintGizmo(){
2727
2744
  const c0 = two ? P(dofEnd2, -0.06) : null, c1 = two ? P(dofEnd2, 1.06) : null, cc = two ? P(dofEnd2, dofK) : null;
2728
2745
  const th = P(dofEnd, 0.08 + 0.84 * dofStr); // the strength thumb slides along the dashed line
2729
2746
  const sigma = dofStr * SIGMA_FULL_FRAC * g.side;
2730
- const r = 6 * k, rp = 4 * k, fs11 = 11 * k, off = 14 * k;
2747
+ const r = 4 * k, rp = 3.5 * k, fs11 = 11 * k, off = 14 * k;
2748
+ const ring = (p, h, cls='') => `<circle cx="${p[0]}" cy="${p[1]}" r="${r}" class="hcase"/><circle cx="${p[0]}" cy="${p[1]}" r="${r}" class="h ${cls}" data-h="${h}"/>`;
2731
2749
  const L = (x, y, cls, extra='') => `<line x1="${x[0]}" y1="${x[1]}" x2="${y[0]}" y2="${y[1]}" class="${cls}" ${extra}/>`;
2732
2750
  const T = (p, txt, dy) => `<text x="${p[0]}" y="${p[1] + dy}" text-anchor="middle" font-size="${fs11}">${txt}</text>`;
2733
2751
  // GUIDE_DASH in screen px, expressed in photo px; a line past the screen gets a sparser dash
@@ -2735,15 +2753,18 @@ function paintGizmo(){
2735
2753
  const away = unit(sub(bc, ac)); // label offsets: away from the other line
2736
2754
  const beyond = dofEnd > 1.0001;
2737
2755
  const beyond2 = two && dofEnd2 < -0.0001;
2756
+ // Each line breaks around its ring handle: the ring is hollow, so a line
2757
+ // running through it would show inside. Break = ring outer edge + 1px.
2758
+ const brk = (HANDLE_BREAK) * k;
2759
+ const split = (p0, p1, h) => { const v = unit(sub(p1, p0)); return [[p0, sub(h, mul(v, brk))], [add(h, mul(v, brk)), p1]]; };
2760
+ const LL = (p0, p1, h, cls, extra='') => split(p0, p1, h).map(([x, y]) => L(x, y, cls, extra)).join('');
2738
2761
  gz.innerHTML =
2739
- L(a0, a1, 'case', 'stroke-width="3"') + L(a0, a1, 'focus') +
2740
- L(b0, b1, 'case', `stroke-width="2.9" ${dash(beyond)}`) + L(b0, b1, 'far' + (beyond ? ' out' : ''), dash(beyond)) +
2741
- (two ? L(c0, c1, 'case', `stroke-width="2.9" ${dash(beyond2)}`) + L(c0, c1, 'far' + (beyond2 ? ' out' : ''), dash(beyond2)) : '') +
2762
+ LL(a0, a1, ac, 'case', 'stroke-width="3"') + LL(a0, a1, ac, 'focus') +
2763
+ LL(b0, b1, bc, 'case', `stroke-width="2.9" ${dash(beyond)}`) + LL(b0, b1, bc, 'far' + (beyond ? ' out' : ''), dash(beyond)) +
2764
+ (two ? LL(c0, c1, cc, 'case', `stroke-width="2.9" ${dash(beyond2)}`) + LL(c0, c1, cc, 'far' + (beyond2 ? ' out' : ''), dash(beyond2)) : '') +
2742
2765
  `<circle cx="${a0[0]}" cy="${a0[1]}" r="${rp}" class="h pip" data-h="rotA"/>` +
2743
2766
  `<circle cx="${a1[0]}" cy="${a1[1]}" r="${rp}" class="h pip" data-h="rotB"/>` +
2744
- `<circle cx="${ac[0]}" cy="${ac[1]}" r="${r}" class="h" data-h="start"/>` +
2745
- `<circle cx="${bc[0]}" cy="${bc[1]}" r="${r}" class="h hollow" data-h="end"/>` +
2746
- (two ? `<circle cx="${cc[0]}" cy="${cc[1]}" r="${r}" class="h hollow" data-h="end2"/>` : '') +
2767
+ ring(ac, 'start') + ring(bc, 'end') + (two ? ring(cc, 'end2') : '') +
2747
2768
  `<rect x="${th[0]-r}" y="${th[1]-r}" width="${2*r}" height="${2*r}" transform="rotate(45 ${th[0]} ${th[1]})" class="h thumb" data-h="str"/>` +
2748
2769
  T([ac[0] - away[0]*off*(two ? 0 : 1), ac[1] - away[1]*off*(two ? 0 : 1) + (two ? -off : 0)], 'sharp', 4*k) +
2749
2770
  T([bc[0] + away[0]*off, bc[1] + away[1]*off], `σ ${sigma.toFixed(0)}px from here${beyond ? ' · past the screen' : ''}`, 4*k) +
@@ -2899,9 +2920,38 @@ function swingText(mode){
2899
2920
  if (mode === 'rotB') return 'the left end swings, pivot on the right';
2900
2921
  return 'flat on the rule means aligned';
2901
2922
  }
2923
+ /* A corner under the pointer gets a plain magnified view instead of the
2924
+ rectified strip: the strip straightens ONE edge, and a corner is where two
2925
+ meet, so straightening either would bend the other. Same magnification as
2926
+ the strip (H / 2·stripHalf, so the ± buttons work here too), centred on the
2927
+ corner, unrotated, with both edges drawn through it and the ring at the
2928
+ centre. Requested 13 Sep 2026; edges keep the strip as it is. */
2929
+ function paintCorner(c, W, H, i){
2930
+ c.setTransform(1,0,0,1,0,0);
2931
+ c.fillStyle = '#0c0c0b'; c.fillRect(0,0,W,H);
2932
+ const P = st.corners[i], M = H / (2*stripHalf);
2933
+ c.save();
2934
+ c.setTransform(M, 0, 0, M, W/2 - P[0]*M, H/2 - P[1]*M);
2935
+ c.imageSmoothingEnabled = true;
2936
+ c.drawImage(img, 0, 0);
2937
+ c.restore();
2938
+ if (stripHC) stretchContrast(c, W, H);
2939
+ const toS = q => [W/2 + (q[0]-P[0])*M, H/2 + (q[1]-P[1])*M];
2940
+ const prev = st.corners[(i+3)%4], next = st.corners[(i+1)%4];
2941
+ for (const q of [prev, next]){
2942
+ const e = toS(q), d = unit(sub(e, [W/2, H/2]));
2943
+ const s0 = add([W/2, H/2], mul(d, HANDLE_GAP_S)), s1 = add([W/2, H/2], mul(d, 4000));
2944
+ cased(c, () => { c.beginPath(); c.moveTo(s0[0], s0[1]); c.lineTo(s1[0], s1[1]); }, QUAD_W, CORE_QUAD);
2945
+ }
2946
+ casedRing(c, W/2, H/2, 4, CORE_QUAD);
2947
+ c.font = '600 11px system-ui'; c.textBaseline = 'middle';
2948
+ casedText(c, LBL[i], W/2 + 12, H/2 - 12, 'rgba(255,255,255,.95)');
2949
+ }
2950
+ const HANDLE_GAP_S = 4 + 1 + CASE_PAD/2 + 1; // the same break the canvas leaves around a ring
2902
2951
  function drawStrip(){
2903
2952
  const a = drag || hover || pick;
2904
- const has = !!(st.corners && a && a.kind === 'edge' && img.naturalWidth);
2953
+ const has = !!(st.corners && a && (a.kind === 'edge' || a.kind === 'corner') && img.naturalWidth);
2954
+ const corner = has && a.kind === 'corner';
2905
2955
  $('#stripZ').textContent = '±' + stripHalf + 'px';
2906
2956
  if (loupeMode === 'dock'){
2907
2957
  $('#floatLoupe').classList.remove('on');
@@ -2911,13 +2961,23 @@ function drawStrip(){
2911
2961
  $('#stripSt').textContent = 'Hover or drag an edge — this straightens it, so misalignment shows as a wedge.';
2912
2962
  return;
2913
2963
  }
2914
- paintStrip(sctx, W, H, a.i, a.mode);
2915
- $('#stripSt').textContent = `${EDGE_LBL[a.i]} edge · ±${stripHalf}px · ${(H/(2*stripHalf)).toFixed(1)}× across the boundary — ${swingText(a.mode)}`;
2964
+ if (corner){
2965
+ paintCorner(sctx, W, H, a.i);
2966
+ $('#stripSt').textContent = `${LBL[a.i]} corner · ${(H/(2*stripHalf)).toFixed(1)}× — where the two edges meet`;
2967
+ } else {
2968
+ paintStrip(sctx, W, H, a.i, a.mode);
2969
+ $('#stripSt').textContent = `${EDGE_LBL[a.i]} edge · ±${stripHalf}px · ${(H/(2*stripHalf)).toFixed(1)}× across the boundary — ${swingText(a.mode)}`;
2970
+ }
2916
2971
  } else {
2917
2972
  const fl = $('#floatLoupe');
2918
2973
  if (!has){ fl.classList.remove('on'); return; }
2919
- paintStrip(fctx, stripF.width, stripF.height, a.i, a.mode);
2920
- $('#stripFCap').textContent = `${EDGE_LBL[a.i]} edge · ±${stripHalf}px — ${swingText(a.mode)}`;
2974
+ if (corner){
2975
+ paintCorner(fctx, stripF.width, stripF.height, a.i);
2976
+ $('#stripFCap').textContent = `${LBL[a.i]} corner · ${(stripF.height/(2*stripHalf)).toFixed(1)}× — where the two edges meet`;
2977
+ } else {
2978
+ paintStrip(fctx, stripF.width, stripF.height, a.i, a.mode);
2979
+ $('#stripFCap').textContent = `${EDGE_LBL[a.i]} edge · ±${stripHalf}px — ${swingText(a.mode)}`;
2980
+ }
2921
2981
  fl.classList.add('on');
2922
2982
  const w = fl.offsetWidth || 356, h = fl.offsetHeight || 130;
2923
2983
  let x = lastPointer[0] + 26, y = lastPointer[1] - h - 18;