diff --git a/lib/folk-shape.ts b/lib/folk-shape.ts index b98a54c..b84f161 100644 --- a/lib/folk-shape.ts +++ b/lib/folk-shape.ts @@ -76,6 +76,16 @@ const styles = css` pointer-events: none; } + :host(:state(editing)) .slot-container { + pointer-events: auto; + } + + :host(:state(editing)) { + outline: 2px solid #14b8a6; + outline-offset: 2px; + cursor: text; + } + ::slotted(*) { cursor: default; pointer-events: auto; @@ -263,6 +273,37 @@ export class FolkShape extends FolkElement { : this.#internals.states.delete("highlighted"); } + #editing = false; + get editing() { + return this.#editing; + } + + enterEditMode() { + if (this.#editing) return; + this.#editing = true; + this.#internals.states.add("editing"); + + // Find first focusable child and focus it + const root = this.renderRoot as ShadowRoot; + const focusable = root.querySelector( + 'input, textarea, [contenteditable="true"], select' + ) ?? this.querySelector( + 'input, textarea, [contenteditable="true"], select' + ); + if (focusable) { + focusable.focus(); + } + + this.dispatchEvent(new CustomEvent("edit-enter")); + } + + exitEditMode() { + if (!this.#editing) return; + this.#editing = false; + this.#internals.states.delete("editing"); + this.dispatchEvent(new CustomEvent("edit-exit")); + } + override createRenderRoot() { const root = super.createRenderRoot(); @@ -272,6 +313,11 @@ export class FolkShape extends FolkElement { this.addEventListener("touchend", this); this.addEventListener("keydown", this); + this.addEventListener("dblclick", (e: MouseEvent) => { + e.stopPropagation(); + this.enterEditMode(); + }); + (root as ShadowRoot).setHTMLUnsafe( html` @@ -609,7 +655,7 @@ export class FolkShape extends FolkElement { /** * After moving, push this shape away from any overlapping siblings. - * Uses the direction of the move to decide which side to slide to. + * Resolves by minimum penetration on the axis aligned with movement direction. */ #resolveOverlaps(dx: number, dy: number) { const parent = this.parentElement; @@ -624,28 +670,27 @@ export class FolkShape extends FolkElement { const other = { x: sibling.x, y: sibling.y, w: sibling.width, h: sibling.height }; - // Check overlap (axis-aligned) + // Check overlap (with gap buffer) const overlapX = me.x < other.x + other.w + gap && me.x + me.w + gap > other.x; const overlapY = me.y < other.y + other.h + gap && me.y + me.h + gap > other.y; if (!overlapX || !overlapY) continue; - // Compute penetration depths from each side - const pushRight = (other.x + other.w + gap) - me.x; - const pushLeft = me.x + me.w + gap - other.x; - const pushDown = (other.y + other.h + gap) - me.y; - const pushUp = me.y + me.h + gap - other.y; + // Distance to clear on each side + const clearRight = (other.x + other.w + gap) - me.x; // push me right of other + const clearLeft = other.x - (me.x + me.w + gap); // push me left of other (negative) + const clearDown = (other.y + other.h + gap) - me.y; // push me below other + const clearUp = other.y - (me.y + me.h + gap); // push me above other (negative) - // Pick the axis with the smallest penetration, biased by move direction - const minX = pushRight < pushLeft ? -pushRight : pushLeft; - const minY = pushDown < pushUp ? -pushDown : pushUp; + // Pick push direction per axis based on movement direction + const pushX = dx >= 0 ? clearRight : clearLeft; + const pushY = dy >= 0 ? clearDown : clearUp; - if (Math.abs(minX) < Math.abs(minY)) { - // Slide horizontally - this.#rect.x += dx <= 0 ? -pushLeft : pushRight; + // Apply the axis with the smallest absolute displacement + if (Math.abs(pushX) <= Math.abs(pushY)) { + this.#rect.x += pushX; } else { - // Slide vertically - this.#rect.y += dy <= 0 ? -pushUp : pushDown; + this.#rect.y += pushY; } me.x = this.#rect.x; diff --git a/website/canvas.html b/website/canvas.html index 1e5c4ce..f0f9fed 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -105,6 +105,69 @@ color: white; } + /* Popout panel — renders group tools to the right of toolbar */ + #toolbar-panel { + position: fixed; + top: 108px; + left: calc(68px + 12px + 8px); + min-width: 180px; + max-height: calc(100vh - 130px); + background: white; + border-radius: 12px; + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.18); + z-index: 1001; + display: none; + flex-direction: column; + } + + #toolbar-panel.panel-open { + display: flex; + } + + #toolbar-panel-header { + padding: 10px 14px; + border-bottom: 1px solid #e2e8f0; + font-size: 12px; + font-weight: 600; + color: #64748b; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + #toolbar-panel-body { + padding: 6px; + display: flex; + flex-direction: column; + gap: 2px; + overflow-y: auto; + } + + #toolbar-panel-body button { + padding: 8px 12px; + border: none; + border-radius: 6px; + background: transparent; + cursor: pointer; + font-size: 13px; + text-align: left; + white-space: nowrap; + transition: background 0.15s; + } + + #toolbar-panel-body button:hover { + background: #f1f5f9; + } + + #toolbar-panel-body button.active { + background: #14b8a6; + color: white; + } + + /* Hide inline dropdowns — all rendering goes through the panel */ + .toolbar-group.open > .toolbar-dropdown { + display: none !important; + } + /* Separator between sections */ .toolbar-sep { width: 100%; @@ -586,6 +649,16 @@ #memory-panel { max-width: calc(100vw - 32px); } + + /* Mobile: panel slides up from bottom as sheet */ + #toolbar-panel { + top: auto; + bottom: 90px; + left: 8px; + right: 8px; + border-radius: 16px; + max-height: 50vh; + } } @@ -619,6 +692,8 @@
+ +
@@ -746,6 +821,11 @@
+
+
+
+
+

💭 Memory

@@ -1415,10 +1495,10 @@ } shape.id = data.id; - shape.x = data.x || 100; - shape.y = data.y || 100; - shape.width = data.width || 300; - shape.height = data.height || 200; + shape.x = data.x ?? 100; + shape.y = data.y ?? 100; + shape.width = data.width ?? 300; + shape.height = data.height ?? 200; if (data.rotation) shape.rotation = data.rotation; return shape; @@ -1521,11 +1601,18 @@ .map(el => ({ x: el.x, y: el.y, width: el.width, height: el.height })); } - // Find a free position near the viewport center that doesn't overlap existing shapes - function findFreePosition(width, height) { - const center = getViewportCenter(); - const candidateX = center.x - width / 2; - const candidateY = center.y - height / 2; + // Find a free position that doesn't overlap existing shapes. + // If preferX/preferY are provided, use that as the anchor; otherwise use viewport center. + function findFreePosition(width, height, preferX, preferY) { + let candidateX, candidateY; + if (preferX !== undefined && preferY !== undefined) { + candidateX = preferX - width / 2; + candidateY = preferY - height / 2; + } else { + const center = getViewportCenter(); + candidateX = center.x - width / 2; + candidateY = center.y - height / 2; + } const gap = 20; const existing = getExistingShapeRects(); @@ -1569,15 +1656,62 @@ }; } + // ── Pending tool state for click-to-place ── + let pendingTool = null; // { tagName, props } + let ghostEl = null; + + function setPendingTool(tagName, props = {}) { + pendingTool = { tagName, props }; + canvas.style.cursor = "crosshair"; + + // Create ghost outline + if (ghostEl) ghostEl.remove(); + const defaults = SHAPE_DEFAULTS[tagName] || { width: 300, height: 200 }; + ghostEl = document.createElement("div"); + ghostEl.style.cssText = ` + position: fixed; pointer-events: none; z-index: 9999; + width: ${defaults.width * scale}px; height: ${defaults.height * scale}px; + border: 2px dashed #14b8a6; border-radius: 8px; + background: rgba(20, 184, 166, 0.06); + transform: translate(-50%, -50%); + transition: width 0.1s, height 0.1s; + `; + document.body.appendChild(ghostEl); + } + + function clearPendingTool() { + pendingTool = null; + canvas.style.cursor = ""; + if (ghostEl) { ghostEl.remove(); ghostEl = null; } + } + + // Track ghost position + document.addEventListener("mousemove", (e) => { + if (ghostEl) { + ghostEl.style.left = e.clientX + "px"; + ghostEl.style.top = e.clientY + "px"; + } + }); + + // ESC clears pending tool + document.addEventListener("keydown", (e) => { + if (e.key === "Escape" && pendingTool) { + clearPendingTool(); + } + }); + // Create a shape, position it without overlapping others, add to canvas, and register for sync - function newShape(tagName, props = {}) { + // atPosition: optional { x, y } in canvas coordinates to place near + function newShape(tagName, props = {}, atPosition) { const id = `shape-${Date.now()}-${++shapeCounter}`; const defaults = SHAPE_DEFAULTS[tagName] || { width: 300, height: 200 }; const shape = document.createElement(tagName); shape.id = id; - const pos = findFreePosition(defaults.width, defaults.height); + const pos = atPosition + ? findFreePosition(defaults.width, defaults.height, atPosition.x, atPosition.y) + : findFreePosition(defaults.width, defaults.height); shape.x = pos.x; shape.y = pos.y; shape.width = defaults.width; @@ -1606,99 +1740,95 @@ window.__canvasApi = { newShape, findFreePosition, SHAPE_DEFAULTS, setupShapeEventListeners, sync, canvasContent }; installSelectionTransforms(); - // Toolbar button handlers + // Toolbar button handlers — set pending tool for click-to-place document.getElementById("new-markdown").addEventListener("click", () => { - newShape("folk-markdown", { content: "# New Note\n\nStart typing..." }); + setPendingTool("folk-markdown", { content: "# New Note\n\nStart typing..." }); }); document.getElementById("new-wrapper").addEventListener("click", () => { const colors = ["#14b8a6", "#8b5cf6", "#f59e0b", "#ef4444", "#3b82f6", "#22c55e"]; const icons = ["📋", "💡", "📌", "🔗", "📁", "⭐"]; - const shape = newShape("folk-wrapper", { + setPendingTool("folk-wrapper", { title: "New Card", icon: icons[Math.floor(Math.random() * icons.length)], primaryColor: colors[Math.floor(Math.random() * colors.length)], + __postCreate: (shape) => { + const content = document.createElement("div"); + content.style.padding = "16px"; + content.style.color = "#374151"; + content.innerHTML = "

Click to edit this card...

"; + shape.appendChild(content); + } }); - if (shape) { - const content = document.createElement("div"); - content.style.padding = "16px"; - content.style.color = "#374151"; - content.innerHTML = "

Click to edit this card...

"; - shape.appendChild(content); - } }); document.getElementById("new-slide").addEventListener("click", () => { - newShape("folk-slide", { label: `Slide ${shapeCounter}` }); + setPendingTool("folk-slide", { label: `Slide ${shapeCounter}` }); }); document.getElementById("new-chat").addEventListener("click", () => { const id = `shape-${Date.now()}-${shapeCounter}`; - newShape("folk-chat", { roomId: `room-${id}` }); + setPendingTool("folk-chat", { roomId: `room-${id}` }); }); - 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-splat").addEventListener("click", () => newShape("folk-splat")); - document.getElementById("new-blender").addEventListener("click", () => newShape("folk-blender")); - document.getElementById("new-drawfast").addEventListener("click", () => newShape("folk-drawfast")); - document.getElementById("new-freecad").addEventListener("click", () => newShape("folk-freecad")); - document.getElementById("new-kicad").addEventListener("click", () => newShape("folk-kicad")); + document.getElementById("new-piano").addEventListener("click", () => setPendingTool("folk-piano")); + document.getElementById("new-embed").addEventListener("click", () => setPendingTool("folk-embed")); + document.getElementById("new-calendar").addEventListener("click", () => setPendingTool("folk-calendar")); + document.getElementById("new-map").addEventListener("click", () => setPendingTool("folk-map")); + document.getElementById("new-image-gen").addEventListener("click", () => setPendingTool("folk-image-gen")); + document.getElementById("new-video-gen").addEventListener("click", () => setPendingTool("folk-video-gen")); + document.getElementById("new-prompt").addEventListener("click", () => setPendingTool("folk-prompt")); + document.getElementById("new-transcription").addEventListener("click", () => setPendingTool("folk-transcription")); + document.getElementById("new-video-chat").addEventListener("click", () => setPendingTool("folk-video-chat")); + document.getElementById("new-obs-note").addEventListener("click", () => setPendingTool("folk-obs-note")); + document.getElementById("new-workflow").addEventListener("click", () => setPendingTool("folk-workflow-block")); + document.getElementById("new-splat").addEventListener("click", () => setPendingTool("folk-splat")); + document.getElementById("new-blender").addEventListener("click", () => setPendingTool("folk-blender")); + document.getElementById("new-drawfast").addEventListener("click", () => setPendingTool("folk-drawfast")); + document.getElementById("new-freecad").addEventListener("click", () => setPendingTool("folk-freecad")); + document.getElementById("new-kicad").addEventListener("click", () => setPendingTool("folk-kicad")); document.getElementById("new-google-item").addEventListener("click", () => { - newShape("folk-google-item", { service: "drive", title: "New Google Item" }); + setPendingTool("folk-google-item", { service: "drive", title: "New Google Item" }); }); // Trip planning components - 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")); + document.getElementById("new-itinerary").addEventListener("click", () => setPendingTool("folk-itinerary")); + document.getElementById("new-destination").addEventListener("click", () => setPendingTool("folk-destination")); + document.getElementById("new-budget").addEventListener("click", () => setPendingTool("folk-budget")); + document.getElementById("new-packing-list").addEventListener("click", () => setPendingTool("folk-packing-list")); + document.getElementById("new-booking").addEventListener("click", () => setPendingTool("folk-booking")); // Token creation - creates a mint + ledger pair with connecting arrow document.getElementById("new-token").addEventListener("click", () => { - const mint = newShape("folk-token-mint", { + setPendingTool("folk-token-mint", { tokenName: "New Token", tokenSymbol: "TKN", totalSupply: 1000, tokenColor: "#8b5cf6", tokenIcon: "🪙", createdAt: new Date().toISOString(), - }); - if (mint) { - const ledger = newShape("folk-token-ledger", { - mintId: mint.id, - entries: [], - }); - if (ledger) { - // Position ledger to the right of mint - ledger.x = mint.x + mint.width + 60; - ledger.y = mint.y; - // Connect with an arrow - const arrowId = `arrow-${Date.now()}-${++shapeCounter}`; - const arrow = document.createElement("folk-arrow"); - arrow.id = arrowId; - arrow.sourceId = mint.id; - arrow.targetId = ledger.id; - arrow.color = "#8b5cf6"; - canvasContent.appendChild(arrow); - sync.registerShape(arrow); + __postCreate: (mint) => { + const ledger = newShape("folk-token-ledger", { + mintId: mint.id, + entries: [], + }, { x: mint.x + mint.width + 60 + 150, y: mint.y + mint.height / 2 }); + if (ledger) { + const arrowId = `arrow-${Date.now()}-${++shapeCounter}`; + const arrow = document.createElement("folk-arrow"); + arrow.id = arrowId; + arrow.sourceId = mint.id; + arrow.targetId = ledger.id; + arrow.color = "#8b5cf6"; + canvasContent.appendChild(arrow); + sync.registerShape(arrow); + } } - } + }); }); // Decision/choice components document.getElementById("new-choice-vote").addEventListener("click", () => { - newShape("folk-choice-vote", { + setPendingTool("folk-choice-vote", { title: "Quick Poll", options: [ { id: "opt-1", label: "Option A", color: "#3b82f6" }, @@ -1712,7 +1842,7 @@ }); document.getElementById("new-choice-rank").addEventListener("click", () => { - newShape("folk-choice-rank", { + setPendingTool("folk-choice-rank", { title: "Rank These", options: [ { id: "opt-1", label: "Option A" }, @@ -1724,7 +1854,7 @@ }); document.getElementById("new-choice-spider").addEventListener("click", () => { - newShape("folk-choice-spider", { + setPendingTool("folk-choice-spider", { title: "Evaluate Options", options: [ { id: "opt-1", label: "Option A" }, @@ -1742,7 +1872,7 @@ // Social media post document.getElementById("new-social-post").addEventListener("click", () => { - newShape("folk-social-post", { + setPendingTool("folk-social-post", { platform: "x", postType: "text", content: "Write your post content here...", @@ -1775,7 +1905,7 @@ const btn = document.getElementById(app.btnId); if (btn) { btn.addEventListener("click", () => { - newShape("folk-rapp", { moduleId: app.moduleId, spaceSlug: communitySlug }); + setPendingTool("folk-rapp", { moduleId: app.moduleId, spaceSlug: communitySlug }); }); } } @@ -2180,8 +2310,22 @@ const toolbarEl = document.getElementById("toolbar"); mobileMenuBtn.addEventListener("click", () => { - const isOpen = toolbarEl.classList.toggle("mobile-open"); - mobileMenuBtn.textContent = isOpen ? "✕" : "✚"; + // On mobile, first tap opens rApps group in the popout panel + 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 && !toolbarEl.classList.contains("mobile-open")) { + toolbarEl.classList.add("mobile-open"); + mobileMenuBtn.textContent = "✕"; + // Auto-open the rApps panel + setTimeout(() => { + if (typeof openToolbarPanel === "function") openToolbarPanel(rAppsGroup); + }, 50); + } else { + const isOpen = toolbarEl.classList.toggle("mobile-open"); + mobileMenuBtn.textContent = isOpen ? "✕" : "✚"; + if (!isOpen && typeof closeToolbarPanel === "function") closeToolbarPanel(); + } }); // Auto-close toolbar after tapping a shape-creation button on mobile @@ -2199,30 +2343,67 @@ } }); - // Dropdown group toggles + // Popout panel references + const toolbarPanel = document.getElementById("toolbar-panel"); + const toolbarPanelHeader = document.getElementById("toolbar-panel-header"); + const toolbarPanelBody = document.getElementById("toolbar-panel-body"); + let activeToolbarGroup = null; + + function openToolbarPanel(group) { + const toggle = group.querySelector(".toolbar-group-toggle"); + const dropdown = group.querySelector(".toolbar-dropdown"); + if (!dropdown) return; + + // Set header text from the toggle button + toolbarPanelHeader.textContent = toggle.textContent.trim(); + + // Clone dropdown buttons into the panel body + toolbarPanelBody.innerHTML = ""; + for (const btn of dropdown.querySelectorAll("button")) { + const clone = btn.cloneNode(true); + // Forward click to the original button + clone.addEventListener("click", (e) => { + e.stopPropagation(); + btn.click(); + // Close panel after tool is selected (unless it's a whiteboard toggle) + const keepOpen = ["wb-pencil", "wb-rect", "wb-circle", "wb-line", "wb-eraser"]; + if (!keepOpen.includes(btn.id)) { + closeToolbarPanel(); + } + }); + toolbarPanelBody.appendChild(clone); + } + + // Mark group as active + toolbarEl.querySelectorAll(".toolbar-group").forEach(g => g.classList.remove("open")); + group.classList.add("open"); + activeToolbarGroup = group; + toolbarPanel.classList.add("panel-open"); + } + + function closeToolbarPanel() { + toolbarPanel.classList.remove("panel-open"); + toolbarEl.querySelectorAll(".toolbar-group").forEach(g => g.classList.remove("open")); + activeToolbarGroup = null; + } + + // Dropdown group toggles → popout panel toolbarEl.querySelectorAll(".toolbar-group-toggle").forEach(toggle => { toggle.addEventListener("click", (e) => { e.stopPropagation(); const group = toggle.closest(".toolbar-group"); - const wasOpen = group.classList.contains("open"); - // Close all other groups - toolbarEl.querySelectorAll(".toolbar-group.open").forEach(g => g.classList.remove("open")); - // Toggle this one - if (!wasOpen) group.classList.add("open"); + if (activeToolbarGroup === group) { + closeToolbarPanel(); + } else { + openToolbarPanel(group); + } }); }); - // Close dropdowns when clicking a tool inside one - toolbarEl.querySelectorAll(".toolbar-dropdown button").forEach(btn => { - btn.addEventListener("click", () => { - toolbarEl.querySelectorAll(".toolbar-group.open").forEach(g => g.classList.remove("open")); - }); - }); - - // Close dropdowns when clicking outside + // Close panel when clicking outside toolbar + panel document.addEventListener("click", (e) => { - if (!e.target.closest("#toolbar")) { - toolbarEl.querySelectorAll(".toolbar-group.open").forEach(g => g.classList.remove("open")); + if (!e.target.closest("#toolbar") && !e.target.closest("#toolbar-panel")) { + closeToolbarPanel(); } }); @@ -2317,9 +2498,31 @@ canvas.addEventListener("pointerdown", (e) => { if (e.target !== canvas && e.target !== canvasContent) return; if (connectMode) return; - // Clicking canvas background clears MI selection + + // Click-to-place: if a pending tool is set, place it at the click position + if (pendingTool) { + e.preventDefault(); + e.stopPropagation(); + const rect = canvasContent.getBoundingClientRect(); + const canvasX = (e.clientX - rect.left) / scale; + const canvasY = (e.clientY - rect.top) / scale; + const { tagName, props } = pendingTool; + const postCreate = props.__postCreate; + const cleanProps = { ...props }; + delete cleanProps.__postCreate; + const shape = newShape(tagName, cleanProps, { x: canvasX, y: canvasY }); + if (shape && postCreate) postCreate(shape); + clearPendingTool(); + return; + } + + // Clicking canvas background clears MI selection and exits any editing shape selectedShapeId = null; __miCanvasBridge.setSelection([]); + // Exit edit mode on any currently-editing shape + canvasContent.querySelectorAll("folk-shape, folk-markdown, folk-wrapper, folk-slide, folk-chat, folk-obs-note, folk-rapp, folk-embed, folk-drawfast, folk-prompt, folk-workflow-block").forEach(el => { + if (el.exitEditMode) el.exitEditMode(); + }); isPanning = true; panPointerId = e.pointerId; panStartX = e.clientX;