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`
|
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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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') {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue