sokko-g/website/src/features/toolboxes/modules/CalculatorModule.jsx
Shinuwa 51853245ad
All checks were successful
Deploy Sokko G / deploy (push) Successful in 18s
Improve calculator progress tracking and responsive layout
2026-09-29 11:19:11 +02:00

361 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Rôle : fournit l'outil calculateur avec résultats enregistrés en arborescence et échange texte.
import { useLayoutEffect, useMemo, useRef, useState } from "react";
import { Icon } from "../../../components/Icon.jsx";
import { MixTagsInput } from "../../../components/MixTagsInput.jsx";
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import { CalculatorEntries, FormattedCalculatorNumber } from "./CalculatorEntries.jsx";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
import {
BASE_TOKEN,
calculateRemainingValue,
createStoredFormula,
evaluateCalculatorEntries,
formatDisplayResult,
formatResult,
getDescendantIds,
getEditableFormula,
getEntriesInTreeOrder,
getResultLengthClass,
parseCalculatorCurrent
} from "./calculatorUtils.js";
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]);
const calculatedData = useMemo(() => ({ ...data, entries: calculatedEntries }), [data, calculatedEntries]);
const textContent = context.moduleText?.calculator || {};
const [expression, setExpression] = useState("");
const [current, setCurrent] = useState("");
const [showCurrent, setShowCurrent] = useState(false);
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 expressionInputRef = useRef(null);
const currentInputRef = 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(() => calculateRemainingValue(expression, current, formulaParent?.value), [current, formulaParent?.value, expression]);
const scopeParentId = (parentId = "") => `${moduleId}:${parentId}`;
const reorder = useGroupedReorder({
namespace: "calculator",
items: calculatedEntries,
getItemId: (entry) => entry.id,
getParentId: (entry) => scopeParentId(entry.parentId || ""),
onItemMove: (operation) => {
save(moveItem(calculatedEntries, operation.sourceId, operation.targetId, operation.placement));
},
hierarchy: { enabled: true, stickyParents: true }
});
useLayoutEffect(() => {
const element = calculatorCardRef.current;
if (!element) return undefined;
function updateHeight() {
setCalculatorHeight(Math.ceil(element.getBoundingClientRect().height));
}
updateHeight();
const observer = new ResizeObserver(updateHeight);
observer.observe(element);
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) });
}
function setScrollResults(scrollResults) {
context.setModuleData(toolboxId, moduleId, { ...data, scrollResults });
}
function saveResult(event) {
event.preventDefault();
if (result == null) return;
const cleanLabel = label.trim();
const storedFormula = createStoredFormula(expression, formulaParent?.value);
const storedCurrent = parseCalculatorCurrent(current);
if (storedCurrent == null) return;
if (editingEntry) {
save(calculatedEntries.map((entry) => entry.id === editingEntry.id ? {
...entry,
label: cleanLabel,
value: result,
formula: storedFormula,
current: storedCurrent
} : entry));
setLabel(cleanLabel);
return;
}
save([
...data.entries,
{
id: context.uid("calc"),
parentId: formulaParentId,
label: cleanLabel,
value: result,
formula: storedFormula,
current: storedCurrent
}
]);
setExpression(formulaParent ? BASE_TOKEN : "");
setCurrent("");
setShowCurrent(false);
setLabel("");
}
function editEntry(entry) {
setEditingEntryId(entry.id);
setActiveParentId("");
setExpression(getEditableFormula(entry));
setCurrent(entry.current ? formatResult(entry.current) : "");
setShowCurrent(Boolean(entry.current));
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,
current: 0
}
]);
setEditingEntryId("");
pendingExpressionFocusRef.current = true;
setEditingEntryId(childId);
setActiveParentId("");
setExpression(BASE_TOKEN);
setCurrent("");
setShowCurrent(false);
setLabel("");
}
function deleteEntry(entryId) {
const deletedIds = new Set([entryId, ...getDescendantIds(calculatedEntries, entryId)]);
if (deletedIds.has(activeParentId)) {
setActiveParentId("");
setExpression("");
}
if (deletedIds.has(editingEntryId)) {
setEditingEntryId("");
setLabel("");
setExpression("");
setCurrent("");
setShowCurrent(false);
}
save(calculatedEntries.filter((entry) => !deletedIds.has(entry.id)));
}
function resetCalculator() {
setExpression("");
setCurrent("");
setShowCurrent(false);
setLabel("");
setActiveParentId("");
setEditingEntryId("");
}
function returnToRoot() {
setActiveParentId("");
setEditingEntryId("");
setExpression("");
setCurrent("");
setShowCurrent(false);
setLabel("");
}
function toggleCurrent() {
if (showCurrent) {
setCurrent("");
setShowCurrent(false);
return;
}
setShowCurrent(true);
window.requestAnimationFrame(() => currentInputRef.current?.focus());
}
async function copyChecklistImport() {
const text = getEntriesInTreeOrder(calculatedEntries)
.map((entry) => `${entry.label || formatResult(entry.value)}:${formatResult(entry.value)}`)
.join("\n");
if (!text || !await context.copyText(text)) return;
setCopied(true);
context.notify?.(textContent.copiedTitle || "Copié");
window.setTimeout(() => setCopied(false), 1400);
}
return (
<div className="calculator-module">
{textExchangeOpen && (
<div className="module-text-exchange-panel">
<TextExchangeActions type="calculator" data={calculatedData} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "calculator")} />
</div>
)}
<form className="calculator-form" onSubmit={saveResult}>
<div className="calculator-card" ref={calculatorCardRef}>
<div className="calculator-field">
<span>{textContent.expressionLabel || "Calcul"}</span>
<div className={`calculator-expression-row ${showCurrent ? "has-current" : ""}`}>
{showCurrent && <span className="calculator-total-label">{textContent.totalLabel || "Total"}</span>}
{formulaParent ? (
<MixTagsInput
aria-label={textContent.expressionLabel || "Calcul"}
className="calculator-expression-input"
inputRef={expressionInputRef}
onChange={setExpression}
onKeyDown={(event) => {
if (event.key !== "Enter") return;
event.preventDefault();
event.currentTarget.closest("form")?.requestSubmit();
}}
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"}
tokens={[{
value: "base",
label: textContent.baseTokenLabel || "Valeur du parent"
}]}
value={expression}
/>
) : (
<input
type="text"
aria-label={textContent.expressionLabel || "Calcul"}
className="calculator-expression-input nds-input"
data-size="sm"
ref={expressionInputRef}
onChange={(event) => setExpression(event.target.value)}
onKeyDown={(event) => {
if (event.key !== "Enter") return;
event.preventDefault();
event.currentTarget.closest("form")?.requestSubmit();
}}
inputMode="decimal"
placeholder={textContent.expressionPlaceholder || "10*10"}
value={expression}
/>
)}
<button
className="calculator-current-toggle nds-icon-button"
data-size="sm"
type="button"
onClick={toggleCurrent}
aria-label={textContent.subtractCurrentTitle || "Soustraire l'actuel"}
aria-pressed={showCurrent}
title={textContent.subtractCurrentTitle || "Soustraire l'actuel"}
>
<span aria-hidden="true">−</span>
</button>
</div>
</div>
{showCurrent && (
<div className="calculator-current-field">
<span>{textContent.currentLabel || "Actuel"}</span>
<input
aria-label={textContent.currentLabel || "Actuel"}
className="nds-input"
data-size="sm"
ref={currentInputRef}
type="text"
inputMode="decimal"
value={current}
onChange={(event) => setCurrent(event.target.value)}
placeholder={textContent.currentPlaceholder || "0"}
/>
</div>
)}
<div className={`calculator-result ${result == null ? "" : getResultLengthClass(result)}`} aria-live="polite">
<span>{textContent.resultLabel || "Résultat"}</span>
<strong aria-label={result == null ? "-" : formatDisplayResult(result)}>
<FormattedCalculatorNumber value={result} />
</strong>
</div>
<label className="calculator-label-field">
<span>{textContent.labelLabel || "Libellé"}</span>
<input className="nds-input" data-size="sm" type="text" value={label} onChange={(event) => setLabel(event.target.value)} placeholder={textContent.labelPlaceholder || "Lingots de fer"} />
</label>
<button className="calculator-save-button nds-button" data-size="sm" data-variant="primary" disabled={result == null}>{formMode === "edit" ? textContent.updateButton || "Mettre à jour" : textContent.saveButton || "Enregistrer"}</button>
</div>
</form>
<div
className={`tool-split-results calculator-saved ${data.scrollResults ? "is-scrollable" : ""}`}
style={data.scrollResults && calculatorHeight ? { "--calculator-scroll-height": `${calculatorHeight}px` } : undefined}
>
<div className="tool-split-actions">
{(formulaParent || editingEntry) && (
<button className="tool-split-root-button nds-button" data-size="xs" type="button" onClick={returnToRoot}>
{editingEntry ? textContent.newButton || "Nouveau calcul" : textContent.rootButton || "Revenir à la racine"}
</button>
)}
<button className="tool-split-action-button nds-icon-button" data-size="sm" data-variant="danger" type="button" onClick={resetCalculator} aria-label={textContent.resetTitle || "Réinitialiser"} title={textContent.resetTitle || "Réinitialiser"}>
<Icon name="rubber" />
</button>
<button className="tool-split-action-button nds-icon-button" data-size="sm" type="button" onClick={copyChecklistImport} disabled={!data.entries.length} aria-label={textContent.copyTitle || "Copier pour checklist"} title={copied ? textContent.copiedTitle || "Copié" : textContent.copyTitle || "Copier pour checklist"}>
<Icon name="copy" />
</button>
<button
className={`tool-split-scroll-toggle nds-switch ${data.scrollResults ? "active" : ""}`}
data-size="md"
type="button"
onClick={() => setScrollResults(!data.scrollResults)}
aria-pressed={data.scrollResults}
title={textContent.scrollableTitle || "Liste scrollable"}
aria-label={textContent.scrollableTitle || "Liste scrollable"}
>
<Icon name="scrollable" />
<i aria-hidden="true" />
</button>
</div>
<div className={`tool-split-tree calculator-tree ${data.scrollResults ? "is-scrollable legacy-scrollbar" : ""}`}>
{calculatedEntries.length ? (
<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>
)}
</div>
</div>
</div>
);
}