diff --git a/lib/community-sync.ts b/lib/community-sync.ts index 634abd7..f8d5a87 100644 --- a/lib/community-sync.ts +++ b/lib/community-sync.ts @@ -14,6 +14,13 @@ export interface ShapeData { // Arrow-specific sourceId?: string; targetId?: string; + // Wrapper-specific + title?: string; + icon?: string; + primaryColor?: string; + isMinimized?: boolean; + isPinned?: boolean; + tags?: string[]; } // Automerge document structure @@ -312,6 +319,17 @@ export class CommunitySync extends EventTarget { data.targetId = (shape as any).targetId; } + // Add wrapper properties + if (shape.tagName.toLowerCase() === "folk-wrapper") { + const wrapper = shape as any; + if (wrapper.title) data.title = wrapper.title; + if (wrapper.icon) data.icon = wrapper.icon; + if (wrapper.primaryColor) data.primaryColor = wrapper.primaryColor; + if (wrapper.isMinimized !== undefined) data.isMinimized = wrapper.isMinimized; + if (wrapper.isPinned !== undefined) data.isPinned = wrapper.isPinned; + if (wrapper.tags?.length) data.tags = wrapper.tags; + } + return data; } @@ -443,6 +461,29 @@ export class CommunitySync extends EventTarget { shapeWithContent.content = data.content; } } + + // Update wrapper-specific properties + if (data.type === "folk-wrapper") { + const wrapper = shape as any; + if (data.title !== undefined && wrapper.title !== data.title) { + wrapper.title = data.title; + } + if (data.icon !== undefined && wrapper.icon !== data.icon) { + wrapper.icon = data.icon; + } + if (data.primaryColor !== undefined && wrapper.primaryColor !== data.primaryColor) { + wrapper.primaryColor = data.primaryColor; + } + if (data.isMinimized !== undefined && wrapper.isMinimized !== data.isMinimized) { + wrapper.isMinimized = data.isMinimized; + } + if (data.isPinned !== undefined && wrapper.isPinned !== data.isPinned) { + wrapper.isPinned = data.isPinned; + } + if (data.tags !== undefined) { + wrapper.tags = data.tags; + } + } } /** diff --git a/lib/folk-wrapper.ts b/lib/folk-wrapper.ts new file mode 100644 index 0000000..b0205d1 --- /dev/null +++ b/lib/folk-wrapper.ts @@ -0,0 +1,488 @@ +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` +
+
+ + +
+
+ + + +
+
+
+ +
+
+ `; + + // 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("#", "")} ×`; + + 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, + }; + } +} diff --git a/lib/index.ts b/lib/index.ts index 0d1b5e4..2c8bb02 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -21,6 +21,7 @@ export * from "./tags"; // Components export * from "./folk-shape"; export * from "./folk-markdown"; +export * from "./folk-wrapper"; // Sync export * from "./community-sync"; diff --git a/website/canvas.html b/website/canvas.html index 2cf0d26..b6371d7 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -125,7 +125,8 @@ overflow: hidden; } - folk-markdown { + folk-markdown, + folk-wrapper { position: absolute; } @@ -137,7 +138,8 @@
- + + @@ -151,11 +153,12 @@