Add AI sketch-to-image generation to folk-drawfast
- Split layout: drawing canvas (left) + AI result (right) - Prompt input with Generate button using /api/image-gen/img2img - Auto-generate toggle: debounced generation after each stroke - Provider selector (fal.ai / Gemini) and strength slider - Loading spinner overlay with shimmer animation - Image preloading before display for smooth transitions - Port descriptors for folk-arrow connections (prompt, sketch, image) - Wider default size (700x520) for split view Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
de9cc21301
commit
f58445c35e
|
|
@ -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<typeof setTimeout> | 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 {
|
|||
<div class="header">
|
||||
<span class="header-title">
|
||||
<span>✏️</span>
|
||||
<span>Drawfast</span>
|
||||
<span>Drawfast AI</span>
|
||||
</span>
|
||||
<div class="header-actions">
|
||||
<button class="export-png-btn" title="Export PNG">💾</button>
|
||||
|
|
@ -216,9 +396,33 @@ export class FolkDrawfast extends FolkShape {
|
|||
<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 sketch and click Generate</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="prompt-bar">
|
||||
<input type="text" class="prompt-input" placeholder="Describe what to generate from your sketch..." />
|
||||
<div class="prompt-controls">
|
||||
<label class="auto-toggle" title="Auto-generate after each stroke">
|
||||
<input type="checkbox" class="auto-checkbox" />
|
||||
Auto
|
||||
</label>
|
||||
<select class="provider-select">
|
||||
<option value="fal">fal.ai</option>
|
||||
<option value="gemini">Gemini</option>
|
||||
</select>
|
||||
<input type="range" class="strength-slider" min="30" max="95" value="65" title="AI strength" />
|
||||
<span class="strength-label">65%</span>
|
||||
</div>
|
||||
<button class="generate-btn">Generate</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
|
|
@ -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<void> {
|
||||
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 = `
|
||||
<div class="spinner-overlay">
|
||||
<div class="spinner"></div>
|
||||
<span class="spinner-text">Generating...</span>
|
||||
</div>
|
||||
${this.#lastResultUrl ? `<img src="${this.#escapeAttr(this.#lastResultUrl)}" alt="Previous result" style="opacity:0.3" />` : ""}
|
||||
`;
|
||||
}
|
||||
|
||||
#renderResult(url: string, prompt: string) {
|
||||
if (!this.#resultArea) return;
|
||||
this.#resultArea.innerHTML = `<img src="${this.#escapeAttr(url)}" alt="${this.#escapeAttr(prompt)}" />`;
|
||||
}
|
||||
|
||||
#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();
|
||||
|
|
@ -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, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
static override fromData(data: Record<string, any>): 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<string, any>): void {
|
||||
super.applyData(data);
|
||||
if (data.lastResultUrl && this.#resultArea) {
|
||||
this.#lastResultUrl = data.lastResultUrl;
|
||||
this.#renderResult(data.lastResultUrl, "");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
Loading…
Reference in New Issue