Add FolkWrapper component for standardized card UI

- Port StandardizedWrapper from React to web component
- Header with title, icon, color theming
- Pin, minimize, close action buttons
- Tags footer with add/remove functionality
- Integrate into canvas with "Card" toolbar button
- Sync wrapper properties via Automerge CRDT

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-01-01 23:06:48 +01:00
parent d6042fcfe7
commit ddab22abc2
4 changed files with 573 additions and 3 deletions

View File

@ -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;
}
}
}
/**

488
lib/folk-wrapper.ts Normal file
View File

@ -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`
<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,
};
}
}

View File

@ -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";

View File

@ -125,7 +125,8 @@
overflow: hidden;
}
folk-markdown {
folk-markdown,
folk-wrapper {
position: absolute;
}
</style>
@ -137,7 +138,8 @@
</div>
<div id="toolbar">
<button id="add-markdown" title="Add Markdown Note">+ Add Note</button>
<button id="add-markdown" title="Add Markdown Note">📝 Note</button>
<button id="add-wrapper" title="Add Wrapped Card">🗂️ Card</button>
<button id="zoom-in" title="Zoom In">+</button>
<button id="zoom-out" title="Zoom Out">-</button>
<button id="reset-view" title="Reset View">Reset</button>
@ -151,11 +153,12 @@
<div id="canvas"></div>
<script type="module">
import { FolkShape, FolkMarkdown, CommunitySync } from "@lib";
import { FolkShape, FolkMarkdown, FolkWrapper, CommunitySync } from "@lib";
// Register custom elements
FolkShape.define();
FolkMarkdown.define();
FolkWrapper.define();
// Get community info from URL
const hostname = window.location.hostname;
@ -223,6 +226,15 @@
let shape;
switch (data.type) {
case "folk-wrapper":
shape = document.createElement("folk-wrapper");
if (data.title) shape.title = data.title;
if (data.icon) shape.icon = data.icon;
if (data.primaryColor) shape.primaryColor = data.primaryColor;
if (data.isMinimized) shape.isMinimized = data.isMinimized;
if (data.isPinned) shape.isPinned = data.isPinned;
if (data.tags) shape.tags = data.tags;
break;
case "folk-markdown":
default:
shape = document.createElement("folk-markdown");
@ -279,6 +291,34 @@
sync.registerShape(shape);
});
// Add wrapper card button
document.getElementById("add-wrapper").addEventListener("click", () => {
const id = `shape-${Date.now()}-${++shapeCounter}`;
const colors = ["#14b8a6", "#8b5cf6", "#f59e0b", "#ef4444", "#3b82f6", "#22c55e"];
const icons = ["📋", "💡", "📌", "🔗", "📁", "⭐"];
const shape = document.createElement("folk-wrapper");
shape.id = id;
shape.x = 100 + Math.random() * 200;
shape.y = 100 + Math.random() * 200;
shape.width = 320;
shape.height = 240;
shape.title = "New Card";
shape.icon = icons[Math.floor(Math.random() * icons.length)];
shape.primaryColor = colors[Math.floor(Math.random() * colors.length)];
// Add some placeholder content inside the wrapper
const content = document.createElement("div");
content.style.padding = "16px";
content.style.color = "#374151";
content.innerHTML = "<p>Click to edit this card...</p>";
shape.appendChild(content);
setupShapeEventListeners(shape);
canvas.appendChild(shape);
sync.registerShape(shape);
});
// Zoom controls
let scale = 1;
const minScale = 0.25;