361 lines
14 KiB
JavaScript
361 lines
14 KiB
JavaScript
// 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>
|
||
);
|
||
}
|