Files
freno-dev/src/components/blog/TextEditor.tsx

1729 lines
54 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Show, untrack, createEffect, on, createSignal, For } from "solid-js";
import { createTiptapEditor, useEditorHTML } from "solid-tiptap";
import StarterKit from "@tiptap/starter-kit";
import Link from "@tiptap/extension-link";
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
import Image from "@tiptap/extension-image";
import { Table } from "@tiptap/extension-table";
import { TableRow } from "@tiptap/extension-table-row";
import { TableHeader } from "@tiptap/extension-table-header";
import { TableCell } from "@tiptap/extension-table-cell";
import TaskList from "@tiptap/extension-task-list";
import TaskItem from "@tiptap/extension-task-item";
import Details from "@tiptap/extension-details";
import DetailsSummary from "@tiptap/extension-details-summary";
import DetailsContent from "@tiptap/extension-details-content";
import { Node } from "@tiptap/core";
import { createLowlight, common } from "lowlight";
import { Mermaid } from "./extensions/Mermaid";
import TextAlign from "@tiptap/extension-text-align";
import css from "highlight.js/lib/languages/css";
import js from "highlight.js/lib/languages/javascript";
import ts from "highlight.js/lib/languages/typescript";
import ocaml from "highlight.js/lib/languages/ocaml";
import rust from "highlight.js/lib/languages/rust";
import python from "highlight.js/lib/languages/python";
import java from "highlight.js/lib/languages/java";
import go from "highlight.js/lib/languages/go";
import c from "highlight.js/lib/languages/c";
import cpp from "highlight.js/lib/languages/cpp";
import csharp from "highlight.js/lib/languages/csharp";
import sql from "highlight.js/lib/languages/sql";
import bash from "highlight.js/lib/languages/bash";
import json from "highlight.js/lib/languages/json";
import yaml from "highlight.js/lib/languages/yaml";
import markdown from "highlight.js/lib/languages/markdown";
import xml from "highlight.js/lib/languages/xml";
import php from "highlight.js/lib/languages/php";
import ruby from "highlight.js/lib/languages/ruby";
import swift from "highlight.js/lib/languages/swift";
import kotlin from "highlight.js/lib/languages/kotlin";
import dockerfile from "highlight.js/lib/languages/dockerfile";
// Create lowlight instance with common languages
const lowlight = createLowlight(common);
// Register existing languages
lowlight.register("css", css);
lowlight.register("js", js);
lowlight.register("javascript", js);
lowlight.register("ts", ts);
lowlight.register("typescript", ts);
lowlight.register("ocaml", ocaml);
lowlight.register("rust", rust);
// Register new languages
lowlight.register("python", python);
lowlight.register("py", python);
lowlight.register("java", java);
lowlight.register("go", go);
lowlight.register("golang", go);
lowlight.register("c", c);
lowlight.register("cpp", cpp);
lowlight.register("c++", cpp);
lowlight.register("csharp", csharp);
lowlight.register("cs", csharp);
lowlight.register("sql", sql);
lowlight.register("bash", bash);
lowlight.register("shell", bash);
lowlight.register("sh", bash);
lowlight.register("json", json);
lowlight.register("yaml", yaml);
lowlight.register("yml", yaml);
lowlight.register("markdown", markdown);
lowlight.register("md", markdown);
lowlight.register("xml", xml);
lowlight.register("html", xml);
lowlight.register("php", php);
lowlight.register("ruby", ruby);
lowlight.register("rb", ruby);
lowlight.register("swift", swift);
lowlight.register("kotlin", kotlin);
lowlight.register("kt", kotlin);
lowlight.register("dockerfile", dockerfile);
lowlight.register("docker", dockerfile);
// Available languages for selector
const AVAILABLE_LANGUAGES = [
{ value: null, label: "Plain Text" },
{ value: "bash", label: "Bash/Shell" },
{ value: "c", label: "C" },
{ value: "cpp", label: "C++" },
{ value: "csharp", label: "C#" },
{ value: "css", label: "CSS" },
{ value: "dockerfile", label: "Dockerfile" },
{ value: "go", label: "Go" },
{ value: "html", label: "HTML" },
{ value: "java", label: "Java" },
{ value: "javascript", label: "JavaScript" },
{ value: "json", label: "JSON" },
{ value: "kotlin", label: "Kotlin" },
{ value: "markdown", label: "Markdown" },
{ value: "ocaml", label: "OCaml" },
{ value: "php", label: "PHP" },
{ value: "python", label: "Python" },
{ value: "ruby", label: "Ruby" },
{ value: "rust", label: "Rust" },
{ value: "sql", label: "SQL" },
{ value: "swift", label: "Swift" },
{ value: "typescript", label: "TypeScript" },
{ value: "xml", label: "XML" },
{ value: "yaml", label: "YAML" }
] as const;
// Mermaid diagram templates
const MERMAID_TEMPLATES = [
{
name: "Flowchart",
code: `graph TD
A[Start] --> B{Decision}
B -->|Yes| C[Option 1]
B -->|No| D[Option 2]
C --> E[End]
D --> E`
},
{
name: "Sequence Diagram",
code: `sequenceDiagram
participant A as Alice
participant B as Bob
A->>B: Hello Bob!
B->>A: Hello Alice!`
},
{
name: "State Diagram",
code: `stateDiagram-v2
[*] --> Idle
Idle --> Processing
Processing --> Done
Done --> [*]`
},
{
name: "Class Diagram",
code: `classDiagram
class Animal {
+String name
+makeSound()
}
class Dog {
+bark()
}
Animal <|-- Dog`
},
{
name: "Entity Relationship",
code: `erDiagram
CUSTOMER ||--o{ ORDER : places
ORDER ||--|{ LINE-ITEM : contains
CUSTOMER {
string name
string email
}`
},
{
name: "Gantt Chart",
code: `gantt
title Project Timeline
dateFormat YYYY-MM-DD
section Phase 1
Task 1 :a1, 2024-01-01, 30d
Task 2 :after a1, 20d`
},
{
name: "Pie Chart",
code: `pie title Languages Used
"JavaScript" : 45
"TypeScript" : 30
"Python" : 15
"Go" : 10`
}
];
// Keyboard shortcuts data
interface ShortcutCategory {
name: string;
shortcuts: Array<{
keys: string;
keysAlt?: string;
description: string;
}>;
}
const KEYBOARD_SHORTCUTS: ShortcutCategory[] = [
{
name: "Text Formatting",
shortcuts: [
{ keys: "⌘ B", keysAlt: "Ctrl B", description: "Bold" },
{ keys: "⌘ I", keysAlt: "Ctrl I", description: "Italic" },
{ keys: "⌘ ⇧ X", keysAlt: "Ctrl Shift X", description: "Strikethrough" },
{ keys: "⌘ E", keysAlt: "Ctrl E", description: "Inline Code" }
]
},
{
name: "Headings",
shortcuts: [
{ keys: "⌘ ⌥ 1", keysAlt: "Ctrl Alt 1", description: "Heading 1" },
{ keys: "⌘ ⌥ 2", keysAlt: "Ctrl Alt 2", description: "Heading 2" },
{ keys: "⌘ ⌥ 3", keysAlt: "Ctrl Alt 3", description: "Heading 3" },
{ keys: "⌘ ⌥ 0", keysAlt: "Ctrl Alt 0", description: "Paragraph" }
]
},
{
name: "Lists",
shortcuts: [
{ keys: "⌘ ⇧ 7", keysAlt: "Ctrl Shift 7", description: "Ordered List" },
{ keys: "⌘ ⇧ 8", keysAlt: "Ctrl Shift 8", description: "Bullet List" },
{ keys: "⌘ ⇧ 9", keysAlt: "Ctrl Shift 9", description: "Task List" },
{ keys: "Tab", keysAlt: "Tab", description: "Indent" },
{ keys: "⇧ Tab", keysAlt: "Shift Tab", description: "Outdent" }
]
},
{
name: "Text Alignment",
shortcuts: [
{ keys: "⌘ ⇧ L", keysAlt: "Ctrl Shift L", description: "Align Left" },
{ keys: "⌘ ⇧ E", keysAlt: "Ctrl Shift E", description: "Align Center" },
{ keys: "⌘ ⇧ R", keysAlt: "Ctrl Shift R", description: "Align Right" },
{ keys: "⌘ ⇧ J", keysAlt: "Ctrl Shift J", description: "Justify" }
]
},
{
name: "Insert",
shortcuts: [
{ keys: "⌘ K", keysAlt: "Ctrl K", description: "Insert/Edit Link" },
{ keys: "⌘ ⇧ C", keysAlt: "Ctrl Shift C", description: "Code Block" },
{ keys: "⌘ Enter", keysAlt: "Ctrl Enter", description: "Hard Break" },
{ keys: "⌘ ⇧ -", keysAlt: "Ctrl Shift -", description: "Horizontal Rule" }
]
},
{
name: "Editing",
shortcuts: [
{ keys: "⌘ Z", keysAlt: "Ctrl Z", description: "Undo" },
{ keys: "⌘ ⇧ Z", keysAlt: "Ctrl Shift Z", description: "Redo" },
{ keys: "⌘ Y", keysAlt: "Ctrl Y", description: "Redo (Alt)" },
{ keys: "⌘ A", keysAlt: "Ctrl A", description: "Select All" }
]
},
{
name: "Other",
shortcuts: [
{
keys: "⌘ ⇧ \\",
keysAlt: "Ctrl Shift \\",
description: "Clear Formatting"
},
{ keys: "ESC", keysAlt: "ESC", description: "Exit Fullscreen" }
]
}
];
const isMac = () => {
return (
typeof window !== "undefined" &&
/Mac|iPhone|iPad|iPod/.test(window.navigator.platform)
);
};
// IFrame extension
interface IframeOptions {
allowFullscreen: boolean;
HTMLAttributes: {
[key: string]: any;
};
}
declare module "@tiptap/core" {
interface Commands<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;
}
};
}
});
export interface TextEditorProps {
updateContent: (content: string) => void;
preSet?: string;
}
export default function TextEditor(props: TextEditorProps) {
let editorRef!: HTMLDivElement;
let bubbleMenuRef!: HTMLDivElement;
let containerRef!: HTMLDivElement;
const [showBubbleMenu, setShowBubbleMenu] = createSignal(false);
const [bubbleMenuPosition, setBubbleMenuPosition] = createSignal({
top: 0,
left: 0
});
const [showLanguageSelector, setShowLanguageSelector] = createSignal(false);
const [languageSelectorPosition, setLanguageSelectorPosition] = createSignal({
top: 0,
left: 0
});
const [showTableMenu, setShowTableMenu] = createSignal(false);
const [tableMenuPosition, setTableMenuPosition] = createSignal({
top: 0,
left: 0
});
const [showMermaidTemplates, setShowMermaidTemplates] = createSignal(false);
const [mermaidMenuPosition, setMermaidMenuPosition] = createSignal({
top: 0,
left: 0
});
const [showKeyboardHelp, setShowKeyboardHelp] = createSignal(false);
const [isFullscreen, setIsFullscreen] = createSignal(false);
const editor = createTiptapEditor(() => ({
element: editorRef,
extensions: [
StarterKit,
CodeBlockLowlight.configure({ lowlight }),
Link.configure({
openOnClick: true
}),
Image,
IframeEmbed,
TaskList,
TaskItem.configure({
nested: true,
HTMLAttributes: {
class: "task-item"
}
}),
Details.configure({
HTMLAttributes: {
class: "tiptap-details"
}
}),
DetailsSummary,
DetailsContent.configure({
HTMLAttributes: {
class: "details-content"
}
}),
Table.configure({
resizable: true,
HTMLAttributes: {
class: "tiptap-table"
}
}),
TableRow.configure({
HTMLAttributes: {
class: "tiptap-table-row"
}
}),
TableHeader.configure({
HTMLAttributes: {
class: "tiptap-table-header"
}
}),
TableCell.configure({
HTMLAttributes: {
class: "tiptap-table-cell"
}
}),
Mermaid,
TextAlign.configure({
types: ["heading", "paragraph"],
alignments: ["left", "center", "right", "justify"],
defaultAlignment: "left"
})
],
content: props.preSet || `<p><em><b>Hello!</b> World</em></p>`,
editorProps: {
attributes: {
class: "focus:outline-none"
},
handleClickOn(view, pos, node, nodePos, event) {
const target = event.target as HTMLElement;
// Check if click is on a summary element inside details
const summary = target.closest("summary");
if (summary) {
const details = summary.closest('[data-type="details"]');
if (details) {
// Toggle the open attribute
const isOpen = details.hasAttribute("open");
if (isOpen) {
details.removeAttribute("open");
} else {
details.setAttribute("open", "");
}
// Also toggle hidden attribute on details content
const content = details.querySelector(
'[data-type="detailsContent"]'
);
if (content) {
if (isOpen) {
content.setAttribute("hidden", "hidden");
} else {
content.removeAttribute("hidden");
}
}
return true; // Prevent default behavior
}
}
return false;
}
},
onUpdate: ({ editor }) => {
untrack(() => {
props.updateContent(editor.getHTML());
});
},
onSelectionUpdate: ({ editor }) => {
const { from, to } = editor.state.selection;
const hasSelection = from !== to;
if (hasSelection && !editor.state.selection.empty) {
setShowBubbleMenu(true);
// Position the bubble menu
const { view } = editor;
const start = view.coordsAtPos(from);
const end = view.coordsAtPos(to);
const left = Math.max((start.left + end.left) / 2, 0);
const top = Math.max(start.top - 10, 0);
setBubbleMenuPosition({ top, left });
} else {
setShowBubbleMenu(false);
}
}
}));
// Update editor content when preSet changes (e.g., when data loads), but only if editor exists and content is different
createEffect(
on(
() => props.preSet,
(newContent) => {
const instance = editor();
if (instance && newContent && instance.getHTML() !== newContent) {
instance.commands.setContent(newContent, false); // false = don't emit update event
}
},
{ defer: true }
)
);
const setLink = () => {
const instance = editor();
if (!instance) return;
const previousUrl = instance.getAttributes("link").href;
const url = window.prompt("URL", previousUrl);
if (url === null) return;
if (url === "") {
instance.chain().focus().extendMarkRange("link").unsetLink().run();
return;
}
instance
.chain()
.focus()
.extendMarkRange("link")
.setLink({ href: url })
.run();
};
const addIframe = () => {
const instance = editor();
if (!instance) return;
const url = window.prompt("URL");
if (url) {
instance.commands.setIframe({ src: url });
}
};
const addImage = () => {
const instance = editor();
if (!instance) return;
const url = window.prompt("URL");
if (url) {
instance.chain().focus().setImage({ src: url }).run();
}
};
const insertCollapsibleSection = () => {
const instance = editor();
if (!instance) return;
const title = window.prompt("Section title:", "Click to expand");
if (title !== null && title.trim() !== "") {
const content = {
type: "details",
attrs: { open: true },
content: [
{
type: "detailsSummary",
content: [{ type: "text", text: title }]
},
{
type: "detailsContent",
content: [
{
type: "paragraph"
}
]
}
]
};
const { from } = instance.state.selection;
instance.chain().focus().insertContent(content).run();
// Move cursor to the paragraph inside detailsContent
// Structure: details (from+1) > detailsSummary > detailsContent > paragraph
// We need to position inside the paragraph which is roughly from + title.length + 3 nodes deep
setTimeout(() => {
const { state } = instance;
let targetPos = from;
// Navigate through the document to find the paragraph inside detailsContent
state.doc.nodesBetween(from, from + 200, (node, pos) => {
if (node.type.name === "detailsContent") {
// Position cursor at the start of the first child (paragraph)
targetPos = pos + 1;
return false; // Stop iteration
}
});
if (targetPos > from) {
instance.commands.setTextSelection(targetPos);
instance.commands.focus();
}
}, 10);
}
};
const insertCodeBlock = (language: string | null) => {
const instance = editor();
if (!instance) return;
instance.chain().focus().toggleCodeBlock().run();
// If language specified, update the node attributes
if (language) {
instance.chain().updateAttributes("codeBlock", { language }).run();
}
setShowLanguageSelector(false);
};
const showLanguagePicker = (e: MouseEvent) => {
const buttonRect = (e.currentTarget as HTMLElement).getBoundingClientRect();
setLanguageSelectorPosition({
top: buttonRect.bottom + 5,
left: buttonRect.left
});
setShowLanguageSelector(!showLanguageSelector());
};
const insertTable = (rows: number, cols: number) => {
const instance = editor();
if (!instance) return;
instance
.chain()
.focus()
.insertTable({ rows, cols, withHeaderRow: true })
.run();
setShowTableMenu(false);
};
const showTableInserter = (e: MouseEvent) => {
const buttonRect = (e.currentTarget as HTMLElement).getBoundingClientRect();
setTableMenuPosition({
top: buttonRect.bottom + 5,
left: buttonRect.left
});
setShowTableMenu(!showTableMenu());
};
const deleteTableWithConfirmation = () => {
const instance = editor();
if (!instance) return;
const confirmed = window.confirm(
"Are you sure you want to delete this table?"
);
if (!confirmed) return;
instance.chain().focus().deleteTable().run();
};
const deleteRowWithConfirmation = () => {
const instance = editor();
if (!instance) return;
const { state } = instance;
const { selection } = state;
// Find the row node
let rowNode = null;
let depth = 0;
for (let d = selection.$anchor.depth; d > 0; d--) {
const node = selection.$anchor.node(d);
if (node.type.name === "tableRow") {
rowNode = node;
depth = d;
break;
}
}
if (rowNode) {
let hasContent = false;
rowNode.descendants((node) => {
if (node.textContent.trim().length > 0) {
hasContent = true;
return false;
}
});
if (hasContent) {
const confirmed = window.confirm(
"This row contains content. Are you sure you want to delete it?"
);
if (!confirmed) return;
}
}
instance.chain().focus().deleteRow().run();
};
const deleteColumnWithConfirmation = () => {
const instance = editor();
if (!instance) return;
const { state } = instance;
const { selection } = state;
// Get the current cell position
const cellPos = selection.$anchor;
// Find table and column index
let tableNode = null;
let tableDepth = 0;
for (let d = cellPos.depth; d > 0; d--) {
const node = cellPos.node(d);
if (node.type.name === "table") {
tableNode = node;
tableDepth = d;
break;
}
}
if (tableNode) {
// Find which column we're in
let colIndex = 0;
const cellNode = cellPos.node(cellPos.depth);
const rowNode = cellPos.node(cellPos.depth - 1);
rowNode.forEach((node, offset, index) => {
if (
cellPos.pos >= cellPos.start(cellPos.depth - 1) + offset &&
cellPos.pos <
cellPos.start(cellPos.depth - 1) + offset + node.nodeSize
) {
colIndex = index;
}
});
// Check if this column has content
let hasContent = false;
tableNode.descendants((node, pos, parent) => {
if (parent && parent.type.name === "tableRow") {
let currentCol = 0;
parent.forEach((cell, offset, index) => {
if (index === colIndex && cell.textContent.trim().length > 0) {
hasContent = true;
return false;
}
});
}
});
if (hasContent) {
const confirmed = window.confirm(
"This column contains content. Are you sure you want to delete it?"
);
if (!confirmed) return;
}
}
instance.chain().focus().deleteColumn().run();
};
// Close language selector on outside click
createEffect(() => {
if (showLanguageSelector()) {
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (
!target.closest(".language-selector") &&
!target.closest("[data-language-picker-trigger]")
) {
setShowLanguageSelector(false);
}
};
setTimeout(() => {
document.addEventListener("click", handleClickOutside);
}, 0);
return () => document.removeEventListener("click", handleClickOutside);
}
});
// Close table menu on outside click
createEffect(() => {
if (showTableMenu()) {
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (
!target.closest(".table-menu") &&
!target.closest("[data-table-trigger]")
) {
setShowTableMenu(false);
}
};
setTimeout(() => {
document.addEventListener("click", handleClickOutside);
}, 0);
return () => document.removeEventListener("click", handleClickOutside);
}
});
// Close mermaid menu on outside click
createEffect(() => {
if (showMermaidTemplates()) {
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (
!target.closest(".mermaid-menu") &&
!target.closest("[data-mermaid-trigger]")
) {
setShowMermaidTemplates(false);
}
};
setTimeout(() => {
document.addEventListener("click", handleClickOutside);
}, 0);
return () => document.removeEventListener("click", handleClickOutside);
}
});
const showMermaidSelector = (e: MouseEvent) => {
const buttonRect = (e.currentTarget as HTMLElement).getBoundingClientRect();
setMermaidMenuPosition({
top: buttonRect.bottom + 5,
left: buttonRect.left
});
setShowMermaidTemplates(!showMermaidTemplates());
};
const insertMermaidDiagram = (template: (typeof MERMAID_TEMPLATES)[0]) => {
const instance = editor();
if (!instance) return;
instance.chain().focus().setMermaid(template.code).run();
setShowMermaidTemplates(false);
};
// Toggle fullscreen mode
const toggleFullscreen = () => {
setIsFullscreen(!isFullscreen());
};
// ESC key to exit fullscreen
createEffect(() => {
if (isFullscreen()) {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setIsFullscreen(false);
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}
});
// Table Grid Selector Component
const TableGridSelector = () => {
const [hoverCell, setHoverCell] = createSignal({ row: 0, col: 0 });
const maxRows = 10;
const maxCols = 10;
return (
<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>
);
};
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":
isFullscreen(),
"bg-base": isFullscreen()
}}
>
<Show when={editor()}>
{(instance) => (
<>
{/* Bubble Menu - appears when text is selected */}
<Show when={showBubbleMenu()}>
<div
ref={bubbleMenuRef}
class="bg-mantle text-text fixed z-50 w-fit rounded p-2 text-sm whitespace-nowrap shadow-lg"
style={{
top: `${bubbleMenuPosition().top}px`,
left: `${bubbleMenuPosition().left}px`,
transform: "translate(-50%, -100%)",
"margin-top": "-8px"
}}
>
<div class="flex flex-wrap gap-1">
<button
type="button"
onClick={() =>
instance()
.chain()
.focus()
.toggleHeading({ level: 1 })
.run()
}
class={`${
instance().isActive("heading", { level: 1 })
? "bg-surface2"
: "hover:bg-surface1"
} rounded px-2 py-1`}
>
H1
</button>
<button
type="button"
onClick={() =>
instance()
.chain()
.focus()
.toggleHeading({ level: 2 })
.run()
}
class={`${
instance().isActive("heading", { level: 2 })
? "bg-surface2"
: "hover:bg-surface1"
} rounded px-2 py-1`}
>
H2
</button>
<button
type="button"
onClick={() =>
instance()
.chain()
.focus()
.toggleHeading({ level: 3 })
.run()
}
class={`${
instance().isActive("heading", { level: 3 })
? "bg-surface2"
: "hover:bg-surface1"
} rounded px-2 py-1`}
>
H3
</button>
<button
type="button"
onClick={() =>
instance().chain().focus().toggleBold().run()
}
class={`${
instance().isActive("bold")
? "bg-crust"
: "hover:bg-crust"
} bg-opacity-30 hover:bg-opacity-30 rounded px-2 py-1`}
>
<strong>B</strong>
</button>
<button
type="button"
onClick={() =>
instance().chain().focus().toggleItalic().run()
}
class={`${
instance().isActive("italic")
? "bg-crust"
: "hover:bg-crust"
} bg-opacity-30 hover:bg-opacity-30 rounded px-2 py-1`}
>
<em>I</em>
</button>
<button
type="button"
onClick={() =>
instance().chain().focus().toggleStrike().run()
}
class={`${
instance().isActive("strike")
? "bg-crust"
: "hover:bg-crust"
} bg-opacity-30 hover:bg-opacity-30 rounded px-2 py-1`}
>
<s>S</s>
</button>
<button
type="button"
onClick={setLink}
class={`${
instance().isActive("link")
? "bg-crust"
: "hover:bg-crust"
} bg-opacity-30 hover:bg-opacity-30 rounded px-2 py-1`}
>
Link
</button>
<button
type="button"
onClick={() =>
instance().chain().focus().toggleCode().run()
}
class={`${
instance().isActive("code")
? "bg-crust"
: "hover:bg-crust"
} bg-opacity-30 hover:bg-opacity-30 rounded px-2 py-1`}
>
Code
</button>
<button
type="button"
onClick={() =>
instance().chain().focus().toggleTaskList().run()
}
class={`${
instance().isActive("taskList")
? "bg-crust"
: "hover:bg-crust"
} bg-opacity-30 hover:bg-opacity-30 rounded px-2 py-1`}
title="Task List"
>
☑
</button>
{/* Table controls in bubble menu */}
<Show when={instance().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 rounded px-2 py-1"
title="Add Row Before"
>
↑ Row
</button>
<button
type="button"
onClick={() =>
instance().chain().focus().addRowAfter().run()
}
class="hover:bg-crust hover:bg-opacity-30 rounded px-2 py-1"
title="Add Row After"
>
Row ↓
</button>
<button
type="button"
onClick={deleteRowWithConfirmation}
class="hover:bg-red hover:bg-opacity-30 rounded px-2 py-1"
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 rounded px-2 py-1"
title="Add Column Before"
>
← Col
</button>
<button
type="button"
onClick={() =>
instance().chain().focus().addColumnAfter().run()
}
class="hover:bg-crust hover:bg-opacity-30 rounded px-2 py-1"
title="Add Column After"
>
Col →
</button>
<button
type="button"
onClick={deleteColumnWithConfirmation}
class="hover:bg-red hover:bg-opacity-30 rounded px-2 py-1"
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 rounded px-2 py-1"
title="Merge Cells"
>
⊡
</button>
<button
type="button"
onClick={() =>
instance().chain().focus().splitCell().run()
}
class="hover:bg-crust hover:bg-opacity-30 rounded px-2 py-1"
title="Split Cell"
>
⊞
</button>
<button
type="button"
onClick={deleteTableWithConfirmation}
class="hover:bg-red hover:bg-opacity-30 rounded px-2 py-1"
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-50 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-50"
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-50 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>
<div class="border-surface2 mb-2 flex flex-wrap gap-1 border-b pb-2">
<button
type="button"
onClick={() =>
instance().chain().focus().toggleHeading({ level: 1 }).run()
}
class={`${
instance().isActive("heading", { level: 1 })
? "bg-surface2"
: "hover:bg-surface1"
} rounded px-2 py-1 text-xs`}
title="Heading 1"
>
H1
</button>
<button
type="button"
onClick={() =>
instance().chain().focus().toggleHeading({ level: 2 }).run()
}
class={`${
instance().isActive("heading", { level: 2 })
? "bg-surface2"
: "hover:bg-surface1"
} rounded px-2 py-1 text-xs`}
title="Heading 2"
>
H2
</button>
<button
type="button"
onClick={() =>
instance().chain().focus().toggleHeading({ level: 3 }).run()
}
class={`${
instance().isActive("heading", { level: 3 })
? "bg-surface2"
: "hover:bg-surface1"
} rounded px-2 py-1 text-xs`}
title="Heading 3"
>
H3
</button>
<div class="border-surface2 mx-1 border-l"></div>
<button
type="button"
onClick={() => instance().chain().focus().toggleBold().run()}
class={`${
instance().isActive("bold")
? "bg-surface2"
: "hover:bg-surface1"
} rounded px-2 py-1 text-xs`}
title="Bold"
>
<strong>B</strong>
</button>
<button
type="button"
onClick={() => instance().chain().focus().toggleItalic().run()}
class={`${
instance().isActive("italic")
? "bg-surface2"
: "hover:bg-surface1"
} rounded px-2 py-1 text-xs`}
title="Italic"
>
<em>I</em>
</button>
<button
type="button"
onClick={() => instance().chain().focus().toggleStrike().run()}
class={`${
instance().isActive("strike")
? "bg-surface2"
: "hover:bg-surface1"
} rounded px-2 py-1 text-xs`}
title="Strikethrough"
>
<s>S</s>
</button>
<div class="border-surface2 mx-1 border-l"></div>
<button
type="button"
onClick={() =>
instance().chain().focus().toggleBulletList().run()
}
class={`${
instance().isActive("bulletList")
? "bg-surface2"
: "hover:bg-surface1"
} rounded px-2 py-1 text-xs`}
title="Bullet List"
>
• List
</button>
<button
type="button"
onClick={() =>
instance().chain().focus().toggleOrderedList().run()
}
class={`${
instance().isActive("orderedList")
? "bg-surface2"
: "hover:bg-surface1"
} rounded px-2 py-1 text-xs`}
title="Ordered List"
>
1. List
</button>
<button
type="button"
onClick={() =>
instance().chain().focus().toggleTaskList().run()
}
class={`${
instance().isActive("taskList")
? "bg-surface2"
: "hover:bg-surface1"
} rounded px-2 py-1 text-xs`}
title="Task List"
>
☑ Tasks
</button>
<button
type="button"
onClick={() =>
instance().chain().focus().toggleBlockquote().run()
}
class={`${
instance().isActive("blockquote")
? "bg-surface2"
: "hover:bg-surface1"
} rounded px-2 py-1 text-xs`}
title="Blockquote"
>
" Quote
</button>
<button
type="button"
onClick={insertCollapsibleSection}
class="hover:bg-surface1 rounded px-2 py-1 text-xs"
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()
}
class={`${
instance().isActive({ textAlign: "left" })
? "bg-surface2"
: "hover:bg-surface1"
} rounded px-2 py-1 text-xs`}
title="Align Left"
>
⬅
</button>
<button
type="button"
onClick={() =>
instance().chain().focus().setTextAlign("center").run()
}
class={`${
instance().isActive({ textAlign: "center" })
? "bg-surface2"
: "hover:bg-surface1"
} rounded px-2 py-1 text-xs`}
title="Align Center"
>
↔
</button>
<button
type="button"
onClick={() =>
instance().chain().focus().setTextAlign("right").run()
}
class={`${
instance().isActive({ textAlign: "right" })
? "bg-surface2"
: "hover:bg-surface1"
} rounded px-2 py-1 text-xs`}
title="Align Right"
>
➡
</button>
<button
type="button"
onClick={() =>
instance().chain().focus().setTextAlign("justify").run()
}
class={`${
instance().isActive({ textAlign: "justify" })
? "bg-surface2"
: "hover:bg-surface1"
} rounded px-2 py-1 text-xs`}
title="Justify"
>
⬌
</button>
<div class="border-surface2 mx-1 border-l"></div>
<button
type="button"
onClick={showLanguagePicker}
data-language-picker-trigger
class={`${
instance().isActive("codeBlock")
? "bg-surface2"
: "hover:bg-surface1"
} rounded px-2 py-1 text-xs`}
title="Code Block"
>
{"</>"}
</button>
<button
type="button"
onClick={setLink}
class={`${
instance().isActive("link")
? "bg-surface2"
: "hover:bg-surface1"
} rounded px-2 py-1 text-xs`}
title="Add Link"
>
🔗 Link
</button>
<button
type="button"
onClick={addImage}
class="hover:bg-surface1 rounded px-2 py-1 text-xs"
title="Add Image"
>
🖼 Image
</button>
<button
type="button"
onClick={addIframe}
class="hover:bg-surface1 rounded px-2 py-1 text-xs"
title="Add Iframe"
>
📺 Iframe
</button>
<button
type="button"
onClick={showTableInserter}
data-table-trigger
class={`${
instance().isActive("table")
? "bg-surface2"
: "hover:bg-surface1"
} rounded px-2 py-1 text-xs`}
title="Insert Table"
>
⊞ Table
</button>
<button
type="button"
onClick={showMermaidSelector}
data-mermaid-trigger
class="hover:bg-surface1 rounded px-2 py-1 text-xs"
title="Insert Diagram"
>
📊 Diagram
</button>
<div class="border-surface2 mx-1 border-l"></div>
<button
type="button"
onClick={() =>
instance().chain().focus().setHorizontalRule().run()
}
class="bg-surface0 hover:bg-surface1 rounded px-3 py-1 text-xs"
title="Horizontal Rule"
>
━━ HR
</button>
<div class="border-surface2 mx-1 border-l"></div>
<button
type="button"
onClick={toggleFullscreen}
class="hover:bg-surface1 rounded px-2 py-1 text-xs"
title={
isFullscreen() ? "Exit Fullscreen (ESC)" : "Enter Fullscreen"
}
>
{isFullscreen() ? "⇲ Exit" : "⇱ Fullscreen"}
</button>
<button
type="button"
onClick={() => setShowKeyboardHelp(!showKeyboardHelp())}
class="hover:bg-surface1 rounded px-2 py-1 text-xs"
title="Keyboard Shortcuts"
>
⌨ Help
</button>
{/* Table controls - shown when cursor is in a table */}
<Show when={instance().isActive("table")}>
<div class="border-surface2 mx-1 border-l"></div>
<button
type="button"
onClick={() =>
instance().chain().focus().addColumnBefore().run()
}
class="hover:bg-surface1 rounded px-2 py-1 text-xs"
title="Add Column Before"
>
← Col
</button>
<button
type="button"
onClick={() =>
instance().chain().focus().addColumnAfter().run()
}
class="hover:bg-surface1 rounded px-2 py-1 text-xs"
title="Add Column After"
>
Col →
</button>
<button
type="button"
onClick={deleteColumnWithConfirmation}
class="hover:bg-red bg-opacity-20 rounded px-2 py-1 text-xs"
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 rounded px-2 py-1 text-xs"
title="Add Row Before"
>
↑ Row
</button>
<button
type="button"
onClick={() => instance().chain().focus().addRowAfter().run()}
class="hover:bg-surface1 rounded px-2 py-1 text-xs"
title="Add Row After"
>
Row ↓
</button>
<button
type="button"
onClick={deleteRowWithConfirmation}
class="hover:bg-red bg-opacity-20 rounded px-2 py-1 text-xs"
title="Delete Row"
>
✕ Row
</button>
<div class="border-surface2 mx-1 border-l"></div>
<button
type="button"
onClick={deleteTableWithConfirmation}
class="hover:bg-red rounded px-2 py-1 text-xs"
title="Delete Table"
>
✕ Table
</button>
<button
type="button"
onClick={() =>
instance().chain().focus().toggleHeaderRow().run()
}
class={`${
instance().isActive("tableHeader")
? "bg-surface2"
: "hover:bg-surface1"
} rounded px-2 py-1 text-xs`}
title="Toggle Header Row"
>
≡ Header
</button>
<button
type="button"
onClick={() => instance().chain().focus().mergeCells().run()}
class="hover:bg-surface1 rounded px-2 py-1 text-xs"
title="Merge Cells"
>
⊡ Merge
</button>
<button
type="button"
onClick={() => instance().chain().focus().splitCell().run()}
class="hover:bg-surface1 rounded px-2 py-1 text-xs"
title="Split Cell"
>
⊞ Split
</button>
</Show>
</div>
</>
)}
</Show>
<div
ref={editorRef}
class="prose prose-sm prose-invert sm:prose-base md:prose-xl lg:prose-xl xl:prose-2xl mx-auto max-w-full overflow-scroll focus:outline-none"
classList={{
"h-[80dvh]": !isFullscreen(),
"h-[calc(100dvh-8rem)]": isFullscreen()
}}
/>
{/* Keyboard Help Modal */}
<Show when={showKeyboardHelp()}>
<div
class="bg-opacity-50 fixed inset-0 z-[100] flex items-center justify-center bg-black"
onClick={() => setShowKeyboardHelp(false)}
>
<div
class="bg-base border-surface2 max-h-[80vh] 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>
</div>
);
}