import { Show, untrack, createEffect, on, createSignal, For } from "solid-js"; import { useSearchParams, useNavigate } from "@solidjs/router"; import { api } from "~/lib/api"; 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" } ] }, { name: "AI Autocomplete (Admin)", shortcuts: [ { keys: "⌘ Space", keysAlt: "Ctrl Space", description: "Trigger AI suggestion" }, { keys: "→", keysAlt: "Right", description: "Accept word" }, { keys: "⌥ Tab", keysAlt: "Alt Tab", description: "Accept line" }, { keys: "⇧ Tab", keysAlt: "Shift Tab", description: "Accept full" }, { keys: "ESC", keysAlt: "ESC", description: "Cancel suggestion" } ] } ]; 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; } }; } }); const CONTEXT_SIZE = 512; // Characters before/after cursor for context for llm infill // Custom Reference mark extension import { Extension } from "@tiptap/core"; import { Plugin, PluginKey } from "@tiptap/pm/state"; import { Decoration, DecorationSet } from "@tiptap/pm/view"; // Suggestion decoration extension - shows inline AI suggestions const SuggestionDecoration = Extension.create({ name: "suggestionDecoration", addProseMirrorPlugins() { const editor = this.editor; return [ new Plugin({ key: new PluginKey("suggestionDecoration"), state: { init() { return DecorationSet.empty; }, apply(tr, oldSet, oldState, newState) { // Get suggestion from editor storage const suggestion = (editor.storage as any).suggestionDecoration?.text || ""; if (!suggestion) { return DecorationSet.empty; } const { selection } = newState; const pos = selection.$anchor.pos; // Create a widget decoration at cursor position const decoration = Decoration.widget( pos, () => { const span = document.createElement("span"); span.textContent = suggestion; span.style.color = "rgb(239, 68, 68)"; // Tailwind red-500 span.style.opacity = "0.5"; span.style.fontStyle = "italic"; span.style.fontFamily = "monospace"; span.style.pointerEvents = "none"; span.style.whiteSpace = "pre-wrap"; span.style.wordWrap = "break-word"; return span; }, { side: 1 // Place after the cursor } ); return DecorationSet.create(newState.doc, [decoration]); } }, props: { decorations(state) { return this.getState(state); } } }) ]; }, addStorage() { return { text: "" }; } }); // 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; postId?: number; // Optional: for persisting history to database } 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 }); // Search params and navigation for fullscreen persistence const [searchParams, setSearchParams] = useSearchParams(); const navigate = useNavigate(); // Initialize fullscreen from URL search param const [isFullscreen, setIsFullscreen] = createSignal( searchParams.fullscreen === "true" ); const [keyboardVisible, setKeyboardVisible] = createSignal(false); const [keyboardHeight, setKeyboardHeight] = createSignal(0); // Undo Tree History (MVP - In-Memory + Database) interface HistoryNode { id: string; // Local UUID dbId?: number; // Database ID from PostHistory table content: string; timestamp: Date; } const [history, setHistory] = createSignal([]); const [currentHistoryIndex, setCurrentHistoryIndex] = createSignal(-1); const [showHistoryModal, setShowHistoryModal] = createSignal(false); const [isLoadingHistory, setIsLoadingHistory] = createSignal(false); const MAX_HISTORY_SIZE = 100; // Match database pruning limit let historyDebounceTimer: ReturnType | null = null; let isInitialLoad = true; // Flag to prevent capturing history on initial load let hasAttemptedHistoryLoad = false; // Flag to prevent repeated load attempts // LLM Infill state const [currentSuggestion, setCurrentSuggestion] = createSignal(""); const [isInfillLoading, setIsInfillLoading] = createSignal(false); const [infillConfig, setInfillConfig] = createSignal<{ endpoint: string; token: string; } | null>(null); let infillDebounceTimer: ReturnType | null = null; // 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(); }; // Fetch infill config on mount (admin-only, desktop-only) createEffect(async () => { try { const config = await api.infill.getConfig.query(); if (config.endpoint && config.token) { setInfillConfig({ endpoint: config.endpoint, token: config.token }); } } catch (error) { console.error("Failed to fetch infill config:", error); } }); // Update suggestion: Store in editor and force view update createEffect(() => { const instance = editor(); const suggestion = currentSuggestion(); if (instance) { // Store suggestion in editor storage (cast to any to avoid TS error) (instance.storage as any).suggestionDecoration = { text: suggestion }; // Force view update to show/hide decoration instance.view.dispatch(instance.state.tr); } }); const requestInfill = async (): Promise => { const config = infillConfig(); if (!config) return; const context = getEditorContext(); if (!context) return; setIsInfillLoading(true); try { // llama.cpp infill format const requestBody = { input_prefix: context.prefix, input_suffix: context.suffix, n_predict: 100, temperature: 0.3, stop: ["\n\n", "", "<|endoftext|>"], stream: false }; console.log("[Infill] Request:", { prefix: context.prefix, suffix: context.suffix, prefixLength: context.prefix.length, suffixLength: context.suffix.length }); const response = await fetch(config.endpoint, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${config.token}` }, body: JSON.stringify(requestBody) }); if (!response.ok) { throw new Error(`Infill request failed: ${response.status}`); } const data = await response.json(); // llama.cpp infill format returns { content: "..." } const suggestion = data.content || ""; if (suggestion.trim()) { setCurrentSuggestion(suggestion.trim()); } } catch (error) { console.error("Infill request failed:", error); setCurrentSuggestion(""); } finally { setIsInfillLoading(false); } }; // Helper to check if suggestion is active const hasSuggestion = () => currentSuggestion().length > 0; // Accept next word from suggestion const acceptWord = () => { const suggestion = currentSuggestion(); if (!suggestion) return; // Take first word (split on whitespace) const words = suggestion.split(/\s+/); const firstWord = words[0] || ""; const instance = editor(); if (instance) { instance.commands.insertContent(firstWord + " "); } // Update suggestion to remaining text const remaining = words.slice(1).join(" "); setCurrentSuggestion(remaining); }; // Accept current line from suggestion const acceptLine = () => { const suggestion = currentSuggestion(); if (!suggestion) return; // Take up to first newline const lines = suggestion.split("\n"); const firstLine = lines[0] || ""; const instance = editor(); if (instance) { instance.commands.insertContent(firstLine); } // Update suggestion to remaining text const remaining = lines.slice(1).join("\n"); setCurrentSuggestion(remaining); }; // Accept full suggestion const acceptFull = () => { const suggestion = currentSuggestion(); if (!suggestion) return; const instance = editor(); if (instance) { instance.commands.insertContent(suggestion); } setCurrentSuggestion(""); }; // Capture history snapshot const captureHistory = async (editorInstance: any) => { // Skip if initial load if (isInitialLoad) { return; } const content = editorInstance.getHTML(); const currentHistory = history(); const currentIndex = currentHistoryIndex(); // Get previous content for diff creation const previousContent = currentIndex >= 0 ? currentHistory[currentIndex].content : ""; // Skip if content hasn't changed if (content === previousContent) { return; } // Create new history node const newNode: HistoryNode = { id: crypto.randomUUID(), content, timestamp: new Date() }; // If we're not at the end of history, truncate future history (linear history for MVP) const updatedHistory = currentIndex === currentHistory.length - 1 ? [...currentHistory, newNode] : [...currentHistory.slice(0, currentIndex + 1), newNode]; // Limit history size const limitedHistory = updatedHistory.length > MAX_HISTORY_SIZE ? updatedHistory.slice(updatedHistory.length - MAX_HISTORY_SIZE) : updatedHistory; setHistory(limitedHistory); setCurrentHistoryIndex(limitedHistory.length - 1); // Persist to database if postId is provided if (props.postId) { try { const parentHistoryId = currentIndex >= 0 && currentHistory[currentIndex]?.dbId ? currentHistory[currentIndex].dbId : null; const result = await api.postHistory.save.mutate({ postId: props.postId, content, previousContent, parentHistoryId, isSaved: false }); // Update the node with database ID if (result.success && result.historyId) { newNode.dbId = result.historyId; // Update history with dbId setHistory((prev) => { const updated = [...prev]; updated[updated.length - 1] = newNode; return updated; }); } } catch (error) { console.error("Failed to persist history to database:", error); // Continue anyway - we have in-memory history } } }; // Parse UTC datetime string from SQLite to JavaScript Date // SQLite datetime('now') returns format: "YYYY-MM-DD HH:MM:SS" in UTC const parseUTCDateTime = (utcDateString: string): Date => { // SQLite returns datetime in format "YYYY-MM-DD HH:MM:SS" // We need to append 'Z' to indicate UTC, or convert to ISO format // Replace space with 'T' and append 'Z' for proper UTC parsing const isoString = utcDateString.replace(" ", "T") + "Z"; return new Date(isoString); }; // Format relative time for history display const formatRelativeTime = (date: Date): string => { const now = new Date(); const diffMs = now.getTime() - date.getTime(); const diffSec = Math.floor(diffMs / 1000); const diffMin = Math.floor(diffSec / 60); const diffHour = Math.floor(diffMin / 60); const diffDay = Math.floor(diffHour / 24); if (diffSec < 60) return `${diffSec} seconds ago`; if (diffMin < 60) return `${diffMin} minute${diffMin === 1 ? "" : "s"} ago`; if (diffHour < 24) return `${diffHour} hour${diffHour === 1 ? "" : "s"} ago`; return `${diffDay} day${diffDay === 1 ? "" : "s"} ago`; }; // Restore history to a specific point const restoreHistory = (index: number) => { const instance = editor(); if (!instance) return; const node = history()[index]; if (!node) return; // Set content without triggering history capture instance.commands.setContent(node.content, { emitUpdate: false }); // Update current index setCurrentHistoryIndex(index); // Update parent content props.updateContent(node.content); // Close modal setShowHistoryModal(false); // Force UI update setEditorState((prev) => prev + 1); }; // Load history from database const loadHistoryFromDB = async () => { if (!props.postId) return; setIsLoadingHistory(true); hasAttemptedHistoryLoad = true; // Mark that we've attempted to load try { console.log("[History] Loading from DB for postId:", props.postId); const dbHistory = await api.postHistory.getHistory.query({ postId: props.postId }); console.log("[History] DB returned entries:", dbHistory.length); if (dbHistory && dbHistory.length > 0) { console.log( "[History] First entry content length:", dbHistory[0].content.length ); console.log( "[History] Last entry content length:", dbHistory[dbHistory.length - 1].content.length ); // Convert database history to HistoryNode format with reconstructed content // Database stores timestamps in UTC, so we need to parse them correctly const historyNodes: HistoryNode[] = dbHistory.map((entry) => ({ id: `db-${entry.id}`, dbId: entry.id, content: entry.content, // Full reconstructed content from diffs timestamp: parseUTCDateTime(entry.created_at) // Parse UTC timestamp })); setHistory(historyNodes); setCurrentHistoryIndex(historyNodes.length - 1); console.log( "[History] Loaded", historyNodes.length, "entries into memory" ); } else { console.log("[History] No history found in DB"); } } catch (error) { console.error("Failed to load history from database:", error); } finally { setIsLoadingHistory(false); } }; // Extract editor context for LLM infill (CONTEXT_SIZE chars before/after cursor) const getEditorContext = (): { prefix: string; suffix: string; cursorPos: number; } | null => { const instance = editor(); if (!instance) return null; const { state } = instance; const cursorPos = state.selection.$anchor.pos; // Convert ProseMirror position to text offset // We need to count actual text characters, not node positions let textOffset = 0; let reachedCursor = false; state.doc.descendants((node, pos) => { if (reachedCursor) return false; // Stop traversing if (node.isText) { const nodeEnd = pos + node.nodeSize; if (cursorPos <= nodeEnd) { // Cursor is within or right after this text node textOffset += Math.min(cursorPos - pos, node.text?.length || 0); reachedCursor = true; return false; } textOffset += node.text?.length || 0; } }); const text = state.doc.textContent; if (text.length === 0) return null; const prefix = text.slice( Math.max(0, textOffset - CONTEXT_SIZE), textOffset ); const suffix = text.slice( textOffset, Math.min(text.length, textOffset + CONTEXT_SIZE) ); return { prefix, suffix, cursorPos: textOffset }; }; 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, SuggestionDecoration, 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); } // CRITICAL FIX: Always set isInitialLoad to false after a delay // This ensures infill works regardless of how content was loaded setTimeout(() => { isInitialLoad = false; }, 1000); }, editorProps: { attributes: { class: "focus:outline-none" }, handleKeyDown(view, event) { // Trigger infill: Ctrl+Space (or Cmd+Space) if ((event.ctrlKey || event.metaKey) && event.key === " ") { event.preventDefault(); requestInfill(); return true; } // Cancel suggestion: Escape if (event.key === "Escape" && hasSuggestion()) { event.preventDefault(); setCurrentSuggestion(""); return true; } // Accept word: Right Arrow (only when suggestion active) if ( event.key === "ArrowRight" && hasSuggestion() && !event.shiftKey && !event.ctrlKey && !event.metaKey ) { event.preventDefault(); acceptWord(); return true; } // Accept line: Alt+Tab if (event.altKey && event.key === "Tab" && hasSuggestion()) { event.preventDefault(); acceptLine(); return true; } // Accept full: Shift+Tab if ( event.shiftKey && event.key === "Tab" && hasSuggestion() && !event.altKey ) { event.preventDefault(); acceptFull(); return true; } // 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); // Debounced history capture (capture after 2 seconds of inactivity) // Skip during initial load if (!isInitialLoad) { if (historyDebounceTimer) { clearTimeout(historyDebounceTimer); } historyDebounceTimer = setTimeout(() => { captureHistory(editor); }, 2000); } // Debounced infill trigger (250ms) if (infillConfig() && !isInitialLoad) { if (infillDebounceTimer) { clearTimeout(infillDebounceTimer); } infillDebounceTimer = setTimeout(() => { requestInfill(); }, 250); } }); }, onSelectionUpdate: ({ editor }) => { // Clear suggestion when cursor moves (click/arrow keys without suggestion) if (currentSuggestion()) { setCurrentSuggestion(""); } // 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, async (newContent) => { const instance = editor(); if (instance && newContent) { const currentHTML = instance.getHTML(); const contentMatches = currentHTML === newContent; if (!contentMatches) { console.log( "[History] Initial content load, postId:", props.postId ); instance.commands.setContent(newContent, { emitUpdate: false }); // Reset the load attempt flag when content changes hasAttemptedHistoryLoad = false; // Load history from database if postId is provided if (props.postId) { await loadHistoryFromDB(); console.log( "[History] After load, history length:", history().length ); } // Migrate legacy superscript references to Reference marks setTimeout(() => migrateLegacyReferences(instance), 50); // Capture initial state in history only if no history was loaded setTimeout(() => { if (history().length === 0) { console.log( "[History] No history found, capturing initial state" ); captureHistory(instance); } else { console.log( "[History] Skipping initial capture, have", history().length, "entries" ); } isInitialLoad = false; }, 200); } else { // Content already matches - this is the initial load case setTimeout(() => { isInitialLoad = false; }, 500); } } }, { defer: true } ) ); // Load history when editor is ready (for edit mode) createEffect(() => { const instance = editor(); if ( instance && props.postId && history().length === 0 && !isLoadingHistory() && !hasAttemptedHistoryLoad // Only attempt once ) { console.log( "[History] Editor ready, loading history for postId:", props.postId ); loadHistoryFromDB(); } }); 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 in correct order if (referencesHeadingPos >= 0) { // For each missing reference, find the correct insertion position refNumbers.forEach((refNum) => { if (!existingRefs.has(refNum)) { const refNumInt = parseInt(refNum); let insertPos = referencesHeadingPos; const headingNode = doc.nodeAt(referencesHeadingPos); if (headingNode) { insertPos = referencesHeadingPos + headingNode.nodeSize; } // Find the last existing reference that comes before this one let foundInsertPos = false; existingRefs.forEach((info, existingRefNum) => { const existingRefNumInt = parseInt(existingRefNum); if ( !isNaN(existingRefNumInt) && !isNaN(refNumInt) && existingRefNumInt < refNumInt ) { // This existing ref comes before the new one, insert after it const existingNode = doc.nodeAt(info.pos); if ( existingNode && info.pos + existingNode.nodeSize > insertPos ) { insertPos = info.pos + existingNode.nodeSize; foundInsertPos = true; } } }); // If no existing reference comes before this one, but there are references after, // we've already set insertPos to right after heading which is correct // If this is larger than all existing refs, find the last one if (!foundInsertPos && existingRefs.size > 0) { let maxRefNum = -1; let maxRefPos = insertPos; existingRefs.forEach((info, existingRefNum) => { const existingRefNumInt = parseInt(existingRefNum); if (!isNaN(existingRefNumInt) && existingRefNumInt > maxRefNum) { maxRefNum = existingRefNumInt; maxRefPos = info.pos; } }); if (maxRefNum >= 0 && refNumInt > maxRefNum) { // This new ref comes after all existing refs const maxNode = doc.nodeAt(maxRefPos); if (maxNode) { insertPos = maxRefPos + maxNode.nodeSize; } } } const nodeData = { type: "paragraph", content: [ { type: "text", text: `[${refNum}] `, marks: [{ type: "bold" }] }, { type: "text", text: "Add your reference text here" } ] }; const node = editorInstance.schema.nodeFromJSON(nodeData); tr.insert(insertPos, node); // Update existingRefs map so subsequent inserts know about this one existingRefs.set(refNum, { pos: insertPos, isPlaceholder: true }); 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 = () => { const newFullscreenState = !isFullscreen(); setIsFullscreen(newFullscreenState); // Update URL search param to persist state setSearchParams({ fullscreen: newFullscreenState ? "true" : undefined }); }; createEffect(() => { if (isFullscreen()) { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { setIsFullscreen(false); setSearchParams({ fullscreen: undefined }); } }; 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
{/* History Modal */}
setShowHistoryModal(false)} >
e.stopPropagation()} > {/* Header */}

Document History

{/* History List */} 0} fallback={
No history available yet. Start editing to capture history.
} >
{(node, index) => { const isCurrent = index() === currentHistoryIndex(); return (
restoreHistory(index())} >
{isCurrent ? `>${index() + 1}<` : index() + 1} {formatRelativeTime(node.timestamp)}
CURRENT
); }}
{/* Footer */}
Click on any history item to restore that version
{/* Infill Loading Indicator */}
AI thinking...
); }