a848003db2
Adds CommentHoverPreview, mounted in page-editor next to <EditorContent>: hovering a `.comment-mark[data-comment-id]` span shows a small floating card (createPortal, position:fixed, pointer-events:none so it never intercepts the mark's click) with the parent comment's plain text. Uses useCommentsQuery (shares the ["comments", pageId] cache with the side panel — no extra request). Skips unknown/not-yet-loaded, resolved (data-resolved attr or resolvedAt/resolvedById), and empty-text comments. A ~120ms open delay avoids flicker; hides on mouseout / mousedown / scroll(capture) / resize / page change. commentContentToText flattens the comment's ProseMirror doc (stringified or parsed) to plain text, preserving hardBreaks as newlines and never throwing. Main editor only (read-only / shares / history out of scope). closes #268 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
72 lines
2.0 KiB
TypeScript
72 lines
2.0 KiB
TypeScript
/**
|
|
* Flatten a comment's ProseMirror JSON document to plain text.
|
|
*
|
|
* `IComment.content` is stored as a stringified ProseMirror doc, but this also
|
|
* accepts an already-parsed object. Walks the node tree, concatenating `text`
|
|
* leaves and joining text-bearing blocks with newlines. Missing, empty or
|
|
* malformed content yields an empty string (never throws).
|
|
*/
|
|
export function commentContentToText(content: unknown): string {
|
|
let doc: any = content;
|
|
|
|
if (typeof content === "string") {
|
|
const trimmed = content.trim();
|
|
if (!trimmed) return "";
|
|
try {
|
|
doc = JSON.parse(trimmed);
|
|
} catch {
|
|
// Not JSON — fall back to treating the raw string as plain text.
|
|
return trimmed;
|
|
}
|
|
}
|
|
|
|
if (!doc || typeof doc !== "object") return "";
|
|
|
|
const blocks: string[] = [];
|
|
|
|
const walk = (node: any): void => {
|
|
if (!node || typeof node !== "object") return;
|
|
|
|
if (typeof node.text === "string") {
|
|
// Inline text leaf: append to the current block line.
|
|
if (blocks.length === 0) blocks.push("");
|
|
blocks[blocks.length - 1] += node.text;
|
|
return;
|
|
}
|
|
|
|
if (node.type === "hardBreak") {
|
|
// A soft line break inside a block: keep the newline so the two halves
|
|
// do not run together.
|
|
if (blocks.length === 0) blocks.push("");
|
|
blocks[blocks.length - 1] += "\n";
|
|
return;
|
|
}
|
|
|
|
const children = Array.isArray(node.content) ? node.content : [];
|
|
const containsText = children.some(
|
|
(child: any) =>
|
|
child && typeof child === "object" && typeof child.text === "string",
|
|
);
|
|
|
|
if (containsText) {
|
|
// Text-bearing block (paragraph, heading, ...): start a fresh line, then
|
|
// collect its inline text.
|
|
blocks.push("");
|
|
children.forEach(walk);
|
|
return;
|
|
}
|
|
|
|
// Structural container (doc, list, blockquote, ...): recurse so each nested
|
|
// text block becomes its own line.
|
|
children.forEach(walk);
|
|
};
|
|
|
|
walk(doc);
|
|
|
|
return blocks
|
|
.map((block) => block.trim())
|
|
.filter((block) => block.length > 0)
|
|
.join("\n")
|
|
.trim();
|
|
}
|