feat: three-state FUN — present, forgotten (faded), deleted
Shapes now have three states instead of two. "Forgetting" a shape fades it (35% opacity, greyscale) for all connected clients rather than hiding it. Other users can then choose to "forget too", "remember" (restore), or "delete" (hard-remove from DOM). A forgottenBy map tracks who forgot, enabling social signaling around shared attention. - folk-shape.ts: :state(forgotten) CSS + forgotten property - community-sync.ts: forgetShape(id,did), rememberShape, hardDeleteShape, getShapeVisualState, hasUserForgotten, getFadedShapes, getDeletedShapes - community-store.ts: forgottenBy map server-side, rememberShape clears map - canvas.html: right-click context menu, two-section memory panel (Fading/ Deleted), close button fades instead of removes, Delete key escalates Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
a61f562bbf
commit
317bc46de6
|
|
@ -502,10 +502,11 @@ export class CommunitySync extends EventTarget {
|
|||
}
|
||||
|
||||
/**
|
||||
* Delete a shape from the document (hard delete — use forgetShape instead)
|
||||
* Delete a shape — now aliases to forgetShape for backward compat.
|
||||
* For true hard-delete, use hardDeleteShape().
|
||||
*/
|
||||
deleteShape(shapeId: string): void {
|
||||
this.forgetShape(shapeId);
|
||||
deleteShape(shapeId: string, did?: string): void {
|
||||
this.forgetShape(shapeId, did || 'unknown');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -547,61 +548,157 @@ export class CommunitySync extends EventTarget {
|
|||
}
|
||||
|
||||
/**
|
||||
* Forget a shape — soft-delete. Shape stays in the doc but is hidden.
|
||||
* Forget a shape — add DID to forgottenBy map. Shape fades but stays in DOM.
|
||||
* Three-state: present → forgotten (faded) → deleted
|
||||
*/
|
||||
forgetShape(shapeId: string): void {
|
||||
forgetShape(shapeId: string, did: 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();
|
||||
const shape = doc.shapes[shapeId] as Record<string, unknown>;
|
||||
if (!shape.forgottenBy || typeof shape.forgottenBy !== 'object') {
|
||||
shape.forgottenBy = {};
|
||||
}
|
||||
(shape.forgottenBy as Record<string, number>)[did] = Date.now();
|
||||
// Legacy compat
|
||||
shape.forgotten = true;
|
||||
shape.forgottenAt = Date.now();
|
||||
}
|
||||
});
|
||||
|
||||
// Remove from visible DOM
|
||||
this.#removeShapeFromDOM(shapeId);
|
||||
// Don't remove from DOM — just update visual state
|
||||
this.dispatchEvent(new CustomEvent("shape-state-changed", {
|
||||
detail: { shapeId, state: 'forgotten', data: this.#doc.shapes?.[shapeId] }
|
||||
}));
|
||||
this.#scheduleSave();
|
||||
this.#syncToServer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remember a forgotten shape — restore it to the canvas.
|
||||
* Remember a forgotten shape — clear forgottenBy + deleted, restore to present.
|
||||
*/
|
||||
rememberShape(shapeId: string): void {
|
||||
const shapeData = this.#doc.shapes?.[shapeId];
|
||||
if (!shapeData) return;
|
||||
|
||||
const wasDeleted = !!(shapeData as Record<string, unknown>).deleted;
|
||||
|
||||
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 = '';
|
||||
const shape = doc.shapes[shapeId] as Record<string, unknown>;
|
||||
shape.forgottenBy = {};
|
||||
shape.deleted = false;
|
||||
// Legacy compat
|
||||
shape.forgotten = false;
|
||||
shape.forgottenAt = 0;
|
||||
}
|
||||
});
|
||||
|
||||
// Re-add to DOM
|
||||
this.#applyShapeToDOM(this.#doc.shapes[shapeId]);
|
||||
if (wasDeleted) {
|
||||
// Re-add to DOM if was hard-deleted
|
||||
this.#applyShapeToDOM(this.#doc.shapes[shapeId]);
|
||||
}
|
||||
|
||||
this.dispatchEvent(new CustomEvent("shape-state-changed", {
|
||||
detail: { shapeId, state: 'present', data: this.#doc.shapes?.[shapeId] }
|
||||
}));
|
||||
this.#scheduleSave();
|
||||
this.#syncToServer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all forgotten shapes (for the memory layer UI).
|
||||
* Hard-delete a shape — set deleted: true, remove from DOM.
|
||||
* Shape stays in Automerge doc for restore from memory panel.
|
||||
*/
|
||||
hardDeleteShape(shapeId: string): void {
|
||||
this.#doc = Automerge.change(this.#doc, `Delete shape ${shapeId}`, (doc) => {
|
||||
if (doc.shapes && doc.shapes[shapeId]) {
|
||||
(doc.shapes[shapeId] as Record<string, unknown>).deleted = true;
|
||||
}
|
||||
});
|
||||
|
||||
this.#removeShapeFromDOM(shapeId);
|
||||
this.dispatchEvent(new CustomEvent("shape-state-changed", {
|
||||
detail: { shapeId, state: 'deleted', data: this.#doc.shapes?.[shapeId] }
|
||||
}));
|
||||
this.#scheduleSave();
|
||||
this.#syncToServer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the visual state of a shape: 'present' | 'forgotten' | 'deleted'
|
||||
*/
|
||||
getShapeVisualState(shapeId: string): 'present' | 'forgotten' | 'deleted' {
|
||||
const data = this.#doc.shapes?.[shapeId] as Record<string, unknown> | undefined;
|
||||
if (!data) return 'deleted';
|
||||
if (data.deleted === true) return 'deleted';
|
||||
const fb = data.forgottenBy;
|
||||
if (fb && typeof fb === 'object' && Object.keys(fb).length > 0) return 'forgotten';
|
||||
return 'present';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific user has forgotten a shape
|
||||
*/
|
||||
hasUserForgotten(shapeId: string, did: string): boolean {
|
||||
const data = this.#doc.shapes?.[shapeId] as Record<string, unknown> | undefined;
|
||||
if (!data) return false;
|
||||
const fb = data.forgottenBy as Record<string, number> | undefined;
|
||||
return !!(fb && fb[did]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all forgotten (faded but not deleted) shapes
|
||||
*/
|
||||
getFadedShapes(): ShapeData[] {
|
||||
const shapes = this.#doc.shapes || {};
|
||||
return Object.values(shapes).filter(s => {
|
||||
const d = s as Record<string, unknown>;
|
||||
if (d.deleted === true) return false;
|
||||
const fb = d.forgottenBy;
|
||||
return fb && typeof fb === 'object' && Object.keys(fb).length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all hard-deleted shapes (for memory panel "Deleted" section)
|
||||
*/
|
||||
getDeletedShapes(): ShapeData[] {
|
||||
const shapes = this.#doc.shapes || {};
|
||||
return Object.values(shapes).filter(s => (s as Record<string, unknown>).deleted === true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all forgotten shapes — includes both faded and deleted (for backward compat).
|
||||
*/
|
||||
getForgottenShapes(): ShapeData[] {
|
||||
const shapes = this.#doc.shapes || {};
|
||||
return Object.values(shapes).filter(s => s.forgotten);
|
||||
return Object.values(shapes).filter(s => {
|
||||
const d = s as Record<string, unknown>;
|
||||
if (d.deleted === true) return true;
|
||||
const fb = d.forgottenBy;
|
||||
if (fb && typeof fb === 'object' && Object.keys(fb).length > 0) return true;
|
||||
return !!d.forgotten;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply full document to DOM (for initial load).
|
||||
* Skips forgotten shapes — they live in the doc but are hidden from view.
|
||||
* Three-state: deleted shapes are skipped, forgotten shapes are rendered faded.
|
||||
*/
|
||||
#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
|
||||
const d = shapeData as Record<string, unknown>;
|
||||
if (d.deleted === true) continue; // Deleted: not in DOM
|
||||
this.#applyShapeToDOM(shapeData);
|
||||
// If forgotten (faded), emit state-changed so canvas can apply visual
|
||||
const fb = d.forgottenBy;
|
||||
if (fb && typeof fb === 'object' && Object.keys(fb).length > 0) {
|
||||
this.dispatchEvent(new CustomEvent("shape-state-changed", {
|
||||
detail: { shapeId: id, state: 'forgotten', data: shapeData }
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
this.dispatchEvent(new CustomEvent("synced", { detail: { shapes } }));
|
||||
|
|
@ -609,8 +706,7 @@ export class CommunitySync extends EventTarget {
|
|||
|
||||
/**
|
||||
* Apply Automerge patches to DOM.
|
||||
* Handles forgotten state: when a shape becomes forgotten, remove it from
|
||||
* the visible canvas; when remembered, re-add it.
|
||||
* Three-state: forgotten shapes stay in DOM (faded), deleted shapes are removed.
|
||||
*/
|
||||
#applyPatchesToDOM(patches: Automerge.Patch[]): void {
|
||||
for (const patch of patches) {
|
||||
|
|
@ -622,16 +718,31 @@ export class CommunitySync extends EventTarget {
|
|||
const shapeData = this.#doc.shapes?.[shapeId];
|
||||
|
||||
if (patch.action === "del" && path.length === 2) {
|
||||
// Shape hard-deleted
|
||||
// Shape truly removed from Automerge doc
|
||||
this.#removeShapeFromDOM(shapeId);
|
||||
} else if (shapeData) {
|
||||
// FUN: if shape was just forgotten, remove from DOM
|
||||
if (shapeData.forgotten) {
|
||||
const d = shapeData as Record<string, unknown>;
|
||||
const state = this.getShapeVisualState(shapeId);
|
||||
|
||||
if (state === 'deleted') {
|
||||
// Hard-deleted: remove from DOM
|
||||
this.#removeShapeFromDOM(shapeId);
|
||||
this.dispatchEvent(new CustomEvent("shape-state-changed", {
|
||||
detail: { shapeId, state: 'deleted', data: shapeData }
|
||||
}));
|
||||
} else if (state === 'forgotten') {
|
||||
// Forgotten: keep in DOM, emit state change for fade visual
|
||||
this.#applyShapeToDOM(shapeData);
|
||||
this.dispatchEvent(new CustomEvent("shape-state-changed", {
|
||||
detail: { shapeId, state: 'forgotten', data: shapeData }
|
||||
}));
|
||||
this.dispatchEvent(new CustomEvent("shape-forgotten", { detail: { shapeId, data: shapeData } }));
|
||||
} else {
|
||||
// Shape created, updated, or remembered — render it
|
||||
// Present: render normally
|
||||
this.#applyShapeToDOM(shapeData);
|
||||
this.dispatchEvent(new CustomEvent("shape-state-changed", {
|
||||
detail: { shapeId, state: 'present', data: shapeData }
|
||||
}));
|
||||
this.#postMessageToParent("shape-updated", shapeData);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -106,6 +106,12 @@ const styles = css`
|
|||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
:host(:state(forgotten)) {
|
||||
opacity: 0.35;
|
||||
filter: grayscale(0.8);
|
||||
transition: opacity 0.3s ease, filter 0.3s ease;
|
||||
}
|
||||
|
||||
:host(:state(move)),
|
||||
:host(:state(rotate)),
|
||||
:host(:state(resize-top-left)),
|
||||
|
|
@ -279,6 +285,18 @@ export class FolkShape extends FolkElement {
|
|||
: this.#internals.states.delete("highlighted");
|
||||
}
|
||||
|
||||
#forgotten = false;
|
||||
get forgotten() {
|
||||
return this.#forgotten;
|
||||
}
|
||||
set forgotten(forgotten) {
|
||||
if (this.#forgotten === forgotten) return;
|
||||
this.#forgotten = forgotten;
|
||||
forgotten
|
||||
? this.#internals.states.add("forgotten")
|
||||
: this.#internals.states.delete("forgotten");
|
||||
}
|
||||
|
||||
#editing = false;
|
||||
get editing() {
|
||||
return this.#editing;
|
||||
|
|
|
|||
|
|
@ -661,8 +661,8 @@ 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().
|
||||
* Forget a shape — add DID to forgottenBy map. Shape stays in doc but fades for all.
|
||||
* Three-state: present → forgotten (faded) → deleted
|
||||
*/
|
||||
export function forgetShape(slug: string, shapeId: string, forgottenBy?: string): void {
|
||||
const doc = communities.get(slug);
|
||||
|
|
@ -670,11 +670,17 @@ export function forgetShape(slug: string, shapeId: string, forgottenBy?: string)
|
|||
|
||||
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;
|
||||
const shape = d.shapes[shapeId] as Record<string, unknown>;
|
||||
// Add DID to forgottenBy map (Automerge merges concurrent map writes cleanly)
|
||||
if (!shape.forgottenBy || typeof shape.forgottenBy !== 'object') {
|
||||
shape.forgottenBy = {};
|
||||
}
|
||||
if (forgottenBy) {
|
||||
(shape.forgottenBy as Record<string, number>)[forgottenBy] = Date.now();
|
||||
}
|
||||
// Legacy compat: keep scalar forgotten flag synced
|
||||
shape.forgotten = true;
|
||||
shape.forgottenAt = Date.now();
|
||||
}
|
||||
});
|
||||
communities.set(slug, newDoc);
|
||||
|
|
@ -682,7 +688,8 @@ export function forgetShape(slug: string, shapeId: string, forgottenBy?: string)
|
|||
}
|
||||
|
||||
/**
|
||||
* Remember a forgotten shape — restore it to the canvas.
|
||||
* Remember a forgotten shape — clear forgottenBy map + deleted flag.
|
||||
* Restores shape to present state for everyone.
|
||||
*/
|
||||
export function rememberShape(slug: string, shapeId: string): void {
|
||||
const doc = communities.get(slug);
|
||||
|
|
@ -690,9 +697,12 @@ export function rememberShape(slug: string, shapeId: string): void {
|
|||
|
||||
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 = '';
|
||||
const shape = d.shapes[shapeId] as Record<string, unknown>;
|
||||
shape.forgottenBy = {};
|
||||
shape.deleted = false;
|
||||
// Legacy compat
|
||||
shape.forgotten = false;
|
||||
shape.forgottenAt = 0;
|
||||
}
|
||||
});
|
||||
communities.set(slug, newDoc);
|
||||
|
|
|
|||
|
|
@ -422,6 +422,115 @@
|
|||
background: #0d9488;
|
||||
}
|
||||
|
||||
.memory-item .delete-btn {
|
||||
padding: 4px 10px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.memory-item .delete-btn:hover {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
.memory-item .restore-btn {
|
||||
padding: 4px 10px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.memory-item .restore-btn:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.memory-section-header {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #94a3b8;
|
||||
padding: 8px 10px 4px;
|
||||
}
|
||||
|
||||
.memory-item .forget-count {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Shape context menu */
|
||||
#shape-context-menu {
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.18);
|
||||
padding: 4px;
|
||||
min-width: 160px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
#shape-context-menu.open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#shape-context-menu button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
background: none;
|
||||
text-align: left;
|
||||
font-size: 13px;
|
||||
color: #1e293b;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#shape-context-menu button:hover {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
#shape-context-menu button.danger {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
#shape-context-menu button.danger:hover {
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
body[data-theme="dark"] #shape-context-menu {
|
||||
background: #1e293b;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
body[data-theme="dark"] #shape-context-menu button {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
body[data-theme="dark"] #shape-context-menu button:hover {
|
||||
background: #334155;
|
||||
}
|
||||
|
||||
body[data-theme="dark"] #shape-context-menu button.danger {
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
body[data-theme="dark"] #shape-context-menu button.danger:hover {
|
||||
background: #3b1c1c;
|
||||
}
|
||||
|
||||
#canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
|
@ -1048,6 +1157,8 @@
|
|||
<div id="memory-list"></div>
|
||||
</div>
|
||||
|
||||
<div id="shape-context-menu"></div>
|
||||
|
||||
<div id="status" class="disconnected">
|
||||
<span class="indicator"></span>
|
||||
<span id="status-text">Connecting...</span>
|
||||
|
|
@ -1587,6 +1698,11 @@
|
|||
setupShapeEventListeners(shape);
|
||||
canvasContent.appendChild(shape);
|
||||
sync.registerShape(shape);
|
||||
// Apply forgotten visual if shape arrives in forgotten state
|
||||
const visualState = sync.getShapeVisualState(data.id);
|
||||
if (visualState === 'forgotten') {
|
||||
shape.forgotten = true;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[Canvas] Failed to create remote shape ${data.id} (${data.type}):`, err);
|
||||
|
|
@ -1595,7 +1711,7 @@
|
|||
}
|
||||
});
|
||||
|
||||
// FUN: Forget — handle shape removal from remote sync
|
||||
// FUN: shape removed from DOM (hard-delete or true Automerge delete)
|
||||
sync.addEventListener("shape-removed", (e) => {
|
||||
const { shapeId, shape } = e.detail;
|
||||
if (shape && shape.parentNode) {
|
||||
|
|
@ -1606,6 +1722,19 @@
|
|||
if (wbEl) wbEl.remove();
|
||||
});
|
||||
|
||||
// Three-state: update shape visual when state changes
|
||||
sync.addEventListener("shape-state-changed", (e) => {
|
||||
const { shapeId, state } = e.detail;
|
||||
const el = document.getElementById(shapeId);
|
||||
if (!el) return;
|
||||
if (state === 'forgotten') {
|
||||
el.forgotten = true;
|
||||
} else if (state === 'present') {
|
||||
el.forgotten = false;
|
||||
}
|
||||
// 'deleted' is handled by shape-removed (element is removed from DOM)
|
||||
});
|
||||
|
||||
// Create a shape element from data
|
||||
function newShapeElement(data) {
|
||||
let shape;
|
||||
|
|
@ -1898,10 +2027,11 @@
|
|||
updateSelectionVisuals();
|
||||
});
|
||||
|
||||
// Close button
|
||||
// Close button — forget (fade) instead of remove
|
||||
shape.addEventListener("close", () => {
|
||||
sync.deleteShape(shape.id);
|
||||
shape.remove();
|
||||
const did = getLocalDID();
|
||||
sync.forgetShape(shape.id, did);
|
||||
shape.forgotten = true;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -2574,12 +2704,81 @@
|
|||
if (hit && hit !== wbOverlay && wbOverlay.contains(hit)) {
|
||||
const wbId = hit.getAttribute("data-wb-id");
|
||||
if (wbId) {
|
||||
sync.deleteShape(wbId);
|
||||
sync.hardDeleteShape(wbId);
|
||||
}
|
||||
hit.remove();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Helper: get local user DID ──
|
||||
function getLocalDID() {
|
||||
try {
|
||||
const sess = JSON.parse(localStorage.getItem('encryptid_session') || '');
|
||||
return sess?.claims?.sub || sess?.claims?.did || 'anonymous';
|
||||
} catch { return 'anonymous'; }
|
||||
}
|
||||
|
||||
// ── Shape context menu (right-click on shapes) ──
|
||||
const shapeContextMenu = document.getElementById("shape-context-menu");
|
||||
let contextShapeId = null;
|
||||
|
||||
canvasContent.addEventListener("contextmenu", (e) => {
|
||||
const shapeEl = e.target.closest("folk-shape, folk-markdown, folk-wrapper, folk-slide, folk-chat, folk-embed, 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-rapp, folk-feed, folk-piano, folk-splat, folk-blender, folk-drawfast, folk-freecad, folk-kicad");
|
||||
if (!shapeEl || !shapeEl.id) return;
|
||||
|
||||
e.preventDefault();
|
||||
contextShapeId = shapeEl.id;
|
||||
const state = sync.getShapeVisualState(contextShapeId);
|
||||
const did = getLocalDID();
|
||||
const alreadyForgotten = sync.hasUserForgotten(contextShapeId, did);
|
||||
|
||||
let html = '';
|
||||
if (state === 'present') {
|
||||
html = `<button data-action="forget">Forget</button>`;
|
||||
} else if (state === 'forgotten') {
|
||||
html += `<button data-action="remember">Remember</button>`;
|
||||
if (!alreadyForgotten) {
|
||||
html += `<button data-action="forget-too">Forget too</button>`;
|
||||
}
|
||||
html += `<button data-action="delete" class="danger">Delete</button>`;
|
||||
}
|
||||
|
||||
shapeContextMenu.innerHTML = html;
|
||||
shapeContextMenu.style.left = e.clientX + 'px';
|
||||
shapeContextMenu.style.top = e.clientY + 'px';
|
||||
shapeContextMenu.classList.add("open");
|
||||
});
|
||||
|
||||
shapeContextMenu.addEventListener("click", (e) => {
|
||||
const btn = e.target.closest("button");
|
||||
if (!btn || !contextShapeId) return;
|
||||
|
||||
const action = btn.dataset.action;
|
||||
const did = getLocalDID();
|
||||
|
||||
if (action === 'forget') {
|
||||
sync.forgetShape(contextShapeId, did);
|
||||
const el = document.getElementById(contextShapeId);
|
||||
if (el) el.forgotten = true;
|
||||
} else if (action === 'forget-too') {
|
||||
sync.forgetShape(contextShapeId, did);
|
||||
} else if (action === 'remember') {
|
||||
sync.rememberShape(contextShapeId);
|
||||
} else if (action === 'delete') {
|
||||
sync.hardDeleteShape(contextShapeId);
|
||||
}
|
||||
|
||||
shapeContextMenu.classList.remove("open");
|
||||
contextShapeId = null;
|
||||
if (memoryPanel.classList.contains("open")) renderMemoryPanel();
|
||||
});
|
||||
|
||||
// Close context menu on click elsewhere
|
||||
document.addEventListener("click", () => {
|
||||
shapeContextMenu.classList.remove("open");
|
||||
contextShapeId = null;
|
||||
});
|
||||
|
||||
// Memory panel — browse and remember forgotten shapes
|
||||
const memoryPanel = document.getElementById("memory-panel");
|
||||
const memoryList = document.getElementById("memory-list");
|
||||
|
|
@ -2607,34 +2806,84 @@
|
|||
}
|
||||
|
||||
function renderMemoryPanel() {
|
||||
const forgotten = sync.getForgottenShapes();
|
||||
memoryCount.textContent = forgotten.length;
|
||||
const faded = sync.getFadedShapes();
|
||||
const deleted = sync.getDeletedShapes();
|
||||
memoryCount.textContent = faded.length + deleted.length;
|
||||
|
||||
memoryList.innerHTML = "";
|
||||
for (const shape of forgotten) {
|
||||
const item = document.createElement("div");
|
||||
item.className = "memory-item";
|
||||
|
||||
const ago = shape.forgottenAt
|
||||
? timeAgo(shape.forgottenAt)
|
||||
: "";
|
||||
// ── Fading section ──
|
||||
if (faded.length > 0) {
|
||||
const header = document.createElement("div");
|
||||
header.className = "memory-section-header";
|
||||
header.textContent = "Fading";
|
||||
memoryList.appendChild(header);
|
||||
|
||||
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>
|
||||
`;
|
||||
for (const shape of faded) {
|
||||
const item = document.createElement("div");
|
||||
item.className = "memory-item";
|
||||
|
||||
item.querySelector(".remember-btn").addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
sync.rememberShape(shape.id);
|
||||
renderMemoryPanel();
|
||||
});
|
||||
const ago = shape.forgottenAt ? timeAgo(shape.forgottenAt) : "";
|
||||
const fb = shape.forgottenBy;
|
||||
const forgetCount = (fb && typeof fb === 'object') ? Object.keys(fb).length : 0;
|
||||
|
||||
memoryList.appendChild(item);
|
||||
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>
|
||||
${forgetCount > 0 ? `<span class="forget-count">${forgetCount}x</span>` : ''}
|
||||
<button class="remember-btn">Remember</button>
|
||||
<button class="delete-btn">Delete</button>
|
||||
`;
|
||||
|
||||
item.querySelector(".remember-btn").addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
sync.rememberShape(shape.id);
|
||||
renderMemoryPanel();
|
||||
});
|
||||
|
||||
item.querySelector(".delete-btn").addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
sync.hardDeleteShape(shape.id);
|
||||
renderMemoryPanel();
|
||||
});
|
||||
|
||||
memoryList.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Deleted section ──
|
||||
if (deleted.length > 0) {
|
||||
const header = document.createElement("div");
|
||||
header.className = "memory-section-header";
|
||||
header.textContent = "Deleted";
|
||||
memoryList.appendChild(header);
|
||||
|
||||
for (const shape of deleted) {
|
||||
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="restore-btn">Restore</button>
|
||||
`;
|
||||
|
||||
item.querySelector(".restore-btn").addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
sync.rememberShape(shape.id);
|
||||
renderMemoryPanel();
|
||||
});
|
||||
|
||||
memoryList.appendChild(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2658,11 +2907,15 @@
|
|||
if (isOpen) renderMemoryPanel();
|
||||
});
|
||||
|
||||
// Refresh panel when shapes are forgotten/remembered via remote sync
|
||||
// Refresh panel when shapes change state via remote sync
|
||||
sync.addEventListener("shape-forgotten", () => {
|
||||
if (memoryPanel.classList.contains("open")) renderMemoryPanel();
|
||||
});
|
||||
|
||||
sync.addEventListener("shape-state-changed", () => {
|
||||
if (memoryPanel.classList.contains("open")) renderMemoryPanel();
|
||||
});
|
||||
|
||||
sync.addEventListener("synced", () => {
|
||||
if (memoryPanel.classList.contains("open")) renderMemoryPanel();
|
||||
});
|
||||
|
|
@ -2933,14 +3186,24 @@
|
|||
}
|
||||
});
|
||||
|
||||
// ── Delete selected shapes ──
|
||||
// ── Delete selected shapes (forget, not hard-delete) ──
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if ((e.key === "Delete" || e.key === "Backspace") &&
|
||||
!e.target.closest("input, textarea, [contenteditable]") &&
|
||||
selectedShapeIds.size > 0) {
|
||||
const did = getLocalDID();
|
||||
for (const id of selectedShapeIds) {
|
||||
sync.deleteShape(id);
|
||||
document.getElementById(id)?.remove();
|
||||
const el = document.getElementById(id);
|
||||
const state = sync.getShapeVisualState(id);
|
||||
if (state === 'forgotten') {
|
||||
// Already forgotten — hard delete
|
||||
sync.hardDeleteShape(id);
|
||||
if (el) el.remove();
|
||||
} else {
|
||||
// Present — forget (fade)
|
||||
sync.forgetShape(id, did);
|
||||
if (el) el.forgotten = true;
|
||||
}
|
||||
}
|
||||
deselectAll();
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue