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`
: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') {