rspace-online/lib/folk-obs-note.ts

822 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { FolkShape } from "./folk-shape";
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 {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: linear-gradient(135deg, #7c3aed, #8b5cf6);
color: white;
border-radius: 8px 8px 0 0;
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 {
background: transparent;
border: none;
color: white;
font-size: 12px;
font-weight: 600;
outline: none;
min-width: 60px;
max-width: 200px;
flex: 1;
}
.note-title::placeholder {
color: rgba(255, 255, 255, 0.7);
}
.header-actions {
display: flex;
gap: 4px;
}
.header-actions button {
background: transparent;
border: none;
color: white;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
font-size: 14px;
}
.header-actions button:hover {
background: rgba(255, 255, 255, 0.2);
}
.content {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
.toolbar {
display: flex;
align-items: center;
gap: 4px;
padding: 8px 12px;
border-bottom: 1px solid var(--rs-border, rgba(255, 255, 255, 0.1));
flex-wrap: wrap;
flex-shrink: 0;
}
.toolbar-btn {
padding: 4px 8px;
border: none;
border-radius: 4px;
background: var(--rs-bg-surface-raised, #334155);
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
}
.toolbar-btn:hover {
background: var(--rs-bg-hover, rgba(255, 255, 255, 0.05));
}
.toolbar-btn.active {
background: #7c3aed;
color: white;
}
.toolbar-divider {
width: 1px;
height: 20px;
background: var(--rs-border, rgba(255, 255, 255, 0.1));
margin: 0 4px;
}
.mode-toggle {
margin-left: auto;
display: flex;
gap: 2px;
background: var(--rs-bg-surface-sunken, #0f172a);
border-radius: 6px;
padding: 2px;
}
.mode-btn {
padding: 4px 10px;
border: none;
border-radius: 4px;
background: transparent;
cursor: pointer;
font-size: 11px;
font-weight: 500;
color: var(--rs-text-muted, #64748b);
transition: all 0.2s;
}
.mode-btn.active {
background: var(--rs-bg-surface, #1e293b);
color: var(--rs-text-primary, #e2e8f0);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.editor-container {
flex: 1;
display: flex;
overflow: hidden;
min-height: 0;
}
.editor {
flex: 1;
padding: 12px 16px;
border: none;
outline: none;
resize: none;
font-family: "Monaco", "Consolas", "Courier New", monospace;
font-size: 13px;
line-height: 1.6;
background: var(--rs-bg-surface-sunken, #0f172a);
min-height: 0;
overflow-y: auto;
}
.preview {
flex: 1;
padding: 12px 16px;
overflow-y: auto;
font-size: 14px;
line-height: 1.7;
display: none;
min-height: 0;
}
.preview.visible {
display: block;
}
.preview h1 {
font-size: 1.5em;
margin: 0 0 0.5em;
border-bottom: 1px solid var(--rs-border, rgba(255, 255, 255, 0.1));
padding-bottom: 0.3em;
}
.preview h2 {
font-size: 1.3em;
margin: 1em 0 0.5em;
}
.preview h3 {
font-size: 1.1em;
margin: 1em 0 0.5em;
}
.preview p {
margin: 0.5em 0;
}
.preview code {
background: var(--rs-bg-surface-raised, #334155);
padding: 2px 6px;
border-radius: 4px;
font-family: "Monaco", "Consolas", monospace;
font-size: 0.9em;
}
.preview pre {
background: #1e293b;
color: #e2e8f0;
padding: 12px 16px;
border-radius: 6px;
overflow-x: auto;
}
.preview pre code {
background: none;
padding: 0;
}
.preview blockquote {
border-left: 4px solid #7c3aed;
margin: 0.5em 0;
padding: 0.5em 1em;
background: rgba(124, 58, 237, 0.1);
color: var(--rs-text-primary, #e2e8f0);
}
.preview ul, .preview ol {
margin: 0.5em 0;
padding-left: 1.5em;
}
.preview li {
margin: 0.25em 0;
}
.preview a {
color: #7c3aed;
text-decoration: none;
}
.preview a:hover {
text-decoration: underline;
}
.preview hr {
border: none;
border-top: 1px solid #e2e8f0;
margin: 1em 0;
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 12px;
border-top: 1px solid var(--rs-border, rgba(255, 255, 255, 0.1));
font-size: 11px;
color: #64748b;
flex-shrink: 0;
}
.word-count {
display: flex;
gap: 12px;
}
.save-status {
display: flex;
align-items: center;
gap: 4px;
}
.save-status.saved {
color: #10b981;
}
.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 {
interface HTMLElementTagNameMap {
"folk-obs-note": FolkObsNote;
}
}
export class FolkObsNote extends FolkShape {
static override tagName = "folk-obs-note";
static {
const sheet = new CSSStyleSheet();
const parentRules = Array.from(FolkShape.styles.cssRules)
.map((r) => r.cssText)
.join("\n");
const childRules = Array.from(styles.cssRules)
.map((r) => r.cssText)
.join("\n");
sheet.replaceSync(`${parentRules}\n${childRules}`);
this.styles = sheet;
}
#content = "";
#title = "Untitled";
#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;
}
set content(value: string) {
this.#content = value;
if (this.#editor) this.#editor.value = value;
this.#updatePreview();
this.#updateWordCount();
this.#updateZoomContent();
}
get title() {
return this.#title;
}
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">
<span>📝</span>
<input type="text" class="note-title" placeholder="Note title..." />
</span>
<div class="header-actions">
<button class="save-btn" title="Save">💾</button>
<button class="close-btn" title="Close">×</button>
</div>
</div>
<div class="content">
<div class="toolbar">
<button class="toolbar-btn" data-action="heading" title="Heading">#</button>
<button class="toolbar-btn" data-action="bold" title="Bold">B</button>
<button class="toolbar-btn" data-action="italic" title="Italic">I</button>
<button class="toolbar-btn" data-action="code" title="Code">&lt;/&gt;</button>
<span class="toolbar-divider"></span>
<button class="toolbar-btn" data-action="link" title="Link">🔗</button>
<button class="toolbar-btn" data-action="list" title="List">•</button>
<button class="toolbar-btn" data-action="quote" title="Quote">"</button>
<div class="mode-toggle">
<button class="mode-btn active" data-mode="edit">Edit</button>
<button class="mode-btn" data-mode="preview">Preview</button>
<button class="mode-btn" data-mode="split">Split</button>
</div>
</div>
<div class="editor-container">
<textarea class="editor" placeholder="Start writing..."></textarea>
<div class="preview"></div>
</div>
<div class="footer">
<div class="word-count">
<span class="words">0 words</span>
<span class="chars">0 characters</span>
</div>
<div class="save-status saved">
<span>✓</span>
<span>Saved</span>
</div>
</div>
</div>
`;
// 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) {
const frag = document.createDocumentFragment();
frag.appendChild(wrapper);
frag.appendChild(iconView);
frag.appendChild(summaryView);
containerDiv.replaceWith(frag);
}
this.#editor = wrapper.querySelector(".editor");
this.#preview = wrapper.querySelector(".preview");
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]");
const modeBtns = wrapper.querySelectorAll(".mode-btn");
// Editor input
this.#editor?.addEventListener("input", () => {
this.#content = this.#editor?.value || "";
this.#isDirty = true;
this.#updatePreview();
this.#updateWordCount();
this.#updateSaveStatus();
this.#updateZoomContent();
this.dispatchEvent(new CustomEvent("content-change", { detail: { content: this.#content } }));
});
// Title input
this.#titleInput?.addEventListener("input", () => {
this.#title = this.#titleInput?.value || "Untitled";
this.#isDirty = true;
this.#updateSaveStatus();
this.#updateZoomContent();
this.dispatchEvent(new CustomEvent("title-change", { detail: { title: this.#title } }));
});
// Prevent drag on inputs
this.#editor?.addEventListener("pointerdown", (e) => e.stopPropagation());
this.#titleInput?.addEventListener("pointerdown", (e) => e.stopPropagation());
// Toolbar actions
toolbarBtns.forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
const action = (btn as HTMLElement).dataset.action;
if (action) this.#applyFormatting(action);
});
});
// Mode toggle
modeBtns.forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
const mode = (btn as HTMLElement).dataset.mode as "edit" | "preview" | "split";
this.#setMode(mode, modeBtns);
});
});
// Save button
saveBtn.addEventListener("click", (e) => {
e.stopPropagation();
this.#save();
});
// Close button
closeBtn.addEventListener("click", (e) => {
e.stopPropagation();
this.dispatchEvent(new CustomEvent("close"));
});
// 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();
}
if ((e.metaKey || e.ctrlKey) && e.key === "b") {
e.preventDefault();
this.#applyFormatting("bold");
}
if ((e.metaKey || e.ctrlKey) && e.key === "i") {
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;
buttons.forEach((btn) => {
btn.classList.toggle("active", (btn as HTMLElement).dataset.mode === mode);
});
if (this.#editor && this.#preview) {
switch (mode) {
case "edit":
this.#editor.style.display = "block";
this.#preview.style.display = "none";
break;
case "preview":
this.#editor.style.display = "none";
this.#preview.style.display = "block";
break;
case "split":
this.#editor.style.display = "block";
this.#preview.style.display = "block";
break;
}
}
this.#updatePreview();
}
#applyFormatting(action: string) {
if (!this.#editor) return;
const start = this.#editor.selectionStart;
const end = this.#editor.selectionEnd;
const text = this.#editor.value;
const selected = text.substring(start, end);
let replacement = selected;
let cursorOffset = 0;
switch (action) {
case "heading":
replacement = `## ${selected}`;
cursorOffset = 3;
break;
case "bold":
replacement = `**${selected}**`;
cursorOffset = selected ? 0 : 2;
break;
case "italic":
replacement = `*${selected}*`;
cursorOffset = selected ? 0 : 1;
break;
case "code":
if (selected.includes("\n")) {
replacement = `\`\`\`\n${selected}\n\`\`\``;
} else {
replacement = `\`${selected}\``;
cursorOffset = selected ? 0 : 1;
}
break;
case "link":
replacement = `[${selected || "link text"}](url)`;
cursorOffset = selected ? selected.length + 3 : 1;
break;
case "list":
replacement = `- ${selected}`;
cursorOffset = 2;
break;
case "quote":
replacement = `> ${selected}`;
cursorOffset = 2;
break;
}
this.#editor.value =
text.substring(0, start) + replacement + text.substring(end);
this.#content = this.#editor.value;
// Set cursor position
const newPos = start + (selected ? replacement.length : cursorOffset);
this.#editor.setSelectionRange(newPos, newPos);
this.#editor.focus();
this.#isDirty = true;
this.#updatePreview();
this.#updateWordCount();
this.#updateSaveStatus();
}
#updatePreview() {
if (!this.#preview) return;
this.#preview.innerHTML = this.#renderMarkdown(this.#content);
}
#renderMarkdown(text: string): string {
let html = this.#escapeHtml(text);
// Code blocks
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, "<pre><code>$2</code></pre>");
// Headers
html = html.replace(/^### (.+)$/gm, "<h3>$1</h3>");
html = html.replace(/^## (.+)$/gm, "<h2>$1</h2>");
html = html.replace(/^# (.+)$/gm, "<h1>$1</h1>");
// Bold/Italic
html = html.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
html = html.replace(/\*(.+?)\*/g, "<em>$1</em>");
// Inline code
html = html.replace(/`([^`]+)`/g, "<code>$1</code>");
// Links
html = html.replace(
/\[([^\]]+)\]\(([^)]+)\)/g,
'<a href="$2" target="_blank" rel="noopener">$1</a>'
);
// Blockquotes
html = html.replace(/^&gt; (.+)$/gm, "<blockquote>$1</blockquote>");
// Lists
html = html.replace(/^- (.+)$/gm, "<li>$1</li>");
html = html.replace(/(<li>.*<\/li>\n?)+/g, "<ul>$&</ul>");
// Horizontal rules
html = html.replace(/^---$/gm, "<hr>");
// Paragraphs
html = html.replace(/\n\n/g, "</p><p>");
html = `<p>${html}</p>`;
html = html.replace(/<p><\/p>/g, "");
return html;
}
#updateWordCount() {
if (!this.#wordCountEl) return;
const words = this.#content
.trim()
.split(/\s+/)
.filter((w) => w.length > 0).length;
const chars = this.#content.length;
this.#wordCountEl.innerHTML = `
<span class="words">${words} words</span>
<span class="chars">${chars} characters</span>
`;
}
#updateSaveStatus() {
if (!this.#saveStatusEl) return;
if (this.#isDirty) {
this.#saveStatusEl.className = "save-status unsaved";
this.#saveStatusEl.innerHTML = "<span>•</span><span>Unsaved</span>";
} else {
this.#saveStatusEl.className = "save-status saved";
this.#saveStatusEl.innerHTML = "<span>✓</span><span>Saved</span>";
}
}
#save() {
this.#isDirty = false;
this.#lastSaved = new Date();
this.#updateSaveStatus();
this.dispatchEvent(
new CustomEvent("save", {
detail: { title: this.#title, content: this.#content },
})
);
}
#escapeHtml(text: string): string {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
static override fromData(data: Record<string, any>): FolkObsNote {
const shape = FolkShape.fromData(data) as FolkObsNote;
if (data.title) shape.title = data.title;
if (data.content) shape.content = data.content;
return shape;
}
override toJSON() {
return {
...super.toJSON(),
type: "folk-obs-note",
title: this.title,
content: this.content,
};
}
override applyData(data: Record<string, any>): void {
super.applyData(data);
if (data.title !== undefined && this.title !== data.title) this.title = data.title;
if (data.content !== undefined && this.content !== data.content) this.content = data.content;
}
}