449 lines
18 KiB
JavaScript
449 lines
18 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 { TextExchangeActions } from "./TextExchangeActions.jsx";
|
|
|
|
const DISPLAY_NUMBER_FORMATTER = new Intl.NumberFormat("fr-FR", { maximumFractionDigits: 6 });
|
|
const BASE_TOKEN = "base";
|
|
|
|
function calculateExpression(expression, baseValue = null) {
|
|
const normalized = String(expression || "").replaceAll(",", ".").trim();
|
|
if (!normalized) return null;
|
|
const withBase = normalized.replace(/\bbase\b/gi, () => Number.isFinite(baseValue) ? `(${formatResult(baseValue)})` : "NaN");
|
|
if (!/^[\d\s+\-*/().]+$/.test(withBase)) return null;
|
|
|
|
try {
|
|
const value = Function(`"use strict"; return (${withBase})`)();
|
|
return Number.isFinite(value) ? value : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function formatResult(value) {
|
|
if (!Number.isFinite(value)) return "";
|
|
return Number.parseFloat(value.toFixed(6)).toString();
|
|
}
|
|
|
|
function formatDisplayResult(value) {
|
|
if (!Number.isFinite(value)) return "";
|
|
return DISPLAY_NUMBER_FORMATTER.format(Number.parseFloat(value.toFixed(6)));
|
|
}
|
|
|
|
function getResultLengthClass(value) {
|
|
const length = formatResult(value).replace(/[^\d]/g, "").length;
|
|
if (length >= 13) return "is-compact";
|
|
if (length >= 9) return "is-long";
|
|
return "";
|
|
}
|
|
|
|
function getEntryResultLengthClass(value) {
|
|
const length = formatResult(value).replace(/[^\d]/g, "").length;
|
|
if (length >= 21) return "is-compact";
|
|
if (length >= 18) return "is-long";
|
|
return "";
|
|
}
|
|
|
|
function getChildren(entries, parentId) {
|
|
return entries.filter((entry) => (entry.parentId || "") === parentId);
|
|
}
|
|
|
|
function getDescendantIds(entries, parentId) {
|
|
const children = getChildren(entries, parentId);
|
|
return children.flatMap((child) => [child.id, ...getDescendantIds(entries, child.id)]);
|
|
}
|
|
|
|
function getEntriesInTreeOrder(entries, parentId = "") {
|
|
return getChildren(entries, parentId).flatMap((entry) => [entry, ...getEntriesInTreeOrder(entries, entry.id)]);
|
|
}
|
|
|
|
function getEntryFormula(entry) {
|
|
return String(entry?.formula || "").trim();
|
|
}
|
|
|
|
function getEditableFormula(entry) {
|
|
return getEntryFormula(entry) || formatResult(entry.value);
|
|
}
|
|
|
|
function escapeRegExp(value) {
|
|
return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
}
|
|
|
|
function createStoredFormula(expression, parentValue) {
|
|
const normalized = String(expression || "").replaceAll(",", ".").trim();
|
|
if (!Number.isFinite(parentValue)) return normalized;
|
|
const parentText = formatResult(parentValue);
|
|
if (/\bbase\b/i.test(normalized)) return normalized;
|
|
const withBase = normalized.replace(new RegExp(`(^|[^\\d.])${escapeRegExp(parentText)}(?=$|[^\\d.])`, "g"), `$1${BASE_TOKEN}`);
|
|
if (withBase !== normalized) return withBase;
|
|
if (/^[+\-*/]/.test(normalized)) return `${BASE_TOKEN}${normalized}`;
|
|
return normalized;
|
|
}
|
|
|
|
function evaluateCalculatorEntries(entries) {
|
|
const byParent = new Map();
|
|
entries.forEach((entry) => {
|
|
const parentId = entry.parentId || "";
|
|
byParent.set(parentId, [...(byParent.get(parentId) || []), entry]);
|
|
});
|
|
|
|
function visit(parentId = "", parentValue = null) {
|
|
return (byParent.get(parentId) || []).flatMap((entry) => {
|
|
const formula = getEntryFormula(entry);
|
|
const calculatedValue = formula ? calculateExpression(formula, parentValue) : entry.value;
|
|
const nextEntry = { ...entry, value: calculatedValue ?? entry.value };
|
|
return [nextEntry, ...visit(entry.id, nextEntry.value)];
|
|
});
|
|
}
|
|
|
|
return visit();
|
|
}
|
|
|
|
function FormattedCalculatorNumber({ value }) {
|
|
if (!Number.isFinite(value)) return "-";
|
|
let integerGroupIndex = 0;
|
|
const normalized = Number.parseFloat(value.toFixed(6));
|
|
return DISPLAY_NUMBER_FORMATTER.formatToParts(normalized).map((part, index) => {
|
|
if (part.type === "integer") {
|
|
const className = `calculator-number-group ${integerGroupIndex % 2 ? "is-muted" : "is-strong"}`;
|
|
integerGroupIndex += 1;
|
|
return <span className={className} key={`${part.type}-${index}`}>{part.value}</span>;
|
|
}
|
|
if (part.type === "group") return <span className="calculator-number-separator" key={`${part.type}-${index}`}>{part.value}</span>;
|
|
return <span key={`${part.type}-${index}`}>{part.value}</span>;
|
|
});
|
|
}
|
|
|
|
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 [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 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",
|
|
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);
|
|
|
|
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: formulaParentId,
|
|
label: cleanLabel,
|
|
value: result,
|
|
formula: storedFormula
|
|
}
|
|
]);
|
|
setExpression(formulaParent ? BASE_TOKEN : "");
|
|
setLabel("");
|
|
}
|
|
|
|
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) {
|
|
const deletedIds = new Set([entryId, ...getDescendantIds(calculatedEntries, entryId)]);
|
|
if (deletedIds.has(activeParentId)) {
|
|
setActiveParentId("");
|
|
setExpression("");
|
|
}
|
|
if (deletedIds.has(editingEntryId)) {
|
|
setEditingEntryId("");
|
|
setLabel("");
|
|
setExpression("");
|
|
}
|
|
save(calculatedEntries.filter((entry) => !deletedIds.has(entry.id)));
|
|
}
|
|
|
|
function resetCalculator() {
|
|
setExpression("");
|
|
setLabel("");
|
|
setActiveParentId("");
|
|
setEditingEntryId("");
|
|
}
|
|
|
|
function returnToRoot() {
|
|
setActiveParentId("");
|
|
setEditingEntryId("");
|
|
setExpression("");
|
|
setLabel("");
|
|
}
|
|
|
|
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>
|
|
{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
|
|
aria-label={textContent.expressionLabel || "Calcul"}
|
|
className="calculator-expression-input"
|
|
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}
|
|
/>
|
|
)}
|
|
</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>
|
|
<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}>{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" type="button" onClick={returnToRoot}>
|
|
{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"}>
|
|
<Icon name="rubber" />
|
|
</button>
|
|
<button className="tool-split-action-button" 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 ${data.scrollResults ? "active" : ""}`}
|
|
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>
|
|
);
|
|
}
|
|
|
|
function CalculatorEntries({ entries, parentId, getScopedParentId, activeEntryId, textContent, reorder, onEdit, onAddChild, onDelete }) {
|
|
const children = getChildren(entries, parentId);
|
|
if (!children.length) return null;
|
|
|
|
return (
|
|
<ul className="tool-split-entry-list calculator-entry-list">
|
|
{children.map((entry) => {
|
|
const className = [
|
|
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" : ""
|
|
].filter(Boolean).join(" ");
|
|
|
|
return (
|
|
<li className={className} key={entry.id} {...reorder.getItemProps({ itemId: entry.id, parentId: getScopedParentId(parentId) })}>
|
|
<div className="tool-split-entry calculator-entry has-drag-handle">
|
|
<button
|
|
className="calculator-drag-handle"
|
|
type="button"
|
|
onPointerDown={(event) => reorder.itemReorder.startDrag(event, entry.id)}
|
|
aria-label={`${textContent.reorderTitle || "Déplacer"} ${entry.label || formatResult(entry.value)}`}
|
|
title={textContent.reorderTitle || "Déplacer"}
|
|
>
|
|
<Icon name="drag" />
|
|
</button>
|
|
<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>
|
|
<button
|
|
type="button"
|
|
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="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} activeEntryId={activeEntryId} textContent={textContent} reorder={reorder} onEdit={onEdit} onAddChild={onAddChild} onDelete={onDelete} />
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
);
|
|
}
|