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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-23 13:48:08 -07:00
parent 83fa874147
commit 38053eee34
2 changed files with 173 additions and 8 deletions

View File

@ -3,12 +3,23 @@ import { css, html } from "./tags";
const styles = css` const styles = css`
:host { :host {
display: flex !important;
flex-direction: column;
background: var(--rs-bg-surface, #fff); background: var(--rs-bg-surface, #fff);
color: var(--rs-text-primary, #1e293b); color: var(--rs-text-primary, #1e293b);
border-radius: 8px; border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
min-width: 350px; min-width: 350px;
min-height: 400px; min-height: 400px;
overflow: hidden;
}
.note-wrapper {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
min-height: 0;
} }
.header { .header {
@ -22,12 +33,14 @@ const styles = css`
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
cursor: move; cursor: move;
flex-shrink: 0;
} }
.header-title { .header-title {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
min-width: 0;
} }
.note-title { .note-title {
@ -37,7 +50,9 @@ const styles = css`
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
outline: none; outline: none;
width: 150px; min-width: 60px;
max-width: 200px;
flex: 1;
} }
.note-title::placeholder { .note-title::placeholder {
@ -66,7 +81,9 @@ const styles = css`
.content { .content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: calc(100% - 36px); flex: 1;
min-height: 0;
overflow: hidden;
} }
.toolbar { .toolbar {
@ -76,6 +93,7 @@ const styles = css`
padding: 8px 12px; padding: 8px 12px;
border-bottom: 1px solid var(--rs-border, rgba(255, 255, 255, 0.1)); border-bottom: 1px solid var(--rs-border, rgba(255, 255, 255, 0.1));
flex-wrap: wrap; flex-wrap: wrap;
flex-shrink: 0;
} }
.toolbar-btn { .toolbar-btn {
@ -135,6 +153,7 @@ const styles = css`
flex: 1; flex: 1;
display: flex; display: flex;
overflow: hidden; overflow: hidden;
min-height: 0;
} }
.editor { .editor {
@ -147,6 +166,8 @@ const styles = css`
font-size: 13px; font-size: 13px;
line-height: 1.6; line-height: 1.6;
background: var(--rs-bg-surface-sunken, #0f172a); background: var(--rs-bg-surface-sunken, #0f172a);
min-height: 0;
overflow-y: auto;
} }
.preview { .preview {
@ -156,6 +177,7 @@ const styles = css`
font-size: 14px; font-size: 14px;
line-height: 1.7; line-height: 1.7;
display: none; display: none;
min-height: 0;
} }
.preview.visible { .preview.visible {
@ -241,9 +263,10 @@ const styles = css`
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 6px 12px; padding: 6px 12px;
border-top: 1px solid #e2e8f0; border-top: 1px solid var(--rs-border, rgba(255, 255, 255, 0.1));
font-size: 11px; font-size: 11px;
color: #64748b; color: #64748b;
flex-shrink: 0;
} }
.word-count { .word-count {
@ -264,6 +287,77 @@ const styles = css`
.save-status.unsaved { .save-status.unsaved {
color: #f59e0b; 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 { declare global {
@ -292,12 +386,17 @@ export class FolkObsNote extends FolkShape {
#mode: "edit" | "preview" | "split" = "edit"; #mode: "edit" | "preview" | "split" = "edit";
#isDirty = false; #isDirty = false;
#lastSaved: Date | null = null; #lastSaved: Date | null = null;
#zoomLevel: "icon" | "summary" | "full" = "full";
#zoomRaf = 0;
#editor: HTMLTextAreaElement | null = null; #editor: HTMLTextAreaElement | null = null;
#preview: HTMLElement | null = null; #preview: HTMLElement | null = null;
#titleInput: HTMLInputElement | null = null; #titleInput: HTMLInputElement | null = null;
#wordCountEl: HTMLElement | null = null; #wordCountEl: HTMLElement | null = null;
#saveStatusEl: HTMLElement | null = null; #saveStatusEl: HTMLElement | null = null;
#iconTitleEl: HTMLElement | null = null;
#summaryTitleEl: HTMLElement | null = null;
#summaryBodyEl: HTMLElement | null = null;
get content() { get content() {
return this.#content; return this.#content;
@ -308,6 +407,7 @@ export class FolkObsNote extends FolkShape {
if (this.#editor) this.#editor.value = value; if (this.#editor) this.#editor.value = value;
this.#updatePreview(); this.#updatePreview();
this.#updateWordCount(); this.#updateWordCount();
this.#updateZoomContent();
} }
get title() { get title() {
@ -317,12 +417,14 @@ export class FolkObsNote extends FolkShape {
set title(value: string) { set title(value: string) {
this.#title = value; this.#title = value;
if (this.#titleInput) this.#titleInput.value = value; if (this.#titleInput) this.#titleInput.value = value;
this.#updateZoomContent();
} }
override createRenderRoot() { override createRenderRoot() {
const root = super.createRenderRoot(); const root = super.createRenderRoot();
const wrapper = document.createElement("div"); const wrapper = document.createElement("div");
wrapper.className = "note-wrapper";
wrapper.innerHTML = html` wrapper.innerHTML = html`
<div class="header"> <div class="header">
<span class="header-title"> <span class="header-title">
@ -367,11 +469,25 @@ export class FolkObsNote extends FolkShape {
</div> </div>
`; `;
// 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 = `<span class="zoom-icon-emoji">📝</span><span class="zoom-icon-title"></span>`;
// Semantic zoom: summary view
const summaryView = document.createElement("div");
summaryView.className = "zoom-summary";
summaryView.innerHTML = `<div class="zoom-summary-header"><span>📝</span><span class="zoom-summary-title"></span></div><div class="zoom-summary-body"></div>`;
// Replace the container div (slot's parent) with our wrapper + zoom views
const slot = root.querySelector("slot"); const slot = root.querySelector("slot");
const containerDiv = slot?.parentElement as HTMLElement; const containerDiv = slot?.parentElement as HTMLElement;
if (containerDiv) { 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"); this.#editor = wrapper.querySelector(".editor");
@ -379,6 +495,9 @@ export class FolkObsNote extends FolkShape {
this.#titleInput = wrapper.querySelector(".note-title"); this.#titleInput = wrapper.querySelector(".note-title");
this.#wordCountEl = wrapper.querySelector(".word-count"); this.#wordCountEl = wrapper.querySelector(".word-count");
this.#saveStatusEl = wrapper.querySelector(".save-status"); 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 saveBtn = wrapper.querySelector(".save-btn") as HTMLButtonElement;
const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement; const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement;
const toolbarBtns = wrapper.querySelectorAll(".toolbar-btn[data-action]"); const toolbarBtns = wrapper.querySelectorAll(".toolbar-btn[data-action]");
@ -391,6 +510,7 @@ export class FolkObsNote extends FolkShape {
this.#updatePreview(); this.#updatePreview();
this.#updateWordCount(); this.#updateWordCount();
this.#updateSaveStatus(); this.#updateSaveStatus();
this.#updateZoomContent();
this.dispatchEvent(new CustomEvent("content-change", { detail: { content: this.#content } })); 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.#title = this.#titleInput?.value || "Untitled";
this.#isDirty = true; this.#isDirty = true;
this.#updateSaveStatus(); this.#updateSaveStatus();
this.#updateZoomContent();
this.dispatchEvent(new CustomEvent("title-change", { detail: { title: this.#title } })); this.dispatchEvent(new CustomEvent("title-change", { detail: { title: this.#title } }));
}); });
@ -436,8 +557,10 @@ export class FolkObsNote extends FolkShape {
this.dispatchEvent(new CustomEvent("close")); this.dispatchEvent(new CustomEvent("close"));
}); });
// Keyboard shortcuts // Keyboard shortcuts — stop all propagation from editor/title to prevent
this.#editor?.addEventListener("keydown", (e) => { // canvas handlers (delete, arrow-move, space-pan) from firing
const stopKeys = (e: KeyboardEvent) => {
e.stopPropagation();
if ((e.metaKey || e.ctrlKey) && e.key === "s") { if ((e.metaKey || e.ctrlKey) && e.key === "s") {
e.preventDefault(); e.preventDefault();
this.#save(); this.#save();
@ -450,11 +573,51 @@ export class FolkObsNote extends FolkShape {
e.preventDefault(); e.preventDefault();
this.#applyFormatting("italic"); 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; 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<Element>) { #setMode(mode: "edit" | "preview" | "split", buttons: NodeListOf<Element>) {
this.#mode = mode; this.#mode = mode;

View File

@ -6305,6 +6305,8 @@
function updateCanvasTransform() { function updateCanvasTransform() {
// Transform only the content layer — canvas viewport stays fixed // Transform only the content layer — canvas viewport stays fixed
canvasContent.style.transform = `translate(${panX}px, ${panY}px) scale(${scale})`; 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) // Adjust grid/dot pattern to track pan/zoom (skip for blank)
const bgStyle = document.documentElement.getAttribute('data-canvas-bg') || 'grid'; const bgStyle = document.documentElement.getAttribute('data-canvas-bg') || 'grid';
if (bgStyle !== 'blank') { if (bgStyle !== 'blank') {