@myrtilleio/redline 0.1.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/README.md +281 -0
- package/bin/redline.js +375 -0
- package/client/boot.js +146 -0
- package/client/check.js +926 -0
- package/client/composer.js +384 -0
- package/client/core.js +1610 -0
- package/client/devices.js +273 -0
- package/client/edits.js +389 -0
- package/client/keyboard.js +110 -0
- package/client/panel.js +222 -0
- package/client/pen.js +324 -0
- package/client/references.js +114 -0
- package/client/select.js +180 -0
- package/client/shots.js +357 -0
- package/client/variants.js +117 -0
- package/examples/demo-site/hero.svg +18 -0
- package/examples/demo-site/index.html +96 -0
- package/examples/demo-site/styles.css +64 -0
- package/integrations/claude-code/redline.md +27 -0
- package/package.json +42 -0
- package/src/account.js +101 -0
- package/src/bundle.js +33 -0
- package/src/demo.js +137 -0
- package/src/format.js +346 -0
- package/src/guards.js +108 -0
- package/src/inject.js +15 -0
- package/src/mcp.js +380 -0
- package/src/report.js +199 -0
- package/src/server.js +477 -0
- package/src/shots.js +311 -0
- package/src/store.js +544 -0
- package/src/sync.js +126 -0
- package/src/taste.js +80 -0
- package/src/verify.js +71 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// ---------------------------------------------------------------- references
|
|
2
|
+
|
|
3
|
+
// "Make it feel like this": the reviewer pastes, drops or picks an image (a
|
|
4
|
+
// screenshot of a site they like, a sketch) and attaches it to a note.
|
|
5
|
+
|
|
6
|
+
const MAX_REF_SIDE = 1400;
|
|
7
|
+
|
|
8
|
+
function assetSrc(ref) {
|
|
9
|
+
if (!ref) return '';
|
|
10
|
+
if (ref.data) return ref.data;
|
|
11
|
+
return withKey((SERVER || '') + '/__redline/files/' + ref.path);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Downscale and re-encode in the browser so big screenshots stay small.
|
|
15
|
+
async function imageToDataUrl(blob) {
|
|
16
|
+
const bitmap = await createImageBitmap(blob);
|
|
17
|
+
const scale = Math.min(1, MAX_REF_SIDE / Math.max(bitmap.width, bitmap.height));
|
|
18
|
+
const canvas = document.createElement('canvas');
|
|
19
|
+
canvas.width = Math.round(bitmap.width * scale);
|
|
20
|
+
canvas.height = Math.round(bitmap.height * scale);
|
|
21
|
+
canvas.getContext('2d').drawImage(bitmap, 0, 0, canvas.width, canvas.height);
|
|
22
|
+
const webp = canvas.toDataURL('image/webp', 0.85);
|
|
23
|
+
return webp.startsWith('data:image/webp') ? webp : canvas.toDataURL('image/jpeg', 0.85);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function uploadReference(blob, name) {
|
|
27
|
+
const dataUrl = await imageToDataUrl(blob);
|
|
28
|
+
if (!store.remote) return { data: dataUrl, name };
|
|
29
|
+
const res = await fetch(SERVER + '/__redline/api/assets', {
|
|
30
|
+
method: 'POST',
|
|
31
|
+
headers: { 'content-type': 'application/json', ...keyHeader() },
|
|
32
|
+
body: JSON.stringify({ dataUrl }),
|
|
33
|
+
});
|
|
34
|
+
if (!res.ok) throw new Error(`upload failed (${res.status})`);
|
|
35
|
+
return { path: (await res.json()).path, name };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function imagesFrom(dataTransfer) {
|
|
39
|
+
return [...(dataTransfer ? dataTransfer.files || [] : [])].filter((f) => /^image\//.test(f.type));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The references row of the composer: thumbnails plus an attach button. Also
|
|
44
|
+
* accepts images pasted or dropped anywhere on `form`. onChange(list) fires
|
|
45
|
+
* whenever the list changes.
|
|
46
|
+
*/
|
|
47
|
+
function createReferences(form, initial, onChange) {
|
|
48
|
+
let refs = (initial || []).slice();
|
|
49
|
+
const row = h('div', { class: 'refs' });
|
|
50
|
+
const picker = h('input', { type: 'file', accept: 'image/*', multiple: true, hidden: true });
|
|
51
|
+
|
|
52
|
+
async function add(files) {
|
|
53
|
+
for (const file of files.slice(0, 6 - refs.length)) {
|
|
54
|
+
try {
|
|
55
|
+
refs.push(await uploadReference(file, file.name || 'pasted image'));
|
|
56
|
+
} catch (err) {
|
|
57
|
+
toast('Could not attach image: ' + err.message);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
render();
|
|
61
|
+
onChange(refs);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function render() {
|
|
65
|
+
fill(row,
|
|
66
|
+
...refs.map((r, i) =>
|
|
67
|
+
h('span', { class: 'ref' },
|
|
68
|
+
h('img', { src: assetSrc(r), alt: r.name || 'reference', title: r.name || 'reference', onclick: () => openGallery(refs.map((x) => ({ src: assetSrc(x), label: x.name || 'Reference' })), i) }),
|
|
69
|
+
h('button', {
|
|
70
|
+
type: 'button',
|
|
71
|
+
class: 'ref-x',
|
|
72
|
+
'aria-label': 'Remove reference',
|
|
73
|
+
onclick: () => {
|
|
74
|
+
refs.splice(i, 1);
|
|
75
|
+
render();
|
|
76
|
+
onChange(refs);
|
|
77
|
+
},
|
|
78
|
+
}, '×'),
|
|
79
|
+
),
|
|
80
|
+
),
|
|
81
|
+
refs.length < 6
|
|
82
|
+
? h('button', { type: 'button', class: 'btn small', title: 'Attach an image: a site you like, a sketch… (or paste / drop one here)', onclick: () => picker.click(), html: ICONS.image + ' Reference' })
|
|
83
|
+
: null,
|
|
84
|
+
picker,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
picker.addEventListener('change', () => add([...picker.files]));
|
|
89
|
+
form.addEventListener('paste', (e) => {
|
|
90
|
+
const files = imagesFrom(e.clipboardData);
|
|
91
|
+
if (files.length) {
|
|
92
|
+
e.preventDefault();
|
|
93
|
+
add(files);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
form.addEventListener('dragover', (e) => {
|
|
97
|
+
if ([...(e.dataTransfer.types || [])].includes('Files')) {
|
|
98
|
+
e.preventDefault();
|
|
99
|
+
form.classList.add('dropping');
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
form.addEventListener('dragleave', () => form.classList.remove('dropping'));
|
|
103
|
+
form.addEventListener('drop', (e) => {
|
|
104
|
+
form.classList.remove('dropping');
|
|
105
|
+
const files = imagesFrom(e.dataTransfer);
|
|
106
|
+
if (files.length) {
|
|
107
|
+
e.preventDefault();
|
|
108
|
+
add(files);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
render();
|
|
113
|
+
return { node: row, list: () => refs };
|
|
114
|
+
}
|
package/client/select.js
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// ---------------------------------------------------------------- select mode
|
|
2
|
+
|
|
3
|
+
// The page element under a point, looking through the overlay itself.
|
|
4
|
+
// elementsFromPoint also returns iframes, so embeds and consent frames can be picked.
|
|
5
|
+
function pageElementAt(x, y) {
|
|
6
|
+
const el = document.elementsFromPoint(x, y).find((n) => !isOurs(n));
|
|
7
|
+
return el === document.documentElement ? document.body : el || null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function showHover(el) {
|
|
11
|
+
state.hoverEl = el;
|
|
12
|
+
if (!el) {
|
|
13
|
+
hoverBox.style.display = 'none';
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
const r = el.getBoundingClientRect();
|
|
17
|
+
placeBox(hoverBox, r);
|
|
18
|
+
const tag = hoverBox.firstChild;
|
|
19
|
+
tag.replaceChildren(`${label(el)} · ${Math.round(r.width)}×${Math.round(r.height)} `, h('kbd', {}, el.parentElement && el.parentElement !== document.documentElement ? '↑ parent' : ''));
|
|
20
|
+
tag.classList.toggle('below', r.top < 28);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// In select mode a transparent surface covers the page, so clicks pick elements
|
|
24
|
+
// instead of following links, and no page script or iframe can intercept them.
|
|
25
|
+
pick.addEventListener('pointermove', (e) => {
|
|
26
|
+
if (state.composer || (boxDrag && boxDrag.moved)) return;
|
|
27
|
+
// Compare against the element under the pointer, not the highlighted one, so an
|
|
28
|
+
// ↑-selected parent stays selected while the mouse moves within the same child.
|
|
29
|
+
const el = pageElementAt(e.clientX, e.clientY);
|
|
30
|
+
if (el !== state.hoverStack[state.hoverStack.length - 1]) {
|
|
31
|
+
state.hoverStack = el ? [el] : [];
|
|
32
|
+
showHover(el);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
pick.addEventListener('pointerleave', () => !state.composer && !boxDrag && showHover(null));
|
|
36
|
+
pick.addEventListener('contextmenu', (e) => e.preventDefault());
|
|
37
|
+
|
|
38
|
+
// Box tool: drag to draw a box around any area; a plain click boxes the element
|
|
39
|
+
// under the pointer (↑ first to take its parent). A drawn box that roughly matches
|
|
40
|
+
// an element snaps to it; otherwise it stays a free region inside its container.
|
|
41
|
+
const boxDraft = h('div', { class: 'box-draft' });
|
|
42
|
+
layer.append(boxDraft);
|
|
43
|
+
let boxDrag = null; // { x, y, moved }
|
|
44
|
+
let swallowClick = false;
|
|
45
|
+
|
|
46
|
+
pick.addEventListener('pointerdown', (e) => {
|
|
47
|
+
if (e.button > 0 || state.composer) return;
|
|
48
|
+
pick.setPointerCapture(e.pointerId);
|
|
49
|
+
boxDrag = { x: e.clientX, y: e.clientY, moved: false };
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
pick.addEventListener('pointermove', (e) => {
|
|
53
|
+
if (!boxDrag) return;
|
|
54
|
+
const w = e.clientX - boxDrag.x, hgt = e.clientY - boxDrag.y;
|
|
55
|
+
if (!boxDrag.moved && Math.hypot(w, hgt) < 6) return;
|
|
56
|
+
boxDrag.moved = true;
|
|
57
|
+
showHover(null);
|
|
58
|
+
const c = COLORS[state.verdict];
|
|
59
|
+
Object.assign(boxDraft.style, {
|
|
60
|
+
display: 'block',
|
|
61
|
+
left: Math.min(boxDrag.x, e.clientX) + 'px',
|
|
62
|
+
top: Math.min(boxDrag.y, e.clientY) + 'px',
|
|
63
|
+
width: Math.abs(w) + 'px',
|
|
64
|
+
height: Math.abs(hgt) + 'px',
|
|
65
|
+
borderColor: c,
|
|
66
|
+
background: c + '1f',
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
pick.addEventListener('pointerup', (e) => {
|
|
71
|
+
const drag = boxDrag;
|
|
72
|
+
boxDrag = null;
|
|
73
|
+
boxDraft.style.display = 'none';
|
|
74
|
+
if (!drag || !drag.moved) return; // a plain click: handled by 'click'
|
|
75
|
+
swallowClick = true;
|
|
76
|
+
const rect = { x: Math.min(drag.x, e.clientX), y: Math.min(drag.y, e.clientY), w: Math.abs(e.clientX - drag.x), h: Math.abs(e.clientY - drag.y) };
|
|
77
|
+
if (rect.w < 8 || rect.h < 8) return;
|
|
78
|
+
const { el, region } = boxTarget(rect);
|
|
79
|
+
if (state.retarget) return retargetTo(el, { dx: 0, dy: 0 });
|
|
80
|
+
openComposer({ el, draft: { kind: 'note', anchor: { dx: 0, dy: 0 }, region }, point: { x: rect.x + rect.w, y: rect.y } });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// Which element a drawn box is about. Returns a region (relative to the element,
|
|
84
|
+
// in px) only when the box is a part of it rather than the element itself.
|
|
85
|
+
function boxTarget(rect) {
|
|
86
|
+
const area = (r) => Math.max(0, r.width) * Math.max(0, r.height);
|
|
87
|
+
const inter = (r) => Math.max(0, Math.min(r.right, rect.x + rect.w) - Math.max(r.left, rect.x)) * Math.max(0, Math.min(r.bottom, rect.y + rect.h) - Math.max(r.top, rect.y));
|
|
88
|
+
const seen = new Set();
|
|
89
|
+
for (let i = 1; i <= 4; i++) {
|
|
90
|
+
for (let j = 1; j <= 4; j++) {
|
|
91
|
+
for (let el = pageElementAt(rect.x + (rect.w * i) / 5, rect.y + (rect.h * j) / 5); el && el !== document.documentElement; el = el.parentElement) seen.add(el);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
let best = null, bestIoU = 0;
|
|
95
|
+
for (const el of seen) {
|
|
96
|
+
const r = el.getBoundingClientRect();
|
|
97
|
+
const i = inter(r);
|
|
98
|
+
const iou = i / (area(r) + rect.w * rect.h - i || 1);
|
|
99
|
+
if (iou > bestIoU) {
|
|
100
|
+
best = el;
|
|
101
|
+
bestIoU = iou;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (best && bestIoU >= 0.6) return { el: best, region: null };
|
|
105
|
+
// A part of something: anchor to the smallest element that contains the box.
|
|
106
|
+
let el = pageElementAt(rect.x + rect.w / 2, rect.y + rect.h / 2) || document.body;
|
|
107
|
+
while (el.parentElement && el !== document.body) {
|
|
108
|
+
const r = el.getBoundingClientRect();
|
|
109
|
+
if (inter(r) >= rect.w * rect.h * 0.9) break;
|
|
110
|
+
el = el.parentElement;
|
|
111
|
+
}
|
|
112
|
+
const r = el.getBoundingClientRect();
|
|
113
|
+
return { el, region: { x: fmt(rect.x - r.left), y: fmt(rect.y - r.top), w: fmt(rect.w), h: fmt(rect.h) } };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
pick.addEventListener('click', (e) => {
|
|
117
|
+
if (swallowClick) {
|
|
118
|
+
swallowClick = false;
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (state.composer) return closeComposer();
|
|
122
|
+
const el = state.hoverEl || pageElementAt(e.clientX, e.clientY);
|
|
123
|
+
if (!el) return;
|
|
124
|
+
const r = el.getBoundingClientRect();
|
|
125
|
+
const anchor = { dx: r.width ? (e.clientX - r.left) / r.width : 0, dy: r.height ? (e.clientY - r.top) / r.height : 0 };
|
|
126
|
+
if (state.retarget) return retargetTo(el, anchor);
|
|
127
|
+
openComposer({
|
|
128
|
+
el,
|
|
129
|
+
draft: { kind: 'note', anchor },
|
|
130
|
+
point: { x: e.clientX, y: e.clientY },
|
|
131
|
+
});
|
|
132
|
+
hoverBox.style.display = 'none';
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// "Move to…" in the composer: the next element picked becomes the note's target.
|
|
136
|
+
function startRetarget(a) {
|
|
137
|
+
closeComposer();
|
|
138
|
+
state.retarget = a;
|
|
139
|
+
setMode('select');
|
|
140
|
+
toast(`Click the element #${a.n} should point at (Esc cancels)`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function retargetTo(el, anchor) {
|
|
144
|
+
const a = state.retarget;
|
|
145
|
+
state.retarget = null;
|
|
146
|
+
try {
|
|
147
|
+
const saved = await store.update(a.id, { target: describeEl(el), anchor, ...captureContext(el) });
|
|
148
|
+
await load();
|
|
149
|
+
openComposer({ annotation: saved, el });
|
|
150
|
+
toast(`#${a.n} now points at ${label(el)}`);
|
|
151
|
+
} catch (err) {
|
|
152
|
+
toast('Could not move note: ' + err.message);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// The surfaces sit above the page, so scroll whatever scrollable element is underneath by hand.
|
|
157
|
+
function scrollableAt(x, y, dx, dy) {
|
|
158
|
+
for (let el = pageElementAt(x, y); el && el !== document.body && el !== document.documentElement; el = el.parentElement) {
|
|
159
|
+
const cs = getComputedStyle(el);
|
|
160
|
+
const canY = dy && /(auto|scroll|overlay)/.test(cs.overflowY) && el.scrollHeight > el.clientHeight &&
|
|
161
|
+
(dy > 0 ? el.scrollTop + el.clientHeight < el.scrollHeight - 1 : el.scrollTop > 0);
|
|
162
|
+
const canX = dx && /(auto|scroll|overlay)/.test(cs.overflowX) && el.scrollWidth > el.clientWidth &&
|
|
163
|
+
(dx > 0 ? el.scrollLeft + el.clientWidth < el.scrollWidth - 1 : el.scrollLeft > 0);
|
|
164
|
+
if (canY || canX) return el;
|
|
165
|
+
}
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
for (const surface of [pick, ink]) {
|
|
169
|
+
surface.addEventListener(
|
|
170
|
+
'wheel',
|
|
171
|
+
(e) => {
|
|
172
|
+
const el = scrollableAt(e.clientX, e.clientY, e.deltaX, e.deltaY);
|
|
173
|
+
if (el) {
|
|
174
|
+
e.preventDefault();
|
|
175
|
+
el.scrollBy(e.deltaX, e.deltaY);
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
{ passive: false },
|
|
179
|
+
);
|
|
180
|
+
}
|
package/client/shots.js
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
// ---------------------------------------------------------------- screenshots
|
|
2
|
+
|
|
3
|
+
// Two halves. Shot mode: when the Redline server loads a page in headless Chrome
|
|
4
|
+
// to photograph an item, the overlay restores the item's page state, shows just
|
|
5
|
+
// what that shot is about (the mark, the proposed edit, an option or the result)
|
|
6
|
+
// and reports the area to crop. Gallery: the reviewer sees those shots, with a
|
|
7
|
+
// before/after slider, in the composer and the Notes panel.
|
|
8
|
+
|
|
9
|
+
const SHOT = window.__REDLINE_SHOT__ || null; // { id, key } when photographed
|
|
10
|
+
|
|
11
|
+
const settle = () =>
|
|
12
|
+
new Promise((ok) => requestAnimationFrame(() => requestAnimationFrame(() => setTimeout(ok, 250))));
|
|
13
|
+
|
|
14
|
+
// Switch the controls recorded with the item (tabs, toggles, menus) back to how they were.
|
|
15
|
+
async function replayState(list) {
|
|
16
|
+
for (const { selector, state: want } of list || []) {
|
|
17
|
+
const el = queryOne(selector);
|
|
18
|
+
if (el && stateNow(el) !== want) {
|
|
19
|
+
el.click();
|
|
20
|
+
await settle();
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Photographs must not depend on the moment they're taken: animations and
|
|
26
|
+
// transitions jump to where they end, running ones stop, videos pause.
|
|
27
|
+
function freezeMotion() {
|
|
28
|
+
try {
|
|
29
|
+
const sheet = new CSSStyleSheet();
|
|
30
|
+
sheet.replaceSync('*, *::before, *::after { animation-duration: 0s !important; animation-delay: 0s !important; animation-iteration-count: 1 !important;' +
|
|
31
|
+
' transition: none !important; caret-color: transparent !important; scroll-behavior: auto !important; }');
|
|
32
|
+
document.adoptedStyleSheets = [...document.adoptedStyleSheets, sheet];
|
|
33
|
+
} catch {}
|
|
34
|
+
for (const anim of document.getAnimations ? document.getAnimations() : []) {
|
|
35
|
+
try {
|
|
36
|
+
anim.finish();
|
|
37
|
+
} catch {
|
|
38
|
+
anim.pause(); // endless: stop it where it starts
|
|
39
|
+
anim.currentTime = 0;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
for (const v of document.querySelectorAll('video')) v.pause();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Images near what's photographed, loaded and decoded (lazy ones too), within reason.
|
|
46
|
+
async function imagesReady(el) {
|
|
47
|
+
const imgs = [...(el || document).querySelectorAll('img')].slice(0, 40);
|
|
48
|
+
for (const img of imgs) if (img.loading === 'lazy') img.loading = 'eager';
|
|
49
|
+
const ready = (img) => {
|
|
50
|
+
if (img.complete) return img.decode().catch(() => {});
|
|
51
|
+
return new Promise((ok) => {
|
|
52
|
+
img.addEventListener('load', ok, { once: true });
|
|
53
|
+
img.addEventListener('error', ok, { once: true });
|
|
54
|
+
});
|
|
55
|
+
};
|
|
56
|
+
await Promise.race([Promise.all(imgs.map(ready)), new Promise((ok) => setTimeout(ok, 3000))]);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function runShot({ id, key }) {
|
|
60
|
+
let done;
|
|
61
|
+
window.__redlineShotReady = new Promise((ok) => (done = ok));
|
|
62
|
+
host.dataset.shot = '';
|
|
63
|
+
try {
|
|
64
|
+
if (document.fonts) await document.fonts.ready;
|
|
65
|
+
freezeMotion();
|
|
66
|
+
const a = (await store.all()).find((x) => x.id === id);
|
|
67
|
+
if (!a) return done(null);
|
|
68
|
+
await replayState(a.uiReplay);
|
|
69
|
+
if (key === 'proposed' && a.edits) applyEdits(a);
|
|
70
|
+
if (key.startsWith('variant-')) applyVariant(a, key.slice('variant-'.length));
|
|
71
|
+
await settle();
|
|
72
|
+
const guard = key.startsWith('guard-');
|
|
73
|
+
// After a fix that restructured the markup, the agent can name what to photograph
|
|
74
|
+
// (and an accepted fix is guarded where it lives now).
|
|
75
|
+
const el = ((key === 'after' || guard) && a.afterTarget && queryOne(a.afterTarget)) || anchorEl(a);
|
|
76
|
+
// Not there, or not shown at this width (display: none, a collapsed menu): nothing to guard.
|
|
77
|
+
if (guard && (!el || !el.getClientRects().length)) return done(null);
|
|
78
|
+
if (el) el.scrollIntoView({ block: 'center' });
|
|
79
|
+
hideOverlays(el);
|
|
80
|
+
await imagesReady(el);
|
|
81
|
+
freezeMotion(); // anything the scroll or the state replay started
|
|
82
|
+
await settle();
|
|
83
|
+
// Only the "before" shot shows the reviewer's mark; the others show the design itself.
|
|
84
|
+
state.annotations = key === 'before' ? [a] : [];
|
|
85
|
+
state.showElsewhere = true;
|
|
86
|
+
rebuildMarks();
|
|
87
|
+
await settle();
|
|
88
|
+
|
|
89
|
+
const boxes = [];
|
|
90
|
+
if (el) boxes.push(el.getBoundingClientRect());
|
|
91
|
+
// A guard is about the element alone, as built: frame just it, and fingerprint it.
|
|
92
|
+
if (guard) {
|
|
93
|
+
// Three takes over a second: whatever moves on its own (a clock, a carousel,
|
|
94
|
+
// a feed) is marked volatile, so a guard never cries wolf over it.
|
|
95
|
+
let fp = fingerprint(el);
|
|
96
|
+
for (const ms of [450, 650]) {
|
|
97
|
+
await new Promise((ok) => setTimeout(ok, ms));
|
|
98
|
+
fp = steady(fp, fingerprint(el));
|
|
99
|
+
}
|
|
100
|
+
const clip = clipAround([el.getBoundingClientRect()], 24);
|
|
101
|
+
return done({ ...clip, box: boxIn(el, clip), fp });
|
|
102
|
+
}
|
|
103
|
+
// An "apply to all" edit is about all of them: frame the ones near the first.
|
|
104
|
+
if (a.edits && a.edits.scope === 'all' && el) {
|
|
105
|
+
const first = el.getBoundingClientRect();
|
|
106
|
+
for (const t of editTargets(a)) {
|
|
107
|
+
const r = t.getBoundingClientRect();
|
|
108
|
+
if (r.width && Math.abs(r.top - first.top) < 900) boxes.push(r);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// The mark's area is framed in every shot (drawn only in "before"), so all of
|
|
112
|
+
// an item's shots share one framing and line up in Compare.
|
|
113
|
+
if (a.kind === 'stroke' && !(key === 'after' && a.afterTarget)) {
|
|
114
|
+
const pts = strokePaths(a).flat();
|
|
115
|
+
if (pts.length) {
|
|
116
|
+
const b = bboxOf(pts);
|
|
117
|
+
boxes.push({ left: b.x - 6, top: b.y - 6, right: b.x + b.w + 6, bottom: b.y + b.h + 6 });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (a.kind === 'note' && !(key === 'after' && a.afterTarget)) {
|
|
121
|
+
const p = pinPoint(a);
|
|
122
|
+
if (p) boxes.push({ left: p.x - 14, top: p.y - 14, right: p.x + 14, bottom: p.y + 14 });
|
|
123
|
+
}
|
|
124
|
+
if (!boxes.length) return done(null);
|
|
125
|
+
const clip = clipAround(boxes, 40); // enough surroundings to see the element in context
|
|
126
|
+
// Where the element sits in the picture, so the overlay can lay it over the live page.
|
|
127
|
+
done({ ...clip, box: el ? boxIn(el, clip) : undefined });
|
|
128
|
+
} catch {
|
|
129
|
+
done(null);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function boxIn(el, clip) {
|
|
134
|
+
const r = el.getBoundingClientRect();
|
|
135
|
+
return { x: Math.round(r.left + scrollX - clip.x), y: Math.round(r.top + scrollY - clip.y), w: Math.round(r.width), h: Math.round(r.height) };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// The page area around some viewport rects, in page coordinates.
|
|
139
|
+
function clipAround(boxes, pad) {
|
|
140
|
+
const docW = document.documentElement.scrollWidth, docH = document.documentElement.scrollHeight;
|
|
141
|
+
const x = Math.max(0, Math.min(...boxes.map((r) => r.left)) - pad + scrollX);
|
|
142
|
+
const y = Math.max(0, Math.min(...boxes.map((r) => r.top)) - pad + scrollY);
|
|
143
|
+
const right = Math.min(docW, Math.max(...boxes.map((r) => r.right)) + pad + scrollX);
|
|
144
|
+
const bottom = Math.min(docH, Math.max(...boxes.map((r) => r.bottom)) + pad + scrollY);
|
|
145
|
+
return { x: Math.round(x), y: Math.round(y), width: Math.max(1, Math.round(right - x)), height: Math.max(1, Math.min(2400, Math.round(bottom - y))) };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Keep what held still between two takes; blank out what didn't (text: null,
|
|
149
|
+
// a style: '*', position or size: null, a node that came or went: volatile).
|
|
150
|
+
function steady(a, b) {
|
|
151
|
+
const later = new Map(b.nodes.map((n) => [n.p, n]));
|
|
152
|
+
const nodes = a.nodes.map((n) => {
|
|
153
|
+
const m = later.get(n.p);
|
|
154
|
+
if (n.v) return n; // already seen moving: stays volatile
|
|
155
|
+
if (!m || m.t !== n.t) return { ...n, v: true };
|
|
156
|
+
const out = { ...n, s: { ...n.s } };
|
|
157
|
+
if (m.text !== n.text) out.text = null;
|
|
158
|
+
for (const k of Object.keys(n.s)) if (m.s[k] !== n.s[k]) out.s[k] = '*';
|
|
159
|
+
if (Math.abs(m.w - n.w) > 2 || Math.abs(m.h - n.h) > 2) out.w = out.h = null;
|
|
160
|
+
if (Math.abs(m.x - n.x) > 3 || Math.abs(m.y - n.y) > 3) out.x = out.y = null;
|
|
161
|
+
return out;
|
|
162
|
+
});
|
|
163
|
+
return { nodes, unstable: a.unstable || a.nodes.length !== b.nodes.length || undefined };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// What an approval looks like, in a form two page loads can be compared by: for the
|
|
167
|
+
// element and its visible descendants, the tag, its own text, its size and position
|
|
168
|
+
// within the element, and the styles that make its look.
|
|
169
|
+
const FP_PROPS = [
|
|
170
|
+
'font-family', 'font-size', 'font-weight', 'font-style', 'line-height', 'letter-spacing', 'text-transform', 'text-align',
|
|
171
|
+
'color', 'background-color', 'background-image', 'border-top-width', 'border-top-style', 'border-top-color', 'border-radius',
|
|
172
|
+
'padding', 'gap', 'box-shadow', 'opacity', 'transform', 'display',
|
|
173
|
+
// Layout, so a changed grid reads as one change, not as everything inside moving.
|
|
174
|
+
'grid-template-columns', 'flex-direction', 'flex-wrap', 'justify-content', 'align-items', 'margin',
|
|
175
|
+
];
|
|
176
|
+
function fingerprint(el) {
|
|
177
|
+
const origin = el.getBoundingClientRect();
|
|
178
|
+
const nodes = [];
|
|
179
|
+
const visit = (n, path, depth) => {
|
|
180
|
+
if (nodes.length >= 60 || depth > 6 || isOurs(n)) return;
|
|
181
|
+
const cs = getComputedStyle(n);
|
|
182
|
+
if (cs.display === 'none' || cs.visibility === 'hidden') return;
|
|
183
|
+
const r = n.getBoundingClientRect();
|
|
184
|
+
const s = {};
|
|
185
|
+
for (const p of FP_PROPS) {
|
|
186
|
+
let v = cs.getPropertyValue(p).trim();
|
|
187
|
+
if (p === 'font-family') v = v.split(',')[0].replace(/["']/g, '').trim();
|
|
188
|
+
else if (/color$/.test(p)) v = hex(v);
|
|
189
|
+
else if (p === 'background-image' && v !== 'none') v = v.includes('gradient') ? v.slice(0, 80) : 'image ' + v.replace(/^url\(["']?|["']?\)$/g, '').split('/').pop().slice(0, 40);
|
|
190
|
+
s[p] = v.replace(/-?\d+\.\d+px/g, (m) => Math.round(parseFloat(m)) + 'px'); // sub-pixels are noise
|
|
191
|
+
}
|
|
192
|
+
if (n.currentSrc || n.getAttribute('src')) s.src = (n.currentSrc || n.getAttribute('src')).split('/').pop().slice(0, 60);
|
|
193
|
+
const text = [...n.childNodes].filter((c) => c.nodeType === 3).map((c) => c.textContent).join(' ').replace(/\s+/g, ' ').trim().slice(0, 120);
|
|
194
|
+
nodes.push({ p: path, t: label(n), text, w: Math.round(r.width), h: Math.round(r.height), x: Math.round(r.left - origin.left), y: Math.round(r.top - origin.top), s });
|
|
195
|
+
[...n.children].forEach((c, i) => visit(c, `${path}.${i}`, depth + 1));
|
|
196
|
+
};
|
|
197
|
+
visit(el, '0', 0);
|
|
198
|
+
return { nodes };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Sticky headers and floating widgets would cover the subject: hide them, unless
|
|
202
|
+
// they are the subject.
|
|
203
|
+
function hideOverlays(subject) {
|
|
204
|
+
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT);
|
|
205
|
+
for (let n = walker.nextNode(), i = 0; n && i < 5000; n = walker.nextNode(), i++) {
|
|
206
|
+
if (isOurs(n)) continue;
|
|
207
|
+
const pos = getComputedStyle(n).position;
|
|
208
|
+
if ((pos === 'fixed' || pos === 'sticky') && !(subject && (n.contains(subject) || subject.contains(n)))) {
|
|
209
|
+
n.style.setProperty('visibility', 'hidden', 'important');
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function openSideBySide(before, after) {
|
|
215
|
+
closeGallery();
|
|
216
|
+
const pane = (img) => h('figure', { class: 'sbs-pane' }, h('img', { src: img.src, alt: img.label }), h('figcaption', {}, img.label));
|
|
217
|
+
galleryEl = h('div', { class: 'gallery', role: 'dialog', 'aria-label': 'Before and after', onclick: (e) => e.target === galleryEl && closeGallery() },
|
|
218
|
+
h('div', { class: 'sbs' }, pane(before), pane(after)),
|
|
219
|
+
h('div', { class: 'gal-cap' }, 'The fix changed the layout, so the two are shown side by side'),
|
|
220
|
+
h('button', { class: 'gal-close', 'aria-label': 'Close', onclick: closeGallery, html: ICONS.close }),
|
|
221
|
+
);
|
|
222
|
+
galleryEl.addEventListener('keydown', (e) => {
|
|
223
|
+
if (e.key === 'Escape') closeGallery();
|
|
224
|
+
e.stopPropagation();
|
|
225
|
+
});
|
|
226
|
+
root.append(galleryEl);
|
|
227
|
+
galleryEl.tabIndex = -1;
|
|
228
|
+
galleryEl.focus();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ---- gallery
|
|
232
|
+
|
|
233
|
+
const SHOT_LABELS = { before: 'As reviewed', proposed: 'Your edit', after: 'After the fix' };
|
|
234
|
+
|
|
235
|
+
/** Images for an item, in the order a reviewer thinks about them. */
|
|
236
|
+
function itemImages(a) {
|
|
237
|
+
const shots = a.shots || {};
|
|
238
|
+
const out = [];
|
|
239
|
+
for (const key of ['before', 'proposed']) if (shots[key]) out.push({ key, src: assetSrc({ path: shots[key] }), label: SHOT_LABELS[key] });
|
|
240
|
+
for (const v of a.variants || []) {
|
|
241
|
+
const path = shots['variant-' + v.key];
|
|
242
|
+
if (path) out.push({ key: 'variant-' + v.key, src: assetSrc({ path }), label: `Option ${v.key}: ${v.label}` });
|
|
243
|
+
}
|
|
244
|
+
if (shots.after) out.push({ key: 'after', src: assetSrc({ path: shots.after }), label: SHOT_LABELS.after });
|
|
245
|
+
for (const r of a.references || []) out.push({ key: 'ref', src: assetSrc(r), label: `Reference${r.name ? ': ' + r.name : ''}` });
|
|
246
|
+
return out;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Cache-bust shots: the same file name is rewritten when an item changes.
|
|
250
|
+
function withVersion(src, a) {
|
|
251
|
+
return src.startsWith('data:') ? src : `${src}${src.includes('?') ? '&' : '?'}v=${encodeURIComponent(a.updatedAt || '')}`;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function thumbStrip(a) {
|
|
255
|
+
const all = itemImages(a).map((i) => ({ ...i, src: withVersion(i.src, a) }));
|
|
256
|
+
if (!all.length) return null;
|
|
257
|
+
const before = all.find((i) => i.key === 'before' || i.key === 'proposed');
|
|
258
|
+
const after = all.find((i) => i.key === 'after');
|
|
259
|
+
// Once fixed, what matters is before vs after; options and edits are history.
|
|
260
|
+
const imgs = a.status === 'resolved' ? all.filter((i) => i === before || i === after || i.key === 'ref') : all;
|
|
261
|
+
return h('div', { class: 'thumbs' },
|
|
262
|
+
before && after
|
|
263
|
+
? h('button', { type: 'button', class: 'btn small primary cmp-btn', title: 'Slide between how it was and how it is now', onclick: (e) => (e.stopPropagation(), openCompare(before, after)) }, 'Compare')
|
|
264
|
+
: null,
|
|
265
|
+
imgs.slice(0, 5).map((img, i) =>
|
|
266
|
+
// stopPropagation: in the Notes panel the strip sits inside the item's own button.
|
|
267
|
+
h('button', { type: 'button', class: 'thumb', title: img.label, onclick: (e) => (e.stopPropagation(), openGallery(imgs, i)) },
|
|
268
|
+
h('img', { src: img.src, alt: img.label, loading: 'lazy' }), h('span', {}, img.label)),
|
|
269
|
+
),
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
let galleryEl = null;
|
|
274
|
+
function closeGallery() {
|
|
275
|
+
if (galleryEl) galleryEl.remove();
|
|
276
|
+
galleryEl = null;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function openGallery(images, index = 0) {
|
|
280
|
+
closeGallery();
|
|
281
|
+
let i = index;
|
|
282
|
+
const img = h('img', { class: 'gal-img' });
|
|
283
|
+
const cap = h('div', { class: 'gal-cap' });
|
|
284
|
+
const show = () => {
|
|
285
|
+
img.src = images[i].src;
|
|
286
|
+
img.alt = images[i].label;
|
|
287
|
+
cap.textContent = `${images[i].label}${images.length > 1 ? ` · ${i + 1} / ${images.length}` : ''}`;
|
|
288
|
+
};
|
|
289
|
+
const step = (d) => {
|
|
290
|
+
i = (i + d + images.length) % images.length;
|
|
291
|
+
show();
|
|
292
|
+
};
|
|
293
|
+
galleryEl = h('div', { class: 'gallery', role: 'dialog', 'aria-label': 'Images', onclick: (e) => e.target === galleryEl && closeGallery() },
|
|
294
|
+
img, cap,
|
|
295
|
+
images.length > 1 ? h('button', { class: 'gal-nav prev', 'aria-label': 'Previous', onclick: () => step(-1) }, '‹') : null,
|
|
296
|
+
images.length > 1 ? h('button', { class: 'gal-nav next', 'aria-label': 'Next', onclick: () => step(1) }, '›') : null,
|
|
297
|
+
h('button', { class: 'gal-close', 'aria-label': 'Close', onclick: closeGallery, html: ICONS.close }),
|
|
298
|
+
);
|
|
299
|
+
galleryEl.addEventListener('keydown', (e) => {
|
|
300
|
+
if (e.key === 'Escape') closeGallery();
|
|
301
|
+
if (e.key === 'ArrowLeft') step(-1);
|
|
302
|
+
if (e.key === 'ArrowRight') step(1);
|
|
303
|
+
e.stopPropagation();
|
|
304
|
+
});
|
|
305
|
+
show();
|
|
306
|
+
root.append(galleryEl);
|
|
307
|
+
galleryEl.tabIndex = -1;
|
|
308
|
+
galleryEl.focus();
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const loadImage = (src) =>
|
|
312
|
+
new Promise((ok) => {
|
|
313
|
+
const img = new Image();
|
|
314
|
+
img.onload = () => ok(img);
|
|
315
|
+
img.onerror = () => ok(null);
|
|
316
|
+
img.src = src;
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
// Before/after. Same framing: a slider ("before" left, "after" right). Different
|
|
320
|
+
// framing (the fix restructured the element): side by side at true scale.
|
|
321
|
+
async function openCompare(before, after) {
|
|
322
|
+
const [b, a] = await Promise.all([loadImage(before.src), loadImage(after.src)]);
|
|
323
|
+
const similar = b && a && Math.abs(b.naturalWidth - a.naturalWidth) / b.naturalWidth < 0.1 && Math.abs(b.naturalHeight - a.naturalHeight) / b.naturalHeight < 0.2;
|
|
324
|
+
if (!similar) return openSideBySide(before, after);
|
|
325
|
+
closeGallery();
|
|
326
|
+
const top = h('img', { class: 'cmp-top', src: before.src, alt: before.label });
|
|
327
|
+
const line = h('div', { class: 'cmp-line' });
|
|
328
|
+
const frame = h('div', { class: 'cmp' }, h('img', { class: 'cmp-base', src: after.src, alt: after.label }), top, line);
|
|
329
|
+
const setAt = (f) => {
|
|
330
|
+
const pct = Math.max(0, Math.min(100, f * 100));
|
|
331
|
+
top.style.clipPath = `inset(0 ${100 - pct}% 0 0)`;
|
|
332
|
+
line.style.left = pct + '%';
|
|
333
|
+
};
|
|
334
|
+
const drag = (e) => {
|
|
335
|
+
const r = frame.getBoundingClientRect();
|
|
336
|
+
setAt((e.clientX - r.left) / r.width);
|
|
337
|
+
};
|
|
338
|
+
frame.addEventListener('pointerdown', (e) => {
|
|
339
|
+
frame.setPointerCapture(e.pointerId);
|
|
340
|
+
drag(e);
|
|
341
|
+
frame.addEventListener('pointermove', drag);
|
|
342
|
+
});
|
|
343
|
+
frame.addEventListener('pointerup', () => frame.removeEventListener('pointermove', drag));
|
|
344
|
+
galleryEl = h('div', { class: 'gallery', role: 'dialog', 'aria-label': 'Compare before and after', onclick: (e) => e.target === galleryEl && closeGallery() },
|
|
345
|
+
frame,
|
|
346
|
+
h('div', { class: 'gal-cap' }, `◀ ${before.label} | ${after.label} ▶ · drag to compare`),
|
|
347
|
+
h('button', { class: 'gal-close', 'aria-label': 'Close', onclick: closeGallery, html: ICONS.close }),
|
|
348
|
+
);
|
|
349
|
+
galleryEl.addEventListener('keydown', (e) => {
|
|
350
|
+
if (e.key === 'Escape') closeGallery();
|
|
351
|
+
e.stopPropagation();
|
|
352
|
+
});
|
|
353
|
+
root.append(galleryEl);
|
|
354
|
+
setAt(0.5);
|
|
355
|
+
galleryEl.tabIndex = -1;
|
|
356
|
+
galleryEl.focus();
|
|
357
|
+
}
|