)}
>
);
}
type CommentSurfaceValue = {
ariaLabel: string;
children: ReactNode;
className: string;
id?: string;
side?: CardSide;
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 [returnTo, setReturnTo] = 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),
...(threads.length >= 10 ? { minimum: 18 } : {}),
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]!,
line: entry.line.height,
}));
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;
// A pointer over the selection toolbar is not over the prose beneath it.
if ((event.target as Element).closest?.("[role=toolbar]")) {
if (hoverOwner.current) unhover(hoverOwner.current);
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);
setReturnTo(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 {
hoverOwner.current = undefined;
setPreview(undefined);
}
};
let escape = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
event.preventDefault();
if (pinned) dismiss();
else {
hoverOwner.current = undefined;
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
? () => {
setReturnTo(view.thread.id);
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);
// The full content height, so a card capped to fit beside its passage keeps its true size.
let rememberHeight = (id: string, element: HTMLDivElement | null) => {
let height = element?.scrollHeight;
if (!height) return;
setCardHeights(current => current[id] === height ? current : { ...current, [id]: height });
};
let page = rect(host.getBoundingClientRect());
let editorRoot = editor.getRootElement();
let column = editorRoot ? proseColumn(editorRoot) : page;
let cardWidth = Math.min(320, host.clientWidth - 24);
let cardPlacement = (passage: Rect, id: string) => {
let { side, ...style } = commentCardPoint(
passage,
column,
page,
cardWidth,
cardHeights[id] ?? 0,
{ lane: MARKER_LANE },
);
return { side, style };
};
let previewWidth = Math.min(288, host.clientWidth * 0.8);
let compactKey: string | undefined;
let compactId: string | undefined;
let compactLabel: string | undefined;
let compactTitle: 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,
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";
compactTitle = "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";
compactTitle = "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";
let count = pinnedView?.thread.notes.length;
compactTitle = count ? count === 1 ? "Comment" : `${count} comments` : "Comments";
compactClose = dismiss;
compactContent = pinnedView ? card(pinnedView, false) : (
);
}
let pinnedCard: CommentSurfaceValue | undefined;
let pinnedPassage = pinnedMarker && union(
placed
.filter(entry =>
pinnedView
? entry.view.thread.id === pinnedView.thread.id
: entry.marker === pinnedMarker.key
)
.flatMap(entry => entry.passages),
);
if (!compact && pinned && pinnedMarker && pinnedPassage && (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,
...cardPlacement(pinnedPassage, id),
};
}
// 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;
// Never taller than the first line, so a tap at the end of the second line reaches the text.
let height = coarse ? Math.min(TOUCH_TARGET, Math.max(CHIP, marker.line)) : 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),
...cardPlacement(draft.placement, "draft"),
}
: undefined}
/>
{orphaned.length > 0 && (