feat: Add 4 FolkJS shape components (slide, chat, google-item, piano)
- folk-slide: Presentation slide container with dashed border - folk-chat: Real-time chat with username persistence - folk-google-item: Data card for Google services with visibility toggle - folk-piano: Chrome Music Lab Shared Piano iframe embed All components extend FolkShape, implement toJSON(), and support drag via data-drag attribute. Toolbar buttons added for each. Completes task-2: Phase 1 - Port Simple Shapes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
aa204a530a
commit
ff3a432c04
|
|
@ -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
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #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
|
||||
<!-- AC:END -->
|
||||
|
||||
## 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
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
<div class="header">
|
||||
<span class="header-title">
|
||||
<span>💬</span>
|
||||
<span>Chat</span>
|
||||
</span>
|
||||
<div class="header-actions">
|
||||
<button class="close-btn" title="Close">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-container">
|
||||
<div class="messages"></div>
|
||||
<div class="input-container">
|
||||
<input type="text" class="message-input" placeholder="Type a message..." />
|
||||
<button class="send-btn">Send</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="username-prompt" style="display: none;">
|
||||
<p>Enter your name to join the chat:</p>
|
||||
<input type="text" class="username-input" placeholder="Your name..." />
|
||||
<button class="username-btn">Join Chat</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 `
|
||||
<div class="message ${isOwn ? "own" : ""}">
|
||||
<div class="message-header">
|
||||
<span class="username">${msg.userName}</span>
|
||||
<span class="time">${time}</span>
|
||||
</div>
|
||||
<div class="message-content">${this.#escapeHtml(msg.content)}</div>
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
.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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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<GoogleService, string> = {
|
||||
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`
|
||||
<div class="card ${this.#visibility}" data-drag>
|
||||
<span class="visibility-badge" title="Toggle visibility">
|
||||
${this.#visibility === "local" ? "\u{1F512}" : "\u{1F310}"}
|
||||
</span>
|
||||
<div class="header">
|
||||
<span class="service-icon">${SERVICE_ICONS[this.#service]}</span>
|
||||
<span class="title">${this.#escapeHtml(this.#title)}</span>
|
||||
</div>
|
||||
${this.#preview ? `<div class="preview">${this.#escapeHtml(this.#preview)}</div>` : ""}
|
||||
<div class="date">${this.#formatDate(this.#date)}</div>
|
||||
${
|
||||
this.#thumbnailUrl && this.height > 100
|
||||
? `<img class="thumbnail" src="${this.#thumbnailUrl}" alt="" />`
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
|
||||
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",
|
||||
};
|
||||
}
|
||||
|
|
@ -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`
|
||||
<div class="piano-container" data-drag>
|
||||
<div class="loading">
|
||||
<span>\u{1F3B9}</span>
|
||||
<span>Loading Shared Piano...</span>
|
||||
</div>
|
||||
<div class="error hidden">
|
||||
<span>\u{1F3B9}</span>
|
||||
<span class="error-message">Failed to load piano</span>
|
||||
<button class="retry-btn">Retry</button>
|
||||
</div>
|
||||
<div class="minimized hidden">
|
||||
<span>\u{1F3B9} Shared Piano</span>
|
||||
</div>
|
||||
<iframe
|
||||
class="piano-iframe"
|
||||
src="${PIANO_URL}"
|
||||
allow="microphone; camera; midi; autoplay; encrypted-media; fullscreen"
|
||||
sandbox="allow-scripts allow-forms allow-popups allow-modals allow-same-origin"
|
||||
style="opacity: 0;"
|
||||
></iframe>
|
||||
<div class="controls">
|
||||
<button class="control-btn minimize-btn" title="Minimize">\u{1F53C}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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`
|
||||
<div class="slide-container" data-drag>
|
||||
<div class="slide-label">Slide 1</div>
|
||||
<div class="slide-content">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
<div id="toolbar">
|
||||
<button id="add-markdown" title="Add Markdown Note">📝 Note</button>
|
||||
<button id="add-wrapper" title="Add Wrapped Card">🗂️ Card</button>
|
||||
<button id="add-slide" title="Add Slide">🎞️ Slide</button>
|
||||
<button id="add-chat" title="Add Chat">💬 Chat</button>
|
||||
<button id="add-piano" title="Add Piano">🎹 Piano</button>
|
||||
<button id="add-arrow" title="Connect Shapes">🔗 Connect</button>
|
||||
<button id="zoom-in" title="Zoom In">+</button>
|
||||
<button id="zoom-out" title="Zoom Out">-</button>
|
||||
|
|
@ -172,13 +179,27 @@
|
|||
<div id="canvas"></div>
|
||||
|
||||
<script type="module">
|
||||
import { FolkShape, FolkMarkdown, FolkWrapper, FolkArrow, CommunitySync } from "@lib";
|
||||
import {
|
||||
FolkShape,
|
||||
FolkMarkdown,
|
||||
FolkWrapper,
|
||||
FolkArrow,
|
||||
FolkSlide,
|
||||
FolkChat,
|
||||
FolkGoogleItem,
|
||||
FolkPiano,
|
||||
CommunitySync
|
||||
} from "@lib";
|
||||
|
||||
// Register custom elements
|
||||
FolkShape.define();
|
||||
FolkMarkdown.define();
|
||||
FolkWrapper.define();
|
||||
FolkArrow.define();
|
||||
FolkSlide.define();
|
||||
FolkChat.define();
|
||||
FolkGoogleItem.define();
|
||||
FolkPiano.define();
|
||||
|
||||
// Get community info from URL
|
||||
const hostname = window.location.hostname;
|
||||
|
|
@ -263,6 +284,28 @@
|
|||
if (data.isPinned) shape.isPinned = data.isPinned;
|
||||
if (data.tags) shape.tags = data.tags;
|
||||
break;
|
||||
case "folk-slide":
|
||||
shape = document.createElement("folk-slide");
|
||||
if (data.label) shape.label = data.label;
|
||||
break;
|
||||
case "folk-chat":
|
||||
shape = document.createElement("folk-chat");
|
||||
if (data.roomId) shape.roomId = data.roomId;
|
||||
break;
|
||||
case "folk-google-item":
|
||||
shape = document.createElement("folk-google-item");
|
||||
if (data.itemId) shape.itemId = data.itemId;
|
||||
if (data.service) shape.service = data.service;
|
||||
if (data.title) shape.title = data.title;
|
||||
if (data.preview) shape.preview = data.preview;
|
||||
if (data.date) shape.date = data.date;
|
||||
if (data.thumbnailUrl) shape.thumbnailUrl = data.thumbnailUrl;
|
||||
if (data.visibility) shape.visibility = data.visibility;
|
||||
break;
|
||||
case "folk-piano":
|
||||
shape = document.createElement("folk-piano");
|
||||
if (data.isMinimized) shape.isMinimized = data.isMinimized;
|
||||
break;
|
||||
case "folk-markdown":
|
||||
default:
|
||||
shape = document.createElement("folk-markdown");
|
||||
|
|
@ -347,6 +390,53 @@
|
|||
sync.registerShape(shape);
|
||||
});
|
||||
|
||||
// Add slide button
|
||||
document.getElementById("add-slide").addEventListener("click", () => {
|
||||
const id = `shape-${Date.now()}-${++shapeCounter}`;
|
||||
const shape = document.createElement("folk-slide");
|
||||
shape.id = id;
|
||||
shape.x = 100 + Math.random() * 200;
|
||||
shape.y = 100 + Math.random() * 200;
|
||||
shape.width = 720;
|
||||
shape.height = 480;
|
||||
shape.label = `Slide ${shapeCounter}`;
|
||||
|
||||
setupShapeEventListeners(shape);
|
||||
canvas.appendChild(shape);
|
||||
sync.registerShape(shape);
|
||||
});
|
||||
|
||||
// Add chat button
|
||||
document.getElementById("add-chat").addEventListener("click", () => {
|
||||
const id = `shape-${Date.now()}-${++shapeCounter}`;
|
||||
const shape = document.createElement("folk-chat");
|
||||
shape.id = id;
|
||||
shape.x = 100 + Math.random() * 200;
|
||||
shape.y = 100 + Math.random() * 200;
|
||||
shape.width = 400;
|
||||
shape.height = 500;
|
||||
shape.roomId = `room-${id}`;
|
||||
|
||||
setupShapeEventListeners(shape);
|
||||
canvas.appendChild(shape);
|
||||
sync.registerShape(shape);
|
||||
});
|
||||
|
||||
// Add piano button
|
||||
document.getElementById("add-piano").addEventListener("click", () => {
|
||||
const id = `shape-${Date.now()}-${++shapeCounter}`;
|
||||
const shape = document.createElement("folk-piano");
|
||||
shape.id = id;
|
||||
shape.x = 100 + Math.random() * 200;
|
||||
shape.y = 100 + Math.random() * 200;
|
||||
shape.width = 800;
|
||||
shape.height = 600;
|
||||
|
||||
setupShapeEventListeners(shape);
|
||||
canvas.appendChild(shape);
|
||||
sync.registerShape(shape);
|
||||
});
|
||||
|
||||
// Arrow connection mode
|
||||
let connectMode = false;
|
||||
let connectSource = null;
|
||||
|
|
|
|||
Loading…
Reference in New Issue