From 38053eee34b2c5802198d22011e9b447ec88e3b9 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Mon, 23 Mar 2026 13:48:08 -0700 Subject: [PATCH] fix(notes): prevent deletion while editing, fix layout, add semantic zoom - Fix Delete/Backspace deleting the note shape when editing text inside it. The canvas keydown handler now uses composedPath() to detect inputs inside Shadow DOM (textarea, title input). Also fixes Space key and Ctrl+Z being intercepted by canvas while typing. - Stop all keydown propagation from the editor/title inputs to prevent arrow keys from moving the shape while editing. - Fix layout: replace calc(100%-36px) with proper flex layout. Header, toolbar, footer are flex-shrink:0; editor fills remaining space. Fixes headers being cut off and weird sizing. - Add semantic zoom: icon-only (<0.3x), summary (0.3-0.7x), full editor (>0.7x). Reads --canvas-scale CSS custom property set by updateCanvasTransform(). Co-Authored-By: Claude Opus 4.6 --- lib/folk-obs-note.ts | 179 +++++++++++++++++++++++++++++++++++++++++-- website/canvas.html | 2 + 2 files changed, 173 insertions(+), 8 deletions(-) diff --git a/lib/folk-obs-note.ts b/lib/folk-obs-note.ts index c826359..1946661 100644 --- a/lib/folk-obs-note.ts +++ b/lib/folk-obs-note.ts @@ -3,12 +3,23 @@ import { css, html } from "./tags"; const styles = css` :host { + display: flex !important; + flex-direction: column; background: var(--rs-bg-surface, #fff); color: var(--rs-text-primary, #1e293b); border-radius: 8px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); min-width: 350px; min-height: 400px; + overflow: hidden; + } + + .note-wrapper { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + min-height: 0; } .header { @@ -22,12 +33,14 @@ const styles = css` font-size: 12px; font-weight: 600; cursor: move; + flex-shrink: 0; } .header-title { display: flex; align-items: center; gap: 6px; + min-width: 0; } .note-title { @@ -37,7 +50,9 @@ const styles = css` font-size: 12px; font-weight: 600; outline: none; - width: 150px; + min-width: 60px; + max-width: 200px; + flex: 1; } .note-title::placeholder { @@ -66,7 +81,9 @@ const styles = css` .content { display: flex; flex-direction: column; - height: calc(100% - 36px); + flex: 1; + min-height: 0; + overflow: hidden; } .toolbar { @@ -76,6 +93,7 @@ const styles = css` padding: 8px 12px; border-bottom: 1px solid var(--rs-border, rgba(255, 255, 255, 0.1)); flex-wrap: wrap; + flex-shrink: 0; } .toolbar-btn { @@ -135,6 +153,7 @@ const styles = css` flex: 1; display: flex; overflow: hidden; + min-height: 0; } .editor { @@ -147,6 +166,8 @@ const styles = css` font-size: 13px; line-height: 1.6; background: var(--rs-bg-surface-sunken, #0f172a); + min-height: 0; + overflow-y: auto; } .preview { @@ -156,6 +177,7 @@ const styles = css` font-size: 14px; line-height: 1.7; display: none; + min-height: 0; } .preview.visible { @@ -241,9 +263,10 @@ const styles = css` align-items: center; justify-content: space-between; padding: 6px 12px; - border-top: 1px solid #e2e8f0; + border-top: 1px solid var(--rs-border, rgba(255, 255, 255, 0.1)); font-size: 11px; color: #64748b; + flex-shrink: 0; } .word-count { @@ -264,6 +287,77 @@ const styles = css` .save-status.unsaved { color: #f59e0b; } + + /* ── Semantic zoom levels ── */ + + /* Icon-only mode: zoomed far out */ + .zoom-icon { + display: none; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + gap: 8px; + padding: 12px; + text-align: center; + } + + .zoom-icon-emoji { + font-size: 48px; + line-height: 1; + } + + .zoom-icon-title { + font-size: 13px; + font-weight: 600; + color: var(--rs-text-primary, #e2e8f0); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; + } + + /* Summary mode: medium zoom */ + .zoom-summary { + display: none; + flex-direction: column; + height: 100%; + } + + .zoom-summary-header { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + background: linear-gradient(135deg, #7c3aed, #8b5cf6); + color: white; + border-radius: 8px 8px 0 0; + font-size: 13px; + font-weight: 600; + flex-shrink: 0; + } + + .zoom-summary-body { + flex: 1; + padding: 12px 14px; + font-size: 13px; + line-height: 1.6; + color: var(--rs-text-secondary, #94a3b8); + overflow: hidden; + } + + /* Zoom state classes applied by JS */ + :host(.zoom-level-icon) .note-wrapper { display: none; } + :host(.zoom-level-icon) .zoom-icon { display: flex; } + :host(.zoom-level-icon) .zoom-summary { display: none; } + + :host(.zoom-level-summary) .note-wrapper { display: none; } + :host(.zoom-level-summary) .zoom-icon { display: none; } + :host(.zoom-level-summary) .zoom-summary { display: flex; } + + :host(.zoom-level-full) .note-wrapper { display: flex; } + :host(.zoom-level-full) .zoom-icon { display: none; } + :host(.zoom-level-full) .zoom-summary { display: none; } `; declare global { @@ -292,12 +386,17 @@ export class FolkObsNote extends FolkShape { #mode: "edit" | "preview" | "split" = "edit"; #isDirty = false; #lastSaved: Date | null = null; + #zoomLevel: "icon" | "summary" | "full" = "full"; + #zoomRaf = 0; #editor: HTMLTextAreaElement | null = null; #preview: HTMLElement | null = null; #titleInput: HTMLInputElement | null = null; #wordCountEl: HTMLElement | null = null; #saveStatusEl: HTMLElement | null = null; + #iconTitleEl: HTMLElement | null = null; + #summaryTitleEl: HTMLElement | null = null; + #summaryBodyEl: HTMLElement | null = null; get content() { return this.#content; @@ -308,6 +407,7 @@ export class FolkObsNote extends FolkShape { if (this.#editor) this.#editor.value = value; this.#updatePreview(); this.#updateWordCount(); + this.#updateZoomContent(); } get title() { @@ -317,12 +417,14 @@ export class FolkObsNote extends FolkShape { set title(value: string) { this.#title = value; if (this.#titleInput) this.#titleInput.value = value; + this.#updateZoomContent(); } override createRenderRoot() { const root = super.createRenderRoot(); const wrapper = document.createElement("div"); + wrapper.className = "note-wrapper"; wrapper.innerHTML = html`
@@ -367,11 +469,25 @@ export class FolkObsNote extends FolkShape {
`; - // Replace the container div (slot's parent) with our wrapper + // Semantic zoom: icon-only view + const iconView = document.createElement("div"); + iconView.className = "zoom-icon"; + iconView.innerHTML = `📝`; + + // Semantic zoom: summary view + const summaryView = document.createElement("div"); + summaryView.className = "zoom-summary"; + summaryView.innerHTML = `
📝
`; + + // Replace the container div (slot's parent) with our wrapper + zoom views const slot = root.querySelector("slot"); const containerDiv = slot?.parentElement as HTMLElement; if (containerDiv) { - containerDiv.replaceWith(wrapper); + const frag = document.createDocumentFragment(); + frag.appendChild(wrapper); + frag.appendChild(iconView); + frag.appendChild(summaryView); + containerDiv.replaceWith(frag); } this.#editor = wrapper.querySelector(".editor"); @@ -379,6 +495,9 @@ export class FolkObsNote extends FolkShape { this.#titleInput = wrapper.querySelector(".note-title"); this.#wordCountEl = wrapper.querySelector(".word-count"); this.#saveStatusEl = wrapper.querySelector(".save-status"); + this.#iconTitleEl = iconView.querySelector(".zoom-icon-title"); + this.#summaryTitleEl = summaryView.querySelector(".zoom-summary-title"); + this.#summaryBodyEl = summaryView.querySelector(".zoom-summary-body"); const saveBtn = wrapper.querySelector(".save-btn") as HTMLButtonElement; const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement; const toolbarBtns = wrapper.querySelectorAll(".toolbar-btn[data-action]"); @@ -391,6 +510,7 @@ export class FolkObsNote extends FolkShape { this.#updatePreview(); this.#updateWordCount(); this.#updateSaveStatus(); + this.#updateZoomContent(); this.dispatchEvent(new CustomEvent("content-change", { detail: { content: this.#content } })); }); @@ -399,6 +519,7 @@ export class FolkObsNote extends FolkShape { this.#title = this.#titleInput?.value || "Untitled"; this.#isDirty = true; this.#updateSaveStatus(); + this.#updateZoomContent(); this.dispatchEvent(new CustomEvent("title-change", { detail: { title: this.#title } })); }); @@ -436,8 +557,10 @@ export class FolkObsNote extends FolkShape { this.dispatchEvent(new CustomEvent("close")); }); - // Keyboard shortcuts - this.#editor?.addEventListener("keydown", (e) => { + // Keyboard shortcuts — stop all propagation from editor/title to prevent + // canvas handlers (delete, arrow-move, space-pan) from firing + const stopKeys = (e: KeyboardEvent) => { + e.stopPropagation(); if ((e.metaKey || e.ctrlKey) && e.key === "s") { e.preventDefault(); this.#save(); @@ -450,11 +573,51 @@ export class FolkObsNote extends FolkShape { e.preventDefault(); this.#applyFormatting("italic"); } - }); + }; + this.#editor?.addEventListener("keydown", stopKeys); + this.#titleInput?.addEventListener("keydown", (e) => e.stopPropagation()); + + // Semantic zoom: poll --canvas-scale CSS custom property + this.classList.add("zoom-level-full"); + this.#updateZoomContent(); + const checkZoom = () => { + const scaleStr = getComputedStyle(this).getPropertyValue("--canvas-scale"); + const s = parseFloat(scaleStr) || 1; + let level: "icon" | "summary" | "full"; + if (s < 0.3) level = "icon"; + else if (s < 0.7) level = "summary"; + else level = "full"; + if (level !== this.#zoomLevel) { + this.classList.remove(`zoom-level-${this.#zoomLevel}`); + this.#zoomLevel = level; + this.classList.add(`zoom-level-${level}`); + this.#updateZoomContent(); + } + this.#zoomRaf = requestAnimationFrame(checkZoom); + }; + this.#zoomRaf = requestAnimationFrame(checkZoom); return root; } + disconnectedCallback() { + if (this.#zoomRaf) cancelAnimationFrame(this.#zoomRaf); + } + + #updateZoomContent() { + if (this.#iconTitleEl) { + this.#iconTitleEl.textContent = this.#title || "Untitled"; + } + if (this.#summaryTitleEl) { + this.#summaryTitleEl.textContent = this.#title || "Untitled"; + } + if (this.#summaryBodyEl) { + // Show first ~200 chars of content as plain text summary + const plain = this.#content.replace(/[#*`>\[\]()-]/g, "").trim(); + this.#summaryBodyEl.textContent = plain.length > 200 ? plain.slice(0, 200) + "..." : plain || "Empty note"; + } + } + #setMode(mode: "edit" | "preview" | "split", buttons: NodeListOf) { this.#mode = mode; diff --git a/website/canvas.html b/website/canvas.html index 443d31e..b1b8bda 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -6305,6 +6305,8 @@ function updateCanvasTransform() { // Transform only the content layer — canvas viewport stays fixed canvasContent.style.transform = `translate(${panX}px, ${panY}px) scale(${scale})`; + // Expose scale as CSS custom property for semantic zoom in shapes + canvasContent.style.setProperty("--canvas-scale", String(scale)); // Adjust grid/dot pattern to track pan/zoom (skip for blank) const bgStyle = document.documentElement.getAttribute('data-canvas-bg') || 'grid'; if (bgStyle !== 'blank') {