ref cleanup

This commit is contained in:
Michael Freno
2025-12-26 11:58:21 -05:00
parent 096fd2b07a
commit 426dce12d8
5 changed files with 316 additions and 146 deletions

View File

@@ -309,7 +309,7 @@ export function LeftBar() {
{/* Hamburger menu button - positioned at right edge of navbar */}
<button
onClick={() => setLeftBarVisible(!leftBarVisible())}
class="hamburger-menu-btn absolute top-4 -right-14 z-10 rounded-md p-2 shadow-md backdrop-blur-2xl transition-transform duration-600 ease-in-out hover:scale-110"
class="hamburger-menu-btn absolute top-4 -right-14 z-200 rounded-md p-2 shadow-md backdrop-blur-2xl transition-transform duration-600 ease-in-out hover:scale-110"
classList={{
hidden: leftBarVisible()
}}

View File

@@ -19,7 +19,6 @@ mermaid.initialize({
export default function MermaidRenderer() {
onMount(() => {
// Find all mermaid diagrams and render them
const mermaidPres = document.querySelectorAll('pre[data-type="mermaid"]');
mermaidPres.forEach(async (pre, index) => {

View File

@@ -99,8 +99,6 @@ export default function PostBodyClient(props: PostBodyClientProps) {
const processReferences = () => {
if (!contentRef) return;
const foundRefs = new Map<string, HTMLElement>();
const supElements = contentRef.querySelectorAll("sup");
supElements.forEach((sup) => {
@@ -278,6 +276,7 @@ export default function PostBodyClient(props: PostBodyClientProps) {
return (
<div class="mx-auto max-w-4xl px-4 pt-32 md:pt-40">
<div
id="post-content-body"
ref={contentRef}
class="text-text prose dark:prose-invert max-w-none"
innerHTML={props.body}

View File

@@ -349,6 +349,9 @@ declare module "@tiptap/core" {
setReference: (options: { refId: string; refNum: number }) => ReturnType;
updateReferenceNumber: (refId: string, newNum: number) => ReturnType;
};
referenceSectionMarker: {
setReferenceSectionMarker: (heading: string) => ReturnType;
};
}
}
@@ -422,47 +425,6 @@ const Reference = Mark.create({
];
},
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;
}
}
};
},
parseHTML() {
return [
{
tag: "sup[data-ref-id]"
}
];
},
renderHTML({ HTMLAttributes }) {
return [
"sup",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
0
];
},
addCommands() {
return {
setReference:
@@ -523,6 +485,65 @@ const Reference = Mark.create({
}
});
// 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; gap: 0.25rem; 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;
@@ -559,6 +580,20 @@ export default function TextEditor(props: TextEditorProps) {
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({
@@ -622,7 +657,10 @@ export default function TextEditor(props: TextEditorProps) {
const editor = createTiptapEditor(() => ({
element: editorRef,
extensions: [
StarterKit,
StarterKit.configure({
// Disable these since we're adding them separately with custom config
codeBlock: false
}),
CodeBlockLowlight.configure({ lowlight }),
Link.configure({
openOnClick: true
@@ -678,14 +716,14 @@ export default function TextEditor(props: TextEditorProps) {
}),
Superscript,
Subscript,
Reference
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(() => {
console.log("Running migration on initial load");
const doc = editor.state.doc;
let refCount = 0;
let legacyCount = 0;
@@ -697,9 +735,6 @@ export default function TextEditor(props: TextEditorProps) {
);
if (refMark) {
refCount++;
console.log(
`Found Reference mark: [${refMark.attrs.refNum}] with ID: ${refMark.attrs.refId}`
);
}
const superMark = node.marks.find(
(mark: any) => mark.type.name === "superscript"
@@ -708,16 +743,11 @@ export default function TextEditor(props: TextEditorProps) {
const match = node.text?.match(/^\[(\d+)\]$/);
if (match) {
legacyCount++;
console.log(`Found legacy superscript: ${node.text}`);
}
}
}
});
console.log(
`Total Reference marks: ${refCount}, Legacy superscript: ${legacyCount}`
);
if (legacyCount > 0) {
migrateLegacyReferences(editor);
}
@@ -850,11 +880,6 @@ export default function TextEditor(props: TextEditorProps) {
}
});
console.log(
"All superscript nodes:",
allSuperscriptNodes.map((n) => `"${n.text}" at ${n.pos}`)
);
// Second pass: identify complete references (might be split)
let i = 0;
while (i < allSuperscriptNodes.length) {
@@ -868,9 +893,6 @@ export default function TextEditor(props: TextEditorProps) {
(mark: any) =>
mark.type.name !== "superscript" && mark.type.name !== "reference"
);
console.log(
`Found complete legacy ref [${completeMatch[1]}] at pos ${node.pos}, hasOtherMarks: ${hasOtherMarks}, text: "${text}"`
);
legacyRefs.push({
pos: node.pos,
num: parseInt(completeMatch[1]),
@@ -892,10 +914,6 @@ export default function TextEditor(props: TextEditorProps) {
const totalLength =
text.length + nextNode.text.length + afterNode.text.length;
console.log(
`Found split reference [${refNum}] starting at pos ${node.pos} (split into "${text}", "${nextNode.text}", "${afterNode.text}")`
);
// We need to handle split references differently - remove all three nodes and create one
legacyRefs.push({
pos: node.pos,
@@ -913,31 +931,21 @@ export default function TextEditor(props: TextEditorProps) {
}
if (legacyRefs.length === 0) {
console.log("No legacy references found to migrate");
return;
}
console.log(
`Migrating ${legacyRefs.length} legacy references to Reference marks`
);
// Sort by position (process from end to start to avoid position shifts)
legacyRefs.sort((a, b) => b.pos - a.pos);
// Build a single transaction to convert all legacy refs
const tr = editorInstance.state.tr;
legacyRefs.forEach((ref) => {
// Generate unique ID for this reference
const refId = `ref-migrated-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
// Create Reference mark (only the reference mark, no other marks)
const newMark = editorInstance.schema.marks.reference.create({
refId: refId,
refNum: ref.num
});
// Replace with Reference mark (preserve only the reference mark, remove other marks like links)
tr.replaceWith(
ref.pos,
ref.pos + ref.textLength,
@@ -945,10 +953,7 @@ export default function TextEditor(props: TextEditorProps) {
);
});
// Dispatch the transaction
editorInstance.view.dispatch(tr);
console.log("Migration complete");
};
const renumberAllReferences = (editorInstance: any) => {
@@ -1050,34 +1055,49 @@ export default function TextEditor(props: TextEditorProps) {
});
if (foundRefs.size === 0) {
let hasReferencesSection = false;
// No references found - remove the entire section if it exists
let markerPos = -1;
let hrPos = -1;
let sectionStartPos = -1;
let sectionEndPos = -1;
doc.descendants((node: any, pos: number) => {
if (node.type.name === "heading" && node.textContent === "References") {
hasReferencesSection = true;
sectionStartPos = pos;
// Find marker first
if (node.type.name === "referenceSectionMarker") {
markerPos = pos;
}
// Find HR before marker
if (markerPos === -1 && node.type.name === "horizontalRule") {
hrPos = pos;
}
});
if (hasReferencesSection && sectionStartPos > 0) {
doc.nodesBetween(
Math.max(0, sectionStartPos - 50),
sectionStartPos,
(node: any, pos: number) => {
if (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;
if (hrPos >= 0) {
const tr = editorInstance.state.tr;
tr.delete(hrPos, doc.content.size);
editorInstance.view.dispatch(tr);
// 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;
}
@@ -1090,31 +1110,117 @@ export default function TextEditor(props: TextEditorProps) {
return a.localeCompare(b);
});
let markerPos = -1;
let markerHeading = "";
let referencesHeadingPos = -1;
let existingRefs = new Set<string>();
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 === "heading" && node.textContent === "References") {
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;
}
if (referencesHeadingPos >= 0 && node.type.name === "paragraph") {
const match = node.textContent.match(/^\[(.+?)\]/);
// 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) {
existingRefs.add(match[1]);
const isPlaceholder = text.includes("Add your reference text here");
existingRefs.set(match[1], { pos, isPlaceholder });
}
}
});
if (referencesHeadingPos === -1) {
// 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: "References" }]
content: [{ type: "text", text: referencesHeading() }]
}
];
// Add placeholder paragraphs for each reference
refNumbers.forEach((refNum) => {
content.push({
type: "paragraph",
@@ -1123,7 +1229,7 @@ export default function TextEditor(props: TextEditorProps) {
type: "text",
text: `[${refNum}] `,
marks: [{ type: "bold" }]
} as any,
},
{
type: "text",
text: "Add your reference text here"
@@ -1138,47 +1244,75 @@ export default function TextEditor(props: TextEditorProps) {
editorInstance.schema.nodeFromJSON({ type: "doc", content }).content
);
editorInstance.view.dispatch(tr);
} else {
const newRefs = refNumbers.filter((ref) => !existingRefs.has(ref));
return;
}
if (newRefs.length > 0) {
let insertPos = referencesHeadingPos;
doc.nodesBetween(
referencesHeadingPos,
doc.content.size,
(node: any, pos: number) => {
if (pos > insertPos) {
insertPos = pos + node.nodeSize;
}
}
);
// Section exists - manage placeholders
const tr = editorInstance.state.tr;
let hasChanges = false;
const content: any[] = [];
newRefs.forEach((refNum) => {
content.push({
// 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
if (referencesHeadingPos >= 0) {
// Find insertion point (after heading, before any content or at section end)
let insertPos = referencesHeadingPos;
const headingNode = doc.nodeAt(referencesHeadingPos);
if (headingNode) {
insertPos = referencesHeadingPos + headingNode.nodeSize;
}
// Add missing references in order
const nodesToInsert: any[] = [];
refNumbers.forEach((refNum) => {
if (!existingRefs.has(refNum)) {
nodesToInsert.push({
type: "paragraph",
content: [
{
type: "text",
text: `[${refNum}] `,
marks: [{ type: "bold" }]
} as any,
},
{
type: "text",
text: "Add your reference text here"
}
]
});
});
}
});
const tr = editorInstance.state.tr;
content.forEach((item) => {
tr.insert(insertPos, editorInstance.schema.nodeFromJSON(item));
insertPos += editorInstance.schema.nodeFromJSON(item).nodeSize;
if (nodesToInsert.length > 0) {
nodesToInsert.forEach((nodeData) => {
const node = editorInstance.schema.nodeFromJSON(nodeData);
tr.insert(insertPos, node);
insertPos += node.nodeSize;
});
editorInstance.view.dispatch(tr);
hasChanges = true;
}
}
if (hasChanges) {
editorInstance.view.dispatch(tr);
}
};
const setLink = () => {
@@ -1627,11 +1761,14 @@ export default function TextEditor(props: TextEditorProps) {
}
};
setTimeout(() => {
const timeoutId = setTimeout(() => {
document.addEventListener("click", handleClickOutside);
}, 0);
return () => document.removeEventListener("click", handleClickOutside);
return () => {
clearTimeout(timeoutId);
document.removeEventListener("click", handleClickOutside);
};
}
});
@@ -1647,11 +1784,14 @@ export default function TextEditor(props: TextEditorProps) {
}
};
setTimeout(() => {
const timeoutId = setTimeout(() => {
document.addEventListener("click", handleClickOutside);
}, 0);
return () => document.removeEventListener("click", handleClickOutside);
return () => {
clearTimeout(timeoutId);
document.removeEventListener("click", handleClickOutside);
};
}
});
@@ -1667,11 +1807,14 @@ export default function TextEditor(props: TextEditorProps) {
}
};
setTimeout(() => {
const timeoutId = setTimeout(() => {
document.addEventListener("click", handleClickOutside);
}, 0);
return () => document.removeEventListener("click", handleClickOutside);
return () => {
clearTimeout(timeoutId);
document.removeEventListener("click", handleClickOutside);
};
}
});
@@ -1687,11 +1830,14 @@ export default function TextEditor(props: TextEditorProps) {
}
};
setTimeout(() => {
const timeoutId = setTimeout(() => {
document.addEventListener("click", handleClickOutside);
}, 0);
return () => document.removeEventListener("click", handleClickOutside);
return () => {
clearTimeout(timeoutId);
document.removeEventListener("click", handleClickOutside);
};
}
});
@@ -2111,7 +2257,7 @@ export default function TextEditor(props: TextEditorProps) {
<Show when={showBubbleMenu()}>
<div
ref={bubbleMenuRef}
class="bg-crust text-text fixed z-110 w-fit rounded p-2 text-sm whitespace-nowrap shadow-xl"
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`,
@@ -2345,7 +2491,7 @@ export default function TextEditor(props: TextEditorProps) {
{/* Language Selector Dropdown */}
<Show when={showLanguageSelector()}>
<div
class="language-selector bg-mantle text-text border-surface2 fixed z-110 max-h-64 w-48 overflow-y-auto rounded border shadow-lg"
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`
@@ -2368,7 +2514,7 @@ export default function TextEditor(props: TextEditorProps) {
{/* Table Grid Selector */}
<Show when={showTableMenu()}>
<div
class="table-menu fixed z-110"
class="table-menu fixed z-[120]"
style={{
top: `${tableMenuPosition().top}px`,
left: `${tableMenuPosition().left}px`
@@ -2381,7 +2527,7 @@ export default function TextEditor(props: TextEditorProps) {
{/* Mermaid Template Selector */}
<Show when={showMermaidTemplates()}>
<div
class="mermaid-menu bg-mantle text-text border-surface2 fixed z-110 max-h-96 w-56 overflow-y-auto rounded border shadow-lg"
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`
@@ -2409,7 +2555,7 @@ export default function TextEditor(props: TextEditorProps) {
{/* Conditional Configurator */}
<Show when={showConditionalConfig()}>
<div
class="conditional-config fixed z-110"
class="conditional-config fixed z-[120]"
style={{
top: `${conditionalConfigPosition().top}px`,
left: `${conditionalConfigPosition().left}px`
@@ -2533,6 +2679,27 @@ export default function TextEditor(props: TextEditorProps) {
>
[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>
<div class="border-surface2 mx-1 border-l"></div>
<button
type="button"
@@ -2896,7 +3063,7 @@ export default function TextEditor(props: TextEditorProps) {
<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 transition-all duration-300 focus:outline-none"
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()
@@ -2909,7 +3076,7 @@ export default function TextEditor(props: TextEditorProps) {
{/* Keyboard Help Modal */}
<Show when={showKeyboardHelp()}>
<div
class="bg-opacity-50 fixed inset-0 z-110 flex items-center justify-center bg-black"
class="bg-opacity-50 fixed inset-0 z-150 flex items-center justify-center bg-black"
onClick={() => setShowKeyboardHelp(false)}
>
<div