import type { StepTechStepTemperatureView, StepTechStepView, SubmitTechStepCorrectionResult, } from "@batch-cooking/shared"; import { Fragment, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Tooltip } from "../../../components/ui/Tooltip"; import { splitDescriptionSegments } from "./highlight-tech-steps"; import { TechStepCorrectionPopover } from "./TechStepCorrectionPopover"; import { type TextSelectionRange, useTextSelection } from "./use-text-selection"; /** * Human label for a detected temperature — the °C value, else the gas-mark * number, else the qualitative level, each via i18n. Shown in the * highlight's Tooltip (`raw` is what stays inline as the highlighted text). * `t` is passed in (see `ingredient-label.ts` for the same "plain function, * unit-testable" split). */ function temperatureLabel( temperature: StepTechStepTemperatureView, t: (key: string, options?: Record) => string, ): string { if (temperature.celsius !== null) { return t("recipes.temperature.celsius", { value: temperature.celsius }); } if (temperature.gasMark !== null) { return t("recipes.temperature.gasMark", { value: temperature.gasMark }); } if (temperature.qualitative !== null) { return t(`recipes.temperature.qualitative.${temperature.qualitative}`); } return temperature.raw; } /** * A recipe step's description, with every detected technique's exact * matched words highlighted and given a {@link Tooltip} naming the * technique (e.g. hovering/focusing "hacher" in "Hacher les oignons" shows * "Hacher") — `RecipeDetailPanel`'s replacement for a bare `

{description}

`. * * A match's wider `contextStart`/`contextEnd` clause (see * `StepTechStepView`) is deliberately *not* visualized here — only the * tight keyword span is highlighted. The backend still computes and * persists it (`tech-step-matcher.ts`/`StepTechStep`), and * `splitDescriptionByTechSteps` still splits the description around it * (`isKeyword: false` context segments), but this component now renders * those non-keyword segments as plain text, same as a segment with no * technique at all — the visual "wider clause, subtler highlight" * treatment (`.step-tech-step-context`) turned out to be more visual noise * than useful signal in practice and was turned back off. * * `techStep.key` resolves its tooltip label through `catalog.techSteps.` * i18n, the same pattern every other reference catalog (diets, units, …) * uses for its display text. A keyword's `source` (`"auto"` — the * classifier — vs `"manual"` — a viewer's correction, applied immediately) * gets its own modifier class (`.step-tech-step--manual`), a different * color, so the two are visually distinguishable at a glance rather than * only via the tooltip text. * * `editable` (off by default) additionally lets the viewer select text or * click an existing highlight to open a {@link TechStepCorrectionPopover} — * see `use-text-selection.ts` for how a browser selection is translated * into an absolute `[start, end)` span. When `!editable`, every segment * renders exactly as before (no extra wrapping elements, no `data-offset`, * no click handlers) — this mode is purely additive, not a rewrite of the * read-only rendering. * * Maintains its own local copy of `techSteps` (seeded from the prop, then * replaced with whatever `POST .../corrections` returns on a successful * submit — see `SubmitTechStepCorrectionResult`'s doc comment, * `packages/shared`) so a correction's effect (a new/relabeled/removed * highlight) appears immediately, without needing the parent to re-fetch * the whole recipe. Resynced whenever the `techSteps` prop itself changes * (e.g. the parent reloaded the recipe for an unrelated reason) so this * never keeps showing stale local state past that. */ export function StepDescription({ description, techSteps, editable = false, recipeId, stepId, }: { description: string; techSteps: StepTechStepView[]; /** Requires `recipeId`/`stepId` when `true` — omit (or leave `false`) for a read-only view with nothing real to correct against yet (e.g. `RecipeDetailPanel`'s `"loaded-draft"` unsaved-preview branch). */ editable?: boolean; recipeId?: number; stepId?: number; }) { const { t } = useTranslation(); const [liveTechSteps, setLiveTechSteps] = useState(techSteps); useEffect(() => setLiveTechSteps(techSteps), [techSteps]); // Temperatures are attached per technique-clause server-side; for the // read-only highlight we only care *where* in the description they are, // so flatten them across every entry (deduped by span). `?? []` guards a // pre-`temperatures` payload the same way this component already tolerates // a match with no `contextStart`/`contextEnd`. const temperatures = Array.from( new Map( liveTechSteps .flatMap((techStep) => techStep.temperatures ?? []) .map((temperature) => [`${temperature.start}:${temperature.end}`, temperature]), ).values(), ); const segments = splitDescriptionSegments(description, liveTechSteps, temperatures); const containerRef = useRef(null); const { getSelectionRange } = useTextSelection(containerRef); const [activeCorrection, setActiveCorrection] = useState<{ range: TextSelectionRange; selectedText: string; previousTechStepId: number | null; } | null>(null); // Routes the *next* text selection to the open `TechStepCorrectionPopover` // (as an ingredient/utensil mention span) instead of opening a brand-new // correction — set when that popover calls `onRequestSpan`, cleared once // `handleMouseUp` resolves the selection below. See // `TechStepCorrectionPopover.tsx`'s own doc comment for why this can live // entirely alongside the still-visible, still-selectable description // rather than needing the popover itself to move/hide. const [pendingSpanRequest, setPendingSpanRequest] = useState<"ingredient" | "utensil" | null>( null, ); const [resolvedMetadataSpan, setResolvedMetadataSpan] = useState<{ nonce: number; kind: "ingredient" | "utensil"; range: TextSelectionRange; text: string; } | null>(null); const nextMetadataSpanNonce = useRef(0); function closeActiveCorrection() { setActiveCorrection(null); setPendingSpanRequest(null); setResolvedMetadataSpan(null); } function handleMouseUp() { if (!editable) return; const range = getSelectionRange(); if (!range) return; if (pendingSpanRequest !== null) { nextMetadataSpanNonce.current += 1; setResolvedMetadataSpan({ nonce: nextMetadataSpanNonce.current, kind: pendingSpanRequest, range, text: description.slice(range.start, range.end), }); setPendingSpanRequest(null); return; } setActiveCorrection({ range, selectedText: description.slice(range.start, range.end), previousTechStepId: null, }); } function handleSubmitted(result: SubmitTechStepCorrectionResult) { setLiveTechSteps(result.techSteps); } // The occurrence `activeCorrection` is currently open for, matched by its // exact `[start, end)` (not just `techStep.id` — the same technique can // legitimately occur more than once in one description) — whatever // ingredients/utensils it already carries seed // `TechStepCorrectionPopover`'s own pending lists. `undefined` (not an // empty array) for a brand-new selection, same as "nothing to look up // yet". const activeStepTechStep = activeCorrection ? liveTechSteps.find( (techStep) => techStep.start === activeCorrection.range.start && techStep.end === activeCorrection.range.end, ) : undefined; // Tracks each segment's own absolute start offset into `description` as // the map below walks them in order — segments are contiguous and cover // the whole description (see `splitDescriptionByTechSteps`'s doc // comment), so a running total is exact, no re-derivation needed. let offset = 0; return ( <>

{segments.map((segment, index) => { const start = offset; offset += segment.text.length; // Captured now, not read as `offset` later inside a click // handler below — `offset` keeps mutating for every subsequent // segment this same `.map()` pass renders, so a closure // referencing it directly would see its *final* value (the end // of the whole description) whenever it actually fires, long // after render — found via a real correction submitted with // `end` far past this segment's own text. const end = offset; // A segment's own text/techStep don't uniquely identify it (the // same word can appear twice in one description) — index is the // only thing that does, but this list is fully regenerated from // `description`/`liveTechSteps` on every render (never reordered // or spliced in place), so using it as part of the key is safe // here. const key = `${index}-${segment.text}`; if (segment.temperature) { // A detected temperature mention ("180°C", "feu doux") — its // own subtle highlight + a Tooltip with the normalized value. // Not correctable (no popover), same as utensils; still gets a // `data-offset` in editable mode so a text selection spanning // it resolves correctly. const label = temperatureLabel(segment.temperature, t); const modifier = segment.temperature.qualitative ?? "value"; return ( {segment.text} ); } if (!segment.techStep || !segment.isKeyword) { // Context-only or plain run — rendered as plain text in // read-only mode, same as before this component supported // `editable` at all (see this component's doc comment for why // the wider-clause highlight itself was turned back off). // Editable mode still wraps it in a `data-offset` span so a // selection starting/ending in plain text resolves correctly. if (!editable) return {segment.text}; return ( {segment.text} ); } const techStep = segment.techStep; const isManual = segment.source === "manual"; const tooltipLabel = isManual ? t("recipes.techStepCorrection.manualTooltip", { technique: t(`catalog.techSteps.${techStep.key}`), }) : t(`catalog.techSteps.${techStep.key}`); return ( {/* A real ); })}

{editable && activeCorrection && recipeId !== undefined && stepId !== undefined && ( )} ); }