"use client";
import { Node, mergeAttributes } from "@tiptap/core";
import { ReactNodeViewRenderer, NodeViewWrapper, type ReactNodeViewProps } from "@tiptap/react";
import { useState, useCallback } from "react";
import type { ReportConfig } from "../../components/charts/types";
// Lazy-load ReportCard to keep bundle light
import dynamic from "next/dynamic";
const ReportCard = dynamic(
() =>
import("../../components/charts/report-card").then((m) => ({
default: m.ReportCard,
})),
{
ssr: false,
loading: () => (
),
},
);
// --- React NodeView Component ---
function ReportBlockView({
node,
updateAttributes,
deleteNode,
selected,
}: ReactNodeViewProps) {
const configAttr = node.attrs.config as string;
const [showSource, setShowSource] = useState(false);
const [editValue, setEditValue] = useState(configAttr);
let parsedConfig: ReportConfig | null = null;
let parseError: string | null = null;
try {
const parsed = JSON.parse(configAttr);
if (parsed?.panels && Array.isArray(parsed.panels)) {
parsedConfig = parsed as ReportConfig;
} else {
parseError = "Invalid report config: missing panels array";
}
} catch {
parseError = "Invalid JSON in report block";
}
const handleSaveSource = useCallback(() => {
try {
JSON.parse(editValue); // validate
updateAttributes({ config: editValue });
setShowSource(false);
} catch {
// Don't close if invalid JSON
}
}, [editValue, updateAttributes]);
return (
{/* Overlay toolbar */}
{showSource ? (
/* JSON source editor */
) : parseError ? (
/* Error state */
{parseError}
) : (
/* Rendered chart */
)}
);
}
// --- Tiptap Node Extension ---
export const ReportBlockNode = Node.create({
name: "reportBlock",
group: "block",
atom: true, // not editable inline -- managed by NodeView
addAttributes() {
return {
config: {
default: "{}",
parseHTML: (element: HTMLElement) =>
element.getAttribute("data-config") || "{}",
renderHTML: (attributes: Record) => ({
"data-config": attributes.config,
}),
},
};
},
parseHTML() {
return [
{
tag: 'div[data-type="report-block"]',
},
];
},
renderHTML({ HTMLAttributes }) {
return [
"div",
mergeAttributes(HTMLAttributes, { "data-type": "report-block" }),
];
},
addNodeView() {
return ReactNodeViewRenderer(ReportBlockView);
},
});
/**
* Pre-process markdown before Tiptap parses it:
* Convert ```report-json ... ``` fenced blocks into HTML that Tiptap can parse
* as ReportBlock nodes.
*/
export function preprocessReportBlocks(markdown: string): string {
return markdown.replace(
/```report-json\s*\n([\s\S]*?)```/g,
(_match, json: string) => {
const escaped = json
.trim()
.replace(/&/g, "&")
.replace(/"/g, """)
.replace(//g, ">");
return ``;
},
);
}
/**
* Post-process HTML before serializing to markdown:
* Convert ReportBlock HTML back to ```report-json``` fenced blocks.
*/
export function postprocessReportBlocks(markdown: string): string {
return markdown.replace(
/\s*<\/div>/g,
(_match, escaped: string) => {
const json = escaped
.replace(/>/g, ">")
.replace(/</g, "<")
.replace(/"/g, '"')
.replace(/&/g, "&");
return "```report-json\n" + json + "\n```";
},
);
}