rspace-online/lib/folk-drawfast.ts

823 lines
21 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;
}
`;
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-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;
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;
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) {
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";
}
#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,
})),
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, "");
}
}
}