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') {