334 lines
7.2 KiB
TypeScript
334 lines
7.2 KiB
TypeScript
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`
|
||
<div class="header">
|
||
<span class="header-title">
|
||
<span>🖼️</span>
|
||
<span class="title-text">Image</span>
|
||
</span>
|
||
<div class="header-actions">
|
||
<button class="close-btn" title="Close">×</button>
|
||
</div>
|
||
</div>
|
||
<div class="content">
|
||
<div class="drop-zone">
|
||
<span class="drop-zone-icon">🖼️</span>
|
||
<span>Paste, drop, or enter image URL…</span>
|
||
<input
|
||
type="text"
|
||
class="url-input"
|
||
placeholder="https://example.com/image.png"
|
||
/>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
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<string, any>): FolkImage {
|
||
const shape = FolkShape.fromData(data) as FolkImage;
|
||
if (data.src) shape.src = data.src;
|
||
if (data.alt) shape.alt = data.alt;
|
||
return shape;
|
||
}
|
||
|
||
override toJSON() {
|
||
return {
|
||
...super.toJSON(),
|
||
type: "folk-image",
|
||
src: this.src,
|
||
alt: this.alt,
|
||
};
|
||
}
|
||
|
||
override applyData(data: Record<string, any>): void {
|
||
super.applyData(data);
|
||
if ("src" in data && this.src !== data.src) {
|
||
this.src = data.src;
|
||
}
|
||
if ("alt" in data && this.alt !== data.alt) {
|
||
this.alt = data.alt;
|
||
}
|
||
}
|
||
}
|