434 lines
12 KiB
TypeScript
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">×</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">×</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";
|
|
}
|
|
}
|