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:
parent
83fa874147
commit
38053eee34
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
Loading…
Reference in New Issue