diff --git a/bun.lockb b/bun.lockb index 27d31fe..5c0e788 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 839d3b5..f088837 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@tiptap/extension-table-row": "^3.14.0", "@tiptap/extension-task-item": "^3.14.0", "@tiptap/extension-task-list": "^3.14.0", + "@tiptap/extension-text-align": "^3.14.0", "@tiptap/extension-text-style": "^3.14.0", "@tiptap/pm": "^3.14.0", "@tiptap/starter-kit": "^3.14.0", @@ -43,6 +44,7 @@ "google-auth-library": "^10.5.0", "highlight.js": "^11.11.1", "jose": "^6.1.3", + "mermaid": "^11.12.2", "motion": "^12.23.26", "solid-js": "^1.9.5", "solid-tiptap": "^0.8.0", diff --git a/src/app.css b/src/app.css index fa91ccd..8fc463b 100644 --- a/src/app.css +++ b/src/app.css @@ -969,3 +969,257 @@ details[open] div[data-type="details-content"] { transform: translateY(0); } } + +/* Mermaid diagram styles */ +.mermaid-diagram, +pre[data-type="mermaid"] { + margin: 2rem 0; + padding: 1rem; + background-color: var(--color-surface0); + border-radius: 0.5rem; + border: 1px solid var(--color-surface2); + overflow: visible; +} + +.mermaid-diagram code, +pre[data-type="mermaid"] code { + display: block; + white-space: pre; + font-family: "JetBrainsMono", monospace; + color: var(--color-text); + background: transparent; +} + +/* Rendered mermaid SVG container */ +.mermaid-rendered { + display: flex; + justify-content: center; + align-items: center; + min-height: 100px; +} + +.mermaid-rendered svg { + max-width: 100%; + height: auto; +} + +/* Mermaid theme adjustments */ +.mermaid .node rect, +.mermaid .node circle, +.mermaid .node polygon, +.mermaid .node ellipse, +.mermaid .node path { + fill: var(--color-surface1) !important; + stroke: var(--color-blue) !important; + stroke-width: 2px; +} + +.mermaid .node .label, +.mermaid .nodeLabel { + color: var(--color-text) !important; + fill: var(--color-text) !important; +} + +.mermaid .edgePath .path, +.mermaid .flowchart-link { + stroke: var(--color-blue) !important; + stroke-width: 2px; +} + +.mermaid .edgeLabel, +.mermaid .edgeLabel rect { + background-color: var(--color-surface0) !important; + fill: var(--color-surface0) !important; +} + +.mermaid .edgeLabel span { + color: var(--color-text) !important; +} + +.mermaid .cluster rect { + fill: var(--color-surface0) !important; + stroke: var(--color-surface2) !important; +} + +.mermaid .cluster-label { + fill: var(--color-text) !important; +} + +/* Class diagram styles */ +.mermaid .classGroup rect, +.mermaid .classGroup line { + stroke: var(--color-blue) !important; + fill: var(--color-surface1) !important; +} + +.mermaid .classLabel { + fill: var(--color-text) !important; +} + +/* State diagram styles */ +.mermaid .statediagram-state rect { + fill: var(--color-surface1) !important; + stroke: var(--color-blue) !important; +} + +.mermaid .statediagram-state text { + fill: var(--color-text) !important; +} + +/* Sequence diagram styles */ +.mermaid .actor { + fill: var(--color-surface1) !important; + stroke: var(--color-blue) !important; +} + +.mermaid .actor text, +.mermaid .messageText { + fill: var(--color-text) !important; + stroke: none !important; +} + +.mermaid .activation0, +.mermaid .activation1, +.mermaid .activation2 { + fill: var(--color-surface2) !important; + stroke: var(--color-blue) !important; +} + +/* ER diagram styles */ +.mermaid .er.entityBox { + fill: var(--color-surface1) !important; + stroke: var(--color-blue) !important; +} + +.mermaid .er.entityLabel { + fill: var(--color-text) !important; +} + +.mermaid .er.relationshipLabel { + fill: var(--color-text) !important; +} + +/* Gantt chart styles */ +.mermaid .grid .tick line { + stroke: var(--color-surface2) !important; +} + +.mermaid .grid .tick text { + fill: var(--color-text) !important; +} + +.mermaid .task { + fill: var(--color-blue) !important; + stroke: var(--color-blue) !important; +} + +.mermaid .taskText { + fill: var(--color-text) !important; +} + +.mermaid .taskTextOutsideRight, +.mermaid .taskTextOutsideLeft { + fill: var(--color-text) !important; +} + +/* Pie chart styles */ +.mermaid .pieCircle { + stroke: var(--color-surface2) !important; +} + +.mermaid .pieTitleText { + fill: var(--color-text) !important; +} + +.mermaid .slice { + stroke-width: 2px; + stroke: var(--color-surface0) !important; +} + +.mermaid .legend rect { + fill: var(--color-blue) !important; + stroke: var(--color-blue) !important; +} + +/* Override all text elements in mermaid SVG for high contrast */ +.mermaid-rendered svg text, +.mermaid svg text, +svg.mermaid text { + fill: #ffffff !important; + color: #ffffff !important; + stroke: #000000 !important; + stroke-width: 0.25px !important; +} + +/* Ensure percentage labels in pie charts are visible */ +.mermaid text.slice, +.mermaid .slice { + fill: #ffffff !important; + font-weight: bold !important; + font-size: 14px !important; + stroke: #000000 !important; + stroke-width: 0.75px !important; + paint-order: stroke fill !important; +} + +/* Text alignment styles */ +.ProseMirror [style*="text-align: left"], +.ProseMirror p[style*="text-align: left"], +.ProseMirror h1[style*="text-align: left"], +.ProseMirror h2[style*="text-align: left"], +.ProseMirror h3[style*="text-align: left"], +.ProseMirror h4[style*="text-align: left"], +.ProseMirror h5[style*="text-align: left"], +.ProseMirror h6[style*="text-align: left"] { + text-align: left; +} + +.ProseMirror [style*="text-align: center"], +.ProseMirror p[style*="text-align: center"], +.ProseMirror h1[style*="text-align: center"], +.ProseMirror h2[style*="text-align: center"], +.ProseMirror h3[style*="text-align: center"], +.ProseMirror h4[style*="text-align: center"], +.ProseMirror h5[style*="text-align: center"], +.ProseMirror h6[style*="text-align: center"] { + text-align: center; +} + +.ProseMirror [style*="text-align: right"], +.ProseMirror p[style*="text-align: right"], +.ProseMirror h1[style*="text-align: right"], +.ProseMirror h2[style*="text-align: right"], +.ProseMirror h3[style*="text-align: right"], +.ProseMirror h4[style*="text-align: right"], +.ProseMirror h5[style*="text-align: right"], +.ProseMirror h6[style*="text-align: right"] { + text-align: right; +} + +.ProseMirror [style*="text-align: justify"], +.ProseMirror p[style*="text-align: justify"], +.ProseMirror h1[style*="text-align: justify"], +.ProseMirror h2[style*="text-align: justify"], +.ProseMirror h3[style*="text-align: justify"], +.ProseMirror h4[style*="text-align: justify"], +.ProseMirror h5[style*="text-align: justify"], +.ProseMirror h6[style*="text-align: justify"] { + text-align: justify; +} + +/* Image alignment */ +.ProseMirror img[style*="text-align: center"] { + display: block; + margin-left: auto; + margin-right: auto; +} + +.ProseMirror img[style*="text-align: right"] { + display: block; + margin-left: auto; +} + +.ProseMirror img[style*="text-align: left"] { + display: block; + margin-right: auto; +} diff --git a/src/components/blog/MermaidRenderer.tsx b/src/components/blog/MermaidRenderer.tsx new file mode 100644 index 0000000..8530abb --- /dev/null +++ b/src/components/blog/MermaidRenderer.tsx @@ -0,0 +1,51 @@ +import { onMount } from "solid-js"; +import mermaid from "mermaid"; + +// Initialize mermaid once +mermaid.initialize({ + startOnLoad: false, + theme: "dark", + securityLevel: "loose", + fontFamily: "monospace", + themeVariables: { + darkMode: true, + primaryColor: "#2c2f40", + primaryTextColor: "#b5c1f1", + primaryBorderColor: "#739df2", + lineColor: "#739df2", + secondaryColor: "#3e4255", + tertiaryColor: "#505469" + } +}); + +export default function MermaidRenderer() { + onMount(() => { + // Find all mermaid diagrams and render them + const mermaidPres = document.querySelectorAll('pre[data-type="mermaid"]'); + + mermaidPres.forEach(async (pre, index) => { + const code = pre.querySelector("code"); + if (!code) return; + + const content = code.textContent || ""; + if (!content.trim()) return; + + try { + const id = `mermaid-${index}-${Math.random().toString(36).substr(2, 9)}`; + const { svg } = await mermaid.render(id, content); + + // Replace the pre/code with rendered SVG + const wrapper = document.createElement("div"); + wrapper.className = "mermaid-rendered"; + wrapper.innerHTML = svg; + pre.replaceWith(wrapper); + } catch (err) { + console.error("Failed to render mermaid diagram:", err); + // Keep the original code block if rendering fails + pre.classList.add("mermaid-error"); + } + }); + }); + + return null; +} diff --git a/src/components/blog/PostBodyClient.tsx b/src/components/blog/PostBodyClient.tsx index 4aab076..5c2933b 100644 --- a/src/components/blog/PostBodyClient.tsx +++ b/src/components/blog/PostBodyClient.tsx @@ -1,6 +1,7 @@ import { createEffect } from "solid-js"; import { createSignal } from "solid-js"; import type { HLJSApi } from "highlight.js"; +import MermaidRenderer from "./MermaidRenderer"; export interface PostBodyClientProps { body: string; @@ -121,6 +122,7 @@ export default function PostBodyClient(props: PostBodyClientProps) { class="text-text prose dark:prose-invert max-w-none" innerHTML={props.body} /> + ); } diff --git a/src/components/blog/TextEditor.tsx b/src/components/blog/TextEditor.tsx index 9aaf1fa..5420862 100644 --- a/src/components/blog/TextEditor.tsx +++ b/src/components/blog/TextEditor.tsx @@ -15,6 +15,8 @@ import DetailsSummary from "@tiptap/extension-details-summary"; import DetailsContent from "@tiptap/extension-details-content"; import { Node } from "@tiptap/core"; import { createLowlight, common } from "lowlight"; +import { Mermaid } from "./extensions/Mermaid"; +import TextAlign from "@tiptap/extension-text-align"; import css from "highlight.js/lib/languages/css"; import js from "highlight.js/lib/languages/javascript"; import ts from "highlight.js/lib/languages/typescript"; @@ -109,6 +111,160 @@ const AVAILABLE_LANGUAGES = [ { value: "yaml", label: "YAML" } ] as const; +// Mermaid diagram templates +const MERMAID_TEMPLATES = [ + { + name: "Flowchart", + code: `graph TD + A[Start] --> B{Decision} + B -->|Yes| C[Option 1] + B -->|No| D[Option 2] + C --> E[End] + D --> E` + }, + { + name: "Sequence Diagram", + code: `sequenceDiagram + participant A as Alice + participant B as Bob + A->>B: Hello Bob! + B->>A: Hello Alice!` + }, + { + name: "State Diagram", + code: `stateDiagram-v2 + [*] --> Idle + Idle --> Processing + Processing --> Done + Done --> [*]` + }, + { + name: "Class Diagram", + code: `classDiagram + class Animal { + +String name + +makeSound() + } + class Dog { + +bark() + } + Animal <|-- Dog` + }, + { + name: "Entity Relationship", + code: `erDiagram + CUSTOMER ||--o{ ORDER : places + ORDER ||--|{ LINE-ITEM : contains + CUSTOMER { + string name + string email + }` + }, + { + name: "Gantt Chart", + code: `gantt + title Project Timeline + dateFormat YYYY-MM-DD + section Phase 1 + Task 1 :a1, 2024-01-01, 30d + Task 2 :after a1, 20d` + }, + { + name: "Pie Chart", + code: `pie title Languages Used + "JavaScript" : 45 + "TypeScript" : 30 + "Python" : 15 + "Go" : 10` + } +]; + +// Keyboard shortcuts data +interface ShortcutCategory { + name: string; + shortcuts: Array<{ + keys: string; + keysAlt?: string; + description: string; + }>; +} + +const KEYBOARD_SHORTCUTS: ShortcutCategory[] = [ + { + name: "Text Formatting", + shortcuts: [ + { keys: "⌘ B", keysAlt: "Ctrl B", description: "Bold" }, + { keys: "⌘ I", keysAlt: "Ctrl I", description: "Italic" }, + { keys: "⌘ ⇧ X", keysAlt: "Ctrl Shift X", description: "Strikethrough" }, + { keys: "⌘ E", keysAlt: "Ctrl E", description: "Inline Code" } + ] + }, + { + name: "Headings", + shortcuts: [ + { keys: "⌘ ⌥ 1", keysAlt: "Ctrl Alt 1", description: "Heading 1" }, + { keys: "⌘ ⌥ 2", keysAlt: "Ctrl Alt 2", description: "Heading 2" }, + { keys: "⌘ ⌥ 3", keysAlt: "Ctrl Alt 3", description: "Heading 3" }, + { keys: "⌘ ⌥ 0", keysAlt: "Ctrl Alt 0", description: "Paragraph" } + ] + }, + { + name: "Lists", + shortcuts: [ + { keys: "⌘ ⇧ 7", keysAlt: "Ctrl Shift 7", description: "Ordered List" }, + { keys: "⌘ ⇧ 8", keysAlt: "Ctrl Shift 8", description: "Bullet List" }, + { keys: "⌘ ⇧ 9", keysAlt: "Ctrl Shift 9", description: "Task List" }, + { keys: "Tab", keysAlt: "Tab", description: "Indent" }, + { keys: "⇧ Tab", keysAlt: "Shift Tab", description: "Outdent" } + ] + }, + { + name: "Text Alignment", + shortcuts: [ + { keys: "⌘ ⇧ L", keysAlt: "Ctrl Shift L", description: "Align Left" }, + { keys: "⌘ ⇧ E", keysAlt: "Ctrl Shift E", description: "Align Center" }, + { keys: "⌘ ⇧ R", keysAlt: "Ctrl Shift R", description: "Align Right" }, + { keys: "⌘ ⇧ J", keysAlt: "Ctrl Shift J", description: "Justify" } + ] + }, + { + name: "Insert", + shortcuts: [ + { keys: "⌘ K", keysAlt: "Ctrl K", description: "Insert/Edit Link" }, + { keys: "⌘ ⇧ C", keysAlt: "Ctrl Shift C", description: "Code Block" }, + { keys: "⌘ Enter", keysAlt: "Ctrl Enter", description: "Hard Break" }, + { keys: "⌘ ⇧ -", keysAlt: "Ctrl Shift -", description: "Horizontal Rule" } + ] + }, + { + name: "Editing", + shortcuts: [ + { keys: "⌘ Z", keysAlt: "Ctrl Z", description: "Undo" }, + { keys: "⌘ ⇧ Z", keysAlt: "Ctrl Shift Z", description: "Redo" }, + { keys: "⌘ Y", keysAlt: "Ctrl Y", description: "Redo (Alt)" }, + { keys: "⌘ A", keysAlt: "Ctrl A", description: "Select All" } + ] + }, + { + name: "Other", + shortcuts: [ + { + keys: "⌘ ⇧ \\", + keysAlt: "Ctrl Shift \\", + description: "Clear Formatting" + }, + { keys: "ESC", keysAlt: "ESC", description: "Exit Fullscreen" } + ] + } +]; + +const isMac = () => { + return ( + typeof window !== "undefined" && + /Mac|iPhone|iPad|iPod/.test(window.navigator.platform) + ); +}; + // IFrame extension interface IframeOptions { allowFullscreen: boolean; @@ -212,6 +368,14 @@ export default function TextEditor(props: TextEditorProps) { left: 0 }); + const [showMermaidTemplates, setShowMermaidTemplates] = createSignal(false); + const [mermaidMenuPosition, setMermaidMenuPosition] = createSignal({ + top: 0, + left: 0 + }); + + const [showKeyboardHelp, setShowKeyboardHelp] = createSignal(false); + const [isFullscreen, setIsFullscreen] = createSignal(false); const editor = createTiptapEditor(() => ({ @@ -262,6 +426,12 @@ export default function TextEditor(props: TextEditorProps) { HTMLAttributes: { class: "tiptap-table-cell" } + }), + Mermaid, + TextAlign.configure({ + types: ["heading", "paragraph"], + alignments: ["left", "center", "right", "justify"], + defaultAlignment: "left" }) ], content: props.preSet || `

Hello! World

`, @@ -638,6 +808,44 @@ export default function TextEditor(props: TextEditorProps) { } }); + // Close mermaid menu on outside click + createEffect(() => { + if (showMermaidTemplates()) { + const handleClickOutside = (e: MouseEvent) => { + const target = e.target as HTMLElement; + if ( + !target.closest(".mermaid-menu") && + !target.closest("[data-mermaid-trigger]") + ) { + setShowMermaidTemplates(false); + } + }; + + setTimeout(() => { + document.addEventListener("click", handleClickOutside); + }, 0); + + return () => document.removeEventListener("click", handleClickOutside); + } + }); + + const showMermaidSelector = (e: MouseEvent) => { + const buttonRect = (e.currentTarget as HTMLElement).getBoundingClientRect(); + setMermaidMenuPosition({ + top: buttonRect.bottom + 5, + left: buttonRect.left + }); + setShowMermaidTemplates(!showMermaidTemplates()); + }; + + const insertMermaidDiagram = (template: (typeof MERMAID_TEMPLATES)[0]) => { + const instance = editor(); + if (!instance) return; + + instance.chain().focus().setMermaid(template.code).run(); + setShowMermaidTemplates(false); + }; + // Toggle fullscreen mode const toggleFullscreen = () => { setIsFullscreen(!isFullscreen()); @@ -1005,6 +1213,34 @@ export default function TextEditor(props: TextEditorProps) { + {/* Mermaid Template Selector */} + +
+
+
+ Select Diagram Type +
+
+ + {(template) => ( + + )} + +
+
+
+ + + +
+
+ {/* Table controls - shown when cursor is in a table */} @@ -1351,6 +1663,66 @@ export default function TextEditor(props: TextEditorProps) { "h-[calc(100dvh-8rem)]": isFullscreen() }} /> + + {/* Keyboard Help Modal */} + +
setShowKeyboardHelp(false)} + > +
e.stopPropagation()} + > + {/* Header */} +
+

Keyboard Shortcuts

+ +
+ + {/* Shortcuts Grid */} +
+ + {(category) => ( +
+

+ {category.name} +

+
+ + {(shortcut) => ( +
+ + {shortcut.description} + + + {isMac() + ? shortcut.keys + : shortcut.keysAlt || shortcut.keys} + +
+ )} +
+
+
+ )} +
+
+ + {/* Footer */} +
+ Press ⌨ Help button + to toggle this help +
+
+
+
); } diff --git a/src/components/blog/extensions/Mermaid.ts b/src/components/blog/extensions/Mermaid.ts new file mode 100644 index 0000000..0334b08 --- /dev/null +++ b/src/components/blog/extensions/Mermaid.ts @@ -0,0 +1,63 @@ +import { Node, mergeAttributes } from "@tiptap/core"; + +export const Mermaid = Node.create({ + name: "mermaid", + group: "block", + content: "text*", + marks: "", + code: true, + + addAttributes() { + return { + language: { + default: "mermaid", + parseHTML: (element) => element.getAttribute("data-language"), + renderHTML: (attributes) => { + return { + "data-language": attributes.language + }; + } + } + }; + }, + + parseHTML() { + return [ + { + tag: 'pre[data-type="mermaid"]' + } + ]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + "pre", + mergeAttributes(HTMLAttributes, { + "data-type": "mermaid", + class: "mermaid-diagram" + }), + ["code", 0] + ]; + }, + + addCommands() { + return { + setMermaid: + (content: string) => + ({ commands }) => { + return commands.insertContent({ + type: this.name, + content: [{ type: "text", text: content }] + }); + } + }; + } +}); + +declare module "@tiptap/core" { + interface Commands { + mermaid: { + setMermaid: (content: string) => ReturnType; + }; + } +}