fix mermaid hydration
This commit is contained in:
@@ -1,4 +1,12 @@
|
|||||||
import { Show, untrack, createEffect, on, createSignal, For } from "solid-js";
|
import {
|
||||||
|
Show,
|
||||||
|
untrack,
|
||||||
|
createEffect,
|
||||||
|
on,
|
||||||
|
createSignal,
|
||||||
|
For,
|
||||||
|
onMount
|
||||||
|
} from "solid-js";
|
||||||
import { useSearchParams, useNavigate } from "@solidjs/router";
|
import { useSearchParams, useNavigate } from "@solidjs/router";
|
||||||
import { api } from "~/lib/api";
|
import { api } from "~/lib/api";
|
||||||
import { createTiptapEditor } from "solid-tiptap";
|
import { createTiptapEditor } from "solid-tiptap";
|
||||||
@@ -732,6 +740,13 @@ export default function TextEditor(props: TextEditorProps) {
|
|||||||
|
|
||||||
const [showKeyboardHelp, setShowKeyboardHelp] = createSignal(false);
|
const [showKeyboardHelp, setShowKeyboardHelp] = createSignal(false);
|
||||||
|
|
||||||
|
// Mermaid editor modal state
|
||||||
|
const [showMermaidEditor, setShowMermaidEditor] = createSignal(false);
|
||||||
|
const [mermaidEditorContent, setMermaidEditorContent] = createSignal("");
|
||||||
|
const [mermaidEditorPos, setMermaidEditorPos] = createSignal<number | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
// References section heading customization
|
// References section heading customization
|
||||||
const [referencesHeading, setReferencesHeading] = createSignal(
|
const [referencesHeading, setReferencesHeading] = createSignal(
|
||||||
typeof window !== "undefined"
|
typeof window !== "undefined"
|
||||||
@@ -764,14 +779,20 @@ export default function TextEditor(props: TextEditorProps) {
|
|||||||
inline: false
|
inline: false
|
||||||
});
|
});
|
||||||
|
|
||||||
// Search params and navigation for fullscreen persistence
|
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
// Initialize fullscreen from URL search param
|
|
||||||
const [isFullscreen, setIsFullscreen] = createSignal(
|
const [isFullscreen, setIsFullscreen] = createSignal(
|
||||||
searchParams.fullscreen === "true"
|
searchParams.fullscreen === "true"
|
||||||
);
|
);
|
||||||
|
onMount(() => {
|
||||||
|
if (isFullscreen()) {
|
||||||
|
const navigationElement = document.getElementById("navigation");
|
||||||
|
if (navigationElement) {
|
||||||
|
navigationElement.classList.add("hidden");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
const [keyboardVisible, setKeyboardVisible] = createSignal(false);
|
const [keyboardVisible, setKeyboardVisible] = createSignal(false);
|
||||||
const [keyboardHeight, setKeyboardHeight] = createSignal(0);
|
const [keyboardHeight, setKeyboardHeight] = createSignal(0);
|
||||||
|
|
||||||
@@ -977,6 +998,39 @@ export default function TextEditor(props: TextEditorProps) {
|
|||||||
setCurrentSuggestion("");
|
setCurrentSuggestion("");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Mermaid editor helpers
|
||||||
|
const saveMermaidEdit = () => {
|
||||||
|
const instance = editor();
|
||||||
|
const pos = mermaidEditorPos();
|
||||||
|
const content = mermaidEditorContent();
|
||||||
|
|
||||||
|
if (!instance || pos === null) return;
|
||||||
|
|
||||||
|
// Update the node at the stored position
|
||||||
|
const tr = instance.state.tr;
|
||||||
|
const node = instance.state.doc.nodeAt(pos);
|
||||||
|
|
||||||
|
if (node && node.type.name === "mermaid") {
|
||||||
|
tr.setNodeMarkup(pos, undefined, {
|
||||||
|
...node.attrs,
|
||||||
|
content
|
||||||
|
});
|
||||||
|
instance.view.dispatch(tr);
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowMermaidEditor(false);
|
||||||
|
setMermaidEditorContent("");
|
||||||
|
setMermaidEditorPos(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const insertMermaidFromTemplate = (templateCode: string) => {
|
||||||
|
const instance = editor();
|
||||||
|
if (!instance) return;
|
||||||
|
|
||||||
|
instance.commands.setMermaid(templateCode);
|
||||||
|
setShowMermaidTemplates(false);
|
||||||
|
};
|
||||||
|
|
||||||
// Capture history snapshot
|
// Capture history snapshot
|
||||||
const captureHistory = async (editorInstance: any) => {
|
const captureHistory = async (editorInstance: any) => {
|
||||||
// Skip if initial load
|
// Skip if initial load
|
||||||
@@ -1445,6 +1499,13 @@ export default function TextEditor(props: TextEditorProps) {
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
isInitialLoad = false;
|
isInitialLoad = false;
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
|
// Listen for mermaid edit events
|
||||||
|
editor.view.dom.addEventListener("edit-mermaid", ((e: CustomEvent) => {
|
||||||
|
setMermaidEditorContent(e.detail.content);
|
||||||
|
setMermaidEditorPos(e.detail.pos);
|
||||||
|
setShowMermaidEditor(true);
|
||||||
|
}) as EventListener);
|
||||||
},
|
},
|
||||||
editorProps: {
|
editorProps: {
|
||||||
attributes: {
|
attributes: {
|
||||||
@@ -1676,7 +1737,10 @@ export default function TextEditor(props: TextEditorProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Migrate legacy superscript references to Reference marks
|
// Migrate legacy superscript references to Reference marks
|
||||||
setTimeout(() => migrateLegacyReferences(instance), 50);
|
setTimeout(() => {
|
||||||
|
migrateLegacyReferences(instance);
|
||||||
|
migrateLegacyMermaidBlocks(instance);
|
||||||
|
}, 50);
|
||||||
|
|
||||||
// Capture initial state in history only if no history was loaded
|
// Capture initial state in history only if no history was loaded
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -1724,6 +1788,73 @@ export default function TextEditor(props: TextEditorProps) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const migrateLegacyMermaidBlocks = (editorInstance: any) => {
|
||||||
|
if (!editorInstance) return;
|
||||||
|
|
||||||
|
const doc = editorInstance.state.doc;
|
||||||
|
const blocksToMigrate: Array<{ pos: number; content: string }> = [];
|
||||||
|
|
||||||
|
// Mermaid diagram keywords to detect
|
||||||
|
const mermaidKeywords = [
|
||||||
|
"graph ",
|
||||||
|
"sequenceDiagram",
|
||||||
|
"classDiagram",
|
||||||
|
"stateDiagram",
|
||||||
|
"erDiagram",
|
||||||
|
"gantt",
|
||||||
|
"pie ",
|
||||||
|
"journey",
|
||||||
|
"gitGraph",
|
||||||
|
"flowchart ",
|
||||||
|
"mindmap",
|
||||||
|
"timeline",
|
||||||
|
"quadrantChart",
|
||||||
|
"requirementDiagram",
|
||||||
|
"C4Context"
|
||||||
|
];
|
||||||
|
|
||||||
|
// Find code blocks that look like mermaid
|
||||||
|
doc.descendants((node: any, pos: number) => {
|
||||||
|
if (node.type.name === "codeBlock") {
|
||||||
|
const content = node.textContent || "";
|
||||||
|
const trimmedContent = content.trim();
|
||||||
|
|
||||||
|
// Check if this looks like a mermaid diagram
|
||||||
|
const isMermaid = mermaidKeywords.some((keyword) =>
|
||||||
|
trimmedContent.startsWith(keyword)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isMermaid) {
|
||||||
|
blocksToMigrate.push({ pos, content: trimmedContent });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (blocksToMigrate.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migrate from end to start to avoid position shifts
|
||||||
|
blocksToMigrate.sort((a, b) => b.pos - a.pos);
|
||||||
|
|
||||||
|
const tr = editorInstance.state.tr;
|
||||||
|
|
||||||
|
blocksToMigrate.forEach(({ pos, content }) => {
|
||||||
|
const node = editorInstance.state.doc.nodeAt(pos);
|
||||||
|
if (node) {
|
||||||
|
// Create new mermaid node
|
||||||
|
const mermaidNode = editorInstance.schema.nodes.mermaid.create({
|
||||||
|
content
|
||||||
|
});
|
||||||
|
|
||||||
|
// Replace the code block with mermaid node
|
||||||
|
tr.replaceWith(pos, pos + node.nodeSize, mermaidNode);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
editorInstance.view.dispatch(tr);
|
||||||
|
};
|
||||||
|
|
||||||
const migrateLegacyReferences = (editorInstance: any) => {
|
const migrateLegacyReferences = (editorInstance: any) => {
|
||||||
if (!editorInstance) return;
|
if (!editorInstance) return;
|
||||||
|
|
||||||
@@ -4183,6 +4314,78 @@ export default function TextEditor(props: TextEditorProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
{/* Mermaid Editor Modal */}
|
||||||
|
<Show when={showMermaidEditor()}>
|
||||||
|
<div
|
||||||
|
class="bg-opacity-50 fixed inset-0 z-150 flex items-center justify-center bg-black"
|
||||||
|
onClick={() => setShowMermaidEditor(false)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="bg-base border-surface2 max-h-[80dvh] w-full max-w-3xl overflow-y-auto rounded-lg border p-6 shadow-2xl"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div class="mb-4 flex items-center justify-between">
|
||||||
|
<h2 class="text-text text-2xl font-bold">Edit Mermaid Diagram</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowMermaidEditor(false)}
|
||||||
|
class="hover:bg-surface1 text-subtext0 rounded p-2 text-xl"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Editor */}
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="text-text mb-2 block text-sm font-semibold">
|
||||||
|
Diagram Code
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
class="bg-surface0 border-surface2 text-text focus:border-blue w-full rounded border p-3 font-mono text-sm focus:outline-none"
|
||||||
|
rows={15}
|
||||||
|
value={mermaidEditorContent()}
|
||||||
|
onInput={(e) =>
|
||||||
|
setMermaidEditorContent(e.currentTarget.value)
|
||||||
|
}
|
||||||
|
placeholder="Enter your mermaid diagram code..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview Info */}
|
||||||
|
<div class="text-subtext0 border-yellow bg-yellow/10 rounded-lg border p-3 text-sm">
|
||||||
|
<strong>Note:</strong> The diagram will render when you view the
|
||||||
|
post. Make sure your syntax is correct to avoid rendering
|
||||||
|
errors.
|
||||||
|
<br />
|
||||||
|
<strong>Common issue:</strong> Use ASCII hyphens for arrows (
|
||||||
|
<code>--></code> not <code>—></code>). Smart punctuation
|
||||||
|
can break diagrams.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Buttons */}
|
||||||
|
<div class="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowMermaidEditor(false)}
|
||||||
|
class="hover:bg-surface1 border-surface2 rounded border px-4 py-2 text-sm transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={saveMermaidEdit}
|
||||||
|
class="bg-blue rounded px-4 py-2 text-sm text-white transition-all hover:brightness-110 active:scale-95"
|
||||||
|
>
|
||||||
|
Save Changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,25 @@
|
|||||||
import { Node, mergeAttributes } from "@tiptap/core";
|
import { Node, mergeAttributes } from "@tiptap/core";
|
||||||
|
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||||
|
|
||||||
export const Mermaid = Node.create({
|
export const Mermaid = Node.create({
|
||||||
name: "mermaid",
|
name: "mermaid",
|
||||||
group: "block",
|
group: "block",
|
||||||
content: "text*",
|
atom: true,
|
||||||
marks: "",
|
selectable: true,
|
||||||
code: true,
|
draggable: true,
|
||||||
|
|
||||||
addAttributes() {
|
addAttributes() {
|
||||||
return {
|
return {
|
||||||
|
content: {
|
||||||
|
default: "",
|
||||||
|
parseHTML: (element) => {
|
||||||
|
const code = element.querySelector("code");
|
||||||
|
return code?.textContent || "";
|
||||||
|
},
|
||||||
|
renderHTML: (attributes) => {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
},
|
||||||
language: {
|
language: {
|
||||||
default: "mermaid",
|
default: "mermaid",
|
||||||
parseHTML: (element) => element.getAttribute("data-language"),
|
parseHTML: (element) => element.getAttribute("data-language"),
|
||||||
@@ -25,18 +36,78 @@ export const Mermaid = Node.create({
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
tag: 'pre[data-type="mermaid"]'
|
tag: 'pre[data-type="mermaid"]'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: "div.mermaid-node-wrapper",
|
||||||
|
getAttrs: (element) => {
|
||||||
|
if (typeof element === "string") return false;
|
||||||
|
const pre = element.querySelector('pre[data-type="mermaid"]');
|
||||||
|
if (!pre) return false;
|
||||||
|
const code = pre.querySelector("code");
|
||||||
|
return {
|
||||||
|
content: code?.textContent || ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Detect regular code blocks that contain mermaid syntax
|
||||||
|
{
|
||||||
|
tag: "pre",
|
||||||
|
getAttrs: (element) => {
|
||||||
|
if (typeof element === "string") return false;
|
||||||
|
|
||||||
|
// Skip if already has data-type attribute
|
||||||
|
if (element.hasAttribute("data-type")) return false;
|
||||||
|
|
||||||
|
const code = element.querySelector("code");
|
||||||
|
if (!code) return false;
|
||||||
|
|
||||||
|
const content = code.textContent || "";
|
||||||
|
const trimmedContent = content.trim();
|
||||||
|
|
||||||
|
// Check if this looks like a mermaid diagram
|
||||||
|
const mermaidKeywords = [
|
||||||
|
"graph ",
|
||||||
|
"sequenceDiagram",
|
||||||
|
"classDiagram",
|
||||||
|
"stateDiagram",
|
||||||
|
"erDiagram",
|
||||||
|
"gantt",
|
||||||
|
"pie ",
|
||||||
|
"journey",
|
||||||
|
"gitGraph",
|
||||||
|
"flowchart ",
|
||||||
|
"mindmap",
|
||||||
|
"timeline",
|
||||||
|
"quadrantChart",
|
||||||
|
"requirementDiagram",
|
||||||
|
"C4Context"
|
||||||
|
];
|
||||||
|
|
||||||
|
const isMermaid = mermaidKeywords.some((keyword) =>
|
||||||
|
trimmedContent.startsWith(keyword)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isMermaid) {
|
||||||
|
return {
|
||||||
|
content: trimmedContent
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
priority: 51 // Higher priority than code block extension
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|
||||||
renderHTML({ HTMLAttributes }) {
|
renderHTML({ node, HTMLAttributes }) {
|
||||||
return [
|
return [
|
||||||
"pre",
|
"pre",
|
||||||
mergeAttributes(HTMLAttributes, {
|
mergeAttributes(HTMLAttributes, {
|
||||||
"data-type": "mermaid",
|
"data-type": "mermaid",
|
||||||
class: "mermaid-diagram"
|
class: "mermaid-diagram"
|
||||||
}),
|
}),
|
||||||
["code", 0]
|
["code", {}, node.attrs.content || ""]
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -47,9 +118,75 @@ export const Mermaid = Node.create({
|
|||||||
({ commands }) => {
|
({ commands }) => {
|
||||||
return commands.insertContent({
|
return commands.insertContent({
|
||||||
type: this.name,
|
type: this.name,
|
||||||
content: [{ type: "text", text: content }]
|
attrs: { content }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
updateMermaid:
|
||||||
|
(content: string) =>
|
||||||
|
({ tr, state, dispatch }) => {
|
||||||
|
const { selection } = state;
|
||||||
|
const node = selection.$anchor.parent;
|
||||||
|
|
||||||
|
if (node.type.name === this.name) {
|
||||||
|
if (dispatch) {
|
||||||
|
tr.setNodeMarkup(selection.$anchor.before(), undefined, {
|
||||||
|
...node.attrs,
|
||||||
|
content
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
addNodeView() {
|
||||||
|
return ({ node, getPos, editor }) => {
|
||||||
|
const dom = document.createElement("div");
|
||||||
|
dom.className = "mermaid-node-wrapper relative group";
|
||||||
|
|
||||||
|
const pre = document.createElement("pre");
|
||||||
|
pre.setAttribute("data-type", "mermaid");
|
||||||
|
pre.className = "mermaid-diagram";
|
||||||
|
|
||||||
|
const code = document.createElement("code");
|
||||||
|
code.textContent = node.attrs.content || "";
|
||||||
|
pre.appendChild(code);
|
||||||
|
|
||||||
|
// Edit button overlay
|
||||||
|
const editBtn = document.createElement("button");
|
||||||
|
editBtn.className =
|
||||||
|
"absolute top-2 right-2 bg-blue text-white px-3 py-1 rounded text-sm opacity-0 group-hover:opacity-100 transition-opacity duration-200 z-10";
|
||||||
|
editBtn.textContent = "Edit Diagram";
|
||||||
|
editBtn.contentEditable = "false";
|
||||||
|
|
||||||
|
editBtn.addEventListener("click", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
// Emit custom event to open modal
|
||||||
|
const pos = typeof getPos === "function" ? getPos() : 0;
|
||||||
|
const event = new CustomEvent("edit-mermaid", {
|
||||||
|
detail: { content: node.attrs.content, pos }
|
||||||
|
});
|
||||||
|
editor.view.dom.dispatchEvent(event);
|
||||||
|
});
|
||||||
|
|
||||||
|
dom.appendChild(pre);
|
||||||
|
dom.appendChild(editBtn);
|
||||||
|
|
||||||
|
return {
|
||||||
|
dom,
|
||||||
|
contentDOM: undefined,
|
||||||
|
update: (updatedNode) => {
|
||||||
|
if (updatedNode.type.name !== this.name) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
code.textContent = updatedNode.attrs.content || "";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -58,6 +195,7 @@ declare module "@tiptap/core" {
|
|||||||
interface Commands<ReturnType> {
|
interface Commands<ReturnType> {
|
||||||
mermaid: {
|
mermaid: {
|
||||||
setMermaid: (content: string) => ReturnType;
|
setMermaid: (content: string) => ReturnType;
|
||||||
|
updateMermaid: (content: string) => ReturnType;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user