import { FolkShape } from "./folk-shape";
import { css, html } from "./tags";
import { SpeechDictation } from "./speech-dictation";
const styles = css`
:host {
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
min-width: 80px;
min-height: 40px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: #14b8a6;
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;
}
.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);
}
.header-actions button.mic-recording {
animation: micPulse 1.5s infinite;
}
@keyframes micPulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.2); }
}
.content {
padding: 12px;
height: calc(100% - 36px);
overflow: auto;
}
.editor {
width: 100%;
height: 100%;
border: none;
outline: none;
resize: none;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 14px;
line-height: 1.5;
}
.markdown-preview {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 14px;
line-height: 1.6;
}
.markdown-preview h1 {
font-size: 1.5em;
margin: 0 0 0.5em;
color: #14b8a6;
}
.markdown-preview h2 {
font-size: 1.25em;
margin: 0.5em 0;
color: #14b8a6;
}
.markdown-preview p {
margin: 0.5em 0;
}
.markdown-preview code {
background: #f1f5f9;
padding: 2px 4px;
border-radius: 3px;
font-family: monospace;
}
.markdown-preview pre {
background: #f1f5f9;
padding: 12px;
border-radius: 6px;
overflow-x: auto;
}
.markdown-preview pre code {
background: none;
padding: 0;
}
.markdown-preview ul,
.markdown-preview ol {
margin: 0.5em 0;
padding-left: 1.5em;
}
.markdown-preview blockquote {
border-left: 3px solid #14b8a6;
margin: 0.5em 0;
padding-left: 1em;
color: #64748b;
}
`;
declare global {
interface HTMLElementTagNameMap {
"folk-markdown": FolkMarkdown;
}
}
export class FolkMarkdown extends FolkShape {
static override tagName = "folk-markdown";
// Merge parent and child styles
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 = "";
#isEditing = false;
get content() {
return this.#content;
}
set content(value: string) {
this.#content = value;
this.requestUpdate("content");
this.dispatchEvent(new CustomEvent("content-change", { detail: { content: value } }));
}
override createRenderRoot() {
const root = super.createRenderRoot();
// Add markdown-specific UI
const wrapper = document.createElement("div");
wrapper.innerHTML = html`
`;
// 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);
}
// Get references to elements
const preview = wrapper.querySelector(".markdown-preview") as HTMLElement;
const editor = wrapper.querySelector(".editor") as HTMLTextAreaElement;
const editBtn = wrapper.querySelector(".edit-btn") as HTMLButtonElement;
const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement;
// Helper to enter/exit markdown edit mode
const enterMarkdownEdit = () => {
if (this.#isEditing) return;
this.#isEditing = true;
editor.style.display = "block";
preview.style.display = "none";
editor.value = this.#content;
editor.focus();
};
const exitMarkdownEdit = () => {
if (!this.#isEditing) return;
this.#isEditing = false;
editor.style.display = "none";
preview.style.display = "block";
this.content = editor.value;
preview.innerHTML = this.#renderMarkdown(this.#content);
};
// Edit toggle button
editBtn.addEventListener("click", (e) => {
e.stopPropagation();
if (this.#isEditing) {
exitMarkdownEdit();
} else {
enterMarkdownEdit();
}
});
// Click on preview enters edit mode (when shape is focused/editing)
preview.addEventListener("click", (e) => {
e.stopPropagation();
enterMarkdownEdit();
});
// When parent shape enters edit mode, also enter markdown edit
this.addEventListener("edit-enter", () => {
enterMarkdownEdit();
});
// When parent shape exits edit mode, also exit markdown edit
this.addEventListener("edit-exit", () => {
exitMarkdownEdit();
});
// Close button
closeBtn.addEventListener("click", (e) => {
e.stopPropagation();
this.dispatchEvent(new CustomEvent("close"));
});
// Voice dictation
const micBtn = wrapper.querySelector(".mic-btn") as HTMLButtonElement | null;
if (micBtn) {
const dictation = new SpeechDictation({
onInterim: (text) => {
// Show interim in editor (will be replaced by final)
},
onFinal: (text) => {
enterMarkdownEdit();
const pos = editor.selectionStart;
const before = editor.value.slice(0, pos);
const after = editor.value.slice(pos);
const sep = before && !before.endsWith(" ") && !before.endsWith("\n") ? " " : "";
editor.value = before + sep + text + after;
this.#content = editor.value;
editor.selectionStart = editor.selectionEnd = pos + sep.length + text.length;
},
onStateChange: (recording) => {
micBtn.classList.toggle("mic-recording", recording);
if (recording) enterMarkdownEdit();
},
onError: (err) => console.warn("Markdown dictation:", err),
});
micBtn.addEventListener("click", (e) => {
e.stopPropagation();
dictation.toggle();
});
}
// Editor input
editor.addEventListener("input", () => {
this.#content = editor.value;
});
editor.addEventListener("blur", () => {
exitMarkdownEdit();
});
// Initial render
this.#content = this.getAttribute("content") || "# Hello World\n\nStart typing...";
preview.innerHTML = this.#renderMarkdown(this.#content);
return root;
}
#renderMarkdown(text: string): string {
// Simple markdown renderer
return text
.replace(/^### (.+)$/gm, "$1
")
.replace(/^## (.+)$/gm, "$1
")
.replace(/^# (.+)$/gm, "$1
")
.replace(/\*\*(.+?)\*\*/g, "$1")
.replace(/\*(.+?)\*/g, "$1")
.replace(/`(.+?)`/g, "$1")
.replace(/^- (.+)$/gm, "$1")
.replace(/(.*<\/li>)/s, "")
.replace(/^> (.+)$/gm, "$1
")
.replace(/\n\n/g, "")
.replace(/^(.+)$/gm, (match) => {
if (
match.startsWith("${match}
`;
});
}
toJSON() {
return {
type: "folk-markdown",
id: this.id,
x: this.x,
y: this.y,
width: this.width,
height: this.height,
rotation: this.rotation,
content: this.content,
};
}
}