/**
* A resolved decision, as a marker beside the prose it produced.
*
* Reader-local chrome, like the comment layer it is modelled on: it is drawn
* over the document from measurements and never put in it. Hovering the marker
* or the prose washes the passage and previews the decision; pressing either
* pins the same popover open, where the options not chosen and the close
* button appear. What each of those says and where it sits is `resolved.ts`.
*/
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useReducer,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { CheckIcon, ClockIcon, CloseIcon, DecisionIcon } from "@chopin/icons";
import { useCellValue } from "@mdxeditor/gurx";
import { when } from "./card";
import { containsHit, passageHits } from "./comment-hits";
import { Face } from "./face";
import { COARSE_POINTER_QUERY } from "./pointer";
import {
COMPACT_GUTTER,
keyOf,
MARKER_SIZE,
MARKER_TOUCH_SIZE,
markerPoint,
point,
popoverBelow,
shown,
unchosen,
} from "./resolved";
import { useQuestionnaires, useRelations } from "./questionnaires";
import { blockElement } from "./scroll";
import { useTransitionPresence } from "./transition-presence";
import { widgets$ } from "./widget-options";
import type { CSSProperties } from "react";
import type { Rect } from "./comment-geometry";
import type { MarkerPlace } from "./resolved";
import type { PassageHit } from "./comment-hits";
import type { QuestionnaireStore } from "./questionnaires";
/** One answered question that has prose to sit beside. */
type Decision = {
key: string;
widget: string;
question: string;
prompt: string;
answer: string;
/** Undefined when the answer cannot be read back against the options. */
others?: string[];
by?: string;
at?: string;
keys: string[];
};
type Placed = {
decision: Decision;
marker: MarkerPlace;
lineHeight: number;
anchor: Rect;
hits: PassageHit[];
};
const POPOVER_WIDTH = 336;
function rect(value: DOMRect): Rect {
return {
top: value.top,
right: value.right,
bottom: value.bottom,
left: value.left,
width: value.width,
height: value.height,
};
}
function lineHeightOf(element: HTMLElement): number {
let style = getComputedStyle(element);
let lineHeight = Number.parseFloat(style.lineHeight);
return Number.isFinite(lineHeight) ? lineHeight : Number.parseFloat(style.fontSize) * 1.6;
}
/**
* The marker's box. A disc is padded out to the hit size around its centre; a
* slim bar keeps its place in the gutter and only grows to a touch target
* toward the prose side, where the bar is drawn at its left edge.
*/
function markerStyle(
marker: MarkerPlace,
lineHeight: number,
hit: number,
inset: number,
): CSSProperties {
if (!marker.compact) return { top: marker.top - inset, left: marker.left - inset };
let coarse = hit > MARKER_SIZE;
let height = coarse ? Math.max(hit, lineHeight) : lineHeight;
return {
top: marker.top - (height - lineHeight) / 2,
left: coarse ? 0 : marker.left,
width: coarse ? hit : COMPACT_GUTTER,
height,
"--plan-decision-bar": `${coarse ? marker.left + 4 : 4}px`,
"--plan-decision-line": `${lineHeight}px`,
} as CSSProperties;
}
type PopoverValue = {
decision: Decision;
pinned: boolean;
style: CSSProperties;
};
function Popover(
{ close, value }: { close: () => void; value: PopoverValue },
) {
let { decision, pinned } = value;
let others = decision.others ?? [];
return (
<>
{decision.prompt}
{decision.answer}
{others.length > 0 && (
{others.map(label => (
{label}
))}
)}
{(decision.by || decision.at) && (
{decision.by && (
{decision.by}
)}
{decision.at && when(decision.at) && (
{when(decision.at)}
)}
)}
>
);
}
function Surface(
{ close, id, immediately, onMeasure, value }: {
close: () => void;
id: string;
immediately: boolean;
onMeasure: (height: number) => void;
value?: PopoverValue;
},
) {
let presence = useTransitionPresence(value, 150, immediately);
let element = useRef(null);
let present = presence.phase !== "closed";
useEffect(() => {
let node = element.current;
if (!node || !present) return;
let observe = new ResizeObserver(() => onMeasure(node.offsetHeight));
observe.observe(node);
onMeasure(node.offsetHeight);
return () => observe.disconnect();
}, [onMeasure, present]);
if (presence.phase === "closed") return null;
let active = presence.phase !== "closing";
let current = presence.value;
return (
);
}
export function ResolvedLayer({ store }: { store: QuestionnaireStore }) {
let [editor] = useLexicalComposerContext();
let options = useCellValue(widgets$);
let entries = useQuestionnaires(store);
let relations = useRelations(store);
let [host, setHost] = useState();
let [placed, setPlaced] = useState([]);
let [pointer, act] = useReducer(point, {});
let [coarse, setCoarse] = useState(false);
let [height, setHeight] = useState(0);
let root = useRef(null);
let placedRef = useRef([]);
let leaving = useRef | undefined>(undefined);
let press = useRef<{ left: number; top: number; moved: boolean } | undefined>(undefined);
let painted = useRef(false);
let restoring = useRef(false);
let enterPopover = useRef(false);
let origin = useRef(undefined);
let decisions = useMemo(() => {
let out: Decision[] = [];
for (let entry of entries) {
for (let question of entry.value.questions) {
if (question.answer === undefined) continue;
let keys = store.blocks(entry.id, question.id);
if (keys.length === 0) continue;
out.push({
key: keyOf({ widget: entry.id, question: question.id }),
widget: entry.id,
question: question.id,
prompt: question.prompt,
answer: question.answer,
others: unchosen(question),
by: entry.value.by,
at: entry.value.at,
keys,
});
}
}
return out;
// `relations` is what says the blocks behind a decision were re-resolved.
}, [entries, relations, store]);
let decisionsRef = useRef(decisions);
decisionsRef.current = decisions;
useEffect(() => {
return editor.registerRootListener(element => {
setHost(element?.closest(".plan-document") ?? undefined);
});
}, [editor]);
useEffect(() => {
let query = matchMedia(COARSE_POINTER_QUERY);
let update = () => setCoarse(query.matches);
update();
query.addEventListener("change", update);
return () => query.removeEventListener("change", update);
}, []);
let measure = useCallback(() => {
if (!host) return;
let page = rect(host.getBoundingClientRect());
let next: Placed[] = [];
for (let decision of decisionsRef.current) {
try {
let elements = [
...new Set(
decision.keys.map(key => blockElement(editor, key)).filter(
(element): element is HTMLElement => !!element?.isConnected,
),
),
];
let rects = elements.map(element => rect(element.getBoundingClientRect())).filter(value =>
value.width > 0 && value.height > 0
);
if (rects.length === 0) continue;
let first = rects.reduce((top, value) => value.top < top.top ? value : top);
let element = elements[rects.indexOf(first)]!;
let lineHeight = lineHeightOf(element);
let marker = markerPoint(first, lineHeight, page);
next.push({ decision, marker, lineHeight, anchor: first, hits: passageHits(page, rects) });
} catch (error) {
// A bad anchor must not break Lexical's update listener.
console.error(`[plan] could not place decision ${decision.key}:`, error);
}
}
placedRef.current = next;
setPlaced(next);
}, [editor, host]);
useLayoutEffect(() => {
measure();
}, [measure, decisions, coarse]);
useEffect(() => {
if (!host) return;
let off = editor.registerUpdateListener(measure);
host.addEventListener("scroll", measure, true);
let observer = new ResizeObserver(measure);
observer.observe(host);
return () => {
off();
host.removeEventListener("scroll", measure, true);
observer.disconnect();
};
}, [editor, host, measure]);
useEffect(() => {
act({ type: "prune", live: new Set(decisions.map(decision => decision.key)) });
}, [decisions]);
let enter = useCallback((key: string) => {
clearTimeout(leaving.current);
act({ type: "enter", key });
}, []);
let leave = useCallback((key: string) => {
clearTimeout(leaving.current);
leaving.current = setTimeout(() => act({ type: "leave", key }), 100);
}, []);
useEffect(() => () => clearTimeout(leaving.current), []);
let restoreFocus = useCallback(() => {
let target = origin.current;
if (!target?.isConnected) return;
if (
root.current?.contains(document.activeElement) || document.activeElement === document.body
) {
// Giving focus back is not a request for the preview.
restoring.current = true;
target.focus();
restoring.current = false;
}
}, []);
let dismiss = useCallback(() => {
act({ type: "dismiss" });
restoreFocus();
}, [restoreFocus]);
useEffect(() => {
if (!host) return;
let over = (event: MouseEvent): Placed | undefined => {
let page = host.getBoundingClientRect();
let at = { top: event.clientY - page.top, left: event.clientX - page.left };
return placedRef.current.find(entry => containsHit(entry.hits, at));
};
let inProse = (target: EventTarget | null) =>
!!target && !!editor.getRootElement()?.contains(target as Node);
let down = (event: PointerEvent) => {
press.current = { left: event.clientX, top: event.clientY, moved: false };
};
let move = (event: PointerEvent) => {
let pending = press.current;
if (pending && Math.hypot(event.clientX - pending.left, event.clientY - pending.top) > 3) {
pending.moved = true;
}
if (root.current?.contains(event.target as Node)) return;
let entry = inProse(event.target) ? over(event) : undefined;
if (entry) enter(entry.decision.key);
else for (let item of placedRef.current) leave(item.decision.key);
};
let click = (event: MouseEvent) => {
let pending = press.current;
press.current = undefined;
if (!pending || pending.moved || !inProse(event.target)) return;
let entry = over(event);
let selection = getSelection();
if (!entry || (selection && !selection.isCollapsed)) return;
origin.current = root.current?.querySelector(
`[data-plan-decision-marker="${entry.decision.key}"]`,
) ?? undefined;
act({ type: "pin", key: entry.decision.key });
};
let out = () => {
for (let item of placedRef.current) leave(item.decision.key);
};
host.addEventListener("pointerdown", down);
host.addEventListener("pointermove", move);
host.addEventListener("click", click);
host.addEventListener("pointerleave", out);
return () => {
host.removeEventListener("pointerdown", down);
host.removeEventListener("pointermove", move);
host.removeEventListener("click", click);
host.removeEventListener("pointerleave", out);
};
}, [editor, enter, host, leave]);
useEffect(() => {
if (!pointer.pinned || !enterPopover.current) return;
enterPopover.current = false;
root.current?.querySelector("[data-plan-decision-close]")?.focus();
}, [pointer.pinned]);
let view = shown(pointer);
let open = view ? placed.find(entry => entry.decision.key === view.key) : undefined;
// One wash at a time, and only the one this layer put up is taken down.
let openKey = open?.decision.key;
useEffect(() => {
let decision = decisions.find(item => item.key === openKey);
if (!decision) return;
store.highlight(decision.widget, decision.question);
painted.current = true;
return () => {
if (!painted.current) return;
painted.current = false;
store.clear();
};
}, [decisions, openKey, store]);
useEffect(() => {
if (!pointer.pinned && !pointer.hover) return;
let outside = (event: PointerEvent) => {
if (!pointer.pinned) return;
let target = event.target as Node;
if (root.current?.contains(target)) return;
// A press on the pinned prose is the click that pins it, not a dismissal.
let page = host?.getBoundingClientRect();
let entry = placedRef.current.find(item => item.decision.key === pointer.pinned);
if (
page && entry
&& containsHit(entry.hits, {
top: event.clientY - page.top,
left: event.clientX - page.left,
})
) return;
act({ type: "dismiss" });
};
let escape = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
// A hover preview goes quietly; only a pinned popover owns the key, so
// Escape still reaches whatever else (a child document) it would close.
if (pointer.pinned) event.preventDefault();
dismiss();
};
document.addEventListener("pointerdown", outside);
document.addEventListener("keydown", escape);
return () => {
document.removeEventListener("pointerdown", outside);
document.removeEventListener("keydown", escape);
};
}, [dismiss, host, pointer.hover, pointer.pinned]);
if (!host) return null;
let page = rect(host.getBoundingClientRect());
let hit = coarse ? MARKER_TOUCH_SIZE : MARKER_SIZE;
let inset = (hit - MARKER_SIZE) / 2;
let width = Math.min(POPOVER_WIDTH, host.clientWidth - 16);
let value: PopoverValue | undefined;
if (open && view) {
let at = popoverBelow(open.anchor, page, width, height);
value = {
decision: open.decision,
pinned: view.pinned,
style: { ...at, width },
};
}
let popoverId = "plan-decision-pop";
let immediately = options.motionImmediately?.() ?? false;
return createPortal(