Refactor calculator module helpers
All checks were successful
Deploy Sokko G / deploy (push) Successful in 15s

This commit is contained in:
Shinuwa 2026-08-24 23:08:26 +02:00
parent e512eef232
commit 61176ba9b7
5 changed files with 211 additions and 170 deletions

View file

@ -3,118 +3,20 @@ 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";
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>;
});
}
import {
BASE_TOKEN,
calculateExpression,
createStoredFormula,
evaluateCalculatorEntries,
formatDisplayResult,
formatResult,
getDescendantIds,
getEditableFormula,
getEntriesInTreeOrder,
getResultLengthClass
} from "./calculatorUtils.js";
function focusExpressionInputAtEnd(element) {
if (!element) return;
@ -393,57 +295,3 @@ export function CalculatorModule({ toolboxId, moduleId, context, textExchangeOpe
</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>
);
}