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>\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"></></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>
|
|
`;
|
|
|
|
// 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>\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,
|
|
};
|
|
}
|
|
}
|