rspace-online/lib/folk-image.ts

334 lines
7.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
}
}
}