diff --git a/backlog/tasks/task-2 - Phase-1-FolkJS-Foundation-Port-Simple-Shapes.md b/backlog/tasks/task-2 - Phase-1-FolkJS-Foundation-Port-Simple-Shapes.md index 066b3f6..314847a 100644 --- a/backlog/tasks/task-2 - Phase-1-FolkJS-Foundation-Port-Simple-Shapes.md +++ b/backlog/tasks/task-2 - Phase-1-FolkJS-Foundation-Port-Simple-Shapes.md @@ -1,9 +1,10 @@ --- id: task-2 title: 'Phase 1: FolkJS Foundation - Port Simple Shapes' -status: To Do +status: Done assignee: [] created_date: '2026-01-02 14:42' +updated_date: '2026-01-02 19:00' labels: - foundation - migration @@ -30,8 +31,46 @@ Key simplifications vs tldraw: ## Acceptance Criteria -- [ ] #1 folk-slide component created -- [ ] #2 folk-chat component created -- [ ] #3 folk-google-item component created -- [ ] #4 folk-piano component created +- [x] #1 folk-slide component created +- [x] #2 folk-chat component created +- [x] #3 folk-google-item component created +- [x] #4 folk-piano component created + +## Notes + +### Implementation Complete + +Created 4 FolkJS web components: + +1. **folk-slide.ts** (`lib/folk-slide.ts`) + - Simple slide container with dashed border + - Label display (e.g., "Slide 1") + - Minimal implementation for presentation mode + +2. **folk-chat.ts** (`lib/folk-chat.ts`) + - Real-time chat with message list + - Username prompt with localStorage persistence + - Message input form with send button + - Orange header theme matching original + - Emits `message` events for sync integration + +3. **folk-google-item.ts** (`lib/folk-google-item.ts`) + - Data card for Gmail/Drive/Photos/Calendar items + - Visibility toggle (local/shared) + - Service icons and relative date formatting + - Dark mode support + - Helper function `createGoogleItemProps()` + +4. **folk-piano.ts** (`lib/folk-piano.ts`) + - Chrome Music Lab Shared Piano iframe embed + - Loading/error states with retry + - Minimize/expand toggle + - Sandboxed with audio/MIDI permissions + +All components: +- Extend FolkShape base class +- Use CSS-in-JS via template literals +- Support drag from header/container via `data-drag` +- Implement `toJSON()` for Automerge sync +- Registered in `canvas.html` with toolbar buttons diff --git a/lib/folk-chat.ts b/lib/folk-chat.ts new file mode 100644 index 0000000..40a622a --- /dev/null +++ b/lib/folk-chat.ts @@ -0,0 +1,360 @@ +import { FolkShape } from "./folk-shape"; +import { css, html } from "./tags"; + +const styles = css` + :host { + background: white; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + min-width: 300px; + min-height: 400px; + } + + .header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: #f97316; + 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); + } + + .chat-container { + display: flex; + flex-direction: column; + height: calc(100% - 36px); + } + + .messages { + flex: 1; + overflow-y: auto; + padding: 12px; + display: flex; + flex-direction: column; + gap: 8px; + } + + .message { + background: #f1f5f9; + border-radius: 8px; + padding: 8px 12px; + max-width: 85%; + } + + .message.own { + background: #f97316; + color: white; + align-self: flex-end; + } + + .message-header { + display: flex; + justify-content: space-between; + font-size: 11px; + opacity: 0.7; + margin-bottom: 4px; + } + + .message-content { + font-size: 14px; + line-height: 1.4; + } + + .input-container { + display: flex; + gap: 8px; + padding: 12px; + border-top: 1px solid #e2e8f0; + } + + .message-input { + flex: 1; + border: 1px solid #e2e8f0; + border-radius: 6px; + padding: 8px 12px; + font-size: 14px; + outline: none; + } + + .message-input:focus { + border-color: #f97316; + } + + .send-btn { + background: #f97316; + color: white; + border: none; + border-radius: 6px; + padding: 8px 16px; + cursor: pointer; + font-weight: 500; + } + + .send-btn:hover { + background: #ea580c; + } + + .username-prompt { + padding: 12px; + text-align: center; + } + + .username-input { + border: 1px solid #e2e8f0; + border-radius: 6px; + padding: 8px 12px; + font-size: 14px; + outline: none; + margin-bottom: 8px; + width: 100%; + box-sizing: border-box; + } + + .username-btn { + background: #f97316; + color: white; + border: none; + border-radius: 6px; + padding: 8px 16px; + cursor: pointer; + font-weight: 500; + } +`; + +interface ChatMessage { + id: string; + userName: string; + content: string; + timestamp: number; +} + +declare global { + interface HTMLElementTagNameMap { + "folk-chat": FolkChat; + } +} + +export class FolkChat extends FolkShape { + static override tagName = "folk-chat"; + + 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; + } + + #roomId = "default-room"; + #userName = ""; + #messages: ChatMessage[] = []; + #messagesEl: HTMLElement | null = null; + + get roomId() { + return this.#roomId; + } + + set roomId(value: string) { + this.#roomId = value; + this.requestUpdate("roomId"); + } + + get userName() { + return this.#userName; + } + + set userName(value: string) { + this.#userName = value; + localStorage.setItem("folk-chat-username", value); + this.requestUpdate("userName"); + } + + get messages() { + return this.#messages; + } + + override createRenderRoot() { + const root = super.createRenderRoot(); + + // Try to restore username from localStorage + this.#userName = localStorage.getItem("folk-chat-username") || ""; + this.#roomId = this.getAttribute("room-id") || "default-room"; + + const wrapper = document.createElement("div"); + wrapper.innerHTML = html` +
+ + 💬 + Chat + +
+ +
+
+
+
+
+ + +
+
+ + `; + + const slot = root.querySelector("slot"); + if (slot?.parentElement) { + const parent = slot.parentElement; + const existingDiv = parent.querySelector("div"); + if (existingDiv) { + parent.replaceChild(wrapper, existingDiv); + } + } + + // Get element references + this.#messagesEl = wrapper.querySelector(".messages"); + const chatContainer = wrapper.querySelector(".chat-container") as HTMLElement; + const usernamePrompt = wrapper.querySelector(".username-prompt") as HTMLElement; + const messageInput = wrapper.querySelector(".message-input") as HTMLInputElement; + const sendBtn = wrapper.querySelector(".send-btn") as HTMLButtonElement; + const usernameInput = wrapper.querySelector(".username-input") as HTMLInputElement; + const usernameBtn = wrapper.querySelector(".username-btn") as HTMLButtonElement; + const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement; + + // Show username prompt if not set + if (!this.#userName) { + chatContainer.style.display = "none"; + usernamePrompt.style.display = "block"; + } + + // Username submit + const submitUsername = () => { + const name = usernameInput.value.trim(); + if (name) { + this.userName = name; + chatContainer.style.display = "flex"; + usernamePrompt.style.display = "none"; + } + }; + + usernameBtn.addEventListener("click", submitUsername); + usernameInput.addEventListener("keydown", (e) => { + if (e.key === "Enter") submitUsername(); + }); + + // Send message + const sendMessage = () => { + const content = messageInput.value.trim(); + if (content && this.#userName) { + this.addMessage({ + id: crypto.randomUUID(), + userName: this.#userName, + content, + timestamp: Date.now(), + }); + messageInput.value = ""; + } + }; + + sendBtn.addEventListener("click", sendMessage); + messageInput.addEventListener("keydown", (e) => { + if (e.key === "Enter") sendMessage(); + }); + + // Close button + closeBtn.addEventListener("click", (e) => { + e.stopPropagation(); + this.dispatchEvent(new CustomEvent("close")); + }); + + return root; + } + + addMessage(message: ChatMessage) { + this.#messages.push(message); + this.#renderMessages(); + this.dispatchEvent( + new CustomEvent("message", { + detail: { message }, + bubbles: true, + }) + ); + } + + #renderMessages() { + if (!this.#messagesEl) return; + + this.#messagesEl.innerHTML = this.#messages + .map((msg) => { + const isOwn = msg.userName === this.#userName; + const time = new Date(msg.timestamp).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }); + return ` +
+
+ ${msg.userName} + ${time} +
+
${this.#escapeHtml(msg.content)}
+
+ `; + }) + .join(""); + + // Scroll to bottom + this.#messagesEl.scrollTop = this.#messagesEl.scrollHeight; + } + + #escapeHtml(text: string): string { + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; + } + + override toJSON() { + return { + ...super.toJSON(), + type: "folk-chat", + roomId: this.roomId, + messages: this.messages, + }; + } +} diff --git a/lib/folk-google-item.ts b/lib/folk-google-item.ts new file mode 100644 index 0000000..3cea812 --- /dev/null +++ b/lib/folk-google-item.ts @@ -0,0 +1,321 @@ +import { FolkShape } from "./folk-shape"; +import { css, html } from "./tags"; + +export type GoogleService = "gmail" | "drive" | "photos" | "calendar"; +export type ItemVisibility = "local" | "shared"; + +const SERVICE_ICONS: Record = { + gmail: "\u{1F4E7}", + drive: "\u{1F4C1}", + photos: "\u{1F4F7}", + calendar: "\u{1F4C5}", +}; + +const styles = css` + :host { + background: white; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + min-width: 180px; + min-height: 60px; + } + + .card { + width: 100%; + height: 100%; + padding: 12px; + box-sizing: border-box; + position: relative; + cursor: move; + } + + .card.local { + border: 2px dashed #6366f1; + } + + .card.shared { + border: 2px solid #22c55e; + } + + .visibility-badge { + position: absolute; + top: 8px; + right: 8px; + font-size: 12px; + cursor: pointer; + } + + .visibility-badge:hover { + transform: scale(1.2); + } + + .header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; + } + + .service-icon { + font-size: 20px; + } + + .title { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + font-size: 14px; + font-weight: 600; + color: #1e293b; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + } + + .preview { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + font-size: 12px; + color: #64748b; + line-height: 1.4; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + } + + .date { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + font-size: 11px; + color: #94a3b8; + margin-top: 8px; + } + + .thumbnail { + width: 100%; + height: 80px; + object-fit: cover; + border-radius: 4px; + margin-top: 8px; + } + + /* Dark mode support */ + @media (prefers-color-scheme: dark) { + :host { + background: #1e293b; + } + .title { + color: #f1f5f9; + } + .preview { + color: #94a3b8; + } + } +`; + +declare global { + interface HTMLElementTagNameMap { + "folk-google-item": FolkGoogleItem; + } +} + +export class FolkGoogleItem extends FolkShape { + static override tagName = "folk-google-item"; + + 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; + } + + #itemId = ""; + #service: GoogleService = "drive"; + #title = "Untitled"; + #preview = ""; + #date: number = Date.now(); + #thumbnailUrl = ""; + #visibility: ItemVisibility = "local"; + + get itemId() { + return this.#itemId; + } + set itemId(value: string) { + this.#itemId = value; + this.requestUpdate("itemId"); + } + + get service() { + return this.#service; + } + set service(value: GoogleService) { + this.#service = value; + this.requestUpdate("service"); + } + + get title() { + return this.#title; + } + set title(value: string) { + this.#title = value; + this.requestUpdate("title"); + } + + get preview() { + return this.#preview; + } + set preview(value: string) { + this.#preview = value; + this.requestUpdate("preview"); + } + + get date() { + return this.#date; + } + set date(value: number) { + this.#date = value; + this.requestUpdate("date"); + } + + get thumbnailUrl() { + return this.#thumbnailUrl; + } + set thumbnailUrl(value: string) { + this.#thumbnailUrl = value; + this.requestUpdate("thumbnailUrl"); + } + + get visibility() { + return this.#visibility; + } + set visibility(value: ItemVisibility) { + this.#visibility = value; + this.requestUpdate("visibility"); + } + + override createRenderRoot() { + const root = super.createRenderRoot(); + + // Read attributes + this.#itemId = this.getAttribute("item-id") || ""; + this.#service = (this.getAttribute("service") as GoogleService) || "drive"; + this.#title = this.getAttribute("title") || "Untitled"; + this.#preview = this.getAttribute("preview") || ""; + this.#date = parseInt(this.getAttribute("date") || String(Date.now()), 10); + this.#thumbnailUrl = this.getAttribute("thumbnail-url") || ""; + this.#visibility = (this.getAttribute("visibility") as ItemVisibility) || "local"; + + const wrapper = document.createElement("div"); + wrapper.innerHTML = html` +
+ + ${this.#visibility === "local" ? "\u{1F512}" : "\u{1F310}"} + +
+ ${SERVICE_ICONS[this.#service]} + ${this.#escapeHtml(this.#title)} +
+ ${this.#preview ? `
${this.#escapeHtml(this.#preview)}
` : ""} +
${this.#formatDate(this.#date)}
+ ${ + this.#thumbnailUrl && this.height > 100 + ? `` + : "" + } +
+ `; + + const slot = root.querySelector("slot"); + if (slot?.parentElement) { + const parent = slot.parentElement; + const existingDiv = parent.querySelector("div"); + if (existingDiv) { + parent.replaceChild(wrapper.querySelector(".card")!, existingDiv); + } + } + + // Toggle visibility on badge click + const badge = root.querySelector(".visibility-badge") as HTMLElement; + const card = root.querySelector(".card") as HTMLElement; + + badge?.addEventListener("click", (e) => { + e.stopPropagation(); + this.#visibility = this.#visibility === "local" ? "shared" : "local"; + + badge.textContent = this.#visibility === "local" ? "\u{1F512}" : "\u{1F310}"; + card.classList.remove("local", "shared"); + card.classList.add(this.#visibility); + + this.dispatchEvent( + new CustomEvent("visibility-change", { + detail: { visibility: this.#visibility, itemId: this.#itemId }, + bubbles: true, + }) + ); + }); + + return root; + } + + #escapeHtml(text: string): string { + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; + } + + #formatDate(timestamp: number): string { + const now = new Date(); + const date = new Date(timestamp); + const diffDays = Math.floor( + (now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24) + ); + + if (diffDays === 0) return "Today"; + if (diffDays === 1) return "Yesterday"; + if (diffDays < 7) return `${diffDays}d ago`; + return date.toLocaleDateString(); + } + + override toJSON() { + return { + ...super.toJSON(), + type: "folk-google-item", + itemId: this.itemId, + service: this.service, + title: this.title, + preview: this.preview, + date: this.date, + thumbnailUrl: this.thumbnailUrl, + visibility: this.visibility, + }; + } +} + +/** + * Helper to create Google item props + */ +export function createGoogleItemProps( + service: GoogleService, + title: string, + options: Partial<{ + itemId: string; + preview: string; + date: number; + thumbnailUrl: string; + visibility: ItemVisibility; + }> = {} +) { + return { + service, + title, + itemId: options.itemId || crypto.randomUUID(), + preview: options.preview || "", + date: options.date || Date.now(), + thumbnailUrl: options.thumbnailUrl || "", + visibility: options.visibility || "local", + }; +} diff --git a/lib/folk-piano.ts b/lib/folk-piano.ts new file mode 100644 index 0000000..09223b5 --- /dev/null +++ b/lib/folk-piano.ts @@ -0,0 +1,291 @@ +import { FolkShape } from "./folk-shape"; +import { css, html } from "./tags"; + +const PIANO_URL = "https://musiclab.chromeexperiments.com/Shared-Piano/"; + +const styles = css` + :host { + background: #1e1e2e; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); + min-width: 400px; + min-height: 300px; + overflow: hidden; + } + + .piano-container { + width: 100%; + height: 100%; + position: relative; + } + + .piano-iframe { + width: 100%; + height: 100%; + border: none; + } + + .loading { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, #1e1e2e 0%, #2d2d44 100%); + color: white; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + font-size: 18px; + gap: 12px; + } + + .loading.hidden { + display: none; + } + + .error { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, #1e1e2e 0%, #2d2d44 100%); + color: white; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + gap: 12px; + } + + .error.hidden { + display: none; + } + + .error-message { + font-size: 14px; + color: #f87171; + } + + .retry-btn { + background: #6366f1; + color: white; + border: none; + border-radius: 6px; + padding: 8px 16px; + cursor: pointer; + font-size: 14px; + } + + .retry-btn:hover { + background: #4f46e5; + } + + .controls { + position: absolute; + top: 8px; + right: 8px; + display: flex; + gap: 4px; + } + + .control-btn { + background: rgba(0, 0, 0, 0.5); + color: white; + border: none; + border-radius: 4px; + padding: 4px 8px; + cursor: pointer; + font-size: 14px; + } + + .control-btn:hover { + background: rgba(0, 0, 0, 0.7); + } + + .minimized { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, #1e1e2e 0%, #2d2d44 100%); + color: white; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + font-size: 24px; + cursor: pointer; + } + + .minimized.hidden { + display: none; + } +`; + +declare global { + interface HTMLElementTagNameMap { + "folk-piano": FolkPiano; + } +} + +export class FolkPiano extends FolkShape { + static override tagName = "folk-piano"; + + 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; + } + + #isMinimized = false; + #isLoading = true; + #hasError = false; + #iframe: HTMLIFrameElement | null = null; + #loadingEl: HTMLElement | null = null; + #errorEl: HTMLElement | null = null; + #minimizedEl: HTMLElement | null = null; + #containerEl: HTMLElement | null = null; + + get isMinimized() { + return this.#isMinimized; + } + + set isMinimized(value: boolean) { + this.#isMinimized = value; + this.#updateVisibility(); + this.requestUpdate("isMinimized"); + } + + override createRenderRoot() { + const root = super.createRenderRoot(); + + const wrapper = document.createElement("div"); + wrapper.innerHTML = html` +
+
+ \u{1F3B9} + Loading Shared Piano... +
+ + + +
+ +
+
+ `; + + const slot = root.querySelector("slot"); + if (slot?.parentElement) { + const parent = slot.parentElement; + const existingDiv = parent.querySelector("div"); + if (existingDiv) { + parent.replaceChild(wrapper.querySelector(".piano-container")!, existingDiv); + } + } + + // Get references + this.#containerEl = root.querySelector(".piano-container"); + this.#loadingEl = root.querySelector(".loading"); + this.#errorEl = root.querySelector(".error"); + this.#minimizedEl = root.querySelector(".minimized"); + this.#iframe = root.querySelector(".piano-iframe"); + const minimizeBtn = root.querySelector(".minimize-btn") as HTMLButtonElement; + const retryBtn = root.querySelector(".retry-btn") as HTMLButtonElement; + + // Iframe load handling + this.#iframe?.addEventListener("load", () => { + this.#isLoading = false; + this.#hasError = false; + if (this.#loadingEl) this.#loadingEl.classList.add("hidden"); + if (this.#iframe) this.#iframe.style.opacity = "1"; + }); + + this.#iframe?.addEventListener("error", () => { + this.#isLoading = false; + this.#hasError = true; + if (this.#loadingEl) this.#loadingEl.classList.add("hidden"); + if (this.#errorEl) this.#errorEl.classList.remove("hidden"); + }); + + // Minimize toggle + minimizeBtn?.addEventListener("click", (e) => { + e.stopPropagation(); + this.isMinimized = !this.#isMinimized; + minimizeBtn.textContent = this.#isMinimized ? "\u{1F53D}" : "\u{1F53C}"; + }); + + // Click minimized view to expand + this.#minimizedEl?.addEventListener("click", (e) => { + e.stopPropagation(); + this.isMinimized = false; + if (minimizeBtn) minimizeBtn.textContent = "\u{1F53C}"; + }); + + // Retry button + retryBtn?.addEventListener("click", (e) => { + e.stopPropagation(); + this.#retry(); + }); + + // Suppress Chrome Music Lab console errors + window.addEventListener("error", (e) => { + if (e.message?.includes("musiclab") || e.filename?.includes("musiclab")) { + e.preventDefault(); + } + }); + + return root; + } + + #updateVisibility() { + if (!this.#iframe || !this.#minimizedEl) return; + + if (this.#isMinimized) { + this.#iframe.style.display = "none"; + this.#minimizedEl.classList.remove("hidden"); + } else { + this.#iframe.style.display = "block"; + this.#minimizedEl.classList.add("hidden"); + } + } + + #retry() { + if (!this.#iframe || !this.#errorEl || !this.#loadingEl) return; + + this.#hasError = false; + this.#isLoading = true; + this.#errorEl.classList.add("hidden"); + this.#loadingEl.classList.remove("hidden"); + this.#iframe.style.opacity = "0"; + this.#iframe.src = PIANO_URL; + } + + override toJSON() { + return { + ...super.toJSON(), + type: "folk-piano", + isMinimized: this.isMinimized, + }; + } +} diff --git a/lib/folk-slide.ts b/lib/folk-slide.ts new file mode 100644 index 0000000..aff03b2 --- /dev/null +++ b/lib/folk-slide.ts @@ -0,0 +1,116 @@ +import { FolkShape } from "./folk-shape"; +import { css, html } from "./tags"; + +const styles = css` + :host { + background: transparent; + min-width: 200px; + min-height: 150px; + } + + .slide-container { + width: 100%; + height: 100%; + position: relative; + border: 2px dashed #94a3b8; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + } + + .slide-label { + position: absolute; + top: 8px; + left: 12px; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + font-size: 14px; + font-weight: 600; + color: #64748b; + background: white; + padding: 2px 8px; + border-radius: 4px; + } + + .slide-content { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + color: #94a3b8; + font-size: 48px; + } +`; + +declare global { + interface HTMLElementTagNameMap { + "folk-slide": FolkSlide; + } +} + +export class FolkSlide extends FolkShape { + static override tagName = "folk-slide"; + + 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; + } + + #label = "Slide 1"; + + get label() { + return this.#label; + } + + set label(value: string) { + this.#label = value; + this.requestUpdate("label"); + } + + override createRenderRoot() { + const root = super.createRenderRoot(); + + const wrapper = document.createElement("div"); + wrapper.innerHTML = html` +
+
Slide 1
+
+ +
+
+ `; + + const slot = root.querySelector("slot"); + if (slot?.parentElement) { + slot.parentElement.replaceChild( + wrapper.querySelector(".slide-container")!, + slot.parentElement.querySelector("div")! + ); + } + + // Update label from attribute + this.#label = this.getAttribute("label") || "Slide 1"; + const labelEl = root.querySelector(".slide-label") as HTMLElement; + if (labelEl) { + labelEl.textContent = this.#label; + } + + return root; + } + + override toJSON() { + return { + ...super.toJSON(), + type: "folk-slide", + label: this.label, + }; + } +} diff --git a/lib/index.ts b/lib/index.ts index 9e17ac4..13a5615 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -25,6 +25,10 @@ export * from "./folk-shape"; export * from "./folk-markdown"; export * from "./folk-wrapper"; export * from "./folk-arrow"; +export * from "./folk-slide"; +export * from "./folk-chat"; +export * from "./folk-google-item"; +export * from "./folk-piano"; // Sync export * from "./community-sync"; diff --git a/website/canvas.html b/website/canvas.html index 7415711..3ca6c5f 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -128,7 +128,11 @@ folk-markdown, folk-wrapper, - folk-arrow { + folk-arrow, + folk-slide, + folk-chat, + folk-google-item, + folk-piano { position: absolute; } @@ -158,6 +162,9 @@
+ + + @@ -172,13 +179,27 @@