Merge branch 'dev'
CI/CD / deploy (push) Successful in 2m37s Details

This commit is contained in:
Jeff Emmett 2026-04-10 15:29:38 -04:00
commit 4704cebf08
2 changed files with 365 additions and 9 deletions

View File

@ -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, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
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, "");
}
}
}

View File

@ -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 },