sitedrift 0.1.0 → 0.3.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/AGENTS.md +112 -0
- package/README.md +190 -27
- package/assets/viewer.css +779 -0
- package/assets/viewer.html +160 -0
- package/assets/viewer.js +958 -0
- package/docs/images/sitedrift-collaboration.jpg +0 -0
- package/docs/images/sitedrift-diff.jpg +0 -0
- package/docs/images/sitedrift-mobile.jpg +0 -0
- package/docs/images/sitedrift-split.jpg +0 -0
- package/package.json +22 -4
- package/sitedrift-mcp.mjs +4 -0
- package/sitedrift.mjs +80 -1787
- package/src/agent.mjs +73 -0
- package/src/browser.mjs +9 -0
- package/src/cli.mjs +231 -0
- package/src/cloudflare-runtime.mjs +114 -0
- package/src/cloudflare.mjs +78 -0
- package/src/frame-content.mjs +65 -0
- package/src/http.mjs +21 -0
- package/src/mcp.mjs +324 -0
- package/src/notes.mjs +78 -0
- package/src/proxy.mjs +80 -0
- package/src/server.mjs +171 -0
- package/src/session.mjs +53 -0
- package/src/tls.mjs +115 -0
- package/src/viewer.mjs +62 -0
package/assets/viewer.js
ADDED
|
@@ -0,0 +1,958 @@
|
|
|
1
|
+
const config = window.__SITEDRIFT_CONFIG__;
|
|
2
|
+
delete window.__SITEDRIFT_CONFIG__;
|
|
3
|
+
if (config.hosted) {
|
|
4
|
+
config.dev = location.origin;
|
|
5
|
+
config.frameOrigins = { dev: location.origin, live: location.origin };
|
|
6
|
+
for (const iframe of document.querySelectorAll('iframe[data-side]')) {
|
|
7
|
+
iframe.setAttribute('sandbox', 'allow-downloads allow-forms allow-modals allow-popups allow-popups-to-escape-sandbox allow-scripts');
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
const root = document.documentElement;
|
|
11
|
+
const app = document.querySelector('.app');
|
|
12
|
+
const routeInput = document.querySelector('.route');
|
|
13
|
+
const divider = document.querySelector('.divider');
|
|
14
|
+
const scrollButton = document.querySelector('[data-action="scroll"]');
|
|
15
|
+
const scrollModeButton = document.querySelector('[data-action="scroll-mode"]');
|
|
16
|
+
const mirrorButton = document.querySelector('[data-action="mirror"]');
|
|
17
|
+
const mobileButton = document.querySelector('[data-action="mobile"]');
|
|
18
|
+
const modeButtons = [...document.querySelectorAll('[data-mode]')];
|
|
19
|
+
const overlaySliders = [...document.querySelectorAll('.overlay-slider input')];
|
|
20
|
+
const blendButtons = [...document.querySelectorAll('[data-action="overlay-blend"]')];
|
|
21
|
+
const notesDrawer = document.querySelector('.review-drawer');
|
|
22
|
+
const noteList = document.querySelector('.note-list');
|
|
23
|
+
const noteInput = document.querySelector('.note-compose textarea');
|
|
24
|
+
const toast = document.querySelector('.toast');
|
|
25
|
+
const params = new URLSearchParams(location.search);
|
|
26
|
+
const suppressScrollUntil = { dev: 0, live: 0 };
|
|
27
|
+
const scrollFrames = { dev: 0, live: 0 };
|
|
28
|
+
const settleTimers = { dev: [], live: [] };
|
|
29
|
+
const frameState = { dev: { y: 0, max: 0 }, live: { y: 0, max: 0 } };
|
|
30
|
+
let order = params.get('swap') === '1' ? ['live', 'dev'] : ['dev', 'live'];
|
|
31
|
+
let syncScroll = queryOrStoredBool('scroll', 'site-compare-scroll', false);
|
|
32
|
+
let scrollMode = params.get('scrollMode') || localStorage.getItem('site-compare-scroll-mode') || 'exact';
|
|
33
|
+
if (!['exact', 'ratio'].includes(scrollMode)) scrollMode = 'exact';
|
|
34
|
+
let mirrorLinks = queryOrStoredBool('mirror', 'site-compare-mirror', false);
|
|
35
|
+
let mobileMode = (params.get('mode') || localStorage.getItem('site-compare-mode')) === 'mobile';
|
|
36
|
+
let compactMode = queryOrStoredBool('compact', 'site-compare-compact', !!config.hosted);
|
|
37
|
+
const storedView = localStorage.getItem('site-compare-view');
|
|
38
|
+
let viewMode = params.get('view')
|
|
39
|
+
|| (params.get('overlay') === '1' ? 'overlay' : params.get('solo') === '1' ? 'solo' : null)
|
|
40
|
+
|| storedView
|
|
41
|
+
|| (config.hosted ? 'solo' : null)
|
|
42
|
+
|| (innerWidth <= 600 ? 'solo' : 'split');
|
|
43
|
+
let overlayBlend = (params.get('overlayBlend') || localStorage.getItem('site-compare-overlay-blend')) === 'difference' ? 'difference' : 'opacity';
|
|
44
|
+
if (viewMode === 'diff') { viewMode = 'overlay'; overlayBlend = 'difference'; } // back-compat
|
|
45
|
+
if (!['split', 'solo', 'overlay'].includes(viewMode)) viewMode = 'split';
|
|
46
|
+
let overlayAmount = Number(params.get('overlayAmount') ?? localStorage.getItem('site-compare-overlay-amount'));
|
|
47
|
+
if (!Number.isFinite(overlayAmount)) overlayAmount = 50;
|
|
48
|
+
let focusSide = params.get('focus') === 'live' ? 'live' : params.get('focus') === 'dev' ? 'dev' : 'dev';
|
|
49
|
+
let reviewNotes = [];
|
|
50
|
+
let notesSignature = '';
|
|
51
|
+
let notesOpen = params.get('notes') === '1';
|
|
52
|
+
let dockMode = queryOrStoredBool('dock', 'site-compare-dock', true);
|
|
53
|
+
let scrollOwner = null;
|
|
54
|
+
const meta = { dev: null, live: null };
|
|
55
|
+
const apiHeaders = {
|
|
56
|
+
authorization: 'Bearer ' + config.token,
|
|
57
|
+
'content-type': 'application/json',
|
|
58
|
+
};
|
|
59
|
+
const localNotesKey = 'sitedrift-preview-notes:' + location.host + ':' + config.live;
|
|
60
|
+
|
|
61
|
+
function queryOrStoredBool(queryName, storageName, fallback) {
|
|
62
|
+
if (params.has(queryName)) return params.get(queryName) === '1';
|
|
63
|
+
const stored = localStorage.getItem(storageName);
|
|
64
|
+
return stored === null ? fallback : stored === '1';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function normalizeRoute(value) {
|
|
68
|
+
try {
|
|
69
|
+
if (/^https?:\/\//.test(value)) {
|
|
70
|
+
const parsed = new URL(value);
|
|
71
|
+
value = parsed.pathname + parsed.search + parsed.hash;
|
|
72
|
+
}
|
|
73
|
+
} catch {}
|
|
74
|
+
value = value.trim() || '/';
|
|
75
|
+
return value.startsWith('/') ? value : '/' + value;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function frame(side) { return document.querySelector('iframe[data-side="' + side + '"]'); }
|
|
79
|
+
function proxyPath(side) { return config.hosted ? '/__sitedrift/' + side : '/__' + side; }
|
|
80
|
+
function proxied(side, route) { return config.frameOrigins[side] + proxyPath(side) + normalizeRoute(route); }
|
|
81
|
+
function statusUrl(side, route) { return proxyPath(side) + normalizeRoute(route); }
|
|
82
|
+
function direct(side, route) {
|
|
83
|
+
return config.hosted && side === 'dev'
|
|
84
|
+
? location.origin + proxyPath(side) + normalizeRoute(route)
|
|
85
|
+
: config[side] + normalizeRoute(route);
|
|
86
|
+
}
|
|
87
|
+
function framePost(side, type, data = {}) {
|
|
88
|
+
frame(side).contentWindow?.postMessage(
|
|
89
|
+
{ source: 'sitedrift-parent', side, type, ...data },
|
|
90
|
+
config.hosted ? '*' : config.frameOrigins[side],
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function statusBadges(side) {
|
|
95
|
+
return [
|
|
96
|
+
document.querySelector('.label[data-label="' + side + '"] .status-badge'),
|
|
97
|
+
document.querySelector('[data-compact-side="' + side + '"] .status-badge'),
|
|
98
|
+
].filter(Boolean);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function setStatusBadge(side, status) {
|
|
102
|
+
const cls = status >= 200 && status < 300 ? 'status-ok'
|
|
103
|
+
: status >= 300 && status < 400 ? 'status-warn'
|
|
104
|
+
: 'status-err';
|
|
105
|
+
const text = status ? String(status) : 'ERR';
|
|
106
|
+
for (const badge of statusBadges(side)) {
|
|
107
|
+
badge.className = 'status-badge show ' + cls;
|
|
108
|
+
badge.textContent = text;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function clearStatusBadge(side) {
|
|
113
|
+
for (const badge of statusBadges(side)) {
|
|
114
|
+
badge.className = 'status-badge';
|
|
115
|
+
badge.textContent = '';
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function fetchStatus(side, route) {
|
|
120
|
+
const url = statusUrl(side, route);
|
|
121
|
+
const read = (method) => fetch(url, { method, cache: 'no-store', redirect: 'manual' });
|
|
122
|
+
read('HEAD')
|
|
123
|
+
.then((res) => (res.status === 405 || res.status === 501 ? read('GET') : res))
|
|
124
|
+
.then((res) => setStatusBadge(side, res.status || (res.type === 'opaqueredirect' ? 302 : 0)))
|
|
125
|
+
.catch(() => setStatusBadge(side, 0));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function brandStrip(title) {
|
|
129
|
+
if (!config.brand) return title;
|
|
130
|
+
const escaped = config.brand.replace(/[.*+?^$()|[\]{}\\]/g, '\\$&');
|
|
131
|
+
return title.replace(new RegExp('\\s*[|\u2013\u2014-]\\s*' + escaped + '.*$', 'i'), '').trim();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function updateDocTitle() {
|
|
135
|
+
const primary = meta[order[0]];
|
|
136
|
+
document.title = primary && primary.heading ? primary.heading + ' · sitedrift' : 'sitedrift';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function renderMetaDiff() {
|
|
140
|
+
const dev = meta.dev;
|
|
141
|
+
const live = meta.live;
|
|
142
|
+
const diffs = {
|
|
143
|
+
title: !!(dev && live) && (dev.title || '') !== (live.title || ''),
|
|
144
|
+
desc: !!(dev && live) && (dev.description || '') !== (live.description || ''),
|
|
145
|
+
url: !!(dev && live) && (dev.canonicalPath || '') !== (live.canonicalPath || ''),
|
|
146
|
+
};
|
|
147
|
+
const any = diffs.title || diffs.desc || diffs.url;
|
|
148
|
+
for (const chip of document.querySelectorAll('.meta-diff')) chip.classList.toggle('show', any);
|
|
149
|
+
for (const side of ['dev', 'live']) {
|
|
150
|
+
const card = document.querySelector('.label[data-label="' + side + '"] .seo-card');
|
|
151
|
+
if (!card) continue;
|
|
152
|
+
for (const key of ['title', 'desc', 'url']) {
|
|
153
|
+
const el = card.querySelector('[data-seo="' + key + '"]');
|
|
154
|
+
if (el) el.classList.toggle('seo-diff', diffs[key]);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function setUrlParam(name, value) {
|
|
160
|
+
const url = new URL(location.href);
|
|
161
|
+
if (value === '' || value === null || value === undefined) url.searchParams.delete(name);
|
|
162
|
+
else url.searchParams.set(name, String(value));
|
|
163
|
+
history.replaceState(null, '', url);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function saveBool(queryName, storageName, value) {
|
|
167
|
+
localStorage.setItem(storageName, value ? '1' : '0');
|
|
168
|
+
setUrlParam(queryName, value ? '1' : '0');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function showToast(message) {
|
|
172
|
+
toast.textContent = message;
|
|
173
|
+
toast.classList.add('show');
|
|
174
|
+
clearTimeout(showToast.timer);
|
|
175
|
+
showToast.timer = setTimeout(() => toast.classList.remove('show'), 1600);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function element(tag, className, text) {
|
|
179
|
+
const node = document.createElement(tag);
|
|
180
|
+
if (className) node.className = className;
|
|
181
|
+
if (text !== undefined) node.textContent = text;
|
|
182
|
+
return node;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function copyIcon() {
|
|
186
|
+
const ns = 'http://www.w3.org/2000/svg';
|
|
187
|
+
const svg = document.createElementNS(ns, 'svg');
|
|
188
|
+
svg.setAttribute('viewBox', '0 0 20 20');
|
|
189
|
+
svg.setAttribute('aria-hidden', 'true');
|
|
190
|
+
const path = document.createElementNS(ns, 'path');
|
|
191
|
+
path.setAttribute('d', 'M8 8V5.5A1.5 1.5 0 0 1 9.5 4h5A1.5 1.5 0 0 1 16 5.5v5A1.5 1.5 0 0 1 14.5 12H12');
|
|
192
|
+
path.setAttribute('fill', 'none');
|
|
193
|
+
path.setAttribute('stroke', 'currentColor');
|
|
194
|
+
path.setAttribute('stroke-width', '1.5');
|
|
195
|
+
const rect = document.createElementNS(ns, 'rect');
|
|
196
|
+
rect.setAttribute('x', '4');
|
|
197
|
+
rect.setAttribute('y', '8');
|
|
198
|
+
rect.setAttribute('width', '8');
|
|
199
|
+
rect.setAttribute('height', '8');
|
|
200
|
+
rect.setAttribute('rx', '1.5');
|
|
201
|
+
rect.setAttribute('fill', 'none');
|
|
202
|
+
rect.setAttribute('stroke', 'currentColor');
|
|
203
|
+
rect.setAttribute('stroke-width', '1.5');
|
|
204
|
+
svg.append(path, rect);
|
|
205
|
+
return svg;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function truncate(value, max) {
|
|
209
|
+
const chars = [...String(value || '')];
|
|
210
|
+
return chars.length <= max ? chars.join('') : chars.slice(0, max - 1).join('').trimEnd() + '…';
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function crumb(value) {
|
|
214
|
+
try {
|
|
215
|
+
const url = new URL(value);
|
|
216
|
+
const parts = url.pathname.replace(/^\/|\/$/g, '').split('/').filter(Boolean)
|
|
217
|
+
.map((part) => decodeURIComponent(part).replaceAll('-', ' '));
|
|
218
|
+
return parts.length ? url.hostname + ' › ' + parts.join(' › ') : url.hostname;
|
|
219
|
+
} catch {
|
|
220
|
+
return value;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function renderMetadata(side, payload) {
|
|
225
|
+
const route = payload.route || '/';
|
|
226
|
+
const source = payload.meta || {};
|
|
227
|
+
const label = document.querySelector('.label[data-label="' + side + '"]');
|
|
228
|
+
if (!label) return;
|
|
229
|
+
const title = (source.title || '').trim();
|
|
230
|
+
const heading = brandStrip(title) || source.heading || 'Untitled page';
|
|
231
|
+
const description = source.description || '';
|
|
232
|
+
const canonical = source.canonical || direct(side, route);
|
|
233
|
+
const siteName = source.siteName
|
|
234
|
+
|| config.brand
|
|
235
|
+
|| new URL(direct(side, route)).hostname;
|
|
236
|
+
const faviconSrc = source.icon || (config.frameOrigins[side] + proxyPath(side) + '/favicon.ico');
|
|
237
|
+
let canonicalPath = canonical;
|
|
238
|
+
try { canonicalPath = new URL(canonical).pathname; } catch {}
|
|
239
|
+
meta[side] = { title, description, canonicalPath, heading };
|
|
240
|
+
label.querySelector('.page-heading').textContent = heading;
|
|
241
|
+
label.querySelector('.page-heading').title = title || heading;
|
|
242
|
+
updateDocTitle();
|
|
243
|
+
document.querySelector('[data-compact-title="' + side + '"]').textContent = heading;
|
|
244
|
+
label.querySelector('.origin').textContent = config[side] + route;
|
|
245
|
+
const fav = label.querySelector('.favicon');
|
|
246
|
+
fav.onerror = () => { fav.onerror = null; fav.src = '/icon.svg'; };
|
|
247
|
+
fav.src = faviconSrc;
|
|
248
|
+
label.querySelector('.open-side').href = direct(side, route);
|
|
249
|
+
const card = label.querySelector('.seo-card');
|
|
250
|
+
const sourceRow = element('div', 'seo-source');
|
|
251
|
+
const seoFavicon = element('img', 'seo-favicon');
|
|
252
|
+
seoFavicon.alt = '';
|
|
253
|
+
seoFavicon.src = faviconSrc;
|
|
254
|
+
const sourceText = element('div');
|
|
255
|
+
sourceText.append(
|
|
256
|
+
element('div', 'seo-site', siteName),
|
|
257
|
+
element('div', 'seo-url', crumb(canonical)),
|
|
258
|
+
);
|
|
259
|
+
sourceText.lastChild.dataset.seo = 'url';
|
|
260
|
+
const menu = element('div', 'seo-menu', '⋮');
|
|
261
|
+
menu.setAttribute('aria-hidden', 'true');
|
|
262
|
+
sourceRow.append(seoFavicon, sourceText, menu);
|
|
263
|
+
const seoTitle = element('div', `seo-title${title ? '' : ' seo-empty'}`, truncate(title || 'Missing page title', 62));
|
|
264
|
+
seoTitle.dataset.seo = 'title';
|
|
265
|
+
const seoDescription = element(
|
|
266
|
+
'div',
|
|
267
|
+
`seo-description${description ? '' : ' seo-empty'}`,
|
|
268
|
+
truncate(description || 'Missing meta description', 158),
|
|
269
|
+
);
|
|
270
|
+
seoDescription.dataset.seo = 'desc';
|
|
271
|
+
card.replaceChildren(
|
|
272
|
+
element('div', 'seo-eyebrow', `${side.toUpperCase()} metadata preview`),
|
|
273
|
+
sourceRow,
|
|
274
|
+
seoTitle,
|
|
275
|
+
seoDescription,
|
|
276
|
+
seoChecks(source.checks || []),
|
|
277
|
+
);
|
|
278
|
+
const seoFav = label.querySelector('.seo-favicon');
|
|
279
|
+
if (seoFav) seoFav.onerror = () => { seoFav.onerror = null; seoFav.src = '/icon.svg'; };
|
|
280
|
+
const fails = (source.checks || []).filter((check) => !check.ok).length;
|
|
281
|
+
const flag = label.querySelector('.seo-flag');
|
|
282
|
+
if (flag) {
|
|
283
|
+
flag.hidden = fails === 0;
|
|
284
|
+
flag.textContent = fails ? String(fails) : '';
|
|
285
|
+
flag.title = fails ? fails + ' SEO check' + (fails === 1 ? '' : 's') + ' failing' : '';
|
|
286
|
+
}
|
|
287
|
+
renderMetaDiff();
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function seoChecks(checks) {
|
|
291
|
+
const fails = checks.filter((check) => !check.ok).length;
|
|
292
|
+
const container = element('div', 'seo-checks');
|
|
293
|
+
const head = element('div', 'seo-checks-head');
|
|
294
|
+
head.append(
|
|
295
|
+
element('span', '', 'SEO checks'),
|
|
296
|
+
element('span', fails ? 'bad' : 'good', fails ? `${fails} to fix` : 'all good'),
|
|
297
|
+
);
|
|
298
|
+
container.append(head);
|
|
299
|
+
for (const check of checks) {
|
|
300
|
+
const row = element('div', `seo-check ${check.ok ? 'ok' : 'bad'}`);
|
|
301
|
+
row.append(
|
|
302
|
+
element('span', 'seo-check-mark', check.ok ? '✓' : '✗'),
|
|
303
|
+
element('span', 'seo-check-label', check.label),
|
|
304
|
+
);
|
|
305
|
+
if (check.note) row.append(element('span', 'seo-check-note', check.note));
|
|
306
|
+
container.append(row);
|
|
307
|
+
}
|
|
308
|
+
return container;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function positionSeoCard(details) {
|
|
312
|
+
const summary = details.querySelector('summary');
|
|
313
|
+
const card = details.querySelector('.seo-card');
|
|
314
|
+
const rect = summary.getBoundingClientRect();
|
|
315
|
+
// Cap to half the viewport so the two cards can't collide, and anchor each
|
|
316
|
+
// card's right edge under its SEO button so it drops within its own pane.
|
|
317
|
+
const width = Math.max(260, Math.min(420, (innerWidth - 32) / 2));
|
|
318
|
+
card.style.width = width + 'px';
|
|
319
|
+
const left = Math.max(8, Math.min(rect.right - width, innerWidth - width - 8));
|
|
320
|
+
card.style.left = left + 'px';
|
|
321
|
+
card.style.top = Math.min(innerHeight - 120, rect.bottom + 8) + 'px';
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function googleOpen() {
|
|
325
|
+
return !!document.querySelector('.label details[open]');
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function setGoogleOpen(open) {
|
|
329
|
+
const all = document.querySelectorAll('.label details');
|
|
330
|
+
for (const details of all) {
|
|
331
|
+
if (open) details.setAttribute('open', '');
|
|
332
|
+
else details.removeAttribute('open');
|
|
333
|
+
}
|
|
334
|
+
if (open) {
|
|
335
|
+
requestAnimationFrame(() => {
|
|
336
|
+
for (const details of document.querySelectorAll('.label details[open]')) positionSeoCard(details);
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function updateLabels(route) {
|
|
342
|
+
for (const side of ['dev', 'live']) {
|
|
343
|
+
const label = document.querySelector('.label[data-label="' + side + '"]');
|
|
344
|
+
label.querySelector('.pill').className = 'pill ' + side;
|
|
345
|
+
label.querySelector('.pill').textContent = side.toUpperCase();
|
|
346
|
+
label.querySelector('.page-heading').textContent = 'Loading…';
|
|
347
|
+
document.querySelector('[data-compact-title="' + side + '"]').textContent = 'Loading…';
|
|
348
|
+
label.querySelector('.origin').textContent = config[side] + route;
|
|
349
|
+
label.querySelector('.favicon').src = '/__' + side + '/favicon.ico';
|
|
350
|
+
label.querySelector('.open-side').href = direct(side, route);
|
|
351
|
+
meta[side] = null;
|
|
352
|
+
clearStatusBadge(side);
|
|
353
|
+
}
|
|
354
|
+
renderMetaDiff();
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function applyOrder() {
|
|
358
|
+
order.forEach((side, index) => {
|
|
359
|
+
const pane = document.querySelector('[data-pane="' + side + '"]');
|
|
360
|
+
pane.style.order = String(index);
|
|
361
|
+
pane.classList.toggle('overlay-top', index === 1);
|
|
362
|
+
document.querySelector('.label[data-label="' + side + '"]').style.order = String(index);
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function go(value = routeInput.value) {
|
|
367
|
+
const route = normalizeRoute(value);
|
|
368
|
+
routeInput.value = route;
|
|
369
|
+
updateLabels(route);
|
|
370
|
+
frame('dev').src = proxied('dev', route);
|
|
371
|
+
frame('live').src = proxied('live', route);
|
|
372
|
+
const url = new URL(location.href);
|
|
373
|
+
url.searchParams.set('path', route);
|
|
374
|
+
history.replaceState(null, '', url);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function setSplit(percent) {
|
|
378
|
+
const value = Math.max(15, Math.min(85, percent));
|
|
379
|
+
root.style.setProperty('--split', value + '%');
|
|
380
|
+
divider.setAttribute('aria-valuenow', String(Math.round(value)));
|
|
381
|
+
localStorage.setItem('site-compare-split', String(value));
|
|
382
|
+
setUrlParam('split', Math.round(value * 10) / 10);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Overlay and diff are only legible if both panes scroll in lockstep, so
|
|
386
|
+
// they force pixel-exact linked scrolling regardless of the user's toggle.
|
|
387
|
+
function stacked() { return viewMode === 'overlay'; }
|
|
388
|
+
function linked() { return syncScroll || stacked(); }
|
|
389
|
+
function effScrollMode() { return stacked() ? 'exact' : scrollMode; }
|
|
390
|
+
|
|
391
|
+
function applyFrameSettings(side) {
|
|
392
|
+
framePost(side, 'settings', { linked: linked(), mirror: mirrorLinks });
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function setLinkedScroll(sourceSide, requestedY) {
|
|
396
|
+
const otherSide = sourceSide === 'dev' ? 'live' : 'dev';
|
|
397
|
+
const sourceMax = frameState[sourceSide].max;
|
|
398
|
+
const sourceY = Math.max(0, Math.min(sourceMax, requestedY));
|
|
399
|
+
suppressScrollUntil[sourceSide] = Date.now() + 120;
|
|
400
|
+
if (effScrollMode() === 'exact') {
|
|
401
|
+
const sharedMax = Math.min(sourceMax, frameState[otherSide].max);
|
|
402
|
+
const sharedY = Math.min(sharedMax, sourceY);
|
|
403
|
+
suppressScrollUntil[otherSide] = Date.now() + 120;
|
|
404
|
+
frameState[sourceSide].y = sharedY;
|
|
405
|
+
frameState[otherSide].y = sharedY;
|
|
406
|
+
framePost(sourceSide, 'scroll', { y: sharedY });
|
|
407
|
+
framePost(otherSide, 'scroll', { y: sharedY });
|
|
408
|
+
} else {
|
|
409
|
+
frameState[sourceSide].y = sourceY;
|
|
410
|
+
framePost(sourceSide, 'scroll', { y: sourceY });
|
|
411
|
+
alignSide(sourceSide, otherSide);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function alignSide(sourceSide, targetSide) {
|
|
416
|
+
let targetY = frameState[sourceSide].y;
|
|
417
|
+
if (effScrollMode() === 'ratio') {
|
|
418
|
+
const sourceMax = frameState[sourceSide].max;
|
|
419
|
+
const ratio = sourceMax ? frameState[sourceSide].y / sourceMax : 0;
|
|
420
|
+
targetY = ratio * frameState[targetSide].max;
|
|
421
|
+
}
|
|
422
|
+
suppressScrollUntil[targetSide] = Date.now() + (effScrollMode() === 'exact' ? 120 : 600);
|
|
423
|
+
frameState[targetSide].y = targetY;
|
|
424
|
+
framePost(targetSide, 'scroll', { y: targetY });
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function syncFrom(side, force = false) {
|
|
428
|
+
if (!linked() || Date.now() < suppressScrollUntil[side]) return;
|
|
429
|
+
if (!scrollOwner) scrollOwner = side;
|
|
430
|
+
if (!force && scrollOwner !== side) return;
|
|
431
|
+
if (effScrollMode() === 'exact') {
|
|
432
|
+
alignSide(side, side === 'dev' ? 'live' : 'dev');
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
cancelAnimationFrame(scrollFrames[side]);
|
|
436
|
+
scrollFrames[side] = requestAnimationFrame(() => {
|
|
437
|
+
const otherSide = side === 'dev' ? 'live' : 'dev';
|
|
438
|
+
alignSide(side, otherSide);
|
|
439
|
+
for (const timer of settleTimers[side]) clearTimeout(timer);
|
|
440
|
+
settleTimers[side] = [80, 240].map((delay) => setTimeout(() => {
|
|
441
|
+
if (scrollOwner === side) alignSide(side, otherSide);
|
|
442
|
+
}, delay));
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function markScrollOwner(side) {
|
|
447
|
+
scrollOwner = side;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
for (const side of ['dev', 'live']) {
|
|
451
|
+
frame(side).addEventListener('load', () => {
|
|
452
|
+
applyFrameSettings(side);
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function runFrameKey(key, side, message) {
|
|
457
|
+
const lower = String(key).toLowerCase();
|
|
458
|
+
if (lower === 'r') document.querySelector('[data-action="reload"]').click();
|
|
459
|
+
else if (lower === 's') document.querySelector('[data-action="swap"]').click();
|
|
460
|
+
else if (lower === 'o') setMode(viewMode === 'overlay' ? 'split' : 'overlay');
|
|
461
|
+
else if (lower === 'd') {
|
|
462
|
+
if (viewMode === 'overlay' && overlayBlend === 'difference') setMode('split');
|
|
463
|
+
else { setMode('overlay'); setOverlayBlend('difference'); }
|
|
464
|
+
} else if (lower === '0') setSplit(50);
|
|
465
|
+
else if (key === '/') { routeInput.focus(); routeInput.select(); }
|
|
466
|
+
else if (linked()) {
|
|
467
|
+
let next = null;
|
|
468
|
+
if (key === 'ArrowDown') next = message.y + 44;
|
|
469
|
+
if (key === 'ArrowUp') next = message.y - 44;
|
|
470
|
+
if (key === 'PageDown' || (key === ' ' && !message.shift)) next = message.y + message.height * .85;
|
|
471
|
+
if (key === 'PageUp' || (key === ' ' && message.shift)) next = message.y - message.height * .85;
|
|
472
|
+
if (key === 'Home') next = 0;
|
|
473
|
+
if (key === 'End') next = message.max;
|
|
474
|
+
if (next !== null) {
|
|
475
|
+
markScrollOwner(side);
|
|
476
|
+
setLinkedScroll(side, next);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
addEventListener('message', (event) => {
|
|
482
|
+
const message = event.data || {};
|
|
483
|
+
const side = message.side;
|
|
484
|
+
if (!['dev', 'live'].includes(side)
|
|
485
|
+
|| (!config.hosted && event.origin !== config.frameOrigins[side])
|
|
486
|
+
|| message.source !== 'sitedrift-frame'
|
|
487
|
+
|| event.source !== frame(side).contentWindow) return;
|
|
488
|
+
if (message.type === 'ready') {
|
|
489
|
+
renderMetadata(side, message);
|
|
490
|
+
fetchStatus(side, message.route || '/');
|
|
491
|
+
applyFrameSettings(side);
|
|
492
|
+
} else if (message.type === 'scroll') {
|
|
493
|
+
frameState[side] = { y: Number(message.y) || 0, max: Number(message.max) || 0 };
|
|
494
|
+
syncFrom(side);
|
|
495
|
+
} else if (message.type === 'wheel') {
|
|
496
|
+
const delta = message.mode === 1 ? message.delta * 18
|
|
497
|
+
: message.mode === 2 ? message.delta * message.height : message.delta;
|
|
498
|
+
markScrollOwner(side);
|
|
499
|
+
setLinkedScroll(side, frameState[side].y + delta);
|
|
500
|
+
} else if (message.type === 'navigate') {
|
|
501
|
+
go(message.route);
|
|
502
|
+
} else if (message.type === 'key') {
|
|
503
|
+
runFrameKey(message.key, side, message);
|
|
504
|
+
}
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
scrollButton.addEventListener('click', () => {
|
|
508
|
+
syncScroll = !syncScroll;
|
|
509
|
+
scrollButton.classList.toggle('active', syncScroll);
|
|
510
|
+
saveBool('scroll', 'site-compare-scroll', syncScroll);
|
|
511
|
+
for (const side of ['dev', 'live']) applyFrameSettings(side);
|
|
512
|
+
renderSettings();
|
|
513
|
+
if (syncScroll) syncFrom(focusSide, true);
|
|
514
|
+
});
|
|
515
|
+
function renderSetting(button, active, stateText) {
|
|
516
|
+
button.classList.toggle('active', active);
|
|
517
|
+
button.querySelector('.state').textContent = stateText;
|
|
518
|
+
button.setAttribute('aria-pressed', active ? 'true' : 'false');
|
|
519
|
+
}
|
|
520
|
+
function renderSettings() {
|
|
521
|
+
renderSetting(mobileButton, mobileMode, mobileMode ? 'On' : 'Off');
|
|
522
|
+
renderSetting(mirrorButton, mirrorLinks, mirrorLinks ? 'On' : 'Off');
|
|
523
|
+
renderSetting(scrollModeButton, scrollMode === 'exact', scrollMode === 'exact' ? 'Exact' : 'Proportional');
|
|
524
|
+
scrollButton.title = syncScroll ? 'Locked scrolling is on' : 'Locked scrolling is off';
|
|
525
|
+
scrollButton.setAttribute('aria-pressed', syncScroll ? 'true' : 'false');
|
|
526
|
+
}
|
|
527
|
+
function renderScrollMode() {
|
|
528
|
+
document.querySelector('[data-scroll-label]').textContent =
|
|
529
|
+
scrollMode === 'exact' ? 'Locked scroll' : 'Ratio scroll';
|
|
530
|
+
renderSettings();
|
|
531
|
+
}
|
|
532
|
+
scrollModeButton.addEventListener('click', () => {
|
|
533
|
+
scrollMode = scrollMode === 'exact' ? 'ratio' : 'exact';
|
|
534
|
+
localStorage.setItem('site-compare-scroll-mode', scrollMode);
|
|
535
|
+
setUrlParam('scrollMode', scrollMode);
|
|
536
|
+
renderScrollMode();
|
|
537
|
+
for (const side of ['dev', 'live']) applyFrameSettings(side);
|
|
538
|
+
if (syncScroll) syncFrom(focusSide, true);
|
|
539
|
+
});
|
|
540
|
+
mirrorButton.addEventListener('click', () => {
|
|
541
|
+
mirrorLinks = !mirrorLinks;
|
|
542
|
+
saveBool('mirror', 'site-compare-mirror', mirrorLinks);
|
|
543
|
+
for (const side of ['dev', 'live']) applyFrameSettings(side);
|
|
544
|
+
renderSettings();
|
|
545
|
+
});
|
|
546
|
+
mobileButton.addEventListener('click', () => {
|
|
547
|
+
mobileMode = !mobileMode;
|
|
548
|
+
app.classList.toggle('mobile', mobileMode);
|
|
549
|
+
localStorage.setItem('site-compare-mode', mobileMode ? 'mobile' : 'desktop');
|
|
550
|
+
setUrlParam('mode', mobileMode ? 'mobile' : 'desktop');
|
|
551
|
+
renderSettings();
|
|
552
|
+
});
|
|
553
|
+
function setOverlayAmount(value) {
|
|
554
|
+
overlayAmount = Math.max(0, Math.min(100, Math.round(value)));
|
|
555
|
+
root.style.setProperty('--overlay', (overlayAmount / 100).toFixed(3));
|
|
556
|
+
for (const slider of overlaySliders) slider.value = String(overlayAmount);
|
|
557
|
+
localStorage.setItem('site-compare-overlay-amount', String(overlayAmount));
|
|
558
|
+
setUrlParam('overlayAmount', overlayAmount);
|
|
559
|
+
}
|
|
560
|
+
function renderModes() {
|
|
561
|
+
for (const button of modeButtons) {
|
|
562
|
+
const active = button.dataset.mode === viewMode;
|
|
563
|
+
button.classList.toggle('active', active);
|
|
564
|
+
button.setAttribute('aria-pressed', active ? 'true' : 'false');
|
|
565
|
+
}
|
|
566
|
+
const diffActive = viewMode === 'overlay' && overlayBlend === 'difference';
|
|
567
|
+
for (const button of blendButtons) {
|
|
568
|
+
button.classList.toggle('active', diffActive);
|
|
569
|
+
button.setAttribute('aria-pressed', diffActive ? 'true' : 'false');
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
// Split / Solo / Overlay are the mutually-exclusive layouts; Diff is the
|
|
573
|
+
// overlay's blend (the slider's far end), toggled within Overlay.
|
|
574
|
+
function setMode(mode) {
|
|
575
|
+
if (!['split', 'solo', 'overlay'].includes(mode)) mode = 'split';
|
|
576
|
+
viewMode = mode;
|
|
577
|
+
app.classList.toggle('solo', mode === 'solo');
|
|
578
|
+
app.classList.toggle('overlay', mode === 'overlay');
|
|
579
|
+
app.classList.toggle('diff', mode === 'overlay' && overlayBlend === 'difference');
|
|
580
|
+
app.dataset.focus = focusSide;
|
|
581
|
+
localStorage.setItem('site-compare-view', mode);
|
|
582
|
+
setUrlParam('view', mode === 'split' ? null : mode);
|
|
583
|
+
renderModes();
|
|
584
|
+
applyOrder();
|
|
585
|
+
// Overlay forces scroll-lock, so refresh scrollbar hiding + re-align.
|
|
586
|
+
for (const side of ['dev', 'live']) applyFrameSettings(side);
|
|
587
|
+
if (stacked()) alignSide(order[1], order[0]);
|
|
588
|
+
}
|
|
589
|
+
function setOverlayBlend(blend) {
|
|
590
|
+
overlayBlend = blend === 'difference' ? 'difference' : 'opacity';
|
|
591
|
+
app.classList.toggle('diff', viewMode === 'overlay' && overlayBlend === 'difference');
|
|
592
|
+
localStorage.setItem('site-compare-overlay-blend', overlayBlend);
|
|
593
|
+
setUrlParam('overlayBlend', overlayBlend === 'difference' ? 'difference' : null);
|
|
594
|
+
renderModes();
|
|
595
|
+
}
|
|
596
|
+
for (const button of modeButtons) button.addEventListener('click', () => setMode(button.dataset.mode));
|
|
597
|
+
for (const slider of overlaySliders) slider.addEventListener('input', () => {
|
|
598
|
+
if (viewMode !== 'overlay') setMode('overlay');
|
|
599
|
+
if (overlayBlend === 'difference') setOverlayBlend('opacity');
|
|
600
|
+
setOverlayAmount(Number(slider.value));
|
|
601
|
+
});
|
|
602
|
+
for (const button of blendButtons) button.addEventListener('click', () => {
|
|
603
|
+
if (viewMode !== 'overlay') setMode('overlay');
|
|
604
|
+
setOverlayBlend(overlayBlend === 'difference' ? 'opacity' : 'difference');
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
function setCompact(value) {
|
|
608
|
+
compactMode = value;
|
|
609
|
+
app.classList.toggle('compact', compactMode);
|
|
610
|
+
saveBool('compact', 'site-compare-compact', compactMode);
|
|
611
|
+
}
|
|
612
|
+
for (const button of document.querySelectorAll('[data-action="compact"]')) {
|
|
613
|
+
button.addEventListener('click', () => setCompact(!compactMode));
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function applyNotes(notes) {
|
|
617
|
+
const list = Array.isArray(notes) ? notes : [];
|
|
618
|
+
const signature = JSON.stringify(list);
|
|
619
|
+
if (signature === notesSignature) return;
|
|
620
|
+
notesSignature = signature;
|
|
621
|
+
reviewNotes = list;
|
|
622
|
+
renderNotes();
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
async function notesPull() {
|
|
626
|
+
if (config.localNotes) {
|
|
627
|
+
try { applyNotes(JSON.parse(localStorage.getItem(localNotesKey) || '[]')); } catch { applyNotes([]); }
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
try {
|
|
631
|
+
const res = await fetch(config.api + '/notes', { cache: 'no-store', headers: apiHeaders });
|
|
632
|
+
const data = await res.json();
|
|
633
|
+
applyNotes(data.notes);
|
|
634
|
+
} catch {}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
async function notesPost(op) {
|
|
638
|
+
if (config.localNotes) {
|
|
639
|
+
let notes = [...reviewNotes];
|
|
640
|
+
if (op.op === 'add') {
|
|
641
|
+
notes.push({
|
|
642
|
+
id: crypto.randomUUID ? crypto.randomUUID() : String(Date.now()),
|
|
643
|
+
text: op.text,
|
|
644
|
+
author: op.author,
|
|
645
|
+
route: op.route,
|
|
646
|
+
side: op.side || null,
|
|
647
|
+
done: false,
|
|
648
|
+
createdAt: new Date().toISOString(),
|
|
649
|
+
});
|
|
650
|
+
} else if (op.op === 'toggle') {
|
|
651
|
+
notes = notes.map((note) => note.id === op.id ? { ...note, done: !note.done } : note);
|
|
652
|
+
} else if (op.op === 'remove') {
|
|
653
|
+
notes = notes.filter((note) => note.id !== op.id);
|
|
654
|
+
} else if (op.op === 'clear') {
|
|
655
|
+
notes = [];
|
|
656
|
+
}
|
|
657
|
+
localStorage.setItem(localNotesKey, JSON.stringify(notes));
|
|
658
|
+
applyNotes(notes);
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
try {
|
|
662
|
+
const res = await fetch(config.api + '/notes', {
|
|
663
|
+
method: 'POST',
|
|
664
|
+
headers: apiHeaders,
|
|
665
|
+
body: JSON.stringify(op),
|
|
666
|
+
});
|
|
667
|
+
const data = await res.json();
|
|
668
|
+
applyNotes(data.notes);
|
|
669
|
+
} catch {}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function authorClass(name) {
|
|
673
|
+
const who = String(name || '').toLowerCase();
|
|
674
|
+
return who === 'joe' ? 'joe' : who === 'claude' ? 'claude' : 'other';
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function renderNotes() {
|
|
678
|
+
noteList.replaceChildren();
|
|
679
|
+
for (const note of reviewNotes) {
|
|
680
|
+
const item = document.createElement('li');
|
|
681
|
+
if (note.done) item.classList.add('done');
|
|
682
|
+
|
|
683
|
+
const metaRow = document.createElement('div');
|
|
684
|
+
metaRow.className = 'note-meta';
|
|
685
|
+
const who = document.createElement('span');
|
|
686
|
+
who.className = 'note-author ' + authorClass(note.author);
|
|
687
|
+
who.textContent = note.author || 'note';
|
|
688
|
+
metaRow.append(who);
|
|
689
|
+
const where = [note.side ? note.side.toUpperCase() : '', note.route && note.route !== '/' ? note.route : '']
|
|
690
|
+
.filter(Boolean).join(' · ');
|
|
691
|
+
if (where) {
|
|
692
|
+
const tag = document.createElement('span');
|
|
693
|
+
tag.className = 'note-where';
|
|
694
|
+
tag.textContent = where;
|
|
695
|
+
metaRow.append(tag);
|
|
696
|
+
}
|
|
697
|
+
item.append(metaRow);
|
|
698
|
+
|
|
699
|
+
const text = document.createElement('div');
|
|
700
|
+
text.className = 'note-text';
|
|
701
|
+
text.textContent = note.text;
|
|
702
|
+
if (note.route) {
|
|
703
|
+
text.classList.add('note-go');
|
|
704
|
+
text.title = 'Go to ' + note.route + (note.side ? ' · ' + note.side.toUpperCase() : '');
|
|
705
|
+
text.addEventListener('click', () => {
|
|
706
|
+
if (note.side) { focusSide = note.side; app.dataset.focus = focusSide; renderModes(); }
|
|
707
|
+
go(note.route);
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
item.append(text);
|
|
711
|
+
|
|
712
|
+
const toggle = document.createElement('button');
|
|
713
|
+
toggle.className = 'note-toggle';
|
|
714
|
+
toggle.textContent = note.done ? '↺' : '✓';
|
|
715
|
+
toggle.title = note.done ? 'Reopen note' : 'Mark done';
|
|
716
|
+
toggle.setAttribute('aria-label', toggle.title);
|
|
717
|
+
toggle.addEventListener('click', () => notesPost({ op: 'toggle', id: note.id }));
|
|
718
|
+
item.append(toggle);
|
|
719
|
+
|
|
720
|
+
const copy = document.createElement('button');
|
|
721
|
+
copy.className = 'note-copy';
|
|
722
|
+
copy.title = 'Copy a link to this note';
|
|
723
|
+
copy.setAttribute('aria-label', 'Copy link to this note');
|
|
724
|
+
copy.append(copyIcon());
|
|
725
|
+
copy.addEventListener('click', async () => {
|
|
726
|
+
const url = new URL(location.href);
|
|
727
|
+
url.searchParams.set('path', note.route || '/');
|
|
728
|
+
await navigator.clipboard.writeText(url.href);
|
|
729
|
+
showToast('Note link copied');
|
|
730
|
+
});
|
|
731
|
+
item.append(copy);
|
|
732
|
+
|
|
733
|
+
const remove = document.createElement('button');
|
|
734
|
+
remove.className = 'remove-note';
|
|
735
|
+
remove.textContent = '×';
|
|
736
|
+
remove.setAttribute('aria-label', 'Remove note');
|
|
737
|
+
remove.addEventListener('click', () => notesPost({ op: 'remove', id: note.id }));
|
|
738
|
+
item.append(remove);
|
|
739
|
+
|
|
740
|
+
noteList.append(item);
|
|
741
|
+
}
|
|
742
|
+
const open = reviewNotes.filter((note) => !note.done).length;
|
|
743
|
+
for (const count of document.querySelectorAll('[data-action="notes"] .count')) {
|
|
744
|
+
count.textContent = String(open);
|
|
745
|
+
count.style.display = open ? '' : 'none';
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
const dockButton = document.querySelector('[data-action="notes-dock"]');
|
|
750
|
+
function applyDock() {
|
|
751
|
+
// Dock pushes the panes aside; float overlays them.
|
|
752
|
+
app.classList.toggle('drawer-dock', notesOpen && dockMode);
|
|
753
|
+
dockButton.classList.toggle('active', dockMode);
|
|
754
|
+
dockButton.setAttribute('aria-pressed', dockMode ? 'true' : 'false');
|
|
755
|
+
}
|
|
756
|
+
function setNotesOpen(value) {
|
|
757
|
+
notesOpen = value;
|
|
758
|
+
notesDrawer.classList.toggle('open', notesOpen);
|
|
759
|
+
setUrlParam('notes', notesOpen ? '1' : '0');
|
|
760
|
+
applyDock();
|
|
761
|
+
if (notesOpen) noteInput.focus();
|
|
762
|
+
}
|
|
763
|
+
dockButton.addEventListener('click', () => {
|
|
764
|
+
dockMode = !dockMode;
|
|
765
|
+
saveBool('dock', 'site-compare-dock', dockMode);
|
|
766
|
+
applyDock();
|
|
767
|
+
});
|
|
768
|
+
for (const button of document.querySelectorAll('[data-action="notes"]')) {
|
|
769
|
+
button.addEventListener('click', () => setNotesOpen(!notesOpen));
|
|
770
|
+
}
|
|
771
|
+
document.querySelector('[data-action="notes-close"]').addEventListener('click', () => setNotesOpen(false));
|
|
772
|
+
addEventListener('keydown', (event) => {
|
|
773
|
+
if (event.key !== 'Escape') return;
|
|
774
|
+
let handled = false;
|
|
775
|
+
for (const details of document.querySelectorAll('details[open]')) {
|
|
776
|
+
details.removeAttribute('open');
|
|
777
|
+
handled = true;
|
|
778
|
+
}
|
|
779
|
+
if (notesOpen) {
|
|
780
|
+
setNotesOpen(false);
|
|
781
|
+
handled = true;
|
|
782
|
+
}
|
|
783
|
+
if (handled && (event.target === noteInput || event.target === routeInput)) event.target.blur();
|
|
784
|
+
});
|
|
785
|
+
// Auto-grow the compose box to its content (scroll past a cap), with a
|
|
786
|
+
// floor the user can raise by dragging the top grip.
|
|
787
|
+
const NOTE_MIN = 76;
|
|
788
|
+
let noteFloor = NOTE_MIN;
|
|
789
|
+
function autosizeNote() {
|
|
790
|
+
const hardMax = Math.round(innerHeight * 0.6);
|
|
791
|
+
noteInput.style.height = 'auto';
|
|
792
|
+
const needed = noteInput.scrollHeight;
|
|
793
|
+
const height = Math.min(hardMax, Math.max(NOTE_MIN, noteFloor, needed));
|
|
794
|
+
noteInput.style.height = height + 'px';
|
|
795
|
+
noteInput.style.overflowY = needed > height ? 'auto' : 'hidden';
|
|
796
|
+
}
|
|
797
|
+
noteInput.addEventListener('input', autosizeNote);
|
|
798
|
+
const noteGrip = document.querySelector('.note-grip');
|
|
799
|
+
noteGrip.addEventListener('pointerdown', (event) => {
|
|
800
|
+
noteGrip.setPointerCapture(event.pointerId);
|
|
801
|
+
const startY = event.clientY;
|
|
802
|
+
const startHeight = noteInput.offsetHeight;
|
|
803
|
+
const onMove = (move) => {
|
|
804
|
+
noteFloor = Math.max(NOTE_MIN, Math.min(Math.round(innerHeight * 0.6), startHeight + (startY - move.clientY)));
|
|
805
|
+
autosizeNote();
|
|
806
|
+
};
|
|
807
|
+
const onUp = (up) => {
|
|
808
|
+
noteGrip.releasePointerCapture(up.pointerId);
|
|
809
|
+
noteGrip.removeEventListener('pointermove', onMove);
|
|
810
|
+
noteGrip.removeEventListener('pointerup', onUp);
|
|
811
|
+
};
|
|
812
|
+
noteGrip.addEventListener('pointermove', onMove);
|
|
813
|
+
noteGrip.addEventListener('pointerup', onUp);
|
|
814
|
+
});
|
|
815
|
+
document.querySelector('[data-action="note-add"]').addEventListener('click', () => {
|
|
816
|
+
const text = noteInput.value.trim();
|
|
817
|
+
if (!text) return;
|
|
818
|
+
noteInput.value = '';
|
|
819
|
+
autosizeNote();
|
|
820
|
+
const side = viewMode === 'solo' ? focusSide : null;
|
|
821
|
+
notesPost({ op: 'add', text, author: config.author || 'joe', route: routeInput.value, side });
|
|
822
|
+
});
|
|
823
|
+
noteInput.addEventListener('keydown', (event) => {
|
|
824
|
+
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
|
|
825
|
+
document.querySelector('[data-action="note-add"]').click();
|
|
826
|
+
}
|
|
827
|
+
});
|
|
828
|
+
document.querySelector('[data-action="note-export"]').addEventListener('click', () => {
|
|
829
|
+
const link = document.createElement('a');
|
|
830
|
+
if (config.localNotes) {
|
|
831
|
+
const lines = ['# sitedrift review notes', ''];
|
|
832
|
+
for (const note of reviewNotes) {
|
|
833
|
+
lines.push(`- [${note.done ? 'x' : ' '}] ${note.text} (${note.side || 'both'} ${note.route || '/'})`);
|
|
834
|
+
}
|
|
835
|
+
link.href = URL.createObjectURL(new Blob([lines.join('\n') + '\n'], { type: 'text/markdown' }));
|
|
836
|
+
} else {
|
|
837
|
+
link.href = '/notes.md';
|
|
838
|
+
}
|
|
839
|
+
link.download = 'site-compare-notes.md';
|
|
840
|
+
link.click();
|
|
841
|
+
showToast('Exported notes .md');
|
|
842
|
+
});
|
|
843
|
+
const vaultButton = document.querySelector('[data-action="note-vault"]');
|
|
844
|
+
if (config.vault) vaultButton.hidden = false;
|
|
845
|
+
vaultButton.addEventListener('click', async () => {
|
|
846
|
+
try {
|
|
847
|
+
const res = await fetch(config.api + '/notes/save', { method: 'POST', headers: apiHeaders, body: '{}' });
|
|
848
|
+
const data = await res.json();
|
|
849
|
+
showToast(data.ok ? 'Saved to vault' : (data.error || 'Vault save failed'));
|
|
850
|
+
} catch {
|
|
851
|
+
showToast('Vault save failed');
|
|
852
|
+
}
|
|
853
|
+
});
|
|
854
|
+
const localNotesNotice = document.querySelector('.local-notes-notice');
|
|
855
|
+
if (config.localNotes) localNotesNotice.hidden = false;
|
|
856
|
+
|
|
857
|
+
divider.addEventListener('pointerdown', (event) => {
|
|
858
|
+
divider.setPointerCapture(event.pointerId);
|
|
859
|
+
app.classList.add('dragging');
|
|
860
|
+
divider.dataset.pointerDrag = '1';
|
|
861
|
+
});
|
|
862
|
+
divider.addEventListener('pointermove', (event) => {
|
|
863
|
+
if (!divider.hasPointerCapture(event.pointerId)) return;
|
|
864
|
+
setSplit(event.clientX / innerWidth * 100);
|
|
865
|
+
});
|
|
866
|
+
divider.addEventListener('pointerup', (event) => {
|
|
867
|
+
divider.releasePointerCapture(event.pointerId);
|
|
868
|
+
app.classList.remove('dragging');
|
|
869
|
+
divider.blur();
|
|
870
|
+
delete divider.dataset.pointerDrag;
|
|
871
|
+
});
|
|
872
|
+
divider.addEventListener('keydown', (event) => {
|
|
873
|
+
const current = parseFloat(getComputedStyle(root).getPropertyValue('--split'));
|
|
874
|
+
if (event.key === 'ArrowLeft') setSplit(current - (event.shiftKey ? 10 : 2));
|
|
875
|
+
if (event.key === 'ArrowRight') setSplit(current + (event.shiftKey ? 10 : 2));
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
document.querySelector('[data-action="go"]').addEventListener('click', () => go());
|
|
879
|
+
for (const button of document.querySelectorAll('[data-action="reload"]')) {
|
|
880
|
+
button.addEventListener('click', () => {
|
|
881
|
+
for (const side of ['dev', 'live']) framePost(side, 'reload');
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
document.querySelector('[data-action="swap"]').addEventListener('click', () => {
|
|
885
|
+
if (viewMode === 'solo') {
|
|
886
|
+
const nextSide = focusSide === 'dev' ? 'live' : 'dev';
|
|
887
|
+
if (syncScroll) alignSide(focusSide, nextSide);
|
|
888
|
+
focusSide = nextSide;
|
|
889
|
+
app.dataset.focus = focusSide;
|
|
890
|
+
setUrlParam('focus', focusSide);
|
|
891
|
+
renderSettings();
|
|
892
|
+
} else {
|
|
893
|
+
order.reverse();
|
|
894
|
+
applyOrder();
|
|
895
|
+
updateDocTitle();
|
|
896
|
+
setUrlParam('swap', order[0] === 'live' ? '1' : '0');
|
|
897
|
+
}
|
|
898
|
+
});
|
|
899
|
+
// Opening one Google preview opens both, anchored under their buttons.
|
|
900
|
+
for (const summary of document.querySelectorAll('.label details > summary')) {
|
|
901
|
+
summary.addEventListener('click', (event) => {
|
|
902
|
+
event.preventDefault();
|
|
903
|
+
setGoogleOpen(!googleOpen());
|
|
904
|
+
});
|
|
905
|
+
}
|
|
906
|
+
document.addEventListener('click', (event) => {
|
|
907
|
+
for (const details of document.querySelectorAll('details.settings[open], details.help[open]')) {
|
|
908
|
+
if (!details.contains(event.target)) details.removeAttribute('open');
|
|
909
|
+
}
|
|
910
|
+
if (googleOpen() && !event.target.closest('.label')) setGoogleOpen(false);
|
|
911
|
+
if (notesOpen && !dockMode && !event.target.closest('.review-drawer') && !event.target.closest('[data-action="notes"]')) {
|
|
912
|
+
setNotesOpen(false);
|
|
913
|
+
}
|
|
914
|
+
});
|
|
915
|
+
document.addEventListener('pointerup', (event) => {
|
|
916
|
+
if (event.target.closest('input, textarea')) return;
|
|
917
|
+
const control = event.target.closest('button, summary');
|
|
918
|
+
if (control && control !== document.activeElement) return;
|
|
919
|
+
control?.blur();
|
|
920
|
+
getSelection()?.removeAllRanges();
|
|
921
|
+
});
|
|
922
|
+
addEventListener('resize', () => {
|
|
923
|
+
for (const details of document.querySelectorAll('.label details[open]')) positionSeoCard(details);
|
|
924
|
+
});
|
|
925
|
+
routeInput.addEventListener('keydown', (event) => {
|
|
926
|
+
if (event.key === 'Enter') go();
|
|
927
|
+
});
|
|
928
|
+
addEventListener('keydown', (event) => {
|
|
929
|
+
if (event.target === routeInput || event.target === noteInput) return;
|
|
930
|
+
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
|
931
|
+
if (event.key === 'r') document.querySelector('[data-action="reload"]').click();
|
|
932
|
+
if (event.key === 's') document.querySelector('[data-action="swap"]').click();
|
|
933
|
+
if (event.key === 'o') setMode(viewMode === 'overlay' ? 'split' : 'overlay');
|
|
934
|
+
if (event.key === 'd') {
|
|
935
|
+
if (viewMode === 'overlay' && overlayBlend === 'difference') setMode('split');
|
|
936
|
+
else { setMode('overlay'); setOverlayBlend('difference'); }
|
|
937
|
+
}
|
|
938
|
+
if (event.key === '0') setSplit(50);
|
|
939
|
+
if (event.key === '/') { event.preventDefault(); routeInput.focus(); routeInput.select(); }
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
const initialSplit = Number(params.get('split') || localStorage.getItem('site-compare-split')) || 50;
|
|
943
|
+
scrollButton.classList.toggle('active', syncScroll);
|
|
944
|
+
renderScrollMode();
|
|
945
|
+
app.classList.toggle('mobile', mobileMode);
|
|
946
|
+
app.classList.toggle('compact', compactMode);
|
|
947
|
+
app.dataset.focus = focusSide;
|
|
948
|
+
setOverlayAmount(overlayAmount);
|
|
949
|
+
renderSettings();
|
|
950
|
+
notesDrawer.classList.toggle('open', notesOpen);
|
|
951
|
+
applyDock();
|
|
952
|
+
renderNotes();
|
|
953
|
+
autosizeNote();
|
|
954
|
+
setSplit(initialSplit);
|
|
955
|
+
setMode(viewMode);
|
|
956
|
+
go(params.get('path') || config.initialPath || '/');
|
|
957
|
+
notesPull();
|
|
958
|
+
if (!config.localNotes) setInterval(notesPull, 4000);
|