@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.
@@ -0,0 +1,273 @@
1
+ // ---------------------------------------------------------------- devices
2
+
3
+ // Desktop, tablet and mobile side by side. Each device is a real iframe of the
4
+ // page at that width (so media queries apply), running its own overlay: notes
5
+ // made in the mobile frame are recorded at 390px. The top window's toolbar drives
6
+ // all frames, and scrolling one scrolls the others: the frame you are using leads,
7
+ // the others follow it instantly, and nothing a follower does is sent back.
8
+
9
+ const DEVICES = [
10
+ { id: 'desktop', label: 'Desktop', w: 1440, h: 900 },
11
+ { id: 'tablet', label: 'Tablet', w: 820, h: 1180 },
12
+ { id: 'mobile', label: 'Mobile', w: 390, h: 844 },
13
+ ];
14
+ const FRAME_PREFIX = 'redline-frame|';
15
+ const FRAME = window.parent !== window && typeof window.name === 'string' && window.name.startsWith(FRAME_PREFIX)
16
+ ? { device: window.name.slice(FRAME_PREFIX.length) }
17
+ : null;
18
+
19
+ let uiZoom = 1; // in a scaled-down frame, the composer is zoomed back up to normal size
20
+ let stage = null;
21
+ let frames = []; // [{ d, iframe, box }]
22
+ let syncScroll = prefs('syncScroll') !== false;
23
+ let leader = null; // the frame the reviewer last touched: the only one whose scrolling is followed
24
+ let shown = new Set(prefs('devicesShown') || DEVICES.map((d) => d.id)); // which devices are on stage
25
+
26
+ const post = (win, msg) => win && win.postMessage({ redline: true, ...msg }, location.origin);
27
+
28
+ // ---- top window
29
+
30
+ function toggleDevices() {
31
+ if (FRAME) return post(window.parent, { type: 'key', action: 'devices' });
32
+ state.devices ? closeDevices() : openDevices();
33
+ }
34
+
35
+ function forwardToFrames(msg) {
36
+ for (const f of frames) post(f.iframe.contentWindow, msg);
37
+ }
38
+
39
+ function openDevices() {
40
+ closeComposer();
41
+ if (state.panel) togglePanel();
42
+ state.devices = true;
43
+ host.dataset.devices = '';
44
+ frames = DEVICES.filter((d) => shown.has(d.id)).map((d) => {
45
+ const iframe = h('iframe', { name: FRAME_PREFIX + d.id, src: location.href, title: `${d.label} (${d.w}px)` });
46
+ iframe.style.width = d.w + 'px';
47
+ iframe.style.height = d.h + 'px';
48
+ return { d, iframe, box: h('div', { class: 'device-box' }, iframe), label: h('div', { class: 'device-label' }, `${d.label} · ${d.w}px`) };
49
+ });
50
+ stage = h('div', { class: 'stage', role: 'region', 'aria-label': 'Devices' },
51
+ h('div', { class: 'stage-head' },
52
+ h('b', {}, 'Devices'),
53
+ h('span', { class: 'hint' }, 'Select or draw in any frame: notes record that screen width.'),
54
+ h('span', { class: 'grow' }),
55
+ // Fewer devices on stage means bigger frames.
56
+ DEVICES.map((d) =>
57
+ h('label', {},
58
+ h('input', {
59
+ type: 'checkbox',
60
+ checked: shown.has(d.id),
61
+ onchange: (e) => {
62
+ e.target.checked ? shown.add(d.id) : shown.size > 1 ? shown.delete(d.id) : (e.target.checked = true);
63
+ prefs('devicesShown', [...shown]);
64
+ stage.remove();
65
+ openDevices();
66
+ },
67
+ }),
68
+ d.label)),
69
+ h('span', { class: 'sep-v' }),
70
+ h('label', {},
71
+ h('input', { type: 'checkbox', checked: syncScroll, onchange: (e) => prefs('syncScroll', (syncScroll = e.target.checked)) }),
72
+ 'Sync scroll'),
73
+ h('button', { class: 'btn small', onclick: closeDevices, html: ICONS.close + ' Close' }),
74
+ ),
75
+ h('div', { class: 'stage-frames' }, frames.map((f) => h('div', { class: 'device' }, f.label, f.box))),
76
+ );
77
+ root.insertBefore(stage, bar); // the toolbar stays on top
78
+ // The page underneath is fully covered by the stage: stop rendering it, so the
79
+ // three frames get the machine (the overlay lives outside <body> and stays).
80
+ hideUnderlay(true);
81
+ layoutDevices();
82
+ addEventListener('resize', layoutDevices);
83
+ renderBar();
84
+ }
85
+
86
+ function closeDevices() {
87
+ // Frames save any note in progress first (a note is kept, never silently dropped).
88
+ if (frames.length) {
89
+ forwardToFrames({ type: 'commit' });
90
+ const s = stage;
91
+ frames = [];
92
+ setTimeout(() => s && s.remove(), 400);
93
+ stage = null;
94
+ }
95
+ if (stage) stage.remove();
96
+ stage = null;
97
+ frames = [];
98
+ state.devices = false;
99
+ hideUnderlay(false);
100
+ delete host.dataset.devices;
101
+ removeEventListener('resize', layoutDevices);
102
+ renderBar();
103
+ load();
104
+ }
105
+
106
+ let underlay = null;
107
+ function hideUnderlay(on) {
108
+ const b = document.body;
109
+ if (!b) return;
110
+ if (on && underlay === null) {
111
+ underlay = b.style.getPropertyValue('content-visibility');
112
+ b.style.setProperty('content-visibility', 'hidden', 'important');
113
+ } else if (!on && underlay !== null) {
114
+ b.style.removeProperty('content-visibility');
115
+ if (underlay) b.style.setProperty('content-visibility', underlay);
116
+ underlay = null;
117
+ }
118
+ }
119
+
120
+ // Give every device the same on-screen height, as large as the window allows.
121
+ function layoutDevices() {
122
+ if (!stage) return;
123
+ const gaps = 24 * (frames.length - 1) + 32;
124
+ const height = Math.max(200, Math.min(innerHeight - 140, (innerWidth - gaps) / frames.reduce((sum, f) => sum + f.d.w / f.d.h, 0)));
125
+ for (const f of frames) {
126
+ const scale = height / f.d.h;
127
+ f.scale = scale;
128
+ f.iframe.style.transform = `scale(${scale})`;
129
+ f.box.style.width = f.d.w * scale + 'px';
130
+ f.box.style.height = height + 'px';
131
+ post(f.iframe.contentWindow, { type: 'scale', scale });
132
+ }
133
+ }
134
+
135
+ /** Push mode, verdict and preview settings to the frames (or, from a frame, up to the top window). */
136
+ function syncFrames() {
137
+ if (FRAME) return post(window.parent, { type: 'set', mode: state.mode, verdict: state.verdict });
138
+ for (const f of frames) post(f.iframe.contentWindow, { type: 'sync', mode: state.mode, verdict: state.verdict, previewEdits: state.previewEdits });
139
+ }
140
+
141
+ /** Open an item in the frame whose width it was made at. Returns false if not in Devices view. */
142
+ function focusInDevices(a) {
143
+ if (!state.devices || FRAME) return false;
144
+ const bp = breakpoint((a.viewport && a.viewport.w) || 1440);
145
+ const f = frames.find((x) => breakpoint(x.d.w) === bp) || frames[0];
146
+ post(f.iframe.contentWindow, { type: 'focus', id: a.id });
147
+ return true;
148
+ }
149
+
150
+ addEventListener('message', (e) => {
151
+ const msg = e.data;
152
+ if (!msg || msg.redline !== true || e.origin !== location.origin) return;
153
+ if (!FRAME) {
154
+ const from = frames.find((f) => f.iframe.contentWindow === e.source);
155
+ if (!from) return;
156
+ if (msg.type === 'ready') {
157
+ post(e.source, { type: 'scale', scale: from.scale || 1 });
158
+ post(e.source, { type: 'sync', mode: state.mode, verdict: state.verdict, previewEdits: state.previewEdits });
159
+ } else if (msg.type === 'set') {
160
+ if (msg.verdict !== state.verdict) setVerdict(msg.verdict);
161
+ if (msg.mode !== state.mode) setMode(msg.mode);
162
+ } else if (msg.type === 'key') {
163
+ // Shortcuts pressed inside a frame that belong to the whole view.
164
+ if (msg.action === 'devices') toggleDevices();
165
+ if (msg.action === 'panel') togglePanel();
166
+ if (msg.action === 'previews') togglePreviews();
167
+ } else if (msg.type === 'lead') {
168
+ leader = from;
169
+ } else if (msg.type === 'scroll' && syncScroll && from === leader) {
170
+ for (const f of frames) if (f !== from) post(f.iframe.contentWindow, { type: 'scroll', ratio: msg.ratio, anchor: msg.anchor });
171
+ }
172
+ return;
173
+ }
174
+ if (e.source !== window.parent) return;
175
+ if (msg.type === 'scale') {
176
+ // Zoom the composer back up, but not so much it swallows a small frame; pins likewise.
177
+ uiZoom = Math.min(1 / msg.scale, 1.6);
178
+ host.style.setProperty('--ui-zoom', String(uiZoom));
179
+ host.style.setProperty('--pin-scale', String(Math.min(1 / msg.scale, 2)));
180
+ } else if (msg.type === 'commit') {
181
+ closeComposer();
182
+ } else if (msg.type === 'hoverKey') {
183
+ hoverKey(msg.key);
184
+ } else if (msg.type === 'sync') {
185
+ // Applied directly (not through setMode) so it doesn't echo back up.
186
+ state.mode = msg.mode;
187
+ state.verdict = msg.verdict;
188
+ if (state.previewEdits !== msg.previewEdits) {
189
+ state.previewEdits = msg.previewEdits;
190
+ applyPreviews();
191
+ }
192
+ showHover(null);
193
+ renderBar();
194
+ } else if (msg.type === 'scroll') {
195
+ // Follow at the next frame, to the latest position only (a burst of wheel steps
196
+ // is one jump per frame, not a queue to work through).
197
+ const first = !followTo;
198
+ followTo = msg;
199
+ if (first) requestAnimationFrame(() => {
200
+ const m = followTo;
201
+ followTo = null;
202
+ scrollToAnchor(m.anchor, m.ratio);
203
+ });
204
+ } else if (msg.type === 'focus') {
205
+ const a = state.annotations.find((x) => x.id === msg.id);
206
+ if (a) focusAnnotation(a);
207
+ }
208
+ });
209
+
210
+ // ---- inside a frame
211
+
212
+ let followTo = null;
213
+ function initFrame() {
214
+ host.dataset.frame = '';
215
+ state.collapsed = false;
216
+ post(window.parent, { type: 'ready' });
217
+ // Only the reviewer's own scrolling leads: a wheel, a touch, a key or a click in this
218
+ // frame (a link's smooth scroll included, while it lasts). Scrolls we were told to do
219
+ // as a follower have no input behind them, so they are never sent back.
220
+ let userAt = -1e9, lastScroll = 0, leading = false, leadSent = 0;
221
+ const touched = () => {
222
+ userAt = performance.now();
223
+ if (userAt - leadSent > 250) {
224
+ leadSent = userAt;
225
+ post(window.parent, { type: 'lead' });
226
+ }
227
+ };
228
+ for (const t of ['wheel', 'touchstart', 'touchmove', 'keydown', 'pointerdown']) addEventListener(t, touched, { capture: true, passive: true });
229
+ let queued = false;
230
+ addEventListener(
231
+ 'scroll',
232
+ () => {
233
+ const now = performance.now();
234
+ leading = now - userAt < 700 || (leading && now - lastScroll < 200);
235
+ lastScroll = now;
236
+ if (!leading || queued) return;
237
+ queued = true;
238
+ requestAnimationFrame(() => {
239
+ queued = false;
240
+ const max = document.documentElement.scrollHeight - innerHeight;
241
+ post(window.parent, { type: 'scroll', ratio: max > 0 ? scrollY / max : 0, anchor: scrollAnchor() });
242
+ });
243
+ },
244
+ { passive: true },
245
+ );
246
+ }
247
+
248
+ // Scroll sync by content: the section at the top of one frame is brought to the
249
+ // top of the others (page lengths differ a lot between desktop and mobile).
250
+ const ANCHOR_Y = 80; // below a typical sticky header
251
+
252
+ function sectionOf(el) {
253
+ for (let n = el; n && n !== document.body; n = n.parentElement) {
254
+ if (n.id || /^(SECTION|HEADER|FOOTER|ARTICLE)$/.test(n.tagName) || (n.parentElement && /^(MAIN|BODY)$/.test(n.parentElement.tagName))) return n;
255
+ }
256
+ return null;
257
+ }
258
+
259
+ function scrollAnchor() {
260
+ const el = pageElementAt(innerWidth / 2, ANCHOR_Y);
261
+ const section = el && sectionOf(el);
262
+ if (!section) return null;
263
+ const r = section.getBoundingClientRect();
264
+ return { selector: cssPath(section), frac: r.height ? (ANCHOR_Y - r.top) / r.height : 0 };
265
+ }
266
+
267
+ function scrollToAnchor(anchor, ratio) {
268
+ const el = anchor && queryOne(anchor.selector);
269
+ const r = el && el.getBoundingClientRect();
270
+ // Instant: a page's own scroll-behavior: smooth would make every follower trail behind.
271
+ const top = r && r.height ? scrollY + r.top + anchor.frac * r.height - ANCHOR_Y : ratio * Math.max(0, document.documentElement.scrollHeight - innerHeight);
272
+ scrollTo({ top, behavior: 'instant' });
273
+ }
@@ -0,0 +1,389 @@
1
+ // ---------------------------------------------------------------- live edits
2
+
3
+ // The reviewer adjusts an element right on the page (size, spacing, colour,
4
+ // wording). Redline keeps the exact before → after values as the instruction for
5
+ // the agent, and previews them on the page until the agent has made them real.
6
+
7
+ const EDIT_GROUPS = [
8
+ {
9
+ name: 'Text',
10
+ fields: [
11
+ { prop: 'font-size', kind: 'len', min: 1 },
12
+ { prop: 'font-weight', kind: 'select', options: ['300', '400', '500', '600', '700', '800', '900'] },
13
+ { prop: 'line-height', kind: 'len', min: 0 },
14
+ { prop: 'letter-spacing', kind: 'len', step: 0.1 },
15
+ { prop: 'color', kind: 'color' },
16
+ { prop: 'text-align', kind: 'select', options: ['left', 'center', 'right', 'justify'] },
17
+ ],
18
+ },
19
+ {
20
+ name: 'Box',
21
+ fields: [
22
+ { prop: 'padding', kind: 'sides', min: 0 },
23
+ { prop: 'margin', kind: 'sides' },
24
+ { prop: 'gap', kind: 'len', min: 0 },
25
+ { prop: 'border-radius', kind: 'len', min: 0 },
26
+ { prop: 'background-color', kind: 'color' },
27
+ { prop: 'max-width', kind: 'len', min: 0 },
28
+ { prop: 'opacity', kind: 'num', step: 0.05, min: 0, max: 1 },
29
+ ],
30
+ },
31
+ ];
32
+ const SIDES = ['top', 'right', 'bottom', 'left'];
33
+
34
+ // ---- previews: inline !important styles, remembered so they can be undone
35
+
36
+ const previewed = new Map(); // element -> { props: Map(prop -> [value, priority]), text?: string }
37
+
38
+ function setPreview(el, prop, value) {
39
+ let rec = previewed.get(el);
40
+ if (!rec) previewed.set(el, (rec = { props: new Map() }));
41
+ if (!rec.props.has(prop)) rec.props.set(prop, [el.style.getPropertyValue(prop), el.style.getPropertyPriority(prop)]);
42
+ el.style.setProperty(prop, value, 'important');
43
+ }
44
+
45
+ function setTextPreview(el, text) {
46
+ let rec = previewed.get(el);
47
+ if (!rec) previewed.set(el, (rec = { props: new Map() }));
48
+ if (rec.text === undefined) rec.text = el.textContent;
49
+ el.textContent = text;
50
+ }
51
+
52
+ function clearPreviews() {
53
+ for (const [el, rec] of previewed) {
54
+ for (const [prop, [value, priority]] of rec.props) {
55
+ if (value) el.style.setProperty(prop, value, priority);
56
+ else el.style.removeProperty(prop);
57
+ }
58
+ if (rec.text !== undefined && el.textContent !== rec.text) el.textContent = rec.text;
59
+ }
60
+ previewed.clear();
61
+ clearVariantPreviews();
62
+ }
63
+
64
+ function editTargets(a, edits = a.edits) {
65
+ if (!edits) return [];
66
+ if (edits.scope === 'all' && edits.selector) {
67
+ try {
68
+ return [...document.querySelectorAll(edits.selector)].filter((el) => !isOurs(el));
69
+ } catch {
70
+ return [];
71
+ }
72
+ }
73
+ const el = anchorEl(a);
74
+ return el ? [el] : [];
75
+ }
76
+
77
+ function applyEdits(a, edits = a.edits) {
78
+ for (const el of editTargets(a, edits)) {
79
+ for (const p of edits.props || []) setPreview(el, p.prop, p.to);
80
+ }
81
+ // Wording only changes on the element itself, even when styles apply to all.
82
+ const el = anchorEl(a);
83
+ if (edits && edits.text && el) setTextPreview(el, edits.text.to);
84
+ }
85
+
86
+ /**
87
+ * Show every open item's proposed edits and chosen option on the page (unless the
88
+ * reviewer toggled to "original"). Resolved items aren't previewed: their change is in the code now.
89
+ */
90
+ function applyPreviews() {
91
+ clearPreviews();
92
+ if (!state.previewEdits || SHOT) return;
93
+ const editing = state.composer && state.composer.annotation && state.composer.annotation.id;
94
+ for (const a of state.annotations) {
95
+ if (a.status === 'resolved' || a.id === editing) continue;
96
+ if (a.edits) applyEdits(a);
97
+ if (a.chosen) applyVariant(a, a.chosen);
98
+ }
99
+ if (state.composer && state.composer.reapply) state.composer.reapply();
100
+ }
101
+
102
+ function hasPreviews() {
103
+ return state.annotations.some((a) => a.status !== 'resolved' && (a.edits || a.chosen));
104
+ }
105
+
106
+ function togglePreviews() {
107
+ if (FRAME) return post(window.parent, { type: 'key', action: 'previews' });
108
+ state.previewEdits = !state.previewEdits;
109
+ applyPreviews();
110
+ renderBar();
111
+ toast(state.previewEdits ? 'Showing your proposed changes' : 'Showing the page as built');
112
+ }
113
+
114
+ // ---- "all similar": tag + stable classes (h2.section-title), or for a
115
+ // class-less element, by its parent's classes (article.card > h3)
116
+
117
+ function classSelector(el) {
118
+ const classes = [...el.classList].filter((c) => /^[a-zA-Z][\w-]{0,40}$/.test(c) && !/(^(css|sc|jsx|svelte|emotion)-)|\d{3,}|^_/.test(c) && !STATE_CLASS.test(c));
119
+ return classes.length ? el.tagName.toLowerCase() + classes.map((c) => '.' + CSS.escape(c)).join('') : null;
120
+ }
121
+
122
+ function similarSelector(el) {
123
+ const own = classSelector(el);
124
+ if (own) return own;
125
+ const parent = el.parentElement && classSelector(el.parentElement);
126
+ return parent ? `${parent} > ${el.tagName.toLowerCase()}` : null;
127
+ }
128
+
129
+ // ---- reading current values
130
+
131
+ function px(v) {
132
+ const n = parseFloat(v);
133
+ return Number.isFinite(n) ? n : 0;
134
+ }
135
+
136
+ function currentValue(el, prop) {
137
+ const cs = getComputedStyle(el);
138
+ if (prop === 'line-height' && cs.lineHeight === 'normal') return (px(cs.fontSize) * 1.2).toFixed(1) + 'px';
139
+ if (prop === 'letter-spacing' && cs.letterSpacing === 'normal') return '0px';
140
+ if (prop === 'gap' && (cs.rowGap === 'normal' || !cs.rowGap)) return '0px';
141
+ if (prop === 'max-width' && cs.maxWidth === 'none') return 'none';
142
+ if (prop === 'color' || prop === 'background-color') return hex(cs.getPropertyValue(prop));
143
+ if (prop === 'gap') return cs.rowGap;
144
+ return cs.getPropertyValue(prop);
145
+ }
146
+
147
+ function toHex6(c) {
148
+ const v = hex(c);
149
+ return /^#[0-9a-f]{6}$/i.test(v) ? v : '#000000';
150
+ }
151
+
152
+ // ---- the Adjust panel
153
+
154
+ /**
155
+ * Build the Adjust panel for `el`. `saved` is the item's existing edits, if any.
156
+ * onChange() fires after every change so the composer can mark itself dirty.
157
+ */
158
+ function createEditor(el, saved, onChange) {
159
+ const baseline = new Map(); // prop -> computed value before any edit
160
+ const changes = new Map(); // prop -> new value
161
+ let scope = saved && saved.scope === 'all' ? 'all' : 'element';
162
+ const similar = similarSelector(el);
163
+ let text = saved && saved.text ? { ...saved.text } : null;
164
+
165
+ const read = (prop) => {
166
+ if (!baseline.has(prop)) baseline.set(prop, asBuilt(() => currentValue(el, prop)));
167
+ return changes.has(prop) ? changes.get(prop) : baseline.get(prop);
168
+ };
169
+ // Baselines must come from the page without our own previews on it.
170
+ const baseFromSaved = new Map((saved && saved.props ? saved.props : []).map((p) => [p.prop, p]));
171
+ for (const [prop, p] of baseFromSaved) {
172
+ baseline.set(prop, p.from);
173
+ changes.set(prop, p.to);
174
+ }
175
+
176
+ const targets = () => {
177
+ if (scope === 'all' && similar) {
178
+ try {
179
+ return [...document.querySelectorAll(similar)].filter((n) => !isOurs(n));
180
+ } catch {}
181
+ }
182
+ return [el];
183
+ };
184
+
185
+ function apply() {
186
+ for (const t of targets()) for (const [prop, value] of changes) setPreview(t, prop, value);
187
+ if (text) setTextPreview(el, text.to);
188
+ }
189
+
190
+ function set(prop, value) {
191
+ if (value === baseline.get(prop)) changes.delete(prop);
192
+ else changes.set(prop, value);
193
+ applyPreviews(); // re-derive from saved items, then layer this editor's changes on top
194
+ apply();
195
+ renderCount();
196
+ onChange();
197
+ }
198
+
199
+ const count = h('span', { class: 'adj-count' });
200
+ const renderCount = () => {
201
+ const n = changes.size + (text ? 1 : 0);
202
+ count.textContent = n ? `${n} change${n === 1 ? '' : 's'}` : '';
203
+ };
204
+
205
+ function numberField(prop, opts, labelText, sideProp) {
206
+ const p = sideProp || prop;
207
+ const value = read(p);
208
+ const unit = opts.kind === 'num' ? '' : 'px';
209
+ const input = h('input', { type: 'text', inputmode: 'decimal', class: 'adj-num', 'aria-label': p, value: value === 'none' ? '' : String(px(value)) });
210
+ if (value === 'none') input.placeholder = 'none';
211
+ const commit = (n) => {
212
+ if (opts.min !== undefined) n = Math.max(opts.min, n);
213
+ if (opts.max !== undefined) n = Math.min(opts.max, n);
214
+ n = Math.round(n * 100) / 100;
215
+ input.value = String(n);
216
+ set(p, n + unit);
217
+ };
218
+ input.addEventListener('change', () => {
219
+ const n = parseFloat(input.value);
220
+ if (Number.isFinite(n)) commit(n);
221
+ });
222
+ input.addEventListener('keydown', (e) => {
223
+ if (e.key === 'Enter') {
224
+ e.preventDefault(); // apply the value; don't submit the note
225
+ const n = parseFloat(input.value);
226
+ if (Number.isFinite(n)) commit(n);
227
+ return;
228
+ }
229
+ if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
230
+ e.preventDefault();
231
+ const step = (opts.step || 1) * (e.shiftKey ? 10 : 1);
232
+ commit((parseFloat(input.value) || 0) + (e.key === 'ArrowUp' ? step : -step));
233
+ });
234
+ // Drag the label sideways to scrub the value, like in Figma.
235
+ const lab = h('span', { class: 'adj-label', title: 'Drag to adjust · ↑↓ in the field (Shift ×10)' }, labelText);
236
+ lab.addEventListener('pointerdown', (e) => {
237
+ e.preventDefault();
238
+ lab.setPointerCapture(e.pointerId);
239
+ const start = parseFloat(input.value) || 0;
240
+ const x0 = e.clientX;
241
+ const move = (ev) => commit(start + Math.round((ev.clientX - x0) / 2) * (opts.step || 1) * (ev.shiftKey ? 10 : 1));
242
+ const up = () => {
243
+ lab.removeEventListener('pointermove', move);
244
+ lab.removeEventListener('pointerup', up);
245
+ };
246
+ lab.addEventListener('pointermove', move);
247
+ lab.addEventListener('pointerup', up);
248
+ });
249
+ return h('label', { class: 'adj-field' + (sideProp ? ' side' : '') }, lab, h('span', { class: 'adj-input' }, input, unit && !sideProp ? h('span', { class: 'adj-unit' }, unit) : null));
250
+ }
251
+
252
+ function field(f) {
253
+ if (f.kind === 'len' || f.kind === 'num') return h('div', { class: 'adj-row' }, numberField(f.prop, f, f.prop.replace(/-/g, ' ')));
254
+ if (f.kind === 'sides') {
255
+ return h('div', { class: 'adj-row' },
256
+ h('span', { class: 'adj-name' }, f.prop),
257
+ h('div', { class: 'adj-sides' }, SIDES.map((side) => numberField(f.prop, f, side[0].toUpperCase(), `${f.prop}-${side}`))),
258
+ );
259
+ }
260
+ if (f.kind === 'select') {
261
+ const cur = read(f.prop);
262
+ const sel = h('select', { class: 'adj-select', 'aria-label': f.prop }, f.options.map((o) => h('option', { value: o, selected: String(cur) === o }, o)));
263
+ if (!f.options.includes(String(cur))) sel.prepend(h('option', { value: cur, selected: true }, cur));
264
+ sel.addEventListener('change', () => set(f.prop, sel.value));
265
+ return h('div', { class: 'adj-row' }, h('span', { class: 'adj-name' }, f.prop.replace(/-/g, ' ')), sel);
266
+ }
267
+ if (f.kind === 'color') {
268
+ const cur = read(f.prop);
269
+ const swatch = h('input', { type: 'color', class: 'adj-color', 'aria-label': f.prop, value: toHex6(cur) });
270
+ const code = h('input', { type: 'text', class: 'adj-hex', 'aria-label': f.prop + ' value', value: cur });
271
+ swatch.addEventListener('input', () => {
272
+ code.value = swatch.value;
273
+ set(f.prop, swatch.value);
274
+ });
275
+ code.addEventListener('keydown', (e) => {
276
+ if (e.key === 'Enter') {
277
+ e.preventDefault();
278
+ code.dispatchEvent(new Event('change'));
279
+ }
280
+ });
281
+ code.addEventListener('change', () => {
282
+ swatch.value = toHex6(code.value);
283
+ set(f.prop, code.value.trim());
284
+ });
285
+ return h('div', { class: 'adj-row' }, h('span', { class: 'adj-name' }, f.prop.replace(/-/g, ' ')), h('span', { class: 'adj-colorwrap' }, swatch, code));
286
+ }
287
+ return null;
288
+ }
289
+
290
+ // Wording: only for elements whose content is plain text, so markup can't be lost.
291
+ const canEditText = el.children.length === 0 && (el.textContent || '').trim().length > 0;
292
+ const textBtn = h('button', { type: 'button', class: 'btn small', disabled: !canEditText, title: canEditText ? 'Type the new wording right on the page' : 'Select an element that holds only text to edit its wording' }, 'Edit text');
293
+ textBtn.addEventListener('click', () => {
294
+ const from = text ? text.from : el.textContent;
295
+ host.dataset.textEdit = '';
296
+ el.contentEditable = 'plaintext-only';
297
+ el.focus();
298
+ const range = document.createRange();
299
+ range.selectNodeContents(el);
300
+ getSelection().removeAllRanges();
301
+ getSelection().addRange(range);
302
+ toast('Type the new text · Enter or click away to finish');
303
+ const finish = () => {
304
+ el.removeEventListener('blur', finish);
305
+ el.removeEventListener('keydown', key);
306
+ el.contentEditable = 'false';
307
+ el.removeAttribute('contenteditable');
308
+ delete host.dataset.textEdit;
309
+ const to = el.textContent;
310
+ text = to !== from ? { from, to } : null;
311
+ // Put the original back under our preview bookkeeping, then preview the new wording.
312
+ el.textContent = from;
313
+ applyPreviews();
314
+ apply();
315
+ renderCount();
316
+ onChange();
317
+ if (state.composer) state.composer.form.querySelector('textarea').focus({ preventScroll: true });
318
+ };
319
+ const key = (e) => {
320
+ if (e.key === 'Enter' && !e.shiftKey) {
321
+ e.preventDefault();
322
+ el.blur();
323
+ } else if (e.key === 'Escape') {
324
+ el.textContent = from;
325
+ el.blur();
326
+ }
327
+ };
328
+ el.addEventListener('blur', finish);
329
+ el.addEventListener('keydown', key);
330
+ });
331
+
332
+ const similarCount = similar ? document.querySelectorAll(similar).length : 0;
333
+ const scopeBox = similarCount > 1
334
+ ? h('label', { class: 'adj-scope' },
335
+ h('input', {
336
+ type: 'checkbox',
337
+ checked: scope === 'all',
338
+ onchange: (e) => {
339
+ scope = e.target.checked ? 'all' : 'element';
340
+ applyPreviews();
341
+ apply();
342
+ onChange();
343
+ },
344
+ }),
345
+ `Apply to all ${similarCount} `, h('code', {}, similar))
346
+ : null;
347
+
348
+ const reset = h('button', {
349
+ type: 'button',
350
+ class: 'btn small',
351
+ onclick: () => {
352
+ changes.clear();
353
+ text = null;
354
+ applyPreviews();
355
+ renderCount();
356
+ onChange();
357
+ node.replaceWith((node = build()));
358
+ },
359
+ }, 'Reset');
360
+
361
+ function build() {
362
+ return h('div', { class: 'adjust' },
363
+ // Text starts open; Box only when it holds edits, to keep the panel short.
364
+ EDIT_GROUPS.map((g, i) =>
365
+ h('details', { class: 'adj-group', open: i === 0 || g.fields.some((f) => [...changes.keys()].some((p) => p.startsWith(f.prop))) },
366
+ h('summary', {}, g.name),
367
+ g.fields.map(field))),
368
+ h('div', { class: 'adj-foot' }, textBtn, reset, count),
369
+ scopeBox,
370
+ );
371
+ }
372
+ let node = build();
373
+ renderCount();
374
+
375
+ return {
376
+ get node() {
377
+ return node;
378
+ },
379
+ count: () => changes.size + (text ? 1 : 0),
380
+ countEl: count,
381
+ apply,
382
+ /** The edits to save, or null when nothing changed. */
383
+ edits() {
384
+ const props = [...changes].map(([prop, to]) => ({ prop, from: baseline.get(prop) || '', to }));
385
+ if (!props.length && !text) return null;
386
+ return { scope: scope === 'all' && similar ? 'all' : 'element', selector: scope === 'all' ? similar : cssPath(el), props, text: text || undefined };
387
+ },
388
+ };
389
+ }