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.
- package/LICENSE +21 -0
- package/README.md +117 -0
- package/overlay/interact.min.js +4 -0
- package/overlay/overlay.css +162 -0
- package/overlay/overlay.js +758 -0
- package/package.json +29 -0
- package/reconcile/SKILL.md +51 -0
- package/reconcile/scripts/wtreconcile.py +174 -0
- package/webtweak.js +380 -0
|
@@ -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, """) + '"' : "");
|
|
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(" › ");
|
|
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
|
+
})();
|