feat: SVG drawing persistence, click-to-edit markdown, quick-add rApps, dblclick pencil

- SVG whiteboard drawings now persist via Automerge (addShapeData for
  DOM-less shapes, wb-svg type in newShapeElement, eraser deletes from doc)
- folk-markdown: click preview to edit, edit-enter/edit-exit events sync
  with folk-shape editing state, refactored into enter/exitMarkdownEdit
- Desktop quick-add (+) button opens rApps popout panel directly
- Double-click empty canvas background activates pencil draw mode
- Canvas background click exits editing mode on all shapes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-27 16:25:44 -08:00
parent 9f3c9abf5b
commit 645f1fc015
3 changed files with 114 additions and 16 deletions

View File

@ -25,6 +25,8 @@ export interface ShapeData {
isMinimized?: boolean;
isPinned?: boolean;
tags?: string[];
// Whiteboard SVG drawing
svgMarkup?: string;
// Allow arbitrary shape-specific properties from toJSON()
[key: string]: unknown;
}
@ -495,6 +497,18 @@ export class CommunitySync extends EventTarget {
this.forgetShape(shapeId);
}
/**
* Add raw shape data directly (for shapes without DOM elements, like wb-svg drawings).
*/
addShapeData(shapeData: ShapeData): void {
this.#doc = Automerge.change(this.#doc, `Add shape ${shapeData.id}`, (doc) => {
if (!doc.shapes) doc.shapes = {};
doc.shapes[shapeData.id] = shapeData;
});
this.#scheduleSave();
this.#syncToServer();
}
/**
* FUN: Update explicitly update specific fields of a shape.
* Use this for programmatic updates (API calls, module callbacks).

View File

@ -186,23 +186,51 @@ export class FolkMarkdown extends FolkShape {
const editBtn = wrapper.querySelector(".edit-btn") as HTMLButtonElement;
const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement;
// Edit toggle
// Helper to enter/exit markdown edit mode
const enterMarkdownEdit = () => {
if (this.#isEditing) return;
this.#isEditing = true;
editor.style.display = "block";
preview.style.display = "none";
editor.value = this.#content;
editor.focus();
};
const exitMarkdownEdit = () => {
if (!this.#isEditing) return;
this.#isEditing = false;
editor.style.display = "none";
preview.style.display = "block";
this.content = editor.value;
preview.innerHTML = this.#renderMarkdown(this.#content);
};
// Edit toggle button
editBtn.addEventListener("click", (e) => {
e.stopPropagation();
this.#isEditing = !this.#isEditing;
if (this.#isEditing) {
editor.style.display = "block";
preview.style.display = "none";
editor.value = this.#content;
editor.focus();
exitMarkdownEdit();
} else {
editor.style.display = "none";
preview.style.display = "block";
this.content = editor.value;
preview.innerHTML = this.#renderMarkdown(this.#content);
enterMarkdownEdit();
}
});
// Click on preview enters edit mode (when shape is focused/editing)
preview.addEventListener("click", (e) => {
e.stopPropagation();
enterMarkdownEdit();
});
// When parent shape enters edit mode, also enter markdown edit
this.addEventListener("edit-enter", () => {
enterMarkdownEdit();
});
// When parent shape exits edit mode, also exit markdown edit
this.addEventListener("edit-exit", () => {
exitMarkdownEdit();
});
// Close button
closeBtn.addEventListener("click", (e) => {
e.stopPropagation();
@ -215,11 +243,7 @@ export class FolkMarkdown extends FolkShape {
});
editor.addEventListener("blur", () => {
this.#isEditing = false;
editor.style.display = "none";
preview.style.display = "block";
this.content = editor.value;
preview.innerHTML = this.#renderMarkdown(this.#content);
exitMarkdownEdit();
});
// Initial render

View File

@ -1240,6 +1240,10 @@
if (document.getElementById(data.id)) {
return;
}
// Check if wb-svg already exists in the overlay
if (data.type === "wb-svg" && wbOverlay.querySelector(`[data-wb-id="${data.id}"]`)) {
return;
}
try {
isProcessingRemote = true;
@ -1262,6 +1266,9 @@
if (shape && shape.parentNode) {
shape.remove();
}
// Also remove wb-svg elements from the overlay
const wbEl = wbOverlay?.querySelector(`[data-wb-id="${shapeId}"]`);
if (wbEl) wbEl.remove();
});
// Create a shape element from data
@ -1487,6 +1494,18 @@
if (data.maxItems) shape.maxItems = data.maxItems;
if (data.refreshInterval) shape.refreshInterval = data.refreshInterval;
break;
case "wb-svg":
// Whiteboard SVG drawing — recreate in SVG overlay, not as a folk-shape
if (data.svgMarkup) {
const temp = document.createElementNS("http://www.w3.org/2000/svg", "g");
temp.innerHTML = data.svgMarkup;
const svgEl = temp.firstElementChild;
if (svgEl) {
svgEl.setAttribute("data-wb-id", data.id);
wbOverlay.appendChild(svgEl);
}
}
return null; // Not a folk-shape element
case "folk-markdown":
default:
shape = document.createElement("folk-markdown");
@ -2170,15 +2189,34 @@
wbOverlay.addEventListener("pointerup", (e) => {
if (!wbDrawing) return;
wbDrawing = false;
// Persist the completed SVG element to Automerge
if (wbPreviewEl) {
const wbId = `wb-${Date.now()}-${++shapeCounter}`;
wbPreviewEl.setAttribute("data-wb-id", wbId);
const svgMarkup = wbPreviewEl.outerHTML;
sync.addShapeData({
type: "wb-svg",
id: wbId,
svgMarkup,
x: 0, y: 0, width: 0, height: 0, rotation: 0,
});
}
wbPreviewEl = null;
wbCurrentPath = [];
});
// Eraser: click on existing SVG strokes to delete them
// Eraser: click on existing SVG strokes to delete them + remove from Automerge
wbOverlay.addEventListener("click", (e) => {
if (wbTool !== "eraser") return;
const hit = e.target;
if (hit && hit !== wbOverlay && wbOverlay.contains(hit)) {
const wbId = hit.getAttribute("data-wb-id");
if (wbId) {
sync.deleteShape(wbId);
}
hit.remove();
}
});
@ -2407,6 +2445,21 @@
}
});
// Desktop quick-add button → opens the rApps popout panel
document.getElementById("quick-add")?.addEventListener("click", (e) => {
e.stopPropagation();
const rAppsGroup = toolbarEl.querySelector(".toolbar-group:has(#embed-notes)")
|| [...toolbarEl.querySelectorAll(".toolbar-group")].find(g =>
g.querySelector(".toolbar-group-toggle")?.textContent.includes("rApps"));
if (rAppsGroup) {
if (activeToolbarGroup === rAppsGroup) {
closeToolbarPanel();
} else {
openToolbarPanel(rAppsGroup);
}
}
});
// Collapse/expand toolbar
const collapseBtn = document.getElementById("toolbar-collapse");
collapseBtn.addEventListener("click", () => {
@ -2556,6 +2609,13 @@
canvas.style.cursor = "";
});
// Double-click on empty canvas background → quick-draw (pencil) mode
canvas.addEventListener("dblclick", (e) => {
if (e.target === canvas || e.target === canvasContent) {
setWbTool("pencil");
}
});
// Keep-alive ping to prevent WebSocket idle timeout
setInterval(() => {
try {