tweaklocal 0.3.3 → 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 +23 -5
- package/package.json +1 -1
- package/src/resolver.js +82 -51
package/overlay/overlay.js
CHANGED
|
@@ -35,6 +35,8 @@
|
|
|
35
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}
|
|
36
36
|
.twk-pop select{background:#1f2937;border:1px solid #374151;border-radius:6px;color:#f9fafb;padding:3px 4px;font-size:11.5px;outline:none}
|
|
37
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)}
|
|
38
40
|
.twk-cur{color:#6ee7b7;font-size:11px;margin-left:auto}
|
|
39
41
|
.twk-swatches{display:flex;gap:4px;flex-wrap:wrap;max-height:96px;overflow-y:auto;padding:2px}
|
|
40
42
|
.twk-swatch{width:18px;height:18px;border-radius:4px;border:1px solid rgba(255,255,255,.25);cursor:pointer;padding:0}
|
|
@@ -241,7 +243,10 @@
|
|
|
241
243
|
function select(target) {
|
|
242
244
|
finishTextEdit(false);
|
|
243
245
|
const loc = target.getAttribute('data-twk');
|
|
244
|
-
|
|
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 };
|
|
245
250
|
selBox.style.display = 'block';
|
|
246
251
|
renderPopover();
|
|
247
252
|
reposition();
|
|
@@ -379,7 +384,7 @@
|
|
|
379
384
|
const left = Math.min(Math.max(r.left, 8), innerWidth - 356);
|
|
380
385
|
Object.assign(pop.style, { left: left + 'px', top: Math.max(top, 8 + labelH) + 'px' });
|
|
381
386
|
popLabel.style.display = 'block';
|
|
382
|
-
popLabel.textContent = shortLoc(s.loc);
|
|
387
|
+
popLabel.textContent = s.instances > 1 ? `${shortLoc(s.loc)} · ${s.instances}×` : shortLoc(s.loc);
|
|
383
388
|
Object.assign(popLabel.style, { left: (left + pop.offsetWidth / 2) + 'px', top: pop.style.top });
|
|
384
389
|
deleteBtn.style.display = 'flex';
|
|
385
390
|
const inset = 4;
|
|
@@ -605,11 +610,24 @@
|
|
|
605
610
|
|
|
606
611
|
// location now lives in the green tab above the panel (popLabel)
|
|
607
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
|
+
|
|
608
621
|
// Copy lane, driven by the SOURCE text literals (s.meta.texts), not the
|
|
609
622
|
// DOM: animation libs split text into spans and expressions render as
|
|
610
623
|
// text, so DOM shape says nothing about what's editable in the JSX.
|
|
611
624
|
const literals = s.meta ? s.meta.texts : null;
|
|
612
|
-
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) {
|
|
613
631
|
// In-place editing only when the single literal IS the element's whole
|
|
614
632
|
// text (animation-split DOM still qualifies — same text, different
|
|
615
633
|
// nodes). Partial literals (mixed with {expressions}) get input fields
|
|
@@ -999,8 +1017,8 @@
|
|
|
999
1017
|
e.preventDefault();
|
|
1000
1018
|
e.stopPropagation();
|
|
1001
1019
|
const target = e.target instanceof Element ? e.target.closest('[data-twk]') : null;
|
|
1002
|
-
// Shift
|
|
1003
|
-
if (target &&
|
|
1020
|
+
// Shift-click toggles an element in the multi-selection set.
|
|
1021
|
+
if (target && e.shiftKey) return toggleMulti(target);
|
|
1004
1022
|
if (state.multi.length) clearMulti();
|
|
1005
1023
|
if (target) select(target);
|
|
1006
1024
|
else deselect();
|
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,10 +90,64 @@ 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
|
|
150
|
+
const classAttrs = opening.attributes.filter(
|
|
97
151
|
(a) => a.type === 'JSXAttribute' && a.name.name === 'className'
|
|
98
152
|
);
|
|
99
153
|
|
|
@@ -104,62 +158,39 @@ export function applyClassEdit(root, loc, removes = [], adds = []) {
|
|
|
104
158
|
};
|
|
105
159
|
|
|
106
160
|
// No className yet — add a fresh string attribute.
|
|
107
|
-
if (!
|
|
161
|
+
if (!classAttrs.length) {
|
|
108
162
|
const pos = opening.name.end;
|
|
109
163
|
return writeChecked(abs, content, content.slice(0, pos) + ` className="${adds.join(' ')}"` + content.slice(pos));
|
|
110
164
|
}
|
|
111
165
|
|
|
112
|
-
// className
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
const next = content.slice(0, expr.start) + JSON.stringify(editList(expr.value)) + content.slice(expr.end);
|
|
123
|
-
return writeChecked(abs, content, next);
|
|
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
|
+
);
|
|
124
176
|
}
|
|
125
177
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
});
|
|
137
|
-
let tokens = parts
|
|
138
|
-
.join('')
|
|
139
|
-
.split(/\s+/)
|
|
140
|
-
.filter(Boolean)
|
|
141
|
-
.flatMap((t) => {
|
|
142
|
-
if (!t.includes(SENT)) return [t];
|
|
143
|
-
const out = [];
|
|
144
|
-
t.split(SENT).forEach((seg, i, a) => { if (seg) out.push(seg); if (i < a.length - 1) out.push(SENT); });
|
|
145
|
-
return out;
|
|
146
|
-
});
|
|
147
|
-
tokens = tokens.filter((t) => t === SENT || !removes.includes(t));
|
|
148
|
-
for (const a of adds) if (!tokens.includes(a)) tokens.push(a);
|
|
149
|
-
let ei = 0;
|
|
150
|
-
const rebuilt = tokens
|
|
151
|
-
.map((t) => (t === SENT ? '${' + content.slice(expr.expressions[ei].start, expr.expressions[ei++].end) + '}' : t))
|
|
152
|
-
.join(' ');
|
|
153
|
-
const next = content.slice(0, expr.start) + '`' + rebuilt + '`' + content.slice(expr.end);
|
|
154
|
-
return writeChecked(abs, content, next);
|
|
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: '' });
|
|
155
189
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
throw new Error(
|
|
161
|
-
"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."
|
|
162
|
-
);
|
|
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);
|
|
193
|
+
return writeChecked(abs, content, next);
|
|
163
194
|
}
|
|
164
195
|
|
|
165
196
|
/**
|