/** Reader-local comment chrome overlays rather than mutates collaborative prose. */
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { MessageIcon } from "@chopin/icons";
import { useCellValue } from "@mdxeditor/gurx";
import { $getNodeByKey } from "lexical";
import { DraftCard, ThreadCard, ThreadList } from "./comments";
import { blockMarkerPoints, edgePanelPoint, markerRect, popoverPoint } from "./comment-geometry";
import { containsHit, passageHits } from "./comment-hits";
import { CommentSheet, usesCommentSheet } from "./comment-sheet";
import { useCommentSheetReveal } from "./comment-sheet-reveal";
import { $rangeOf } from "./marks";
import { blockElement } from "./scroll";
import { COARSE_POINTER_QUERY, PRIMARY_COARSE_POINTER_QUERY } from "./pointer";
import { useThreads } from "./threads";
import { useTransitionPresence } from "./transition-presence";
import { widgets$ } from "./widget-options";
import type { CSSProperties, ReactNode } from "react";
import type { BlockMarkerPoint, Point, Rect } from "./comment-geometry";
import type { PassageHit } from "./comment-hits";
import type { ThreadStore, ThreadView } from "./threads";
/** The visible chip; a coarse pointer gets a larger, invisible hit area around it. */
const CHIP = 24;
const TOUCH_TARGET = 44;
type PlacedThread = {
view: ThreadView;
/** The block marker that stands for this thread. */
marker: string;
hits: PassageHit[];
passages: Rect[];
};
/** One marker per commented block, standing for every open thread on it. */
type PlacedMarker = {
key: string;
views: ThreadView[];
excerpt: string;
button: BlockMarkerPoint;
};
type MeasuredThread = {
view: ThreadView;
passages: Rect[];
hits: Rect[];
};
type MeasuredBlock = {
key: string;
block: Rect;
line: { top: number; height: number };
excerpt: string;
threads: MeasuredThread[];
};
/** A pinned list of one block's threads, as opposed to a pinned thread id. */
const LIST = "list:";
function dialogId(pinned: string): string {
return pinned.startsWith(LIST)
? `plan-comment-list-${pinned.slice(LIST.length)}`
: `plan-comment-thread-${pinned}`;
}
function excerptOf(text: string): string {
let flat = text.replace(/\s+/g, " ").trim();
return flat.length > 48 ? `${flat.slice(0, 47).trimEnd()}…` : flat;
}
/** The first line box of a block, from its own type metrics. */
function firstLine(element: HTMLElement, box: Rect): { top: number; height: number } {
let style = getComputedStyle(element);
let height = parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.2 || CHIP;
let inset = (parseFloat(style.paddingTop) || 0) + (parseFloat(style.borderTopWidth) || 0);
return { top: box.top + inset, height: Math.min(height, box.height - inset || height) };
}
function chipWidth(count: number): number {
return count > 1 ? 30 + 7 * String(count).length : CHIP;
}
type PassagePress = { id: string; left: number; pointer: number; top: number; moved: boolean };
function rect(value: DOMRect): Rect {
return {
top: value.top,
right: value.right,
bottom: value.bottom,
left: value.left,
width: value.width,
height: value.height,
};
}
type PreviewRequest = {
button: Point;
id: string;
page: Rect;
size: number;
view: ThreadView;
width: number;
};
type PreviewValue = {
id: string;
style: CSSProperties;
view: ThreadView;
};
type PreviewMeasurement = {
height: number;
id: string;
noteCount: number;
quote: string;
width: number;
};
function previewHeight(
request: PreviewRequest | undefined,
measurement: PreviewMeasurement | undefined,
): number | undefined {
return request
&& measurement?.id === request.id
&& measurement.width === request.width
&& measurement.quote === request.view.quote
&& measurement.noteCount === request.view.thread.notes.length
? measurement.height
: undefined;
}
function placedPreview(
request: PreviewRequest | undefined,
measurement: PreviewMeasurement | undefined,
): PreviewValue | undefined {
let height = previewHeight(request, measurement);
if (!request || height === undefined) return undefined;
let point = popoverPoint(
markerRect(request.button, request.page, request.size),
request.page,
request.width,
height,
);
let originX = point.left >= request.button.left ? 0 : request.width;
let originY = Math.min(
height,
Math.max(0, request.button.top + request.size / 2 - point.top),
);
return {
id: request.id,
style: {
...point,
transformOrigin: `${originX}px ${originY}px`,
width: request.width,
},
view: request.view,
};
}
function PreviewContent({ view }: { view: ThreadView }) {
let replies = Math.max(0, view.thread.notes.length - 1);
let opening = view.thread.notes[0];
return (
<>
{opening && (
<>
@{opening.handle}
{opening.text}
>
)}
{replies > 0 && {replies} {replies === 1 ? "reply" : "replies"}}
>
);
}
function PreviewSurface(
{
immediately,
onMeasure,
request,
value,
}: {
immediately: boolean;
onMeasure: (request: PreviewRequest, height: number) => void;
request?: PreviewRequest;
value?: PreviewValue;
},
) {
let measurementElement = useRef(null);
let lifecycle = useCommentPresence(value, 150, immediately);
useLayoutEffect(() => {
let element = measurementElement.current;
if (request && element) onMeasure(request, element.offsetHeight);
}, [onMeasure, request]);
let measuring = !!request && value === undefined;
if (!measuring && lifecycle.presence.phase === "closed") return null;
let preview = lifecycle.presence.phase === "closed" ? undefined : lifecycle.presence.value;
return (
<>
{measuring && request && (
)}
{preview && (
)}
>
);
}
type CommentSurfaceValue = {
ariaLabel: string;
children: ReactNode;
className: string;
id?: string;
onMeasure?: (element: HTMLDivElement | null) => void;
onMouseEnter?: () => void;
onMouseLeave?: () => void;
style?: CSSProperties;
};
function useCommentPresence(value: T | undefined, duration: number, immediately: boolean) {
let presence = useTransitionPresence(value, duration, immediately);
let active = presence.phase !== "closed" && presence.phase !== "closing";
return {
ariaHidden: active ? undefined : "true" as const,
inert: !active,
presence,
};
}
function CommentSurface(
{
compact,
immediately,
value,
}: {
compact: boolean;
immediately: boolean;
value?: CommentSurfaceValue;
},
) {
let lifecycle = useCommentPresence(value, compact ? 180 : 150, immediately);
if (lifecycle.presence.phase === "closed") return null;
let surface = lifecycle.presence.value;
return (
{surface.children}
);
}
function replyState(view: ThreadView): string {
return repliesWaiting(Math.max(0, view.thread.notes.length - 1));
}
function repliesWaiting(replies: number): string {
return replies === 0
? "No replies waiting."
: `${replies} ${replies === 1 ? "reply" : "replies"} waiting.`;
}
export function CommentLayer({ store }: { store: ThreadStore }) {
let [editor] = useLexicalComposerContext();
let state = useThreads(store);
let [host, setHost] = useState();
let [placed, setPlaced] = useState([]);
let [markers, setMarkers] = useState([]);
let markersRef = useRef([]);
let [preview, setPreview] = useState();
let [previewMeasurement, setPreviewMeasurement] = useState();
let [pinned, setPinned] = useState();
let pinnedRef = useRef(undefined);
pinnedRef.current = pinned;
let [coarse, setCoarse] = useState(false);
let [primaryCoarse, setPrimaryCoarse] = useState(false);
let [cardHeights, setCardHeights] = useState<{ [id: string]: number }>({});
let root = useRef(null);
let placedRef = useRef([]);
let hoverOwner = useRef(undefined);
let press = useRef(undefined);
let close = useRef | undefined>(undefined);
let failures = useRef(new Set());
let origin = useRef(undefined);
let draftOpen = useRef(false);
let selected = useRef(undefined);
let options = useCellValue(widgets$);
let canEdit = options.canEdit !== false;
let compact = options.commentPresentation === "sheet"
&& usesCommentSheet({ coarse: primaryCoarse, width: host?.clientWidth ?? Infinity });
let previousCompact = useRef(compact);
let immediately = options.motionImmediately?.() ?? false;
let draft = canEdit ? state.draft : undefined;
let measurePreview = useCallback((request: PreviewRequest, height: number) => {
if (height <= 0) {
setPreviewMeasurement(undefined);
if (preview === request.view.thread.id) {
setPreview(undefined);
store.focus(undefined);
}
return;
}
let next = {
height,
id: request.id,
noteCount: request.view.thread.notes.length,
quote: request.view.quote,
width: request.width,
};
setPreviewMeasurement(current => previewHeight(request, current) === height ? current : next);
}, [preview, store]);
useEffect(() => {
return editor.registerRootListener(element => {
setHost(element?.closest(".plan-document") ?? undefined);
});
}, [editor]);
useEffect(() => {
let query = matchMedia(COARSE_POINTER_QUERY);
let primary = matchMedia(PRIMARY_COARSE_POINTER_QUERY);
let update = () => {
setCoarse(query.matches);
setPrimaryCoarse(primary.matches);
};
update();
query.addEventListener("change", update);
primary.addEventListener("change", update);
return () => {
query.removeEventListener("change", update);
primary.removeEventListener("change", update);
};
}, []);
useLayoutEffect(() => {
let previous = previousCompact.current;
previousCompact.current = compact;
if (!previous || compact || !pinned) return;
let dialog = document.getElementById(dialogId(pinned));
dialog?.querySelector("[data-plan-comment-close], button")?.focus();
}, [compact, pinned]);
let enter = useCallback((id: string) => {
clearTimeout(close.current);
setPreview(id);
store.focus(id);
}, [store]);
let leave = useCallback((id: string) => {
clearTimeout(close.current);
close.current = setTimeout(() => {
if (pinned !== id) setPreview(current => current === id ? undefined : current);
store.focus(undefined);
}, 100);
}, [pinned, store]);
let hover = useCallback((id: string) => {
if (hoverOwner.current === id) return;
if (hoverOwner.current) leave(hoverOwner.current);
hoverOwner.current = id;
enter(id);
}, [enter, leave]);
let unhover = useCallback((id: string) => {
if (hoverOwner.current !== id) return;
hoverOwner.current = undefined;
leave(id);
}, [leave]);
let measure = () => {
if (!host) return;
let page = rect(host.getBoundingClientRect());
let blocks = new Map();
for (let view of state.threads) {
if (view.thread.status !== "open") continue;
try {
let target = editor.getEditorState().read(() => {
let exact = view.places[0] && $rangeOf(editor, view.places[0]);
let anchor = view.places[0]?.anchorKey ?? view.targetKey;
let element = anchor ? blockElement(editor, anchor) : undefined;
let key = element
? $getNodeByKey(anchor!)?.getTopLevelElement()?.getKey() ?? anchor!
: `thread:${view.thread.id}`;
if (exact) {
return {
bounds: exact.getBoundingClientRect(),
element,
hits: Array.from(exact.getClientRects(), rect),
key,
};
}
let fallback = view.targetKey ? blockElement(editor, view.targetKey) : undefined;
return fallback
? { bounds: fallback.getBoundingClientRect(), element: fallback, hits: [], key }
: undefined;
});
if (!target || (target.bounds.width === 0 && target.bounds.height === 0)) continue;
let targetRect = rect(target.bounds);
let passages = target.hits.length > 0 ? target.hits : [targetRect];
let block = blocks.get(target.key!);
if (!block) {
let box = target.element ? rect(target.element.getBoundingClientRect()) : targetRect;
block = {
key: target.key!,
block: box,
line: target.element
? firstLine(target.element, box)
: { top: box.top, height: Math.min(box.height, CHIP) },
excerpt: excerptOf(target.element?.textContent ?? view.quote),
threads: [],
};
blocks.set(block.key, block);
}
block.threads.push({ view, passages, hits: target.hits });
} catch (error) {
// A bad anchor must not break Lexical's update listener.
if (failures.current.has(view.thread.id)) continue;
failures.current.add(view.thread.id);
console.error(`[plan] could not measure comment ${view.thread.id}:`, error);
}
}
let measured = [...blocks.values()].sort((a, b) => a.block.top - b.block.top);
let focused = document.activeElement?.getAttribute("data-plan-comment-button");
let points = blockMarkerPoints(
measured.map(({ block, key, line, threads }) => ({
block,
line,
width: chipWidth(threads.length),
held: focused === key
|| pinnedRef.current === `${LIST}${key}`
|| threads.some(({ view }) => view.thread.id === pinnedRef.current),
})),
page,
{ size: CHIP },
);
let nextMarkers = measured.map((entry, index) => ({
key: entry.key,
views: entry.threads.map(({ view }) => view),
excerpt: entry.excerpt,
button: points[index]!,
}));
let next = measured.flatMap(entry =>
entry.threads.map(thread => ({
view: thread.view,
marker: entry.key,
hits: passageHits(page, thread.hits),
passages: thread.passages,
}))
);
placedRef.current = next;
markersRef.current = nextMarkers;
setPlaced(next);
setMarkers(nextMarkers);
};
useLayoutEffect(() => {
measure();
}, [host, state.threads, coarse, pinned]);
useEffect(() => {
if (!host) return;
let update = () => measure();
let off = editor.registerUpdateListener(update);
host.addEventListener("scroll", update, true);
let observer = new ResizeObserver(update);
observer.observe(host);
return () => {
off();
host.removeEventListener("scroll", update, true);
observer.disconnect();
};
}, [editor, host, state.threads, coarse]);
useEffect(() => () => clearTimeout(close.current), []);
useEffect(() => {
if (!canEdit && state.draft) store.draft(undefined);
}, [canEdit, state.draft, store]);
useEffect(() => {
if (!host) return;
let over = (event: MouseEvent | PointerEvent): PlacedThread | undefined => {
let page = host.getBoundingClientRect();
let point = { top: event.clientY - page.top, left: event.clientX - page.left };
return placedRef.current.find(entry => containsHit(entry.hits, point));
};
let inProse = (target: EventTarget | null) =>
!!target && editor.getRootElement()?.contains(target as Node);
let down = (event: PointerEvent) => {
press.current = undefined;
if (
event.button !== 0 || root.current?.contains(event.target as Node) || !inProse(event.target)
) return;
let entry = over(event);
if (!entry) return;
press.current = {
id: entry.view.thread.id,
left: event.clientX,
pointer: event.pointerId,
top: event.clientY,
moved: false,
};
};
let move = (event: PointerEvent) => {
let pending = press.current;
if (
pending?.pointer === event.pointerId
&& Math.hypot(event.clientX - pending.left, event.clientY - pending.top) > 3
) pending.moved = true;
// Keep native prose selection outside the controls.
if (root.current?.contains(event.target as Node)) return;
let next = over(event)?.view.thread.id;
if (next) hover(next);
else if (hoverOwner.current) unhover(hoverOwner.current);
};
let click = (event: MouseEvent) => {
let pending = press.current;
press.current = undefined;
if (
!pending
|| pending.moved
|| root.current?.contains(event.target as Node)
|| !inProse(event.target)
) return;
let entry = over(event);
let selection = getSelection();
if (entry?.view.thread.id !== pending.id || (selection && !selection.isCollapsed)) return;
origin.current = root.current?.querySelector(
`[data-plan-comment-button="${entry.marker}"]`,
) ?? undefined;
enter(pending.id);
setPinned(current => current === pending.id ? undefined : pending.id);
};
let out = () => {
if (hoverOwner.current) unhover(hoverOwner.current);
};
let cancel = () => {
press.current = undefined;
};
host.addEventListener("pointerdown", down);
host.addEventListener("pointermove", move);
host.addEventListener("click", click);
host.addEventListener("pointerleave", out);
host.addEventListener("pointercancel", cancel);
return () => {
host.removeEventListener("pointerdown", down);
host.removeEventListener("pointermove", move);
host.removeEventListener("click", click);
host.removeEventListener("pointerleave", out);
host.removeEventListener("pointercancel", cancel);
};
}, [editor, enter, host, hover, unhover]);
let restoreOrigin = useCallback(() => {
let target = origin.current;
requestAnimationFrame(() => {
let fallback = editor.getRootElement();
(target?.isConnected ? target : fallback)?.focus();
});
}, [editor]);
let dismiss = useCallback(() => {
setPinned(undefined);
setPreview(undefined);
restoreOrigin();
}, [restoreOrigin]);
let cancelDraft = useCallback(() => {
draftOpen.current = false;
store.draft(undefined);
restoreOrigin();
}, [restoreOrigin, store]);
useLayoutEffect(() => {
let open = !!draft;
if (open && !draftOpen.current) {
let active = document.activeElement;
origin.current = active instanceof HTMLElement
? active
: editor.getRootElement() ?? undefined;
} else if (!open && draftOpen.current) restoreOrigin();
draftOpen.current = open;
if (!pinned) return;
let available = pinned === "orphans"
? state.threads.some(view => view.thread.status === "open" && view.orphaned)
: pinned.startsWith(LIST)
? markersRef.current.some(marker => `${LIST}${marker.key}` === pinned)
: state.threads.some(view =>
view.thread.id === pinned && view.thread.status === "open" && !view.orphaned
);
if (available) return;
setPinned(undefined);
setPreview(undefined);
store.focus(undefined);
restoreOrigin();
}, [draft, editor, pinned, restoreOrigin, state.threads, store]);
// The list item that held focus is replaced by the thread it opened; focus its way back.
useLayoutEffect(() => {
let id = selected.current;
if (!id || id !== pinned) return;
selected.current = undefined;
document.querySelector(
`[data-plan-comment-thread="${id}"] [data-plan-comment-back]`,
)?.focus();
}, [pinned]);
let sheetId = compact && pinned !== "orphans" ? pinned : undefined;
let sheetPassages = useMemo(() => {
if (!sheetId) return undefined;
let list = sheetId.startsWith(LIST) ? sheetId.slice(LIST.length) : undefined;
let entries = placed.filter(entry =>
list ? entry.marker === list : entry.view.thread.id === sheetId
);
return entries.length > 0 ? entries.flatMap(entry => entry.passages) : undefined;
}, [placed, sheetId]);
let revealId = compact && draft?.placement ? "draft" : sheetId;
let revealPassages = useMemo(
() => compact && draft?.placement ? [draft.placement] : sheetPassages,
[compact, draft?.placement, sheetPassages],
);
useCommentSheetReveal({
host,
id: revealId,
passages: revealPassages,
});
useEffect(() => {
if (!pinned && !preview) return;
if (compact && pinned) return;
let outside = (event: PointerEvent) => {
let dialog = pinned ? document.getElementById(dialogId(pinned)) : undefined;
if (dialog?.contains(event.target as Node)) return;
if (pinned) dismiss();
else setPreview(undefined);
};
let escape = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
event.preventDefault();
if (pinned) dismiss();
else setPreview(undefined);
};
document.addEventListener("pointerdown", outside);
document.addEventListener("keydown", escape);
return () => {
document.removeEventListener("pointerdown", outside);
document.removeEventListener("keydown", escape);
};
}, [compact, dismiss, pinned, preview]);
if (!host) return null;
let orphaned = state.threads.filter(view => view.thread.status === "open" && view.orphaned);
let markerOf = (id: string) => {
let entry = placed.find(candidate => candidate.view.thread.id === id);
return entry ? markers.find(marker => marker.key === entry.marker) : undefined;
};
let selectThread = (id: string) => {
selected.current = id;
setPinned(id);
};
let card = (view: ThreadView, showClose = true) => {
let group = markerOf(view.thread.id);
let back = group && group.views.length > 1 ? group : undefined;
return (
store.accept(view.thread.id)}
onBack={back ? () => setPinned(`${LIST}${back.key}`) : undefined}
onBlur={() => unhover(view.thread.id)}
onClose={showClose ? dismiss : undefined}
onDismiss={() => store.dismiss(view.thread.id)}
onFocus={() => hover(view.thread.id)}
onReply={text => store.reply(view.thread.id, text)}
onRetry={() => store.retry(view.thread.id)}
onTyping={writing => store.announce(view.thread.id, writing)}
quote={view.quote}
showClose={showClose}
view={view}
writing={state.writing[view.thread.id]}
/>
);
};
let pinnedList = pinned?.startsWith(LIST)
? markers.find(marker => `${LIST}${marker.key}` === pinned)
: undefined;
let pinnedView = pinned && pinned !== "orphans" && !pinnedList
? state.threads.find(view => view.thread.id === pinned && view.thread.status === "open")
: undefined;
let pinnedMarker = pinnedList ?? (pinnedView ? markerOf(pinnedView.thread.id) : undefined);
let rememberHeight = (id: string, element: HTMLDivElement | null) => {
let height = element?.offsetHeight;
if (!height) return;
setCardHeights(current => current[id] === height ? current : { ...current, [id]: height });
};
let page = host.getBoundingClientRect();
let cardWidth = Math.min(320, host.clientWidth - 24);
let previewWidth = Math.min(288, host.clientWidth * 0.8);
let compactKey: string | undefined;
let compactId: string | undefined;
let compactLabel: string | undefined;
let compactClose: (() => void) | undefined;
let compactContent: ReactNode = undefined;
let previewView = preview && pinned !== preview
? placed.find(entry => entry.view.thread.id === preview)?.view
: undefined;
let previewMarker = previewView ? markerOf(previewView.thread.id) : undefined;
let previewRequest: PreviewRequest | undefined = previewView && previewMarker
&& !previewMarker.button.offscreen
? {
button: previewMarker.button,
id: `plan-comment-preview-${previewView.thread.id}`,
page: rect(page),
size: CHIP,
view: previewView,
width: previewWidth,
}
: undefined;
let previewValue = placedPreview(previewRequest, previewMeasurement);
let activePreviewId = previewValue?.id;
if (compact && draft?.placement) {
compactKey = "draft";
compactId = "plan-comment-draft";
compactLabel = "New comment";
compactClose = cancelDraft;
compactContent = (
store.start(text)}
showClose={false}
/>
);
} else if (compact && pinned === "orphans" && orphaned.length > 0) {
compactKey = "orphans";
compactId = "plan-comment-thread-orphans";
compactLabel = "Orphaned comments";
compactClose = dismiss;
compactContent = orphaned.map(view => card(view, false));
} else if (compact && pinned && (pinnedView || pinnedList)) {
// One sheet serves a block's list and its threads, so moving between them does not reopen it.
compactKey = pinnedMarker && pinnedMarker.views.length > 1
? `marker:${pinnedMarker.key}`
: `thread:${pinned}`;
compactId = dialogId(pinned);
compactLabel = pinnedView ? "Comment thread" : "Comments";
compactClose = dismiss;
compactContent = pinnedView ? card(pinnedView, false) : (
);
}
let pinnedCard: CommentSurfaceValue | undefined;
if (!compact && pinned && pinnedMarker && (pinnedView || pinnedList)) {
let id = dialogId(pinned);
let hoverId = pinnedView?.thread.id;
pinnedCard = {
ariaLabel: pinnedView ? "Comment thread" : "Comments",
children: pinnedView
? card(pinnedView)
: ,
className: "plan-comment-card",
id,
onMeasure: element => rememberHeight(id, element),
onMouseEnter: hoverId ? () => hover(hoverId) : undefined,
onMouseLeave: hoverId ? () => unhover(hoverId) : undefined,
style: edgePanelPoint(
markerRect(pinnedMarker.button, page, CHIP),
page,
cardWidth,
cardHeights[id] ?? 0,
),
};
}
// An open card keeps the reader on it: every other marker steps back.
let engaged = !!pinned || !!draft?.placement;
let documentChrome = createPortal(
{placed.flatMap(({ hits, view }) =>
hits.map((hit, index) => (
))
)}
{markers.map(marker => {
let { button, views } = marker;
let single = views.length === 1 ? views[0] : undefined;
let shown = !!pinned && marker === pinnedMarker;
let dimmed = engaged && !shown;
let replies = views.reduce(
(total, view) => total + Math.max(0, view.thread.notes.length - 1),
0,
);
let previewId = single ? `plan-comment-preview-${single.thread.id}` : undefined;
let width = coarse ? Math.max(TOUCH_TARGET, button.width) : button.width;
let height = coarse ? TOUCH_TARGET : CHIP;
// The touch area stays inside the document, so a chip in the padding keeps a full target.
let left = Math.max(
0,
Math.min(button.left - (width - button.width) / 2, page.width - width),
);
return (
);
})}
store.start(text)}
/>
),
className: "plan-comment-card",
onMeasure: element => rememberHeight("draft", element),
style: edgePanelPoint(
draft.placement,
page,
cardWidth,
cardHeights.draft ?? 0,
),
}
: undefined}
/>
{orphaned.length > 0 && (
card(view)),
className: "plan-comment-card plan-comment-orphan-card",
id: "plan-comment-thread-orphans",
}
: undefined}
/>
)}
,
host,
);
return (
<>
{documentChrome}
{compactKey && compactId && compactLabel && compactClose && (
{compactContent}
)}
>
);
}