import { Show, untrack, createEffect, on, createSignal, For } from "solid-js"; import { createTiptapEditor, useEditorHTML } from "solid-tiptap"; import StarterKit from "@tiptap/starter-kit"; import Link from "@tiptap/extension-link"; import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight"; import Image from "@tiptap/extension-image"; import { Table } from "@tiptap/extension-table"; import { TableRow } from "@tiptap/extension-table-row"; import { TableHeader } from "@tiptap/extension-table-header"; import { TableCell } from "@tiptap/extension-table-cell"; import TaskList from "@tiptap/extension-task-list"; import TaskItem from "@tiptap/extension-task-item"; import Details from "@tiptap/extension-details"; 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"; import ocaml from "highlight.js/lib/languages/ocaml"; import rust from "highlight.js/lib/languages/rust"; import python from "highlight.js/lib/languages/python"; import java from "highlight.js/lib/languages/java"; import go from "highlight.js/lib/languages/go"; import c from "highlight.js/lib/languages/c"; import cpp from "highlight.js/lib/languages/cpp"; import csharp from "highlight.js/lib/languages/csharp"; import sql from "highlight.js/lib/languages/sql"; import bash from "highlight.js/lib/languages/bash"; import json from "highlight.js/lib/languages/json"; import yaml from "highlight.js/lib/languages/yaml"; import markdown from "highlight.js/lib/languages/markdown"; import xml from "highlight.js/lib/languages/xml"; import php from "highlight.js/lib/languages/php"; import ruby from "highlight.js/lib/languages/ruby"; import swift from "highlight.js/lib/languages/swift"; import kotlin from "highlight.js/lib/languages/kotlin"; import dockerfile from "highlight.js/lib/languages/dockerfile"; // Create lowlight instance with common languages const lowlight = createLowlight(common); // Register existing languages lowlight.register("css", css); lowlight.register("js", js); lowlight.register("javascript", js); lowlight.register("ts", ts); lowlight.register("typescript", ts); lowlight.register("ocaml", ocaml); lowlight.register("rust", rust); // Register new languages lowlight.register("python", python); lowlight.register("py", python); lowlight.register("java", java); lowlight.register("go", go); lowlight.register("golang", go); lowlight.register("c", c); lowlight.register("cpp", cpp); lowlight.register("c++", cpp); lowlight.register("csharp", csharp); lowlight.register("cs", csharp); lowlight.register("sql", sql); lowlight.register("bash", bash); lowlight.register("shell", bash); lowlight.register("sh", bash); lowlight.register("json", json); lowlight.register("yaml", yaml); lowlight.register("yml", yaml); lowlight.register("markdown", markdown); lowlight.register("md", markdown); lowlight.register("xml", xml); lowlight.register("html", xml); lowlight.register("php", php); lowlight.register("ruby", ruby); lowlight.register("rb", ruby); lowlight.register("swift", swift); lowlight.register("kotlin", kotlin); lowlight.register("kt", kotlin); lowlight.register("dockerfile", dockerfile); lowlight.register("docker", dockerfile); // Available languages for selector const AVAILABLE_LANGUAGES = [ { value: null, label: "Plain Text" }, { value: "bash", label: "Bash/Shell" }, { value: "c", label: "C" }, { value: "cpp", label: "C++" }, { value: "csharp", label: "C#" }, { value: "css", label: "CSS" }, { value: "dockerfile", label: "Dockerfile" }, { value: "go", label: "Go" }, { value: "html", label: "HTML" }, { value: "java", label: "Java" }, { value: "javascript", label: "JavaScript" }, { value: "json", label: "JSON" }, { value: "kotlin", label: "Kotlin" }, { value: "markdown", label: "Markdown" }, { value: "ocaml", label: "OCaml" }, { value: "php", label: "PHP" }, { value: "python", label: "Python" }, { value: "ruby", label: "Ruby" }, { value: "rust", label: "Rust" }, { value: "sql", label: "SQL" }, { value: "swift", label: "Swift" }, { value: "typescript", label: "TypeScript" }, { value: "xml", label: "XML" }, { 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; HTMLAttributes: { [key: string]: any; }; } declare module "@tiptap/core" { interface Commands { iframe: { setIframe: (options: { src: string }) => ReturnType; }; } } const IframeEmbed = Node.create({ name: "iframe", group: "block", atom: true, addOptions() { return { allowFullscreen: true, HTMLAttributes: { class: "iframe-wrapper" } }; }, addAttributes() { return { src: { default: null }, frameborder: { default: 0 }, allowfullscreen: { default: this.options.allowFullscreen, parseHTML: () => this.options.allowFullscreen } }; }, parseHTML() { return [ { tag: "iframe" } ]; }, renderHTML({ HTMLAttributes }) { return ["div", this.options.HTMLAttributes, ["iframe", HTMLAttributes]]; }, addCommands() { return { setIframe: (options: { src: string }) => ({ tr, dispatch }) => { const { selection } = tr; const node = this.type.create(options); if (dispatch) { tr.replaceRangeWith(selection.from, selection.to, node); } return true; } }; } }); export interface TextEditorProps { updateContent: (content: string) => void; preSet?: string; } export default function TextEditor(props: TextEditorProps) { let editorRef!: HTMLDivElement; let bubbleMenuRef!: HTMLDivElement; let containerRef!: HTMLDivElement; const [showBubbleMenu, setShowBubbleMenu] = createSignal(false); const [bubbleMenuPosition, setBubbleMenuPosition] = createSignal({ top: 0, left: 0 }); const [showLanguageSelector, setShowLanguageSelector] = createSignal(false); const [languageSelectorPosition, setLanguageSelectorPosition] = createSignal({ top: 0, left: 0 }); const [showTableMenu, setShowTableMenu] = createSignal(false); const [tableMenuPosition, setTableMenuPosition] = createSignal({ top: 0, 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(() => ({ element: editorRef, extensions: [ StarterKit, CodeBlockLowlight.configure({ lowlight }), Link.configure({ openOnClick: true }), Image, IframeEmbed, TaskList, TaskItem.configure({ nested: true, HTMLAttributes: { class: "task-item" } }), Details.configure({ HTMLAttributes: { class: "tiptap-details" } }), DetailsSummary, DetailsContent.configure({ HTMLAttributes: { class: "details-content" } }), Table.configure({ resizable: true, HTMLAttributes: { class: "tiptap-table" } }), TableRow.configure({ HTMLAttributes: { class: "tiptap-table-row" } }), TableHeader.configure({ HTMLAttributes: { class: "tiptap-table-header" } }), TableCell.configure({ HTMLAttributes: { class: "tiptap-table-cell" } }), Mermaid, TextAlign.configure({ types: ["heading", "paragraph"], alignments: ["left", "center", "right", "justify"], defaultAlignment: "left" }) ], content: props.preSet || `

Hello! World

`, editorProps: { attributes: { class: "focus:outline-none" }, handleClickOn(view, pos, node, nodePos, event) { const target = event.target as HTMLElement; // Check if click is on a summary element inside details const summary = target.closest("summary"); if (summary) { const details = summary.closest('[data-type="details"]'); if (details) { // Toggle the open attribute const isOpen = details.hasAttribute("open"); if (isOpen) { details.removeAttribute("open"); } else { details.setAttribute("open", ""); } // Also toggle hidden attribute on details content const content = details.querySelector( '[data-type="detailsContent"]' ); if (content) { if (isOpen) { content.setAttribute("hidden", "hidden"); } else { content.removeAttribute("hidden"); } } return true; // Prevent default behavior } } return false; } }, onUpdate: ({ editor }) => { untrack(() => { props.updateContent(editor.getHTML()); }); }, onSelectionUpdate: ({ editor }) => { const { from, to } = editor.state.selection; const hasSelection = from !== to; if (hasSelection && !editor.state.selection.empty) { setShowBubbleMenu(true); // Position the bubble menu const { view } = editor; const start = view.coordsAtPos(from); const end = view.coordsAtPos(to); const left = Math.max((start.left + end.left) / 2, 0); const top = Math.max(start.top - 10, 0); setBubbleMenuPosition({ top, left }); } else { setShowBubbleMenu(false); } } })); // Update editor content when preSet changes (e.g., when data loads), but only if editor exists and content is different createEffect( on( () => props.preSet, (newContent) => { const instance = editor(); if (instance && newContent && instance.getHTML() !== newContent) { instance.commands.setContent(newContent, false); // false = don't emit update event } }, { defer: true } ) ); const setLink = () => { const instance = editor(); if (!instance) return; const previousUrl = instance.getAttributes("link").href; const url = window.prompt("URL", previousUrl); if (url === null) return; if (url === "") { instance.chain().focus().extendMarkRange("link").unsetLink().run(); return; } instance .chain() .focus() .extendMarkRange("link") .setLink({ href: url }) .run(); }; const addIframe = () => { const instance = editor(); if (!instance) return; const url = window.prompt("URL"); if (url) { instance.commands.setIframe({ src: url }); } }; const addImage = () => { const instance = editor(); if (!instance) return; const url = window.prompt("URL"); if (url) { instance.chain().focus().setImage({ src: url }).run(); } }; const insertCollapsibleSection = () => { const instance = editor(); if (!instance) return; const title = window.prompt("Section title:", "Click to expand"); if (title !== null && title.trim() !== "") { const content = { type: "details", attrs: { open: true }, content: [ { type: "detailsSummary", content: [{ type: "text", text: title }] }, { type: "detailsContent", content: [ { type: "paragraph" } ] } ] }; const { from } = instance.state.selection; instance.chain().focus().insertContent(content).run(); // Move cursor to the paragraph inside detailsContent // Structure: details (from+1) > detailsSummary > detailsContent > paragraph // We need to position inside the paragraph which is roughly from + title.length + 3 nodes deep setTimeout(() => { const { state } = instance; let targetPos = from; // Navigate through the document to find the paragraph inside detailsContent state.doc.nodesBetween(from, from + 200, (node, pos) => { if (node.type.name === "detailsContent") { // Position cursor at the start of the first child (paragraph) targetPos = pos + 1; return false; // Stop iteration } }); if (targetPos > from) { instance.commands.setTextSelection(targetPos); instance.commands.focus(); } }, 10); } }; const insertCodeBlock = (language: string | null) => { const instance = editor(); if (!instance) return; instance.chain().focus().toggleCodeBlock().run(); // If language specified, update the node attributes if (language) { instance.chain().updateAttributes("codeBlock", { language }).run(); } setShowLanguageSelector(false); }; const showLanguagePicker = (e: MouseEvent) => { const buttonRect = (e.currentTarget as HTMLElement).getBoundingClientRect(); setLanguageSelectorPosition({ top: buttonRect.bottom + 5, left: buttonRect.left }); setShowLanguageSelector(!showLanguageSelector()); }; const insertTable = (rows: number, cols: number) => { const instance = editor(); if (!instance) return; instance .chain() .focus() .insertTable({ rows, cols, withHeaderRow: true }) .run(); setShowTableMenu(false); }; const showTableInserter = (e: MouseEvent) => { const buttonRect = (e.currentTarget as HTMLElement).getBoundingClientRect(); setTableMenuPosition({ top: buttonRect.bottom + 5, left: buttonRect.left }); setShowTableMenu(!showTableMenu()); }; const deleteTableWithConfirmation = () => { const instance = editor(); if (!instance) return; const confirmed = window.confirm( "Are you sure you want to delete this table?" ); if (!confirmed) return; instance.chain().focus().deleteTable().run(); }; const deleteRowWithConfirmation = () => { const instance = editor(); if (!instance) return; const { state } = instance; const { selection } = state; // Find the row node let rowNode = null; let depth = 0; for (let d = selection.$anchor.depth; d > 0; d--) { const node = selection.$anchor.node(d); if (node.type.name === "tableRow") { rowNode = node; depth = d; break; } } if (rowNode) { let hasContent = false; rowNode.descendants((node) => { if (node.textContent.trim().length > 0) { hasContent = true; return false; } }); if (hasContent) { const confirmed = window.confirm( "This row contains content. Are you sure you want to delete it?" ); if (!confirmed) return; } } instance.chain().focus().deleteRow().run(); }; const deleteColumnWithConfirmation = () => { const instance = editor(); if (!instance) return; const { state } = instance; const { selection } = state; // Get the current cell position const cellPos = selection.$anchor; // Find table and column index let tableNode = null; let tableDepth = 0; for (let d = cellPos.depth; d > 0; d--) { const node = cellPos.node(d); if (node.type.name === "table") { tableNode = node; tableDepth = d; break; } } if (tableNode) { // Find which column we're in let colIndex = 0; const cellNode = cellPos.node(cellPos.depth); const rowNode = cellPos.node(cellPos.depth - 1); rowNode.forEach((node, offset, index) => { if ( cellPos.pos >= cellPos.start(cellPos.depth - 1) + offset && cellPos.pos < cellPos.start(cellPos.depth - 1) + offset + node.nodeSize ) { colIndex = index; } }); // Check if this column has content let hasContent = false; tableNode.descendants((node, pos, parent) => { if (parent && parent.type.name === "tableRow") { let currentCol = 0; parent.forEach((cell, offset, index) => { if (index === colIndex && cell.textContent.trim().length > 0) { hasContent = true; return false; } }); } }); if (hasContent) { const confirmed = window.confirm( "This column contains content. Are you sure you want to delete it?" ); if (!confirmed) return; } } instance.chain().focus().deleteColumn().run(); }; // Close language selector on outside click createEffect(() => { if (showLanguageSelector()) { const handleClickOutside = (e: MouseEvent) => { const target = e.target as HTMLElement; if ( !target.closest(".language-selector") && !target.closest("[data-language-picker-trigger]") ) { setShowLanguageSelector(false); } }; setTimeout(() => { document.addEventListener("click", handleClickOutside); }, 0); return () => document.removeEventListener("click", handleClickOutside); } }); // Close table menu on outside click createEffect(() => { if (showTableMenu()) { const handleClickOutside = (e: MouseEvent) => { const target = e.target as HTMLElement; if ( !target.closest(".table-menu") && !target.closest("[data-table-trigger]") ) { setShowTableMenu(false); } }; setTimeout(() => { document.addEventListener("click", handleClickOutside); }, 0); return () => document.removeEventListener("click", handleClickOutside); } }); // 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()); }; // ESC key to exit fullscreen createEffect(() => { if (isFullscreen()) { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { setIsFullscreen(false); } }; document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); } }); // Table Grid Selector Component const TableGridSelector = () => { const [hoverCell, setHoverCell] = createSignal({ row: 0, col: 0 }); const maxRows = 10; const maxCols = 10; return (
Insert Table: {hoverCell().row + 1} × {hoverCell().col + 1}
{(_, idx) => { const row = Math.floor(idx() / maxCols); const col = idx() % maxCols; return (
setHoverCell({ row, col })} onClick={() => insertTable(row + 1, col + 1)} /> ); }}
); }; return (
{(instance) => ( <> {/* Bubble Menu - appears when text is selected */}
{/* Table controls in bubble menu */}
{/* Language Selector Dropdown */}
{(lang) => ( )}
{/* Table Grid Selector */}
{/* Mermaid Template Selector */}
Select Diagram Type
{(template) => ( )}
{/* Text Alignment */}
{/* Table controls - shown when cursor is in a table */}
)}
{/* 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
); }