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: 380px; min-height: 480px; } .header { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; background: linear-gradient(135deg, #ea580c, #f59e0b); 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); } .wrapper { height: 100%; overflow: hidden; display: flex; flex-direction: column; } .content { display: flex; flex-direction: column; flex: 1; min-height: 0; overflow: hidden; } .prompt-area { padding: 12px; border-bottom: 1px solid #e2e8f0; } .prompt-input { width: 100%; padding: 10px 12px; border: 2px solid var(--rs-input-border, #e2e8f0); border-radius: 8px; font-size: 13px; resize: none; outline: none; font-family: inherit; background: var(--rs-input-bg, #fff); color: var(--rs-input-text, inherit); } .prompt-input:focus { border-color: #ea580c; } .controls { display: flex; gap: 8px; margin-top: 8px; } .generate-btn { flex: 1; padding: 8px 16px; background: linear-gradient(135deg, #ea580c, #f59e0b); color: white; border: none; border-radius: 6px; font-size: 13px; font-weight: 600; cursor: pointer; transition: opacity 0.2s; } .generate-btn:hover { opacity: 0.9; } .generate-btn:disabled { opacity: 0.5; cursor: not-allowed; } .tabs { display: flex; border-bottom: 1px solid #e2e8f0; } .tab { flex: 1; padding: 8px; text-align: center; font-size: 12px; font-weight: 600; color: #64748b; border: none; background: none; cursor: pointer; border-bottom: 2px solid transparent; transition: all 0.2s; } .tab:hover { color: #ea580c; } .tab.active { color: #ea580c; border-bottom-color: #ea580c; } .preview-area { flex: 1; min-height: 0; overflow: hidden; display: flex; flex-direction: column; } .render-preview { flex: 1; display: flex; align-items: center; justify-content: center; overflow: hidden; min-height: 0; position: relative; touch-action: none; cursor: grab; user-select: none; } .render-preview.dragging { cursor: grabbing; } .render-preview img { transform-origin: 0 0; border-radius: 8px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); pointer-events: none; max-width: none; max-height: none; } .render-preview .zoom-hint { position: absolute; bottom: 8px; right: 8px; font-size: 10px; color: var(--rs-text-tertiary, #94a3b8); opacity: 0.7; pointer-events: none; } .render-preview .zoom-reset { position: absolute; top: 8px; right: 8px; font-size: 11px; padding: 3px 8px; border-radius: 4px; border: 1px solid var(--rs-border, #e2e8f0); background: var(--rs-bg-surface, #fff); color: var(--rs-text-secondary, #64748b); cursor: pointer; opacity: 0; transition: opacity 0.15s; } .render-preview:hover .zoom-reset { opacity: 1; } .render-preview .zoom-reset:hover { background: var(--rs-surface-hover, #f1f5f9); } .code-area { flex: 1; overflow: auto; padding: 12px; } .code-area pre { margin: 0; padding: 12px; background: #1e293b; color: #e2e8f0; border-radius: 6px; font-size: 11px; line-height: 1.5; overflow-x: auto; white-space: pre-wrap; word-break: break-all; } .download-row { display: flex; gap: 8px; padding: 8px 12px; border-top: 1px solid #e2e8f0; } .download-btn { padding: 6px 12px; background: #334155; color: white; border: none; border-radius: 6px; font-size: 12px; cursor: pointer; text-decoration: none; } .download-btn:hover { background: #475569; } .placeholder { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; color: #94a3b8; text-align: center; gap: 8px; } .placeholder-icon { font-size: 48px; opacity: 0.5; } .loading { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 24px; gap: 12px; } .spinner { width: 32px; height: 32px; border: 3px solid #e2e8f0; border-top-color: #ea580c; border-radius: 50%; animation: spin 1s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } .error { color: #ef4444; padding: 12px; background: #fef2f2; border-radius: 6px; font-size: 13px; margin: 12px; } `; declare global { interface HTMLElementTagNameMap { "folk-blender": FolkBlender; } } export class FolkBlender extends FolkShape { static override tagName = "folk-blender"; 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; } #isLoading = false; #error: string | null = null; #prompt: string | null = null; #renderUrl: string | null = null; #script: string | null = null; #blendUrl: string | null = null; #activeTab: "preview" | "code" = "preview"; #promptInput: HTMLTextAreaElement | null = null; #generateBtn: HTMLButtonElement | null = null; #previewArea: HTMLElement | null = null; #codeArea: HTMLElement | null = null; #downloadRow: HTMLElement | null = null; override createRenderRoot() { const root = super.createRenderRoot(); const wrapper = document.createElement("div"); wrapper.className = "wrapper"; wrapper.innerHTML = html`
${this.#escapeHtml(this.#script)}`;
}
if (this.#downloadRow && this.#blendUrl) {
this.#downloadRow.style.display = "flex";
const blendLink = this.#downloadRow.querySelector(".blend-dl") as HTMLAnchorElement;
if (blendLink) blendLink.href = this.#blendUrl;
}
}
#applyTransform(img: HTMLImageElement) {
img.style.transform = `translate(${this.#viewX}px, ${this.#viewY}px) scale(${this.#viewScale})`;
}
#wireViewerEvents(container: HTMLElement, img: HTMLImageElement) {
let dragging = false;
let dragStartX = 0;
let dragStartY = 0;
let startViewX = 0;
let startViewY = 0;
const onPointerDown = (e: PointerEvent) => {
e.stopPropagation();
this.#pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
container.setPointerCapture(e.pointerId);
if (this.#pointers.size === 1) {
dragging = true;
dragStartX = e.clientX;
dragStartY = e.clientY;
startViewX = this.#viewX;
startViewY = this.#viewY;
container.classList.add("dragging");
} else if (this.#pointers.size === 2) {
// Start pinch
const pts = [...this.#pointers.values()];
this.#lastPinchDist = Math.hypot(pts[1].x - pts[0].x, pts[1].y - pts[0].y);
}
};
const onPointerMove = (e: PointerEvent) => {
if (!this.#pointers.has(e.pointerId)) return;
this.#pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (this.#pointers.size === 2) {
// Pinch zoom
const pts = [...this.#pointers.values()];
const dist = Math.hypot(pts[1].x - pts[0].x, pts[1].y - pts[0].y);
if (this.#lastPinchDist > 0) {
const ratio = dist / this.#lastPinchDist;
const cx = (pts[0].x + pts[1].x) / 2;
const cy = (pts[0].y + pts[1].y) / 2;
const rect = container.getBoundingClientRect();
const px = cx - rect.left;
const py = cy - rect.top;
this.#zoomAt(px, py, ratio, img);
}
this.#lastPinchDist = dist;
} else if (dragging && this.#pointers.size === 1) {
this.#viewX = startViewX + (e.clientX - dragStartX);
this.#viewY = startViewY + (e.clientY - dragStartY);
this.#applyTransform(img);
}
};
const onPointerUp = (e: PointerEvent) => {
this.#pointers.delete(e.pointerId);
if (this.#pointers.size < 2) this.#lastPinchDist = 0;
if (this.#pointers.size === 0) {
dragging = false;
container.classList.remove("dragging");
}
};
const onWheel = (e: WheelEvent) => {
e.preventDefault();
e.stopPropagation();
const rect = container.getBoundingClientRect();
const px = e.clientX - rect.left;
const py = e.clientY - rect.top;
const factor = e.deltaY < 0 ? 1.15 : 1 / 1.15;
this.#zoomAt(px, py, factor, img);
};
const onDblClick = (e: MouseEvent) => {
e.stopPropagation();
// Double-click to reset
const cw = container.clientWidth;
const ch = container.clientHeight;
const iw = img.naturalWidth || img.width;
const ih = img.naturalHeight || img.height;
if (!iw || !ih) return;
this.#viewScale = Math.min(cw / iw, ch / ih, 1);
this.#viewX = (cw - iw * this.#viewScale) / 2;
this.#viewY = (ch - ih * this.#viewScale) / 2;
this.#applyTransform(img);
};
container.addEventListener("pointerdown", onPointerDown);
container.addEventListener("pointermove", onPointerMove);
container.addEventListener("pointerup", onPointerUp);
container.addEventListener("pointercancel", onPointerUp);
container.addEventListener("wheel", onWheel, { passive: false });
container.addEventListener("dblclick", onDblClick);
this.#viewCleanup = () => {
container.removeEventListener("pointerdown", onPointerDown);
container.removeEventListener("pointermove", onPointerMove);
container.removeEventListener("pointerup", onPointerUp);
container.removeEventListener("pointercancel", onPointerUp);
container.removeEventListener("wheel", onWheel);
container.removeEventListener("dblclick", onDblClick);
};
}
#zoomAt(px: number, py: number, factor: number, img: HTMLImageElement) {
const newScale = Math.min(Math.max(this.#viewScale * factor, 0.1), 10);
const ratio = newScale / this.#viewScale;
this.#viewX = px - ratio * (px - this.#viewX);
this.#viewY = py - ratio * (py - this.#viewY);
this.#viewScale = newScale;
this.#applyTransform(img);
}
#escapeHtml(text: string): string {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
static override fromData(data: Record