rspace-online/lib/folk-ascii-gen.ts

434 lines
12 KiB
TypeScript

import { FolkShape } from "./folk-shape";
import { css, html } from "./tags";
const PATTERNS = [
"plasma", "mandelbrot", "spiral", "waves", "nebula", "kaleidoscope",
"aurora", "lava", "crystals", "fractal_tree", "random",
] as const;
const PALETTES = [
"classic", "blocks", "braille", "dots", "shades", "hires", "ultra", "dense",
"emoji", "cosmic", "mystic", "runes", "geometric", "flora", "weather",
"wingdings", "zodiac", "chess", "arrows", "music", "box", "math",
"kanji", "thai", "arabic", "devanagari", "hieroglyph", "cuneiform",
"alchemical", "dominos", "mahjong", "dingbats", "playing", "yijing",
] as const;
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: 420px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: linear-gradient(135deg, #22c55e, #14b8a6);
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;
}
.prompt-area {
padding: 12px;
border-bottom: 1px solid var(--rs-border, #e2e8f0);
}
.prompt-input {
width: 100%;
padding: 8px 10px;
border: 2px solid var(--rs-input-border, #e2e8f0);
border-radius: 6px;
font-size: 13px;
resize: none;
outline: none;
font-family: inherit;
background: var(--rs-input-bg, #fff);
color: var(--rs-input-text, inherit);
box-sizing: border-box;
}
.prompt-input:focus {
border-color: #14b8a6;
}
.controls {
display: flex;
gap: 6px;
margin-top: 8px;
flex-wrap: wrap;
}
.controls select {
padding: 5px 8px;
border: 2px solid var(--rs-input-border, #e2e8f0);
border-radius: 6px;
font-size: 11px;
background: var(--rs-input-bg, #fff);
color: var(--rs-input-text, inherit);
cursor: pointer;
}
.size-input {
width: 52px;
padding: 5px 6px;
border: 2px solid var(--rs-input-border, #e2e8f0);
border-radius: 6px;
font-size: 11px;
background: var(--rs-input-bg, #fff);
color: var(--rs-input-text, inherit);
text-align: center;
}
.generate-btn {
padding: 6px 14px;
background: linear-gradient(135deg, #22c55e, #14b8a6);
color: white;
border: none;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
margin-left: auto;
}
.generate-btn:hover { opacity: 0.9; }
.generate-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.preview-area {
flex: 1;
overflow: auto;
padding: 8px;
display: flex;
flex-direction: column;
gap: 8px;
}
.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; }
.ascii-output {
font-family: "Courier New", Consolas, monospace;
font-size: 10px;
line-height: 1.1;
white-space: pre;
overflow: auto;
padding: 8px;
border-radius: 6px;
background: #1e1e2e;
color: #cdd6f4;
max-height: 100%;
}
.ascii-output.light-bg {
background: #f8fafc;
color: #1e293b;
}
.actions-bar {
display: flex;
gap: 6px;
padding: 6px 12px;
border-top: 1px solid var(--rs-border, #e2e8f0);
justify-content: flex-end;
}
.action-btn {
padding: 4px 10px;
border: 1px solid var(--rs-border, #e2e8f0);
border-radius: 4px;
font-size: 11px;
background: var(--rs-bg-surface, #fff);
color: var(--rs-text-primary, #1e293b);
cursor: pointer;
}
.action-btn:hover {
background: var(--rs-bg-hover, #f1f5f9);
}
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px;
gap: 12px;
}
.spinner {
width: 28px;
height: 28px;
border: 3px solid #e2e8f0;
border-top-color: #14b8a6;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.error {
color: #ef4444;
padding: 12px;
background: #fef2f2;
border-radius: 6px;
font-size: 13px;
}
`;
declare global {
interface HTMLElementTagNameMap {
"folk-ascii-gen": FolkAsciiGen;
}
}
export class FolkAsciiGen extends FolkShape {
static override tagName = "folk-ascii-gen";
static override portDescriptors = [
{ name: "prompt", type: "text" as const, direction: "input" as const },
{ name: "ascii", type: "text" 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;
}
#isLoading = false;
#error: string | null = null;
#htmlOutput: string | null = null;
#textOutput: string | null = null;
#promptInput: HTMLTextAreaElement | null = null;
#patternSelect: HTMLSelectElement | null = null;
#paletteSelect: HTMLSelectElement | null = null;
#widthInput: HTMLInputElement | null = null;
#heightInput: HTMLInputElement | null = null;
#previewArea: HTMLElement | null = null;
#generateBtn: HTMLButtonElement | null = null;
#actionsBar: HTMLElement | null = null;
override createRenderRoot() {
const root = super.createRenderRoot();
const wrapper = document.createElement("div");
wrapper.innerHTML = html`
<div class="header">
<span class="header-title">
<span style="font-size:14px">▦</span>
<span>ASCII Art</span>
</span>
<div class="header-actions">
<button class="close-btn" title="Close">&times;</button>
</div>
</div>
<div class="content">
<div class="prompt-area">
<textarea class="prompt-input" placeholder="Pattern name or describe what to generate..." rows="2"></textarea>
<div class="controls">
<select class="pattern-select">
${PATTERNS.map((p) => `<option value="${p}">${p}</option>`).join("")}
</select>
<select class="palette-select">
${PALETTES.map((p) => `<option value="${p}"${p === "classic" ? " selected" : ""}>${p}</option>`).join("")}
</select>
<input class="size-input" type="number" min="20" max="300" value="80" title="Width" placeholder="W" />
<span style="line-height:28px;color:#94a3b8;font-size:11px">&times;</span>
<input class="size-input" type="number" min="10" max="150" value="40" title="Height" placeholder="H" />
<button class="generate-btn">Generate</button>
</div>
</div>
<div class="preview-area">
<div class="placeholder">
<span class="placeholder-icon">▦</span>
<span>Pick a pattern and click Generate</span>
</div>
</div>
<div class="actions-bar" style="display:none">
<button class="action-btn copy-text-btn">Copy Text</button>
<button class="action-btn copy-html-btn">Copy HTML</button>
</div>
</div>
`;
const slot = root.querySelector("slot");
const containerDiv = slot?.parentElement as HTMLElement;
if (containerDiv) containerDiv.replaceWith(wrapper);
this.#promptInput = wrapper.querySelector(".prompt-input");
this.#patternSelect = wrapper.querySelector(".pattern-select");
this.#paletteSelect = wrapper.querySelector(".palette-select");
this.#widthInput = wrapper.querySelector('input[title="Width"]');
this.#heightInput = wrapper.querySelector('input[title="Height"]');
this.#previewArea = wrapper.querySelector(".preview-area");
this.#generateBtn = wrapper.querySelector(".generate-btn");
this.#actionsBar = wrapper.querySelector(".actions-bar");
const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement;
const copyTextBtn = wrapper.querySelector(".copy-text-btn") as HTMLButtonElement;
const copyHtmlBtn = wrapper.querySelector(".copy-html-btn") as HTMLButtonElement;
this.#generateBtn?.addEventListener("click", (e) => {
e.stopPropagation();
this.#generate();
});
this.#promptInput?.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
this.#generate();
}
});
copyTextBtn?.addEventListener("click", (e) => {
e.stopPropagation();
if (this.#textOutput) navigator.clipboard.writeText(this.#textOutput);
});
copyHtmlBtn?.addEventListener("click", (e) => {
e.stopPropagation();
if (this.#htmlOutput) navigator.clipboard.writeText(this.#htmlOutput);
});
closeBtn?.addEventListener("click", (e) => {
e.stopPropagation();
this.dispatchEvent(new CustomEvent("close"));
});
// Prevent canvas drag
this.#previewArea?.addEventListener("pointerdown", (e) => e.stopPropagation());
this.#promptInput?.addEventListener("pointerdown", (e) => e.stopPropagation());
return root;
}
async #generate() {
if (this.#isLoading) return;
const pattern = this.#patternSelect?.value || "plasma";
const prompt = this.#promptInput?.value.trim() || pattern;
const palette = this.#paletteSelect?.value || "classic";
const width = parseInt(this.#widthInput?.value || "80") || 80;
const height = parseInt(this.#heightInput?.value || "40") || 40;
this.#isLoading = true;
this.#error = null;
if (this.#generateBtn) this.#generateBtn.disabled = true;
this.#renderLoading();
try {
const res = await fetch("/api/ascii-gen", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt, pattern, palette, width, height, output_format: "html" }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
throw new Error(data.error || `HTTP ${res.status}`);
}
const data = await res.json();
this.#htmlOutput = data.html || null;
this.#textOutput = data.text || null;
this.#renderResult();
// Emit to output port
if (this.#textOutput) {
this.dispatchEvent(new CustomEvent("port-output", {
detail: { port: "ascii", value: this.#textOutput },
bubbles: true,
}));
}
} catch (e: any) {
this.#error = e.message || "Generation failed";
this.#renderError();
} finally {
this.#isLoading = false;
if (this.#generateBtn) this.#generateBtn.disabled = false;
}
}
#renderLoading() {
if (!this.#previewArea) return;
this.#previewArea.innerHTML = `<div class="loading"><div class="spinner"></div><span style="font-size:12px;color:#64748b">Generating...</span></div>`;
if (this.#actionsBar) this.#actionsBar.style.display = "none";
}
#renderError() {
if (!this.#previewArea) return;
this.#previewArea.innerHTML = `<div class="error">${this.#error}</div>`;
if (this.#actionsBar) this.#actionsBar.style.display = "none";
}
#renderResult() {
if (!this.#previewArea) return;
if (this.#htmlOutput) {
this.#previewArea.innerHTML = `<div class="ascii-output">${this.#htmlOutput}</div>`;
} else if (this.#textOutput) {
const el = document.createElement("div");
el.className = "ascii-output";
el.textContent = this.#textOutput;
this.#previewArea.innerHTML = "";
this.#previewArea.appendChild(el);
} else {
this.#previewArea.innerHTML = `<div class="placeholder"><span>No output received</span></div>`;
}
if (this.#actionsBar) this.#actionsBar.style.display = "flex";
}
}