Merge branch 'dev'

This commit is contained in:
Jeff Emmett 2026-03-23 13:48:19 -07:00
commit 4139b829a1
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') {