489 lines
12 KiB
TypeScript
489 lines
12 KiB
TypeScript
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,
|
||
};
|
||
}
|
||
}
|