webtweak 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,758 @@
1
+ /* webtweak Overlay - injected into the target page, not part of any source.
2
+ *
3
+ * Captures visual edits as Patches and POSTs them to the local server, which
4
+ * writes them to <page>.webtweak.json for Claude to reconcile. The Overlay only
5
+ * captures *intent* - it never rewrites source. See CONTEXT.md / ADR-0001.
6
+ *
7
+ * Note: the `wt-` class prefix is load-bearing - fingerprint() strips classes
8
+ * starting with `wt-`, so the Overlay must never add a non-`wt-` class to a page
9
+ * element or it would pollute the captured identity.
10
+ */
11
+ (function () {
12
+ "use strict";
13
+
14
+ // Idempotent: a second injection (SPA soft-nav, double include) is a no-op.
15
+ if (window.__WEBTWEAK_ACTIVE__) return;
16
+ window.__WEBTWEAK_ACTIVE__ = true;
17
+
18
+ var CFG = window.__WEBTWEAK__ || {};
19
+ var RESERVED = "/__webtweak__/";
20
+
21
+ // Only activate on the target page (not on links the user follows away).
22
+ var here = location.pathname.endsWith("/")
23
+ ? "index.html"
24
+ : decodeURIComponent(location.pathname.split("/").pop() || "index.html");
25
+ if (CFG.target && here !== CFG.target) return;
26
+
27
+ // One session per tab, stable across reloads, so re-saving overwrites the same
28
+ // pending Batch rather than orphaning a new one (running-history contract).
29
+ var SKEY = "wt-session-" + (CFG.target || here);
30
+ var SESSION = sessionStorage.getItem(SKEY) ||
31
+ ("s" + Math.random().toString(36).slice(2, 10));
32
+ sessionStorage.setItem(SKEY, SESSION);
33
+
34
+ // el -> { changes, _x, _y, origStyle } for every selected/edited element.
35
+ var edited = new Map();
36
+ var selectedEl = null;
37
+ var dirty = false; // unsaved changes since the last successful save
38
+ var persisted = false; // this session has a saved/restored batch on disk to clear
39
+ var missed = []; // restored patches we couldn't re-locate - preserved across saves
40
+ var interacting = false; // a drag/resize gesture is in progress
41
+ var undoStack = []; // stack of batches: each [{el, prop, prev}]
42
+
43
+ function entry(el) {
44
+ var e = edited.get(el);
45
+ if (!e) {
46
+ // origStyle captured on first contact = the authored baseline, used by reset.
47
+ e = { changes: {}, _x: 0, _y: 0, origStyle: el.getAttribute("style") };
48
+ edited.set(el, e);
49
+ }
50
+ return e;
51
+ }
52
+ function record(el, prop, value) {
53
+ entry(el).changes[prop] = value;
54
+ dirty = true;
55
+ }
56
+ // True iff any edited element still holds real changes - the single source of
57
+ // truth for the unsaved-changes (beforeunload) guard, so resets that empty the
58
+ // map don't leave a stale dirty flag.
59
+ function hasRealEdits() {
60
+ var any = false;
61
+ edited.forEach(function (e) { if (Object.keys(e.changes).length) any = true; });
62
+ return any;
63
+ }
64
+ // ---- undo -----------------------------------------------------------------
65
+ // Push a panel-input undo step before mutating changes[prop].
66
+ // Consecutive calls for the same el+prop collapse into one step so typing
67
+ // into a field leaves a single undo step regardless of how many keystrokes.
68
+ function pushUndoWrite(el, prop) {
69
+ var ch = (edited.get(el) || {}).changes;
70
+ var prev = ch ? ch[prop] : undefined;
71
+ var top = undoStack[undoStack.length - 1];
72
+ if (top && top.length === 1 && top[0].el === el && top[0].prop === prop) return;
73
+ undoStack.push([{ el: el, prop: prop, prev: prev }]);
74
+ }
75
+
76
+ function applyUndoBatch(batch) {
77
+ // Collect unique elements so each element's inline is rebuilt exactly once.
78
+ var els = [];
79
+ batch.forEach(function (u) {
80
+ var ent = entry(u.el);
81
+ if (u.prev === undefined) {
82
+ delete ent.changes[u.prop];
83
+ // rebuildInline only seeds _x/_y when nudge IS in changes; reset manually here.
84
+ if (u.prop === "nudge") { ent._x = 0; ent._y = 0; }
85
+ } else {
86
+ ent.changes[u.prop] = u.prev;
87
+ }
88
+ if (els.indexOf(u.el) < 0) els.push(u.el);
89
+ });
90
+ els.forEach(function (el) {
91
+ rebuildInline(el, edited.get(el));
92
+ if (el === selectedEl) { positionBox(selBox, el); populate(el); }
93
+ });
94
+ dirty = hasRealEdits();
95
+ status("undone");
96
+ }
97
+
98
+ function undo() {
99
+ if (!undoStack.length) { status("nothing to undo"); return; }
100
+ applyUndoBatch(undoStack.pop());
101
+ }
102
+
103
+ // The value each control was populated with this selection, so an unchanged
104
+ // re-entry (e.g. opening the colour picker on a transparent swatch and clicking
105
+ // the same shown value) is treated as a no-op and not recorded.
106
+ var baselines = {};
107
+
108
+ // The editable properties, declared once. The panel markup, the live binding,
109
+ // and the populate-from-computed-style read all derive from this single table,
110
+ // so adding a property is one entry, not three hand-synced lists.
111
+ // `read(cs)` maps a computed style to the control's display value; `unit` (if
112
+ // set) is appended on write; `box` re-fits the selection box after the change.
113
+ var CONTROLS = [
114
+ { group: "Type", id: "wt-ff", prop: "font-family", label: "Font", kind: "text",
115
+ read: function (cs) { return cs.fontFamily; } }, // full stack, so editing keeps fallbacks
116
+ { group: "Type", id: "wt-fs", prop: "font-size", label: "Size", kind: "number", unit: "px",
117
+ read: function (cs) { return px(cs.fontSize); } },
118
+ { group: "Type", id: "wt-fw", prop: "font-weight", label: "Weight", kind: "select",
119
+ opts: ["100", "200", "300", "400", "500", "600", "700", "800", "900"],
120
+ read: function (cs) { return String(parseInt(cs.fontWeight, 10) || 400); } },
121
+ { group: "Type", id: "wt-lh", prop: "line-height", label: "Line", kind: "text",
122
+ // show the unitless ratio (computed resolves to px); writing a bare number keeps it unitless
123
+ read: function (cs) {
124
+ if (cs.lineHeight === "normal") return "normal";
125
+ var fs = parseFloat(cs.fontSize), lh = parseFloat(cs.lineHeight);
126
+ return (fs > 0 && lh > 0) ? String(+(lh / fs).toFixed(2)) : cs.lineHeight;
127
+ } },
128
+ { group: "Type", id: "wt-ls", prop: "letter-spacing", label: "Spacing", kind: "text",
129
+ read: function (cs) { return cs.letterSpacing === "normal" ? "normal" : cs.letterSpacing; } },
130
+ { group: "Type", id: "wt-align", prop: "text-align", label: "Align", kind: "align",
131
+ read: function (cs) { var a = cs.textAlign; return a === "start" ? "left" : (a === "end" ? "right" : a); } },
132
+ { group: "Colour", id: "wt-color", prop: "color", label: "Text", kind: "color",
133
+ read: function (cs) { return rgbToHex(cs.color); } },
134
+ { group: "Colour", id: "wt-bg", prop: "background-color", label: "Background", kind: "color",
135
+ read: function (cs) { return rgbToHex(cs.backgroundColor); } },
136
+ { group: "Box", id: "wt-w", prop: "width", label: "Width", kind: "number", unit: "px", box: true,
137
+ read: function (cs) { return px(cs.width); } },
138
+ { group: "Box", id: "wt-h", prop: "height", label: "Height", kind: "number", unit: "px", box: true,
139
+ read: function (cs) { return px(cs.height); } },
140
+ { group: "Box", id: "wt-margin", prop: "margin", label: "Margin", kind: "text",
141
+ read: function (cs) { return cs.margin; } },
142
+ { group: "Box", id: "wt-padding", prop: "padding", label: "Padding", kind: "text",
143
+ read: function (cs) { return cs.padding; } },
144
+ ];
145
+ var GROUPS = ["Type", "Colour", "Box"];
146
+
147
+ // ---- DOM scaffolding ------------------------------------------------------
148
+ var root = document.createElement("div");
149
+ root.id = "wt-root";
150
+ root.innerHTML = [
151
+ '<div class="wt-bar wt-ui">',
152
+ ' <span class="wt-logo">webtweak</span>',
153
+ ' <span class="wt-crumb" id="wt-crumb">click an element to select</span>',
154
+ ' <span class="wt-status" id="wt-status"></span>',
155
+ ' <button class="wt-btn" id="wt-deselect">Deselect</button>',
156
+ ' <button class="wt-btn wt-primary" id="wt-save">Save</button>',
157
+ "</div>",
158
+ '<div class="wt-box wt-hover" id="wt-hover" hidden></div>',
159
+ '<div class="wt-box wt-selected" id="wt-selected" hidden>',
160
+ ' <span class="wt-tag" id="wt-seltag"></span>',
161
+ ' <span class="wt-grip wt-grip-r"></span><span class="wt-grip wt-grip-b"></span>',
162
+ ' <span class="wt-grip wt-grip-br"></span>',
163
+ "</div>",
164
+ panelHTML(),
165
+ '<div class="wt-hint wt-ui">Click to select. Drag the interior to <b>nudge</b>, drag the right/bottom/corner grips to <b>resize</b>. <b>Esc</b> deselect, <b>Cmd/Ctrl+S</b> save.</div>',
166
+ ].join("\n");
167
+ document.body.appendChild(root);
168
+
169
+ var hoverBox = document.getElementById("wt-hover");
170
+ var selBox = document.getElementById("wt-selected");
171
+ var selTag = document.getElementById("wt-seltag");
172
+ var crumbEl = document.getElementById("wt-crumb");
173
+ var statusEl = document.getElementById("wt-status");
174
+ var panel = document.getElementById("wt-panel");
175
+
176
+ function panelHTML() {
177
+ var parts = ['<div class="wt-panel wt-ui" id="wt-panel" hidden>', " <h3>Properties</h3>"];
178
+ GROUPS.forEach(function (g) {
179
+ parts.push(' <div class="wt-group"><div class="wt-legend">' + g + "</div>");
180
+ CONTROLS.filter(function (c) { return c.group === g; }).forEach(function (c) {
181
+ parts.push(field(c.label, controlMarkup(c)));
182
+ });
183
+ parts.push(" </div>");
184
+ });
185
+ parts.push(' <button class="wt-btn wt-block" id="wt-reset">Reset this element</button>');
186
+ parts.push(' <p class="wt-note">Changes preview live and are captured as intent. Claude reconciles them into clean CSS on save.</p>');
187
+ parts.push("</div>");
188
+ return parts.join("\n");
189
+ }
190
+ function controlMarkup(c) {
191
+ if (c.kind === "number") return '<input type="number" id="' + c.id + '" min="' + (c.box ? 0 : 1) + '"> px';
192
+ if (c.kind === "color") return '<input type="color" id="' + c.id + '">';
193
+ if (c.kind === "select") return select(c.id, c.opts);
194
+ if (c.kind === "align") return alignButtons(c.id);
195
+ return '<input type="text" id="' + c.id + '">';
196
+ }
197
+ function field(label, control) {
198
+ return ' <div class="wt-field"><label>' + label + "</label>" + control + "</div>";
199
+ }
200
+ function select(id, opts) {
201
+ return '<select id="' + id + '">' +
202
+ opts.map(function (o) { return '<option value="' + o + '">' + o + "</option>"; }).join("") +
203
+ "</select>";
204
+ }
205
+ function alignButtons(id) {
206
+ return '<div class="wt-align" id="' + id + '">' +
207
+ ["left", "center", "right", "justify"].map(function (a) {
208
+ return '<button data-align="' + a + '">' + a[0].toUpperCase() + "</button>";
209
+ }).join("") + "</div>";
210
+ }
211
+
212
+ // ---- helpers --------------------------------------------------------------
213
+ function isOverlay(el) { return el && el.closest && el.closest("#wt-root"); }
214
+
215
+ function cssEsc(s) {
216
+ if (window.CSS && CSS.escape) return CSS.escape(s);
217
+ return String(s).replace(/[^\w-]/g, "\\$&");
218
+ }
219
+
220
+ function rgbToHex(rgb) {
221
+ var m = (rgb || "").match(/\d+/g);
222
+ if (!m) return "#000000";
223
+ return "#" + m.slice(0, 3).map(function (n) {
224
+ return ("0" + (+n).toString(16)).slice(-2);
225
+ }).join("");
226
+ }
227
+
228
+ function px(v) { var n = parseInt(v, 10); return isNaN(n) ? "" : n; }
229
+
230
+ function positionBox(box, el) {
231
+ var r = el.getBoundingClientRect();
232
+ box.style.top = r.top + "px";
233
+ box.style.left = r.left + "px";
234
+ box.style.width = r.width + "px";
235
+ box.style.height = r.height + "px";
236
+ box.hidden = false;
237
+ }
238
+
239
+ function nonWtClasses(el) {
240
+ return Array.prototype.filter.call(el.classList, function (c) {
241
+ return c.indexOf("wt-") !== 0;
242
+ });
243
+ }
244
+
245
+ function cssPath(el) {
246
+ if (!el || el === document.body) return "body";
247
+ var parts = [];
248
+ while (el && el.nodeType === 1 && el !== document.body) {
249
+ if (el.id) { parts.unshift("#" + cssEsc(el.id)); return parts.join(" > "); }
250
+ var part = el.tagName.toLowerCase() +
251
+ nonWtClasses(el).map(function (c) { return "." + cssEsc(c); }).join("");
252
+ var parent = el.parentElement;
253
+ if (parent) {
254
+ var sibs = Array.prototype.filter.call(parent.children, function (c) {
255
+ return c.tagName === el.tagName && c.id !== "wt-root"; // ignore the overlay root
256
+ });
257
+ if (sibs.length > 1) part += ":nth-of-type(" + (sibs.indexOf(el) + 1) + ")";
258
+ }
259
+ parts.unshift(part);
260
+ el = el.parentElement;
261
+ }
262
+ return "body > " + parts.join(" > ");
263
+ }
264
+
265
+ // Build the opening tag from attributes (robust against '>' inside attribute
266
+ // values) and exclude the Overlay's injected inline `style`.
267
+ function openTag(el) {
268
+ var s = "<" + el.tagName.toLowerCase();
269
+ Array.prototype.forEach.call(el.attributes, function (a) {
270
+ if (a.name === "style") return;
271
+ s += " " + a.name + (a.value !== "" ? '="' + a.value.replace(/"/g, "&quot;") + '"' : "");
272
+ });
273
+ return (s + ">").slice(0, 300);
274
+ }
275
+
276
+ function ownText(el) {
277
+ return Array.prototype.filter.call(el.childNodes, function (n) { return n.nodeType === 3; })
278
+ .map(function (n) { return n.textContent; }).join("").trim().replace(/\s+/g, " ").slice(0, 80);
279
+ }
280
+
281
+ // Index of `el` among siblings sharing its tag + classes - the ordinal that lets
282
+ // reconcile name "the 2nd of 3 identical blocks" when nothing else distinguishes them.
283
+ function siblingIndex(el) {
284
+ var parent = el.parentElement;
285
+ if (!parent) return 0;
286
+ var key = el.tagName + "|" + nonWtClasses(el).join(".");
287
+ var same = Array.prototype.filter.call(parent.children, function (c) {
288
+ return c.id !== "wt-root" && (c.tagName + "|" + nonWtClasses(c).join(".")) === key;
289
+ });
290
+ return same.indexOf(el);
291
+ }
292
+
293
+ function fingerprint(el) {
294
+ return {
295
+ tag: el.tagName.toLowerCase(),
296
+ id: el.id || "",
297
+ classes: nonWtClasses(el),
298
+ text: (el.textContent || "").trim().replace(/\s+/g, " ").slice(0, 80),
299
+ ownText: ownText(el),
300
+ selector: cssPath(el),
301
+ siblingIndex: siblingIndex(el),
302
+ openTag: openTag(el),
303
+ };
304
+ }
305
+
306
+ function describe(el) {
307
+ var s = el.tagName.toLowerCase();
308
+ if (el.id) s += "#" + el.id;
309
+ else { var cls = nonWtClasses(el); if (cls.length) s += "." + cls[0]; }
310
+ return s;
311
+ }
312
+
313
+ function setCrumb(el) {
314
+ var chain = [], n = el;
315
+ while (n && n.nodeType === 1 && n !== document.body) { chain.unshift(n); n = n.parentElement; }
316
+ crumbEl.innerHTML = chain.map(function (node, i) {
317
+ var label = describe(node);
318
+ return i === chain.length - 1 ? "<b>" + label + "</b>" : label;
319
+ }).join(" &rsaquo; ");
320
+ }
321
+
322
+ function status(msg, ok) {
323
+ statusEl.textContent = msg || "";
324
+ statusEl.style.color = ok === false ? "#ff8a8a" : "#8ad18a";
325
+ }
326
+
327
+ // ---- selection ------------------------------------------------------------
328
+ function selectEl(el) {
329
+ if (!el || el === document.body || el === document.documentElement) return;
330
+ if (selectedEl && window.interact) interact(selectedEl).unset();
331
+ interacting = false; // unset() can abort an in-flight gesture without firing 'end'
332
+ selectedEl = el;
333
+ entry(el); // lock the authored baseline before any edit
334
+ positionBox(selBox, el);
335
+ selTag.textContent = describe(el);
336
+ setCrumb(el);
337
+ populate(el);
338
+ panel.hidden = false;
339
+ attachInteract(el);
340
+ }
341
+
342
+ function deselect() {
343
+ if (selectedEl && window.interact) interact(selectedEl).unset();
344
+ interacting = false; // unset() can abort an in-flight gesture without firing 'end'
345
+ selectedEl = null;
346
+ selBox.hidden = true;
347
+ panel.hidden = true;
348
+ crumbEl.textContent = "click an element to select";
349
+ }
350
+
351
+ function resetEl(el) {
352
+ var e = edited.get(el);
353
+ if (e) {
354
+ if (e.origStyle == null) el.removeAttribute("style");
355
+ else el.setAttribute("style", e.origStyle);
356
+ edited.delete(el);
357
+ dirty = hasRealEdits(); // don't leave a false 'unsaved changes' flag when nothing remains
358
+ }
359
+ if (el === selectedEl) {
360
+ entry(el); // re-arm a fresh baseline
361
+ positionBox(selBox, el);
362
+ populate(el);
363
+ }
364
+ status("reset - save to drop these edits");
365
+ }
366
+
367
+ // Replaced (and replaced-like) inline elements that DO honour width/height and
368
+ // transform, unlike ordinary inline text boxes. Keyed by uppercase tagName.
369
+ var REPLACED = { IMG: 1, SVG: 1, VIDEO: 1, CANVAS: 1, IFRAME: 1, EMBED: 1,
370
+ OBJECT: 1, PICTURE: 1, INPUT: 1, TEXTAREA: 1, SELECT: 1, BUTTON: 1, AUDIO: 1 };
371
+
372
+ function populate(el) {
373
+ var cs = getComputedStyle(el);
374
+ var ent = edited.get(el);
375
+ baselines = {};
376
+ CONTROLS.forEach(function (c) {
377
+ var shown = c.read(cs); // current (possibly already-edited) value -> the panel
378
+ var base = shown;
379
+ // After a reload+restore the override is applied inline, so computed == the
380
+ // edited value. Recover the true authored baseline by reading computed with
381
+ // just this property's override peeled off, so "revert to original" is still
382
+ // detected (and doesn't record a no-op patch setting a prop to its own origin).
383
+ if (ent && ent.changes && c.prop && Object.prototype.hasOwnProperty.call(ent.changes, c.prop)) {
384
+ base = withTempStyle(el,
385
+ function (s) { s.removeProperty(c.prop); },
386
+ function () { return c.read(getComputedStyle(el)); });
387
+ }
388
+ baselines[c.id] = String(base);
389
+ if (c.kind === "align") {
390
+ Array.prototype.forEach.call(document.querySelectorAll("#" + c.id + " button"), function (b) {
391
+ b.classList.toggle("on", b.dataset.align === shown);
392
+ });
393
+ } else {
394
+ set(c.id, shown);
395
+ }
396
+ });
397
+ // width/height + nudge are inert on NON-REPLACED inline elements - disable them
398
+ // so a user can't record a dead patch the element never honours. Replaced inline
399
+ // elements (img, svg, video, form controls...) DO honour sizing/transform, so they
400
+ // stay enabled even at display:inline.
401
+ var inlineOnly = cs.display === "inline" && !REPLACED[el.tagName];
402
+ ["wt-w", "wt-h"].forEach(function (id) {
403
+ var n = document.getElementById(id);
404
+ if (n) { n.disabled = inlineOnly; n.title = inlineOnly ? "width/height are ignored on inline elements" : ""; }
405
+ });
406
+ el.__wtInline = inlineOnly; // also gate the resize grips (see attachInteract)
407
+ }
408
+ function set(id, v) { var el = document.getElementById(id); if (el) el.value = v; }
409
+
410
+ // ---- property wiring (all from the CONTROLS table) ------------------------
411
+ // Wrap a single multi-word font family in quotes so the live preview applies
412
+ // (a stack with commas, an already-quoted value, or a single word is left alone).
413
+ function quoteFamily(val) {
414
+ if (/[,'"]/.test(val) || !/\s/.test(val)) return val;
415
+ return '"' + val + '"';
416
+ }
417
+ // Run `mutate(el.style)`, return `read()`, then restore the element's FULL inline
418
+ // cssText verbatim - so a temporary shorthand write/removal can't drop a coexisting
419
+ // authored longhand (e.g. an inline margin-top) the caller didn't mean to touch.
420
+ function withTempStyle(el, mutate, read) {
421
+ var savedCss = el.style.cssText;
422
+ mutate(el.style);
423
+ var result = read();
424
+ el.style.cssText = savedCss;
425
+ return result;
426
+ }
427
+ // Resolve a typed value to its computed form via the element, so a shorthand like
428
+ // margin "10px 20px" can be compared to the computed 4-value baseline.
429
+ function resolveValue(prop, value) {
430
+ return withTempStyle(selectedEl,
431
+ function (s) { s.setProperty(prop, value); },
432
+ function () { return getComputedStyle(selectedEl)[prop]; });
433
+ }
434
+ // Re-apply one recorded change (a nudge transform or a plain property) to an element.
435
+ function applyChange(el, prop, value) {
436
+ if (prop === "nudge") el.style.transform = "translate(" + value.dx + "px, " + value.dy + "px)";
437
+ else el.style.setProperty(prop, value);
438
+ }
439
+ // Rebuild an element's inline style from its authored original plus the session's
440
+ // remaining changes. Used to revert a single property without a removeProperty() that
441
+ // would wipe a coexisting authored inline longhand the user never touched.
442
+ function rebuildInline(el, ent) {
443
+ if (!ent || ent.origStyle == null) el.removeAttribute("style");
444
+ else el.setAttribute("style", ent.origStyle);
445
+ if (ent) Object.keys(ent.changes).forEach(function (p) {
446
+ var v = ent.changes[p];
447
+ applyChange(el, p, v);
448
+ if (p === "nudge") { ent._x = v.dx; ent._y = v.dy; } // re-seed the drag accumulator to the snapped value
449
+ });
450
+ }
451
+ function writeControl(c, raw) {
452
+ if (!selectedEl) return;
453
+ if (raw === "" && c.kind !== "align") return; // cleared field: nothing to apply/record
454
+ if (c.box) raw = Math.max(1, parseInt(raw, 10) || 1); // width/height floor of 1, matching resize
455
+ // Setting a control back to the value it was populated with means "revert this
456
+ // property" - drop the override + the recorded change rather than baking a no-op
457
+ // (also stops an accidental opaque #000000 from a transparent-shown colour swatch).
458
+ // Shorthand props (margin/padding) carry a computed 4-value baseline, so resolve
459
+ // the typed value through the element before comparing.
460
+ var revertTarget = (c.prop === "margin" || c.prop === "padding") ? resolveValue(c.prop, raw) : String(raw);
461
+ // Guard the "" === "" trap: an engine that serialises an asymmetric computed
462
+ // shorthand as "" must not make every typed value look like a revert.
463
+ if (revertTarget !== "" && revertTarget === baselines[c.id]) {
464
+ var ent = edited.get(selectedEl);
465
+ if (ent && ent.changes[c.prop] !== undefined) pushUndoWrite(selectedEl, c.prop);
466
+ if (ent) delete ent.changes[c.prop];
467
+ rebuildInline(selectedEl, ent); // restore authored inline + remaining edits (preserves longhands)
468
+ dirty = hasRealEdits(); // reverting the last edit must clear the stale unsaved flag
469
+ positionBox(selBox, selectedEl);
470
+ return;
471
+ }
472
+ var v = c.unit ? raw + c.unit : raw;
473
+ if (c.prop === "font-family") v = quoteFamily(raw);
474
+ // Don't bake a phantom patch the page never showed: if the browser would
475
+ // reject this value (a typo like "banana" in a free-text field), the live
476
+ // preview wouldn't change either, so leave any prior valid edit untouched.
477
+ if (!c.box && !CSS.supports(c.prop, v)) { status("ignored invalid " + c.prop + ": " + raw, false); return; }
478
+ pushUndoWrite(selectedEl, c.prop);
479
+ selectedEl.style.setProperty(c.prop, v);
480
+ record(selectedEl, c.prop, v);
481
+ positionBox(selBox, selectedEl); // any edit can reflow - always re-fit the box
482
+ }
483
+ CONTROLS.forEach(function (c) {
484
+ var node = document.getElementById(c.id);
485
+ if (c.kind === "align") {
486
+ node.addEventListener("click", function (ev) {
487
+ var btn = ev.target.closest("button");
488
+ if (!btn || !selectedEl) return;
489
+ writeControl(c, btn.dataset.align);
490
+ Array.prototype.forEach.call(node.querySelectorAll("button"), function (b) {
491
+ b.classList.toggle("on", b === btn);
492
+ });
493
+ });
494
+ } else {
495
+ node.addEventListener("input", function () { writeControl(c, this.value); });
496
+ }
497
+ });
498
+
499
+ document.getElementById("wt-reset").addEventListener("click", function () {
500
+ if (selectedEl) resetEl(selectedEl);
501
+ });
502
+
503
+ // ---- interact.js: nudge (drag interior) + resize (right/bottom grips) ------
504
+ // interact's rect is border-box; convert to the element's own box model so the
505
+ // recorded value matches the panel (content-box for content-box elements) and
506
+ // the element doesn't jump by its padding+border on the first drag.
507
+ function resizeWrite(el, rect) {
508
+ var cs = getComputedStyle(el);
509
+ var w = Math.round(rect.width), h = Math.round(rect.height);
510
+ if (cs.boxSizing !== "border-box") {
511
+ w -= parseFloat(cs.paddingLeft) + parseFloat(cs.paddingRight) +
512
+ parseFloat(cs.borderLeftWidth) + parseFloat(cs.borderRightWidth);
513
+ h -= parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom) +
514
+ parseFloat(cs.borderTopWidth) + parseFloat(cs.borderBottomWidth);
515
+ }
516
+ w = Math.max(1, Math.round(w));
517
+ h = Math.max(1, Math.round(h));
518
+ el.style.width = w + "px";
519
+ el.style.height = h + "px";
520
+ record(el, "width", w + "px");
521
+ record(el, "height", h + "px");
522
+ // If a stylesheet max-width/min-height would override the resize, pin it inline
523
+ // so the element actually reaches the desired size.
524
+ var cMaxW = getComputedStyle(el).maxWidth;
525
+ if (cMaxW && cMaxW !== "none" && w > parseFloat(cMaxW)) {
526
+ el.style.maxWidth = w + "px";
527
+ record(el, "max-width", w + "px");
528
+ }
529
+ var cMinH = getComputedStyle(el).minHeight;
530
+ if (cMinH && cMinH !== "0px" && h < parseFloat(cMinH)) {
531
+ el.style.minHeight = h + "px";
532
+ record(el, "min-height", h + "px");
533
+ }
534
+ set("wt-w", w); set("wt-h", h);
535
+ // NB: baselines["wt-w"/"wt-h"] deliberately stay at the select-time original, so
536
+ // typing the original size still reverts; re-typing the shown size just re-records
537
+ // the same value (idempotent). Syncing them here would make retyping the shown size
538
+ // delete the resize - the opposite of what's wanted.
539
+ }
540
+ function attachInteract(el) {
541
+ if (!window.interact) return;
542
+ // Scale the resize grab-band to the element so small elements stay nudgeable.
543
+ var margin = el.offsetHeight < 40 ? 4 : 10;
544
+ // Gesture-batched undo: snapshot at start, push one batch at end.
545
+ var nudgePrev, resizePrev;
546
+ interact(el)
547
+ .draggable({
548
+ // a nudge is a CSS transform, which has no effect on non-replaced inline
549
+ // elements - disable it there so a drag can't record a dead nudge patch
550
+ enabled: !el.__wtInline,
551
+ listeners: {
552
+ start: function () {
553
+ interacting = true; hoverBox.hidden = true;
554
+ var ch = (edited.get(el) || {}).changes;
555
+ nudgePrev = ch ? ch.nudge : undefined;
556
+ },
557
+ end: function () {
558
+ interacting = false;
559
+ var ch = (edited.get(el) || {}).changes;
560
+ var cur = ch ? ch.nudge : undefined;
561
+ if (cur !== nudgePrev) undoStack.push([{ el: el, prop: "nudge", prev: nudgePrev }]);
562
+ },
563
+ move: function (event) {
564
+ var e = entry(el);
565
+ e._x += event.dx; e._y += event.dy;
566
+ var sx = Math.round(e._x / 4) * 4, sy = Math.round(e._y / 4) * 4;
567
+ if (sx === 0 && sy === 0) { // dragged back to origin: not a real nudge
568
+ el.style.removeProperty("transform");
569
+ delete e.changes.nudge;
570
+ dirty = hasRealEdits(); // clear the stale unsaved flag if this was the only edit
571
+ } else {
572
+ el.style.transform = "translate(" + sx + "px, " + sy + "px)";
573
+ record(el, "nudge", { dx: sx, dy: sy });
574
+ }
575
+ positionBox(selBox, el);
576
+ },
577
+ },
578
+ })
579
+ .resizable({
580
+ // resize is meaningless on inline (non-replaced) elements - disable it there
581
+ enabled: !el.__wtInline,
582
+ edges: { right: true, bottom: true, top: false, left: false },
583
+ margin: margin,
584
+ listeners: {
585
+ start: function () {
586
+ interacting = true; hoverBox.hidden = true;
587
+ var ch = (edited.get(el) || {}).changes || {};
588
+ resizePrev = {
589
+ "width": ch.width, "height": ch.height,
590
+ "max-width": ch["max-width"], "min-height": ch["min-height"],
591
+ };
592
+ },
593
+ end: function () {
594
+ interacting = false;
595
+ var ch = (edited.get(el) || {}).changes || {};
596
+ var batch = [];
597
+ ["width", "height", "max-width", "min-height"].forEach(function (p) {
598
+ if (ch[p] !== resizePrev[p]) batch.push({ el: el, prop: p, prev: resizePrev[p] });
599
+ });
600
+ if (batch.length) undoStack.push(batch);
601
+ },
602
+ move: function (event) {
603
+ resizeWrite(el, event.rect);
604
+ positionBox(selBox, el);
605
+ },
606
+ },
607
+ });
608
+ }
609
+
610
+ // ---- picker ---------------------------------------------------------------
611
+ var lastHoverEl = null;
612
+ document.addEventListener("mousemove", function (ev) {
613
+ if (interacting) { hoverBox.hidden = true; return; } // don't flicker during drag/resize
614
+ var el = ev.target;
615
+ if (isOverlay(el) || el === document.body || el === document.documentElement) {
616
+ hoverBox.hidden = true;
617
+ lastHoverEl = null;
618
+ return;
619
+ }
620
+ if (el === lastHoverEl && !hoverBox.hidden) return; // same element, already drawn
621
+ lastHoverEl = el;
622
+ positionBox(hoverBox, el);
623
+ });
624
+ document.addEventListener("mouseleave", function () { hoverBox.hidden = true; });
625
+
626
+ // Prevent the browser's native drag (text selection drag, element drag) from
627
+ // stealing pointer events before interact.js can track them. In editor mode,
628
+ // native drag is never wanted on page content.
629
+ document.addEventListener("dragstart", function (ev) {
630
+ if (!isOverlay(ev.target)) ev.preventDefault();
631
+ }, true);
632
+
633
+ document.addEventListener("click", function (ev) {
634
+ if (isOverlay(ev.target)) return; // let panel/bar controls work
635
+ ev.preventDefault(); // editor mode: no navigation
636
+ ev.stopPropagation();
637
+ selectEl(ev.target); // always select the deepest target
638
+ }, true);
639
+
640
+ window.addEventListener("scroll", reposition, true);
641
+ window.addEventListener("resize", reposition);
642
+ function reposition() {
643
+ if (selectedEl) positionBox(selBox, selectedEl);
644
+ hoverBox.hidden = true;
645
+ }
646
+
647
+ document.getElementById("wt-deselect").addEventListener("click", deselect);
648
+
649
+ // ---- keyboard -------------------------------------------------------------
650
+ document.addEventListener("keydown", function (ev) {
651
+ if (ev.key === "Escape") { deselect(); return; }
652
+ if ((ev.metaKey || ev.ctrlKey) && (ev.key === "s" || ev.key === "S")) {
653
+ ev.preventDefault();
654
+ save();
655
+ }
656
+ if ((ev.metaKey || ev.ctrlKey) && !ev.shiftKey && (ev.key === "z" || ev.key === "Z")) {
657
+ ev.preventDefault();
658
+ undo();
659
+ }
660
+ });
661
+
662
+ window.addEventListener("beforeunload", function (ev) {
663
+ if (dirty) { ev.preventDefault(); ev.returnValue = ""; }
664
+ });
665
+
666
+ // ---- save -----------------------------------------------------------------
667
+ function save() {
668
+ var patches = [];
669
+ edited.forEach(function (e, el) {
670
+ if (Object.keys(e.changes).length) {
671
+ patches.push({ fingerprint: fingerprint(el), changes: e.changes });
672
+ }
673
+ });
674
+ // Re-attach patches a partial restore couldn't re-locate, so saving the elements
675
+ // that DID restore never silently drops the ones that didn't (apply_batch replaces
676
+ // this session's whole batch). Skip any whose element the user has since edited this
677
+ // session (same id/selector) - the fresh patch supersedes the stranded one, so we
678
+ // don't emit two conflicting patches for one element.
679
+ var idKey = function (fp) { return fp.id ? "id:" + fp.id : (fp.selector ? "sel:" + fp.selector : null); };
680
+ var covered = {};
681
+ patches.forEach(function (p) { var k = idKey(p.fingerprint || {}); if (k) covered[k] = true; });
682
+ missed.forEach(function (p) { var k = idKey(p.fingerprint || {}); if (!k || !covered[k]) patches.push(p); });
683
+ // No patches AND nothing on disk for this session: genuinely nothing to do.
684
+ // No patches but a batch IS persisted (edits saved then all reverted): fall
685
+ // through so the empty save clears that stale batch on disk.
686
+ if (!patches.length && !persisted) { status("nothing changed yet"); return; }
687
+ status("saving...");
688
+ fetch(RESERVED + "save", {
689
+ method: "POST",
690
+ headers: { "Content-Type": "application/json" },
691
+ body: JSON.stringify({ sessionId: SESSION, viewport: window.innerWidth, patches: patches }),
692
+ })
693
+ .then(function (r) { return r.json(); })
694
+ .then(function (j) {
695
+ if (j.ok) {
696
+ dirty = false;
697
+ persisted = patches.length > 0; // empty save just cleared the batch
698
+ status(patches.length
699
+ ? "saved " + j.patches + " change" + (j.patches === 1 ? "" : "s")
700
+ : "reverted - cleared saved edits", true);
701
+ } else {
702
+ status("save failed: " + (j.error || "unknown"), false);
703
+ }
704
+ })
705
+ .catch(function () { status("save failed", false); });
706
+ }
707
+ document.getElementById("wt-save").addEventListener("click", save);
708
+
709
+ // ---- restore this session's pending edits after a reload ------------------
710
+ function restore() {
711
+ fetch(RESERVED + "edits")
712
+ .then(function (r) { return r.json(); })
713
+ .then(function (doc) {
714
+ var batch = (doc.batches || []).filter(function (b) {
715
+ return b.status === "pending" && b.sessionId === SESSION;
716
+ })[0];
717
+ if (!batch) return;
718
+ persisted = true; // a saved batch exists on disk; a full revert must clear it
719
+ missed = [];
720
+ var n = 0, total = (batch.patches || []).length;
721
+ (batch.patches || []).forEach(function (p) {
722
+ var fp = p.fingerprint || {}, el = null;
723
+ try {
724
+ el = fp.id ? document.getElementById(fp.id)
725
+ : (fp.selector ? document.querySelector(fp.selector) : null);
726
+ } catch (e) { /* invalid selector */ }
727
+ // Confirm the located element is really the one that was edited. The tag must
728
+ // match (an id can be moved to a different-tag element in source), and if the
729
+ // fingerprint recorded ownText it must still match (a positional selector or a
730
+ // reused id can otherwise hit the wrong same-tag element after a source reorder).
731
+ // On any mismatch, keep the patch for reconcile rather than mis-applying it.
732
+ var elOwn = el ? ownText(el) : "";
733
+ // A recorded ownText that no longer matches (including a now-empty element)
734
+ // means this isn't the same element - strand the patch rather than mis-apply it.
735
+ if (!el || (fp.tag && el.tagName.toLowerCase() !== fp.tag) ||
736
+ (fp.ownText && elOwn !== fp.ownText)) {
737
+ missed.push(p); // keep the patch; the next save must NOT drop it
738
+ return;
739
+ }
740
+ var e = entry(el); // captures authored baseline before re-applying
741
+ Object.keys(p.changes || {}).forEach(function (prop) {
742
+ var v = p.changes[prop];
743
+ applyChange(el, prop, v); // single place that maps a change to inline style
744
+ e.changes[prop] = v;
745
+ if (prop === "nudge") { e._x = v.dx; e._y = v.dy; } // also seed the interact offset
746
+ });
747
+ n++;
748
+ });
749
+ if (total) {
750
+ var lost = total - n;
751
+ status("restored " + n + " of " + total + " edited element" + (total === 1 ? "" : "s") +
752
+ (lost ? "; " + lost + " could not be re-located (kept for reconcile)" : ""), lost === 0);
753
+ }
754
+ })
755
+ .catch(function () { /* no edits file yet */ });
756
+ }
757
+ restore();
758
+ })();