3930 lines
123 KiB
TypeScript
3930 lines
123 KiB
TypeScript
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<ReturnType> {
|
||
iframe: {
|
||
setIframe: (options: { src: string }) => ReturnType;
|
||
};
|
||
}
|
||
}
|
||
|
||
const IframeEmbed = Node.create<IframeOptions>({
|
||
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<ReturnType> {
|
||
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<HistoryNode[]>([]);
|
||
const [currentHistoryIndex, setCurrentHistoryIndex] =
|
||
createSignal<number>(-1);
|
||
const [showHistoryModal, setShowHistoryModal] = createSignal(false);
|
||
const [isLoadingHistory, setIsLoadingHistory] = createSignal(false);
|
||
const MAX_HISTORY_SIZE = 100; // Match database pruning limit
|
||
let historyDebounceTimer: ReturnType<typeof setTimeout> | 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<string>("");
|
||
const [isInfillLoading, setIsInfillLoading] = createSignal(false);
|
||
const [infillConfig, setInfillConfig] = createSignal<{
|
||
endpoint: string;
|
||
token: string;
|
||
} | null>(null);
|
||
const [infillEnabled, setInfillEnabled] = createSignal(true); // Toggle for auto-suggestions
|
||
let infillDebounceTimer: ReturnType<typeof setTimeout> | 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<string, any>) => {
|
||
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<void> => {
|
||
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", "</s>", "<|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 || `<p><em><b>Hello!</b> World</em></p>`,
|
||
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) - only if enabled
|
||
if (infillConfig() && !isInitialLoad && infillEnabled()) {
|
||
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<string>();
|
||
|
||
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 (
|
||
<div class="bg-mantle border-surface2 rounded border p-3 shadow-lg">
|
||
<div class="text-subtext0 mb-2 text-xs">
|
||
Insert Table: {hoverCell().row + 1} × {hoverCell().col + 1}
|
||
</div>
|
||
<div
|
||
class="grid gap-1"
|
||
style={{ "grid-template-columns": `repeat(${maxCols}, 1fr)` }}
|
||
>
|
||
<For each={Array.from({ length: maxRows * maxCols })}>
|
||
{(_, idx) => {
|
||
const row = Math.floor(idx() / maxCols);
|
||
const col = idx() % maxCols;
|
||
|
||
return (
|
||
<div
|
||
class={`border-surface2 h-4 w-4 cursor-pointer border ${
|
||
row <= hoverCell().row && col <= hoverCell().col
|
||
? "bg-blue"
|
||
: "bg-surface0"
|
||
}`}
|
||
onMouseEnter={() => setHoverCell({ row, col })}
|
||
onClick={() => insertTable(row + 1, col + 1)}
|
||
/>
|
||
);
|
||
}}
|
||
</For>
|
||
</div>
|
||
<div class="mt-2 flex gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
const rows = parseInt(prompt("Number of rows:", "3") || "3");
|
||
const cols = parseInt(prompt("Number of columns:", "3") || "3");
|
||
if (rows && cols) insertTable(rows, cols);
|
||
}}
|
||
class="hover:bg-surface1 rounded px-2 py-1 text-xs"
|
||
>
|
||
Custom Size...
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const ConditionalConfigurator = () => {
|
||
return (
|
||
<div class="bg-mantle border-surface2 w-80 rounded border p-4 shadow-lg">
|
||
<h3 class="text-text mb-3 font-semibold">Conditional Block</h3>
|
||
|
||
{/* Condition Type Selector */}
|
||
<label class="text-subtext0 mb-2 block text-xs">Condition Type</label>
|
||
<select
|
||
class="bg-surface0 text-text border-surface2 mb-3 w-full rounded border px-2 py-1"
|
||
value={conditionalForm().conditionType}
|
||
onInput={(e) =>
|
||
setConditionalForm({
|
||
...conditionalForm(),
|
||
conditionType: e.currentTarget.value as any
|
||
})
|
||
}
|
||
>
|
||
<option value="auth">User Authentication</option>
|
||
<option value="privilege">Privilege Level</option>
|
||
<option value="date">Date Range</option>
|
||
<option value="feature">Feature Flag</option>
|
||
<option value="env">Environment Variable</option>
|
||
</select>
|
||
|
||
{/* Dynamic Condition Value Input based on type */}
|
||
<Show when={conditionalForm().conditionType === "auth"}>
|
||
<label class="text-subtext0 mb-2 block text-xs">User State</label>
|
||
<select
|
||
class="bg-surface0 text-text border-surface2 mb-3 w-full rounded border px-2 py-1"
|
||
value={conditionalForm().conditionValue}
|
||
onInput={(e) =>
|
||
setConditionalForm({
|
||
...conditionalForm(),
|
||
conditionValue: e.currentTarget.value
|
||
})
|
||
}
|
||
>
|
||
<option value="authenticated">Authenticated</option>
|
||
<option value="anonymous">Anonymous</option>
|
||
</select>
|
||
</Show>
|
||
|
||
<Show when={conditionalForm().conditionType === "privilege"}>
|
||
<label class="text-subtext0 mb-2 block text-xs">
|
||
Privilege Level
|
||
</label>
|
||
<select
|
||
class="bg-surface0 text-text border-surface2 mb-3 w-full rounded border px-2 py-1"
|
||
value={conditionalForm().conditionValue}
|
||
onInput={(e) =>
|
||
setConditionalForm({
|
||
...conditionalForm(),
|
||
conditionValue: e.currentTarget.value
|
||
})
|
||
}
|
||
>
|
||
<option value="admin">Admin</option>
|
||
<option value="user">User</option>
|
||
<option value="anonymous">Anonymous</option>
|
||
</select>
|
||
</Show>
|
||
|
||
<Show when={conditionalForm().conditionType === "date"}>
|
||
<label class="text-subtext0 mb-2 block text-xs">Date Condition</label>
|
||
<input
|
||
type="text"
|
||
placeholder="before:2026-01-01 or after:2025-01-01"
|
||
class="bg-surface0 text-text border-surface2 mb-3 w-full rounded border px-2 py-1"
|
||
value={conditionalForm().conditionValue}
|
||
onInput={(e) =>
|
||
setConditionalForm({
|
||
...conditionalForm(),
|
||
conditionValue: e.currentTarget.value
|
||
})
|
||
}
|
||
/>
|
||
<div class="text-subtext0 mb-3 text-xs">
|
||
Format: before:YYYY-MM-DD, after:YYYY-MM-DD, or
|
||
between:YYYY-MM-DD,YYYY-MM-DD
|
||
</div>
|
||
</Show>
|
||
|
||
<Show when={conditionalForm().conditionType === "feature"}>
|
||
<label class="text-subtext0 mb-2 block text-xs">
|
||
Feature Flag Name
|
||
</label>
|
||
<input
|
||
type="text"
|
||
placeholder="feature-name"
|
||
class="bg-surface0 text-text border-surface2 mb-3 w-full rounded border px-2 py-1"
|
||
value={conditionalForm().conditionValue}
|
||
onInput={(e) =>
|
||
setConditionalForm({
|
||
...conditionalForm(),
|
||
conditionValue: e.currentTarget.value
|
||
})
|
||
}
|
||
/>
|
||
</Show>
|
||
|
||
<Show when={conditionalForm().conditionType === "env"}>
|
||
<label class="text-subtext0 mb-2 block text-xs">
|
||
Environment Variable
|
||
</label>
|
||
<input
|
||
type="text"
|
||
list="env-variables"
|
||
placeholder="NODE_ENV:production"
|
||
class="bg-surface0 text-text border-surface2 mb-3 w-full rounded border px-2 py-1"
|
||
value={conditionalForm().conditionValue}
|
||
onInput={(e) =>
|
||
setConditionalForm({
|
||
...conditionalForm(),
|
||
conditionValue: e.currentTarget.value
|
||
})
|
||
}
|
||
/>
|
||
<datalist id="env-variables">
|
||
<option value="NODE_ENV:development">
|
||
Development environment
|
||
</option>
|
||
<option value="NODE_ENV:production">Production environment</option>
|
||
<option value="NODE_ENV:test">Test environment</option>
|
||
<option value="VERCEL_ENV:preview">
|
||
Vercel preview deployment
|
||
</option>
|
||
<option value="VERCEL_ENV:production">Vercel production</option>
|
||
<option value="VITE_DOMAIN:*">Any domain configured</option>
|
||
<option value="VITE_AWS_BUCKET_STRING:*">
|
||
S3 bucket configured
|
||
</option>
|
||
<option value="VITE_GOOGLE_CLIENT_ID:*">Google auth enabled</option>
|
||
<option value="VITE_GITHUB_CLIENT_ID:*">GitHub auth enabled</option>
|
||
<option value="VITE_WEBSOCKET:*">WebSocket configured</option>
|
||
</datalist>
|
||
<div class="text-subtext0 mb-3 text-xs">
|
||
Format: VAR_NAME:value or VAR_NAME:* for any truthy value
|
||
</div>
|
||
</Show>
|
||
|
||
{/* Show When Toggle */}
|
||
<label class="text-subtext0 mb-2 block text-xs">Show When</label>
|
||
<select
|
||
class="bg-surface0 text-text border-surface2 mb-3 w-full rounded border px-2 py-1"
|
||
value={conditionalForm().showWhen}
|
||
onInput={(e) =>
|
||
setConditionalForm({
|
||
...conditionalForm(),
|
||
showWhen: e.currentTarget.value as "true" | "false"
|
||
})
|
||
}
|
||
>
|
||
<option value="true">Condition is TRUE</option>
|
||
<option value="false">Condition is FALSE</option>
|
||
</select>
|
||
|
||
{/* Inline Toggle */}
|
||
<label class="text-subtext0 mb-3 flex items-center gap-2 text-xs">
|
||
<input
|
||
type="checkbox"
|
||
checked={conditionalForm().inline}
|
||
onChange={(e) =>
|
||
setConditionalForm({
|
||
...conditionalForm(),
|
||
inline: e.currentTarget.checked
|
||
})
|
||
}
|
||
class="rounded"
|
||
/>
|
||
<span>Inline (no line break)</span>
|
||
</label>
|
||
|
||
{/* Action Buttons */}
|
||
<div class="flex gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={insertConditionalBlock}
|
||
class="bg-blue rounded px-3 py-1 text-sm hover:brightness-125"
|
||
>
|
||
Apply
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowConditionalConfig(false)}
|
||
class="hover:bg-surface1 rounded px-3 py-1 text-sm"
|
||
>
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<div
|
||
ref={containerRef}
|
||
class="border-surface2 text-text w-full max-w-full overflow-hidden rounded-md border px-4 py-2"
|
||
classList={{
|
||
"fixed inset-0 z-100 m-0 h-screen max-h-screen rounded-none flex flex-col overflow-hidden!":
|
||
isFullscreen(),
|
||
"bg-base": isFullscreen()
|
||
}}
|
||
>
|
||
<Show when={editor()}>
|
||
{(instance) => (
|
||
<>
|
||
{/* Bubble Menu - appears when text is selected */}
|
||
<Show when={showBubbleMenu()}>
|
||
<div
|
||
ref={bubbleMenuRef}
|
||
class="bg-crust text-text fixed z-120 w-fit rounded p-2 text-sm whitespace-nowrap shadow-xl"
|
||
style={{
|
||
top: `${bubbleMenuPosition().top}px`,
|
||
left: `${bubbleMenuPosition().left}px`,
|
||
transform: "translate(-50%, -100%)",
|
||
"margin-top": "-8px"
|
||
}}
|
||
>
|
||
<div class="flex scale-105 flex-wrap gap-1">
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
instance()
|
||
.chain()
|
||
.focus()
|
||
.toggleHeading({ level: 1 })
|
||
.run()
|
||
}
|
||
class={`${
|
||
isActive("heading", { level: 1 })
|
||
? "bg-surface2"
|
||
: "hover:bg-surface1"
|
||
} touch-manipulation rounded px-2 py-1 select-none`}
|
||
>
|
||
H1
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
instance()
|
||
.chain()
|
||
.focus()
|
||
.toggleHeading({ level: 2 })
|
||
.run()
|
||
}
|
||
class={`${
|
||
isActive("heading", { level: 2 })
|
||
? "bg-surface2"
|
||
: "hover:bg-surface1"
|
||
} touch-manipulation rounded px-2 py-1 select-none`}
|
||
>
|
||
H2
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
instance()
|
||
.chain()
|
||
.focus()
|
||
.toggleHeading({ level: 3 })
|
||
.run()
|
||
}
|
||
class={`${
|
||
isActive("heading", { level: 3 })
|
||
? "bg-surface2"
|
||
: "hover:bg-surface1"
|
||
} touch-manipulation rounded px-2 py-1 select-none`}
|
||
>
|
||
H3
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
instance().chain().focus().toggleBold().run()
|
||
}
|
||
class={`${
|
||
isActive("bold") && "bg-crust"
|
||
} bg-opacity-30 hover:bg-opacity-30 touch-manipulation rounded px-2 py-1 select-none`}
|
||
>
|
||
<strong>B</strong>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
instance().chain().focus().toggleItalic().run()
|
||
}
|
||
class={`${
|
||
isActive("italic") && "bg-crust"
|
||
} bg-opacity-30 hover:bg-opacity-30 touch-manipulation rounded px-2 py-1 select-none`}
|
||
>
|
||
<em>I</em>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
instance().chain().focus().toggleStrike().run()
|
||
}
|
||
class={`${
|
||
isActive("strike") && "bg-crust"
|
||
} bg-opacity-30 hover:bg-opacity-30 touch-manipulation rounded px-2 py-1 select-none`}
|
||
>
|
||
<s>S</s>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={setLink}
|
||
class={`${
|
||
isActive("link") && "bg-crust"
|
||
} bg-opacity-30 hover:bg-opacity-30 touch-manipulation rounded px-2 py-1 select-none`}
|
||
>
|
||
Link
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
instance().chain().focus().toggleSuperscript().run()
|
||
}
|
||
class={`${
|
||
isActive("superscript") && "bg-crust"
|
||
} bg-opacity-30 hover:bg-opacity-30 touch-manipulation rounded px-2 py-1 select-none`}
|
||
title="Superscript (Reference)"
|
||
>
|
||
X<sup>n</sup>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
instance().chain().focus().toggleSubscript().run()
|
||
}
|
||
class={`${
|
||
isActive("subscript") && "bg-crust"
|
||
} bg-opacity-30 hover:bg-opacity-30 touch-manipulation rounded px-2 py-1 select-none`}
|
||
title="Subscript"
|
||
>
|
||
X<sub>n</sub>
|
||
</button>
|
||
{/* Table controls in bubble menu */}
|
||
<Show when={isActive("table")}>
|
||
<div class="border-crust mx-1 border-l opacity-30"></div>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
instance().chain().focus().addRowBefore().run()
|
||
}
|
||
class="hover:bg-crust hover:bg-opacity-30 touch-manipulation rounded px-2 py-1 select-none"
|
||
title="Add Row Before"
|
||
>
|
||
↑ Row
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
instance().chain().focus().addRowAfter().run()
|
||
}
|
||
class="hover:bg-crust hover:bg-opacity-30 touch-manipulation rounded px-2 py-1 select-none"
|
||
title="Add Row After"
|
||
>
|
||
Row ↓
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={deleteRowWithConfirmation}
|
||
class="hover:bg-red hover:bg-opacity-30 touch-manipulation rounded px-2 py-1 select-none"
|
||
title="Delete Row"
|
||
>
|
||
✕ Row
|
||
</button>
|
||
|
||
<div class="border-crust mx-1 border-l opacity-30"></div>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
instance().chain().focus().addColumnBefore().run()
|
||
}
|
||
class="hover:bg-crust hover:bg-opacity-30 touch-manipulation rounded px-2 py-1 select-none"
|
||
title="Add Column Before"
|
||
>
|
||
← Col
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
instance().chain().focus().addColumnAfter().run()
|
||
}
|
||
class="hover:bg-crust hover:bg-opacity-30 touch-manipulation rounded px-2 py-1 select-none"
|
||
title="Add Column After"
|
||
>
|
||
Col →
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={deleteColumnWithConfirmation}
|
||
class="hover:bg-red hover:bg-opacity-30 touch-manipulation rounded px-2 py-1 select-none"
|
||
title="Delete Column"
|
||
>
|
||
✕ Col
|
||
</button>
|
||
|
||
<div class="border-crust mx-1 border-l opacity-30"></div>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
instance().chain().focus().mergeCells().run()
|
||
}
|
||
class="hover:bg-crust hover:bg-opacity-30 touch-manipulation rounded px-2 py-1 select-none"
|
||
title="Merge Cells"
|
||
>
|
||
⊡
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
instance().chain().focus().splitCell().run()
|
||
}
|
||
class="hover:bg-crust hover:bg-opacity-30 touch-manipulation rounded px-2 py-1 select-none"
|
||
title="Split Cell"
|
||
>
|
||
⊞
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={deleteTableWithConfirmation}
|
||
class="hover:bg-red hover:bg-opacity-30 touch-manipulation rounded px-2 py-1 select-none"
|
||
title="Delete Table"
|
||
>
|
||
✕ Table
|
||
</button>
|
||
</Show>
|
||
</div>
|
||
</div>
|
||
</Show>
|
||
|
||
{/* Language Selector Dropdown */}
|
||
<Show when={showLanguageSelector()}>
|
||
<div
|
||
class="language-selector bg-mantle text-text border-surface2 fixed z-[120] max-h-64 w-48 overflow-y-auto rounded border shadow-lg"
|
||
style={{
|
||
top: `${languageSelectorPosition().top}px`,
|
||
left: `${languageSelectorPosition().left}px`
|
||
}}
|
||
>
|
||
<For each={AVAILABLE_LANGUAGES}>
|
||
{(lang) => (
|
||
<button
|
||
type="button"
|
||
onClick={() => insertCodeBlock(lang.value)}
|
||
class="hover:bg-surface1 w-full px-3 py-2 text-left text-sm transition-colors"
|
||
>
|
||
{lang.label}
|
||
</button>
|
||
)}
|
||
</For>
|
||
</div>
|
||
</Show>
|
||
|
||
{/* Table Grid Selector */}
|
||
<Show when={showTableMenu()}>
|
||
<div
|
||
class="table-menu fixed z-[120]"
|
||
style={{
|
||
top: `${tableMenuPosition().top}px`,
|
||
left: `${tableMenuPosition().left}px`
|
||
}}
|
||
>
|
||
<TableGridSelector />
|
||
</div>
|
||
</Show>
|
||
|
||
{/* Mermaid Template Selector */}
|
||
<Show when={showMermaidTemplates()}>
|
||
<div
|
||
class="mermaid-menu bg-mantle text-text border-surface2 fixed z-[120] max-h-96 w-56 overflow-y-auto rounded border shadow-lg"
|
||
style={{
|
||
top: `${mermaidMenuPosition().top}px`,
|
||
left: `${mermaidMenuPosition().left}px`
|
||
}}
|
||
>
|
||
<div class="border-surface2 border-b p-2">
|
||
<div class="text-subtext0 text-xs font-semibold">
|
||
Select Diagram Type
|
||
</div>
|
||
</div>
|
||
<For each={MERMAID_TEMPLATES}>
|
||
{(template) => (
|
||
<button
|
||
type="button"
|
||
onClick={() => insertMermaidDiagram(template)}
|
||
class="hover:bg-surface1 w-full px-3 py-2 text-left text-sm"
|
||
>
|
||
{template.name}
|
||
</button>
|
||
)}
|
||
</For>
|
||
</div>
|
||
</Show>
|
||
|
||
{/* Conditional Configurator */}
|
||
<Show when={showConditionalConfig()}>
|
||
<div
|
||
class="conditional-config fixed z-[120]"
|
||
style={{
|
||
top: `${conditionalConfigPosition().top}px`,
|
||
left: `${conditionalConfigPosition().left}px`
|
||
}}
|
||
>
|
||
<ConditionalConfigurator />
|
||
</div>
|
||
</Show>
|
||
|
||
{/* Main Toolbar - Fixed at top always */}
|
||
<div
|
||
id="main-toolbar"
|
||
class="border-surface2 bg-base sticky top-0 z-[105] border-b"
|
||
classList={{
|
||
"flex-none": isFullscreen()
|
||
}}
|
||
>
|
||
<div class="flex flex-wrap gap-1 pb-2">
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
instance().chain().focus().toggleHeading({ level: 1 }).run()
|
||
}
|
||
class={`${
|
||
isActive("heading", { level: 1 })
|
||
? "bg-surface2"
|
||
: "hover:bg-surface1"
|
||
} touch-manipulation rounded px-2 py-1 text-xs select-none`}
|
||
title="Heading 1"
|
||
>
|
||
H1
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
instance().chain().focus().toggleHeading({ level: 2 }).run()
|
||
}
|
||
class={`${
|
||
isActive("heading", { level: 2 })
|
||
? "bg-surface2"
|
||
: "hover:bg-surface1"
|
||
} touch-manipulation rounded px-2 py-1 text-xs select-none`}
|
||
title="Heading 2"
|
||
>
|
||
H2
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
instance().chain().focus().toggleHeading({ level: 3 }).run()
|
||
}
|
||
class={`${
|
||
isActive("heading", { level: 3 })
|
||
? "bg-surface2"
|
||
: "hover:bg-surface1"
|
||
} touch-manipulation rounded px-2 py-1 text-xs select-none`}
|
||
title="Heading 3"
|
||
>
|
||
H3
|
||
</button>
|
||
<div class="border-surface2 mx-1 border-l"></div>
|
||
<button
|
||
type="button"
|
||
onClick={() => instance().chain().focus().toggleBold().run()}
|
||
class={getButtonClasses(isActive("bold"))}
|
||
title="Bold"
|
||
>
|
||
<strong>B</strong>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
instance().chain().focus().toggleItalic().run()
|
||
}
|
||
class={getButtonClasses(isActive("italic"))}
|
||
title="Italic"
|
||
>
|
||
<em>I</em>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
instance().chain().focus().toggleStrike().run()
|
||
}
|
||
class={getButtonClasses(isActive("strike"))}
|
||
title="Strikethrough"
|
||
>
|
||
<s>S</s>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
instance().chain().focus().toggleSuperscript().run()
|
||
}
|
||
class={`${
|
||
isActive("superscript")
|
||
? "bg-surface2"
|
||
: "hover:bg-surface1"
|
||
} touch-manipulation rounded px-2 py-1 text-xs select-none`}
|
||
title="Superscript (for references)"
|
||
>
|
||
X<sup class="text-[0.6em]">n</sup>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
instance().chain().focus().toggleSubscript().run()
|
||
}
|
||
class={`${
|
||
isActive("subscript") && "bg-surface2"
|
||
} touch-manipulation rounded px-2 py-1 text-xs select-none`}
|
||
title="Subscript"
|
||
>
|
||
X<sub class="text-[0.6em]">n</sub>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={insertReference}
|
||
class="hover:bg-surface1 touch-manipulation rounded px-2 py-1 text-xs select-none"
|
||
title="Insert Reference [n] (Cmd/Ctrl+R)"
|
||
>
|
||
[n]
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
const newHeading = window.prompt(
|
||
"Enter heading for references section:",
|
||
referencesHeading()
|
||
);
|
||
if (newHeading && newHeading.trim()) {
|
||
setReferencesHeading(newHeading.trim());
|
||
// Update existing section if it exists
|
||
const instance = editor();
|
||
if (instance) {
|
||
updateReferencesSection(instance);
|
||
}
|
||
}
|
||
}}
|
||
class="hover:bg-surface1 touch-manipulation rounded px-2 py-1 text-xs select-none"
|
||
title={`Change references heading (current: "${referencesHeading()}")`}
|
||
>
|
||
📑
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowHistoryModal(true)}
|
||
class="hover:bg-surface1 touch-manipulation rounded px-2 py-1 text-xs select-none"
|
||
title={`View Document History (${history().length} snapshots)`}
|
||
>
|
||
🕐
|
||
</button>
|
||
<div class="border-surface2 mx-1 border-l"></div>
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
instance().chain().focus().toggleBulletList().run()
|
||
}
|
||
class={`${
|
||
isActive("bulletList") && "bg-surface2"
|
||
} touch-manipulation rounded px-2 py-1 text-xs select-none`}
|
||
title="Bullet List"
|
||
>
|
||
• List
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
instance().chain().focus().toggleOrderedList().run()
|
||
}
|
||
class={`${
|
||
isActive("orderedList")
|
||
? "bg-surface2"
|
||
: "hover:bg-surface1"
|
||
} touch-manipulation rounded px-2 py-1 text-xs select-none`}
|
||
title="Ordered List"
|
||
>
|
||
1. List
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
instance().chain().focus().toggleTaskList().run()
|
||
}
|
||
class={`${
|
||
isActive("taskList") && "bg-surface2"
|
||
} touch-manipulation rounded px-2 py-1 text-xs select-none`}
|
||
title="Task List"
|
||
>
|
||
☑ Tasks
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
instance().chain().focus().toggleBlockquote().run()
|
||
}
|
||
class={`${
|
||
isActive("blockquote") && "bg-surface2"
|
||
} touch-manipulation rounded px-2 py-1 text-xs select-none`}
|
||
title="Blockquote"
|
||
>
|
||
" Quote
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={insertCollapsibleSection}
|
||
class="hover:bg-surface1 touch-manipulation rounded px-2 py-1 text-xs select-none"
|
||
title="Insert Collapsible Section"
|
||
>
|
||
▼ Details
|
||
</button>
|
||
<div class="border-surface2 mx-1 border-l"></div>
|
||
|
||
{/* Text Alignment */}
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
instance().chain().focus().setTextAlign("left").run();
|
||
setEditorState((prev) => prev + 1);
|
||
}}
|
||
class={`${
|
||
isAlignActive("left") && "bg-surface2"
|
||
} touch-manipulation rounded px-2 py-1 text-xs select-none`}
|
||
title="Align Left"
|
||
>
|
||
←
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
instance().chain().focus().setTextAlign("center").run();
|
||
setEditorState((prev) => prev + 1);
|
||
}}
|
||
class={`${
|
||
isAlignActive("center") && "bg-surface2"
|
||
} touch-manipulation rounded px-2 py-1 text-xs select-none`}
|
||
title="Align Center"
|
||
>
|
||
↔
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
instance().chain().focus().setTextAlign("right").run();
|
||
setEditorState((prev) => prev + 1);
|
||
}}
|
||
class={`${
|
||
isAlignActive("right") && "bg-surface2"
|
||
} touch-manipulation rounded px-2 py-1 text-xs select-none`}
|
||
title="Align Right"
|
||
>
|
||
→
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
instance().chain().focus().setTextAlign("justify").run();
|
||
setEditorState((prev) => prev + 1);
|
||
}}
|
||
class={`${
|
||
isAlignActive("justify") && "bg-surface2"
|
||
} touch-manipulation rounded px-2 py-1 text-xs select-none`}
|
||
title="Justify"
|
||
>
|
||
⇄
|
||
</button>
|
||
<div class="border-surface2 mx-1 border-l"></div>
|
||
<button
|
||
type="button"
|
||
onClick={showLanguagePicker}
|
||
data-language-picker-trigger
|
||
class={`${
|
||
(showLanguageSelector() || isActive("codeBlock")) &&
|
||
"bg-surface2"
|
||
} touch-manipulation rounded px-2 py-1 text-xs select-none`}
|
||
title="Code Block"
|
||
>
|
||
{"</>"}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={setLink}
|
||
class={`${
|
||
isActive("link") && "bg-surface2"
|
||
} touch-manipulation rounded px-2 py-1 text-xs select-none`}
|
||
title="Add Link"
|
||
>
|
||
🔗 Link
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={addImage}
|
||
class="touch-manipulation rounded px-2 py-1 text-xs select-none"
|
||
title="Add Image"
|
||
>
|
||
🖼 Image
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={addIframe}
|
||
class="touch-manipulation rounded px-2 py-1 text-xs select-none"
|
||
title="Add Iframe"
|
||
>
|
||
📺 Iframe
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={showTableInserter}
|
||
data-table-trigger
|
||
class={`${
|
||
(showTableMenu() || isActive("table")) && "bg-surface2"
|
||
} touch-manipulation rounded px-2 py-1 text-xs select-none`}
|
||
title="Insert Table"
|
||
>
|
||
⊞ Table
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={showMermaidSelector}
|
||
data-mermaid-trigger
|
||
class={`${
|
||
showMermaidTemplates() && "bg-surface2"
|
||
} touch-manipulation rounded px-2 py-1 text-xs select-none`}
|
||
title="Insert Diagram"
|
||
>
|
||
📊 Diagram
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={showConditionalConfigurator}
|
||
data-conditional-trigger
|
||
class={`${
|
||
(showConditionalConfig() || isActive("conditionalBlock")) &&
|
||
"bg-surface2"
|
||
} rounded px-2 py-1 text-xs select-none`}
|
||
title="Insert Conditional Block"
|
||
>
|
||
🔒 Conditional
|
||
</button>
|
||
<div class="border-surface2 mx-1 border-l"></div>
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
instance().chain().focus().setHorizontalRule().run()
|
||
}
|
||
class="hover:bg-surface1 rounded px-3 py-1 text-xs"
|
||
title="Horizontal Rule"
|
||
>
|
||
━━ HR
|
||
</button>
|
||
<div class="border-surface2 mx-1 border-l"></div>
|
||
|
||
{/* Undo/Redo buttons - critical for mobile */}
|
||
<button
|
||
type="button"
|
||
onClick={() => instance().chain().focus().undo().run()}
|
||
disabled={!instance().can().undo()}
|
||
class="hover:bg-surface1 touch-manipulation rounded px-2 py-1 text-xs select-none disabled:cursor-not-allowed disabled:opacity-60"
|
||
title="Undo (Cmd/Ctrl+Z)"
|
||
>
|
||
↺
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => instance().chain().focus().redo().run()}
|
||
disabled={!instance().can().redo()}
|
||
class="hover:bg-surface1 touch-manipulation rounded px-2 py-1 text-xs select-none disabled:cursor-not-allowed disabled:opacity-60"
|
||
title="Redo (Cmd/Ctrl+Shift+Z)"
|
||
>
|
||
↻
|
||
</button>
|
||
<div class="border-surface2 mx-1 border-l"></div>
|
||
<button
|
||
type="button"
|
||
onClick={toggleFullscreen}
|
||
class="hover:bg-surface1 touch-manipulation rounded px-2 py-1 text-xs select-none"
|
||
title={
|
||
isFullscreen()
|
||
? "Exit Fullscreen (ESC)"
|
||
: "Enter Fullscreen"
|
||
}
|
||
>
|
||
{isFullscreen() ? "⇲ Exit" : "⇱ Fullscreen"}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowKeyboardHelp(!showKeyboardHelp())}
|
||
class="hover:bg-surface1 touch-manipulation rounded px-2 py-1 text-xs select-none"
|
||
title="Keyboard Shortcuts"
|
||
>
|
||
⌨ Help
|
||
</button>
|
||
|
||
{/* AI Autocomplete Toggle - Desktop only, shown when config available */}
|
||
<Show when={infillConfig()}>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setInfillEnabled(!infillEnabled());
|
||
// Clear any existing suggestion when disabled
|
||
if (!infillEnabled()) {
|
||
setCurrentSuggestion("");
|
||
}
|
||
}}
|
||
class={`${
|
||
infillEnabled()
|
||
? "bg-blue text-base"
|
||
: "bg-surface1 text-subtext0"
|
||
} hidden touch-manipulation rounded px-2 py-1 text-xs font-semibold transition-colors select-none md:block`}
|
||
title={
|
||
infillEnabled()
|
||
? "AI Autocomplete: ON (Ctrl/Cmd+Space to trigger manually)"
|
||
: "AI Autocomplete: OFF (Click to enable)"
|
||
}
|
||
>
|
||
{infillEnabled() ? "🤖 AI" : "🤖"}
|
||
</button>
|
||
</Show>
|
||
|
||
{/* Table controls - shown when cursor is in a table */}
|
||
<Show when={isActive("table")}>
|
||
<div class="border-surface2 mx-1 border-l"></div>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
instance().chain().focus().addColumnBefore().run()
|
||
}
|
||
class="hover:bg-surface1 touch-manipulation rounded px-2 py-1 text-xs select-none"
|
||
title="Add Column Before"
|
||
>
|
||
← Col
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
instance().chain().focus().addColumnAfter().run()
|
||
}
|
||
class="hover:bg-surface1 touch-manipulation rounded px-2 py-1 text-xs select-none"
|
||
title="Add Column After"
|
||
>
|
||
Col →
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={deleteColumnWithConfirmation}
|
||
class="hover:bg-red bg-opacity-20 touch-manipulation rounded px-2 py-1 text-xs select-none"
|
||
title="Delete Column"
|
||
>
|
||
✕ Col
|
||
</button>
|
||
|
||
<div class="border-surface2 mx-1 border-l"></div>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
instance().chain().focus().addRowBefore().run()
|
||
}
|
||
class="hover:bg-surface1 touch-manipulation rounded px-2 py-1 text-xs select-none"
|
||
title="Add Row Before"
|
||
>
|
||
↑ Row
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
instance().chain().focus().addRowAfter().run()
|
||
}
|
||
class="hover:bg-surface1 touch-manipulation rounded px-2 py-1 text-xs select-none"
|
||
title="Add Row After"
|
||
>
|
||
Row ↓
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={deleteRowWithConfirmation}
|
||
class="hover:bg-red bg-opacity-20 touch-manipulation rounded px-2 py-1 text-xs select-none"
|
||
title="Delete Row"
|
||
>
|
||
✕ Row
|
||
</button>
|
||
|
||
<div class="border-surface2 mx-1 border-l"></div>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={deleteTableWithConfirmation}
|
||
class="hover:bg-red touch-manipulation rounded px-2 py-1 text-xs select-none"
|
||
title="Delete Table"
|
||
>
|
||
✕ Table
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
instance().chain().focus().toggleHeaderRow().run()
|
||
}
|
||
class={`${
|
||
isActive("tableHeader")
|
||
? "bg-surface2"
|
||
: "hover:bg-surface1"
|
||
} touch-manipulation rounded px-2 py-1 text-xs select-none`}
|
||
title="Toggle Header Row"
|
||
>
|
||
≡ Header
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
instance().chain().focus().mergeCells().run()
|
||
}
|
||
class="hover:bg-surface1 touch-manipulation rounded px-2 py-1 text-xs select-none"
|
||
title="Merge Cells"
|
||
>
|
||
⊡ Merge
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => instance().chain().focus().splitCell().run()}
|
||
class="hover:bg-surface1 touch-manipulation rounded px-2 py-1 text-xs select-none"
|
||
title="Split Cell"
|
||
>
|
||
⊞ Split
|
||
</button>
|
||
</Show>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
</Show>
|
||
|
||
<div
|
||
ref={editorRef}
|
||
class="prose prose-sm prose-invert sm:prose-base md:prose-lg mx-auto max-w-full transition-all duration-300 focus:outline-none md:px-8"
|
||
classList={{
|
||
"h-[80dvh] overflow-scroll": !isFullscreen(),
|
||
"flex-1 h-full overflow-y-auto": isFullscreen()
|
||
}}
|
||
style={{
|
||
"padding-bottom": keyboardVisible() ? `${keyboardHeight()}px` : "1rem"
|
||
}}
|
||
/>
|
||
|
||
{/* Keyboard Help Modal */}
|
||
<Show when={showKeyboardHelp()}>
|
||
<div
|
||
class="bg-opacity-50 fixed inset-0 z-150 flex items-center justify-center bg-black"
|
||
onClick={() => setShowKeyboardHelp(false)}
|
||
>
|
||
<div
|
||
class="bg-base border-surface2 max-h-[80dvh] w-full max-w-2xl overflow-y-auto rounded-lg border p-6 shadow-2xl"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
{/* Header */}
|
||
<div class="mb-6 flex items-center justify-between">
|
||
<h2 class="text-text text-2xl font-bold">Keyboard Shortcuts</h2>
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowKeyboardHelp(false)}
|
||
class="hover:bg-surface1 text-subtext0 rounded p-2 text-xl"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
|
||
{/* Shortcuts Grid */}
|
||
<div class="space-y-6">
|
||
<For each={KEYBOARD_SHORTCUTS}>
|
||
{(category) => (
|
||
<div>
|
||
<h3 class="text-blue mb-3 text-lg font-semibold">
|
||
{category.name}
|
||
</h3>
|
||
<div class="space-y-2">
|
||
<For each={category.shortcuts}>
|
||
{(shortcut) => (
|
||
<div class="flex items-center justify-between">
|
||
<span class="text-text">
|
||
{shortcut.description}
|
||
</span>
|
||
<kbd class="bg-surface0 border-surface2 text-subtext0 rounded border px-3 py-1 font-mono text-sm">
|
||
{isMac()
|
||
? shortcut.keys
|
||
: shortcut.keysAlt || shortcut.keys}
|
||
</kbd>
|
||
</div>
|
||
)}
|
||
</For>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</For>
|
||
</div>
|
||
|
||
{/* Footer */}
|
||
<div class="text-subtext0 border-surface2 mt-6 border-t pt-4 text-center text-sm">
|
||
Press <span class="text-text font-semibold">⌨ Help</span> button
|
||
to toggle this help
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Show>
|
||
|
||
{/* History Modal */}
|
||
<Show when={showHistoryModal()}>
|
||
<div
|
||
class="bg-opacity-50 fixed inset-0 z-150 flex items-center justify-center bg-black"
|
||
onClick={() => setShowHistoryModal(false)}
|
||
>
|
||
<div
|
||
class="bg-base border-surface2 max-h-[80dvh] w-full max-w-2xl overflow-y-auto rounded-lg border p-6 shadow-2xl"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
{/* Header */}
|
||
<div class="mb-6 flex items-center justify-between">
|
||
<h2 class="text-text text-2xl font-bold">Document History</h2>
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowHistoryModal(false)}
|
||
class="hover:bg-surface1 text-subtext0 rounded p-2 text-xl"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
|
||
{/* History List */}
|
||
<Show
|
||
when={history().length > 0}
|
||
fallback={
|
||
<div class="text-subtext0 py-8 text-center">
|
||
No history available yet. Start editing to capture history.
|
||
</div>
|
||
}
|
||
>
|
||
<div class="space-y-2">
|
||
<For each={history()}>
|
||
{(node, index) => {
|
||
const isCurrent = index() === currentHistoryIndex();
|
||
return (
|
||
<div
|
||
class={`hover:bg-surface1 flex cursor-pointer items-center justify-between rounded-lg border p-3 transition-colors ${
|
||
isCurrent
|
||
? "border-blue bg-surface1"
|
||
: "border-surface2"
|
||
}`}
|
||
onClick={() => restoreHistory(index())}
|
||
>
|
||
<div class="flex items-center gap-3">
|
||
<span
|
||
class={`font-mono text-sm ${
|
||
isCurrent
|
||
? "text-blue font-bold"
|
||
: "text-subtext0"
|
||
}`}
|
||
>
|
||
{isCurrent ? `>${index() + 1}<` : index() + 1}
|
||
</span>
|
||
<span class="text-text text-sm">
|
||
{formatRelativeTime(node.timestamp)}
|
||
</span>
|
||
</div>
|
||
<Show when={isCurrent}>
|
||
<span class="text-blue text-xs font-semibold">
|
||
CURRENT
|
||
</span>
|
||
</Show>
|
||
</div>
|
||
);
|
||
}}
|
||
</For>
|
||
</div>
|
||
</Show>
|
||
|
||
{/* Footer */}
|
||
<div class="text-subtext0 border-surface2 mt-6 border-t pt-4 text-center text-sm">
|
||
Click on any history item to restore that version
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Show>
|
||
|
||
{/* Infill Loading Indicator */}
|
||
<Show when={isInfillLoading()}>
|
||
<div class="bg-surface0 border-surface2 text-subtext0 fixed right-4 bottom-4 z-50 animate-pulse rounded border px-3 py-2 text-xs shadow-lg">
|
||
AI thinking...
|
||
</div>
|
||
</Show>
|
||
</div>
|
||
);
|
||
}
|