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:
parent
9f3c9abf5b
commit
645f1fc015
|
|
@ -25,6 +25,8 @@ export interface ShapeData {
|
||||||
isMinimized?: boolean;
|
isMinimized?: boolean;
|
||||||
isPinned?: boolean;
|
isPinned?: boolean;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
|
// Whiteboard SVG drawing
|
||||||
|
svgMarkup?: string;
|
||||||
// Allow arbitrary shape-specific properties from toJSON()
|
// Allow arbitrary shape-specific properties from toJSON()
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
@ -495,6 +497,18 @@ export class CommunitySync extends EventTarget {
|
||||||
this.forgetShape(shapeId);
|
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.
|
* FUN: Update — explicitly update specific fields of a shape.
|
||||||
* Use this for programmatic updates (API calls, module callbacks).
|
* Use this for programmatic updates (API calls, module callbacks).
|
||||||
|
|
|
||||||
|
|
@ -186,23 +186,51 @@ export class FolkMarkdown extends FolkShape {
|
||||||
const editBtn = wrapper.querySelector(".edit-btn") as HTMLButtonElement;
|
const editBtn = wrapper.querySelector(".edit-btn") as HTMLButtonElement;
|
||||||
const closeBtn = wrapper.querySelector(".close-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) => {
|
editBtn.addEventListener("click", (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
this.#isEditing = !this.#isEditing;
|
|
||||||
if (this.#isEditing) {
|
if (this.#isEditing) {
|
||||||
editor.style.display = "block";
|
exitMarkdownEdit();
|
||||||
preview.style.display = "none";
|
|
||||||
editor.value = this.#content;
|
|
||||||
editor.focus();
|
|
||||||
} else {
|
} else {
|
||||||
editor.style.display = "none";
|
enterMarkdownEdit();
|
||||||
preview.style.display = "block";
|
|
||||||
this.content = editor.value;
|
|
||||||
preview.innerHTML = this.#renderMarkdown(this.#content);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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
|
// Close button
|
||||||
closeBtn.addEventListener("click", (e) => {
|
closeBtn.addEventListener("click", (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
@ -215,11 +243,7 @@ export class FolkMarkdown extends FolkShape {
|
||||||
});
|
});
|
||||||
|
|
||||||
editor.addEventListener("blur", () => {
|
editor.addEventListener("blur", () => {
|
||||||
this.#isEditing = false;
|
exitMarkdownEdit();
|
||||||
editor.style.display = "none";
|
|
||||||
preview.style.display = "block";
|
|
||||||
this.content = editor.value;
|
|
||||||
preview.innerHTML = this.#renderMarkdown(this.#content);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initial render
|
// Initial render
|
||||||
|
|
|
||||||
|
|
@ -1240,6 +1240,10 @@
|
||||||
if (document.getElementById(data.id)) {
|
if (document.getElementById(data.id)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Check if wb-svg already exists in the overlay
|
||||||
|
if (data.type === "wb-svg" && wbOverlay.querySelector(`[data-wb-id="${data.id}"]`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
isProcessingRemote = true;
|
isProcessingRemote = true;
|
||||||
|
|
@ -1262,6 +1266,9 @@
|
||||||
if (shape && shape.parentNode) {
|
if (shape && shape.parentNode) {
|
||||||
shape.remove();
|
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
|
// Create a shape element from data
|
||||||
|
|
@ -1487,6 +1494,18 @@
|
||||||
if (data.maxItems) shape.maxItems = data.maxItems;
|
if (data.maxItems) shape.maxItems = data.maxItems;
|
||||||
if (data.refreshInterval) shape.refreshInterval = data.refreshInterval;
|
if (data.refreshInterval) shape.refreshInterval = data.refreshInterval;
|
||||||
break;
|
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":
|
case "folk-markdown":
|
||||||
default:
|
default:
|
||||||
shape = document.createElement("folk-markdown");
|
shape = document.createElement("folk-markdown");
|
||||||
|
|
@ -2170,15 +2189,34 @@
|
||||||
wbOverlay.addEventListener("pointerup", (e) => {
|
wbOverlay.addEventListener("pointerup", (e) => {
|
||||||
if (!wbDrawing) return;
|
if (!wbDrawing) return;
|
||||||
wbDrawing = false;
|
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;
|
wbPreviewEl = null;
|
||||||
wbCurrentPath = [];
|
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) => {
|
wbOverlay.addEventListener("click", (e) => {
|
||||||
if (wbTool !== "eraser") return;
|
if (wbTool !== "eraser") return;
|
||||||
const hit = e.target;
|
const hit = e.target;
|
||||||
if (hit && hit !== wbOverlay && wbOverlay.contains(hit)) {
|
if (hit && hit !== wbOverlay && wbOverlay.contains(hit)) {
|
||||||
|
const wbId = hit.getAttribute("data-wb-id");
|
||||||
|
if (wbId) {
|
||||||
|
sync.deleteShape(wbId);
|
||||||
|
}
|
||||||
hit.remove();
|
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
|
// Collapse/expand toolbar
|
||||||
const collapseBtn = document.getElementById("toolbar-collapse");
|
const collapseBtn = document.getElementById("toolbar-collapse");
|
||||||
collapseBtn.addEventListener("click", () => {
|
collapseBtn.addEventListener("click", () => {
|
||||||
|
|
@ -2556,6 +2609,13 @@
|
||||||
canvas.style.cursor = "";
|
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
|
// Keep-alive ping to prevent WebSocket idle timeout
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue