import { Show, untrack, createEffect, on, createSignal, For } from "solid-js"; import { createTiptapEditor } 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 { ConditionalBlock } from "./extensions/ConditionalBlock"; import { ConditionalInline } from "./extensions/ConditionalInline"; import TextAlign from "@tiptap/extension-text-align"; import Superscript from "@tiptap/extension-superscript"; import Subscript from "@tiptap/extension-subscript"; 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"; const lowlight = createLowlight(common); 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); 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); 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; 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` } ]; 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" }, { keys: "⌘ .", keysAlt: "Ctrl .", description: "Superscript" }, { keys: "⌘ ,", keysAlt: "Ctrl ,", description: "Subscript" } ] }, { 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: "⌘ R", keysAlt: "Ctrl R", description: "Insert Reference [n]" }, { 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) ); }; 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; } }; } }); // Custom Reference mark extension import { Mark, mergeAttributes } from "@tiptap/core"; declare module "@tiptap/core" { interface Commands { reference: { setReference: (options: { refId: string; refNum: number }) => ReturnType; updateReferenceNumber: (refId: string, newNum: number) => ReturnType; }; referenceSectionMarker: { setReferenceSectionMarker: (heading: string) => ReturnType; }; } } const Reference = Mark.create({ name: "reference", addOptions() { return { HTMLAttributes: {} }; }, addAttributes() { return { refId: { default: null, parseHTML: (element) => element.getAttribute("data-ref-id"), renderHTML: (attributes) => { if (!attributes.refId) { return {}; } return { "data-ref-id": attributes.refId }; } }, refNum: { default: 1, parseHTML: (element) => { const text = element.textContent || ""; const match = text.match(/^\[(\d+)\]$/); return match ? parseInt(match[1]) : 1; } } }; }, // Exclude other marks (like links) from being applied to references excludes: "_", parseHTML() { return [ { tag: "sup[data-ref-id]" }, // Also parse legacy superscript references during HTML parsing { tag: "sup", getAttrs: (element) => { if (typeof element === "string") return false; const text = element.textContent || ""; const match = text.match(/^\[(\d+)\]$/); if (match && !element.getAttribute("data-ref-id")) { // This is a legacy reference - convert it return { refId: `ref-legacy-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, refNum: parseInt(match[1]) }; } return false; } } ]; }, renderHTML({ HTMLAttributes }) { return [ "sup", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0 ]; }, addCommands() { return { setReference: (options: { refId: string; refNum: number }) => ({ commands }) => { return commands.insertContent({ type: "text", text: `[${options.refNum}]`, marks: [ { type: this.name, attrs: { refId: options.refId, refNum: options.refNum } } ] }); }, updateReferenceNumber: (refId: string, newNum: number) => ({ tr, state, dispatch }) => { const { doc } = state; let found = false; doc.descendants((node, pos) => { if (node.isText && node.marks) { const refMark = node.marks.find( (mark) => mark.type.name === "reference" && mark.attrs.refId === refId ); if (refMark) { if (dispatch) { // Update both the mark attributes and the text content const from = pos; const to = pos + node.text.length; const newMark = refMark.type.create({ refId: refId, refNum: newNum }); // Replace text and marks together tr.replaceWith( from, to, state.schema.text(`[${newNum}]`, [newMark]) ); } found = true; return false; } } }); return found; } }; } }); // Custom ReferenceSectionMarker node - invisible marker to identify references section const ReferenceSectionMarker = Node.create({ name: "referenceSectionMarker", group: "inline", inline: true, atom: true, selectable: false, draggable: false, addAttributes() { return { heading: { default: "References", parseHTML: (element) => element.getAttribute("data-heading") || "References", renderHTML: (attributes) => { return { "data-heading": attributes.heading }; } } }; }, parseHTML() { return [ { tag: "span[id='references-section-start']" } ]; }, renderHTML({ HTMLAttributes }) { return [ "span", mergeAttributes(HTMLAttributes, { id: "references-section-start", style: "display: inline-flex; align-items: center; padding: 0.125rem 0.5rem; margin: 0 0.25rem; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; border-radius: 0.25rem; font-size: 0.75rem; font-weight: 600; font-family: system-ui, -apple-system, sans-serif; user-select: none; cursor: default; vertical-align: middle;", contenteditable: "false" }), "📌 References Section" ]; }, addCommands() { return { setReferenceSectionMarker: (heading: string) => ({ commands }) => { return commands.insertContent({ type: this.name, attrs: { heading } }); } }; } }); 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); // References section heading customization const [referencesHeading, setReferencesHeading] = createSignal( typeof window !== "undefined" ? localStorage.getItem("editor-references-heading") || "References" : "References" ); // Persist heading changes to localStorage createEffect(() => { if (typeof window !== "undefined") { localStorage.setItem("editor-references-heading", referencesHeading()); } }); const [showConditionalConfig, setShowConditionalConfig] = createSignal(false); const [conditionalConfigPosition, setConditionalConfigPosition] = createSignal({ top: 0, left: 0 }); const [conditionalForm, setConditionalForm] = createSignal<{ conditionType: "auth" | "privilege" | "date" | "feature" | "env"; conditionValue: string; showWhen: "true" | "false"; inline: boolean; // New field for inline vs block }>({ conditionType: "auth", conditionValue: "authenticated", showWhen: "true", inline: false }); const [isFullscreen, setIsFullscreen] = createSignal(false); const [keyboardVisible, setKeyboardVisible] = createSignal(false); const [keyboardHeight, setKeyboardHeight] = createSignal(0); // Force reactive updates for button states const [editorState, setEditorState] = createSignal(0); // Helper to check editor active state reactively const isActive = (type: string, attrs?: Record) => { editorState(); // Track reactive dependency const instance = editor(); return instance ? instance.isActive(type, attrs) : false; }; const isAlignActive = (alignment: string) => { editorState(); const instance = editor(); if (!instance) return false; const { $from } = instance.state.selection; const node = $from.node($from.depth); const currentAlign = node?.attrs?.textAlign; if (currentAlign) { return currentAlign === alignment; } return alignment === "left"; }; // Helper for mobile-optimized button classes const getButtonClasses = ( isActive: boolean, includeHover: boolean = false ) => { const baseClasses = "rounded px-2 py-1 text-xs select-none touch-manipulation active:scale-95 transition-transform"; const activeClass = isActive ? "bg-surface2" : ""; const hoverClass = includeHover && !isActive ? "hover:bg-surface1" : ""; return `${baseClasses} ${activeClass} ${hoverClass}`.trim(); }; const editor = createTiptapEditor(() => ({ element: editorRef, extensions: [ StarterKit.configure({ // Disable these since we're adding them separately with custom config codeBlock: false }), 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, ConditionalBlock, ConditionalInline, TextAlign.configure({ types: ["heading", "paragraph"], alignments: ["left", "center", "right", "justify"], defaultAlignment: "left" }), Superscript, Subscript, Reference, ReferenceSectionMarker ], content: props.preSet || `

Hello! World

`, onCreate: ({ editor }) => { // Migrate legacy references on initial load if (props.preSet) { setTimeout(() => { const doc = editor.state.doc; let refCount = 0; let legacyCount = 0; doc.descendants((node: any) => { if (node.isText && node.marks) { const refMark = node.marks.find( (mark: any) => mark.type.name === "reference" ); if (refMark) { refCount++; } const superMark = node.marks.find( (mark: any) => mark.type.name === "superscript" ); if (superMark && !refMark) { const match = node.text?.match(/^\[(\d+)\]$/); if (match) { legacyCount++; } } } }); if (legacyCount > 0) { migrateLegacyReferences(editor); } }, 100); } }, editorProps: { attributes: { class: "focus:outline-none" }, handleKeyDown(view, event) { // Cmd/Ctrl+R for inserting reference if ((event.metaKey || event.ctrlKey) && event.key === "r") { event.preventDefault(); insertReference(); return true; } return false; }, handleClickOn(view, pos, node, nodePos, event) { const target = event.target as HTMLElement; const summary = target.closest("summary"); if (summary) { const details = summary.closest('[data-type="details"]'); if (details) { const isOpen = details.hasAttribute("open"); if (isOpen) { details.removeAttribute("open"); } else { details.setAttribute("open", ""); } 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()); setTimeout(() => { renumberAllReferences(editor); updateReferencesSection(editor); }, 100); }); }, onSelectionUpdate: ({ editor }) => { // Force reactive update for button states setEditorState((prev) => prev + 1); const { from, to } = editor.state.selection; const hasSelection = from !== to; if (hasSelection && !editor.state.selection.empty) { setShowBubbleMenu(true); 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); } } })); createEffect( on( () => props.preSet, (newContent) => { const instance = editor(); if (instance && newContent && instance.getHTML() !== newContent) { instance.commands.setContent(newContent, { emitUpdate: false }); // Migrate legacy superscript references to Reference marks setTimeout(() => migrateLegacyReferences(instance), 50); } }, { defer: true } ) ); const migrateLegacyReferences = (editorInstance: any) => { if (!editorInstance) return; const doc = editorInstance.state.doc; const legacyRefs: Array<{ pos: number; num: number; textLength: number; hasOtherMarks: boolean; }> = []; const allSuperscriptNodes: Array<{ pos: number; text: string; marks: any[]; }> = []; // First pass: collect all text nodes with superscript doc.descendants((node: any, pos: number) => { if (node.isText && node.marks) { const hasReference = node.marks.some( (mark: any) => mark.type.name === "reference" ); const hasSuperscript = node.marks.some( (mark: any) => mark.type.name === "superscript" ); if (!hasReference && hasSuperscript) { allSuperscriptNodes.push({ pos, text: node.text || "", marks: node.marks }); } } }); // Second pass: identify complete references (might be split) let i = 0; while (i < allSuperscriptNodes.length) { const node = allSuperscriptNodes[i]; const text = node.text; // Check if this is a complete reference (with optional whitespace) const completeMatch = text.match(/^\s*\[(\d+)\]\s*$/); if (completeMatch) { const hasOtherMarks = node.marks.some( (mark: any) => mark.type.name !== "superscript" && mark.type.name !== "reference" ); legacyRefs.push({ pos: node.pos, num: parseInt(completeMatch[1]), textLength: text.length, hasOtherMarks }); i++; continue; } // Check if this might be the start of a split reference if (text === "[" && i + 2 < allSuperscriptNodes.length) { const nextNode = allSuperscriptNodes[i + 1]; const afterNode = allSuperscriptNodes[i + 2]; // Check if next nodes form [n] if (nextNode.text.match(/^\d+$/) && afterNode.text === "]") { const refNum = parseInt(nextNode.text); const totalLength = text.length + nextNode.text.length + afterNode.text.length; // We need to handle split references differently - remove all three nodes and create one legacyRefs.push({ pos: node.pos, num: refNum, textLength: totalLength, hasOtherMarks: true // Treat split refs as having other marks }); i += 3; // Skip the next two nodes continue; } } i++; } if (legacyRefs.length === 0) { return; } legacyRefs.sort((a, b) => b.pos - a.pos); const tr = editorInstance.state.tr; legacyRefs.forEach((ref) => { const refId = `ref-migrated-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; const newMark = editorInstance.schema.marks.reference.create({ refId: refId, refNum: ref.num }); tr.replaceWith( ref.pos, ref.pos + ref.textLength, editorInstance.schema.text(`[${ref.num}]`, [newMark]) ); }); editorInstance.view.dispatch(tr); }; const renumberAllReferences = (editorInstance: any) => { if (!editorInstance) return; const doc = editorInstance.state.doc; const allRefs: Array<{ pos: number; refId: string; refNum: number; textLength: number; }> = []; doc.descendants((node: any, pos: number) => { if (node.isText && node.marks) { const refMark = node.marks.find( (mark: any) => mark.type.name === "reference" ); if (refMark) { allRefs.push({ pos, refId: refMark.attrs.refId, refNum: refMark.attrs.refNum, textLength: node.text.length }); } } }); // Sort by position allRefs.sort((a, b) => a.pos - b.pos); // Check if renumbering is needed (if any ref doesn't match its expected number) let needsRenumbering = false; for (let i = 0; i < allRefs.length; i++) { if (allRefs[i].refNum !== i + 1) { needsRenumbering = true; break; } } if (!needsRenumbering) return; // Build a single transaction with all updates (from end to start to avoid position shifts) const tr = editorInstance.state.tr; for (let i = allRefs.length - 1; i >= 0; i--) { const correctNum = i + 1; const ref = allRefs[i]; if (ref.refNum !== correctNum) { // Create updated mark const newMark = editorInstance.schema.marks.reference.create({ refId: ref.refId, refNum: correctNum }); // Replace the node with updated text and mark tr.replaceWith( ref.pos, ref.pos + ref.textLength, editorInstance.schema.text(`[${correctNum}]`, [newMark]) ); } } // Dispatch the single transaction with all changes editorInstance.view.dispatch(tr); }; const updateReferencesSection = (editorInstance: any) => { if (!editorInstance) return; const doc = editorInstance.state.doc; const foundRefs = new Set(); doc.descendants((node: any) => { if (node.isText && node.marks) { // Look for both Reference marks (new) and superscript (legacy) const refMark = node.marks.find( (mark: any) => mark.type.name === "reference" ); const hasSuperscript = node.marks.some( (mark: any) => mark.type.name === "superscript" ); if (refMark) { // Use refNum from Reference mark foundRefs.add(refMark.attrs.refNum.toString()); } else if (hasSuperscript) { // Fallback to legacy superscript pattern matching const text = node.text || ""; const match = text.match(/^\[(.+?)\]$/); if (match) { foundRefs.add(match[1]); } } } }); if (foundRefs.size === 0) { // No references found - remove the entire section if it exists let markerPos = -1; let hrPos = -1; let sectionEndPos = -1; doc.descendants((node: any, pos: number) => { // Find marker first if (node.type.name === "referenceSectionMarker") { markerPos = pos; } // Find HR before marker if (markerPos === -1 && node.type.name === "horizontalRule") { hrPos = pos; } }); // Find the end of the references section if (markerPos >= 0) { let foundEnd = false; doc.descendants((node: any, pos: number) => { if (foundEnd || pos <= markerPos) return; // Section ends at next HR or H2 heading if ( node.type.name === "horizontalRule" || (node.type.name === "heading" && node.attrs.level <= 2) ) { sectionEndPos = pos; foundEnd = true; } }); // If no end found, section goes to end of document if (!foundEnd) { sectionEndPos = doc.content.size; } } if (hrPos >= 0 && sectionEndPos > hrPos) { const tr = editorInstance.state.tr; tr.delete(hrPos, sectionEndPos); editorInstance.view.dispatch(tr); } return; } const refNumbers = Array.from(foundRefs).sort((a, b) => { const numA = parseInt(a); const numB = parseInt(b); if (!isNaN(numA) && !isNaN(numB)) { return numA - numB; } return a.localeCompare(b); }); let markerPos = -1; let markerHeading = ""; let referencesHeadingPos = -1; let sectionEndPos = -1; let existingRefs = new Map< string, { pos: number; isPlaceholder: boolean } >(); // Look for the marker first doc.descendants((node: any, pos: number) => { if (node.type.name === "referenceSectionMarker") { markerPos = pos; markerHeading = node.attrs.heading || referencesHeading(); } // If marker found, look for heading after it if ( markerPos >= 0 && referencesHeadingPos === -1 && node.type.name === "heading" && pos > markerPos && pos < markerPos + 50 ) { referencesHeadingPos = pos; } // Find section end (next HR or H2) if ( referencesHeadingPos >= 0 && sectionEndPos === -1 && pos > referencesHeadingPos && (node.type.name === "horizontalRule" || (node.type.name === "heading" && node.attrs.level <= 2)) ) { sectionEndPos = pos; } // Collect existing reference numbers within the section if ( referencesHeadingPos >= 0 && pos > referencesHeadingPos && (sectionEndPos === -1 || pos < sectionEndPos) && node.type.name === "paragraph" ) { const text = node.textContent; const match = text.match(/^\[(.+?)\]/); if (match) { const isPlaceholder = text.includes("Add your reference text here"); existingRefs.set(match[1], { pos, isPlaceholder }); } } }); // If no section end found, it goes to document end if (referencesHeadingPos >= 0 && sectionEndPos === -1) { sectionEndPos = doc.content.size; } // Update marker heading if it changed if (markerPos >= 0 && markerHeading !== referencesHeading()) { const tr = editorInstance.state.tr; const markerNode = doc.nodeAt(markerPos); if (markerNode) { tr.replaceWith( markerPos, markerPos + markerNode.nodeSize, editorInstance.schema.nodes.referenceSectionMarker.create({ heading: referencesHeading() }) ); editorInstance.view.dispatch(tr); } } // Update heading text if it changed if (referencesHeadingPos >= 0 && markerHeading !== referencesHeading()) { const tr = editorInstance.state.tr; const headingNode = doc.nodeAt(referencesHeadingPos); if (headingNode) { tr.replaceWith( referencesHeadingPos, referencesHeadingPos + headingNode.nodeSize, editorInstance.schema.nodes.heading.create( { level: 2 }, editorInstance.schema.text(referencesHeading()) ) ); editorInstance.view.dispatch(tr); return; } } // Create section if marker not found if (markerPos === -1) { const content: any[] = [ { type: "horizontalRule" }, { type: "paragraph", content: [ { type: "referenceSectionMarker", attrs: { heading: referencesHeading() } } ] }, { type: "heading", attrs: { level: 2 }, content: [{ type: "text", text: referencesHeading() }] } ]; // Add placeholder paragraphs for each reference refNumbers.forEach((refNum) => { content.push({ type: "paragraph", content: [ { type: "text", text: `[${refNum}] `, marks: [{ type: "bold" }] }, { type: "text", text: "Add your reference text here" } ] }); }); const tr = editorInstance.state.tr; tr.insert( doc.content.size, editorInstance.schema.nodeFromJSON({ type: "doc", content }).content ); editorInstance.view.dispatch(tr); return; } // Section exists - manage placeholders const tr = editorInstance.state.tr; let hasChanges = false; // Step 1: Remove placeholders for references that no longer exist const toDelete: Array<{ pos: number; nodeSize: number }> = []; existingRefs.forEach((info, refNum) => { if (info.isPlaceholder && !refNumbers.includes(refNum)) { const node = doc.nodeAt(info.pos); if (node) { toDelete.push({ pos: info.pos, nodeSize: node.nodeSize }); } } }); // Delete in reverse order to maintain positions toDelete .sort((a, b) => b.pos - a.pos) .forEach(({ pos, nodeSize }) => { tr.delete(pos, pos + nodeSize); hasChanges = true; }); // Step 2: Add placeholders for new references if (referencesHeadingPos >= 0) { // Find insertion point (after heading, before any content or at section end) let insertPos = referencesHeadingPos; const headingNode = doc.nodeAt(referencesHeadingPos); if (headingNode) { insertPos = referencesHeadingPos + headingNode.nodeSize; } // Add missing references in order const nodesToInsert: any[] = []; refNumbers.forEach((refNum) => { if (!existingRefs.has(refNum)) { nodesToInsert.push({ type: "paragraph", content: [ { type: "text", text: `[${refNum}] `, marks: [{ type: "bold" }] }, { type: "text", text: "Add your reference text here" } ] }); } }); if (nodesToInsert.length > 0) { nodesToInsert.forEach((nodeData) => { const node = editorInstance.schema.nodeFromJSON(nodeData); tr.insert(insertPos, node); insertPos += node.nodeSize; }); hasChanges = true; } } if (hasChanges) { editorInstance.view.dispatch(tr); } }; 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 insertReference = () => { const instance = editor(); if (!instance) return; const doc = instance.state.doc; const { from } = instance.state.selection; // Collect all existing references with their IDs and positions const refs: Array<{ pos: number; refId: string; refNum: number; textLength: number; isLegacy: boolean; }> = []; doc.descendants((node: any, pos: number) => { if (node.isText && node.marks) { // Check for new Reference marks const refMark = node.marks.find( (mark: any) => mark.type.name === "reference" ); if (refMark) { refs.push({ pos, refId: refMark.attrs.refId, refNum: refMark.attrs.refNum, textLength: node.text.length, isLegacy: false }); } else { // Check for legacy superscript references const hasSuperscript = node.marks.some( (mark: any) => mark.type.name === "superscript" ); if (hasSuperscript) { const text = node.text || ""; const match = text.match(/^\[(\d+)\]$/); if (match) { refs.push({ pos, refId: `ref-legacy-${pos}`, // Temporary ID for legacy refs refNum: parseInt(match[1]), textLength: text.length, isLegacy: true }); } } } } }); // Sort by position in document refs.sort((a, b) => a.pos - b.pos); // Find where to insert (what number should this be?) let newRefNum = 1; let insertIndex = refs.length; // Default to end for (let i = 0; i < refs.length; i++) { if (from <= refs[i].pos) { newRefNum = i + 1; insertIndex = i; break; } } if (insertIndex === refs.length) { newRefNum = refs.length + 1; } // Generate unique ID for this reference const newRefId = `ref-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; // Insert the new reference instance.commands.setReference({ refId: newRefId, refNum: newRefNum }); // Now renumber ALL references that come after the insertion point setTimeout(() => { const currentDoc = instance.state.doc; const allRefs: Array<{ pos: number; refId: string; refNum: number; textLength: number; isLegacy: boolean; }> = []; currentDoc.descendants((node: any, pos: number) => { if (node.isText && node.marks) { // Check for new Reference marks const refMark = node.marks.find( (mark: any) => mark.type.name === "reference" ); if (refMark) { allRefs.push({ pos, refId: refMark.attrs.refId, refNum: refMark.attrs.refNum, textLength: node.text.length, isLegacy: false }); } else { // Check for legacy superscript references const hasSuperscript = node.marks.some( (mark: any) => mark.type.name === "superscript" ); if (hasSuperscript) { const text = node.text || ""; const match = text.match(/^\[(\d+)\]$/); if (match) { allRefs.push({ pos, refId: `ref-legacy-${pos}`, refNum: parseInt(match[1]), textLength: text.length, isLegacy: true }); } } } } }); // Sort by position allRefs.sort((a, b) => a.pos - b.pos); // Build a single transaction with all updates (from end to start to avoid position shifts) const tr = instance.state.tr; let hasChanges = false; for (let i = allRefs.length - 1; i >= 0; i--) { const correctNum = i + 1; const ref = allRefs[i]; if (ref.refNum !== correctNum) { if (ref.isLegacy) { // Convert legacy to Reference mark while renumbering const newRefId = `ref-${Date.now()}-${Math.random().toString(36).substr(2, 9)}-${i}`; const newMark = instance.schema.marks.reference.create({ refId: newRefId, refNum: correctNum }); tr.replaceWith( ref.pos, ref.pos + ref.textLength, instance.schema.text(`[${correctNum}]`, [newMark]) ); } else { // Update existing Reference mark const newMark = instance.schema.marks.reference.create({ refId: ref.refId, refNum: correctNum }); tr.replaceWith( ref.pos, ref.pos + ref.textLength, instance.schema.text(`[${correctNum}]`, [newMark]) ); } hasChanges = true; } else if (ref.isLegacy) { // Even if number is correct, convert legacy to Reference mark const newRefId = `ref-${Date.now()}-${Math.random().toString(36).substr(2, 9)}-${i}`; const newMark = instance.schema.marks.reference.create({ refId: newRefId, refNum: correctNum }); tr.replaceWith( ref.pos, ref.pos + ref.textLength, instance.schema.text(`[${correctNum}]`, [newMark]) ); hasChanges = true; } } // Dispatch the single transaction with all changes if (hasChanges) { instance.view.dispatch(tr); } // Update references section updateReferencesSection(instance); }, 10); }; 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(); setTimeout(() => { const { state } = instance; let targetPos = from; state.doc.nodesBetween(from, from + 200, (node, pos) => { if (node.type.name === "detailsContent") { 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) { 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; 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; const cellPos = selection.$anchor; 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) { 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; } }); 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(); }; 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); } }; const timeoutId = setTimeout(() => { document.addEventListener("click", handleClickOutside); }, 0); return () => { clearTimeout(timeoutId); document.removeEventListener("click", handleClickOutside); }; } }); 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); } }; const timeoutId = setTimeout(() => { document.addEventListener("click", handleClickOutside); }, 0); return () => { clearTimeout(timeoutId); document.removeEventListener("click", handleClickOutside); }; } }); 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); } }; const timeoutId = setTimeout(() => { document.addEventListener("click", handleClickOutside); }, 0); return () => { clearTimeout(timeoutId); document.removeEventListener("click", handleClickOutside); }; } }); createEffect(() => { if (showConditionalConfig()) { const handleClickOutside = (e: MouseEvent) => { const target = e.target as HTMLElement; if ( !target.closest(".conditional-config") && !target.closest("[data-conditional-trigger]") ) { setShowConditionalConfig(false); } }; const timeoutId = setTimeout(() => { document.addEventListener("click", handleClickOutside); }, 0); return () => { clearTimeout(timeoutId); 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); }; const showConditionalConfigurator = (e: MouseEvent) => { const buttonRect = (e.currentTarget as HTMLElement).getBoundingClientRect(); setConditionalConfigPosition({ top: buttonRect.bottom + 5, left: buttonRect.left }); const instance = editor(); if (instance?.isActive("conditionalBlock")) { const attrs = instance.getAttributes("conditionalBlock"); setConditionalForm({ conditionType: attrs.conditionType || "auth", conditionValue: attrs.conditionValue || "authenticated", showWhen: attrs.showWhen || "true", inline: false }); } else if (instance?.isActive("conditionalInline")) { const attrs = instance.getAttributes("conditionalInline"); setConditionalForm({ conditionType: attrs.conditionType || "auth", conditionValue: attrs.conditionValue || "authenticated", showWhen: attrs.showWhen || "true", inline: true }); } else { setConditionalForm({ conditionType: "auth", conditionValue: "authenticated", showWhen: "true", inline: false }); } setShowConditionalConfig(!showConditionalConfig()); }; const insertConditionalBlock = () => { const instance = editor(); if (!instance) return; const { conditionType, conditionValue, showWhen, inline } = conditionalForm(); if (inline) { if (instance.isActive("conditionalInline")) { instance .chain() .focus() .unsetConditionalInline() .setConditionalInline({ conditionType, conditionValue, showWhen }) .run(); } else { instance .chain() .focus() .setConditionalInline({ conditionType, conditionValue, showWhen }) .run(); } } else { if (instance.isActive("conditionalBlock")) { instance .chain() .focus() .updateConditionalBlock({ conditionType, conditionValue, showWhen }) .run(); } else { instance .chain() .focus() .setConditionalBlock({ conditionType, conditionValue, showWhen }) .run(); } } setShowConditionalConfig(false); }; const toggleFullscreen = () => { setIsFullscreen(!isFullscreen()); }; createEffect(() => { if (isFullscreen()) { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { setIsFullscreen(false); } }; document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); } }); createEffect(() => { if (typeof window === "undefined" || !window.visualViewport) return; const viewport = window.visualViewport; const initialHeight = viewport.height; const handleResize = () => { const currentHeight = viewport.height; const heightDiff = initialHeight - currentHeight; if (heightDiff > 150) { setKeyboardVisible(true); setKeyboardHeight(heightDiff); } else { setKeyboardVisible(false); setKeyboardHeight(0); } }; viewport.addEventListener("resize", handleResize); viewport.addEventListener("scroll", handleResize); return () => { viewport.removeEventListener("resize", handleResize); viewport.removeEventListener("scroll", handleResize); }; }); 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)} /> ); }}
); }; const ConditionalConfigurator = () => { return (

Conditional Block

{/* Condition Type Selector */} {/* Dynamic Condition Value Input based on type */} setConditionalForm({ ...conditionalForm(), conditionValue: e.currentTarget.value }) } />
Format: before:YYYY-MM-DD, after:YYYY-MM-DD, or between:YYYY-MM-DD,YYYY-MM-DD
setConditionalForm({ ...conditionalForm(), conditionValue: e.currentTarget.value }) } /> setConditionalForm({ ...conditionalForm(), conditionValue: e.currentTarget.value }) } />
Format: VAR_NAME:value or VAR_NAME:* for any truthy value
{/* Show When Toggle */} {/* Inline Toggle */} {/* Action Buttons */}
); }; 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) => ( )}
{/* Conditional Configurator */}
{/* Main Toolbar - Fixed at top always */}
{/* Text Alignment */}
{/* Undo/Redo buttons - critical for mobile */}
{/* 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
); }