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`
: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`
<div class="header">
<span class="header-title">
@ -367,11 +469,25 @@ export class FolkObsNote extends FolkShape {
</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 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<Element>) {
this.#mode = mode;

View File

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