screengraft 0.60.0 → 0.62.0
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/scripts/recents.py +125 -0
- package/scripts/ui.py +29 -3
- package/skills/inject-screenshot/SKILL.md +4 -3
- package/ui/index.html +88 -26
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "screengraft",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.62.0",
|
|
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",
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
screengraft -- the images a designer has actually used, per role.
|
|
4
|
+
|
|
5
|
+
The picker used to list whatever was newest on ~/Desktop and ~/Downloads. That
|
|
6
|
+
is a guess about where files live; this is a record of what was chosen. Every
|
|
7
|
+
source that reaches the session through /api/use or /api/upload is written
|
|
8
|
+
here, newest first, one list per role (a photograph of a device and a
|
|
9
|
+
screenshot to put on it are different populations), and the picker shows the
|
|
10
|
+
ones that still exist on disk.
|
|
11
|
+
|
|
12
|
+
Store: ~/.screengraft/recents.json
|
|
13
|
+
{"photo": [{"path": ..., "name": ..., "used": <epoch>}, ...], "screenshot": [...]}
|
|
14
|
+
|
|
15
|
+
An uploaded file's path is its session copy, which the launch-time sweep
|
|
16
|
+
removes when nothing was saved from that session -- so an upload stays recent
|
|
17
|
+
only as long as its session does. A file chosen by path is read through in
|
|
18
|
+
place and stays for as long as the file does.
|
|
19
|
+
|
|
20
|
+
First run: seeds itself from the sessions that already exist, oldest first,
|
|
21
|
+
so the list is not empty for someone who has been using the tool for a week.
|
|
22
|
+
"""
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import time
|
|
26
|
+
|
|
27
|
+
ROLES = ("photo", "screenshot")
|
|
28
|
+
CAP = 60
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def store_path() -> str:
|
|
32
|
+
return os.path.join(os.path.expanduser("~"), ".screengraft", "recents.json")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _load() -> dict:
|
|
36
|
+
try:
|
|
37
|
+
with open(store_path()) as f:
|
|
38
|
+
d = json.load(f)
|
|
39
|
+
if not isinstance(d, dict):
|
|
40
|
+
raise ValueError
|
|
41
|
+
except (OSError, ValueError):
|
|
42
|
+
d = _seed()
|
|
43
|
+
for r in ROLES:
|
|
44
|
+
d.setdefault(r, [])
|
|
45
|
+
return d
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _save(d: dict):
|
|
49
|
+
p = store_path()
|
|
50
|
+
os.makedirs(os.path.dirname(p), exist_ok=True)
|
|
51
|
+
tmp = p + ".tmp"
|
|
52
|
+
with open(tmp, "w") as f:
|
|
53
|
+
json.dump(d, f, indent=1)
|
|
54
|
+
os.replace(tmp, p)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _seed() -> dict:
|
|
58
|
+
"""Once: what the existing sessions say was used, in the order they ran."""
|
|
59
|
+
d = {r: [] for r in ROLES}
|
|
60
|
+
root = os.path.join(os.path.expanduser("~"), ".screengraft", "sessions")
|
|
61
|
+
try:
|
|
62
|
+
names = sorted(os.listdir(root))
|
|
63
|
+
except OSError:
|
|
64
|
+
return d
|
|
65
|
+
for n in names:
|
|
66
|
+
sp = os.path.join(root, n, "state.json")
|
|
67
|
+
try:
|
|
68
|
+
with open(sp) as f:
|
|
69
|
+
st = json.load(f)
|
|
70
|
+
used = os.path.getmtime(sp)
|
|
71
|
+
except (OSError, ValueError):
|
|
72
|
+
continue
|
|
73
|
+
for r in ROLES:
|
|
74
|
+
p = st.get(r)
|
|
75
|
+
if isinstance(p, str) and os.path.isfile(p):
|
|
76
|
+
_push(d, r, p, used)
|
|
77
|
+
return d
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _push(d: dict, role: str, path: str, used: float):
|
|
81
|
+
lst = [e for e in d[role] if e.get("path") != path]
|
|
82
|
+
lst.insert(0, {"path": path, "name": os.path.basename(path), "used": used})
|
|
83
|
+
d[role] = lst[:CAP]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def record(role: str, path: str):
|
|
87
|
+
if role not in ROLES or not path:
|
|
88
|
+
return
|
|
89
|
+
d = _load()
|
|
90
|
+
_push(d, role, os.path.realpath(path), time.time())
|
|
91
|
+
_save(d)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def items(role: str, limit: int = 40) -> list:
|
|
95
|
+
"""Newest-used first, only what still exists; dead entries are dropped."""
|
|
96
|
+
if role not in ROLES:
|
|
97
|
+
return []
|
|
98
|
+
d = _load()
|
|
99
|
+
live, out = [], []
|
|
100
|
+
for e in d[role]:
|
|
101
|
+
p = e.get("path")
|
|
102
|
+
if not (isinstance(p, str) and os.path.isfile(p)):
|
|
103
|
+
continue
|
|
104
|
+
live.append(e)
|
|
105
|
+
try:
|
|
106
|
+
st = os.stat(p)
|
|
107
|
+
except OSError:
|
|
108
|
+
continue
|
|
109
|
+
out.append({"path": p, "name": e.get("name") or os.path.basename(p), "used": e.get("used", 0),
|
|
110
|
+
"mtime": st.st_mtime, "bytes": st.st_size,
|
|
111
|
+
"ext": os.path.splitext(p)[1].lower(),
|
|
112
|
+
"folder": _short_folder(os.path.dirname(p))})
|
|
113
|
+
if len(live) != len(d[role]):
|
|
114
|
+
d[role] = live
|
|
115
|
+
_save(d)
|
|
116
|
+
return out[:limit]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _short_folder(dirpath: str) -> str:
|
|
120
|
+
home = os.path.expanduser("~")
|
|
121
|
+
if dirpath.startswith(os.path.join(home, ".screengraft", "sessions")):
|
|
122
|
+
return "dropped in"
|
|
123
|
+
if dirpath.startswith(home + os.sep):
|
|
124
|
+
return "~" + dirpath[len(home):]
|
|
125
|
+
return dirpath
|
package/scripts/ui.py
CHANGED
|
@@ -50,6 +50,7 @@ import numpy as np # noqa: E402
|
|
|
50
50
|
import detect as D # noqa: E402
|
|
51
51
|
import fitfile as FF # noqa: E402
|
|
52
52
|
import fits as FIT # noqa: E402
|
|
53
|
+
import recents as RECENT # noqa: E402
|
|
53
54
|
import dof as DOF # noqa: E402
|
|
54
55
|
import grade as _grade # noqa: E402
|
|
55
56
|
import scan as S # noqa: E402
|
|
@@ -727,6 +728,25 @@ def _dof_args(b):
|
|
|
727
728
|
ROLES = ("photo", "screenshot")
|
|
728
729
|
|
|
729
730
|
|
|
731
|
+
def _thumb_for(item):
|
|
732
|
+
"""A thumbnail for a recent: scan.thumb for stills; the first frame for a clip."""
|
|
733
|
+
out_dir = os.path.join(SESSION.dir, "thumbs")
|
|
734
|
+
if item.get("ext") in VIDEO_EXT:
|
|
735
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
736
|
+
out = os.path.join(out_dir, f"{abs(hash(item['path']))}_{int(item.get('mtime') or 0)}_clip.jpg")
|
|
737
|
+
if not os.path.exists(out):
|
|
738
|
+
try:
|
|
739
|
+
fr = W.read_frame_at(item["path"], 0)
|
|
740
|
+
h, w = fr.shape[:2]
|
|
741
|
+
sc = 320 / max(h, w)
|
|
742
|
+
fr = cv2.resize(fr, (max(1, int(w * sc)), max(1, int(h * sc))), interpolation=cv2.INTER_AREA)
|
|
743
|
+
cv2.imwrite(out, fr, [cv2.IMWRITE_JPEG_QUALITY, 80])
|
|
744
|
+
except Exception:
|
|
745
|
+
return None
|
|
746
|
+
return out
|
|
747
|
+
return S.thumb(item, out_dir)
|
|
748
|
+
|
|
749
|
+
|
|
730
750
|
def _adopt(role, path):
|
|
731
751
|
"""Make a chosen source the session's, and answer what the page needs.
|
|
732
752
|
|
|
@@ -751,6 +771,7 @@ def _adopt(role, path):
|
|
|
751
771
|
im, real = _read_image(path)
|
|
752
772
|
meta = {"video": False}
|
|
753
773
|
SESSION.update(**{role: real})
|
|
774
|
+
RECENT.record(role, real)
|
|
754
775
|
if role == "photo":
|
|
755
776
|
# The quad is a property of the PHOTOGRAPH, so a fit saved on an earlier
|
|
756
777
|
# run is a better starting position than any detector -- and a stronger
|
|
@@ -956,10 +977,15 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
956
977
|
"home": HOME, "presets": PRESETS, "version": VERSION,
|
|
957
978
|
"build": BUILD})
|
|
958
979
|
if u.path == "/api/recent":
|
|
959
|
-
|
|
980
|
+
# What was USED, per role -- not what is newest in two folders.
|
|
981
|
+
# The folder scan (scan.py) still exists for the CLI and for a
|
|
982
|
+
# first run with nothing used yet; ?role= selects the list.
|
|
983
|
+
role = (q.get("role") or ["photo"])[0]
|
|
984
|
+
limit = int(q.get("limit", ["40"])[0])
|
|
985
|
+
items = RECENT.items(role, limit)
|
|
960
986
|
for it in items:
|
|
961
|
-
it["thumb"] =
|
|
962
|
-
return self._json({"items": items})
|
|
987
|
+
it["thumb"] = _thumb_for(it)
|
|
988
|
+
return self._json({"items": items, "role": role})
|
|
963
989
|
if u.path == "/file":
|
|
964
990
|
return self._file(_safe_local_path(q["path"][0]))
|
|
965
991
|
if u.path == "/api/job":
|
|
@@ -5,7 +5,7 @@ description: Injects a UI screenshot OR a screen recording onto a photographed d
|
|
|
5
5
|
|
|
6
6
|
# Inject a screenshot onto a photographed device
|
|
7
7
|
|
|
8
|
-
**What ships (v0.
|
|
8
|
+
**What ships (v0.62):** a local browser UI (`scripts/ui.py`) that walks the designer through the whole job — pick the photo and the screen source, which may be an image **or a video** (the images you have used before, drag-drop, browse, path, or a **Figma frame link**), auto-detect the screen as a starting position — and when detection cannot tell which region is a screen, **Point at screen**: one click inside it and the detector uses that point — or, if this photograph has been fitted before, **the fit it was saved with comes back** as the starting position instead of a detection, recognised by the photo's own pixels so a rename or a drag-drop still match — and every save also writes a **portable `.fit.json` beside the mockup** that can be dropped back onto the page later, which is how a fit survives a re-export, another machine, or someone else's hands — then **match the four edges** (drag an edge's middle to slide it, near an end to pivot; corners still draggable) with canvas navigation that follows the usual conventions — **hold ⌘ and scroll to zoom to the pointer, hold space and drag to pan** — and a rectified strip loupe. The fit and the composite sit **side by side and always have** — the result pane re-renders as you drag, which is how a corner gets judged, so it is the layout rather than a mode you can switch off. Then an on-by-default realism pass that colour-matches the source to the photo's light, **Save** (or **Render**, for a video) into the project folder (`--out-dir`), and a **Send to Claude** button that reaches you through the plugin's own MCP server. The UI is a hand port of the project's Figma design file — dark only.
|
|
9
9
|
|
|
10
10
|
The geometry is exact (`warp.py`); the detection is advisory (`detect.py`) and the human corrects it. **When a detection is wrong and you want to know why**, ask for the candidate list: `POST /api/detect {"trace": true}` writes `<session>/candidates.json`, or run `python3 scripts/detect.py --photo P --out-corners /tmp/c.json --trace /tmp/t.json` (add `--click X,Y`). Every candidate quad is in there with its score and whether it was accepted, rejected, never reached, or filtered out by the click — which is what separates "the screen was never proposed" from "it was proposed and something else won".
|
|
11
11
|
|
|
@@ -68,7 +68,7 @@ It prints one JSON line — `url`, `base`, `token`, `session`, `job`, `result`,
|
|
|
68
68
|
|
|
69
69
|
> **What this does** — it computes the perspective between your photo and your screenshot, so the screenshot lands on the glass exactly. Geometry, not AI: nothing is invented and your pixels are unchanged.
|
|
70
70
|
>
|
|
71
|
-
> **1 · Choose a photo, then a screenshot — or a screen recording.**
|
|
71
|
+
> **1 · Choose a photo, then a screenshot — or a screen recording.** The images you have used before are listed for you — or drag a file in, browse, paste a path, or paste a Figma frame link and I'll export it. A video source (mp4/mov/webm) works the same way; you'll pick which frame to match the edges on.
|
|
72
72
|
>
|
|
73
73
|
> **2 · Match the four edges to the screen.** Drag an edge's middle to slide it, or near an end to pivot — only that edge moves. The magnified strip below shows the boundary straightened, so aligned reads as flat. Arrow keys nudge 1px, Shift+arrow 10px, Tab moves to the next edge.
|
|
74
74
|
>
|
|
@@ -148,7 +148,8 @@ The server is a detached daemon with no window. **Stop it with `scripts/stop.sh`
|
|
|
148
148
|
| `scripts/ui.py` | Local server + browser UI; calls the two below |
|
|
149
149
|
| `scripts/detect.py` | Advisory screen-quad + corner-radius detection (no ML) |
|
|
150
150
|
| `scripts/warp.py` | The engine: `compose()` and a CLI for scripted use |
|
|
151
|
-
| `scripts/
|
|
151
|
+
| `scripts/recents.py` | The images used before, per role (`~/.screengraft/recents.json`) |
|
|
152
|
+
| `scripts/scan.py` | Desktop/Downloads scan and thumbnails (CLI; the picker no longer uses the scan) |
|
|
152
153
|
| `ui/index.html` | The page |
|
|
153
154
|
|
|
154
155
|
Design rationale and the roadmap live with the project's own notes, not in the plugin.
|
package/ui/index.html
CHANGED
|
@@ -39,6 +39,8 @@
|
|
|
39
39
|
/* surface/float is a real token (#26262b, the chip hover surface). The
|
|
40
40
|
canvas overlay is a separate thing and gets its own name. */
|
|
41
41
|
--float:#26262b;
|
|
42
|
+
/* --float at 86%: the popover's glass, over a background blur. */
|
|
43
|
+
--float-glass:rgba(38,38,43,.86);
|
|
42
44
|
--overlay:rgba(17,17,18,.98);
|
|
43
45
|
--ink:#e9ecf1; --mute:#8f939a; --faint:#8f939ab2;
|
|
44
46
|
--line:#1d1e20; --edge:#3a3b41; --edge-mid:#494a50; --edge-hi:#6d6f79;
|
|
@@ -1006,8 +1008,13 @@
|
|
|
1006
1008
|
opacity:0;visibility:hidden;
|
|
1007
1009
|
transition:opacity var(--t-pop), visibility 0s linear var(--t-pop)}
|
|
1008
1010
|
.scrim.on{opacity:1;visibility:visible;transition:opacity var(--t-pop), visibility 0s}
|
|
1011
|
+
/* surface/float at 86% over a background blur. Figma's blur radius is
|
|
1012
|
+
about twice CSS's -- a Figma background blur of 10 is blur(5px) here
|
|
1013
|
+
(14 Sep 2026, on request). Enough shows through to read as glass; the
|
|
1014
|
+
stroke stays a hairline. */
|
|
1009
1015
|
.pop{position:fixed;z-index:51;width:640px;max-width:calc(100vw - 24px);
|
|
1010
|
-
max-height:78vh;overflow:auto;background:var(--float);border:
|
|
1016
|
+
max-height:78vh;overflow:auto;background:var(--float-glass);border:var(--bw) solid var(--edge);
|
|
1017
|
+
backdrop-filter:blur(5px);-webkit-backdrop-filter:blur(5px);
|
|
1011
1018
|
border-radius:var(--r-lg);box-shadow:var(--shadow), var(--hi);padding:16px;
|
|
1012
1019
|
opacity:0;visibility:hidden;transform:translateY(6px) scale(.985);
|
|
1013
1020
|
transform-origin:50% 0;
|
|
@@ -1020,28 +1027,50 @@
|
|
|
1020
1027
|
}
|
|
1021
1028
|
.pop h3{margin:0 0 4px;font-size:14px;font-weight:600}
|
|
1022
1029
|
.pop .sub{color:var(--mute);font-size:12px;margin:0 0 16px;line-height:1.5}
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1030
|
+
/* The preview slot is as tall as the list beside it (stretch, not start),
|
|
1031
|
+
and the list has a floor so an empty or short list still leaves a slot
|
|
1032
|
+
worth previewing into. 14 Sep 2026, on request. */
|
|
1033
|
+
.picker{display:grid;grid-template-columns:200px minmax(0,1fr);gap:12px;align-items:stretch}
|
|
1034
|
+
.rail2{display:flex;flex-direction:column;gap:2px;min-height:240px;max-height:300px;overflow:auto;padding:2px}
|
|
1035
|
+
/* A list item at rest is the picture and its name on the popover's own
|
|
1036
|
+
surface -- no fill, no stroke. Hover raises it; the chosen one is the
|
|
1037
|
+
raised neutral every selected thing on this page uses (not the accent:
|
|
1038
|
+
the accent is for the next action). */
|
|
1039
|
+
.th{display:flex;align-items:center;gap:10px;padding:4px 6px;border-radius:var(--r-sm);cursor:pointer;
|
|
1040
|
+
border:var(--bw) solid transparent;background:transparent;min-width:0;
|
|
1027
1041
|
transition:var(--t-all)}
|
|
1028
|
-
.th:hover{
|
|
1029
|
-
.th img{width:40px;height:40px;object-fit:cover;border-radius:
|
|
1030
|
-
.th .cap{
|
|
1031
|
-
.th.
|
|
1042
|
+
.th:hover{background:var(--raise);border-color:var(--edge)}
|
|
1043
|
+
.th img{width:40px;height:40px;object-fit:cover;border-radius:4px;flex:none;background:var(--sunk)}
|
|
1044
|
+
.th .cap{min-width:0;display:grid;gap:1px}
|
|
1045
|
+
.th .nm{font-size:12px;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
1046
|
+
.th .when{font-size:11px;color:var(--mute);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
1047
|
+
.th.sel{background:var(--raise-hi);border-color:var(--edge-hi)}
|
|
1032
1048
|
/* The preview frame sits INSIDE the popover, so --well (#0a0a0b, the canvas
|
|
1033
1049
|
tone) read as a hole punched in it. --sunk is the system's recessed surface:
|
|
1034
1050
|
lighter than the well, still clearly below --float, and it matches the
|
|
1035
1051
|
thumbnail rail beside it. The stroke moves to --edge so the frame still has
|
|
1036
1052
|
an edge now that its fill is closer to the surface behind it. */
|
|
1037
|
-
|
|
1038
|
-
|
|
1053
|
+
/* No stroke (14 Sep 2026): the recessed surface alone marks the slot. */
|
|
1054
|
+
.pv{border-radius:var(--r-md);background:var(--sunk);min-height:0;
|
|
1055
|
+
display:grid;place-items:center;overflow:hidden}
|
|
1039
1056
|
.pv img{max-width:100%;max-height:300px;object-fit:contain;display:block}
|
|
1040
|
-
|
|
1057
|
+
/* The drop zone's dashed rule is drawn, not bordered: CSS `dashed` fixes the
|
|
1058
|
+
dash at ~3× the stroke and cannot be tuned. A masked pseudo-element paints
|
|
1059
|
+
a token colour through an SVG rounded rect with a 5px dash / 4px gap
|
|
1060
|
+
(2 longer / 1 longer than the browser's, on request, 14 Sep 2026) and a
|
|
1061
|
+
1px stroke centred on the box edge, so half a pixel shows inside -- the
|
|
1062
|
+
same hairline weight as every other stroke -- in the lighter edge tone. */
|
|
1063
|
+
.drop{position:relative;border:0;border-radius:var(--r-md);padding:30px 14px;text-align:center;
|
|
1041
1064
|
color:var(--mute);cursor:pointer;margin-top:12px;font-size:12px;
|
|
1042
1065
|
transition:var(--t-all)}
|
|
1043
|
-
.drop:
|
|
1044
|
-
|
|
1066
|
+
.drop::before{content:"";position:absolute;inset:0;border-radius:inherit;pointer-events:none;
|
|
1067
|
+
background:var(--edge-mid);transition:background-color var(--t);
|
|
1068
|
+
-webkit-mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='100%25' height='100%25' rx='8' fill='none' stroke='%23000' stroke-width='1' stroke-dasharray='5 4'/%3E%3C/svg%3E") 0 0/100% 100% no-repeat;
|
|
1069
|
+
mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='100%25' height='100%25' rx='8' fill='none' stroke='%23000' stroke-width='1' stroke-dasharray='5 4'/%3E%3C/svg%3E") 0 0/100% 100% no-repeat}
|
|
1070
|
+
.drop:hover{color:var(--ink)}
|
|
1071
|
+
.drop:hover::before{background:var(--edge-hi)}
|
|
1072
|
+
.drop.over{color:var(--ink);background:var(--acc-soft)}
|
|
1073
|
+
.drop.over::before{background:var(--acc-stroke)}
|
|
1045
1074
|
.sm{font-size:12px}
|
|
1046
1075
|
code{font:12px ui-monospace,Menlo,monospace;background:var(--sunk);padding:2px 6px;
|
|
1047
1076
|
border-radius:var(--r-sm);box-shadow:inset 0 0 0 1px var(--line)}
|
|
@@ -1313,7 +1342,7 @@
|
|
|
1313
1342
|
|
|
1314
1343
|
<div class="pop" id="pop1">
|
|
1315
1344
|
<h3>Photo of the device</h3>
|
|
1316
|
-
<p class="sub">
|
|
1345
|
+
<p class="sub">Photos you have used before — click one. Or browse, drop a file, or paste a path.</p>
|
|
1317
1346
|
<div class="picker">
|
|
1318
1347
|
<div class="rail2" id="recent1"></div>
|
|
1319
1348
|
<div class="pv" id="pv1"><span class="empty">Select a recent image</span></div>
|
|
@@ -1693,17 +1722,29 @@ addEventListener('drop', e => {
|
|
|
1693
1722
|
}, true);
|
|
1694
1723
|
|
|
1695
1724
|
/* ===== pickers (popovers over the canvas) ===== */
|
|
1725
|
+
// "Used 3 h ago", "used yesterday" -- when a source was last chosen.
|
|
1726
|
+
function agoText(t){
|
|
1727
|
+
const s = Math.max(0, Date.now()/1000 - t);
|
|
1728
|
+
if (s < 90) return 'just now';
|
|
1729
|
+
if (s < 3600) return Math.round(s/60) + ' min ago';
|
|
1730
|
+
if (s < 86400) return Math.round(s/3600) + ' h ago';
|
|
1731
|
+
if (s < 172800) return 'yesterday';
|
|
1732
|
+
return Math.round(s/86400) + ' days ago';
|
|
1733
|
+
}
|
|
1734
|
+
const esc = t => String(t).replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
|
1696
1735
|
async function loadRecent(){
|
|
1697
|
-
|
|
1698
|
-
|
|
1736
|
+
// What was USED before, per role -- the photo list and the screenshot list
|
|
1737
|
+
// are different populations (a device photo is never a screenshot).
|
|
1738
|
+
for (const [id, role] of [['recent1','photo'],['recent2','screenshot']]){
|
|
1739
|
+
const {items} = await api('/api/recent?role=' + role + '&limit=40');
|
|
1699
1740
|
const g = $('#'+id); g.innerHTML='';
|
|
1700
1741
|
for (const it of items){
|
|
1701
1742
|
const d = document.createElement('div'); d.className='th'; d.title = it.path;
|
|
1702
|
-
d.innerHTML = `<img src="${it.thumb ? fileURL(it.thumb) : ''}" alt=""><div class="cap">${it.name}</div>`;
|
|
1703
|
-
d.onclick = () => choose(
|
|
1743
|
+
d.innerHTML = `<img src="${it.thumb ? fileURL(it.thumb) : ''}" alt=""><div class="cap"><div class="nm">${esc(it.name)}</div><div class="when">${agoText(it.used)} · ${esc(it.folder)}</div></div>`;
|
|
1744
|
+
d.onclick = () => choose(role, it.path, d);
|
|
1704
1745
|
g.appendChild(d);
|
|
1705
1746
|
}
|
|
1706
|
-
if (!items.length) g.innerHTML = '<span class="sm" style="color:var(--mute)">
|
|
1747
|
+
if (!items.length) g.innerHTML = '<span class="sm" style="color:var(--mute);padding:6px">Nothing used yet — drop a file below, browse, or paste a path.</span>';
|
|
1707
1748
|
}
|
|
1708
1749
|
}
|
|
1709
1750
|
/* Un-choose a source. The mirror of setChosen(): every visual the fill put up
|
|
@@ -1828,8 +1869,11 @@ for (const [n, role] of [[1,'photo'],[2,'screenshot']]){
|
|
|
1828
1869
|
// popover. Same upload path as the popover's drop zone.
|
|
1829
1870
|
const chip = $('#chip'+n);
|
|
1830
1871
|
chip.ondragover = e => { if ([...(e.dataTransfer?.items || [])].some(i => i.kind === 'file')) { e.preventDefault(); chip.classList.add('over'); } };
|
|
1831
|
-
chip
|
|
1832
|
-
|
|
1872
|
+
// Leaving with a drag only un-lights the chip if its popover is not the
|
|
1873
|
+
// reason it is lit.
|
|
1874
|
+
const popOpen = () => $('#pop'+n).classList.contains('on');
|
|
1875
|
+
chip.ondragleave = () => { if (!popOpen()) chip.classList.remove('over'); };
|
|
1876
|
+
chip.ondrop = e => { e.preventDefault(); if (!popOpen()) chip.classList.remove('over'); const f = e.dataTransfer.files[0]; if (f) upload(role, f); };
|
|
1833
1877
|
}
|
|
1834
1878
|
/* One popover mechanic for every popover: anchor it under whatever opened it,
|
|
1835
1879
|
clamped into the viewport. The pickers and the preview both use it. */
|
|
@@ -1840,10 +1884,16 @@ function openPopAt(pop, anchor){
|
|
|
1840
1884
|
pop.style.left = Math.max(12, Math.min(window.innerWidth - w - 12, anchor.left)) + 'px';
|
|
1841
1885
|
pop.style.top = (anchor.bottom + 8) + 'px';
|
|
1842
1886
|
}
|
|
1843
|
-
|
|
1887
|
+
// The chip whose popover is open wears the Input chip's ACTIVE state (the
|
|
1888
|
+
// same one a file dragged over it shows): the popover is about that chip.
|
|
1889
|
+
function openPop(n){
|
|
1890
|
+
openPopAt($('#pop'+n), $('#chip'+n).getBoundingClientRect());
|
|
1891
|
+
$('#chip'+n).classList.add('over');
|
|
1892
|
+
}
|
|
1844
1893
|
function closePop(){
|
|
1845
1894
|
document.querySelectorAll('.pop').forEach(p=>p.classList.remove('on'));
|
|
1846
1895
|
$('#scrim').classList.remove('on');
|
|
1896
|
+
document.querySelectorAll('.inputchip.over').forEach(c => c.classList.remove('over'));
|
|
1847
1897
|
}
|
|
1848
1898
|
$('#scrim').onclick = closePop;
|
|
1849
1899
|
|
|
@@ -3056,10 +3106,22 @@ function swingText(mode){
|
|
|
3056
3106
|
the strip (H / 2·stripHalf, so the ± buttons work here too), centred on the
|
|
3057
3107
|
corner, unrotated, with both edges drawn through it and the ring at the
|
|
3058
3108
|
centre. Requested 13 Sep 2026; edges keep the strip as it is. */
|
|
3109
|
+
// The corner view's own scale. A corner is an ARC, not a point: it starts a
|
|
3110
|
+
// radius away from where the two edges would meet, on each edge. At the edge
|
|
3111
|
+
// strip's magnification (±14px) a rounded corner showed only the bezel's
|
|
3112
|
+
// blank curve and neither edge, so there was nothing to judge (14 Sep 2026).
|
|
3113
|
+
// The window covers 2.5 radii on each side -- the whole arc plus enough straight
|
|
3114
|
+
// edge to see it land -- never tighter than the strip. The radius in photo px
|
|
3115
|
+
// is the radius fraction (of the screenshot's width) times the quad's top edge.
|
|
3116
|
+
function cornerHalf(){
|
|
3117
|
+
const C = st.corners; if (!C) return stripHalf;
|
|
3118
|
+
const rPhoto = (typeof radiusValue === 'function' ? radiusValue() : 0) * vlen(sub(C[1], C[0]));
|
|
3119
|
+
return Math.max(stripHalf, 1.25 * rPhoto, 24);
|
|
3120
|
+
}
|
|
3059
3121
|
function paintCorner(c, W, H, i){
|
|
3060
3122
|
c.setTransform(1,0,0,1,0,0);
|
|
3061
3123
|
c.fillStyle = '#0c0c0b'; c.fillRect(0,0,W,H);
|
|
3062
|
-
const P = st.corners[i], M = H / (2*
|
|
3124
|
+
const P = st.corners[i], M = H / (2*cornerHalf());
|
|
3063
3125
|
c.save();
|
|
3064
3126
|
c.setTransform(M, 0, 0, M, W/2 - P[0]*M, H/2 - P[1]*M);
|
|
3065
3127
|
c.imageSmoothingEnabled = true;
|
|
@@ -3093,7 +3155,7 @@ function drawStrip(){
|
|
|
3093
3155
|
}
|
|
3094
3156
|
if (corner){
|
|
3095
3157
|
paintCorner(sctx, W, H, a.i);
|
|
3096
|
-
$('#stripSt').textContent = `${LBL[a.i]} corner · ${(H/(2*
|
|
3158
|
+
$('#stripSt').textContent = `${LBL[a.i]} corner · ${(H/(2*cornerHalf())).toFixed(1)}× — the whole arc, and where each edge lands on it`;
|
|
3097
3159
|
} else {
|
|
3098
3160
|
paintStrip(sctx, W, H, a.i, a.mode);
|
|
3099
3161
|
$('#stripSt').textContent = `${EDGE_LBL[a.i]} edge · ±${stripHalf}px · ${(H/(2*stripHalf)).toFixed(1)}× across the boundary — ${swingText(a.mode)}`;
|
|
@@ -3103,7 +3165,7 @@ function drawStrip(){
|
|
|
3103
3165
|
if (!has){ fl.classList.remove('on'); return; }
|
|
3104
3166
|
if (corner){
|
|
3105
3167
|
paintCorner(fctx, stripF.width, stripF.height, a.i);
|
|
3106
|
-
$('#stripFCap').textContent = `${LBL[a.i]} corner · ${(stripF.height/(2*
|
|
3168
|
+
$('#stripFCap').textContent = `${LBL[a.i]} corner · ${(stripF.height/(2*cornerHalf())).toFixed(1)}× — the whole arc, and where each edge lands on it`;
|
|
3107
3169
|
} else {
|
|
3108
3170
|
paintStrip(fctx, stripF.width, stripF.height, a.i, a.mode);
|
|
3109
3171
|
$('#stripFCap').textContent = `${EDGE_LBL[a.i]} edge · ±${stripHalf}px — ${swingText(a.mode)}`;
|