@easybits.cloud/html-tailwind-generator 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,239 @@
1
+ import { useEffect, useRef, useCallback, useState } from "react";
2
+ import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter, Decoration, type DecorationSet } from "@codemirror/view";
3
+ import { EditorState, StateField, StateEffect } from "@codemirror/state";
4
+ import { html } from "@codemirror/lang-html";
5
+ import { oneDark } from "@codemirror/theme-one-dark";
6
+ import { defaultKeymap, indentWithTab, history, historyKeymap } from "@codemirror/commands";
7
+ import { searchKeymap, highlightSelectionMatches } from "@codemirror/search";
8
+ import { bracketMatching, foldGutter, foldKeymap } from "@codemirror/language";
9
+ import { closeBrackets, closeBracketsKeymap } from "@codemirror/autocomplete";
10
+
11
+ interface CodeEditorProps {
12
+ code: string;
13
+ label: string;
14
+ scrollToText?: string;
15
+ onSave: (code: string) => void;
16
+ onClose: () => void;
17
+ }
18
+
19
+ function formatHtml(html: string): string {
20
+ let result = html.replace(/>\s*</g, ">\n<");
21
+ const lines = result.split("\n");
22
+ const output: string[] = [];
23
+ let indent = 0;
24
+ for (const raw of lines) {
25
+ const line = raw.trim();
26
+ if (!line) continue;
27
+ const isClosing = /^<\//.test(line);
28
+ const isSelfClosing =
29
+ /\/>$/.test(line) ||
30
+ /^<(img|br|hr|input|meta|link|col|area|base|embed|source|track|wbr)\b/i.test(line);
31
+ const hasInlineClose = /^<[^/][^>]*>.*<\//.test(line);
32
+ if (isClosing) indent = Math.max(0, indent - 1);
33
+ output.push(" ".repeat(indent) + line);
34
+ if (!isClosing && !isSelfClosing && !hasInlineClose && /^<[a-zA-Z]/.test(line)) {
35
+ indent++;
36
+ }
37
+ }
38
+ return output.join("\n");
39
+ }
40
+
41
+ // Flash highlight effect for scroll-to-code
42
+ const flashLineEffect = StateEffect.define<{ from: number; to: number }>();
43
+ const clearFlashEffect = StateEffect.define<null>();
44
+
45
+ const flashLineDeco = Decoration.line({ class: "cm-flash-line" });
46
+
47
+ const flashLineField = StateField.define<DecorationSet>({
48
+ create: () => Decoration.none,
49
+ update(decos, tr) {
50
+ for (const e of tr.effects) {
51
+ if (e.is(flashLineEffect)) {
52
+ return Decoration.set([flashLineDeco.range(e.value.from)]);
53
+ }
54
+ if (e.is(clearFlashEffect)) {
55
+ return Decoration.none;
56
+ }
57
+ }
58
+ return decos;
59
+ },
60
+ provide: (f) => EditorView.decorations.from(f),
61
+ });
62
+
63
+
64
+ function scrollToTarget(view: EditorView, target?: string) {
65
+ if (!target) return;
66
+ const docText = view.state.doc.toString();
67
+ const normalized = target.replace(/"/g, "'");
68
+ let idx = docText.indexOf(normalized);
69
+ if (idx === -1) idx = docText.indexOf(target);
70
+
71
+ // If exact match fails, extract tag+class and search line by line
72
+ if (idx === -1) {
73
+ const tagMatch = target.match(/^<(\w+)/);
74
+ const classMatch = target.match(/class=["']([^"']*?)["']/);
75
+ if (tagMatch) {
76
+ const searchTag = tagMatch[0];
77
+ const searchClass = classMatch ? classMatch[1].split(" ")[0] : null;
78
+ for (let i = 1; i <= view.state.doc.lines; i++) {
79
+ const line = view.state.doc.line(i);
80
+ if (line.text.includes(searchTag) && (!searchClass || line.text.includes(searchClass))) {
81
+ idx = line.from;
82
+ break;
83
+ }
84
+ }
85
+ }
86
+ }
87
+
88
+ if (idx !== -1) {
89
+ const line = view.state.doc.lineAt(idx);
90
+ view.dispatch({
91
+ selection: { anchor: line.from },
92
+ effects: [
93
+ EditorView.scrollIntoView(line.from, { y: "center" }),
94
+ flashLineEffect.of({ from: line.from, to: line.to }),
95
+ ],
96
+ });
97
+ // Clear flash after 2s
98
+ setTimeout(() => {
99
+ view.dispatch({ effects: clearFlashEffect.of(null) });
100
+ }, 2000);
101
+ }
102
+ }
103
+
104
+ export function CodeEditor({ code, label, scrollToText, onSave, onClose }: CodeEditorProps) {
105
+ const containerRef = useRef<HTMLDivElement>(null);
106
+ const viewRef = useRef<EditorView | null>(null);
107
+ const [stats, setStats] = useState({ lines: 0, kb: "0.0" });
108
+
109
+ const onSaveRef = useRef(onSave);
110
+ const onCloseRef = useRef(onClose);
111
+ onSaveRef.current = onSave;
112
+ onCloseRef.current = onClose;
113
+
114
+ const updateStats = useCallback((doc: { length: number; lines: number }) => {
115
+ setStats({ lines: doc.lines, kb: (doc.length / 1024).toFixed(1) });
116
+ }, []);
117
+
118
+ useEffect(() => {
119
+ if (!containerRef.current) return;
120
+
121
+ const initialDoc = code.includes("\n") ? code : formatHtml(code);
122
+
123
+ const state = EditorState.create({
124
+ doc: initialDoc,
125
+ extensions: [
126
+ lineNumbers(),
127
+ highlightActiveLine(),
128
+ highlightActiveLineGutter(),
129
+ bracketMatching(),
130
+ closeBrackets(),
131
+ foldGutter(),
132
+ highlightSelectionMatches(),
133
+ html(),
134
+ oneDark,
135
+ history(),
136
+ EditorView.lineWrapping,
137
+ keymap.of([
138
+ { key: "Mod-s", run: (v) => { onSaveRef.current(v.state.doc.toString()); return true; } },
139
+ { key: "Escape", run: () => { onCloseRef.current(); return true; } },
140
+ indentWithTab,
141
+ ...closeBracketsKeymap,
142
+ ...searchKeymap,
143
+ ...foldKeymap,
144
+ ...historyKeymap,
145
+ ...defaultKeymap,
146
+ ]),
147
+ EditorView.updateListener.of((update) => {
148
+ if (update.docChanged) {
149
+ updateStats(update.state.doc);
150
+ }
151
+ }),
152
+ flashLineField,
153
+ EditorView.theme({
154
+ "&": { height: "100%", fontSize: "13px" },
155
+ ".cm-scroller": { overflow: "auto", fontFamily: "ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace" },
156
+ ".cm-content": { padding: "8px 0" },
157
+ ".cm-gutters": { borderRight: "1px solid #21262d" },
158
+ ".cm-flash-line": { backgroundColor: "rgba(250, 204, 21, 0.25)", transition: "background-color 2s ease-out" },
159
+ }),
160
+ ],
161
+ });
162
+
163
+ const view = new EditorView({ state, parent: containerRef.current });
164
+ viewRef.current = view;
165
+
166
+ updateStats(view.state.doc);
167
+ scrollToTarget(view, scrollToText);
168
+ view.focus();
169
+
170
+ return () => { view.destroy(); };
171
+ // eslint-disable-next-line react-hooks/exhaustive-deps
172
+ }, []);
173
+
174
+ // Re-scroll when scrollToText changes while editor is already open
175
+ useEffect(() => {
176
+ const view = viewRef.current;
177
+ if (!view || !scrollToText) return;
178
+ scrollToTarget(view, scrollToText);
179
+ }, [scrollToText]);
180
+
181
+ function handleFormat() {
182
+ const view = viewRef.current;
183
+ if (!view) return;
184
+ const formatted = formatHtml(view.state.doc.toString());
185
+ view.dispatch({
186
+ changes: { from: 0, to: view.state.doc.length, insert: formatted },
187
+ });
188
+ }
189
+
190
+ function handleSave() {
191
+ const view = viewRef.current;
192
+ if (!view) return;
193
+ onSave(view.state.doc.toString());
194
+ }
195
+
196
+ return (
197
+ <div className="flex flex-col h-full bg-[#0d1117]">
198
+ {/* Header */}
199
+ <div className="flex items-center justify-between px-4 py-3 border-b border-gray-800 shrink-0">
200
+ <div className="flex items-center gap-3">
201
+ <span className="px-2 py-0.5 rounded bg-orange-600/20 text-orange-400 text-[10px] font-mono font-bold uppercase tracking-wider">
202
+ HTML
203
+ </span>
204
+ <span className="text-sm font-bold text-gray-300">{label}</span>
205
+ </div>
206
+ <div className="flex items-center gap-2">
207
+ <button
208
+ onClick={handleFormat}
209
+ className="px-3 py-1.5 text-xs font-bold rounded-lg bg-gray-800 text-gray-400 hover:text-white hover:bg-gray-700 transition-colors"
210
+ >
211
+ Formatear
212
+ </button>
213
+ <button
214
+ onClick={handleSave}
215
+ className="px-4 py-1.5 text-xs font-bold rounded-lg bg-blue-500 text-white hover:bg-blue-600 transition-colors"
216
+ >
217
+ Guardar
218
+ </button>
219
+ <button
220
+ onClick={onClose}
221
+ className="px-3 py-1.5 text-xs font-bold rounded-lg bg-gray-800 text-gray-400 hover:text-white hover:bg-gray-700 transition-colors"
222
+ >
223
+ Cerrar
224
+ </button>
225
+ </div>
226
+ </div>
227
+
228
+ {/* Editor */}
229
+ <div ref={containerRef} className="flex-1 overflow-hidden" />
230
+
231
+ {/* Footer */}
232
+ <div className="flex items-center justify-between px-4 py-1.5 border-t border-gray-800 text-[10px] text-gray-500 font-mono shrink-0">
233
+ <span>{stats.lines} lineas</span>
234
+ <span>Tab = indentar &middot; Cmd+S = guardar &middot; Esc = cerrar</span>
235
+ <span>{stats.kb} KB</span>
236
+ </div>
237
+ </div>
238
+ );
239
+ }
@@ -0,0 +1,350 @@
1
+ import { useState, useRef, useEffect } from "react";
2
+ import { HiSparkles } from "react-icons/hi2";
3
+ import type { IframeMessage } from "../types";
4
+
5
+ const STYLE_PRESETS = [
6
+ { label: "Minimal", icon: "○", instruction: "Redisena esta seccion con estetica minimal: mucho espacio en blanco, tipografia limpia, sin bordes ni sombras innecesarias. Manten el mismo contenido." },
7
+ { label: "Cards", icon: "▦", instruction: "Redisena esta seccion usando layout de cards en grid: cada item en su propia card con padding, sombra sutil y bordes redondeados. Manten el mismo contenido." },
8
+ { label: "Bold", icon: "■", instruction: "Redisena esta seccion con estilo bold/brutalist: tipografia grande y gruesa, colores de alto contraste, bordes solidos, sin gradientes. Manten el mismo contenido." },
9
+ { label: "Glass", icon: "◇", instruction: "Redisena esta seccion con glassmorphism: fondos translucidos con backdrop-blur, bordes sutiles blancos, sombras suaves. Usa un fondo oscuro o con gradiente detras. Manten el mismo contenido." },
10
+ { label: "Dark", icon: "●", instruction: "Redisena esta seccion con fondo oscuro (#111 o similar), texto claro, acentos de color vibrantes. Manten el mismo contenido." },
11
+ ];
12
+
13
+ interface FloatingToolbarProps {
14
+ selection: IframeMessage | null;
15
+ iframeRect: DOMRect | null;
16
+ onRefine: (instruction: string, referenceImage?: string) => void;
17
+ onMoveUp: () => void;
18
+ onMoveDown: () => void;
19
+ onDelete: () => void;
20
+ onClose: () => void;
21
+ onViewCode: () => void;
22
+ onUpdateAttribute?: (sectionId: string, elementPath: string, attr: string, value: string) => void;
23
+ isRefining: boolean;
24
+ }
25
+
26
+ export function FloatingToolbar({
27
+ selection,
28
+ iframeRect,
29
+ onRefine,
30
+ onMoveUp,
31
+ onMoveDown,
32
+ onDelete,
33
+ onClose,
34
+ onViewCode,
35
+ onUpdateAttribute,
36
+ isRefining,
37
+ }: FloatingToolbarProps) {
38
+ const [prompt, setPrompt] = useState("");
39
+ const [showCode, setShowCode] = useState(false);
40
+ const [refImage, setRefImage] = useState<string | null>(null);
41
+ const [refImageName, setRefImageName] = useState<string | null>(null);
42
+ const inputRef = useRef<HTMLInputElement>(null);
43
+ const fileInputRef = useRef<HTMLInputElement>(null);
44
+ const toolbarRef = useRef<HTMLDivElement>(null);
45
+
46
+ // Local attr editing state
47
+ const [imgSrc, setImgSrc] = useState("");
48
+ const [imgAlt, setImgAlt] = useState("");
49
+ const [linkHref, setLinkHref] = useState("");
50
+
51
+ useEffect(() => {
52
+ setPrompt("");
53
+ setShowCode(false);
54
+ setRefImage(null);
55
+ setRefImageName(null);
56
+ }, [selection?.sectionId]);
57
+
58
+ // Sync attr inputs when selection changes
59
+ useEffect(() => {
60
+ if (selection?.attrs) {
61
+ setImgSrc(selection.attrs.src || "");
62
+ setImgAlt(selection.attrs.alt || "");
63
+ setLinkHref(selection.attrs.href || "");
64
+ }
65
+ }, [selection?.attrs, selection?.elementPath]);
66
+
67
+ // ESC closes toolbar
68
+ useEffect(() => {
69
+ function handleKey(e: KeyboardEvent) {
70
+ if (e.key === "Escape") onClose();
71
+ }
72
+ document.addEventListener("keydown", handleKey);
73
+ return () => document.removeEventListener("keydown", handleKey);
74
+ }, [onClose]);
75
+
76
+ if (!selection || !selection.rect || !iframeRect) return null;
77
+
78
+ const toolbarWidth = toolbarRef.current?.offsetWidth || 480;
79
+ const toolbarHeight = toolbarRef.current?.offsetHeight || 60;
80
+ const top = iframeRect.top + selection.rect.top + selection.rect.height + 8;
81
+ const left = iframeRect.left + selection.rect.left;
82
+ const clampedLeft = Math.max(8, Math.min(left, window.innerWidth - toolbarWidth - 8));
83
+ const showAbove = top + toolbarHeight + 8 > window.innerHeight;
84
+ const finalTop = Math.max(8, showAbove
85
+ ? iframeRect.top + selection.rect.top - toolbarHeight - 8
86
+ : top);
87
+
88
+ function handleSubmit(e: React.FormEvent) {
89
+ e.preventDefault();
90
+ if (!prompt.trim() || isRefining) return;
91
+ onRefine(prompt.trim(), refImage || undefined);
92
+ setPrompt("");
93
+ setRefImage(null);
94
+ setRefImageName(null);
95
+ }
96
+
97
+ function handleFileSelect(e: React.ChangeEvent<HTMLInputElement>) {
98
+ const file = e.target.files?.[0];
99
+ if (!file) return;
100
+ setRefImageName(file.name);
101
+ const reader = new FileReader();
102
+ reader.onload = () => {
103
+ setRefImage(reader.result as string);
104
+ };
105
+ reader.readAsDataURL(file);
106
+ e.target.value = "";
107
+ }
108
+
109
+ function handleSetAttr(attr: string, value: string) {
110
+ if (!selection?.sectionId || !selection?.elementPath || !onUpdateAttribute) return;
111
+ onUpdateAttribute(selection.sectionId, selection.elementPath, attr, value);
112
+ }
113
+
114
+ const isImg = selection.tagName === "IMG";
115
+ const isLink = selection.tagName === "A";
116
+ const hasAttrEditing = (isImg || isLink) && onUpdateAttribute;
117
+
118
+ return (
119
+ <div
120
+ ref={toolbarRef}
121
+ className="fixed z-50 flex flex-col gap-1.5 bg-gray-900 text-white rounded-xl shadow-2xl px-2 py-1.5 border border-gray-700"
122
+ style={{ top: finalTop, left: clampedLeft, maxWidth: "min(480px, calc(100vw - 16px))" }}
123
+ >
124
+ {/* Main row */}
125
+ <div className="flex items-center gap-1.5">
126
+ {/* Tag badge */}
127
+ {selection.tagName && (
128
+ <span className="px-2 py-0.5 rounded-md bg-blue-600 text-[10px] font-mono font-bold uppercase tracking-wider shrink-0">
129
+ {selection.tagName.toLowerCase()}
130
+ </span>
131
+ )}
132
+
133
+ {/* AI prompt input */}
134
+ <form onSubmit={handleSubmit} className="flex items-center gap-1 flex-1">
135
+ <input
136
+ ref={inputRef}
137
+ type="text"
138
+ value={prompt}
139
+ onChange={(e) => setPrompt(e.target.value)}
140
+ placeholder={refImage ? "Instruccion + imagen..." : "Editar con AI..."}
141
+ disabled={isRefining}
142
+ className="bg-transparent text-sm text-white placeholder:text-gray-500 outline-none w-40 md:w-56 px-2 py-1"
143
+ />
144
+ {/* Image attach button */}
145
+ <button
146
+ type="button"
147
+ onClick={() => fileInputRef.current?.click()}
148
+ disabled={isRefining}
149
+ className={`w-7 h-7 flex items-center justify-center rounded-lg transition-colors shrink-0 ${
150
+ refImage
151
+ ? "bg-blue-600 text-white"
152
+ : "hover:bg-gray-800 text-gray-400 hover:text-white"
153
+ }`}
154
+ title={refImage ? `Imagen: ${refImageName}` : "Adjuntar imagen de referencia"}
155
+ >
156
+ <svg className="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor">
157
+ <path fillRule="evenodd" d="M1 5.25A2.25 2.25 0 013.25 3h13.5A2.25 2.25 0 0119 5.25v9.5A2.25 2.25 0 0116.75 17H3.25A2.25 2.25 0 011 14.75v-9.5zm1.5 5.81V14.75c0 .414.336.75.75.75h13.5a.75.75 0 00.75-.75v-2.06l-2.22-2.22a.75.75 0 00-1.06 0L8.56 16.1l-3.28-3.28a.75.75 0 00-1.06 0l-1.72 1.72zm12-4.06a1.5 1.5 0 11-3 0 1.5 1.5 0 013 0z" clipRule="evenodd"/>
158
+ </svg>
159
+ </button>
160
+ <input
161
+ ref={fileInputRef}
162
+ type="file"
163
+ accept="image/*"
164
+ onChange={handleFileSelect}
165
+ className="hidden"
166
+ />
167
+ <button
168
+ type="submit"
169
+ disabled={!prompt.trim() || isRefining}
170
+ className="w-7 h-7 flex items-center justify-center rounded-lg bg-blue-500 hover:bg-blue-600 disabled:opacity-30 transition-colors"
171
+ >
172
+ {isRefining ? (
173
+ <span className="w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
174
+ ) : (
175
+ <HiSparkles className="w-3.5 h-3.5" />
176
+ )}
177
+ </button>
178
+ </form>
179
+
180
+ {/* Variante button */}
181
+ <div className="w-px h-5 bg-gray-700" />
182
+ <button
183
+ onClick={() => {
184
+ const tag = selection.tagName?.toLowerCase();
185
+ const text = selection.text?.substring(0, 80);
186
+ const prompt = selection.isSectionRoot
187
+ ? "Genera una variante completamente diferente de esta seccion. Manten el mismo contenido/informacion pero cambia radicalmente el layout, la estructura visual, y el estilo. Sorprendeme con un diseno creativo e inesperado."
188
+ : `Modifica SOLO el elemento <${tag}> que contiene "${text}". Genera una variante visual diferente de ESE elemento (diferente estilo, layout, tipografia). NO modifiques ningun otro elemento de la seccion.`;
189
+ onRefine(prompt, refImage || undefined);
190
+ }}
191
+ disabled={isRefining}
192
+ className="px-2.5 py-1 text-[11px] font-bold rounded-lg bg-blue-600 hover:bg-blue-500 disabled:opacity-30 transition-colors whitespace-nowrap shrink-0"
193
+ title="Generar variante"
194
+ >
195
+ ✦ Variante
196
+ </button>
197
+
198
+ {/* Section-level actions (move/delete) */}
199
+ {selection.isSectionRoot && (
200
+ <>
201
+ <div className="w-px h-5 bg-gray-700" />
202
+ <button
203
+ onClick={onMoveUp}
204
+ className="w-7 h-7 flex items-center justify-center rounded-lg hover:bg-gray-800 transition-colors text-xs"
205
+ title="Mover arriba"
206
+ >
207
+
208
+ </button>
209
+ <button
210
+ onClick={onMoveDown}
211
+ className="w-7 h-7 flex items-center justify-center rounded-lg hover:bg-gray-800 transition-colors text-xs"
212
+ title="Mover abajo"
213
+ >
214
+
215
+ </button>
216
+ </>
217
+ )}
218
+
219
+ {/* View code */}
220
+ <button
221
+ onClick={onViewCode}
222
+ className="w-7 h-7 flex items-center justify-center rounded-lg hover:bg-gray-800 transition-colors text-xs font-mono text-gray-400 hover:text-white"
223
+ title="Ver codigo"
224
+ >
225
+ &lt;/&gt;
226
+ </button>
227
+
228
+ {selection.isSectionRoot && (
229
+ <>
230
+ <div className="w-px h-5 bg-gray-700" />
231
+ <button
232
+ onClick={onDelete}
233
+ className="w-7 h-7 flex items-center justify-center rounded-lg hover:bg-red-900/50 text-red-400 transition-colors"
234
+ title="Eliminar seccion"
235
+ >
236
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
237
+ <polyline points="3 6 5 6 21 6" />
238
+ <path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2" />
239
+ </svg>
240
+ </button>
241
+ </>
242
+ )}
243
+
244
+ {/* Close button */}
245
+ <div className="w-px h-5 bg-gray-700" />
246
+ <button
247
+ onClick={onClose}
248
+ className="w-7 h-7 flex items-center justify-center rounded-lg hover:bg-gray-800 text-gray-400 hover:text-white transition-colors"
249
+ title="Cerrar (ESC)"
250
+ >
251
+
252
+ </button>
253
+ </div>
254
+
255
+ {/* Reference image preview */}
256
+ {refImage && (
257
+ <div className="flex items-center gap-2 pt-0.5 pb-0.5 border-t border-gray-700/50">
258
+ <img src={refImage} alt="Referencia" className="w-10 h-10 rounded object-cover border border-gray-600" />
259
+ <span className="text-[10px] text-gray-400 truncate flex-1">{refImageName}</span>
260
+ <button
261
+ onClick={() => { setRefImage(null); setRefImageName(null); }}
262
+ className="text-[10px] text-gray-500 hover:text-white px-1"
263
+ >
264
+
265
+ </button>
266
+ </div>
267
+ )}
268
+
269
+ {/* Style presets row — only for section roots */}
270
+ {selection.isSectionRoot && (
271
+ <div className="flex items-center gap-1 pt-0.5 pb-0.5 border-t border-gray-700/50">
272
+ <span className="text-[10px] text-gray-500 uppercase tracking-wider mr-1 shrink-0">Estilo</span>
273
+ {STYLE_PRESETS.map((preset) => (
274
+ <button
275
+ key={preset.label}
276
+ onClick={() => onRefine(preset.instruction)}
277
+ disabled={isRefining}
278
+ className="px-2 py-0.5 text-[11px] font-medium rounded-md bg-gray-800 hover:bg-gray-700 disabled:opacity-30 transition-colors whitespace-nowrap"
279
+ title={preset.label}
280
+ >
281
+ <span className="mr-1">{preset.icon}</span>
282
+ {preset.label}
283
+ </button>
284
+ ))}
285
+ </div>
286
+ )}
287
+
288
+ {/* Image attr editing */}
289
+ {isImg && hasAttrEditing && (
290
+ <div className="flex flex-col gap-1 pt-0.5 pb-0.5 border-t border-gray-700/50">
291
+ <div className="flex items-center gap-1">
292
+ <span className="text-[10px] text-gray-500 uppercase tracking-wider w-8 shrink-0">src</span>
293
+ <input
294
+ type="text"
295
+ value={imgSrc}
296
+ onChange={(e) => setImgSrc(e.target.value)}
297
+ onKeyDown={(e) => { if (e.key === "Enter") handleSetAttr("src", imgSrc); }}
298
+ className="flex-1 bg-gray-800 text-xs text-white rounded px-2 py-1 outline-none min-w-0"
299
+ placeholder="URL de imagen..."
300
+ />
301
+ <button
302
+ onClick={() => handleSetAttr("src", imgSrc)}
303
+ className="px-2 py-1 text-[10px] font-bold rounded bg-blue-500 hover:bg-blue-600 transition-colors shrink-0"
304
+ >
305
+ Set
306
+ </button>
307
+ </div>
308
+ <div className="flex items-center gap-1">
309
+ <span className="text-[10px] text-gray-500 uppercase tracking-wider w-8 shrink-0">alt</span>
310
+ <input
311
+ type="text"
312
+ value={imgAlt}
313
+ onChange={(e) => setImgAlt(e.target.value)}
314
+ onKeyDown={(e) => { if (e.key === "Enter") handleSetAttr("alt", imgAlt); }}
315
+ className="flex-1 bg-gray-800 text-xs text-white rounded px-2 py-1 outline-none min-w-0"
316
+ placeholder="Alt text..."
317
+ />
318
+ <button
319
+ onClick={() => handleSetAttr("alt", imgAlt)}
320
+ className="px-2 py-1 text-[10px] font-bold rounded bg-blue-500 hover:bg-blue-600 transition-colors shrink-0"
321
+ >
322
+ Set
323
+ </button>
324
+ </div>
325
+ </div>
326
+ )}
327
+
328
+ {/* Link attr editing */}
329
+ {isLink && hasAttrEditing && (
330
+ <div className="flex items-center gap-1 pt-0.5 pb-0.5 border-t border-gray-700/50">
331
+ <span className="text-[10px] text-gray-500 uppercase tracking-wider w-8 shrink-0">href</span>
332
+ <input
333
+ type="text"
334
+ value={linkHref}
335
+ onChange={(e) => setLinkHref(e.target.value)}
336
+ onKeyDown={(e) => { if (e.key === "Enter") handleSetAttr("href", linkHref); }}
337
+ className="flex-1 bg-gray-800 text-xs text-white rounded px-2 py-1 outline-none min-w-0"
338
+ placeholder="URL del enlace..."
339
+ />
340
+ <button
341
+ onClick={() => handleSetAttr("href", linkHref)}
342
+ className="px-2 py-1 text-[10px] font-bold rounded bg-blue-500 hover:bg-blue-600 transition-colors shrink-0"
343
+ >
344
+ Set
345
+ </button>
346
+ </div>
347
+ )}
348
+ </div>
349
+ );
350
+ }