Suite à la maquette validée : redessine TechStepCorrectionPopover et ajoute l'édition de la température, jusqu'ici la seule des 4 métadonnées sans aucune interface (ni dans l'UI, ni côté API — voir la PR back-end empilée sous celle-ci). - StepDescription.tsx : le surlignage de température devient cliquable (un <button>, plus un <span> mort) — rouvre le popover de correction de la technique à laquelle il est rattaché (findTechStepForTemperature, résolu par span puisque la température n'a pas d'existence propre, elle est une métadonnée de clause comme les ingrédients/ustensiles). - TechStepCorrectionPopover.tsx : réécrit. Les 4 sections (Technique, Ingrédients, Ustensiles, Température) restent toutes visibles en même temps — plus de bascule de tout le popover vers un sous-écran pendant l'ajout d'un ingrédient/ustensile/température, seule la section concernée affiche son propre mini-formulaire, les autres (dont le picker de technique) restent inchangées à côté. Le picker de technique se cache derrière "Changer" une fois une technique choisie (rien à décider une fois confirmée correcte). Chaque ingrédient/ustensile/ température attaché s'affiche en ligne avec sa citation exacte du texte source (nouveau : la traçabilité vers le texte existait déjà en donnée, mais n'était affichée nulle part). Nouveau sélecteur à 3 modes (°C / Thermostat / Qualitatif) pour la température, cohérent avec le modèle de données (un seul des trois à la fois). Icônes SVG par catégorie (tag/feuille/poêle/thermomètre), même langage stroke-based que layouts/nav-icons.tsx. - recipes.scss : nouveau langage visuel du popover (sections avec icône, lignes de métadonnées façon carte plutôt que puces, bouton "+ Ajouter" en pointillés plutôt qu'un lien texte, sélecteurs segmentés). Les deux surlignages (.step-tech-step, .step-temperature) passent de `cursor: help` à `cursor: pointer` — les deux sont maintenant de vrais déclencheurs cliquables. - i18n : nouvelles clés (changeTechnique, temperatureSection, addTemperature, removeTemperature, temperatureMode.*) ; clé currentTechnique supprimée (le pictogramme remplace la phrase "Technique retenue : X") ; préfixe "+ " retiré de addIngredient/addUtensil (l'icône + le porte déjà visuellement). - Tests : TechStepCorrectionPopover.cy.tsx largement réécrit (10 tests, dont 2 nouveaux sur la température) ; recipes.feature/recipes.ts : nouveau scénario Gherkin "cliquer un surlignage de température rouvre le popover de sa technique" (fixture Gratin dédiée), step "Changer" ajouté au scénario de relabel existant. Vérifié : pnpm lint + build (shared/api/web) clean. Suites Cypress exécutées localement (Chrome headless) : TechStepCorrectionPopover.cy.tsx 10/10, suite component complète 50/50, recipes.feature 5/5, recipes.cy.ts 8/8, recipe-sources.feature 6/6, planning-page.cy.ts 7/7. Un échec pré-existant et sans rapport (recipe-form.feature, "Preloads an existing recipe's ingredients when editing") reproduit identiquement avec ce travail entièrement stashé — confirmé non lié. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
347 lines
15 KiB
TypeScript
347 lines
15 KiB
TypeScript
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 { 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 `<p>{description}</p>`.
|
|
*
|
|
* 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.<key>`
|
|
* 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, 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<HTMLParagraphElement>(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/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 (
|
|
<>
|
|
<p ref={containerRef} onMouseUp={handleMouseUp}>
|
|
{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 (
|
|
<Tooltip key={key} content={label}>
|
|
<span className={className} data-offset={editable ? start : undefined}>
|
|
{segment.text}
|
|
</span>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
const ownerStart = owner.start;
|
|
const ownerEnd = owner.end;
|
|
return (
|
|
<Tooltip key={key} content={label}>
|
|
<button
|
|
type="button"
|
|
className={className}
|
|
data-offset={start}
|
|
onClick={() => {
|
|
setPendingSpanRequest(null);
|
|
setResolvedMetadataSpan(null);
|
|
setActiveCorrection({
|
|
range: { start: ownerStart, end: ownerEnd },
|
|
selectedText: description.slice(ownerStart, ownerEnd),
|
|
previousTechStepId: owner.techStep.id,
|
|
});
|
|
}}
|
|
>
|
|
{segment.text}
|
|
</button>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
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 <Fragment key={key}>{segment.text}</Fragment>;
|
|
return (
|
|
<span key={key} data-offset={start}>
|
|
{segment.text}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<Tooltip key={key} content={tooltipLabel}>
|
|
{/* A real <button>, not a <mark>, so it's natively focusable
|
|
(keyboard/screen-reader users can reach the tooltip) without
|
|
fighting the "non-interactive element" a11y lint a bare
|
|
tabIndex on <mark> would trip — styled to read as inline
|
|
highlighted text, not as a button (see .step-tech-step). */}
|
|
<button
|
|
type="button"
|
|
className={isManual ? "step-tech-step step-tech-step--manual" : "step-tech-step"}
|
|
data-offset={editable ? start : undefined}
|
|
onClick={
|
|
editable
|
|
? () => {
|
|
// Clears any in-progress ingredient/utensil
|
|
// span-selection from whatever correction was open
|
|
// before — opening a *different* one has nothing
|
|
// left to resolve that selection into.
|
|
setPendingSpanRequest(null);
|
|
setResolvedMetadataSpan(null);
|
|
setActiveCorrection({
|
|
range: { start, end },
|
|
selectedText: segment.text,
|
|
previousTechStepId: techStep.id,
|
|
});
|
|
}
|
|
: undefined
|
|
}
|
|
>
|
|
{segment.text}
|
|
</button>
|
|
</Tooltip>
|
|
);
|
|
})}
|
|
</p>
|
|
{editable && activeCorrection && recipeId !== undefined && stepId !== undefined && (
|
|
<TechStepCorrectionPopover
|
|
recipeId={recipeId}
|
|
stepId={stepId}
|
|
description={description}
|
|
range={activeCorrection.range}
|
|
selectedText={activeCorrection.selectedText}
|
|
previousTechStepId={activeCorrection.previousTechStepId}
|
|
existingIngredients={activeStepTechStep?.ingredients ?? []}
|
|
existingUtensils={activeStepTechStep?.utensils ?? []}
|
|
existingTemperatures={activeStepTechStep?.temperatures ?? []}
|
|
resolvedMetadataSpan={resolvedMetadataSpan}
|
|
onRequestSpan={setPendingSpanRequest}
|
|
onClose={closeActiveCorrection}
|
|
onSubmitted={handleSubmitted}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|