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 { useTour } from "../../tour/TourContext"; import { splitDescriptionSegments } from "./highlight-tech-steps"; import { TechStepCorrectionPopover } from "./TechStepCorrectionPopover"; import { temperatureLabel } from "./temperature-label"; import { type TextSelectionRange, useTextSelection } from "./use-text-selection"; /** * Finds which of `techSteps`' occurrences a given temperature mention * belongs to — the read-only highlight flattens every occurrence's * `temperatures` together (see this file's own `temperatures` above), * losing that association, so a click handler that needs to reopen "the * technique this temperature is attached to" re-derives it by matching * span. `undefined` when nothing matches (shouldn't happen for a real * persisted temperature, but degrades to "not clickable" rather than a * crash — same posture as `use-text-selection.ts`'s `resolveOffset`). */ function findTechStepForTemperature( techSteps: StepTechStepView[], temperature: StepTechStepTemperatureView, ): StepTechStepView | undefined { return techSteps.find((techStep) => (techStep.temperatures ?? []).some( (candidate) => candidate.start === temperature.start && candidate.end === temperature.end, ), ); } /** * 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 technique or temperature highlight, to open a * {@link TechStepCorrectionPopover} — a temperature highlight reopens * whichever technique occurrence it's attached to (see * `findTechStepForTemperature` above), since temperature is corrected * through that same popover, not on its own (same as ingredients/ * utensils). 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 — or * `[]` if it's momentarily absent, see the prop's own note — 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; /** * The step's detected technique sequence. `StepView.techSteps` * (`packages/shared`) always sends an array — `[]` when nothing matched — * but this is optional so a partial/not-yet-resolved payload degrades to * "render the description as plain text" instead of crashing on * `.flatMap`, the same "tolerate missing data, don't crash" posture this * component already takes for a match with no `contextStart`/`contextEnd` * or a temperature with no owning occurrence. Seen `undefined` in * practice on the post-save navigation of the "edit an existing recipe" * flow, before the reloaded recipe's detail had resolved * (`recipe-form.feature`). */ 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(); // `?? []` here, not just at each read site, so every consumer below // (`.flatMap` for `temperatures`, `splitDescriptionSegments`, // `findTechStepForTemperature`, the `activeStepTechStep` lookup) always // has a real array — see the `techSteps` prop's own note for when it // arrives absent. 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); // Guided tour #2 (issue #42): the first time a viewer actually opens a // correction (editable mode only), walk them through the metadata editor. // `startTour` no-ops if it's already been seen or is disabled. const { startTour, isTourCompleted } = useTour(); useEffect(() => { if (editable && activeCorrection && !isTourCompleted("techStepMetadata")) { startTour("techStepMetadata"); } }, [editable, activeCorrection, isTourCompleted, startTour]); // Routes the *next* text selection to the open `TechStepCorrectionPopover` // (as an ingredient/utensil/temperature 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" | "temperature" | null >(null); const [resolvedMetadataSpan, setResolvedMetadataSpan] = useState<{ nonce: number; kind: "ingredient" | "utensil" | "temperature"; 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. // In editable mode, clicking it reopens the correction popover // for whichever technique occurrence it's attached to (found by // span, see `findTechStepForTemperature` — temperature is a // per-clause metadata like ingredients/utensils, corrected // through that technique's own popover, not on its own) — same // `data-offset` treatment either way, so a text selection // spanning it still resolves correctly. const label = temperatureLabel(segment.temperature, t); const modifier = segment.temperature.qualitative ?? "value"; const owner = editable ? findTechStepForTemperature(liveTechSteps, segment.temperature) : undefined; const className = `step-temperature step-temperature--${modifier}`; if (owner === undefined || owner.start === null || owner.end === null) { // Read-only, or (for a persisted temperature this shouldn't // happen, see `findTechStepForTemperature`'s own doc comment) // no owning occurrence with a real span to reopen — degrades // to the same non-interactive span as before rather than a // dead click. return ( {segment.text} ); } const ownerStart = owner.start; const ownerEnd = owner.end; return ( ); } 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 && ( )} ); }