rspace-online/lib/folk-drawfast.ts

1182 lines
32 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: 520px;
min-height: 480px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: linear-gradient(135deg, #f97316, #eab308);
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: #f97316;
}
.tool-btn.active {
border-color: #f97316;
background: rgba(249, 115, 22, 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: #f97316;
}
.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;
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;
color: white;
border: none;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.export-btn:hover {
opacity: 0.9;
}
.gesture-badge {
position: absolute;
top: 8px;
left: 50%;
transform: translateX(-50%);
background: rgba(249, 115, 22, 0.9);
color: white;
padding: 4px 12px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
z-index: 3;
pointer-events: none;
animation: badge-fade 1.5s ease-out forwards;
}
@keyframes badge-fade {
0% { opacity: 1; transform: translateX(-50%) translateY(0); }
70% { opacity: 1; }
100% { opacity: 0; transform: translateX(-50%) translateY(-10px); }
}
`;
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;
}
// --- $1 Unistroke Recognizer (lightweight, self-contained) ---
// Based on Wobbrock et al. 2007, adapted from canvas-website gesture templates
interface Point2D { x: number; y: number; }
interface RecognizeResult { name: string; score: number; }
const NUM_POINTS = 64;
const SQUARE_SIZE = 250;
const HALF_DIAGONAL = 0.5 * Math.sqrt(SQUARE_SIZE * SQUARE_SIZE + SQUARE_SIZE * SQUARE_SIZE);
const ANGLE_RANGE = Math.PI * 2;
const ANGLE_PRECISION = Math.PI / 90; // 2 degrees
const PHI = 0.5 * (-1 + Math.sqrt(5)); // golden ratio
function resample(pts: Point2D[], n: number): Point2D[] {
const totalLen = pathLength(pts);
const interval = totalLen / (n - 1);
const newPts: Point2D[] = [pts[0]];
let D = 0;
for (let i = 1; i < pts.length; i++) {
const d = distance(pts[i - 1], pts[i]);
if (D + d >= interval) {
const t = (interval - D) / d;
const qx = pts[i - 1].x + t * (pts[i].x - pts[i - 1].x);
const qy = pts[i - 1].y + t * (pts[i].y - pts[i - 1].y);
const q: Point2D = { x: qx, y: qy };
newPts.push(q);
pts.splice(i, 0, q);
D = 0;
} else {
D += d;
}
}
while (newPts.length < n) newPts.push(pts[pts.length - 1]);
return newPts;
}
function rotateToZero(pts: Point2D[]): Point2D[] {
const c = centroid(pts);
const angle = Math.atan2(c.y - pts[0].y, c.x - pts[0].x);
return rotateBy(pts, -angle);
}
function scaleToSquare(pts: Point2D[]): Point2D[] {
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
for (const p of pts) {
minX = Math.min(minX, p.x); maxX = Math.max(maxX, p.x);
minY = Math.min(minY, p.y); maxY = Math.max(maxY, p.y);
}
const w = maxX - minX || 1;
const h = maxY - minY || 1;
return pts.map(p => ({ x: p.x * (SQUARE_SIZE / w), y: p.y * (SQUARE_SIZE / h) }));
}
function translateToOrigin(pts: Point2D[]): Point2D[] {
const c = centroid(pts);
return pts.map(p => ({ x: p.x - c.x, y: p.y - c.y }));
}
function recognize(pts: Point2D[], templates: { name: string; points: Point2D[] }[]): RecognizeResult {
let best = Infinity;
let bestName = "none";
for (const t of templates) {
const d = distanceAtBestAngle(pts, t.points, -ANGLE_RANGE, ANGLE_RANGE, ANGLE_PRECISION);
if (d < best) {
best = d;
bestName = t.name;
}
}
const score = 1 - best / HALF_DIAGONAL;
return { name: bestName, score };
}
function distanceAtBestAngle(pts: Point2D[], template: Point2D[], a: number, b: number, threshold: number): number {
let x1 = PHI * a + (1 - PHI) * b;
let f1 = distanceAtAngle(pts, template, x1);
let x2 = (1 - PHI) * a + PHI * b;
let f2 = distanceAtAngle(pts, template, x2);
while (Math.abs(b - a) > threshold) {
if (f1 < f2) {
b = x2; x2 = x1; f2 = f1;
x1 = PHI * a + (1 - PHI) * b;
f1 = distanceAtAngle(pts, template, x1);
} else {
a = x1; x1 = x2; f1 = f2;
x2 = (1 - PHI) * a + PHI * b;
f2 = distanceAtAngle(pts, template, x2);
}
}
return Math.min(f1, f2);
}
function distanceAtAngle(pts: Point2D[], template: Point2D[], angle: number): number {
const rotated = rotateBy(pts, angle);
return pathDistance(rotated, template);
}
function centroid(pts: Point2D[]): Point2D {
let x = 0, y = 0;
for (const p of pts) { x += p.x; y += p.y; }
return { x: x / pts.length, y: y / pts.length };
}
function rotateBy(pts: Point2D[], angle: number): Point2D[] {
const c = centroid(pts);
const cos = Math.cos(angle), sin = Math.sin(angle);
return pts.map(p => ({
x: (p.x - c.x) * cos - (p.y - c.y) * sin + c.x,
y: (p.x - c.x) * sin + (p.y - c.y) * cos + c.y,
}));
}
function pathDistance(a: Point2D[], b: Point2D[]): number {
let d = 0;
const n = Math.min(a.length, b.length);
for (let i = 0; i < n; i++) d += distance(a[i], b[i]);
return d / n;
}
function pathLength(pts: Point2D[]): number {
let d = 0;
for (let i = 1; i < pts.length; i++) d += distance(pts[i - 1], pts[i]);
return d;
}
function distance(a: Point2D, b: Point2D): number {
const dx = b.x - a.x, dy = b.y - a.y;
return Math.sqrt(dx * dx + dy * dy);
}
function processTemplate(pts: Point2D[]): Point2D[] {
return translateToOrigin(scaleToSquare(rotateToZero(resample(pts, NUM_POINTS))));
}
// Generate templates procedurally (more compact than storing point arrays)
function makeCircleTemplate(): Point2D[] {
const pts: Point2D[] = [];
for (let i = 0; i <= 32; i++) {
const a = (i / 32) * Math.PI * 2;
pts.push({ x: 100 + 80 * Math.cos(a), y: 100 + 80 * Math.sin(a) });
}
return processTemplate(pts);
}
function makeRectangleTemplate(): Point2D[] {
const pts: Point2D[] = [];
// Draw rectangle starting top-left, clockwise
const steps = 8;
for (let i = 0; i <= steps; i++) pts.push({ x: 20 + (160 * i / steps), y: 20 }); // top
for (let i = 0; i <= steps; i++) pts.push({ x: 180, y: 20 + (160 * i / steps) }); // right
for (let i = 0; i <= steps; i++) pts.push({ x: 180 - (160 * i / steps), y: 180 }); // bottom
for (let i = 0; i <= steps; i++) pts.push({ x: 20, y: 180 - (160 * i / steps) }); // left
return processTemplate(pts);
}
function makeLineTemplate(): Point2D[] {
const pts: Point2D[] = [];
for (let i = 0; i <= 16; i++) pts.push({ x: 20 + (160 * i / 16), y: 100 });
return processTemplate(pts);
}
function makeArrowTemplate(): Point2D[] {
// Horizontal line with arrowhead at the end
const pts: Point2D[] = [];
for (let i = 0; i <= 12; i++) pts.push({ x: 20 + (140 * i / 12), y: 100 }); // shaft
for (let i = 0; i <= 4; i++) pts.push({ x: 160 - (40 * i / 4), y: 100 - (40 * i / 4) }); // upper head
pts.push({ x: 160, y: 100 }); // back to tip
for (let i = 0; i <= 4; i++) pts.push({ x: 160 - (40 * i / 4), y: 100 + (40 * i / 4) }); // lower head
return processTemplate(pts);
}
function makeTriangleTemplate(): Point2D[] {
const pts: Point2D[] = [];
const steps = 8;
// Top to bottom-right
for (let i = 0; i <= steps; i++) pts.push({ x: 100 + (80 * i / steps), y: 20 + (160 * i / steps) });
// Bottom-right to bottom-left
for (let i = 0; i <= steps; i++) pts.push({ x: 180 - (160 * i / steps), y: 180 });
// Bottom-left to top
for (let i = 0; i <= steps; i++) pts.push({ x: 20 + (80 * i / steps), y: 180 - (160 * i / steps) });
return processTemplate(pts);
}
function makeCheckTemplate(): Point2D[] {
const pts: Point2D[] = [];
for (let i = 0; i <= 6; i++) pts.push({ x: 20 + (40 * i / 6), y: 100 + (60 * i / 6) });
for (let i = 0; i <= 8; i++) pts.push({ x: 60 + (120 * i / 8), y: 160 - (140 * i / 8) });
return processTemplate(pts);
}
const GESTURE_TEMPLATES = [
{ name: "circle", points: makeCircleTemplate() },
{ name: "rectangle", points: makeRectangleTemplate() },
{ name: "line", points: makeLineTemplate() },
{ name: "arrow", points: makeArrowTemplate() },
{ name: "triangle", points: makeTriangleTemplate() },
{ name: "check", points: makeCheckTemplate() },
];
function recognizeGesture(rawPoints: Point2D[]): RecognizeResult | null {
if (rawPoints.length < 8) return null; // too few points
const processed = processTemplate(rawPoints);
const result = recognize(processed, GESTURE_TEMPLATES);
if (result.score < 0.7) return null; // low confidence
return result;
}
// --- End $1 Unistroke Recognizer ---
declare global {
interface HTMLElementTagNameMap {
"folk-drawfast": FolkDrawfast;
}
}
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)
.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"; // 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;
#canvasArea: HTMLElement | null = null;
#gestureEnabled = true;
get strokes() {
return this.#strokes;
}
override createRenderRoot() {
const root = super.createRenderRoot();
const wrapper = document.createElement("div");
wrapper.innerHTML = html`
<div class="header">
<span class="header-title">
<span>✏️</span>
<span>Drawfast AI</span>
</span>
<div class="header-actions">
<button class="export-png-btn" title="Export PNG">💾</button>
<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 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>
`;
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 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;
this.#canvasArea = canvasArea;
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) => {
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());
// 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();
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 = (e: PointerEvent) => {
if (!this.#isDrawing) return;
this.#isDrawing = false;
if (this.#currentStroke && this.#currentStroke.points.length > 0) {
// Try gesture recognition before adding stroke
let gestureResult: RecognizeResult | null = null;
if (this.#gestureEnabled && this.#currentStroke.tool === "pen") {
const rawPts = this.#currentStroke.points.map(p => ({ x: p.x, y: p.y }));
gestureResult = recognizeGesture(rawPts);
}
if (gestureResult) {
// Replace freehand stroke with clean geometric shape
const cleanStroke = this.#makeCleanShape(gestureResult.name, this.#currentStroke);
this.#strokes.push(cleanStroke);
this.#redraw();
this.#showGestureBadge(gestureResult.name, gestureResult.score);
this.dispatchEvent(new CustomEvent("stroke-complete", {
detail: { stroke: cleanStroke, gesture: gestureResult.name },
}));
} else {
this.#strokes.push(this.#currentStroke);
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;
};
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();
this.#exportPNG();
});
closeBtn.addEventListener("click", (e) => {
e.stopPropagation();
this.dispatchEvent(new CustomEvent("close"));
});
// Size canvas on next frame
requestAnimationFrame(() => {
this.#resizeCanvas(canvasArea);
this.#redraw();
});
// Watch for resize
const ro = new ResizeObserver(() => {
this.#resizeCanvas(canvasArea);
this.#redraw();
});
ro.observe(canvasArea);
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();
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";
}
#makeCleanShape(gesture: string, original: Stroke): Stroke {
const pts = original.points;
const minX = Math.min(...pts.map(p => p.x));
const maxX = Math.max(...pts.map(p => p.x));
const minY = Math.min(...pts.map(p => p.y));
const maxY = Math.max(...pts.map(p => p.y));
const cx = (minX + maxX) / 2;
const cy = (minY + maxY) / 2;
const w = maxX - minX || 1;
const h = maxY - minY || 1;
const pressure = 0.5;
const toStroke = (points: { x: number; y: number }[]): Stroke => ({
points: points.map(p => ({ ...p, pressure })),
color: original.color,
size: original.size,
tool: "pen",
});
switch (gesture) {
case "circle": {
const rx = w / 2, ry = h / 2;
const circPts: { x: number; y: number }[] = [];
for (let i = 0; i <= 48; i++) {
const a = (i / 48) * Math.PI * 2;
circPts.push({ x: cx + rx * Math.cos(a), y: cy + ry * Math.sin(a) });
}
return toStroke(circPts);
}
case "rectangle": {
return toStroke([
{ x: minX, y: minY }, { x: maxX, y: minY },
{ x: maxX, y: maxY }, { x: minX, y: maxY },
{ x: minX, y: minY },
]);
}
case "triangle": {
return toStroke([
{ x: cx, y: minY }, { x: maxX, y: maxY },
{ x: minX, y: maxY }, { x: cx, y: minY },
]);
}
case "line": {
// Use first and last point for direction
const first = pts[0], last = pts[pts.length - 1];
return toStroke([
{ x: first.x, y: first.y },
{ x: last.x, y: last.y },
]);
}
case "arrow": {
const first = pts[0], last = pts[pts.length - 1];
const dx = last.x - first.x, dy = last.y - first.y;
const len = Math.sqrt(dx * dx + dy * dy) || 1;
const ux = dx / len, uy = dy / len;
const headLen = Math.min(20, len * 0.3);
// Arrow shaft + two head lines
return toStroke([
{ x: first.x, y: first.y }, { x: last.x, y: last.y },
{ x: last.x - headLen * (ux + uy * 0.5), y: last.y - headLen * (uy - ux * 0.5) },
{ x: last.x, y: last.y },
{ x: last.x - headLen * (ux - uy * 0.5), y: last.y - headLen * (uy + ux * 0.5) },
]);
}
case "check": {
// V-shape: find the lowest point as the vertex
const lowestIdx = pts.reduce((best, p, i) => p.y > pts[best].y ? i : best, 0);
return toStroke([
{ x: pts[0].x, y: pts[0].y },
{ x: pts[lowestIdx].x, y: pts[lowestIdx].y },
{ x: pts[pts.length - 1].x, y: pts[pts.length - 1].y },
]);
}
default:
return original;
}
}
#showGestureBadge(name: string, score: number) {
if (!this.#canvasArea) return;
// Remove existing badge
this.#canvasArea.querySelector(".gesture-badge")?.remove();
const badge = document.createElement("div");
badge.className = "gesture-badge";
badge.textContent = `${name} (${Math.round(score * 100)}%)`;
this.#canvasArea.appendChild(badge);
setTimeout(() => badge.remove(), 1500);
}
#exportPNG() {
if (!this.#canvas) return;
const dataUrl = this.#canvas.toDataURL("image/png");
const link = document.createElement("a");
link.download = `drawfast-${Date.now()}.png`;
link.href = dataUrl;
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;
}
override toJSON() {
return {
...super.toJSON(),
type: "folk-drawfast",
strokes: this.#strokes.map((s) => ({
points: s.points,
color: s.color,
size: s.size,
tool: s.tool,
})),
prompt: this.#promptInput?.value || "",
lastResultUrl: this.#lastResultUrl,
};
}
override applyData(data: Record<string, any>): void {
super.applyData(data);
// Restore strokes from sync data
if (data.strokes && Array.isArray(data.strokes)) {
this.#strokes = data.strokes.map((s: any) => ({
points: Array.isArray(s.points) ? s.points : [],
color: s.color || "#0f172a",
size: s.size || 4,
tool: s.tool || "pen",
}));
this.#redraw();
}
// Restore prompt text
if (data.prompt !== undefined && this.#promptInput) {
this.#promptInput.value = data.prompt || "";
}
// Restore last generated image
if (data.lastResultUrl && this.#resultArea) {
this.#lastResultUrl = data.lastResultUrl;
this.#renderResult(data.lastResultUrl, "");
}
}
}