File size: 3,609 Bytes
561e6f0 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 | import { NodeViewWrapper } from "@tiptap/react";
import { useState, useEffect, useRef, useCallback } from "react";
import type { NodeViewProps } from "@tiptap/react";
export function FootnoteView({ node, editor, getPos, updateAttributes }: NodeViewProps) {
const content = node.attrs.content as string;
const [index, setIndex] = useState(0);
const [showTooltip, setShowTooltip] = useState(false);
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(content);
const tooltipTimer = useRef<ReturnType<typeof setTimeout>>();
const inputRef = useRef<HTMLTextAreaElement>(null);
// Auto-number: count all footnotes in document order
useEffect(() => {
const computeIndex = () => {
let i = 0;
const pos = getPos();
editor.state.doc.descendants((n, p) => {
if (n.type.name === "footnote") {
i++;
if (p === pos) setIndex(i);
}
});
};
computeIndex();
editor.on("update", computeIndex);
return () => { editor.off("update", computeIndex); };
}, [editor, getPos]);
useEffect(() => {
if (editing && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [editing]);
const handleMouseEnter = useCallback(() => {
if (!editing) tooltipTimer.current = setTimeout(() => setShowTooltip(true), 300);
}, [editing]);
const handleMouseLeave = useCallback(() => {
clearTimeout(tooltipTimer.current);
if (!editing) setShowTooltip(false);
}, [editing]);
const startEdit = useCallback(() => {
setDraft(content);
setEditing(true);
setShowTooltip(true);
}, [content]);
const commitEdit = useCallback(() => {
updateAttributes({ content: draft });
setEditing(false);
setShowTooltip(false);
}, [draft, updateAttributes]);
const remove = useCallback(() => {
const pos = getPos();
if (typeof pos === "number") {
editor.chain().focus().deleteRange({ from: pos, to: pos + 1 }).run();
}
}, [editor, getPos]);
return (
<NodeViewWrapper
as="span"
className="footnote-node"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onDoubleClick={startEdit}
>
<sup className="footnote-marker">{index}</sup>
{showTooltip && (
<div className="footnote-tooltip">
{editing ? (
<textarea
ref={inputRef}
className="footnote-tooltip-input"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); commitEdit(); }
if (e.key === "Escape") { setEditing(false); setShowTooltip(false); }
}}
rows={3}
/>
) : (
<div className="footnote-tooltip-content">{content || "Empty footnote. Double-click to edit."}</div>
)}
<div className="footnote-tooltip-actions">
{editing ? (
<button className="footnote-tooltip-btn" onMouseDown={(e) => { e.preventDefault(); commitEdit(); }}>Save</button>
) : (
<>
<button className="footnote-tooltip-btn" onMouseDown={(e) => { e.preventDefault(); startEdit(); }}>Edit</button>
<button className="footnote-tooltip-btn footnote-tooltip-remove" onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); remove(); }}>Remove</button>
</>
)}
</div>
</div>
)}
</NodeViewWrapper>
);
}
|