diff --git a/lib/folk-drawfast.ts b/lib/folk-drawfast.ts index a309bd76..6170879b 100644 --- a/lib/folk-drawfast.ts +++ b/lib/folk-drawfast.ts @@ -7,8 +7,8 @@ const styles = css` color: var(--rs-text-primary, #1e293b); border-radius: 8px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); - min-width: 360px; - min-height: 420px; + min-width: 520px; + min-height: 480px; } .header { @@ -117,11 +117,19 @@ const styles = css` 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 { @@ -130,6 +138,163 @@ const styles = css` display: block; } + .result-area { + flex: 1; + position: relative; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + background: var(--rs-bg-muted, #f8fafc); + border-left: 1px solid var(--rs-border, rgba(255, 255, 255, 0.1)); + min-width: 0; + } + + .result-area img { + max-width: 100%; + max-height: 100%; + object-fit: contain; + border-radius: 4px; + transition: opacity 0.2s ease-in-out; + } + + .result-placeholder { + display: flex; + flex-direction: column; + align-items: 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; + } + + .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: #f97316; + border-radius: 50%; + animation: spin 1s linear infinite; + } + + .spinner-text { + font-size: 11px; + color: #64748b; + } + + @keyframes spin { + to { transform: rotate(360deg); } + } + + @keyframes shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } + } + + .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: #f97316; + } + + .generate-btn { + padding: 6px 14px; + background: linear-gradient(135deg, #f97316, #eab308); + 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; + } + + .prompt-controls { + display: flex; + gap: 6px; + align-items: center; + } + + .auto-toggle { + display: flex; + align-items: center; + gap: 4px; + font-size: 11px; + color: var(--rs-text-muted, #64748b); + cursor: pointer; + user-select: none; + } + + .auto-toggle input { + accent-color: #f97316; + } + + .provider-select { + padding: 4px 8px; + border: 2px solid #e2e8f0; + border-radius: 6px; + font-size: 11px; + background: var(--rs-bg-surface, white); + cursor: pointer; + } + + .strength-slider { + width: 60px; + accent-color: #f97316; + } + + .strength-label { + font-size: 10px; + color: var(--rs-text-muted, #64748b); + min-width: 28px; + } + .export-btn { padding: 6px 12px; background: #f97316; @@ -164,6 +329,12 @@ declare global { export class FolkDrawfast extends FolkShape { static override tagName = "folk-drawfast"; + static override portDescriptors = [ + { name: "prompt", type: "text" as const, direction: "input" as const }, + { name: "sketch", type: "image-url" as const, direction: "output" as const }, + { name: "image", type: "image-url" as const, direction: "output" as const }, + ]; + static { const sheet = new CSSStyleSheet(); const parentRules = Array.from(FolkShape.styles.cssRules) @@ -184,6 +355,15 @@ export class FolkDrawfast extends FolkShape { #brushSize = 4; #tool = "pen"; // pen | eraser #isDrawing = false; + #isGenerating = false; + #autoGenerate = false; + #autoDebounceTimer: ReturnType | null = null; + #provider: "fal" | "gemini" = "fal"; + #strength = 0.65; + #lastResultUrl: string | null = null; + #promptInput: HTMLInputElement | null = null; + #generateBtn: HTMLButtonElement | null = null; + #resultArea: HTMLElement | null = null; get strokes() { return this.#strokes; @@ -197,7 +377,7 @@ export class FolkDrawfast extends FolkShape {
✏️ - Drawfast + Drawfast AI
@@ -216,8 +396,32 @@ export class FolkDrawfast extends FolkShape {
-
- +
+
+ +
+
+
+ 🎨 + Draw a sketch and click Generate +
+
+
+
+ +
+ + + + 65% +
+
`; @@ -230,12 +434,19 @@ export class FolkDrawfast extends FolkShape { 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 exportBtn = wrapper.querySelector(".export-png-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 autoCheckbox = wrapper.querySelector(".auto-checkbox") as HTMLInputElement; + const providerSelect = wrapper.querySelector(".provider-select") as HTMLSelectElement; + const strengthSlider = wrapper.querySelector(".strength-slider") as HTMLInputElement; + const strengthLabel = wrapper.querySelector(".strength-label") as HTMLElement; // Tool buttons wrapper.querySelectorAll(".tool-btn").forEach((btn) => { @@ -260,7 +471,6 @@ export class FolkDrawfast extends FolkShape { this.#color = (swatch as HTMLElement).dataset.color || "#0f172a"; wrapper.querySelectorAll(".color-swatch").forEach((s) => s.classList.remove("active")); swatch.classList.add("active"); - // Switch to pen when picking a color this.#tool = "pen"; wrapper.querySelectorAll(".tool-btn").forEach((b) => b.classList.remove("active")); wrapper.querySelector('[data-tool="pen"]')?.classList.add("active"); @@ -275,6 +485,27 @@ export class FolkDrawfast extends FolkShape { }); sizeSlider.addEventListener("pointerdown", (e) => e.stopPropagation()); + // Strength slider + strengthSlider.addEventListener("input", (e) => { + e.stopPropagation(); + this.#strength = parseInt(strengthSlider.value) / 100; + strengthLabel.textContent = strengthSlider.value + "%"; + }); + strengthSlider.addEventListener("pointerdown", (e) => e.stopPropagation()); + + // Auto-generate toggle + autoCheckbox.addEventListener("change", (e) => { + e.stopPropagation(); + this.#autoGenerate = autoCheckbox.checked; + }); + + // Provider select + providerSelect.addEventListener("change", (e) => { + e.stopPropagation(); + this.#provider = providerSelect.value as "fal" | "gemini"; + }); + providerSelect.addEventListener("pointerdown", (e) => e.stopPropagation()); + // Drawing events this.#canvas.addEventListener("pointerdown", (e) => { e.stopPropagation(); @@ -306,6 +537,10 @@ export class FolkDrawfast extends FolkShape { this.dispatchEvent(new CustomEvent("stroke-complete", { detail: { stroke: this.#currentStroke }, })); + // Auto-generate on stroke complete if enabled + if (this.#autoGenerate && this.#promptInput?.value.trim()) { + this.#scheduleAutoGenerate(); + } } this.#currentStroke = null; }; @@ -313,6 +548,21 @@ export class FolkDrawfast extends FolkShape { 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()); + // Export exportBtn.addEventListener("click", (e) => { e.stopPropagation(); @@ -340,6 +590,99 @@ export class FolkDrawfast extends FolkShape { return root; } + #scheduleAutoGenerate() { + if (this.#autoDebounceTimer) clearTimeout(this.#autoDebounceTimer); + this.#autoDebounceTimer = setTimeout(() => { + this.#autoDebounceTimer = null; + if (!this.#isGenerating) { + this.#generate(); + } + }, 500); + } + + async #generate() { + const prompt = this.#promptInput?.value.trim(); + if (!prompt || this.#isGenerating || !this.#canvas) return; + + this.#isGenerating = true; + if (this.#generateBtn) this.#generateBtn.disabled = true; + this.#renderLoading(); + + try { + const sourceImage = this.#canvas.toDataURL("image/jpeg", 0.8); + + const response = await fetch("/api/image-gen/img2img", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + prompt, + source_image: sourceImage, + provider: this.#provider, + strength: this.#strength, + }), + }); + + if (!response.ok) { + throw new Error(`Generation failed: ${response.statusText}`); + } + + const result = await response.json(); + const imageUrl = result.url || result.image_url; + if (!imageUrl) throw new Error("No image returned"); + + // Preload image before displaying + await this.#preloadImage(imageUrl); + + this.#lastResultUrl = imageUrl; + this.#renderResult(imageUrl, prompt); + + this.dispatchEvent(new CustomEvent("image-generated", { + detail: { url: imageUrl, 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; + } + } + + #preloadImage(url: string): Promise { + return new Promise((resolve) => { + const img = new Image(); + img.onload = () => resolve(); + img.onerror = () => resolve(); + img.src = url; + }); + } + + #renderLoading() { + if (!this.#resultArea) return; + this.#resultArea.innerHTML = ` +
+
+ Generating... +
+ ${this.#lastResultUrl ? `Previous result` : ""} + `; + } + + #renderResult(url: string, prompt: string) { + if (!this.#resultArea) return; + this.#resultArea.innerHTML = `${this.#escapeAttr(prompt)}`; + } + + #renderError(msg: string) { + if (!this.#resultArea) return; + this.#resultArea.innerHTML = ` +
+ ⚠️ + ${this.#escapeHtml(msg)} +
+ `; + } + #resizeCanvas(container: HTMLElement) { if (!this.#canvas) return; const rect = container.getBoundingClientRect(); @@ -370,7 +713,6 @@ export class FolkDrawfast extends FolkShape { ctx.lineJoin = "round"; ctx.strokeStyle = stroke.color; - // Draw last segment for live drawing const len = stroke.points.length; const p0 = stroke.points[len - 2]; const p1 = stroke.points[len - 1]; @@ -400,7 +742,6 @@ export class FolkDrawfast extends FolkShape { ctx.clearRect(0, 0, w, h); - // Fill white background ctx.fillStyle = "#ffffff"; ctx.fillRect(0, 0, w, h); @@ -442,6 +783,16 @@ export class FolkDrawfast extends FolkShape { link.click(); } + #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, ">"); + } + static override fromData(data: Record): FolkDrawfast { const shape = FolkShape.fromData(data) as FolkDrawfast; return shape; @@ -457,10 +808,15 @@ export class FolkDrawfast extends FolkShape { size: s.size, tool: s.tool, })), + lastResultUrl: this.#lastResultUrl, }; } override applyData(data: Record): void { super.applyData(data); + if (data.lastResultUrl && this.#resultArea) { + this.#lastResultUrl = data.lastResultUrl; + this.#renderResult(data.lastResultUrl, ""); + } } } diff --git a/website/canvas.html b/website/canvas.html index 346dc859..2cc52439 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -4071,7 +4071,7 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest "folk-multisig-email": { width: 400, height: 380 }, "folk-splat": { width: 480, height: 420 }, "folk-blender": { width: 420, height: 520 }, - "folk-drawfast": { width: 500, height: 480 }, + "folk-drawfast": { width: 700, height: 520 }, "folk-freecad": { width: 400, height: 480 }, "folk-kicad": { width: 420, height: 500 }, "folk-canvas": { width: 600, height: 400 },