import { FolkShape } from "./folk-shape"; import { css, html } from "./tags"; const styles = css` :host { background: var(--rs-bg-surface, #fff); color: var(--rs-text-primary, #1e293b); border-radius: 8px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); min-width: 560px; min-height: 480px; } .header { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; background: linear-gradient(135deg, #8b5cf6, #6366f1); color: white; border-radius: 8px 8px 0 0; font-size: 12px; font-weight: 600; cursor: move; } .header-title { display: flex; align-items: center; gap: 6px; } .header-actions { display: flex; gap: 4px; } .header-actions button { background: transparent; border: none; color: white; cursor: pointer; padding: 2px 6px; border-radius: 4px; font-size: 14px; } .header-actions button:hover { background: rgba(255, 255, 255, 0.2); } .content { display: flex; flex-direction: column; height: calc(100% - 36px); overflow: hidden; } .toolbar-row { display: flex; align-items: center; gap: 6px; padding: 8px 12px; border-bottom: 1px solid var(--rs-border, rgba(255, 255, 255, 0.1)); flex-wrap: wrap; } .tool-btn { width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; border: 2px solid var(--rs-border, rgba(255, 255, 255, 0.1)); border-radius: 6px; background: var(--rs-bg-surface, #1e293b); cursor: pointer; font-size: 14px; transition: all 0.15s; } .tool-btn:hover { border-color: #8b5cf6; } .tool-btn.active { border-color: #8b5cf6; background: rgba(139, 92, 246, 0.15); } .color-swatch { width: 24px; height: 24px; border-radius: 50%; border: 2px solid var(--rs-border, rgba(255, 255, 255, 0.1)); cursor: pointer; transition: transform 0.1s; } .color-swatch:hover { transform: scale(1.2); } .color-swatch.active { border-color: var(--rs-text-primary, #e2e8f0); transform: scale(1.15); } .size-slider { width: 80px; accent-color: #8b5cf6; } .size-label { font-size: 11px; color: var(--rs-text-muted, #64748b); min-width: 20px; } .split-area { flex: 1; display: flex; overflow: hidden; min-height: 0; } .canvas-area { flex: 1; position: relative; cursor: crosshair; overflow: hidden; min-width: 0; } .canvas-area canvas { width: 100%; height: 100%; display: block; } .result-area { flex: 1; position: relative; overflow: hidden; display: flex; flex-direction: column; background: var(--rs-bg-muted, #f8fafc); border-left: 1px solid var(--rs-border, rgba(255, 255, 255, 0.1)); min-width: 0; } .result-area iframe { flex: 1; width: 100%; border: none; background: #fff; } .result-area pre { flex: 1; margin: 0; padding: 10px; font-size: 11px; overflow: auto; background: #1e1e2e; color: #cdd6f4; white-space: pre-wrap; word-break: break-word; } .result-placeholder { display: flex; flex: 1; flex-direction: column; align-items: center; justify-content: center; gap: 8px; color: var(--rs-text-muted, #94a3b8); text-align: center; font-size: 12px; padding: 12px; } .result-placeholder-icon { font-size: 32px; opacity: 0.4; } .result-toolbar { display: flex; gap: 4px; padding: 4px 8px; background: var(--rs-bg-surface, #fff); border-bottom: 1px solid var(--rs-border, rgba(255, 255, 255, 0.1)); font-size: 11px; } .result-toolbar button { padding: 3px 8px; border: 1px solid var(--rs-border, #e2e8f0); border-radius: 4px; background: var(--rs-bg-surface, #fff); cursor: pointer; font-size: 11px; color: var(--rs-text-primary, #1e293b); } .result-toolbar button:hover { background: var(--rs-bg-muted, #f1f5f9); } .result-toolbar button.active { background: #8b5cf6; color: white; border-color: #8b5cf6; } .spinner-overlay { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; background: rgba(255, 255, 255, 0.7); gap: 8px; z-index: 2; } .spinner { width: 28px; height: 28px; border: 3px solid #e2e8f0; border-top-color: #8b5cf6; border-radius: 50%; animation: spin 1s linear infinite; } .spinner-text { font-size: 11px; color: #64748b; } @keyframes spin { to { transform: rotate(360deg); } } .prompt-bar { display: flex; gap: 6px; padding: 8px 12px; border-top: 1px solid var(--rs-border, rgba(255, 255, 255, 0.1)); align-items: center; } .prompt-input { flex: 1; padding: 6px 10px; border: 2px solid var(--rs-input-border, #e2e8f0); border-radius: 6px; font-size: 12px; outline: none; font-family: inherit; background: var(--rs-input-bg, #fff); color: var(--rs-input-text, inherit); } .prompt-input:focus { border-color: #8b5cf6; } .generate-btn { padding: 6px 14px; background: linear-gradient(135deg, #8b5cf6, #6366f1); color: white; border: none; border-radius: 6px; font-size: 12px; font-weight: 600; cursor: pointer; white-space: nowrap; transition: opacity 0.2s; } .generate-btn:hover { opacity: 0.9; } .generate-btn:disabled { opacity: 0.5; cursor: not-allowed; } .framework-select { padding: 4px 8px; border: 2px solid #e2e8f0; border-radius: 6px; font-size: 11px; background: var(--rs-bg-surface, white); cursor: pointer; } `; const COLORS = ["#0f172a", "#ef4444", "#f97316", "#eab308", "#22c55e", "#3b82f6", "#8b5cf6", "#ec4899", "#ffffff"]; interface Stroke { points: { x: number; y: number; pressure: number }[]; color: string; size: number; tool: string; } declare global { interface HTMLElementTagNameMap { "folk-makereal": FolkMakereal; } } export class FolkMakereal extends FolkShape { static override tagName = "folk-makereal"; static override portDescriptors = [ { name: "prompt", type: "text" as const, direction: "input" as const }, { name: "html", type: "text" as const, direction: "output" as const }, ]; static { const sheet = new CSSStyleSheet(); const parentRules = Array.from(FolkShape.styles.cssRules) .map((r) => r.cssText) .join("\n"); const childRules = Array.from(styles.cssRules) .map((r) => r.cssText) .join("\n"); sheet.replaceSync(`${parentRules}\n${childRules}`); this.styles = sheet; } #strokes: Stroke[] = []; #currentStroke: Stroke | null = null; #canvas: HTMLCanvasElement | null = null; #ctx: CanvasRenderingContext2D | null = null; #color = "#0f172a"; #brushSize = 4; #tool = "pen"; #isDrawing = false; #activePointerId: number | null = null; #penIsActive = false; #isGenerating = false; #framework = "html"; #lastHtml: string | null = null; #showCode = false; #promptInput: HTMLInputElement | null = null; #generateBtn: HTMLButtonElement | null = null; #resultArea: HTMLElement | null = null; #resizeObserver: ResizeObserver | null = null; override createRenderRoot() { const root = super.createRenderRoot(); const wrapper = document.createElement("div"); wrapper.innerHTML = html`
๐Ÿช„ MakeReal
${COLORS.map((c) => ``).join("")} 4
๐Ÿช„ Draw a wireframe and click Make Real
`; const slot = root.querySelector("slot"); const containerDiv = slot?.parentElement as HTMLElement; if (containerDiv) { containerDiv.replaceWith(wrapper); } this.#canvas = wrapper.querySelector(".canvas-area canvas") as HTMLCanvasElement; this.#ctx = this.#canvas.getContext("2d"); this.#promptInput = wrapper.querySelector(".prompt-input") as HTMLInputElement; this.#generateBtn = wrapper.querySelector(".generate-btn") as HTMLButtonElement; this.#resultArea = wrapper.querySelector(".result-area") as HTMLElement; const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement; const sizeSlider = wrapper.querySelector(".size-slider") as HTMLInputElement; const sizeLabel = wrapper.querySelector(".size-label") as HTMLElement; const canvasArea = wrapper.querySelector(".canvas-area") as HTMLElement; const frameworkSelect = wrapper.querySelector(".framework-select") as HTMLSelectElement; // Tool buttons wrapper.querySelectorAll(".tool-btn").forEach((btn) => { btn.addEventListener("click", (e) => { e.stopPropagation(); const tool = (btn as HTMLElement).dataset.tool; if (tool === "clear") { this.#strokes = []; this.#redraw(); return; } this.#tool = tool || "pen"; wrapper.querySelectorAll(".tool-btn").forEach((b) => b.classList.remove("active")); if (tool !== "clear") btn.classList.add("active"); }); }); // Color swatches wrapper.querySelectorAll(".color-swatch").forEach((swatch) => { swatch.addEventListener("click", (e) => { e.stopPropagation(); this.#color = (swatch as HTMLElement).dataset.color || "#0f172a"; wrapper.querySelectorAll(".color-swatch").forEach((s) => s.classList.remove("active")); swatch.classList.add("active"); this.#tool = "pen"; wrapper.querySelectorAll(".tool-btn").forEach((b) => b.classList.remove("active")); wrapper.querySelector('[data-tool="pen"]')?.classList.add("active"); }); }); // Size slider sizeSlider.addEventListener("input", (e) => { e.stopPropagation(); this.#brushSize = parseInt(sizeSlider.value); sizeLabel.textContent = sizeSlider.value; }); sizeSlider.addEventListener("pointerdown", (e) => e.stopPropagation()); // Framework select frameworkSelect.addEventListener("change", (e) => { e.stopPropagation(); this.#framework = frameworkSelect.value; }); frameworkSelect.addEventListener("pointerdown", (e) => e.stopPropagation()); // Drawing events โ€” pen takes priority over touch (palm rejection). this.#canvas.addEventListener("pointerdown", (e) => { if (this.#penIsActive && e.pointerType === "touch") { e.preventDefault(); return; } if (this.#isDrawing) return; e.stopPropagation(); e.preventDefault(); this.#isDrawing = true; this.#activePointerId = e.pointerId; if (e.pointerType === "pen") this.#penIsActive = true; this.#canvas!.setPointerCapture(e.pointerId); const pos = this.#getCanvasPos(e); const pressure = e.pointerType === "pen" ? (e.pressure || 0.5) : 0.5; this.#currentStroke = { points: [{ ...pos, pressure }], color: this.#tool === "eraser" ? "#ffffff" : this.#color, size: this.#tool === "eraser" ? this.#brushSize * 3 : this.#brushSize, tool: this.#tool, }; }); this.#canvas.addEventListener("pointermove", (e) => { if (!this.#isDrawing || !this.#currentStroke) return; if (e.pointerId !== this.#activePointerId) return; e.stopPropagation(); const pos = this.#getCanvasPos(e); const pressure = e.pointerType === "pen" ? (e.pressure || 0.5) : 0.5; this.#currentStroke.points.push({ ...pos, pressure }); this.#drawStroke(this.#currentStroke); }); const endDraw = (e: PointerEvent) => { if (!this.#isDrawing) return; if (e.pointerId !== this.#activePointerId) return; this.#isDrawing = false; this.#activePointerId = null; if (e.pointerType === "pen") this.#penIsActive = false; if (this.#currentStroke && this.#currentStroke.points.length > 0) { this.#strokes.push(this.#currentStroke); } this.#currentStroke = null; }; this.#canvas.addEventListener("pointerup", endDraw); this.#canvas.addEventListener("pointerleave", endDraw); this.#canvas.addEventListener("pointercancel", endDraw); // Generate button this.#generateBtn.addEventListener("click", (e) => { e.stopPropagation(); this.#generate(); }); // Enter key in prompt this.#promptInput.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); this.#generate(); } }); this.#promptInput.addEventListener("pointerdown", (e) => e.stopPropagation()); closeBtn.addEventListener("click", (e) => { e.stopPropagation(); this.dispatchEvent(new CustomEvent("close")); }); // Size canvas on next frame requestAnimationFrame(() => { this.#resizeCanvas(canvasArea); this.#redraw(); }); this.#resizeObserver = new ResizeObserver(() => { this.#resizeCanvas(canvasArea); this.#redraw(); }); this.#resizeObserver.observe(canvasArea); return root; } async #generate() { const prompt = this.#promptInput?.value.trim() || "Convert this wireframe into a working UI"; if (this.#isGenerating || !this.#canvas) return; this.#isGenerating = true; if (this.#generateBtn) this.#generateBtn.disabled = true; this.#showCode = false; this.#renderLoading(); try { const sketchImage = this.#canvas.toDataURL("image/jpeg", 0.85); const response = await fetch("/api/makereal", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sketch_image: sketchImage, prompt, framework: this.#framework, }), }); if (!response.ok) { const err = await response.json().catch(() => ({ error: response.statusText })); throw new Error(err.error || `Generation failed: ${response.statusText}`); } const result = await response.json(); if (!result.html) throw new Error("No HTML returned"); this.#lastHtml = result.html; this.#renderResult(); this.dispatchEvent(new CustomEvent("html-generated", { detail: { html: result.html, prompt }, })); } catch (error) { const msg = error instanceof Error ? error.message : "Generation failed"; this.#renderError(msg); } finally { this.#isGenerating = false; if (this.#generateBtn) this.#generateBtn.disabled = false; } } #renderLoading() { if (!this.#resultArea) return; this.#resultArea.innerHTML = `
Generating HTML...
`; } #renderResult() { if (!this.#resultArea || !this.#lastHtml) return; const toolbar = `
`; if (this.#showCode) { this.#resultArea.innerHTML = `${toolbar}
${this.#escapeHtml(this.#lastHtml)}
`; } else { this.#resultArea.innerHTML = `${toolbar}`; } // Wire toolbar buttons this.#resultArea.querySelector(".view-preview-btn")?.addEventListener("click", (e) => { e.stopPropagation(); this.#showCode = false; this.#renderResult(); }); this.#resultArea.querySelector(".view-code-btn")?.addEventListener("click", (e) => { e.stopPropagation(); this.#showCode = true; this.#renderResult(); }); this.#resultArea.querySelector(".copy-btn")?.addEventListener("click", (e) => { e.stopPropagation(); if (this.#lastHtml) navigator.clipboard.writeText(this.#lastHtml); }); this.#resultArea.querySelector(".open-btn")?.addEventListener("click", (e) => { e.stopPropagation(); if (this.#lastHtml) { const blob = new Blob([this.#lastHtml], { type: "text/html" }); window.open(URL.createObjectURL(blob), "_blank"); } }); } #renderError(msg: string) { if (!this.#resultArea) return; this.#resultArea.innerHTML = `
โš ๏ธ ${this.#escapeHtml(msg)}
`; } #resizeCanvas(container: HTMLElement) { if (!this.#canvas) return; const rect = container.getBoundingClientRect(); if (rect.width > 0 && rect.height > 0) { this.#canvas.width = rect.width * devicePixelRatio; this.#canvas.height = rect.height * devicePixelRatio; this.#canvas.style.width = rect.width + "px"; this.#canvas.style.height = rect.height + "px"; if (this.#ctx) { this.#ctx.scale(devicePixelRatio, devicePixelRatio); } } } #getCanvasPos(e: PointerEvent): { x: number; y: number } { const rect = this.#canvas!.getBoundingClientRect(); return { x: e.clientX - rect.left, y: e.clientY - rect.top, }; } #drawStroke(stroke: Stroke) { if (!this.#ctx || stroke.points.length < 2) return; const ctx = this.#ctx; ctx.lineCap = "round"; ctx.lineJoin = "round"; ctx.strokeStyle = stroke.color; const len = stroke.points.length; const p0 = stroke.points[len - 2]; const p1 = stroke.points[len - 1]; const pressure = (p0.pressure + p1.pressure) / 2; ctx.lineWidth = stroke.size * (0.5 + pressure); if (stroke.tool === "eraser") { ctx.globalCompositeOperation = "destination-out"; ctx.lineWidth = stroke.size * 3; } else { ctx.globalCompositeOperation = "source-over"; } ctx.beginPath(); ctx.moveTo(p0.x, p0.y); ctx.lineTo(p1.x, p1.y); ctx.stroke(); ctx.globalCompositeOperation = "source-over"; } #redraw() { if (!this.#ctx || !this.#canvas) return; const ctx = this.#ctx; const w = this.#canvas.width / devicePixelRatio; const h = this.#canvas.height / devicePixelRatio; ctx.clearRect(0, 0, w, h); ctx.fillStyle = "#ffffff"; ctx.fillRect(0, 0, w, h); for (const stroke of this.#strokes) { if (stroke.points.length < 2) continue; ctx.lineCap = "round"; ctx.lineJoin = "round"; ctx.strokeStyle = stroke.color; if (stroke.tool === "eraser") { ctx.globalCompositeOperation = "destination-out"; } else { ctx.globalCompositeOperation = "source-over"; } for (let i = 1; i < stroke.points.length; i++) { const p0 = stroke.points[i - 1]; const p1 = stroke.points[i]; const pressure = (p0.pressure + p1.pressure) / 2; ctx.lineWidth = stroke.size * (0.5 + pressure); ctx.beginPath(); ctx.moveTo(p0.x, p0.y); ctx.lineTo(p1.x, p1.y); ctx.stroke(); } } ctx.globalCompositeOperation = "source-over"; } #escapeHtml(text: string): string { const div = document.createElement("div"); div.textContent = text; return div.innerHTML; } #escapeAttr(text: string): string { return text.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">"); } override disconnectedCallback() { super.disconnectedCallback?.(); this.#resizeObserver?.disconnect(); this.#resizeObserver = null; } static override fromData(data: Record): FolkMakereal { const shape = FolkShape.fromData(data) as FolkMakereal; return shape; } override toJSON() { return { ...super.toJSON(), type: "folk-makereal", strokes: this.#strokes.map((s) => ({ points: s.points, color: s.color, size: s.size, tool: s.tool, })), lastHtml: this.#lastHtml, }; } override applyData(data: Record): void { super.applyData(data); if (data.lastHtml && this.#resultArea) { this.#lastHtml = data.lastHtml; this.#renderResult(); } } }