rspace-online/lib/folk-wrapper.ts

489 lines
12 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: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
min-width: 200px;
min-height: 100px;
display: flex;
flex-direction: column;
overflow: hidden;
}
:host([selected]) {
box-shadow: 0 0 0 2px var(--primary-color, #14b8a6), 0 4px 8px rgba(0, 0, 0, 0.15);
}
.header {
height: 40px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 12px;
cursor: move;
user-select: none;
flex-shrink: 0;
transition: background-color 0.2s ease;
background: var(--header-bg, rgba(20, 184, 166, 0.1));
border-bottom: 1px solid var(--header-border, rgba(20, 184, 166, 0.2));
}
:host([selected]) .header {
background: var(--primary-color, #14b8a6);
color: white;
}
.header-title {
font-size: 13px;
font-weight: 600;
color: var(--primary-color, #14b8a6);
display: flex;
align-items: center;
gap: 6px;
}
:host([selected]) .header-title {
color: white;
}
.header-icon {
font-size: 14px;
}
.header-actions {
display: flex;
gap: 8px;
align-items: center;
}
.header-actions button {
width: 24px;
height: 24px;
border-radius: 4px;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
transition: background-color 0.15s ease;
background: var(--button-bg, rgba(20, 184, 166, 0.2));
color: var(--primary-color, #14b8a6);
}
:host([selected]) .header-actions button {
background: rgba(255, 255, 255, 0.2);
color: white;
}
.header-actions button:hover {
background: var(--button-hover-bg, rgba(20, 184, 166, 0.3));
}
:host([selected]) .header-actions button:hover {
background: rgba(255, 255, 255, 0.3);
}
.header-actions button.active {
background: var(--primary-color, #14b8a6);
color: white;
}
:host([selected]) .header-actions button.active {
background: rgba(255, 255, 255, 0.4);
}
.content {
flex: 1;
overflow: auto;
position: relative;
}
:host([minimized]) .content {
display: none;
}
.tags {
padding: 8px 12px;
border-top: 1px solid #e0e0e0;
display: flex;
flex-wrap: wrap;
gap: 4px;
align-items: center;
min-height: 32px;
background: #f8f9fa;
flex-shrink: 0;
}
:host([minimized]) .tags {
display: none;
}
.tag {
background: #6b7280;
color: white;
padding: 4px 8px;
border-radius: 12px;
font-size: 10px;
font-weight: 500;
display: inline-flex;
align-items: center;
gap: 4px;
cursor: pointer;
}
.tag:hover {
background: #4b5563;
}
.tag .remove {
font-size: 8px;
opacity: 0.7;
}
.tag .remove:hover {
opacity: 1;
}
.add-tag {
background: #9ca3af;
color: white;
border: none;
border-radius: 12px;
padding: 4px 10px;
font-size: 10px;
font-weight: 500;
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
}
.add-tag:hover {
background: #6b7280;
}
.tag-input {
border: 1px solid #9ca3af;
border-radius: 12px;
padding: 2px 6px;
font-size: 10px;
outline: none;
min-width: 60px;
flex: 1;
background: white;
}
`;
declare global {
interface HTMLElementTagNameMap {
"folk-wrapper": FolkWrapper;
}
}
export class FolkWrapper extends FolkShape {
static override tagName = "folk-wrapper";
// Merge parent and child styles
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;
}
#title = "Untitled";
#icon = "";
#primaryColor = "#14b8a6";
#isMinimized = false;
#isPinned = false;
#tags: string[] = [];
#isEditingTags = false;
#tagInput: HTMLInputElement | null = null;
get title() {
return this.#title;
}
set title(value: string) {
this.#title = value;
this.requestUpdate("title");
}
get icon() {
return this.#icon;
}
set icon(value: string) {
this.#icon = value;
this.requestUpdate("icon");
}
get primaryColor() {
return this.#primaryColor;
}
set primaryColor(value: string) {
this.#primaryColor = value;
this.style.setProperty("--primary-color", value);
this.style.setProperty("--header-bg", `${value}10`);
this.style.setProperty("--header-border", `${value}30`);
this.style.setProperty("--button-bg", `${value}20`);
this.style.setProperty("--button-hover-bg", `${value}30`);
this.requestUpdate("primaryColor");
}
get isMinimized() {
return this.#isMinimized;
}
set isMinimized(value: boolean) {
this.#isMinimized = value;
this.toggleAttribute("minimized", value);
}
get isPinned() {
return this.#isPinned;
}
set isPinned(value: boolean) {
this.#isPinned = value;
this.requestUpdate("isPinned");
}
get tags() {
return [...this.#tags];
}
set tags(value: string[]) {
this.#tags = [...value];
this.requestUpdate("tags");
}
override createRenderRoot() {
const root = super.createRenderRoot();
// Add wrapper UI
const wrapper = document.createElement("div");
wrapper.innerHTML = html`
<div class="header">
<div class="header-title">
<span class="header-icon"></span>
<span class="title-text"></span>
</div>
<div class="header-actions">
<button class="pin-btn" title="Pin to view">📌</button>
<button class="minimize-btn" title="Minimize">_</button>
<button class="close-btn" title="Close">×</button>
</div>
</div>
<div class="content">
<slot></slot>
</div>
<div class="tags"></div>
`;
// Replace existing content structure
const existingSlot = root.querySelector("slot");
if (existingSlot?.parentElement) {
existingSlot.parentElement.innerHTML = "";
existingSlot.parentElement.appendChild(wrapper);
}
// Get references
const header = wrapper.querySelector(".header") as HTMLElement;
const titleIcon = wrapper.querySelector(".header-icon") as HTMLElement;
const titleText = wrapper.querySelector(".title-text") as HTMLElement;
const pinBtn = wrapper.querySelector(".pin-btn") as HTMLButtonElement;
const minimizeBtn = wrapper.querySelector(".minimize-btn") as HTMLButtonElement;
const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement;
const tagsContainer = wrapper.querySelector(".tags") as HTMLElement;
// Initialize
titleIcon.textContent = this.#icon;
titleText.textContent = this.#title;
this.primaryColor = this.getAttribute("color") || this.#primaryColor;
this.title = this.getAttribute("title") || this.#title;
this.icon = this.getAttribute("icon") || this.#icon;
// Parse tags from attribute
const tagsAttr = this.getAttribute("tags");
if (tagsAttr) {
this.#tags = tagsAttr.split(",").map((t) => t.trim()).filter(Boolean);
}
// Event handlers
pinBtn.addEventListener("click", (e) => {
e.stopPropagation();
this.#isPinned = !this.#isPinned;
pinBtn.classList.toggle("active", this.#isPinned);
this.dispatchEvent(new CustomEvent("pin-toggle", { detail: { pinned: this.#isPinned } }));
});
minimizeBtn.addEventListener("click", (e) => {
e.stopPropagation();
this.isMinimized = !this.isMinimized;
this.dispatchEvent(new CustomEvent("minimize-toggle", { detail: { minimized: this.#isMinimized } }));
});
closeBtn.addEventListener("click", (e) => {
e.stopPropagation();
this.dispatchEvent(new CustomEvent("close"));
});
// Render tags
this.#renderTags(tagsContainer);
// Watch for attribute changes
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === "attributes") {
switch (mutation.attributeName) {
case "title":
titleText.textContent = this.getAttribute("title") || "";
break;
case "icon":
titleIcon.textContent = this.getAttribute("icon") || "";
break;
case "color":
this.primaryColor = this.getAttribute("color") || "#14b8a6";
break;
case "tags":
const tagsAttr = this.getAttribute("tags");
if (tagsAttr) {
this.#tags = tagsAttr.split(",").map((t) => t.trim()).filter(Boolean);
this.#renderTags(tagsContainer);
}
break;
}
}
}
});
observer.observe(this, { attributes: true });
return root;
}
#renderTags(container: HTMLElement) {
container.innerHTML = "";
for (const tag of this.#tags.slice(0, 5)) {
const tagEl = document.createElement("span");
tagEl.className = "tag";
tagEl.innerHTML = `${tag.replace("#", "")} <span class="remove">×</span>`;
tagEl.querySelector(".remove")?.addEventListener("click", (e) => {
e.stopPropagation();
this.#tags = this.#tags.filter((t) => t !== tag);
this.#renderTags(container);
this.dispatchEvent(new CustomEvent("tags-change", { detail: { tags: this.#tags } }));
});
container.appendChild(tagEl);
}
if (this.#tags.length > 5) {
const moreTag = document.createElement("span");
moreTag.className = "tag";
moreTag.textContent = `+${this.#tags.length - 5}`;
container.appendChild(moreTag);
}
// Add tag button
if (this.#tags.length < 10) {
if (this.#isEditingTags) {
const input = document.createElement("input");
input.className = "tag-input";
input.placeholder = "Add tag...";
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
const value = input.value.trim().replace("#", "");
if (value && !this.#tags.includes(value)) {
this.#tags.push(value);
this.#renderTags(container);
this.dispatchEvent(new CustomEvent("tags-change", { detail: { tags: this.#tags } }));
}
this.#isEditingTags = false;
this.#renderTags(container);
} else if (e.key === "Escape") {
this.#isEditingTags = false;
this.#renderTags(container);
}
});
input.addEventListener("blur", () => {
const value = input.value.trim().replace("#", "");
if (value && !this.#tags.includes(value)) {
this.#tags.push(value);
this.dispatchEvent(new CustomEvent("tags-change", { detail: { tags: this.#tags } }));
}
this.#isEditingTags = false;
this.#renderTags(container);
});
container.appendChild(input);
setTimeout(() => input.focus(), 0);
} else {
const addBtn = document.createElement("button");
addBtn.className = "add-tag";
addBtn.textContent = "+ Add";
addBtn.addEventListener("click", (e) => {
e.stopPropagation();
this.#isEditingTags = true;
this.#renderTags(container);
});
container.appendChild(addBtn);
}
}
}
addTag(tag: string) {
const cleanTag = tag.trim().replace("#", "");
if (cleanTag && !this.#tags.includes(cleanTag)) {
this.#tags.push(cleanTag);
const tagsContainer = this.shadowRoot?.querySelector(".tags") as HTMLElement;
if (tagsContainer) {
this.#renderTags(tagsContainer);
}
this.dispatchEvent(new CustomEvent("tags-change", { detail: { tags: this.#tags } }));
}
}
removeTag(tag: string) {
const cleanTag = tag.trim().replace("#", "");
this.#tags = this.#tags.filter((t) => t !== cleanTag);
const tagsContainer = this.shadowRoot?.querySelector(".tags") as HTMLElement;
if (tagsContainer) {
this.#renderTags(tagsContainer);
}
this.dispatchEvent(new CustomEvent("tags-change", { detail: { tags: this.#tags } }));
}
toJSON() {
return {
type: "folk-wrapper",
id: this.id,
x: this.x,
y: this.y,
width: this.width,
height: this.height,
rotation: this.rotation,
title: this.#title,
icon: this.#icon,
primaryColor: this.#primaryColor,
isMinimized: this.#isMinimized,
isPinned: this.#isPinned,
tags: this.#tags,
};
}
}