tweaklocal 0.3.1 → 0.3.4
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/overlay/overlay.js +232 -24
- package/package.json +1 -1
- package/src/resolver.js +184 -28
- package/src/server.js +8 -2
package/overlay/overlay.js
CHANGED
|
@@ -16,6 +16,12 @@
|
|
|
16
16
|
#twk-root{position:fixed;inset:0;pointer-events:none;z-index:2147483000;font-family:ui-sans-serif,system-ui,sans-serif}
|
|
17
17
|
.twk-outline{position:fixed;border:1.5px solid #6366f1;border-radius:3px;background:rgba(99,102,241,.08);pointer-events:none;transition:all .04s linear}
|
|
18
18
|
.twk-outline.twk-selected{border-color:#10b981;background:rgba(16,185,129,.06)}
|
|
19
|
+
.twk-multi{position:fixed;border:1.5px solid #f59e0b;border-radius:3px;background:rgba(245,158,11,.10);pointer-events:none}
|
|
20
|
+
.twk-multibar{position:fixed;left:50%;bottom:14px;transform:translateX(-50%);background:#111827;color:#f9fafb;border:1.5px solid #f59e0b;border-radius:10px;box-shadow:0 8px 30px rgba(0,0,0,.4);padding:8px;pointer-events:auto;display:flex;gap:6px;align-items:center;font-size:12px;z-index:2147483001}
|
|
21
|
+
.twk-multibar input{background:#1f2937;border:1px solid #374151;border-radius:6px;color:#f9fafb;padding:5px 8px;font-size:12px;outline:none;width:220px}
|
|
22
|
+
.twk-multibar button{background:#374151;color:#f9fafb;border:none;border-radius:6px;padding:4px 9px;font-size:12px;cursor:pointer}
|
|
23
|
+
.twk-multibar button.twk-danger{background:#dc2626}
|
|
24
|
+
.twk-multibar .twk-count{color:#fbbf24;font-weight:700}
|
|
19
25
|
.twk-badge{position:fixed;background:#312e81;color:#e0e7ff;font-size:11px;padding:2px 7px;border-radius:4px;pointer-events:none;white-space:nowrap;transform:translateY(-100%)}
|
|
20
26
|
.twk-pop-label{position:fixed;background:#10b981;color:#052e1b;font-size:13.8px;font-weight:700;padding:3px 10px;border-radius:8px 8px 0 0;pointer-events:none;white-space:nowrap;transform:translate(-50%,-100%);text-align:center}
|
|
21
27
|
.twk-delete-btn{position:fixed;width:22px;height:22px;border-radius:50%;background:#dc2626;color:#fff;border:none;cursor:pointer;pointer-events:auto;display:flex;align-items:center;justify-content:center;padding:0;box-shadow:0 2px 6px rgba(0,0,0,.4)}
|
|
@@ -29,6 +35,8 @@
|
|
|
29
35
|
.twk-pop input{flex:1;background:#1f2937;border:1px solid #374151;border-radius:6px;color:#f9fafb;padding:5px 8px;font-size:12px;outline:none}
|
|
30
36
|
.twk-pop select{background:#1f2937;border:1px solid #374151;border-radius:6px;color:#f9fafb;padding:3px 4px;font-size:11.5px;outline:none}
|
|
31
37
|
.twk-label{color:#9ca3af;min-width:50px}
|
|
38
|
+
.twk-note{color:#fbbf24;font-size:11px;line-height:1.35;background:rgba(245,158,11,.1);border-radius:6px;padding:4px 7px}
|
|
39
|
+
.twk-note.twk-info{color:#93c5fd;background:rgba(59,130,246,.12)}
|
|
32
40
|
.twk-cur{color:#6ee7b7;font-size:11px;margin-left:auto}
|
|
33
41
|
.twk-swatches{display:flex;gap:4px;flex-wrap:wrap;max-height:96px;overflow-y:auto;padding:2px}
|
|
34
42
|
.twk-swatch{width:18px;height:18px;border-radius:4px;border:1px solid rgba(255,255,255,.25);cursor:pointer;padding:0}
|
|
@@ -61,8 +69,13 @@
|
|
|
61
69
|
selected: null, // { el, loc }
|
|
62
70
|
editing: null, // { el, original }
|
|
63
71
|
tailwind: true, // daemon reports whether the app uses Tailwind
|
|
72
|
+
model: 'auto', // NL model override; 'auto' = router picks
|
|
73
|
+
multi: [], // [{ el, loc }] shift-click multi-selection
|
|
64
74
|
};
|
|
65
75
|
|
|
76
|
+
// Stack of undoable tweak ids (LIFO) for ⌘Z / Ctrl-Z global undo.
|
|
77
|
+
const undoStack = [];
|
|
78
|
+
|
|
66
79
|
// ---------- helpers ----------
|
|
67
80
|
const el = (tag, cls, text) => {
|
|
68
81
|
const n = document.createElement(tag);
|
|
@@ -192,36 +205,48 @@
|
|
|
192
205
|
deleteBtn.style.display = 'none';
|
|
193
206
|
deleteBtn.title = 'Delete element';
|
|
194
207
|
root.appendChild(deleteBtn);
|
|
208
|
+
// All instances of a mapped template share one source stamp; the DOM position
|
|
209
|
+
// of a given instance tells the daemon WHICH data item it is (list items
|
|
210
|
+
// render in array order).
|
|
211
|
+
function instanceIndex(elm, loc) {
|
|
212
|
+
const instances = [...document.querySelectorAll(`[data-twk="${CSS.escape(loc)}"]`)];
|
|
213
|
+
const i = instances.indexOf(elm);
|
|
214
|
+
return i >= 0 ? i : undefined;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function deleteOne({ el: elm, loc }) {
|
|
218
|
+
const payload = { loc, index: instanceIndex(elm, loc) };
|
|
219
|
+
// Dry-run first so a refusal never flickers the element.
|
|
220
|
+
await api('delete', { ...payload, dryRun: true });
|
|
221
|
+
if (document.contains(elm)) elm.style.display = 'none'; // optimistic; HMR makes it real
|
|
222
|
+
await api('delete', payload);
|
|
223
|
+
}
|
|
224
|
+
|
|
195
225
|
deleteBtn.onclick = async (e) => {
|
|
196
226
|
e.preventDefault();
|
|
197
227
|
e.stopPropagation();
|
|
198
228
|
const sel = state.selected;
|
|
199
229
|
if (!sel || !document.contains(sel.el)) return;
|
|
200
|
-
// Check first so a refusal never flickers the element or loses the
|
|
201
|
-
// selection — only hide optimistically once we know it'll succeed.
|
|
202
230
|
deleteBtn.disabled = true;
|
|
203
231
|
try {
|
|
204
|
-
await
|
|
232
|
+
await deleteOne(sel);
|
|
205
233
|
} catch (err) {
|
|
234
|
+
if (document.contains(sel.el)) sel.el.style.display = '';
|
|
206
235
|
deleteBtn.disabled = false;
|
|
207
|
-
addTweak({ id: 'x' + Date.now(), status: 'error', label: err.message.slice(0, 140) });
|
|
236
|
+
addTweak({ id: 'x' + Date.now(), status: 'error', label: `delete: ${err.message.slice(0, 140)}` });
|
|
208
237
|
return;
|
|
209
238
|
}
|
|
210
239
|
deleteBtn.disabled = false;
|
|
211
|
-
sel.el.style.display = 'none'; // optimistic; HMR makes it real
|
|
212
240
|
deselect();
|
|
213
|
-
try {
|
|
214
|
-
await api('delete', { loc: sel.loc });
|
|
215
|
-
} catch (err) {
|
|
216
|
-
sel.el.style.display = '';
|
|
217
|
-
addTweak({ id: 'x' + Date.now(), status: 'error', label: `delete: ${err.message}` });
|
|
218
|
-
}
|
|
219
241
|
};
|
|
220
242
|
|
|
221
243
|
function select(target) {
|
|
222
244
|
finishTextEdit(false);
|
|
223
245
|
const loc = target.getAttribute('data-twk');
|
|
224
|
-
|
|
246
|
+
// How many rendered elements map to this same source line — i.e. how many
|
|
247
|
+
// instances a shared-source edit (style/functionality) will change.
|
|
248
|
+
const instances = document.querySelectorAll(`[data-twk="${CSS.escape(loc)}"]`).length;
|
|
249
|
+
state.selected = { el: target, loc, meta: null, instances };
|
|
225
250
|
selBox.style.display = 'block';
|
|
226
251
|
renderPopover();
|
|
227
252
|
reposition();
|
|
@@ -252,6 +277,97 @@
|
|
|
252
277
|
deleteBtn.style.display = 'none';
|
|
253
278
|
}
|
|
254
279
|
|
|
280
|
+
// ---------- multi-select (shift-click) ----------
|
|
281
|
+
const multiBoxes = []; // pooled outline divs
|
|
282
|
+
const multiBar = el('div', 'twk-multibar');
|
|
283
|
+
multiBar.style.display = 'none';
|
|
284
|
+
root.appendChild(multiBar);
|
|
285
|
+
|
|
286
|
+
function toggleMulti(target) {
|
|
287
|
+
const loc = target.getAttribute('data-twk');
|
|
288
|
+
const i = state.multi.findIndex((m) => m.el === target);
|
|
289
|
+
if (i >= 0) state.multi.splice(i, 1);
|
|
290
|
+
else {
|
|
291
|
+
// leaving single-selection mode: fold the current single selection in too
|
|
292
|
+
if (state.selected && !state.multi.some((m) => m.el === state.selected.el)) {
|
|
293
|
+
state.multi.push({ el: state.selected.el, loc: state.selected.loc });
|
|
294
|
+
}
|
|
295
|
+
if (!state.multi.some((m) => m.el === target)) state.multi.push({ el: target, loc });
|
|
296
|
+
deselect();
|
|
297
|
+
}
|
|
298
|
+
renderMulti();
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function clearMulti() {
|
|
302
|
+
state.multi = [];
|
|
303
|
+
renderMulti();
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function positionMulti() {
|
|
307
|
+
state.multi.forEach((m, i) => {
|
|
308
|
+
if (!multiBoxes[i]) { multiBoxes[i] = el('div', 'twk-multi'); root.appendChild(multiBoxes[i]); }
|
|
309
|
+
const box = multiBoxes[i];
|
|
310
|
+
if (!document.contains(m.el)) { box.style.display = 'none'; return; }
|
|
311
|
+
box.style.display = 'block';
|
|
312
|
+
positionBox(box, m.el);
|
|
313
|
+
});
|
|
314
|
+
for (let i = state.multi.length; i < multiBoxes.length; i++) multiBoxes[i].style.display = 'none';
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
let multiInput;
|
|
318
|
+
function renderMulti() {
|
|
319
|
+
positionMulti();
|
|
320
|
+
if (!state.multi.length) { multiBar.style.display = 'none'; return; }
|
|
321
|
+
multiBar.style.display = 'flex';
|
|
322
|
+
multiBar.textContent = '';
|
|
323
|
+
multiBar.append(el('span', 'twk-count', String(state.multi.length)));
|
|
324
|
+
multiBar.append(el('span', null, 'selected'));
|
|
325
|
+
multiInput = el('input');
|
|
326
|
+
multiInput.placeholder = 'Describe a change for all…';
|
|
327
|
+
multiInput.value = multiBar._draft || '';
|
|
328
|
+
multiInput.oninput = () => { multiBar._draft = multiInput.value; };
|
|
329
|
+
multiInput.onkeydown = (e) => { if (e.key === 'Enter') applyMultiNL(); e.stopPropagation(); };
|
|
330
|
+
const apply = el('button', 'twk-primary', 'Apply');
|
|
331
|
+
apply.onclick = applyMultiNL;
|
|
332
|
+
const del = el('button', 'twk-danger', 'Delete all');
|
|
333
|
+
del.onclick = deleteMulti;
|
|
334
|
+
const clear = el('button', null, 'Clear');
|
|
335
|
+
clear.onclick = clearMulti;
|
|
336
|
+
multiBar.append(multiInput, apply, del, clear);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async function applyMultiNL() {
|
|
340
|
+
const instruction = (multiInput?.value || '').trim();
|
|
341
|
+
if (!instruction) return;
|
|
342
|
+
const targets = state.multi.slice();
|
|
343
|
+
multiBar._draft = '';
|
|
344
|
+
clearMulti();
|
|
345
|
+
for (const t of targets) {
|
|
346
|
+
try {
|
|
347
|
+
const r = await api('nl', { loc: t.loc, instruction, model: state.model });
|
|
348
|
+
addTweak({ id: r.id, status: 'queued', model: r.model, label: `${shortLoc(t.loc)}: ${instruction.slice(0, 40)}` });
|
|
349
|
+
} catch (e) {
|
|
350
|
+
addTweak({ id: 'x' + Date.now() + t.loc, status: 'error', label: `${shortLoc(t.loc)}: ${e.message}` });
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function deleteMulti() {
|
|
356
|
+
const targets = state.multi.slice();
|
|
357
|
+
clearMulti();
|
|
358
|
+
// Delete bottom-up so a mapped-list removal doesn't shift the index of a
|
|
359
|
+
// not-yet-deleted sibling in the same list.
|
|
360
|
+
targets.sort((a, b) => (instanceIndex(b.el, b.loc) ?? 0) - (instanceIndex(a.el, a.loc) ?? 0));
|
|
361
|
+
for (const t of targets) {
|
|
362
|
+
try {
|
|
363
|
+
await deleteOne(t);
|
|
364
|
+
} catch (e) {
|
|
365
|
+
if (document.contains(t.el)) t.el.style.display = '';
|
|
366
|
+
addTweak({ id: 'x' + Date.now() + t.loc, status: 'error', label: `delete ${shortLoc(t.loc)}: ${e.message}` });
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
255
371
|
function reposition() {
|
|
256
372
|
const s = state.selected;
|
|
257
373
|
if (!s) return;
|
|
@@ -268,7 +384,7 @@
|
|
|
268
384
|
const left = Math.min(Math.max(r.left, 8), innerWidth - 356);
|
|
269
385
|
Object.assign(pop.style, { left: left + 'px', top: Math.max(top, 8 + labelH) + 'px' });
|
|
270
386
|
popLabel.style.display = 'block';
|
|
271
|
-
popLabel.textContent = shortLoc(s.loc);
|
|
387
|
+
popLabel.textContent = s.instances > 1 ? `${shortLoc(s.loc)} · ${s.instances}×` : shortLoc(s.loc);
|
|
272
388
|
Object.assign(popLabel.style, { left: (left + pop.offsetWidth / 2) + 'px', top: pop.style.top });
|
|
273
389
|
deleteBtn.style.display = 'flex';
|
|
274
390
|
const inset = 4;
|
|
@@ -322,12 +438,25 @@
|
|
|
322
438
|
function fontStep(target, dir) {
|
|
323
439
|
const scale = readTheme().textSizes;
|
|
324
440
|
const classes = classList(target);
|
|
441
|
+
// An arbitrary size like `text-[34px]` also sets font-size and would
|
|
442
|
+
// override the scale class we add — remove it too, and seed the step from
|
|
443
|
+
// the element's rendered px so the first bump goes the right direction.
|
|
444
|
+
const arbitrary = classes.filter((c) => /^text-\[[^\]]+\]$/.test(c));
|
|
325
445
|
const cur = classes.find((c) => scale.includes(c));
|
|
326
|
-
let idx
|
|
446
|
+
let idx;
|
|
447
|
+
if (cur) idx = scale.indexOf(cur);
|
|
448
|
+
else {
|
|
449
|
+
const px = parseFloat(getComputedStyle(target).fontSize) || 16;
|
|
450
|
+
// nearest scale entry by rendered size, else middle
|
|
451
|
+
idx = scale.indexOf('text-base');
|
|
452
|
+
if (idx < 0) idx = Math.floor(scale.length / 2);
|
|
453
|
+
}
|
|
327
454
|
if (idx < 0) idx = Math.floor(scale.length / 2);
|
|
328
455
|
const next = Math.min(Math.max(idx + dir, 0), scale.length - 1);
|
|
329
|
-
|
|
330
|
-
|
|
456
|
+
const add = scale[next];
|
|
457
|
+
const remove = [...(cur ? [cur] : []), ...arbitrary];
|
|
458
|
+
if (add === cur && !arbitrary.length) return null;
|
|
459
|
+
return { remove, add: [add] };
|
|
331
460
|
}
|
|
332
461
|
|
|
333
462
|
// Property editor: which classes to strip when setting each property.
|
|
@@ -481,11 +610,24 @@
|
|
|
481
610
|
|
|
482
611
|
// location now lives in the green tab above the panel (popLabel)
|
|
483
612
|
|
|
613
|
+
// Reused component: this source line renders in >1 place, so style and
|
|
614
|
+
// functionality edits (which change the shared source) apply everywhere.
|
|
615
|
+
// Copy is the exception — it must stay on this one instance (below).
|
|
616
|
+
const shared = s.instances > 1;
|
|
617
|
+
if (shared) {
|
|
618
|
+
pop.appendChild(el('div', 'twk-note twk-info', `Reused component — ${s.instances} instances. Style & functionality changes apply to all of them.`));
|
|
619
|
+
}
|
|
620
|
+
|
|
484
621
|
// Copy lane, driven by the SOURCE text literals (s.meta.texts), not the
|
|
485
622
|
// DOM: animation libs split text into spans and expressions render as
|
|
486
623
|
// text, so DOM shape says nothing about what's editable in the JSX.
|
|
487
624
|
const literals = s.meta ? s.meta.texts : null;
|
|
488
|
-
if (literals && literals.length) {
|
|
625
|
+
if (shared && literals && literals.length) {
|
|
626
|
+
// The text literal is shared by every instance, so editing it here would
|
|
627
|
+
// change all of them — which is not what a copy edit should do. Keep copy
|
|
628
|
+
// local: block the in-place edit and point to the per-instance route.
|
|
629
|
+
pop.appendChild(el('div', 'twk-note', 'Copy here is shared across all instances — editing it would change every one. To change just this instance, make its text a prop/data value (describe it below and it\'ll route through the model).'));
|
|
630
|
+
} else if (literals && literals.length) {
|
|
489
631
|
// In-place editing only when the single literal IS the element's whole
|
|
490
632
|
// text (animation-split DOM still qualifies — same text, different
|
|
491
633
|
// nodes). Partial literals (mixed with {expressions}) get input fields
|
|
@@ -608,14 +750,14 @@
|
|
|
608
750
|
|
|
609
751
|
const nlRow = el('div', 'twk-row');
|
|
610
752
|
const input = el('input');
|
|
611
|
-
input.placeholder = 'Describe a change…
|
|
753
|
+
input.placeholder = 'Describe a change…';
|
|
612
754
|
const go = el('button', 'twk-primary', 'Go');
|
|
613
755
|
const send = async () => {
|
|
614
756
|
const instruction = input.value.trim();
|
|
615
757
|
if (!instruction) return;
|
|
616
758
|
input.value = '';
|
|
617
759
|
try {
|
|
618
|
-
const r = await api('nl', { loc: s.loc, instruction });
|
|
760
|
+
const r = await api('nl', { loc: s.loc, instruction, model: state.model });
|
|
619
761
|
addTweak({ id: r.id, status: 'queued', model: r.model, label: instruction.slice(0, 60) });
|
|
620
762
|
} catch (e) {
|
|
621
763
|
addTweak({ id: 'x' + Date.now(), status: 'error', label: e.message });
|
|
@@ -625,6 +767,20 @@
|
|
|
625
767
|
input.onkeydown = (e) => { if (e.key === 'Enter') send(); e.stopPropagation(); };
|
|
626
768
|
nlRow.append(input, go);
|
|
627
769
|
pop.appendChild(nlRow);
|
|
770
|
+
|
|
771
|
+
// Model picker: Auto (router) or a forced tier.
|
|
772
|
+
const modelRow = el('div', 'twk-row');
|
|
773
|
+
modelRow.append(el('span', 'twk-label', 'Model'));
|
|
774
|
+
const modelSel = el('select');
|
|
775
|
+
for (const [label, value] of [['Auto (routed)', 'auto'], ['Sonnet', 'claude-sonnet-5'], ['Opus', 'claude-opus-4-8'], ['Fable', 'claude-fable-5']]) {
|
|
776
|
+
const o = el('option', null, label);
|
|
777
|
+
o.value = value;
|
|
778
|
+
if (value === state.model) o.selected = true;
|
|
779
|
+
modelSel.appendChild(o);
|
|
780
|
+
}
|
|
781
|
+
modelSel.onchange = () => { state.model = modelSel.value; };
|
|
782
|
+
modelRow.append(modelSel);
|
|
783
|
+
pop.appendChild(modelRow);
|
|
628
784
|
// delete lives as a floating icon on the element itself (deleteBtn)
|
|
629
785
|
}
|
|
630
786
|
|
|
@@ -733,7 +889,14 @@
|
|
|
733
889
|
row._dot.className = 'twk-dot ' + t.status;
|
|
734
890
|
const inFlight = t.status === 'queued' || t.status === 'running';
|
|
735
891
|
row._cancel.style.display = inFlight ? '' : 'none';
|
|
736
|
-
|
|
892
|
+
const undoable = t.status === 'done' && !String(t.id).startsWith('x');
|
|
893
|
+
row._undo.style.display = undoable ? '' : 'none';
|
|
894
|
+
const key = String(t.id);
|
|
895
|
+
if (undoable && !undoStack.includes(key)) undoStack.push(key);
|
|
896
|
+
if (t.status === 'reverted') {
|
|
897
|
+
const i = undoStack.indexOf(key);
|
|
898
|
+
if (i >= 0) undoStack.splice(i, 1);
|
|
899
|
+
}
|
|
737
900
|
}
|
|
738
901
|
const bits = [];
|
|
739
902
|
if (t.model) bits.push(t.model.replace(/^claude-/, '') + (t.effort ? ` @ ${t.effort}` : ''));
|
|
@@ -768,22 +931,64 @@
|
|
|
768
931
|
|
|
769
932
|
function setMode(on) {
|
|
770
933
|
state.selectMode = on;
|
|
771
|
-
hint.textContent = on ? 'select mode — click
|
|
772
|
-
if (!on) { setHover(null); deselect(); }
|
|
934
|
+
hint.textContent = on ? 'select mode — click · shift-click multi · Tab next · ⌘Z undo · Esc exit' : '⌘. select mode';
|
|
935
|
+
if (!on) { setHover(null); deselect(); clearMulti(); }
|
|
773
936
|
}
|
|
774
937
|
|
|
938
|
+
// Visible, stamped elements in document order — the Tab cycle.
|
|
939
|
+
function stampedEls() {
|
|
940
|
+
return [...document.querySelectorAll('[data-twk]')].filter((n) => n.getClientRects().length);
|
|
941
|
+
}
|
|
942
|
+
function selectNext(dir) {
|
|
943
|
+
const els = stampedEls();
|
|
944
|
+
if (!els.length) return;
|
|
945
|
+
const cur = state.selected?.el;
|
|
946
|
+
let idx = cur ? els.indexOf(cur) : -1;
|
|
947
|
+
idx = (idx + dir + els.length) % els.length;
|
|
948
|
+
const next = els[idx];
|
|
949
|
+
select(next);
|
|
950
|
+
next.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
async function undoLast() {
|
|
954
|
+
const id = undoStack.pop();
|
|
955
|
+
if (id == null) return;
|
|
956
|
+
try {
|
|
957
|
+
await api('undo', { id });
|
|
958
|
+
setTimeout(reposition, 350);
|
|
959
|
+
} catch { /* already reverted / unknown — drop it */ }
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
// ⌘Z / Ctrl-Z should still do native undo inside inputs and while editing copy.
|
|
963
|
+
const inTextField = (t) =>
|
|
964
|
+
t instanceof Element && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName));
|
|
965
|
+
|
|
775
966
|
addEventListener('keydown', (e) => {
|
|
776
967
|
if ((e.metaKey || e.ctrlKey) && e.key === '.') {
|
|
777
968
|
e.preventDefault();
|
|
778
969
|
setMode(!state.selectMode);
|
|
779
970
|
return;
|
|
780
971
|
}
|
|
972
|
+
// Global undo (works whenever the overlay is active, not just in select
|
|
973
|
+
// mode) — but never hijack undo inside a text field or mid copy-edit.
|
|
974
|
+
if ((e.metaKey || e.ctrlKey) && (e.key === 'z' || e.key === 'Z') && !e.shiftKey) {
|
|
975
|
+
if (state.editing || inTextField(e.target)) return;
|
|
976
|
+
e.preventDefault();
|
|
977
|
+
undoLast();
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
781
980
|
if (!state.selectMode) return;
|
|
782
981
|
if (e.key === 'Escape') {
|
|
783
982
|
if (state.editing) return finishTextEdit(false);
|
|
983
|
+
if (state.multi.length) return clearMulti();
|
|
784
984
|
if (state.selected) return deselect();
|
|
785
985
|
return setMode(false);
|
|
786
986
|
}
|
|
987
|
+
if (e.key === 'Tab' && !state.editing) {
|
|
988
|
+
e.preventDefault();
|
|
989
|
+
selectNext(e.shiftKey ? -1 : 1);
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
787
992
|
if (e.key === 'Enter' && state.editing) {
|
|
788
993
|
e.preventDefault();
|
|
789
994
|
finishTextEdit(true);
|
|
@@ -812,10 +1017,13 @@
|
|
|
812
1017
|
e.preventDefault();
|
|
813
1018
|
e.stopPropagation();
|
|
814
1019
|
const target = e.target instanceof Element ? e.target.closest('[data-twk]') : null;
|
|
1020
|
+
// Shift-click toggles an element in the multi-selection set.
|
|
1021
|
+
if (target && e.shiftKey) return toggleMulti(target);
|
|
1022
|
+
if (state.multi.length) clearMulti();
|
|
815
1023
|
if (target) select(target);
|
|
816
1024
|
else deselect();
|
|
817
1025
|
}, true);
|
|
818
1026
|
|
|
819
|
-
addEventListener('scroll', () => { reposition(); setHover(state.hoverEl); }, true);
|
|
820
|
-
addEventListener('resize', () => reposition());
|
|
1027
|
+
addEventListener('scroll', () => { reposition(); positionMulti(); setHover(state.hoverEl); }, true);
|
|
1028
|
+
addEventListener('resize', () => { reposition(); positionMulti(); });
|
|
821
1029
|
})();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tweaklocal",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.4",
|
|
4
4
|
"description": "Tweak your UI live in the browser and write the changes straight to source. Copy and Tailwind edits cost zero tokens; everything else routes to a right-sized model via headless claude.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/src/resolver.js
CHANGED
|
@@ -90,30 +90,106 @@ export function applyTextEdit(root, loc, oldText, newText) {
|
|
|
90
90
|
return writeChecked(abs, content, next);
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
// Rebuild a template-literal className, editing only the STATIC class tokens
|
|
94
|
+
// and keeping every `${...}` expression verbatim. Returns the `...` source.
|
|
95
|
+
function rebuildTemplateClasses(content, expr, removes, adds) {
|
|
96
|
+
const SENT = '\u0000';
|
|
97
|
+
const parts = [];
|
|
98
|
+
expr.quasis.forEach((q, i) => {
|
|
99
|
+
parts.push(q.value.raw);
|
|
100
|
+
if (i < expr.expressions.length) parts.push(SENT);
|
|
101
|
+
});
|
|
102
|
+
let tokens = parts
|
|
103
|
+
.join('')
|
|
104
|
+
.split(/\s+/)
|
|
105
|
+
.filter(Boolean)
|
|
106
|
+
.flatMap((t) => {
|
|
107
|
+
if (!t.includes(SENT)) return [t];
|
|
108
|
+
const out = [];
|
|
109
|
+
t.split(SENT).forEach((seg, i, a) => { if (seg) out.push(seg); if (i < a.length - 1) out.push(SENT); });
|
|
110
|
+
return out;
|
|
111
|
+
});
|
|
112
|
+
tokens = tokens.filter((t) => t === SENT || !removes.includes(t));
|
|
113
|
+
for (const a of adds) if (!tokens.includes(a)) tokens.push(a);
|
|
114
|
+
let ei = 0;
|
|
115
|
+
const rebuilt = tokens
|
|
116
|
+
.map((t) => (t === SENT ? '${' + content.slice(expr.expressions[ei].start, expr.expressions[ei++].end) + '}' : t))
|
|
117
|
+
.join(' ');
|
|
118
|
+
return '`' + rebuilt + '`';
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Resolve a local `const name = "..."` (or template) referenced by
|
|
122
|
+
// className={name}. Returns the string/template literal node, or null.
|
|
123
|
+
function resolveClassConst(ast, name) {
|
|
124
|
+
let found = null;
|
|
125
|
+
walk(ast, (n) => {
|
|
126
|
+
if (found) return;
|
|
127
|
+
if (n.type === 'VariableDeclarator' && n.id?.type === 'Identifier' && n.id.name === name) {
|
|
128
|
+
const init = unwrapExpr(n.init);
|
|
129
|
+
if (init && (init.type === 'StringLiteral' || init.type === 'TemplateLiteral')) found = init;
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
return found;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// The literal node we can actually edit for a className attribute — the
|
|
136
|
+
// attribute's own string/template, or the `const` it references.
|
|
137
|
+
function classValueNode(ast, attr) {
|
|
138
|
+
const v = attr.value;
|
|
139
|
+
if (v && v.type === 'StringLiteral') return v;
|
|
140
|
+
const expr = v && v.type === 'JSXExpressionContainer' ? v.expression : null;
|
|
141
|
+
if (!expr) return null;
|
|
142
|
+
if (expr.type === 'StringLiteral' || expr.type === 'TemplateLiteral') return expr;
|
|
143
|
+
if (expr.type === 'Identifier') return resolveClassConst(ast, expr.name);
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
93
147
|
export function applyClassEdit(root, loc, removes = [], adds = []) {
|
|
94
|
-
const { abs, content, element } = loadTarget(root, loc);
|
|
148
|
+
const { abs, content, element, ast } = loadTarget(root, loc);
|
|
95
149
|
const opening = element.openingElement;
|
|
96
|
-
const
|
|
97
|
-
(a) =>
|
|
98
|
-
a.type === 'JSXAttribute' &&
|
|
99
|
-
a.name.name === 'className' &&
|
|
100
|
-
a.value &&
|
|
101
|
-
a.value.type === 'StringLiteral'
|
|
150
|
+
const classAttrs = opening.attributes.filter(
|
|
151
|
+
(a) => a.type === 'JSXAttribute' && a.name.name === 'className'
|
|
102
152
|
);
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
let classes =
|
|
106
|
-
classes = classes.filter((c) => !removes.includes(c));
|
|
153
|
+
|
|
154
|
+
const editList = (list) => {
|
|
155
|
+
let classes = list.split(/\s+/).filter(Boolean).filter((c) => !removes.includes(c));
|
|
107
156
|
for (const a of adds) if (!classes.includes(a)) classes.push(a);
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
157
|
+
return classes.join(' ');
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
// No className yet — add a fresh string attribute.
|
|
161
|
+
if (!classAttrs.length) {
|
|
113
162
|
const pos = opening.name.end;
|
|
114
|
-
|
|
115
|
-
content.slice(0, pos) + ` className="${adds.join(' ')}"` + content.slice(pos);
|
|
163
|
+
return writeChecked(abs, content, content.slice(0, pos) + ` className="${adds.join(' ')}"` + content.slice(pos));
|
|
116
164
|
}
|
|
165
|
+
|
|
166
|
+
// When an element has DUPLICATE className attributes (a corruption the old
|
|
167
|
+
// class editor could introduce), React renders only the LAST one. Edit that
|
|
168
|
+
// effective className, and strip the earlier dead duplicates so the element
|
|
169
|
+
// ends up valid and single-className.
|
|
170
|
+
const attr = classAttrs[classAttrs.length - 1];
|
|
171
|
+
const node = classValueNode(ast, attr);
|
|
172
|
+
if (!node) {
|
|
173
|
+
throw new Error(
|
|
174
|
+
"className here is computed, so I can't edit its classes deterministically. Use the size/color controls (inline style) or describe the change to route it through the model."
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const replacement =
|
|
179
|
+
node.type === 'TemplateLiteral'
|
|
180
|
+
? rebuildTemplateClasses(content, node, removes, adds)
|
|
181
|
+
: JSON.stringify(editList(node.value));
|
|
182
|
+
|
|
183
|
+
// Apply the value replacement plus removal of each dead duplicate attribute
|
|
184
|
+
// (with its leading space), right-to-left so offsets stay valid.
|
|
185
|
+
const edits = [{ start: node.start, end: node.end, text: replacement }];
|
|
186
|
+
for (const d of classAttrs.slice(0, -1)) {
|
|
187
|
+
const start = content[d.start - 1] === ' ' ? d.start - 1 : d.start;
|
|
188
|
+
edits.push({ start, end: d.end, text: '' });
|
|
189
|
+
}
|
|
190
|
+
edits.sort((a, b) => b.start - a.start);
|
|
191
|
+
let next = content;
|
|
192
|
+
for (const e of edits) next = next.slice(0, e.start) + e.text + next.slice(e.end);
|
|
117
193
|
return writeChecked(abs, content, next);
|
|
118
194
|
}
|
|
119
195
|
|
|
@@ -255,16 +331,17 @@ function nameOfFunction(ast, fn) {
|
|
|
255
331
|
|
|
256
332
|
// Walk up from the element looking for a `{...}` JSX expression whose whole
|
|
257
333
|
// removal parses — covers `{cond && <el/>}` (conditional) and `{arr.map(...)}`
|
|
258
|
-
// (list template
|
|
259
|
-
// function
|
|
260
|
-
//
|
|
334
|
+
// (list template). Never crosses a function boundary except an inline callback
|
|
335
|
+
// (a function that is itself a call argument, e.g. the .map render callback).
|
|
336
|
+
// For lists, also returns the map CallExpression so the caller can target the
|
|
337
|
+
// underlying data array instead of the whole list.
|
|
261
338
|
function removableExpressionContainer(chain) {
|
|
262
339
|
for (let i = 1; i < chain.length; i++) {
|
|
263
340
|
const node = chain[i];
|
|
264
341
|
if (node.type === 'JSXExpressionContainer') {
|
|
265
342
|
const inner = chain[i - 1];
|
|
266
343
|
const isList = FN_TYPES.has(inner?.type) || inner?.type === 'CallExpression';
|
|
267
|
-
return { container: node, kind: isList ? 'list' : 'conditional block' };
|
|
344
|
+
return { container: node, kind: isList ? 'list' : 'conditional block', mapCall: isList ? inner : null };
|
|
268
345
|
}
|
|
269
346
|
if (FN_TYPES.has(node.type)) {
|
|
270
347
|
// crossing a function is only allowed for inline callbacks (map etc.)
|
|
@@ -275,6 +352,68 @@ function removableExpressionContainer(chain) {
|
|
|
275
352
|
return null;
|
|
276
353
|
}
|
|
277
354
|
|
|
355
|
+
// Unwrap TS wrappers around an initializer (as const / satisfies / parens).
|
|
356
|
+
function unwrapExpr(node) {
|
|
357
|
+
let n = node;
|
|
358
|
+
while (n && (n.type === 'TSAsExpression' || n.type === 'TSSatisfiesExpression' || n.type === 'TSNonNullExpression' || n.type === 'ParenthesizedExpression'))
|
|
359
|
+
n = n.expression;
|
|
360
|
+
return n;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// For `{items.map(cb)}`: resolve `items` to a local `const items = [...]`
|
|
364
|
+
// ArrayExpression in the same file. Returns the array node or null.
|
|
365
|
+
function resolveMappedArray(ast, mapCall) {
|
|
366
|
+
let call = mapCall;
|
|
367
|
+
// chain may have handed us the arrow (expression-body case) — find the call
|
|
368
|
+
if (call && FN_TYPES.has(call.type)) return null; // caller passes the real call below
|
|
369
|
+
if (!call || call.type !== 'CallExpression') return null;
|
|
370
|
+
const callee = call.callee;
|
|
371
|
+
if (
|
|
372
|
+
callee?.type !== 'MemberExpression' ||
|
|
373
|
+
callee.property?.type !== 'Identifier' ||
|
|
374
|
+
!['map', 'flatMap'].includes(callee.property.name) ||
|
|
375
|
+
callee.object?.type !== 'Identifier'
|
|
376
|
+
) return null;
|
|
377
|
+
const arrName = callee.object.name;
|
|
378
|
+
let arr = null;
|
|
379
|
+
walk(ast, (n) => {
|
|
380
|
+
if (arr) return;
|
|
381
|
+
if (n.type === 'VariableDeclarator' && n.id?.type === 'Identifier' && n.id.name === arrName) {
|
|
382
|
+
const init = unwrapExpr(n.init);
|
|
383
|
+
if (init?.type === 'ArrayExpression') arr = init;
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
return arr;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Remove the k-th element of an ArrayExpression, handling commas and whole
|
|
390
|
+
// lines. Returns new content, or null if the result doesn't parse.
|
|
391
|
+
function removeArrayElement(content, arrayNode, k, file) {
|
|
392
|
+
const el = arrayNode.elements[k];
|
|
393
|
+
if (!el) return null;
|
|
394
|
+
let start = el.start;
|
|
395
|
+
let end = el.end;
|
|
396
|
+
const after = content.slice(end).match(/^\s*,/);
|
|
397
|
+
if (after) end += after[0].length;
|
|
398
|
+
else {
|
|
399
|
+
const before = content.slice(0, start).match(/,\s*$/);
|
|
400
|
+
if (before) start -= before[0].length;
|
|
401
|
+
}
|
|
402
|
+
const lineStart = content.lastIndexOf('\n', start - 1) + 1;
|
|
403
|
+
const trailing = content.slice(end).match(/^[ \t]*\n/);
|
|
404
|
+
if (/^[ \t]*$/.test(content.slice(lineStart, start)) && trailing) {
|
|
405
|
+
start = lineStart;
|
|
406
|
+
end += trailing[0].length;
|
|
407
|
+
}
|
|
408
|
+
const next = content.slice(0, start) + content.slice(end);
|
|
409
|
+
try {
|
|
410
|
+
parseSource(next, file);
|
|
411
|
+
return next;
|
|
412
|
+
} catch {
|
|
413
|
+
return null;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
278
417
|
// Walk the project for JSX usages of <Name ...>. Returns [{file, element}].
|
|
279
418
|
function findUsages(root, name) {
|
|
280
419
|
const usages = [];
|
|
@@ -309,15 +448,16 @@ function findUsages(root, name) {
|
|
|
309
448
|
/**
|
|
310
449
|
* Delete behavior, in order:
|
|
311
450
|
* 1. Normal element (has siblings): remove it in place.
|
|
312
|
-
* 2.
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
*
|
|
316
|
-
* 3.
|
|
451
|
+
* 2. List template (`{arr.map(...)}`): the clicked DOM instance's `index`
|
|
452
|
+
* identifies the data item — remove that ONE entry from the local array
|
|
453
|
+
* literal. Deleting one card removes one card, not the list. (To remove a
|
|
454
|
+
* whole list, select its surrounding wrapper element instead.)
|
|
455
|
+
* 3. Conditional (`{cond && <el/>}`): remove the whole conditional block.
|
|
456
|
+
* 4. Sole return of a NAMED component used in exactly one place: remove that
|
|
317
457
|
* usage at its call site.
|
|
318
458
|
* Otherwise refuse with an actionable message (ambiguous → model lane).
|
|
319
459
|
*/
|
|
320
|
-
export function applyDeleteElement(root, loc, { dryRun = false } = {}) {
|
|
460
|
+
export function applyDeleteElement(root, loc, { dryRun = false, index } = {}) {
|
|
321
461
|
// reuse loadTarget's AST — element identity must hold for the ancestor walk
|
|
322
462
|
const { abs, content, element, file, ast } = loadTarget(root, loc);
|
|
323
463
|
|
|
@@ -331,6 +471,22 @@ export function applyDeleteElement(root, loc, { dryRun = false } = {}) {
|
|
|
331
471
|
const chain = ancestorChain(ast, element);
|
|
332
472
|
|
|
333
473
|
const wrapper = removableExpressionContainer(chain);
|
|
474
|
+
if (wrapper && wrapper.kind === 'list') {
|
|
475
|
+
const arr = resolveMappedArray(ast, wrapper.mapCall);
|
|
476
|
+
if (arr && Number.isInteger(index) && index >= 0 && index < arr.elements.length) {
|
|
477
|
+
const next = removeArrayElement(content, arr, index, file);
|
|
478
|
+
if (next !== null) {
|
|
479
|
+
const detail = `item ${index + 1} of ${arr.elements.length}`;
|
|
480
|
+
if (dryRun) return { abs, before: content, after: next, wouldWrite: false, removedBlock: detail };
|
|
481
|
+
return { ...writeChecked(abs, content, next), removedBlock: detail };
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
// data not editable deterministically (imported/computed array, or the
|
|
485
|
+
// instance index is unknown) — never silently delete the whole list
|
|
486
|
+
throw new Error(
|
|
487
|
+
"this is one item in a rendered list whose data I can't safely edit here. Describe the change (e.g. \"remove the second stat\") and it'll route through the model — or select the list's surrounding container to delete the whole list."
|
|
488
|
+
);
|
|
489
|
+
}
|
|
334
490
|
if (wrapper) {
|
|
335
491
|
const next = removeElementFromContent(content, wrapper.container, file);
|
|
336
492
|
if (next !== null) {
|
package/src/server.js
CHANGED
|
@@ -128,12 +128,12 @@ export function startServer({ root, port = 4100 }) {
|
|
|
128
128
|
|
|
129
129
|
if (url.pathname === '/api/delete') {
|
|
130
130
|
if (body.dryRun) {
|
|
131
|
-
applyDeleteElement(root, body.loc, { dryRun: true }); // throws if unsafe, writes nothing
|
|
131
|
+
applyDeleteElement(root, body.loc, { dryRun: true, index: body.index }); // throws if unsafe, writes nothing
|
|
132
132
|
return json(res, { ok: true, dryRun: true });
|
|
133
133
|
}
|
|
134
134
|
const id = nextId++;
|
|
135
135
|
const target = describeTarget(root, body.loc);
|
|
136
|
-
const write = applyDeleteElement(root, body.loc);
|
|
136
|
+
const write = applyDeleteElement(root, body.loc, { index: body.index });
|
|
137
137
|
remember(id, write);
|
|
138
138
|
telemetry.record('delete');
|
|
139
139
|
const label = write.removedUsage
|
|
@@ -173,6 +173,12 @@ export function startServer({ root, port = 4100 }) {
|
|
|
173
173
|
const { file } = parseLoc(body.loc);
|
|
174
174
|
const target = describeTarget(root, body.loc);
|
|
175
175
|
const route = await classify(body.instruction, { cwd: root });
|
|
176
|
+
// Manual model override from the picker: keep the router's effort/tier
|
|
177
|
+
// (still sized to the request) but force the model the user chose.
|
|
178
|
+
if (body.model && body.model !== 'auto') {
|
|
179
|
+
route.model = body.model;
|
|
180
|
+
route.tier = 'manual';
|
|
181
|
+
}
|
|
176
182
|
const abs = path.resolve(root, file);
|
|
177
183
|
remember(id, { abs, before: fs.readFileSync(abs, 'utf8') });
|
|
178
184
|
telemetry.record('nl');
|