import React, { useEffect, useId, useMemo, useRef, useState } from 'react'; import { canonicalQuestionAnswer, emptyQuestionAnswer, isQuestionAnswerEmpty, isRecommendedQuestionAnswer, questionStatus, recommendedQuestionAnswer, type IndexedQuestion, type ParsedQuestion, type QuestionAnswer, type QuestionStatus, } from '@plannotator/core/question-block'; import { InlineMarkdown } from '../InlineMarkdown'; import { QuestionContext } from './QuestionContext'; import { HostTitle } from '../HostTitle'; /** * A `:::question` block rendered as an answer card (the approved "Card" * variant): an eyebrow with "Question N of M" and a status tag, the prompt, * its context, then the choices (native radios / checkboxes plus an always * present "Other…"), or a text box for a free-text question, and a footer * with Add note · Skip · Accept recommended. * * The prompt, context and choice text stay ordinary document prose, so a * reviewer can select and annotate any of it; only the chrome (eyebrow, tags, * footer) is `annotation-exclude`. A click on a choice row picks it only when * the pointer barely moved and no text is selected, so a drag across option * text makes an annotation, never a pick. * * State is derived: `answer` comes from the host (Plannotator: the document's * annotation carrying `questionAnswer` for this key). Two ways to write: * - live (`onAnswer`): every change calls it with the next answer, or null * when it became empty; the host upserts or removes the annotation. * - explicit save (`onSaveAnswer`): changes stay a local draft; the footer * shows Save answer and Cancel (and no Skip), Save calls `onSaveAnswer` * once, Cancel returns to the host's answer. * With neither the block is read-only. * * A host can add its own actions at the right of the footer * (`renderFooter`), and a question carrying `Decision: when answered` or a * `Decision: [statement](url)` link shows that beside the prompt. A host can * also let the reviewer switch a question's decision recording off and on * (`decisionRecording` + `onToggleDecisionRecording`; with * `decisionScope: 'any'`, any question without a recorded decision once the * host supplies a state), open its own decision * card from the tag's words (`onOpenDecision`), and hide the card's own * status tag to draw its own (`statusTag: 'none'`). */ /** Pointer travel (px) above which a press on a choice row is a drag. */ const DRAG_THRESHOLD_MOUSE = 4; const DRAG_THRESHOLD_TOUCH = 10; /** A second click on the same choice within this window is a double-click: * it selects a word, so the pick its first click made is taken back. */ const DOUBLE_CLICK_REVERT_MS = 800; const now = (): number => (typeof performance !== 'undefined' ? performance.now() : Date.now()); const cx = (...parts: Array) => parts.filter(Boolean).join(' '); /** The box every pill in the eyebrow shares (the status tags, the decision * tags, the toggle and the split tag): one height, one line-height, and a * 1px border on all of them (transparent unless the tone draws it), so the * dashed "Open" tag is no taller than the rest. The pill group itself is a * flex row centred on its axis, so no pill rides its own baseline (the split * tag's baseline came from its icon-only switch, which lifted it). * `align-[1px]` does nothing inside that flex row; it stays because it is * the only use of the class, and dropping it would change the stylesheet * the guides.show viewer pins (a deploy for no visible change). */ const PILL = 'annotation-exclude select-none inline-flex h-[18px] items-center gap-1 whitespace-nowrap rounded border px-1.5 align-[1px] text-[10.5px] font-semibold leading-4 tracking-[0.03em]'; const Tag: React.FC<{ tone: 'rec' | 'ok' | 'skip' | 'open'; children: React.ReactNode; className?: string; style?: React.CSSProperties }> = ({ tone, children, className, style }) => ( {children} ); const CheckGlyph = () => ( ); const DiamondGlyph = () => ( ); /** Draws a decision tag in its "off" state: the same words with a dotted * outline instead of the tinted fill, faded. The theme's muted-foreground * alone is exactly the "Open" tag's color beside it, so the off tag read as * barely dimmer than an on one. Text, icon and outline (all `currentColor`) * are muted-foreground mixed 55% with transparent: the look of * `opacity: 0.55` on the tag, without `opacity`, which would also fade the * keyboard focus ring (a `ring-primary` box-shadow on the tag's buttons) * that must stay at full strength. Inline so it adds no utility classes to * the shared stylesheet and wins over the tag's `text-primary`. */ const DECISION_OFF_STYLE: React.CSSProperties = { background: 'transparent', color: 'color-mix(in oklab, var(--muted-foreground) 55%, transparent)', outline: '1px dotted currentColor', outlineOffset: '-1px', }; /** Two answers say the same thing (identity and line ignored). */ const sameAnswer = (a: QuestionAnswer | null | undefined, b: QuestionAnswer | null | undefined): boolean => { const norm = (x: QuestionAnswer | null | undefined) => !x || isQuestionAnswerEmpty(x) ? '' : JSON.stringify([x.selected, x.other ?? '', x.text ?? '', x.note ?? '', !!x.skipped]); return norm(a) === norm(b); }; /** What `onSaveAnswer` may return: nothing (the save is done), or a promise * the card waits on (rejecting keeps the draft so the reviewer can retry). */ export type QuestionSaveResult = void | Promise; export interface QuestionBlockProps { blockId: string; indexed: IndexedQuestion; /** How many question blocks the document has (for "Question N of M"). */ total: number; answer?: QuestionAnswer; onAnswer?: (blockId: string, answer: QuestionAnswer | null, key: string) => void; /** Explicit save mode (takes precedence over `onAnswer`): edits stay a * draft in the card until Save, which calls this with the question's key * and the answer (null when the draft is empty). The host stores it and * passes it back as `answer`. */ onSaveAnswer?: (key: string, answer: QuestionAnswer | null) => QuestionSaveResult; /** Label of the explicit-save-mode Save button (default "Save answer"), * or a function of the question that returns it. */ saveLabel?: string | ((question: ParsedQuestion) => string); /** Host actions at the right end of the footer, given the question and its * saved answer (never the unsaved draft). Rendered in read-only cards too; * return null for nothing. */ renderFooter?: (question: IndexedQuestion, answer: QuestionAnswer | undefined) => React.ReactNode; /** Whether answering this question records a decision. On a question * carrying `Decision: when answered` it defaults to true; false draws the * "Records a decision" tag dimmed with a dotted outline and hides the * "Answering this records a decision" row. On a question WITHOUT a * decision line it is ignored unless `decisionScope` is `'any'` (ui * 0.52.1); then a defined value adds the tag in that state (false: off; * true: on, with the row), so a host can let any answer become a * decision, and undefined leaves the question with no tag. Has no effect * on a question whose decision is already recorded * (`Decision: [statement](url)`). */ decisionRecording?: boolean; /** Which questions can carry the "Records a decision" tag (ui 0.52.1). * `'when-answered'` (default, the 0.52.0 behaviour): only a question that * carries `Decision: when answered`. `'any'`: also a question with no * decision line, once the host passes a `decisionRecording` for it. */ decisionScope?: 'when-answered' | 'any'; /** Makes the "Records a decision" tag a toggle button (`aria-pressed`): * a click calls this with the question's key and the next state. The host * stores it and passes it back as `decisionRecording`. Not offered in a * read-only card (no answer handler), which draws the tag as it stands. */ onToggleDecisionRecording?: (key: string, next: boolean) => void; /** Opens the host's decision card (ui 0.52.1). Given, the tag splits into * two targets inside one visual tag: the diamond is the switch * (`onToggleDecisionRecording`, labelled "Record as a decision") and the * words "Records a decision" are a button (`aria-haspopup="dialog"`) that * calls this with the question's key and the words' own element, to * anchor a popover. Offered whether recording is on or off (the host * decides what an off question's card does). Not offered in a read-only * card. */ onOpenDecision?: (key: string, anchor: HTMLElement) => void; /** `'none'` hides the card's own status tag (Open / Answered / Settled / * Skipped) so the host can draw its own; the decision tags stay. Default * `'card'`. */ statusTag?: 'card' | 'none'; /** The host's own words for the card's status tag, per question (ui seam; * the Plannotator Inbox says "Not sent" for a pick it has saved but not * sent, where the card would say "Answered"). Return undefined for the * card's own tag. Absent: today's tags. Ignored with `statusTag: 'none'`. */ statusLabel?: (question: IndexedQuestion, status: QuestionStatus) => QuestionStatusLabel | undefined; /** Host content drawn right under the prompt (a host note such as "Replaces * "), given the question. Return null for none. * Absent: nothing is drawn there (today's card). */ renderBelowPrompt?: (question: IndexedQuestion) => React.ReactNode; onOpenLinkedDoc?: (path: string) => void; onOpenCodeFile?: (path: string) => void; imageBaseDir?: string; onImageClick?: (src: string, alt: string) => void; githubRepo?: string; repoHost?: string; onNavigateAnchor?: (hash: string) => void; } /** A host's status tag (`statusLabel`): its words, and the tone of one of the card's own tags (default `open`). */ export interface QuestionStatusLabel { label: string; tone?: 'ok' | 'open' | 'skip'; } export const QuestionBlock: React.FC = ({ blockId, indexed, total, answer: storedAnswer, onAnswer: onLiveAnswer, onSaveAnswer, saveLabel, renderFooter, decisionRecording, decisionScope = 'when-answered', onToggleDecisionRecording, onOpenDecision, statusTag = 'card', statusLabel, renderBelowPrompt, onOpenLinkedDoc, onOpenCodeFile, imageBaseDir, onImageClick, githubRepo, repoHost, onNavigateAnchor, }) => { const { question, number } = indexed; // An answer saved under an older label of a choice (0.28.1 read a wrapped // plain bullet's first line as its label) shows that choice picked; the // next edit stores the current label. const savedAnswer = useMemo( () => (storedAnswer ? canonicalQuestionAnswer(question, storedAnswer) : storedAnswer), [question, storedAnswer], ); const saveMode = !!onSaveAnswer; // Save mode: the unsaved draft (null = the reviewer emptied it). Absent // means the card shows the host's answer. const [draft, setDraft] = useState<{ answer: QuestionAnswer | null } | null>(null); const [saving, setSaving] = useState(false); const answer: QuestionAnswer | undefined = saveMode && draft ? draft.answer ?? undefined : savedAnswer; const dirty = saveMode && !!draft && !sameAnswer(draft.answer, savedAnswer); // Every edit goes through here: straight to the host in live mode, into the // draft in save mode. // A draft edited back to the saved answer is dropped, so a later host // update to `answer` shows instead of a stale copy. const onAnswer = onSaveAnswer ? (_blockId: string, next: QuestionAnswer | null) => setDraft(sameAnswer(next, savedAnswer) ? null : { answer: next }) : onLiveAnswer; const readOnly = !onAnswer; const save = () => { if (!onSaveAnswer || !draft || saving) return; const next = draft.answer && !isQuestionAnswerEmpty(draft.answer) ? draft.answer : null; const submitted = draft; const result = onSaveAnswer(question.key, next); if (result && typeof (result as Promise).then === 'function') { setSaving(true); (result as Promise).then( () => { setSaving(false); // An edit made while the save was in flight stays a draft. setDraft((current) => (current === submitted ? null : current)); }, // A failed save keeps the draft; the host reports the error. () => setSaving(false), ); } else { setDraft(null); } }; const cancel = () => { setDraft(null); setNotePinned(null); }; const hostFooter = renderFooter?.(indexed, savedAnswer); const hasHostFooter = hostFooter !== null && hostFooter !== undefined && hostFooter !== false; const uid = useId().replace(/:/g, ''); const promptId = `q-prompt-${uid}`; const contextId = `q-context-${uid}`; const suggestId = `q-suggest-${uid}`; const noteLabelId = `q-note-${uid}`; // null = follow the answer (open while a note exists); a click pins it. const [notePinned, setNotePinned] = useState(null); const noteOpen = notePinned ?? !!answer?.note?.trim(); const pressRef = useRef<{ x: number; y: number; touch: boolean } | null>(null); const noteRef = useRef(null); const cardRef = useRef(null); // Accept recommended (and Use) go away once clicked: focus moves to what // the click filled in, never to the page. const focusAfterAcceptRef = useRef(false); const lastPickRef = useRef<{ label: string; before: QuestionAnswer | undefined; at: number } | null>(null); const inline = (text: string) => ( ); // A note alone does not change a settled choice: the [x] stays drawn (and // the status stays Settled) until the reviewer picks, fills Other or skips. const status = questionStatus(question, answer); const hostStatus = statusTag === 'none' ? undefined : statusLabel?.(indexed, status); const answered = status === 'answered'; const skipped = status === 'skipped'; const settled = status === 'settled'; const settledLabels = question.choices.filter((c) => c.settled).map((c) => c.label); const hasRecommendation = question.choices.some((c) => c.recommended) || !!question.suggestedText; // "Accept recommended" stays offered on a pick-any question whose // recommendation names choices until the answer is exactly those choices: // ticking one box must not take away the one click that ticks the rest. A // pick-one or text question answered any way, a question with a settled // choice, and a pick-any question recommending only a suggestion (whose // Suggested line goes once the card is answered, so Accept would clear the // ticks for something no longer shown) offer it no more. const offerAccept = !readOnly && hasRecommendation && !settled && ( !answered || (question.kind === 'multi' && question.choices.some((c) => c.recommended) && !question.choices.some((c) => c.settled) && !isRecommendedQuestionAnswer( answer!, question.choices.filter((c) => c.recommended).map((c) => c.label), question.suggestedText, )) ); const selected = answered || skipped ? answer!.selected : settledLabels; const base = answer ?? emptyQuestionAnswer(indexed); const commit = (next: QuestionAnswer) => { if (!onAnswer) return; onAnswer(blockId, isQuestionAnswerEmpty(next) ? null : next, question.key); }; const pick = (label: string, checked: boolean) => { lastPickRef.current = { label, before: answer, at: now() }; const { skipped: _s, ...rest } = base; if (question.kind === 'multi') { const nextSelected = checked ? [...selected.filter((l) => l !== label), label] : selected.filter((l) => l !== label); // Keep document order, not click order. const order = question.choices.map((c) => c.label); nextSelected.sort((a, b) => order.indexOf(a) - order.indexOf(b)); commit({ ...rest, selected: nextSelected }); } else if (checked) { const { other: _o, ...single } = rest; commit({ ...single, selected: [label] }); } }; const setOther = (value: string) => { const { skipped: _s, other: _o, ...rest } = base; const keepSelected = question.kind === 'multi' ? selected : value.trim() ? [] : rest.selected; commit({ ...rest, selected: keepSelected, ...(value ? { other: value } : {}) }); }; const setText = (value: string) => { const { skipped: _s, text: _t, ...rest } = base; commit({ ...rest, ...(value ? { text: value } : {}) }); }; const setNote = (value: string) => { const { note: _n, ...rest } = base; commit({ ...rest, ...(value ? { note: value } : {}) }); }; const toggleSkip = () => { if (base.skipped) { const { skipped: _s, ...rest } = base; commit(rest); return; } const { other: _o, text: _t, ...rest } = base; commit({ ...rest, selected: [], skipped: true }); }; const acceptRecommended = () => { const next = recommendedQuestionAnswer(indexed, base); if (!next) return; focusAfterAcceptRef.current = true; commit(next); }; // Runs after the render that drew the accepted answer (and once more at // most, should the host not redraw it): the first checked choice, else the // field holding the suggestion. useEffect(() => { if (!focusAfterAcceptRef.current) return; focusAfterAcceptRef.current = false; const card = cardRef.current; if (!card) return; // Focus still on something in the card (the button was not removed): leave it. const active = document.activeElement; if (active && active !== document.body && card.contains(active)) return; const target = card.querySelector('input:checked:not(:disabled)') ?? card.querySelector('textarea:not(:disabled), input[type="text"]:not(:disabled)'); target?.focus({ preventScroll: true }); }); const toggleNote = () => { const opening = !noteOpen; setNotePinned(opening); if (opening) requestAnimationFrame(() => noteRef.current?.focus()); }; // Drag-vs-pick guard. A native