@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.
- package/LICENSE +131 -0
- package/README.md +178 -0
- package/package.json +50 -0
- package/src/buildHtml.ts +78 -0
- package/src/components/Canvas.tsx +162 -0
- package/src/components/CodeEditor.tsx +239 -0
- package/src/components/FloatingToolbar.tsx +350 -0
- package/src/components/SectionList.tsx +217 -0
- package/src/components/index.ts +4 -0
- package/src/deploy.ts +73 -0
- package/src/generate.ts +274 -0
- package/src/iframeScript.ts +261 -0
- package/src/images/enrichImages.ts +127 -0
- package/src/images/index.ts +2 -0
- package/src/images/pexels.ts +27 -0
- package/src/index.ts +57 -0
- package/src/refine.ts +115 -0
- package/src/themes.ts +204 -0
- package/src/types.ts +30 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# PolyForm Noncommercial License 1.0.0
|
|
2
|
+
|
|
3
|
+
<https://polyformproject.org/licenses/noncommercial/1.0.0>
|
|
4
|
+
|
|
5
|
+
## Acceptance
|
|
6
|
+
|
|
7
|
+
In order to get any license under these terms, you must agree
|
|
8
|
+
to them as both strict obligations and conditions to all
|
|
9
|
+
your licenses.
|
|
10
|
+
|
|
11
|
+
## Copyright License
|
|
12
|
+
|
|
13
|
+
The licensor grants you a copyright license for the
|
|
14
|
+
software to do everything you might do with the software
|
|
15
|
+
that would otherwise infringe the licensor's copyright
|
|
16
|
+
in it for any permitted purpose. However, you may
|
|
17
|
+
only distribute the software according to [Distribution
|
|
18
|
+
License](#distribution-license) and make changes or new works
|
|
19
|
+
based on the software according to [Changes and New Works
|
|
20
|
+
License](#changes-and-new-works-license).
|
|
21
|
+
|
|
22
|
+
## Distribution License
|
|
23
|
+
|
|
24
|
+
The licensor grants you an additional copyright license
|
|
25
|
+
to distribute copies of the software. Your license
|
|
26
|
+
to distribute covers distributing the software with
|
|
27
|
+
changes and new works permitted by [Changes and New Works
|
|
28
|
+
License](#changes-and-new-works-license).
|
|
29
|
+
|
|
30
|
+
## Notices
|
|
31
|
+
|
|
32
|
+
You must ensure that anyone who gets a copy of any part of
|
|
33
|
+
the software from you also gets a copy of these terms or the
|
|
34
|
+
URL for them above, as well as copies of any plain-text lines
|
|
35
|
+
beginning with `Required Notice:` that the licensor provided
|
|
36
|
+
with the software. For example:
|
|
37
|
+
|
|
38
|
+
> Required Notice: Copyright EasyBits (https://easybits.cloud)
|
|
39
|
+
|
|
40
|
+
## Changes and New Works License
|
|
41
|
+
|
|
42
|
+
The licensor grants you an additional copyright license to
|
|
43
|
+
make changes and new works based on the software for any
|
|
44
|
+
permitted purpose.
|
|
45
|
+
|
|
46
|
+
## Patent License
|
|
47
|
+
|
|
48
|
+
The licensor grants you a patent license for the software that
|
|
49
|
+
covers patent claims the licensor can license, or becomes able
|
|
50
|
+
to license, that you would infringe by using the software.
|
|
51
|
+
|
|
52
|
+
## Noncommercial Purposes
|
|
53
|
+
|
|
54
|
+
Any noncommercial purpose is a permitted purpose.
|
|
55
|
+
|
|
56
|
+
## Personal Uses
|
|
57
|
+
|
|
58
|
+
Personal use for research, experiment, and testing for
|
|
59
|
+
the benefit of public knowledge, personal study, private
|
|
60
|
+
entertainment, hobby projects, amateur pursuits, or religious
|
|
61
|
+
observance, without any anticipated commercial application,
|
|
62
|
+
is use for a permitted purpose.
|
|
63
|
+
|
|
64
|
+
## Noncommercial Organizations
|
|
65
|
+
|
|
66
|
+
Use by any charitable organization, educational institution,
|
|
67
|
+
public research organization, public safety or health
|
|
68
|
+
organization, environmental protection organization,
|
|
69
|
+
or government institution is use for a permitted purpose
|
|
70
|
+
regardless of the source of funding or obligations resulting
|
|
71
|
+
from the funding.
|
|
72
|
+
|
|
73
|
+
## Fair Use
|
|
74
|
+
|
|
75
|
+
You may have "fair use" rights for the software under the
|
|
76
|
+
law. These terms do not limit them.
|
|
77
|
+
|
|
78
|
+
## No Other Rights
|
|
79
|
+
|
|
80
|
+
These terms do not allow you to sublicense or transfer any of
|
|
81
|
+
your licenses to anyone else, or prevent the licensor from
|
|
82
|
+
granting licenses to anyone else. These terms do not imply
|
|
83
|
+
any other licenses.
|
|
84
|
+
|
|
85
|
+
## Patent Defense
|
|
86
|
+
|
|
87
|
+
If you make any written claim that the software infringes or
|
|
88
|
+
contributes to infringement of any patent, your patent license
|
|
89
|
+
for the software granted under these terms ends immediately. If
|
|
90
|
+
your company makes such a claim, your patent license ends
|
|
91
|
+
immediately for work on behalf of your company.
|
|
92
|
+
|
|
93
|
+
## Violations
|
|
94
|
+
|
|
95
|
+
The first time you are notified in writing that you have
|
|
96
|
+
violated any of these terms, or done anything with the software
|
|
97
|
+
not covered by your licenses, your licenses can nonetheless
|
|
98
|
+
continue if you come into full compliance with these terms,
|
|
99
|
+
and take practical steps to correct past violations, within
|
|
100
|
+
32 days of receiving notice. Otherwise, all your licenses
|
|
101
|
+
end immediately.
|
|
102
|
+
|
|
103
|
+
## No Liability
|
|
104
|
+
|
|
105
|
+
***As far as the law allows, the software comes as is, without
|
|
106
|
+
any warranty or condition, and the licensor will not be liable
|
|
107
|
+
to you for any damages arising out of these terms or the use
|
|
108
|
+
or nature of the software, under any kind of legal claim.***
|
|
109
|
+
|
|
110
|
+
## Definitions
|
|
111
|
+
|
|
112
|
+
The **licensor** is the individual or entity offering these
|
|
113
|
+
terms, and the **software** is the software the licensor makes
|
|
114
|
+
available under these terms.
|
|
115
|
+
|
|
116
|
+
**You** refers to the individual or entity agreeing to these
|
|
117
|
+
terms.
|
|
118
|
+
|
|
119
|
+
**Your company** is any legal entity, sole proprietorship,
|
|
120
|
+
or other kind of organization that you work for, plus all
|
|
121
|
+
organizations that have control over, are under the control of,
|
|
122
|
+
or are under common control with that organization. **Control**
|
|
123
|
+
means ownership of substantially all the assets of an entity,
|
|
124
|
+
or the power to direct its management and policies by vote,
|
|
125
|
+
contract, or otherwise. Control can be direct or indirect.
|
|
126
|
+
|
|
127
|
+
**Your licenses** are all the licenses granted to you for the
|
|
128
|
+
software under these terms.
|
|
129
|
+
|
|
130
|
+
**Use** means anything you do with the software requiring one
|
|
131
|
+
of your licenses.
|
package/README.md
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# @easybits.cloud/html-tailwind-generator
|
|
2
|
+
|
|
3
|
+
AI-powered landing page generator with Tailwind CSS. Canvas editor, streaming AI generation, one-click deploy.
|
|
4
|
+
|
|
5
|
+
Built and maintained by [EasyBits](https://easybits.cloud).
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- **AI Generation** — Streaming landing page creation with Claude (Sonnet for generation, Haiku for refinement)
|
|
10
|
+
- **Canvas Editor** — iframe-based preview with click-to-select, inline text editing, section reorder
|
|
11
|
+
- **Floating Toolbar** — AI prompt input, style presets (Minimal, Cards, Bold, Glass, Dark), reference image support
|
|
12
|
+
- **Code Editor** — CodeMirror 6 with HTML syntax, flash highlight on scroll-to-code, format, Cmd+S
|
|
13
|
+
- **Theme System** — 5 preset themes (Neutral, Dark, Slate, Midnight, Warm) + custom multi-color picker
|
|
14
|
+
- **Image Enrichment** — Auto-replace placeholder images with Pexels stock photos
|
|
15
|
+
- **Deploy** — To EasyBits hosting (`slug.easybits.cloud`) or any S3-compatible storage
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npm install @easybits.cloud/html-tailwind-generator
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Quick Start
|
|
24
|
+
|
|
25
|
+
### Generate a landing page (server-side)
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
import { generateLanding } from "@easybits.cloud/html-tailwind-generator/generate";
|
|
29
|
+
|
|
30
|
+
const sections = await generateLanding({
|
|
31
|
+
anthropicApiKey: "sk-ant-...",
|
|
32
|
+
pexelsApiKey: "...", // optional, for stock photos
|
|
33
|
+
prompt: "SaaS de gestión de proyectos para equipos remotos",
|
|
34
|
+
onSection(section) {
|
|
35
|
+
console.log("New section:", section.label);
|
|
36
|
+
},
|
|
37
|
+
onImageUpdate(id, html) {
|
|
38
|
+
console.log("Images enriched for", id);
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
console.log(`Generated ${sections.length} sections`);
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Refine a section
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
import { refineLanding } from "@easybits.cloud/html-tailwind-generator/refine";
|
|
49
|
+
|
|
50
|
+
const html = await refineLanding({
|
|
51
|
+
anthropicApiKey: "sk-ant-...",
|
|
52
|
+
currentHtml: sections[0].html,
|
|
53
|
+
instruction: "Make it more minimal with more whitespace",
|
|
54
|
+
onChunk(accumulated) {
|
|
55
|
+
// Stream partial HTML to UI
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Use the editor components (React)
|
|
61
|
+
|
|
62
|
+
```tsx
|
|
63
|
+
import { Canvas, SectionList, FloatingToolbar, CodeEditor } from "@easybits.cloud/html-tailwind-generator/components";
|
|
64
|
+
import type { Section3, IframeMessage } from "@easybits.cloud/html-tailwind-generator";
|
|
65
|
+
|
|
66
|
+
function MyEditor() {
|
|
67
|
+
const [sections, setSections] = useState<Section3[]>([]);
|
|
68
|
+
const canvasRef = useRef<CanvasHandle>(null);
|
|
69
|
+
const iframeRectRef = useRef<DOMRect | null>(null);
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div className="flex h-screen">
|
|
73
|
+
<SectionList
|
|
74
|
+
sections={sections}
|
|
75
|
+
selectedSectionId={null}
|
|
76
|
+
theme="default"
|
|
77
|
+
onThemeChange={(id) => {/* ... */}}
|
|
78
|
+
onSelect={(id) => canvasRef.current?.scrollToSection(id)}
|
|
79
|
+
onOpenCode={(id) => {/* ... */}}
|
|
80
|
+
onReorder={(from, to) => {/* ... */}}
|
|
81
|
+
onDelete={(id) => {/* ... */}}
|
|
82
|
+
onRename={(id, label) => {/* ... */}}
|
|
83
|
+
onAdd={() => {/* ... */}}
|
|
84
|
+
/>
|
|
85
|
+
<Canvas
|
|
86
|
+
ref={canvasRef}
|
|
87
|
+
sections={sections}
|
|
88
|
+
theme="default"
|
|
89
|
+
onMessage={(msg: IframeMessage) => {/* ... */}}
|
|
90
|
+
iframeRectRef={iframeRectRef}
|
|
91
|
+
/>
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Build HTML for deploy
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
import { buildDeployHtml } from "@easybits.cloud/html-tailwind-generator";
|
|
101
|
+
|
|
102
|
+
const html = buildDeployHtml(sections, "midnight");
|
|
103
|
+
// → Complete HTML with Tailwind CDN, theme CSS, all sections
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Deploy
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
// Option 1: Deploy to EasyBits (zero config)
|
|
110
|
+
import { deployToEasyBits } from "@easybits.cloud/html-tailwind-generator/deploy";
|
|
111
|
+
|
|
112
|
+
const url = await deployToEasyBits({
|
|
113
|
+
apiKey: "eb_...",
|
|
114
|
+
slug: "my-landing",
|
|
115
|
+
sections,
|
|
116
|
+
theme: "midnight",
|
|
117
|
+
});
|
|
118
|
+
// → https://my-landing.easybits.cloud
|
|
119
|
+
|
|
120
|
+
// Option 2: Deploy to S3 / R2 / Tigris (bring your own storage)
|
|
121
|
+
import { deployToS3 } from "@easybits.cloud/html-tailwind-generator/deploy";
|
|
122
|
+
|
|
123
|
+
const url = await deployToS3({
|
|
124
|
+
sections,
|
|
125
|
+
theme: "midnight",
|
|
126
|
+
upload: async (html) => {
|
|
127
|
+
await s3.putObject({ Bucket: "my-bucket", Key: "index.html", Body: html });
|
|
128
|
+
return "https://my-bucket.s3.amazonaws.com/index.html";
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## Exports
|
|
134
|
+
|
|
135
|
+
| Path | Description |
|
|
136
|
+
|------|-------------|
|
|
137
|
+
| `@easybits.cloud/html-tailwind-generator` | Everything (types, themes, builders, generate, refine, deploy, images, components) |
|
|
138
|
+
| `@easybits.cloud/html-tailwind-generator/generate` | `generateLanding`, `extractJsonObjects`, `SYSTEM_PROMPT` |
|
|
139
|
+
| `@easybits.cloud/html-tailwind-generator/refine` | `refineLanding`, `REFINE_SYSTEM` |
|
|
140
|
+
| `@easybits.cloud/html-tailwind-generator/deploy` | `deployToEasyBits`, `deployToS3` |
|
|
141
|
+
| `@easybits.cloud/html-tailwind-generator/images` | `searchImage`, `enrichImages`, `findImageSlots` |
|
|
142
|
+
| `@easybits.cloud/html-tailwind-generator/components` | `Canvas`, `SectionList`, `FloatingToolbar`, `CodeEditor` |
|
|
143
|
+
|
|
144
|
+
## Theme System
|
|
145
|
+
|
|
146
|
+
The generator uses a semantic color system with CSS custom properties:
|
|
147
|
+
|
|
148
|
+
- `bg-primary`, `text-primary`, `bg-primary-light`, `bg-primary-dark`
|
|
149
|
+
- `bg-surface`, `bg-surface-alt`, `text-on-surface`, `text-on-surface-muted`
|
|
150
|
+
- `text-on-primary`, `bg-secondary`, `bg-accent`
|
|
151
|
+
|
|
152
|
+
5 built-in themes + custom colors with auto-derived light/dark/contrast variants.
|
|
153
|
+
|
|
154
|
+
## Peer Dependencies
|
|
155
|
+
|
|
156
|
+
**Required:**
|
|
157
|
+
- `react` >= 18
|
|
158
|
+
- `ai` >= 4 (Vercel AI SDK)
|
|
159
|
+
- `@ai-sdk/anthropic` >= 3
|
|
160
|
+
|
|
161
|
+
**Optional (for editor components):**
|
|
162
|
+
- `react-dom`, `react-icons`
|
|
163
|
+
- `@codemirror/*` packages (for CodeEditor)
|
|
164
|
+
|
|
165
|
+
## TODO
|
|
166
|
+
|
|
167
|
+
> These are planned improvements — contributions welcome for noncommercial use.
|
|
168
|
+
|
|
169
|
+
- [ ] **Inline Tailwind CSS build** — Replace CDN `<script src="tailwindcss.com">` with `@tailwindcss/standalone` or PostCSS to generate only used CSS as `<style>`. Faster load, no external dependency, production-ready.
|
|
170
|
+
- [ ] **DALL-E image generation** — Add `openaiApiKey` option to generate unique images instead of Pexels stock. Sections with `data-image-query` could use AI-generated images for cases where stock photos don't fit.
|
|
171
|
+
- [ ] **tsup build** — Add a proper build step (ESM + CJS + types) for npm publish. Currently exported as raw TypeScript source, which works for monorepo consumers but not for external npm users.
|
|
172
|
+
- [ ] **i18n** — Component labels are in Spanish. Add a `locale` prop or i18n system for English and other languages.
|
|
173
|
+
- [ ] **Tests** — Unit tests for `extractJsonObjects`, `findImageSlots`, `buildDeployHtml`, `buildCustomTheme`.
|
|
174
|
+
- [ ] **Storybook** — Visual stories for Canvas, SectionList, FloatingToolbar, CodeEditor.
|
|
175
|
+
|
|
176
|
+
## License
|
|
177
|
+
|
|
178
|
+
[PolyForm Noncommercial 1.0.0](./LICENSE) — Free for personal, educational, and noncommercial use. Commercial use requires permission from [EasyBits](https://easybits.cloud).
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@easybits.cloud/html-tailwind-generator",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "AI-powered landing page generator with Tailwind CSS — canvas editor, streaming generation, and one-click deploy",
|
|
5
|
+
"license": "PolyForm-Noncommercial-1.0.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./src/index.ts",
|
|
8
|
+
"types": "./src/index.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./src/index.ts",
|
|
11
|
+
"./components": "./src/components/index.ts",
|
|
12
|
+
"./images": "./src/images/index.ts",
|
|
13
|
+
"./generate": "./src/generate.ts",
|
|
14
|
+
"./refine": "./src/refine.ts",
|
|
15
|
+
"./deploy": "./src/deploy.ts"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"src"
|
|
19
|
+
],
|
|
20
|
+
"peerDependencies": {
|
|
21
|
+
"react": ">=18",
|
|
22
|
+
"react-dom": ">=18",
|
|
23
|
+
"ai": ">=4",
|
|
24
|
+
"@ai-sdk/anthropic": ">=3",
|
|
25
|
+
"@codemirror/lang-html": ">=6",
|
|
26
|
+
"@codemirror/state": ">=6",
|
|
27
|
+
"@codemirror/theme-one-dark": ">=6",
|
|
28
|
+
"@codemirror/view": ">=6",
|
|
29
|
+
"@codemirror/commands": ">=6",
|
|
30
|
+
"@codemirror/search": ">=6",
|
|
31
|
+
"@codemirror/language": ">=6",
|
|
32
|
+
"@codemirror/autocomplete": ">=6",
|
|
33
|
+
"react-icons": ">=5"
|
|
34
|
+
},
|
|
35
|
+
"peerDependenciesMeta": {
|
|
36
|
+
"@codemirror/lang-html": { "optional": true },
|
|
37
|
+
"@codemirror/state": { "optional": true },
|
|
38
|
+
"@codemirror/theme-one-dark": { "optional": true },
|
|
39
|
+
"@codemirror/view": { "optional": true },
|
|
40
|
+
"@codemirror/commands": { "optional": true },
|
|
41
|
+
"@codemirror/search": { "optional": true },
|
|
42
|
+
"@codemirror/language": { "optional": true },
|
|
43
|
+
"@codemirror/autocomplete": { "optional": true },
|
|
44
|
+
"react-icons": { "optional": true },
|
|
45
|
+
"react-dom": { "optional": true }
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"nanoid": "^5.1.5"
|
|
49
|
+
}
|
|
50
|
+
}
|
package/src/buildHtml.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { 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
|
+
});
|