@easybits.cloud/html-tailwind-generator 0.1.5 → 0.2.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/{src/images/enrichImages.ts → dist/chunk-5HSVOF2J.js} +65 -53
- package/dist/chunk-5HSVOF2J.js.map +1 -0
- package/{src/iframeScript.ts → dist/chunk-5TYGSZAF.js} +259 -10
- package/dist/chunk-5TYGSZAF.js.map +1 -0
- package/{src/refine.ts → dist/chunk-GMJR2GXL.js} +30 -60
- package/dist/chunk-GMJR2GXL.js.map +1 -0
- package/dist/chunk-LQ65H4AO.js +41 -0
- package/dist/chunk-LQ65H4AO.js.map +1 -0
- package/{src/generate.ts → dist/chunk-PK26CWDO.js} +67 -108
- package/dist/chunk-PK26CWDO.js.map +1 -0
- package/dist/chunk-RTGCZUNJ.js +1 -0
- package/dist/chunk-RTGCZUNJ.js.map +1 -0
- package/dist/chunk-XM3D3TTJ.js +852 -0
- package/dist/chunk-XM3D3TTJ.js.map +1 -0
- package/dist/components.d.ts +57 -0
- package/dist/components.js +14 -0
- package/dist/components.js.map +1 -0
- package/dist/deploy.d.ts +39 -0
- package/dist/deploy.js +10 -0
- package/dist/deploy.js.map +1 -0
- package/dist/generate.d.ts +41 -0
- package/dist/generate.js +14 -0
- package/dist/generate.js.map +1 -0
- package/dist/images.d.ts +30 -0
- package/dist/images.js +14 -0
- package/dist/images.js.map +1 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.js +64 -0
- package/dist/index.js.map +1 -0
- package/dist/refine.d.ts +32 -0
- package/dist/refine.js +10 -0
- package/dist/refine.js.map +1 -0
- package/dist/themes-DOoj19c8.d.ts +35 -0
- package/dist/types-Flpl4wDs.d.ts +31 -0
- package/package.json +53 -50
- package/src/buildHtml.ts +0 -78
- package/src/components/Canvas.tsx +0 -162
- package/src/components/CodeEditor.tsx +0 -239
- package/src/components/FloatingToolbar.tsx +0 -350
- package/src/components/SectionList.tsx +0 -217
- package/src/components/index.ts +0 -4
- package/src/deploy.ts +0 -73
- package/src/images/dalleImages.ts +0 -29
- package/src/images/index.ts +0 -3
- package/src/images/pexels.ts +0 -27
- package/src/index.ts +0 -58
- package/src/themes.ts +0 -204
- package/src/types.ts +0 -30
package/package.json
CHANGED
|
@@ -1,74 +1,77 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@easybits.cloud/html-tailwind-generator",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "AI-powered landing page generator with Tailwind CSS — canvas editor, streaming generation, and one-click deploy",
|
|
5
5
|
"license": "PolyForm-Noncommercial-1.0.0",
|
|
6
6
|
"type": "module",
|
|
7
|
-
"main": "./
|
|
8
|
-
"types": "./
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
9
|
"exports": {
|
|
10
|
-
".":
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
"./
|
|
15
|
-
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./components": {
|
|
15
|
+
"types": "./dist/components.d.ts",
|
|
16
|
+
"import": "./dist/components.js"
|
|
17
|
+
},
|
|
18
|
+
"./images": {
|
|
19
|
+
"types": "./dist/images.d.ts",
|
|
20
|
+
"import": "./dist/images.js"
|
|
21
|
+
},
|
|
22
|
+
"./generate": {
|
|
23
|
+
"types": "./dist/generate.d.ts",
|
|
24
|
+
"import": "./dist/generate.js"
|
|
25
|
+
},
|
|
26
|
+
"./refine": {
|
|
27
|
+
"types": "./dist/refine.d.ts",
|
|
28
|
+
"import": "./dist/refine.js"
|
|
29
|
+
},
|
|
30
|
+
"./deploy": {
|
|
31
|
+
"types": "./dist/deploy.d.ts",
|
|
32
|
+
"import": "./dist/deploy.js"
|
|
33
|
+
}
|
|
16
34
|
},
|
|
17
35
|
"files": [
|
|
18
|
-
"
|
|
36
|
+
"dist"
|
|
19
37
|
],
|
|
38
|
+
"scripts": {
|
|
39
|
+
"build": "tsup",
|
|
40
|
+
"prepublishOnly": "tsup"
|
|
41
|
+
},
|
|
20
42
|
"peerDependencies": {
|
|
21
|
-
"react": ">=18",
|
|
22
|
-
"react-dom": ">=18",
|
|
23
|
-
"ai": ">=4",
|
|
24
43
|
"@ai-sdk/anthropic": ">=3",
|
|
25
44
|
"@ai-sdk/openai": ">=1",
|
|
45
|
+
"@codemirror/autocomplete": ">=6",
|
|
46
|
+
"@codemirror/commands": ">=6",
|
|
26
47
|
"@codemirror/lang-html": ">=6",
|
|
48
|
+
"@codemirror/language": ">=6",
|
|
49
|
+
"@codemirror/search": ">=6",
|
|
27
50
|
"@codemirror/state": ">=6",
|
|
28
51
|
"@codemirror/theme-one-dark": ">=6",
|
|
29
52
|
"@codemirror/view": ">=6",
|
|
30
|
-
"
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
"@codemirror/autocomplete": ">=6",
|
|
53
|
+
"ai": ">=4",
|
|
54
|
+
"react": ">=18",
|
|
55
|
+
"react-dom": ">=18",
|
|
34
56
|
"react-icons": ">=5"
|
|
35
57
|
},
|
|
36
58
|
"peerDependenciesMeta": {
|
|
37
|
-
"@codemirror/lang-html": {
|
|
38
|
-
|
|
39
|
-
},
|
|
40
|
-
"@codemirror/
|
|
41
|
-
|
|
42
|
-
},
|
|
43
|
-
"@codemirror/
|
|
44
|
-
|
|
45
|
-
},
|
|
46
|
-
"@
|
|
47
|
-
|
|
48
|
-
},
|
|
49
|
-
"@codemirror/commands": {
|
|
50
|
-
"optional": true
|
|
51
|
-
},
|
|
52
|
-
"@codemirror/search": {
|
|
53
|
-
"optional": true
|
|
54
|
-
},
|
|
55
|
-
"@codemirror/language": {
|
|
56
|
-
"optional": true
|
|
57
|
-
},
|
|
58
|
-
"@codemirror/autocomplete": {
|
|
59
|
-
"optional": true
|
|
60
|
-
},
|
|
61
|
-
"react-icons": {
|
|
62
|
-
"optional": true
|
|
63
|
-
},
|
|
64
|
-
"@ai-sdk/openai": {
|
|
65
|
-
"optional": true
|
|
66
|
-
},
|
|
67
|
-
"react-dom": {
|
|
68
|
-
"optional": true
|
|
69
|
-
}
|
|
59
|
+
"@codemirror/lang-html": { "optional": true },
|
|
60
|
+
"@codemirror/state": { "optional": true },
|
|
61
|
+
"@codemirror/theme-one-dark": { "optional": true },
|
|
62
|
+
"@codemirror/view": { "optional": true },
|
|
63
|
+
"@codemirror/commands": { "optional": true },
|
|
64
|
+
"@codemirror/search": { "optional": true },
|
|
65
|
+
"@codemirror/language": { "optional": true },
|
|
66
|
+
"@codemirror/autocomplete": { "optional": true },
|
|
67
|
+
"react-icons": { "optional": true },
|
|
68
|
+
"@ai-sdk/openai": { "optional": true },
|
|
69
|
+
"react-dom": { "optional": true }
|
|
70
70
|
},
|
|
71
71
|
"dependencies": {
|
|
72
72
|
"nanoid": "^5.1.5"
|
|
73
|
+
},
|
|
74
|
+
"devDependencies": {
|
|
75
|
+
"tsup": "^8.5.1"
|
|
73
76
|
}
|
|
74
77
|
}
|
package/src/buildHtml.ts
DELETED
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
import type { Section3 } from "./types";
|
|
2
|
-
import { getIframeScript } from "./iframeScript";
|
|
3
|
-
import { buildThemeCss, buildSingleThemeCss, buildCustomTheme, type CustomColors } from "./themes";
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Build the full HTML for the iframe preview (with editing script).
|
|
7
|
-
*/
|
|
8
|
-
export function buildPreviewHtml(sections: Section3[], theme?: string): string {
|
|
9
|
-
const sorted = [...sections].sort((a, b) => a.order - b.order);
|
|
10
|
-
const body = sorted
|
|
11
|
-
.map((s) => `<div data-section-id="${s.id}">${s.html}</div>`)
|
|
12
|
-
.join("\n");
|
|
13
|
-
|
|
14
|
-
const dataTheme = theme && theme !== "default" ? ` data-theme="${theme}"` : "";
|
|
15
|
-
const { css, tailwindConfig } = buildThemeCss();
|
|
16
|
-
|
|
17
|
-
return `<!DOCTYPE html>
|
|
18
|
-
<html lang="es"${dataTheme}>
|
|
19
|
-
<head>
|
|
20
|
-
<meta charset="UTF-8"/>
|
|
21
|
-
<meta name="viewport" content="width=device-width,initial-scale=1"/>
|
|
22
|
-
<script src="https://cdn.tailwindcss.com"></script>
|
|
23
|
-
<script>tailwind.config = ${tailwindConfig}</script>
|
|
24
|
-
<style>
|
|
25
|
-
${css}
|
|
26
|
-
*{margin:0;padding:0;box-sizing:border-box}
|
|
27
|
-
html{scroll-behavior:smooth}
|
|
28
|
-
body{font-family:system-ui,-apple-system,sans-serif;background-color:var(--color-surface);color:var(--color-on-surface)}
|
|
29
|
-
img{max-width:100%}
|
|
30
|
-
[contenteditable="true"]{cursor:text}
|
|
31
|
-
</style>
|
|
32
|
-
</head>
|
|
33
|
-
<body class="bg-surface text-on-surface">
|
|
34
|
-
${body}
|
|
35
|
-
<script>${getIframeScript()}</script>
|
|
36
|
-
</body>
|
|
37
|
-
</html>`;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Build the deploy HTML (no editing script, clean output).
|
|
42
|
-
*/
|
|
43
|
-
export function buildDeployHtml(sections: Section3[], theme?: string, customColors?: CustomColors): string {
|
|
44
|
-
const sorted = [...sections].sort((a, b) => a.order - b.order);
|
|
45
|
-
const body = sorted.map((s) => s.html).join("\n");
|
|
46
|
-
|
|
47
|
-
const isCustom = theme === "custom" && customColors;
|
|
48
|
-
const dataTheme = theme && theme !== "default" && !isCustom ? ` data-theme="${theme}"` : "";
|
|
49
|
-
|
|
50
|
-
// For custom theme, build CSS from the custom colors directly (no data-theme needed, inject as :root)
|
|
51
|
-
const { css: baseCss, tailwindConfig } = isCustom
|
|
52
|
-
? (() => {
|
|
53
|
-
const ct = buildCustomTheme(customColors);
|
|
54
|
-
const vars = Object.entries(ct.colors).map(([k, v]) => ` --color-${k}: ${v};`).join("\n");
|
|
55
|
-
return { css: `:root {\n${vars}\n}`, tailwindConfig: buildSingleThemeCss("default").tailwindConfig };
|
|
56
|
-
})()
|
|
57
|
-
: buildSingleThemeCss(theme || "default");
|
|
58
|
-
|
|
59
|
-
return `<!DOCTYPE html>
|
|
60
|
-
<html lang="es"${dataTheme}>
|
|
61
|
-
<head>
|
|
62
|
-
<meta charset="UTF-8"/>
|
|
63
|
-
<meta name="viewport" content="width=device-width,initial-scale=1"/>
|
|
64
|
-
<title>Landing Page</title>
|
|
65
|
-
<script src="https://cdn.tailwindcss.com"></script>
|
|
66
|
-
<script>tailwind.config = ${tailwindConfig}</script>
|
|
67
|
-
<style>
|
|
68
|
-
${baseCss}
|
|
69
|
-
*{margin:0;padding:0;box-sizing:border-box}
|
|
70
|
-
html{scroll-behavior:smooth}
|
|
71
|
-
body{font-family:system-ui,-apple-system,sans-serif;background-color:var(--color-surface);color:var(--color-on-surface)}
|
|
72
|
-
</style>
|
|
73
|
-
</head>
|
|
74
|
-
<body class="bg-surface text-on-surface">
|
|
75
|
-
${body}
|
|
76
|
-
</body>
|
|
77
|
-
</html>`;
|
|
78
|
-
}
|
|
@@ -1,162 +0,0 @@
|
|
|
1
|
-
import React, { useRef, useEffect, useCallback, useState, forwardRef, useImperativeHandle } from "react";
|
|
2
|
-
import type { Section3, IframeMessage } from "../types";
|
|
3
|
-
import { buildPreviewHtml } from "../buildHtml";
|
|
4
|
-
|
|
5
|
-
export interface CanvasHandle {
|
|
6
|
-
scrollToSection: (id: string) => void;
|
|
7
|
-
postMessage: (msg: Record<string, unknown>) => void;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
interface CanvasProps {
|
|
11
|
-
sections: Section3[];
|
|
12
|
-
theme?: string;
|
|
13
|
-
onMessage: (msg: IframeMessage) => void;
|
|
14
|
-
iframeRectRef: React.MutableRefObject<DOMRect | null>;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas({ sections, theme, onMessage, iframeRectRef }, ref) {
|
|
18
|
-
const iframeRef = useRef<HTMLIFrameElement>(null);
|
|
19
|
-
const [ready, setReady] = useState(false);
|
|
20
|
-
// Track what the iframe currently has so we can diff
|
|
21
|
-
const knownSectionsRef = useRef<Map<string, string>>(new Map());
|
|
22
|
-
const initializedRef = useRef(false);
|
|
23
|
-
|
|
24
|
-
// Post a message to the iframe
|
|
25
|
-
const postToIframe = useCallback((msg: Record<string, unknown>) => {
|
|
26
|
-
iframeRef.current?.contentWindow?.postMessage(msg, "*");
|
|
27
|
-
}, []);
|
|
28
|
-
|
|
29
|
-
useImperativeHandle(ref, () => ({
|
|
30
|
-
scrollToSection(id: string) {
|
|
31
|
-
postToIframe({ action: "scroll-to-section", id });
|
|
32
|
-
},
|
|
33
|
-
postMessage(msg: Record<string, unknown>) {
|
|
34
|
-
postToIframe(msg);
|
|
35
|
-
},
|
|
36
|
-
}), [postToIframe]);
|
|
37
|
-
|
|
38
|
-
// Initial write: set up the iframe shell (empty body + script + tailwind)
|
|
39
|
-
useEffect(() => {
|
|
40
|
-
const iframe = iframeRef.current;
|
|
41
|
-
if (!iframe || initializedRef.current) return;
|
|
42
|
-
initializedRef.current = true;
|
|
43
|
-
|
|
44
|
-
const html = buildPreviewHtml([]);
|
|
45
|
-
const doc = iframe.contentDocument;
|
|
46
|
-
if (!doc) return;
|
|
47
|
-
doc.open();
|
|
48
|
-
doc.write(html);
|
|
49
|
-
doc.close();
|
|
50
|
-
}, []);
|
|
51
|
-
|
|
52
|
-
// Handle "ready" from iframe — then inject current sections
|
|
53
|
-
const handleReady = useCallback(() => {
|
|
54
|
-
setReady(true);
|
|
55
|
-
// Inject all current sections
|
|
56
|
-
const sorted = [...sections].sort((a, b) => a.order - b.order);
|
|
57
|
-
for (const s of sorted) {
|
|
58
|
-
postToIframe({ action: "add-section", id: s.id, html: s.html });
|
|
59
|
-
knownSectionsRef.current.set(s.id, s.html);
|
|
60
|
-
}
|
|
61
|
-
}, [sections, postToIframe]);
|
|
62
|
-
|
|
63
|
-
// Incremental diff: detect added/updated/removed sections
|
|
64
|
-
useEffect(() => {
|
|
65
|
-
if (!ready) return;
|
|
66
|
-
|
|
67
|
-
const known = knownSectionsRef.current;
|
|
68
|
-
const currentIds = new Set(sections.map((s) => s.id));
|
|
69
|
-
const sorted = [...sections].sort((a, b) => a.order - b.order);
|
|
70
|
-
|
|
71
|
-
// Add new sections
|
|
72
|
-
for (const s of sorted) {
|
|
73
|
-
if (!known.has(s.id)) {
|
|
74
|
-
postToIframe({ action: "add-section", id: s.id, html: s.html });
|
|
75
|
-
known.set(s.id, s.html);
|
|
76
|
-
} else if (known.get(s.id) !== s.html) {
|
|
77
|
-
// Update changed sections
|
|
78
|
-
postToIframe({ action: "update-section", id: s.id, html: s.html });
|
|
79
|
-
known.set(s.id, s.html);
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
// Remove deleted sections
|
|
84
|
-
for (const id of known.keys()) {
|
|
85
|
-
if (!currentIds.has(id)) {
|
|
86
|
-
postToIframe({ action: "remove-section", id });
|
|
87
|
-
known.delete(id);
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// Reorder if needed
|
|
92
|
-
const knownOrder = [...known.keys()];
|
|
93
|
-
const desiredOrder = sorted.map((s) => s.id);
|
|
94
|
-
if (JSON.stringify(knownOrder) !== JSON.stringify(desiredOrder)) {
|
|
95
|
-
postToIframe({ action: "reorder-sections", order: desiredOrder });
|
|
96
|
-
}
|
|
97
|
-
}, [sections, ready, postToIframe]);
|
|
98
|
-
|
|
99
|
-
// Send theme changes to iframe
|
|
100
|
-
useEffect(() => {
|
|
101
|
-
if (!ready) return;
|
|
102
|
-
postToIframe({ action: "set-theme", theme: theme || "default" });
|
|
103
|
-
}, [theme, ready, postToIframe]);
|
|
104
|
-
|
|
105
|
-
// Update iframe rect on resize/scroll
|
|
106
|
-
const updateRect = useCallback(() => {
|
|
107
|
-
if (iframeRef.current) {
|
|
108
|
-
iframeRectRef.current = iframeRef.current.getBoundingClientRect();
|
|
109
|
-
}
|
|
110
|
-
}, [iframeRectRef]);
|
|
111
|
-
|
|
112
|
-
useEffect(() => {
|
|
113
|
-
updateRect();
|
|
114
|
-
window.addEventListener("resize", updateRect);
|
|
115
|
-
window.addEventListener("scroll", updateRect, true);
|
|
116
|
-
return () => {
|
|
117
|
-
window.removeEventListener("resize", updateRect);
|
|
118
|
-
window.removeEventListener("scroll", updateRect, true);
|
|
119
|
-
};
|
|
120
|
-
}, [updateRect]);
|
|
121
|
-
|
|
122
|
-
// Listen for postMessage from iframe
|
|
123
|
-
useEffect(() => {
|
|
124
|
-
function handleMessage(e: MessageEvent) {
|
|
125
|
-
const data = e.data;
|
|
126
|
-
if (!data || typeof data.type !== "string") return;
|
|
127
|
-
|
|
128
|
-
if (data.type === "ready") {
|
|
129
|
-
handleReady();
|
|
130
|
-
return;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
if (
|
|
134
|
-
["element-selected", "text-edited", "element-deselected", "section-html-updated"].includes(
|
|
135
|
-
data.type
|
|
136
|
-
)
|
|
137
|
-
) {
|
|
138
|
-
updateRect();
|
|
139
|
-
onMessage(data as IframeMessage);
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
window.addEventListener("message", handleMessage);
|
|
143
|
-
return () => window.removeEventListener("message", handleMessage);
|
|
144
|
-
}, [onMessage, updateRect, handleReady]);
|
|
145
|
-
|
|
146
|
-
return (
|
|
147
|
-
<div className="flex-1 bg-gray-100 rounded-xl overflow-hidden border-2 border-gray-200 relative">
|
|
148
|
-
<iframe
|
|
149
|
-
ref={iframeRef}
|
|
150
|
-
title="Landing preview"
|
|
151
|
-
className="w-full h-full border-0"
|
|
152
|
-
sandbox="allow-scripts allow-same-origin"
|
|
153
|
-
style={{ minHeight: "calc(100vh - 120px)" }}
|
|
154
|
-
/>
|
|
155
|
-
{!ready && sections.length > 0 && (
|
|
156
|
-
<div className="absolute inset-0 flex items-center justify-center bg-white/80">
|
|
157
|
-
<span className="w-6 h-6 border-2 border-gray-400 border-t-gray-800 rounded-full animate-spin" />
|
|
158
|
-
</div>
|
|
159
|
-
)}
|
|
160
|
-
</div>
|
|
161
|
-
);
|
|
162
|
-
});
|
|
@@ -1,239 +0,0 @@
|
|
|
1
|
-
import React, { 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 · Cmd+S = guardar · Esc = cerrar</span>
|
|
235
|
-
<span>{stats.kb} KB</span>
|
|
236
|
-
</div>
|
|
237
|
-
</div>
|
|
238
|
-
);
|
|
239
|
-
}
|