645 lines
14 KiB
TypeScript
645 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>📝</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"></></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(/^> (.+)$/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,
|
||
};
|
||
}
|
||
}
|