feat: implement FUN model replacing CRUD across rSpace canvas
Forget (F): Soft-delete shapes — close button sets forgotten:true in Automerge doc instead of removing. Memory panel (toolbar toggle) lets users browse and Remember forgotten shapes. Server-side forgetShape() and rememberShape() with WebSocket handlers. Update (U): New public updateShape(id, fields) method on CommunitySync for programmatic field updates. Existing auto-capture unchanged. New (N): Renamed all create/add vocabulary to new — toolbar buttons, event names (new-shape, shape-new, shape-removed), internal functions (newShape, newShapeElement). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
33f0ef4077
commit
048171131b
|
|
@ -428,27 +428,93 @@ export class CommunitySync extends EventTarget {
|
|||
}
|
||||
|
||||
/**
|
||||
* Delete a shape from the document
|
||||
* Delete a shape from the document (hard delete — use forgetShape instead)
|
||||
*/
|
||||
deleteShape(shapeId: string): void {
|
||||
this.#doc = Automerge.change(this.#doc, `Delete shape ${shapeId}`, (doc) => {
|
||||
this.forgetShape(shapeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* FUN: Update — explicitly update specific fields of a shape.
|
||||
* Use this for programmatic updates (API calls, module callbacks).
|
||||
* Shape transform/content changes are auto-captured via registerShape().
|
||||
*/
|
||||
updateShape(shapeId: string, fields: Record<string, unknown>): void {
|
||||
const existing = this.#doc.shapes?.[shapeId];
|
||||
if (!existing) return;
|
||||
|
||||
this.#doc = Automerge.change(this.#doc, `Update shape ${shapeId}`, (doc) => {
|
||||
if (doc.shapes && doc.shapes[shapeId]) {
|
||||
delete doc.shapes[shapeId];
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
(doc.shapes[shapeId] as Record<string, unknown>)[key] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.#shapes.delete(shapeId);
|
||||
// Sync the updated shape to DOM and server
|
||||
const shape = this.#shapes.get(shapeId);
|
||||
if (shape) {
|
||||
this.#updateShapeElement(shape, this.#doc.shapes[shapeId]);
|
||||
}
|
||||
this.#scheduleSave();
|
||||
this.#syncToServer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply full document to DOM (for initial load)
|
||||
* Forget a shape — soft-delete. Shape stays in the doc but is hidden.
|
||||
*/
|
||||
forgetShape(shapeId: string): void {
|
||||
this.#doc = Automerge.change(this.#doc, `Forget shape ${shapeId}`, (doc) => {
|
||||
if (doc.shapes && doc.shapes[shapeId]) {
|
||||
(doc.shapes[shapeId] as Record<string, unknown>).forgotten = true;
|
||||
(doc.shapes[shapeId] as Record<string, unknown>).forgottenAt = Date.now();
|
||||
}
|
||||
});
|
||||
|
||||
// Remove from visible DOM
|
||||
this.#removeShapeFromDOM(shapeId);
|
||||
this.#scheduleSave();
|
||||
this.#syncToServer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remember a forgotten shape — restore it to the canvas.
|
||||
*/
|
||||
rememberShape(shapeId: string): void {
|
||||
const shapeData = this.#doc.shapes?.[shapeId];
|
||||
if (!shapeData) return;
|
||||
|
||||
this.#doc = Automerge.change(this.#doc, `Remember shape ${shapeId}`, (doc) => {
|
||||
if (doc.shapes && doc.shapes[shapeId]) {
|
||||
(doc.shapes[shapeId] as Record<string, unknown>).forgotten = false;
|
||||
(doc.shapes[shapeId] as Record<string, unknown>).forgottenAt = 0;
|
||||
(doc.shapes[shapeId] as Record<string, unknown>).forgottenBy = '';
|
||||
}
|
||||
});
|
||||
|
||||
// Re-add to DOM
|
||||
this.#applyShapeToDOM(this.#doc.shapes[shapeId]);
|
||||
this.#scheduleSave();
|
||||
this.#syncToServer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all forgotten shapes (for the memory layer UI).
|
||||
*/
|
||||
getForgottenShapes(): ShapeData[] {
|
||||
const shapes = this.#doc.shapes || {};
|
||||
return Object.values(shapes).filter(s => s.forgotten);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply full document to DOM (for initial load).
|
||||
* Skips forgotten shapes — they live in the doc but are hidden from view.
|
||||
*/
|
||||
#applyDocToDOM(): void {
|
||||
const shapes = this.#doc.shapes || {};
|
||||
|
||||
for (const [id, shapeData] of Object.entries(shapes)) {
|
||||
if (shapeData.forgotten) continue; // FUN: forgotten shapes stay in doc, hidden from canvas
|
||||
this.#applyShapeToDOM(shapeData);
|
||||
}
|
||||
|
||||
|
|
@ -456,7 +522,9 @@ export class CommunitySync extends EventTarget {
|
|||
}
|
||||
|
||||
/**
|
||||
* Apply Automerge patches to DOM
|
||||
* Apply Automerge patches to DOM.
|
||||
* Handles forgotten state: when a shape becomes forgotten, remove it from
|
||||
* the visible canvas; when remembered, re-add it.
|
||||
*/
|
||||
#applyPatchesToDOM(patches: Automerge.Patch[]): void {
|
||||
for (const patch of patches) {
|
||||
|
|
@ -468,13 +536,18 @@ export class CommunitySync extends EventTarget {
|
|||
const shapeData = this.#doc.shapes?.[shapeId];
|
||||
|
||||
if (patch.action === "del" && path.length === 2) {
|
||||
// Shape deleted
|
||||
// Shape hard-deleted
|
||||
this.#removeShapeFromDOM(shapeId);
|
||||
} else if (shapeData) {
|
||||
// Shape created or updated
|
||||
this.#applyShapeToDOM(shapeData);
|
||||
// Broadcast to parent frame
|
||||
this.#postMessageToParent("shape-updated", shapeData);
|
||||
// FUN: if shape was just forgotten, remove from DOM
|
||||
if (shapeData.forgotten) {
|
||||
this.#removeShapeFromDOM(shapeId);
|
||||
this.dispatchEvent(new CustomEvent("shape-forgotten", { detail: { shapeId, data: shapeData } }));
|
||||
} else {
|
||||
// Shape created, updated, or remembered — render it
|
||||
this.#applyShapeToDOM(shapeData);
|
||||
this.#postMessageToParent("shape-updated", shapeData);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -487,11 +560,11 @@ export class CommunitySync extends EventTarget {
|
|||
let shape = this.#shapes.get(shapeData.id);
|
||||
|
||||
if (!shape) {
|
||||
// Create new shape element
|
||||
shape = this.#createShapeElement(shapeData);
|
||||
// FUN: New — instantiate shape element
|
||||
shape = this.#newShapeElement(shapeData);
|
||||
if (shape) {
|
||||
this.#shapes.set(shapeData.id, shape);
|
||||
this.dispatchEvent(new CustomEvent("shape-created", { detail: { shape, data: shapeData } }));
|
||||
this.dispatchEvent(new CustomEvent("shape-new", { detail: { shape, data: shapeData } }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
@ -501,11 +574,10 @@ export class CommunitySync extends EventTarget {
|
|||
}
|
||||
|
||||
/**
|
||||
* Create a new shape element from data
|
||||
* FUN: New — emit event for the canvas to instantiate a new shape from data
|
||||
*/
|
||||
#createShapeElement(data: ShapeData): FolkShape | undefined {
|
||||
// This will be handled by the canvas - emit event for canvas to create
|
||||
this.dispatchEvent(new CustomEvent("create-shape", { detail: data }));
|
||||
#newShapeElement(data: ShapeData): FolkShape | undefined {
|
||||
this.dispatchEvent(new CustomEvent("new-shape", { detail: data }));
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
|
@ -641,13 +713,13 @@ export class CommunitySync extends EventTarget {
|
|||
}
|
||||
|
||||
/**
|
||||
* Remove shape from DOM
|
||||
* FUN: Forget — remove shape from visible DOM (shape remains in Automerge doc)
|
||||
*/
|
||||
#removeShapeFromDOM(shapeId: string): void {
|
||||
const shape = this.#shapes.get(shapeId);
|
||||
if (shape) {
|
||||
this.#shapes.delete(shapeId);
|
||||
this.dispatchEvent(new CustomEvent("shape-deleted", { detail: { shapeId, shape } }));
|
||||
this.dispatchEvent(new CustomEvent("shape-removed", { detail: { shapeId, shape } }));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -436,6 +436,45 @@ export function deleteShape(slug: string, shapeId: string): void {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forget a shape — soft-delete. Shape stays in the doc but is hidden from view.
|
||||
* Can be restored with rememberShape().
|
||||
*/
|
||||
export function forgetShape(slug: string, shapeId: string, forgottenBy?: string): void {
|
||||
const doc = communities.get(slug);
|
||||
if (!doc || !doc.shapes?.[shapeId]) return;
|
||||
|
||||
const newDoc = Automerge.change(doc, `Forget shape ${shapeId}`, (d) => {
|
||||
if (d.shapes[shapeId]) {
|
||||
(d.shapes[shapeId] as Record<string, unknown>).forgotten = true;
|
||||
(d.shapes[shapeId] as Record<string, unknown>).forgottenAt = Date.now();
|
||||
if (forgottenBy) {
|
||||
(d.shapes[shapeId] as Record<string, unknown>).forgottenBy = forgottenBy;
|
||||
}
|
||||
}
|
||||
});
|
||||
communities.set(slug, newDoc);
|
||||
saveCommunity(slug);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remember a forgotten shape — restore it to the canvas.
|
||||
*/
|
||||
export function rememberShape(slug: string, shapeId: string): void {
|
||||
const doc = communities.get(slug);
|
||||
if (!doc || !doc.shapes?.[shapeId]) return;
|
||||
|
||||
const newDoc = Automerge.change(doc, `Remember shape ${shapeId}`, (d) => {
|
||||
if (d.shapes[shapeId]) {
|
||||
(d.shapes[shapeId] as Record<string, unknown>).forgotten = false;
|
||||
(d.shapes[shapeId] as Record<string, unknown>).forgottenAt = 0;
|
||||
(d.shapes[shapeId] as Record<string, unknown>).forgottenBy = '';
|
||||
}
|
||||
});
|
||||
communities.set(slug, newDoc);
|
||||
saveCommunity(slug);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update specific fields of a shape (for bidirectional module sync callbacks).
|
||||
* Only updates the fields provided, preserving all other shape data.
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import {
|
|||
communityExists,
|
||||
createCommunity,
|
||||
deleteShape,
|
||||
forgetShape,
|
||||
rememberShape,
|
||||
generateSyncMessageForPeer,
|
||||
getDocumentData,
|
||||
loadCommunity,
|
||||
|
|
@ -393,10 +395,25 @@ const server = Bun.serve<WSData>({
|
|||
ws.send(JSON.stringify({ type: "error", message: "Authentication required to delete" }));
|
||||
return;
|
||||
}
|
||||
deleteShape(communitySlug, msg.id);
|
||||
// Broadcast JSON snapshot to other json-mode clients
|
||||
// FUN model: "delete" now means "forget" (soft-delete)
|
||||
forgetShape(communitySlug, msg.id, ws.data.claims?.sub);
|
||||
broadcastJsonSnapshot(communitySlug, peerId);
|
||||
broadcastAutomergeSync(communitySlug, peerId);
|
||||
} else if (msg.type === "forget" && msg.id) {
|
||||
if (ws.data.readOnly) {
|
||||
ws.send(JSON.stringify({ type: "error", message: "Authentication required to forget" }));
|
||||
return;
|
||||
}
|
||||
forgetShape(communitySlug, msg.id, ws.data.claims?.sub);
|
||||
broadcastJsonSnapshot(communitySlug, peerId);
|
||||
broadcastAutomergeSync(communitySlug, peerId);
|
||||
} else if (msg.type === "remember" && msg.id) {
|
||||
if (ws.data.readOnly) {
|
||||
ws.send(JSON.stringify({ type: "error", message: "Authentication required to remember" }));
|
||||
return;
|
||||
}
|
||||
rememberShape(communitySlug, msg.id);
|
||||
broadcastJsonSnapshot(communitySlug, peerId);
|
||||
// Broadcast Automerge sync to automerge-mode clients
|
||||
broadcastAutomergeSync(communitySlug, peerId);
|
||||
}
|
||||
} catch (e) {
|
||||
|
|
|
|||
|
|
@ -124,6 +124,119 @@
|
|||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
/* Memory layer panel */
|
||||
#memory-panel {
|
||||
position: fixed;
|
||||
top: 72px;
|
||||
right: 16px;
|
||||
width: 300px;
|
||||
max-height: calc(100vh - 120px);
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.18);
|
||||
z-index: 1001;
|
||||
display: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#memory-panel.open {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#memory-panel-header {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#memory-panel-header h3 {
|
||||
font-size: 14px;
|
||||
color: #0f172a;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#memory-panel-header .count {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
background: #f1f5f9;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
#memory-list {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
#memory-list:empty::after {
|
||||
content: "Nothing forgotten yet";
|
||||
display: block;
|
||||
text-align: center;
|
||||
padding: 24px 16px;
|
||||
color: #94a3b8;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.memory-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.memory-item:hover {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.memory-item .icon {
|
||||
font-size: 18px;
|
||||
width: 28px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.memory-item .info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.memory-item .info .name {
|
||||
font-size: 13px;
|
||||
color: #1e293b;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.memory-item .info .meta {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.memory-item .remember-btn {
|
||||
padding: 4px 10px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: #14b8a6;
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.memory-item .remember-btn:hover {
|
||||
background: #0d9488;
|
||||
}
|
||||
|
||||
#canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
|
@ -222,38 +335,47 @@
|
|||
</div>
|
||||
|
||||
<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-embed" title="Add Web Embed">🔗 Embed</button>
|
||||
<button id="add-google-item" title="Add Google Item">📎 Google</button>
|
||||
<button id="add-calendar" title="Add Calendar">📅 Calendar</button>
|
||||
<button id="add-map" title="Add Map">🗺️ Map</button>
|
||||
<button id="add-image-gen" title="AI Image Generation">🎨 Image</button>
|
||||
<button id="add-video-gen" title="AI Video Generation">🎬 Video</button>
|
||||
<button id="add-prompt" title="AI Chat/Prompt">🤖 AI</button>
|
||||
<button id="add-transcription" title="Audio Transcription">🎤 Transcribe</button>
|
||||
<button id="add-video-chat" title="Video Call">📹 Call</button>
|
||||
<button id="add-obs-note" title="Rich Note">📓 Rich Note</button>
|
||||
<button id="add-workflow" title="Workflow Block">⚙️ Workflow</button>
|
||||
<button id="add-itinerary" title="Trip Itinerary">🗓️ Itinerary</button>
|
||||
<button id="add-destination" title="Trip Destination">📍 Destination</button>
|
||||
<button id="add-budget" title="Trip Budget">💰 Budget</button>
|
||||
<button id="add-packing-list" title="Packing List">🎒 Packing</button>
|
||||
<button id="add-booking" title="Trip Booking">✈️ Booking</button>
|
||||
<button id="add-token" title="Create Token">🪙 Token</button>
|
||||
<button id="add-choice-vote" title="Live Poll">☑ Poll</button>
|
||||
<button id="add-choice-rank" title="Rank Choices">📊 Rank</button>
|
||||
<button id="add-choice-spider" title="Score Matrix">🕸 Spider</button>
|
||||
<button id="add-social-post" title="Social Media Post">📱 Post</button>
|
||||
<button id="add-arrow" title="Connect Shapes">↗️ Connect</button>
|
||||
<button id="new-markdown" title="New Note">📝 Note</button>
|
||||
<button id="new-wrapper" title="New Card">🗂️ Card</button>
|
||||
<button id="new-slide" title="New Slide">🎞️ Slide</button>
|
||||
<button id="new-chat" title="New Chat">💬 Chat</button>
|
||||
<button id="new-piano" title="New Piano">🎹 Piano</button>
|
||||
<button id="new-embed" title="New Embed">🔗 Embed</button>
|
||||
<button id="new-google-item" title="New Google Item">📎 Google</button>
|
||||
<button id="new-calendar" title="New Calendar">📅 Calendar</button>
|
||||
<button id="new-map" title="New Map">🗺️ Map</button>
|
||||
<button id="new-image-gen" title="New AI Image">🎨 Image</button>
|
||||
<button id="new-video-gen" title="New AI Video">🎬 Video</button>
|
||||
<button id="new-prompt" title="New AI Chat">🤖 AI</button>
|
||||
<button id="new-transcription" title="New Transcription">🎤 Transcribe</button>
|
||||
<button id="new-video-chat" title="New Video Call">📹 Call</button>
|
||||
<button id="new-obs-note" title="New Rich Note">📓 Rich Note</button>
|
||||
<button id="new-workflow" title="New Workflow">⚙️ Workflow</button>
|
||||
<button id="new-itinerary" title="New Itinerary">🗓️ Itinerary</button>
|
||||
<button id="new-destination" title="New Destination">📍 Destination</button>
|
||||
<button id="new-budget" title="New Budget">💰 Budget</button>
|
||||
<button id="new-packing-list" title="New Packing List">🎒 Packing</button>
|
||||
<button id="new-booking" title="New Booking">✈️ Booking</button>
|
||||
<button id="new-token" title="New Token">🪙 Token</button>
|
||||
<button id="new-choice-vote" title="New Poll">☑ Poll</button>
|
||||
<button id="new-choice-rank" title="New Ranking">📊 Rank</button>
|
||||
<button id="new-choice-spider" title="New Scoring">🕸 Spider</button>
|
||||
<button id="new-social-post" title="New Post">📱 Post</button>
|
||||
<button id="new-arrow" title="Connect Shapes">↗️ Connect</button>
|
||||
<button id="toggle-memory" title="Forgotten shapes">💭 Memory</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>
|
||||
</div>
|
||||
|
||||
<div id="memory-panel">
|
||||
<div id="memory-panel-header">
|
||||
<h3>💭 Memory</h3>
|
||||
<span class="count" id="memory-count">0</span>
|
||||
</div>
|
||||
<div id="memory-list"></div>
|
||||
</div>
|
||||
|
||||
<div id="status" class="disconnected">
|
||||
<span class="indicator"></span>
|
||||
<span id="status-text">Connecting...</span>
|
||||
|
|
@ -463,8 +585,8 @@
|
|||
console.log("[Canvas] Initial sync complete:", e.detail.shapes);
|
||||
});
|
||||
|
||||
// Handle shape creation from remote
|
||||
sync.addEventListener("create-shape", (e) => {
|
||||
// FUN: New — handle new shape from remote sync
|
||||
sync.addEventListener("new-shape", (e) => {
|
||||
const data = e.detail;
|
||||
|
||||
// Check if shape already exists
|
||||
|
|
@ -474,7 +596,7 @@
|
|||
|
||||
try {
|
||||
isProcessingRemote = true;
|
||||
const shape = createShapeElement(data);
|
||||
const shape = newShapeElement(data);
|
||||
if (shape) {
|
||||
setupShapeEventListeners(shape);
|
||||
canvas.appendChild(shape);
|
||||
|
|
@ -487,8 +609,8 @@
|
|||
}
|
||||
});
|
||||
|
||||
// Handle shape deletion from remote
|
||||
sync.addEventListener("shape-deleted", (e) => {
|
||||
// FUN: Forget — handle shape removal from remote sync
|
||||
sync.addEventListener("shape-removed", (e) => {
|
||||
const { shapeId, shape } = e.detail;
|
||||
if (shape && shape.parentNode) {
|
||||
shape.remove();
|
||||
|
|
@ -496,7 +618,7 @@
|
|||
});
|
||||
|
||||
// Create a shape element from data
|
||||
function createShapeElement(data) {
|
||||
function newShapeElement(data) {
|
||||
let shape;
|
||||
|
||||
switch (data.type) {
|
||||
|
|
@ -765,7 +887,7 @@
|
|||
}
|
||||
|
||||
// Create a shape, position it at viewport center, add to canvas, and register for sync
|
||||
function createAndAddShape(tagName, props = {}) {
|
||||
function newShape(tagName, props = {}) {
|
||||
const id = `shape-${Date.now()}-${++shapeCounter}`;
|
||||
const defaults = SHAPE_DEFAULTS[tagName] || { width: 300, height: 200 };
|
||||
|
||||
|
|
@ -796,14 +918,14 @@
|
|||
}
|
||||
|
||||
// Toolbar button handlers
|
||||
document.getElementById("add-markdown").addEventListener("click", () => {
|
||||
createAndAddShape("folk-markdown", { content: "# New Note\n\nStart typing..." });
|
||||
document.getElementById("new-markdown").addEventListener("click", () => {
|
||||
newShape("folk-markdown", { content: "# New Note\n\nStart typing..." });
|
||||
});
|
||||
|
||||
document.getElementById("add-wrapper").addEventListener("click", () => {
|
||||
document.getElementById("new-wrapper").addEventListener("click", () => {
|
||||
const colors = ["#14b8a6", "#8b5cf6", "#f59e0b", "#ef4444", "#3b82f6", "#22c55e"];
|
||||
const icons = ["📋", "💡", "📌", "🔗", "📁", "⭐"];
|
||||
const shape = createAndAddShape("folk-wrapper", {
|
||||
const shape = newShape("folk-wrapper", {
|
||||
title: "New Card",
|
||||
icon: icons[Math.floor(Math.random() * icons.length)],
|
||||
primaryColor: colors[Math.floor(Math.random() * colors.length)],
|
||||
|
|
@ -817,40 +939,40 @@
|
|||
}
|
||||
});
|
||||
|
||||
document.getElementById("add-slide").addEventListener("click", () => {
|
||||
createAndAddShape("folk-slide", { label: `Slide ${shapeCounter}` });
|
||||
document.getElementById("new-slide").addEventListener("click", () => {
|
||||
newShape("folk-slide", { label: `Slide ${shapeCounter}` });
|
||||
});
|
||||
|
||||
document.getElementById("add-chat").addEventListener("click", () => {
|
||||
document.getElementById("new-chat").addEventListener("click", () => {
|
||||
const id = `shape-${Date.now()}-${shapeCounter}`;
|
||||
createAndAddShape("folk-chat", { roomId: `room-${id}` });
|
||||
newShape("folk-chat", { roomId: `room-${id}` });
|
||||
});
|
||||
|
||||
document.getElementById("add-piano").addEventListener("click", () => createAndAddShape("folk-piano"));
|
||||
document.getElementById("add-embed").addEventListener("click", () => createAndAddShape("folk-embed"));
|
||||
document.getElementById("add-calendar").addEventListener("click", () => createAndAddShape("folk-calendar"));
|
||||
document.getElementById("add-map").addEventListener("click", () => createAndAddShape("folk-map"));
|
||||
document.getElementById("add-image-gen").addEventListener("click", () => createAndAddShape("folk-image-gen"));
|
||||
document.getElementById("add-video-gen").addEventListener("click", () => createAndAddShape("folk-video-gen"));
|
||||
document.getElementById("add-prompt").addEventListener("click", () => createAndAddShape("folk-prompt"));
|
||||
document.getElementById("add-transcription").addEventListener("click", () => createAndAddShape("folk-transcription"));
|
||||
document.getElementById("add-video-chat").addEventListener("click", () => createAndAddShape("folk-video-chat"));
|
||||
document.getElementById("add-obs-note").addEventListener("click", () => createAndAddShape("folk-obs-note"));
|
||||
document.getElementById("add-workflow").addEventListener("click", () => createAndAddShape("folk-workflow-block"));
|
||||
document.getElementById("add-google-item").addEventListener("click", () => {
|
||||
createAndAddShape("folk-google-item", { service: "drive", title: "New Google Item" });
|
||||
document.getElementById("new-piano").addEventListener("click", () => newShape("folk-piano"));
|
||||
document.getElementById("new-embed").addEventListener("click", () => newShape("folk-embed"));
|
||||
document.getElementById("new-calendar").addEventListener("click", () => newShape("folk-calendar"));
|
||||
document.getElementById("new-map").addEventListener("click", () => newShape("folk-map"));
|
||||
document.getElementById("new-image-gen").addEventListener("click", () => newShape("folk-image-gen"));
|
||||
document.getElementById("new-video-gen").addEventListener("click", () => newShape("folk-video-gen"));
|
||||
document.getElementById("new-prompt").addEventListener("click", () => newShape("folk-prompt"));
|
||||
document.getElementById("new-transcription").addEventListener("click", () => newShape("folk-transcription"));
|
||||
document.getElementById("new-video-chat").addEventListener("click", () => newShape("folk-video-chat"));
|
||||
document.getElementById("new-obs-note").addEventListener("click", () => newShape("folk-obs-note"));
|
||||
document.getElementById("new-workflow").addEventListener("click", () => newShape("folk-workflow-block"));
|
||||
document.getElementById("new-google-item").addEventListener("click", () => {
|
||||
newShape("folk-google-item", { service: "drive", title: "New Google Item" });
|
||||
});
|
||||
|
||||
// Trip planning components
|
||||
document.getElementById("add-itinerary").addEventListener("click", () => createAndAddShape("folk-itinerary"));
|
||||
document.getElementById("add-destination").addEventListener("click", () => createAndAddShape("folk-destination"));
|
||||
document.getElementById("add-budget").addEventListener("click", () => createAndAddShape("folk-budget"));
|
||||
document.getElementById("add-packing-list").addEventListener("click", () => createAndAddShape("folk-packing-list"));
|
||||
document.getElementById("add-booking").addEventListener("click", () => createAndAddShape("folk-booking"));
|
||||
document.getElementById("new-itinerary").addEventListener("click", () => newShape("folk-itinerary"));
|
||||
document.getElementById("new-destination").addEventListener("click", () => newShape("folk-destination"));
|
||||
document.getElementById("new-budget").addEventListener("click", () => newShape("folk-budget"));
|
||||
document.getElementById("new-packing-list").addEventListener("click", () => newShape("folk-packing-list"));
|
||||
document.getElementById("new-booking").addEventListener("click", () => newShape("folk-booking"));
|
||||
|
||||
// Token creation - creates a mint + ledger pair with connecting arrow
|
||||
document.getElementById("add-token").addEventListener("click", () => {
|
||||
const mint = createAndAddShape("folk-token-mint", {
|
||||
document.getElementById("new-token").addEventListener("click", () => {
|
||||
const mint = newShape("folk-token-mint", {
|
||||
tokenName: "New Token",
|
||||
tokenSymbol: "TKN",
|
||||
totalSupply: 1000,
|
||||
|
|
@ -859,7 +981,7 @@
|
|||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
if (mint) {
|
||||
const ledger = createAndAddShape("folk-token-ledger", {
|
||||
const ledger = newShape("folk-token-ledger", {
|
||||
mintId: mint.id,
|
||||
entries: [],
|
||||
});
|
||||
|
|
@ -881,8 +1003,8 @@
|
|||
});
|
||||
|
||||
// Decision/choice components
|
||||
document.getElementById("add-choice-vote").addEventListener("click", () => {
|
||||
createAndAddShape("folk-choice-vote", {
|
||||
document.getElementById("new-choice-vote").addEventListener("click", () => {
|
||||
newShape("folk-choice-vote", {
|
||||
title: "Quick Poll",
|
||||
options: [
|
||||
{ id: "opt-1", label: "Option A", color: "#3b82f6" },
|
||||
|
|
@ -895,8 +1017,8 @@
|
|||
});
|
||||
});
|
||||
|
||||
document.getElementById("add-choice-rank").addEventListener("click", () => {
|
||||
createAndAddShape("folk-choice-rank", {
|
||||
document.getElementById("new-choice-rank").addEventListener("click", () => {
|
||||
newShape("folk-choice-rank", {
|
||||
title: "Rank These",
|
||||
options: [
|
||||
{ id: "opt-1", label: "Option A" },
|
||||
|
|
@ -907,8 +1029,8 @@
|
|||
});
|
||||
});
|
||||
|
||||
document.getElementById("add-choice-spider").addEventListener("click", () => {
|
||||
createAndAddShape("folk-choice-spider", {
|
||||
document.getElementById("new-choice-spider").addEventListener("click", () => {
|
||||
newShape("folk-choice-spider", {
|
||||
title: "Evaluate Options",
|
||||
options: [
|
||||
{ id: "opt-1", label: "Option A" },
|
||||
|
|
@ -925,8 +1047,8 @@
|
|||
});
|
||||
|
||||
// Social media post
|
||||
document.getElementById("add-social-post").addEventListener("click", () => {
|
||||
createAndAddShape("folk-social-post", {
|
||||
document.getElementById("new-social-post").addEventListener("click", () => {
|
||||
newShape("folk-social-post", {
|
||||
platform: "x",
|
||||
postType: "text",
|
||||
content: "Write your post content here...",
|
||||
|
|
@ -938,11 +1060,11 @@
|
|||
// Arrow connection mode
|
||||
let connectMode = false;
|
||||
let connectSource = null;
|
||||
const addArrowBtn = document.getElementById("add-arrow");
|
||||
const newArrowBtn = document.getElementById("new-arrow");
|
||||
|
||||
addArrowBtn.addEventListener("click", () => {
|
||||
newArrowBtn.addEventListener("click", () => {
|
||||
connectMode = !connectMode;
|
||||
addArrowBtn.classList.toggle("active", connectMode);
|
||||
newArrowBtn.classList.toggle("active", connectMode);
|
||||
canvas.classList.toggle("connect-mode", connectMode);
|
||||
|
||||
if (!connectMode && connectSource) {
|
||||
|
|
@ -982,11 +1104,96 @@
|
|||
connectSource.classList.remove("connect-source");
|
||||
connectSource = null;
|
||||
connectMode = false;
|
||||
addArrowBtn.classList.remove("active");
|
||||
newArrowBtn.classList.remove("active");
|
||||
canvas.classList.remove("connect-mode");
|
||||
}
|
||||
});
|
||||
|
||||
// Memory panel — browse and remember forgotten shapes
|
||||
const memoryPanel = document.getElementById("memory-panel");
|
||||
const memoryList = document.getElementById("memory-list");
|
||||
const memoryCount = document.getElementById("memory-count");
|
||||
const toggleMemoryBtn = document.getElementById("toggle-memory");
|
||||
|
||||
const SHAPE_ICONS = {
|
||||
"folk-markdown": "📝", "folk-wrapper": "🗂️", "folk-slide": "🎞️",
|
||||
"folk-chat": "💬", "folk-piano": "🎹", "folk-embed": "🔗",
|
||||
"folk-google-item": "📎", "folk-calendar": "📅", "folk-map": "🗺️",
|
||||
"folk-image-gen": "🎨", "folk-video-gen": "🎬", "folk-prompt": "🤖",
|
||||
"folk-transcription": "🎤", "folk-video-chat": "📹", "folk-obs-note": "📓",
|
||||
"folk-workflow-block": "⚙️", "folk-itinerary": "🗓️", "folk-destination": "📍",
|
||||
"folk-budget": "💰", "folk-packing-list": "🎒", "folk-booking": "✈️",
|
||||
"folk-token-mint": "🪙", "folk-token-ledger": "📒",
|
||||
"folk-choice-vote": "☑", "folk-choice-rank": "📊",
|
||||
"folk-choice-spider": "🕸", "folk-social-post": "📱",
|
||||
"folk-arrow": "↗️",
|
||||
};
|
||||
|
||||
function getShapeLabel(data) {
|
||||
return data.title || data.content?.slice(0, 40) || data.tokenName || data.label || data.type || "Shape";
|
||||
}
|
||||
|
||||
function renderMemoryPanel() {
|
||||
const forgotten = sync.getForgottenShapes();
|
||||
memoryCount.textContent = forgotten.length;
|
||||
|
||||
memoryList.innerHTML = "";
|
||||
for (const shape of forgotten) {
|
||||
const item = document.createElement("div");
|
||||
item.className = "memory-item";
|
||||
|
||||
const ago = shape.forgottenAt
|
||||
? timeAgo(shape.forgottenAt)
|
||||
: "";
|
||||
|
||||
item.innerHTML = `
|
||||
<span class="icon">${SHAPE_ICONS[shape.type] || "📦"}</span>
|
||||
<div class="info">
|
||||
<div class="name">${escapeHtml(getShapeLabel(shape))}</div>
|
||||
<div class="meta">${shape.type}${ago ? " · " + ago : ""}</div>
|
||||
</div>
|
||||
<button class="remember-btn">Remember</button>
|
||||
`;
|
||||
|
||||
item.querySelector(".remember-btn").addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
sync.rememberShape(shape.id);
|
||||
renderMemoryPanel();
|
||||
});
|
||||
|
||||
memoryList.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
function timeAgo(ts) {
|
||||
const diff = Date.now() - ts;
|
||||
if (diff < 60000) return "just now";
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
|
||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
|
||||
return `${Math.floor(diff / 86400000)}d ago`;
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = str;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
toggleMemoryBtn.addEventListener("click", () => {
|
||||
const isOpen = memoryPanel.classList.toggle("open");
|
||||
toggleMemoryBtn.classList.toggle("active", isOpen);
|
||||
if (isOpen) renderMemoryPanel();
|
||||
});
|
||||
|
||||
// Refresh panel when shapes are forgotten/remembered via remote sync
|
||||
sync.addEventListener("shape-forgotten", () => {
|
||||
if (memoryPanel.classList.contains("open")) renderMemoryPanel();
|
||||
});
|
||||
|
||||
sync.addEventListener("synced", () => {
|
||||
if (memoryPanel.classList.contains("open")) renderMemoryPanel();
|
||||
});
|
||||
|
||||
// Zoom and pan controls
|
||||
let scale = 1;
|
||||
let panX = 0;
|
||||
|
|
|
|||
Loading…
Reference in New Issue