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: 200px;
min-height: 150px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: #22c55e;
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 {
width: 100%;
height: calc(100% - 36px);
position: relative;
border-radius: 0 0 8px 8px;
overflow: hidden;
}
.drop-zone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
padding: 24px;
gap: 12px;
border: 2px dashed var(--rs-input-border, #e2e8f0);
border-radius: 0 0 8px 8px;
margin: 8px;
text-align: center;
color: #94a3b8;
font-size: 13px;
transition: border-color 0.2s, background 0.2s;
}
.drop-zone.dragover {
border-color: #22c55e;
background: rgba(34, 197, 94, 0.05);
}
.drop-zone-icon {
font-size: 32px;
}
.url-input {
width: 100%;
max-width: 300px;
padding: 10px 14px;
border: 2px solid var(--rs-input-border, #e2e8f0);
border-radius: 8px;
font-size: 13px;
outline: none;
background: var(--rs-input-bg, #fff);
color: var(--rs-input-text, inherit);
}
.url-input:focus {
border-color: #22c55e;
}
.image-display {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
`;
declare global {
interface HTMLElementTagNameMap {
"folk-image": FolkImage;
}
}
export class FolkImage extends FolkShape {
static override tagName = "folk-image";
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;
}
#src: string | null = null;
#alt: string = "";
get src() {
return this.#src;
}
set src(value: string | null) {
this.#src = value;
this.requestUpdate("src");
this.dispatchEvent(new CustomEvent("src-change", { detail: { src: value } }));
}
get alt() {
return this.#alt;
}
set alt(value: string) {
this.#alt = value;
}
override createRenderRoot() {
const root = super.createRenderRoot();
this.#src = this.getAttribute("src") || null;
this.#alt = this.getAttribute("alt") || "";
const wrapper = document.createElement("div");
wrapper.innerHTML = html`
`;
const slot = root.querySelector("slot");
const containerDiv = slot?.parentElement as HTMLElement;
if (containerDiv) {
containerDiv.replaceWith(wrapper);
}
const content = wrapper.querySelector(".content") as HTMLElement;
const dropZone = wrapper.querySelector(".drop-zone") as HTMLElement;
const urlInput = wrapper.querySelector(".url-input") as HTMLInputElement;
const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement;
// URL input
const handleUrlSubmit = () => {
let inputUrl = urlInput.value.trim();
if (!inputUrl) return;
if (!inputUrl.startsWith("http://") && !inputUrl.startsWith("https://")) {
inputUrl = `https://${inputUrl}`;
}
this.src = inputUrl;
this.#renderImage(content, dropZone);
};
urlInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
handleUrlSubmit();
}
});
urlInput.addEventListener("blur", () => {
if (urlInput.value.trim()) handleUrlSubmit();
});
// Drop image files directly onto shape
dropZone.addEventListener("dragover", (e) => {
e.preventDefault();
e.stopPropagation();
dropZone.classList.add("dragover");
});
dropZone.addEventListener("dragleave", () => {
dropZone.classList.remove("dragover");
});
dropZone.addEventListener("drop", (e) => {
e.preventDefault();
e.stopPropagation();
dropZone.classList.remove("dragover");
const file = Array.from(e.dataTransfer?.files || []).find((f) =>
f.type.startsWith("image/")
);
if (file) {
this.#uploadFile(file, content, dropZone);
return;
}
const url = e.dataTransfer?.getData("text/plain") || "";
if (url.trim()) {
this.src = url.trim();
this.#renderImage(content, dropZone);
}
});
// Close button
closeBtn.addEventListener("click", (e) => {
e.stopPropagation();
this.dispatchEvent(new CustomEvent("close"));
});
// If src already set, render image
if (this.#src) {
this.#renderImage(content, dropZone);
}
return root;
}
async #uploadFile(
file: File,
content: HTMLElement,
dropZone: HTMLElement
) {
const reader = new FileReader();
reader.onload = async () => {
try {
const res = await fetch("/api/image-upload", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ image: reader.result }),
});
const data = await res.json();
if (data.url) {
this.src = data.url;
this.alt = file.name;
this.#renderImage(content, dropZone);
}
} catch (err) {
console.error("[folk-image] upload failed:", err);
}
};
reader.readAsDataURL(file);
}
#renderImage(content: HTMLElement, dropZone: HTMLElement) {
if (!this.#src) return;
dropZone.style.display = "none";
// Remove existing image if any
const existing = content.querySelector(".image-display");
if (existing) existing.remove();
const img = document.createElement("img");
img.className = "image-display";
img.src = this.#src;
img.alt = this.#alt || "Image";
img.draggable = false;
content.appendChild(img);
// Update header title
const titleText = this.renderRoot.querySelector(".title-text");
if (titleText) {
titleText.textContent = this.#alt || "Image";
}
}
static override fromData(data: Record