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

645 lines
14 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 {
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
min-width: 350px;
min-height: 400px;
}
.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;
}
.header-title {
display: flex;
align-items: center;
gap: 6px;
}
.note-title {
background: transparent;
border: none;
color: white;
font-size: 12px;
font-weight: 600;
outline: none;
width: 150px;
}
.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;
height: calc(100% - 36px);
}
.toolbar {
display: flex;
align-items: center;
gap: 4px;
padding: 8px 12px;
border-bottom: 1px solid #e2e8f0;
flex-wrap: wrap;
}
.toolbar-btn {
padding: 4px 8px;
border: none;
border-radius: 4px;
background: #f1f5f9;
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
}
.toolbar-btn:hover {
background: #e2e8f0;
}
.toolbar-btn.active {
background: #7c3aed;
color: white;
}
.toolbar-divider {
width: 1px;
height: 20px;
background: #e2e8f0;
margin: 0 4px;
}
.mode-toggle {
margin-left: auto;
display: flex;
gap: 2px;
background: #f1f5f9;
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: #64748b;
transition: all 0.2s;
}
.mode-btn.active {
background: white;
color: #1e293b;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.editor-container {
flex: 1;
display: flex;
overflow: hidden;
}
.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: #fafafa;
}
.preview {
flex: 1;
padding: 12px 16px;
overflow-y: auto;
font-size: 14px;
line-height: 1.7;
display: none;
}
.preview.visible {
display: block;
}
.preview h1 {
font-size: 1.5em;
margin: 0 0 0.5em;
border-bottom: 1px solid #e2e8f0;
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: #f1f5f9;
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: #faf5ff;
color: #6b21a8;
}
.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 #e2e8f0;
font-size: 11px;
color: #64748b;
}
.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;
}
`;
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;
#editor: HTMLTextAreaElement | null = null;
#preview: HTMLElement | null = null;
#titleInput: HTMLInputElement | null = null;
#wordCountEl: HTMLElement | null = null;
#saveStatusEl: 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();
}
get title() {
return this.#title;
}
set title(value: string) {
this.#title = value;
if (this.#titleInput) this.#titleInput.value = value;
}
override createRenderRoot() {
const root = super.createRenderRoot();
const wrapper = document.createElement("div");
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>
`;
// Replace the container div (slot's parent) with our wrapper
const slot = root.querySelector("slot");
const containerDiv = slot?.parentElement as HTMLElement;
if (containerDiv) {
containerDiv.replaceWith(wrapper);
}
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");
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.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.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
this.#editor?.addEventListener("keydown", (e) => {
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");
}
});
return root;
}
#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;
}
override toJSON() {
return {
...super.toJSON(),
type: "folk-obs-note",
title: this.title,
content: this.content,
};
}
}