// 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 {part.value}; } if (part.type === "group") return {part.value}; return {part.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]); 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 (
{textExchangeOpen && (
context.setModuleData(toolboxId, moduleId, nextData, "calculator")} />
)}
{textContent.expressionLabel || "Calcul"} {formulaParent ? ( { 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} /> ) : ( 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} /> )}
{textContent.resultLabel || "Résultat"}
{(formulaParent || editingEntry) && ( )}