rspace-online/lib/folk-makereal.ts

775 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
#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`
<div class="header">
<span class="header-title">
<span>🪄</span>
<span>MakeReal</span>
</span>
<div class="header-actions">
<button class="close-btn" title="Close">×</button>
</div>
</div>
<div class="content">
<div class="toolbar-row">
<button class="tool-btn active" data-tool="pen" title="Pen">✏️</button>
<button class="tool-btn" data-tool="eraser" title="Eraser">🧹</button>
<span style="width:1px;height:20px;background:var(--rs-border,rgba(255,255,255,0.1));margin:0 2px"></span>
${COLORS.map((c) => `<span class="color-swatch${c === "#0f172a" ? " active" : ""}" data-color="${c}" style="background:${c}"></span>`).join("")}
<span style="width:1px;height:20px;background:var(--rs-border,rgba(255,255,255,0.1));margin:0 2px"></span>
<input type="range" class="size-slider" min="1" max="24" value="4" />
<span class="size-label">4</span>
<span style="flex:1"></span>
<button class="tool-btn" data-tool="clear" title="Clear all">🗑️</button>
</div>
<div class="split-area">
<div class="canvas-area">
<canvas></canvas>
</div>
<div class="result-area">
<div class="result-placeholder">
<span class="result-placeholder-icon">🪄</span>
<span>Draw a wireframe and click Make Real</span>
</div>
</div>
</div>
<div class="prompt-bar">
<input type="text" class="prompt-input" placeholder="Describe the UI you're sketching..." />
<select class="framework-select">
<option value="html">HTML</option>
<option value="tailwind">Tailwind</option>
<option value="react">React</option>
</select>
<button class="generate-btn">Make Real</button>
</div>
</div>
`;
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
this.#canvas.addEventListener("pointerdown", (e) => {
e.stopPropagation();
e.preventDefault();
this.#isDrawing = true;
this.#canvas!.setPointerCapture(e.pointerId);
const pos = this.#getCanvasPos(e);
this.#currentStroke = {
points: [{ ...pos, pressure: e.pressure || 0.5 }],
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;
e.stopPropagation();
const pos = this.#getCanvasPos(e);
this.#currentStroke.points.push({ ...pos, pressure: e.pressure || 0.5 });
this.#drawStroke(this.#currentStroke);
});
const endDraw = () => {
if (!this.#isDrawing) return;
this.#isDrawing = 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);
// 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 = `
<div class="spinner-overlay">
<div class="spinner"></div>
<span class="spinner-text">Generating HTML...</span>
</div>
`;
}
#renderResult() {
if (!this.#resultArea || !this.#lastHtml) return;
const toolbar = `<div class="result-toolbar">
<button class="view-preview-btn${!this.#showCode ? " active" : ""}">Preview</button>
<button class="view-code-btn${this.#showCode ? " active" : ""}">Code</button>
<button class="copy-btn">Copy HTML</button>
<button class="open-btn">Open Tab</button>
</div>`;
if (this.#showCode) {
this.#resultArea.innerHTML = `${toolbar}<pre>${this.#escapeHtml(this.#lastHtml)}</pre>`;
} else {
this.#resultArea.innerHTML = `${toolbar}<iframe sandbox="allow-scripts" srcdoc="${this.#escapeAttr(this.#lastHtml)}"></iframe>`;
}
// 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 = `
<div class="result-placeholder">
<span class="result-placeholder-icon">⚠️</span>
<span style="color:#ef4444">${this.#escapeHtml(msg)}</span>
</div>
`;
}
#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, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
override disconnectedCallback() {
super.disconnectedCallback?.();
this.#resizeObserver?.disconnect();
this.#resizeObserver = null;
}
static override fromData(data: Record<string, any>): 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<string, any>): void {
super.applyData(data);
if (data.lastHtml && this.#resultArea) {
this.#lastHtml = data.lastHtml;
this.#renderResult();
}
}
}