Refactor calculator formula tagging workflow
All checks were successful
Deploy Sokko G / deploy (push) Successful in 15s

This commit is contained in:
Shinuwa 2026-08-24 14:14:57 +02:00
parent ae503c7260
commit abfa23fb3e
6 changed files with 118 additions and 191 deletions

View file

@ -1,8 +1,7 @@
// Rôle : fournit l'outil calculateur avec résultats enregistrés en arborescence et échange texte.
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useLayoutEffect, useMemo, useRef, useState } from "react";
import { Icon } from "../../../components/Icon.jsx";
import { MixTagsInput } from "../../../components/MixTagsInput.jsx";
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
@ -117,6 +116,23 @@ function FormattedCalculatorNumber({ value }) {
});
}
function focusExpressionInputAtEnd(element) {
if (!element) return;
element.focus();
if (typeof element.setSelectionRange === "function") {
const length = element.value?.length || 0;
element.setSelectionRange(length, length);
return;
}
if (!element.isContentEditable) return;
const range = document.createRange();
range.selectNodeContents(element);
range.collapse(false);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
}
export function CalculatorModule({ toolboxId, moduleId, context, textExchangeOpen }) {
const data = context.normalizeCalculatorData(context.getModuleData(toolboxId, moduleId, { entries: [] }));
const calculatedEntries = useMemo(() => evaluateCalculatorEntries(data.entries), [data.entries]);
@ -125,11 +141,17 @@ export function CalculatorModule({ toolboxId, moduleId, context, textExchangeOpe
const [expression, setExpression] = useState("");
const [label, setLabel] = useState("");
const [activeParentId, setActiveParentId] = useState("");
const [editingEntryId, setEditingEntryId] = useState("");
const [calculatorHeight, setCalculatorHeight] = useState(0);
const [copied, setCopied] = useState(false);
const calculatorCardRef = useRef(null);
const activeParent = calculatedEntries.find((entry) => entry.id === activeParentId);
const result = useMemo(() => calculateExpression(expression, activeParent?.value), [activeParent?.value, expression]);
const expressionInputRef = useRef(null);
const pendingExpressionFocusRef = useRef(false);
const editingEntry = calculatedEntries.find((entry) => entry.id === editingEntryId);
const formulaParentId = editingEntry ? editingEntry.parentId || "" : activeParentId;
const formulaParent = calculatedEntries.find((entry) => entry.id === formulaParentId);
const formMode = editingEntry ? "edit" : formulaParent ? "child" : "root";
const result = useMemo(() => calculateExpression(expression, formulaParent?.value), [formulaParent?.value, expression]);
const scopeParentId = (parentId = "") => `${moduleId}:${parentId}`;
const reorder = useGroupedReorder({
namespace: "calculator",
@ -156,6 +178,12 @@ export function CalculatorModule({ toolboxId, moduleId, context, textExchangeOpe
return () => observer.disconnect();
}, []);
useLayoutEffect(() => {
if (!pendingExpressionFocusRef.current || !editingEntryId) return;
pendingExpressionFocusRef.current = false;
window.requestAnimationFrame(() => focusExpressionInputAtEnd(expressionInputRef.current));
}, [editingEntryId]);
function save(entries) {
context.setModuleData(toolboxId, moduleId, { ...data, entries: evaluateCalculatorEntries(entries) });
}
@ -168,24 +196,58 @@ export function CalculatorModule({ toolboxId, moduleId, context, textExchangeOpe
event.preventDefault();
if (result == null) return;
const cleanLabel = label.trim();
const storedFormula = createStoredFormula(expression, formulaParent?.value);
if (editingEntry) {
save(calculatedEntries.map((entry) => entry.id === editingEntry.id ? {
...entry,
label: cleanLabel,
value: result,
formula: storedFormula
} : entry));
setLabel(cleanLabel);
return;
}
save([
...data.entries,
{
id: context.uid("calc"),
parentId: activeParentId,
parentId: formulaParentId,
label: cleanLabel,
value: result,
formula: createStoredFormula(expression, activeParent?.value)
formula: storedFormula
}
]);
setExpression(activeParent ? formatResult(activeParent.value) : "");
setExpression(formulaParent ? BASE_TOKEN : "");
setLabel("");
}
function useEntry(entry) {
setActiveParentId(entry.id);
function editEntry(entry) {
setEditingEntryId(entry.id);
setActiveParentId("");
setExpression(getEditableFormula(entry));
setLabel(entry.label || "");
}
function createChildEntry(entry) {
const childId = context.uid("calc");
save([
...data.entries,
{
id: childId,
parentId: entry.id,
label: "",
value: Number.isFinite(entry.value) ? entry.value : 0,
formula: BASE_TOKEN
}
]);
setEditingEntryId("");
pendingExpressionFocusRef.current = true;
setEditingEntryId(childId);
setActiveParentId("");
setExpression(BASE_TOKEN);
setLabel("");
}
function deleteEntry(entryId) {
@ -194,34 +256,26 @@ export function CalculatorModule({ toolboxId, moduleId, context, textExchangeOpe
setActiveParentId("");
setExpression("");
}
if (deletedIds.has(editingEntryId)) {
setEditingEntryId("");
setLabel("");
setExpression("");
}
save(calculatedEntries.filter((entry) => !deletedIds.has(entry.id)));
}
function renameEntry(entryId, label) {
const cleanLabel = label.trim();
save(calculatedEntries.map((entry) => entry.id === entryId ? { ...entry, label: cleanLabel } : entry));
}
function updateEntryFormula(entryId, formula) {
const nextEntries = calculatedEntries.map((entry) => {
if (entry.id !== entryId) return entry;
const parent = calculatedEntries.find((candidate) => candidate.id === entry.parentId);
const nextFormula = createStoredFormula(formula, parent?.value);
const nextValue = calculateExpression(nextFormula, parent?.value) ?? entry.value;
return { ...entry, formula: nextFormula, value: nextValue };
});
save(nextEntries);
}
function resetCalculator() {
setExpression("");
setLabel("");
setActiveParentId("");
setEditingEntryId("");
}
function returnToRoot() {
setActiveParentId("");
setEditingEntryId("");
setExpression("");
setLabel("");
}
async function copyChecklistImport() {
@ -245,10 +299,11 @@ export function CalculatorModule({ toolboxId, moduleId, context, textExchangeOpe
<div className="calculator-card" ref={calculatorCardRef}>
<div className="calculator-field">
<span>{textContent.expressionLabel || "Calcul"}</span>
{activeParent ? (
{formulaParent ? (
<MixTagsInput
aria-label={textContent.expressionLabel || "Calcul"}
className="calculator-expression-input"
inputRef={expressionInputRef}
onChange={setExpression}
onKeyDown={(event) => {
if (event.key !== "Enter") return;
@ -273,6 +328,7 @@ export function CalculatorModule({ toolboxId, moduleId, context, textExchangeOpe
<input
aria-label={textContent.expressionLabel || "Calcul"}
className="calculator-expression-input"
ref={expressionInputRef}
onChange={(event) => setExpression(event.target.value)}
onKeyDown={(event) => {
if (event.key !== "Enter") return;
@ -295,7 +351,7 @@ export function CalculatorModule({ toolboxId, moduleId, context, textExchangeOpe
<span>{textContent.labelLabel || "Libellé"}</span>
<input value={label} onChange={(event) => setLabel(event.target.value)} placeholder={textContent.labelPlaceholder || "Lingots de fer"} />
</label>
<button className="primary" disabled={result == null}>{textContent.saveButton || "Enregistrer"}</button>
<button className="primary" disabled={result == null}>{formMode === "edit" ? textContent.updateButton || "Mettre à jour" : textContent.saveButton || "Enregistrer"}</button>
</div>
</form>
<div
@ -303,9 +359,9 @@ export function CalculatorModule({ toolboxId, moduleId, context, textExchangeOpe
style={data.scrollResults && calculatorHeight ? { "--calculator-scroll-height": `${calculatorHeight}px` } : undefined}
>
<div className="tool-split-actions">
{activeParent && (
{(formulaParent || editingEntry) && (
<button className="tool-split-root-button" type="button" onClick={returnToRoot}>
{textContent.rootButton || "Revenir à la racine"}
{editingEntry ? textContent.newButton || "Nouveau calcul" : textContent.rootButton || "Revenir à la racine"}
</button>
)}
<button className="tool-split-action-button danger" type="button" onClick={resetCalculator} aria-label={textContent.resetTitle || "Réinitialiser"} title={textContent.resetTitle || "Réinitialiser"}>
@ -328,7 +384,7 @@ export function CalculatorModule({ toolboxId, moduleId, context, textExchangeOpe
</div>
<div className={`tool-split-tree calculator-tree ${data.scrollResults ? "is-scrollable legacy-scrollbar" : ""}`}>
{calculatedEntries.length ? (
<CalculatorEntries entries={calculatedEntries} parentId="" getScopedParentId={scopeParentId} activeParentId={activeParentId} textContent={textContent} reorder={reorder} onUse={useEntry} onRename={renameEntry} onUpdateFormula={updateEntryFormula} onDelete={deleteEntry} />
<CalculatorEntries entries={calculatedEntries} parentId="" getScopedParentId={scopeParentId} activeEntryId={editingEntryId || activeParentId} textContent={textContent} reorder={reorder} onEdit={editEntry} onAddChild={createChildEntry} onDelete={deleteEntry} />
) : (
<p className="muted">{textContent.emptyResults || "Aucun résultat enregistré."}</p>
)}
@ -338,31 +394,15 @@ export function CalculatorModule({ toolboxId, moduleId, context, textExchangeOpe
);
}
function CalculatorEntries({ entries, parentId, getScopedParentId, activeParentId, textContent, reorder, onUse, onRename, onUpdateFormula, onDelete }) {
function CalculatorEntries({ entries, parentId, getScopedParentId, activeEntryId, textContent, reorder, onEdit, onAddChild, onDelete }) {
const children = getChildren(entries, parentId);
const [editingId, setEditingId] = useState("");
useEffect(() => {
if (!editingId) return undefined;
function closeOnOutsidePointer(event) {
if (event.target?.closest?.(`[data-calculator-edit-fields="${editingId}"]`)) return;
if (event.target?.closest?.("[data-calculator-edit-toggle]")) return;
setEditingId("");
}
document.addEventListener("pointerdown", closeOnOutsidePointer);
return () => document.removeEventListener("pointerdown", closeOnOutsidePointer);
}, [editingId]);
if (!children.length) return null;
return (
<ul className="tool-split-entry-list calculator-entry-list">
{children.map((entry) => {
const className = [
entry.id === activeParentId ? "active" : "",
entry.id === editingId ? "is-editing" : "",
entry.parentId ? "can-edit-formula" : "",
entry.id === activeEntryId ? "active" : "",
reorder.isItemDragging(entry.id) ? "is-dragging" : "",
reorder.isItemDropTarget(entry.id) ? "is-drop-target" : "",
reorder.getDropPlacement("item", entry.id, parentId) === "after" ? "drop-after" : ""
@ -380,121 +420,30 @@ function CalculatorEntries({ entries, parentId, getScopedParentId, activeParentI
>
<Icon name="drag" />
</button>
{entry.id === editingId ? (
<span className="calculator-entry-edit-fields" data-calculator-edit-fields={entry.id}>
<EditableCalculatorFormula entry={entry} entries={entries} textContent={textContent} onUpdateFormula={onUpdateFormula} />
<EditableCalculatorLabel entry={entry} textContent={textContent} onRename={onRename} />
<button className="tool-split-entry-summary" type="button" onClick={() => onEdit(entry)} title={textContent.editTitle || "Modifier"}>
<span>
<strong className={getEntryResultLengthClass(entry.value)} aria-label={formatDisplayResult(entry.value)}>
<FormattedCalculatorNumber value={entry.value} />
</strong>
{entry.label && <em>{entry.label}</em>}
</span>
) : (
<button className="tool-split-entry-summary" type="button" onClick={() => onUse(entry)} title={textContent.useEntryTitle || "Utiliser comme base"}>
<span>
<strong className={getEntryResultLengthClass(entry.value)} aria-label={formatDisplayResult(entry.value)}>
<FormattedCalculatorNumber value={entry.value} />
</strong>
{entry.label && <em>{entry.label}</em>}
</span>
</button>
)}
</button>
<button
className={entry.id === editingId ? "active" : ""}
type="button"
onClick={() => setEditingId(entry.id === editingId ? "" : entry.id)}
aria-pressed={entry.id === editingId}
aria-label={`${textContent.renameTitle || "Renommer"} ${entry.label}`}
data-calculator-edit-toggle
title={textContent.renameTitle || "Renommer"}
onClick={() => onAddChild(entry)}
aria-label={`${textContent.addChildTitle || "Ajouter un calcul enfant"} ${entry.label || formatResult(entry.value)}`}
title={textContent.addChildTitle || "Ajouter un calcul enfant"}
>
<Icon name="edit" />
<Icon name="add" />
</button>
<button type="button" className="danger" onClick={() => onDelete(entry.id)} aria-label={`${textContent.deleteTitle || "Supprimer"} ${entry.label}`} title={textContent.deleteTitle || "Supprimer"}>
<Icon name="trash" />
</button>
</div>
<CalculatorEntries entries={entries} parentId={entry.id} getScopedParentId={getScopedParentId} activeParentId={activeParentId} textContent={textContent} reorder={reorder} onUse={onUse} onRename={onRename} onUpdateFormula={onUpdateFormula} onDelete={onDelete} />
<CalculatorEntries entries={entries} parentId={entry.id} getScopedParentId={getScopedParentId} activeEntryId={activeEntryId} textContent={textContent} reorder={reorder} onEdit={onEdit} onAddChild={onAddChild} onDelete={onDelete} />
</li>
);
})}
</ul>
);
}
function EditableCalculatorLabel({ entry, textContent, onRename, onDone, autoFocus = true }) {
const inlineEdit = useInlineEdit({
value: entry.label,
onCommit: (label) => onRename(entry.id, label),
onDone,
autoFocus,
focusKey: entry.id
});
return (
<input
{...inlineEdit.getInputProps({
className: "tool-split-entry-label",
"aria-label": `${textContent.renameTitle || "Renommer"} ${entry.label}`
})}
/>
);
}
function EditableCalculatorFormula({ entry, entries, textContent, onUpdateFormula, autoFocus = false }) {
const parent = entries.find((candidate) => candidate.id === entry.parentId);
const parentValue = parent?.value;
const hasParent = Boolean(entry.parentId);
const inlineEdit = useInlineEdit({
value: getEditableFormula(entry),
onCommit: (formula) => {
const storedFormula = createStoredFormula(formula, parentValue);
if (calculateExpression(storedFormula, parentValue) != null) onUpdateFormula(entry.id, storedFormula);
},
autoFocus,
focusKey: `${entry.id}:formula`,
transform: (draft) => String(draft || "").replaceAll(",", ".").trim()
});
if (!hasParent) {
return (
<input
{...inlineEdit.getInputProps({
className: "tool-split-entry-label calculator-entry-formula",
"aria-label": `${textContent.formulaTitle || "Formule enregistrée"} ${entry.label || formatResult(entry.value)}`,
title: textContent.rootFormulaHelp || "Valeur ou formule racine."
})}
/>
);
}
return (
<MixTagsInput
aria-label={`${textContent.formulaTitle || "Formule enregistrée"} ${entry.label || formatResult(entry.value)}`}
className="tool-split-entry-label calculator-entry-formula"
onBlur={() => inlineEdit.commit()}
onChange={inlineEdit.setDraft}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
event.currentTarget.blur();
}
if (event.key === "Escape") {
event.preventDefault();
inlineEdit.cancel(event);
}
}}
formulaSpacing
insertToken={{
value: "base",
label: textContent.baseTokenLabel || "Valeur du parent",
icon: "parent",
title: textContent.insertBaseTitle || "Ajouter la valeur du parent"
}}
placeholder={textContent.formulaPlaceholder || "base * 3"}
size="small"
title={entry.parentId ? textContent.childFormulaHelp || "Utilisez base pour référencer le parent." : textContent.rootFormulaHelp || "Valeur ou formule racine."}
tokens={[{
value: "base",
label: textContent.baseTokenLabel || "Valeur du parent"
}]}
value={inlineEdit.draft}
/>
);
}

View file

@ -27,27 +27,6 @@
grid-template-columns: 30px minmax(0, 1fr) 34px 34px;
}
.tool-split-entry-list li.is-editing > .calculator-entry.has-drag-handle {
grid-template-columns: 30px minmax(0, 1fr) 34px 34px;
align-items: start;
}
.tool-split-entry-list li.is-editing > .calculator-entry.has-drag-handle .calculator-drag-handle {
grid-row: 1 / span 2;
}
.calculator-entry-formula {
font-variant-numeric: tabular-nums;
}
.calculator-entry-edit-fields {
display: grid;
min-width: 0;
gap: 6px;
grid-column: 2;
grid-row: 1 / span 2;
}
.calculator-drag-handle {
width: 30px;
min-width: 30px;

View file

@ -658,10 +658,6 @@
min-height: 36px;
}
.tool-split-entry > button.active:not(.tool-split-entry-summary, .tool-split-entry-value, .danger) {
@include mixins.temporary-toggle-active;
}
.tool-split-entry-list li.active > .tool-split-entry > .tool-split-entry-summary,
.tool-split-entry-list li.active > .tool-split-entry > .tool-split-entry-value,
.tool-split-entry-list li.active > .tool-split-entry > .tool-split-entry-label {