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

647 lines
14 KiB
TypeScript

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>\u{1F4DD}</span>
<input type="text" class="note-title" placeholder="Note title..." />
</span>
<div class="header-actions">
<button class="save-btn" title="Save">\u{1F4BE}</button>
<button class="close-btn" title="Close">\u00D7</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">\u{1F517}</button>
<button class="toolbar-btn" data-action="list" title="List">\u2022</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>\u2713</span>
<span>Saved</span>
</div>
</div>
</div>
`;
const slot = root.querySelector("slot");
if (slot?.parentElement) {
const parent = slot.parentElement;
const existingDiv = parent.querySelector("div");
if (existingDiv) {
parent.replaceChild(wrapper, existingDiv);
}
}
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>\u2022</span><span>Unsaved</span>";
} else {
this.#saveStatusEl.className = "save-status saved";
this.#saveStatusEl.innerHTML = "<span>\u2713</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,
};
}
}