Fix notepad cursor and calculator linked formulas
All checks were successful
Deploy Sokko G / deploy (push) Successful in 16s
All checks were successful
Deploy Sokko G / deploy (push) Successful in 16s
This commit is contained in:
parent
992665f4ea
commit
49ba0c4cb2
10 changed files with 223 additions and 45 deletions
|
|
@ -661,13 +661,15 @@ Type : `calculator`
|
||||||
{
|
{
|
||||||
"id": "calc1",
|
"id": "calc1",
|
||||||
"label": "Lingots de fer",
|
"label": "Lingots de fer",
|
||||||
"value": 100
|
"value": 100,
|
||||||
|
"formula": "100"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "calc2",
|
"id": "calc2",
|
||||||
"parentId": "calc1",
|
"parentId": "calc1",
|
||||||
"label": "Minerais de fer",
|
"label": "Minerais de fer",
|
||||||
"value": 500
|
"value": 500,
|
||||||
|
"formula": "base*5"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -679,6 +681,7 @@ Stockage compact :
|
||||||
- `parentId` est omis si vide.
|
- `parentId` est omis si vide.
|
||||||
- `label` peut être vide.
|
- `label` peut être vide.
|
||||||
- `value` est numérique.
|
- `value` est numérique.
|
||||||
|
- `formula` est omis si vide ; dans un calcul enfant, le mot `base` référence la valeur recalculée du parent.
|
||||||
- Si un `parentId` ne pointe vers aucune entrée existante, il est réinitialisé à vide.
|
- Si un `parentId` ne pointe vers aucune entrée existante, il est réinitialisé à vide.
|
||||||
|
|
||||||
## Outil Tableau
|
## Outil Tableau
|
||||||
|
|
@ -770,7 +773,7 @@ Formats :
|
||||||
- `checklist` : sections `# Catégorie`, lignes `Item: quantité cible`.
|
- `checklist` : sections `# Catégorie`, lignes `Item: quantité cible`.
|
||||||
- `links` : lignes `Titre: https://...` ou URL seule.
|
- `links` : lignes `Titre: https://...` ou URL seule.
|
||||||
- `counters` : lignes `Libellé: valeur`, valeurs négatives acceptées.
|
- `counters` : lignes `Libellé: valeur`, valeurs négatives acceptées.
|
||||||
- `calculator` : lignes `Libellé: valeur`, indentation de deux espaces pour les enfants, meta `@scrollResults: true`.
|
- `calculator` : lignes `Libellé: valeur | formula=base*5`, indentation de deux espaces pour les enfants, meta `@scrollResults: true`. Le suffixe `formula=...` est optionnel et les anciens exports `Libellé: valeur` restent acceptés.
|
||||||
- `table` : TSV pur accepté ; l'export peut ajouter `@size`, `@columns`, `@rows` puis `@tsv` pour préserver dimensions et intitulés.
|
- `table` : TSV pur accepté ; l'export peut ajouter `@size`, `@columns`, `@rows` puis `@tsv` pour préserver dimensions et intitulés.
|
||||||
- `combos` : sections `# Catégorie`, lignes `Nom | device=... | kind:value > kind:value+kind:value`, avec suffixes `[hold]` ou `[2s]`.
|
- `combos` : sections `# Catégorie`, lignes `Nom | device=... | kind:value > kind:value+kind:value`, avec suffixes `[hold]` ou `[2s]`.
|
||||||
- `taskPlanner` : sections `# Catégorie`, lignes indentées `- daily Titre`, `- weekly Titre` ou `- unique Titre`, metas `@description`, `@dailyResetTime`, `@weeklyResetDay`, `@prerequisite`.
|
- `taskPlanner` : sections `# Catégorie`, lignes indentées `- daily Titre`, `- weekly Titre` ou `- unique Titre`, metas `@description`, `@dailyResetTime`, `@weeklyResetDay`, `@prerequisite`.
|
||||||
|
|
|
||||||
|
|
@ -263,7 +263,9 @@ export function validateSiteContent(site) {
|
||||||
"toolboxes.modules.calculator.copiedTitle",
|
"toolboxes.modules.calculator.copiedTitle",
|
||||||
"toolboxes.modules.calculator.scrollableTitle",
|
"toolboxes.modules.calculator.scrollableTitle",
|
||||||
"toolboxes.modules.calculator.emptyResults",
|
"toolboxes.modules.calculator.emptyResults",
|
||||||
"toolboxes.modules.calculator.readonlyValueTitle",
|
"toolboxes.modules.calculator.formulaTitle",
|
||||||
|
"toolboxes.modules.calculator.childFormulaHelp",
|
||||||
|
"toolboxes.modules.calculator.rootFormulaHelp",
|
||||||
"toolboxes.modules.calculator.useEntryTitle",
|
"toolboxes.modules.calculator.useEntryTitle",
|
||||||
"toolboxes.modules.calculator.renameTitle",
|
"toolboxes.modules.calculator.renameTitle",
|
||||||
"toolboxes.modules.calculator.deleteTitle",
|
"toolboxes.modules.calculator.deleteTitle",
|
||||||
|
|
|
||||||
|
|
@ -251,7 +251,7 @@ test("toolbox module registry and modules expose expected behavior", async () =>
|
||||||
assert.match(calculatorModule, /resetCalculator/);
|
assert.match(calculatorModule, /resetCalculator/);
|
||||||
assert.match(calculatorModule, /returnToRoot/);
|
assert.match(calculatorModule, /returnToRoot/);
|
||||||
assert.match(calculatorModule, /setExpression\(activeParent \? formatResult\(activeParent\.value\) : ""\)/);
|
assert.match(calculatorModule, /setExpression\(activeParent \? formatResult\(activeParent\.value\) : ""\)/);
|
||||||
assert.match(calculatorModule, /Quantité non modifiable/);
|
assert.match(calculatorModule, /EditableCalculatorFormula/);
|
||||||
assert.match(calculatorModule, /entry\.label \|\| formatResult\(entry\.value\)/);
|
assert.match(calculatorModule, /entry\.label \|\| formatResult\(entry\.value\)/);
|
||||||
assert.match(calculatorModule, /ResizeObserver/);
|
assert.match(calculatorModule, /ResizeObserver/);
|
||||||
assert.match(calculatorModule, /--calculator-scroll-height/);
|
assert.match(calculatorModule, /--calculator-scroll-height/);
|
||||||
|
|
|
||||||
|
|
@ -107,14 +107,15 @@ test("text exchange imports table TSV and calculator hierarchy", () => {
|
||||||
const calculator = normalizeCalculatorData({
|
const calculator = normalizeCalculatorData({
|
||||||
scrollResults: true,
|
scrollResults: true,
|
||||||
entries: [
|
entries: [
|
||||||
{ id: "a", label: "Ore", value: 100 },
|
{ id: "a", label: "Ore", value: 100, formula: "100" },
|
||||||
{ id: "b", parentId: "a", label: "Shard", value: 500 }
|
{ id: "b", parentId: "a", label: "Shard", value: 500, formula: "base*5" }
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
const calculatorImport = importModuleText("calculator", exportModuleText("calculator", calculator), context);
|
const calculatorImport = importModuleText("calculator", exportModuleText("calculator", calculator), context);
|
||||||
assert.equal(calculatorImport.ok, true);
|
assert.equal(calculatorImport.ok, true);
|
||||||
assert.equal(calculatorImport.data.scrollResults, true);
|
assert.equal(calculatorImport.data.scrollResults, true);
|
||||||
assert.equal(calculatorImport.data.entries[1].parentId, calculatorImport.data.entries[0].id);
|
assert.equal(calculatorImport.data.entries[1].parentId, calculatorImport.data.entries[0].id);
|
||||||
|
assert.equal(calculatorImport.data.entries[1].formula, "base*5");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("text exchange round trips combos, tasks, equipment, images and timers", () => {
|
test("text exchange round trips combos, tasks, equipment, images and timers", () => {
|
||||||
|
|
|
||||||
|
|
@ -1763,7 +1763,7 @@
|
||||||
"deleteComboTitle": "Supprimer le combo"
|
"deleteComboTitle": "Supprimer le combo"
|
||||||
},
|
},
|
||||||
"calculator": {
|
"calculator": {
|
||||||
"importPlaceholder": "@scrollResults: true\nLingots de fer: 100\n Minerais de fer: 500",
|
"importPlaceholder": "@scrollResults: true\nLingots de fer: 100 | formula=100\n Minerais de fer: 500 | formula=base*5",
|
||||||
"importOpenButton": "Importer du texte",
|
"importOpenButton": "Importer du texte",
|
||||||
"exportOpenButton": "Exporter en format texte",
|
"exportOpenButton": "Exporter en format texte",
|
||||||
"importModalTitle": "Importer des résultats",
|
"importModalTitle": "Importer des résultats",
|
||||||
|
|
@ -1785,7 +1785,9 @@
|
||||||
"copiedTitle": "Copié",
|
"copiedTitle": "Copié",
|
||||||
"scrollableTitle": "Liste scrollable",
|
"scrollableTitle": "Liste scrollable",
|
||||||
"emptyResults": "Aucun résultat enregistré.",
|
"emptyResults": "Aucun résultat enregistré.",
|
||||||
"readonlyValueTitle": "Quantité non modifiable",
|
"formulaTitle": "Modifier la formule",
|
||||||
|
"childFormulaHelp": "Utilisez base pour référencer le parent.",
|
||||||
|
"rootFormulaHelp": "Valeur ou formule racine.",
|
||||||
"useEntryTitle": "Utiliser comme base",
|
"useEntryTitle": "Utiliser comme base",
|
||||||
"renameTitle": "Renommer",
|
"renameTitle": "Renommer",
|
||||||
"deleteTitle": "Supprimer"
|
"deleteTitle": "Supprimer"
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,21 @@
|
||||||
// Rôle : fournit l'outil calculateur avec résultats enregistrés en arborescence et échange texte.
|
// Rôle : fournit l'outil calculateur avec résultats enregistrés en arborescence et échange texte.
|
||||||
import { useLayoutEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Icon } from "../../../components/Icon.jsx";
|
import { Icon } from "../../../components/Icon.jsx";
|
||||||
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
|
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
|
||||||
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
|
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
|
||||||
import { TextExchangeActions } from "./TextExchangeActions.jsx";
|
import { TextExchangeActions } from "./TextExchangeActions.jsx";
|
||||||
|
|
||||||
const DISPLAY_NUMBER_FORMATTER = new Intl.NumberFormat("fr-FR", { maximumFractionDigits: 6 });
|
const DISPLAY_NUMBER_FORMATTER = new Intl.NumberFormat("fr-FR", { maximumFractionDigits: 6 });
|
||||||
|
const BASE_TOKEN = "base";
|
||||||
|
|
||||||
function calculateExpression(expression) {
|
function calculateExpression(expression, baseValue = null) {
|
||||||
const normalized = String(expression || "").replaceAll(",", ".").trim();
|
const normalized = String(expression || "").replaceAll(",", ".").trim();
|
||||||
if (!normalized || !/^[\d\s+\-*/().]+$/.test(normalized)) return null;
|
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 {
|
try {
|
||||||
const value = Function(`"use strict"; return (${normalized})`)();
|
const value = Function(`"use strict"; return (${withBase})`)();
|
||||||
return Number.isFinite(value) ? value : null;
|
return Number.isFinite(value) ? value : null;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -56,6 +59,48 @@ function getEntriesInTreeOrder(entries, parentId = "") {
|
||||||
return getChildren(entries, parentId).flatMap((entry) => [entry, ...getEntriesInTreeOrder(entries, entry.id)]);
|
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 }) {
|
function FormattedCalculatorNumber({ value }) {
|
||||||
if (!Number.isFinite(value)) return "-";
|
if (!Number.isFinite(value)) return "-";
|
||||||
let integerGroupIndex = 0;
|
let integerGroupIndex = 0;
|
||||||
|
|
@ -73,6 +118,8 @@ function FormattedCalculatorNumber({ value }) {
|
||||||
|
|
||||||
export function CalculatorModule({ toolboxId, moduleId, context, editing }) {
|
export function CalculatorModule({ toolboxId, moduleId, context, editing }) {
|
||||||
const data = context.normalizeCalculatorData(context.getModuleData(toolboxId, moduleId, { entries: [] }));
|
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 textContent = context.moduleText?.calculator || {};
|
||||||
const [expression, setExpression] = useState("");
|
const [expression, setExpression] = useState("");
|
||||||
const [label, setLabel] = useState("");
|
const [label, setLabel] = useState("");
|
||||||
|
|
@ -80,16 +127,16 @@ export function CalculatorModule({ toolboxId, moduleId, context, editing }) {
|
||||||
const [calculatorHeight, setCalculatorHeight] = useState(0);
|
const [calculatorHeight, setCalculatorHeight] = useState(0);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const calculatorCardRef = useRef(null);
|
const calculatorCardRef = useRef(null);
|
||||||
const result = useMemo(() => calculateExpression(expression), [expression]);
|
const activeParent = calculatedEntries.find((entry) => entry.id === activeParentId);
|
||||||
const activeParent = data.entries.find((entry) => entry.id === activeParentId);
|
const result = useMemo(() => calculateExpression(expression, activeParent?.value), [activeParent?.value, expression]);
|
||||||
const scopeParentId = (parentId = "") => `${moduleId}:${parentId}`;
|
const scopeParentId = (parentId = "") => `${moduleId}:${parentId}`;
|
||||||
const reorder = useGroupedReorder({
|
const reorder = useGroupedReorder({
|
||||||
namespace: "calculator",
|
namespace: "calculator",
|
||||||
items: data.entries,
|
items: calculatedEntries,
|
||||||
getItemId: (entry) => entry.id,
|
getItemId: (entry) => entry.id,
|
||||||
getParentId: (entry) => scopeParentId(entry.parentId || ""),
|
getParentId: (entry) => scopeParentId(entry.parentId || ""),
|
||||||
onItemMove: (operation) => {
|
onItemMove: (operation) => {
|
||||||
save(moveItem(data.entries, operation.sourceId, operation.targetId, operation.placement));
|
save(moveItem(calculatedEntries, operation.sourceId, operation.targetId, operation.placement));
|
||||||
},
|
},
|
||||||
hierarchy: { enabled: true, stickyParents: true }
|
hierarchy: { enabled: true, stickyParents: true }
|
||||||
});
|
});
|
||||||
|
|
@ -109,7 +156,7 @@ export function CalculatorModule({ toolboxId, moduleId, context, editing }) {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function save(entries) {
|
function save(entries) {
|
||||||
context.setModuleData(toolboxId, moduleId, { ...data, entries });
|
context.setModuleData(toolboxId, moduleId, { ...data, entries: evaluateCalculatorEntries(entries) });
|
||||||
}
|
}
|
||||||
|
|
||||||
function setScrollResults(scrollResults) {
|
function setScrollResults(scrollResults) {
|
||||||
|
|
@ -127,7 +174,8 @@ export function CalculatorModule({ toolboxId, moduleId, context, editing }) {
|
||||||
id: context.uid("calc"),
|
id: context.uid("calc"),
|
||||||
parentId: activeParentId,
|
parentId: activeParentId,
|
||||||
label: cleanLabel,
|
label: cleanLabel,
|
||||||
value: result
|
value: result,
|
||||||
|
formula: createStoredFormula(expression, activeParent?.value)
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
setExpression(activeParent ? formatResult(activeParent.value) : "");
|
setExpression(activeParent ? formatResult(activeParent.value) : "");
|
||||||
|
|
@ -140,17 +188,28 @@ export function CalculatorModule({ toolboxId, moduleId, context, editing }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteEntry(entryId) {
|
function deleteEntry(entryId) {
|
||||||
const deletedIds = new Set([entryId, ...getDescendantIds(data.entries, entryId)]);
|
const deletedIds = new Set([entryId, ...getDescendantIds(calculatedEntries, entryId)]);
|
||||||
if (deletedIds.has(activeParentId)) {
|
if (deletedIds.has(activeParentId)) {
|
||||||
setActiveParentId("");
|
setActiveParentId("");
|
||||||
setExpression("");
|
setExpression("");
|
||||||
}
|
}
|
||||||
save(data.entries.filter((entry) => !deletedIds.has(entry.id)));
|
save(calculatedEntries.filter((entry) => !deletedIds.has(entry.id)));
|
||||||
}
|
}
|
||||||
|
|
||||||
function renameEntry(entryId, label) {
|
function renameEntry(entryId, label) {
|
||||||
const cleanLabel = label.trim();
|
const cleanLabel = label.trim();
|
||||||
save(data.entries.map((entry) => entry.id === entryId ? { ...entry, label: cleanLabel } : entry));
|
save(calculatedEntries.map((entry) => entry.id === entryId ? { ...entry, label: cleanLabel } : entry));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateEntryFormula(entryId, formula) {
|
||||||
|
const nextEntries = calculatedEntries.map((entry) => {
|
||||||
|
if (entry.id !== entryId) return entry;
|
||||||
|
const parent = calculatedEntries.find((candidate) => candidate.id === entry.parentId);
|
||||||
|
const nextFormula = createStoredFormula(formula, parent?.value);
|
||||||
|
const nextValue = calculateExpression(nextFormula, parent?.value) ?? entry.value;
|
||||||
|
return { ...entry, formula: nextFormula, value: nextValue };
|
||||||
|
});
|
||||||
|
save(nextEntries);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetCalculator() {
|
function resetCalculator() {
|
||||||
|
|
@ -165,7 +224,7 @@ export function CalculatorModule({ toolboxId, moduleId, context, editing }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyChecklistImport() {
|
async function copyChecklistImport() {
|
||||||
const text = getEntriesInTreeOrder(data.entries)
|
const text = getEntriesInTreeOrder(calculatedEntries)
|
||||||
.map((entry) => `${entry.label || formatResult(entry.value)}:${formatResult(entry.value)}`)
|
.map((entry) => `${entry.label || formatResult(entry.value)}:${formatResult(entry.value)}`)
|
||||||
.join("\n");
|
.join("\n");
|
||||||
if (!text || !await context.copyText(text)) return;
|
if (!text || !await context.copyText(text)) return;
|
||||||
|
|
@ -178,7 +237,7 @@ export function CalculatorModule({ toolboxId, moduleId, context, editing }) {
|
||||||
<div className="calculator-module">
|
<div className="calculator-module">
|
||||||
{editing && (
|
{editing && (
|
||||||
<div className="module-add-panel calculator-exchange-panel">
|
<div className="module-add-panel calculator-exchange-panel">
|
||||||
<TextExchangeActions type="calculator" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "calculator")} />
|
<TextExchangeActions type="calculator" data={calculatedData} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "calculator")} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<form className="calculator-form" onSubmit={saveResult}>
|
<form className="calculator-form" onSubmit={saveResult}>
|
||||||
|
|
@ -229,8 +288,8 @@ export function CalculatorModule({ toolboxId, moduleId, context, editing }) {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className={`tool-split-tree calculator-tree ${data.scrollResults ? "is-scrollable legacy-scrollbar" : ""}`}>
|
<div className={`tool-split-tree calculator-tree ${data.scrollResults ? "is-scrollable legacy-scrollbar" : ""}`}>
|
||||||
{data.entries.length ? (
|
{calculatedEntries.length ? (
|
||||||
<CalculatorEntries entries={data.entries} parentId="" getScopedParentId={scopeParentId} activeParentId={activeParentId} textContent={textContent} reorder={reorder} onUse={useEntry} onRename={renameEntry} onDelete={deleteEntry} />
|
<CalculatorEntries entries={calculatedEntries} parentId="" getScopedParentId={scopeParentId} activeParentId={activeParentId} textContent={textContent} editing={editing} reorder={reorder} onUse={useEntry} onRename={renameEntry} onUpdateFormula={updateEntryFormula} onDelete={deleteEntry} />
|
||||||
) : (
|
) : (
|
||||||
<p className="muted">{textContent.emptyResults || "Aucun résultat enregistré."}</p>
|
<p className="muted">{textContent.emptyResults || "Aucun résultat enregistré."}</p>
|
||||||
)}
|
)}
|
||||||
|
|
@ -240,9 +299,21 @@ export function CalculatorModule({ toolboxId, moduleId, context, editing }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CalculatorEntries({ entries, parentId, getScopedParentId, activeParentId, textContent, reorder, onUse, onRename, onDelete }) {
|
function CalculatorEntries({ entries, parentId, getScopedParentId, activeParentId, textContent, editing, reorder, onUse, onRename, onUpdateFormula, onDelete }) {
|
||||||
const children = getChildren(entries, parentId);
|
const children = getChildren(entries, parentId);
|
||||||
const [editingId, setEditingId] = useState("");
|
const [editingId, setEditingId] = useState("");
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editingId) return undefined;
|
||||||
|
|
||||||
|
function closeOnOutsidePointer(event) {
|
||||||
|
if (event.target?.closest?.(`[data-calculator-edit-fields="${editingId}"]`)) return;
|
||||||
|
setEditingId("");
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("pointerdown", closeOnOutsidePointer);
|
||||||
|
return () => document.removeEventListener("pointerdown", closeOnOutsidePointer);
|
||||||
|
}, [editingId]);
|
||||||
|
|
||||||
if (!children.length) return null;
|
if (!children.length) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -251,6 +322,7 @@ function CalculatorEntries({ entries, parentId, getScopedParentId, activeParentI
|
||||||
const className = [
|
const className = [
|
||||||
entry.id === activeParentId ? "active" : "",
|
entry.id === activeParentId ? "active" : "",
|
||||||
entry.id === editingId ? "is-editing" : "",
|
entry.id === editingId ? "is-editing" : "",
|
||||||
|
"can-edit-formula",
|
||||||
reorder.isItemDragging(entry.id) ? "is-dragging" : "",
|
reorder.isItemDragging(entry.id) ? "is-dragging" : "",
|
||||||
reorder.isItemDropTarget(entry.id) ? "is-drop-target" : "",
|
reorder.isItemDropTarget(entry.id) ? "is-drop-target" : "",
|
||||||
reorder.getDropPlacement("item", entry.id, parentId) === "after" ? "drop-after" : ""
|
reorder.getDropPlacement("item", entry.id, parentId) === "after" ? "drop-after" : ""
|
||||||
|
|
@ -269,14 +341,10 @@ function CalculatorEntries({ entries, parentId, getScopedParentId, activeParentI
|
||||||
<Icon name="drag" />
|
<Icon name="drag" />
|
||||||
</button>
|
</button>
|
||||||
{entry.id === editingId ? (
|
{entry.id === editingId ? (
|
||||||
<>
|
<span className="calculator-entry-edit-fields" data-calculator-edit-fields={entry.id}>
|
||||||
<span className="tool-split-entry-value is-readonly" title={textContent.readonlyValueTitle || "Quantité non modifiable"}>
|
<EditableCalculatorFormula entry={entry} entries={entries} textContent={textContent} onUpdateFormula={onUpdateFormula} autoFocus={editing} />
|
||||||
<strong className={getEntryResultLengthClass(entry.value)} aria-label={formatDisplayResult(entry.value)}>
|
<EditableCalculatorLabel entry={entry} textContent={textContent} onRename={onRename} autoFocus={!editing} />
|
||||||
<FormattedCalculatorNumber value={entry.value} />
|
</span>
|
||||||
</strong>
|
|
||||||
</span>
|
|
||||||
<EditableCalculatorLabel entry={entry} textContent={textContent} onRename={onRename} onDone={() => setEditingId("")} />
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<button className="tool-split-entry-summary" type="button" onClick={() => onUse(entry)} title={textContent.useEntryTitle || "Utiliser comme base"}>
|
<button className="tool-split-entry-summary" type="button" onClick={() => onUse(entry)} title={textContent.useEntryTitle || "Utiliser comme base"}>
|
||||||
<span>
|
<span>
|
||||||
|
|
@ -294,7 +362,7 @@ function CalculatorEntries({ entries, parentId, getScopedParentId, activeParentI
|
||||||
<Icon name="trash" />
|
<Icon name="trash" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<CalculatorEntries entries={entries} parentId={entry.id} getScopedParentId={getScopedParentId} activeParentId={activeParentId} textContent={textContent} reorder={reorder} onUse={onUse} onRename={onRename} onDelete={onDelete} />
|
<CalculatorEntries entries={entries} parentId={entry.id} getScopedParentId={getScopedParentId} activeParentId={activeParentId} textContent={textContent} editing={editing} reorder={reorder} onUse={onUse} onRename={onRename} onUpdateFormula={onUpdateFormula} onDelete={onDelete} />
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
@ -302,12 +370,12 @@ function CalculatorEntries({ entries, parentId, getScopedParentId, activeParentI
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function EditableCalculatorLabel({ entry, textContent, onRename, onDone }) {
|
function EditableCalculatorLabel({ entry, textContent, onRename, onDone, autoFocus = true }) {
|
||||||
const inlineEdit = useInlineEdit({
|
const inlineEdit = useInlineEdit({
|
||||||
value: entry.label,
|
value: entry.label,
|
||||||
onCommit: (label) => onRename(entry.id, label),
|
onCommit: (label) => onRename(entry.id, label),
|
||||||
onDone,
|
onDone,
|
||||||
autoFocus: true,
|
autoFocus,
|
||||||
focusKey: entry.id
|
focusKey: entry.id
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -320,3 +388,28 @@ function EditableCalculatorLabel({ entry, textContent, onRename, onDone }) {
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function EditableCalculatorFormula({ entry, entries, textContent, onUpdateFormula, autoFocus = false }) {
|
||||||
|
const parent = entries.find((candidate) => candidate.id === entry.parentId);
|
||||||
|
const parentValue = parent?.value;
|
||||||
|
const inlineEdit = useInlineEdit({
|
||||||
|
value: getEditableFormula(entry),
|
||||||
|
onCommit: (formula) => {
|
||||||
|
const storedFormula = createStoredFormula(formula, parentValue);
|
||||||
|
if (calculateExpression(storedFormula, parentValue) != null) onUpdateFormula(entry.id, storedFormula);
|
||||||
|
},
|
||||||
|
autoFocus,
|
||||||
|
focusKey: `${entry.id}:formula`,
|
||||||
|
transform: (draft) => String(draft || "").replaceAll(",", ".").trim()
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
{...inlineEdit.getInputProps({
|
||||||
|
className: "tool-split-entry-label calculator-entry-formula",
|
||||||
|
"aria-label": `${textContent.formulaTitle || "Formule enregistrée"} ${entry.label || formatResult(entry.value)}`,
|
||||||
|
title: entry.parentId ? textContent.childFormulaHelp || "Utilisez base pour référencer le parent." : textContent.rootFormulaHelp || "Valeur ou formule racine."
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
// Rôle : fournit l'outil bloc-notes riche avec annotations dessinées.
|
// Rôle : fournit l'outil bloc-notes riche avec annotations dessinées.
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import { CompactDropdown } from "../../../components/CompactDropdown.jsx";
|
import { CompactDropdown } from "../../../components/CompactDropdown.jsx";
|
||||||
import { DrawingControls, FormattingColorDropdown } from "./DrawingControls.jsx";
|
import { DrawingControls, FormattingColorDropdown } from "./DrawingControls.jsx";
|
||||||
import { DrawingOverlay } from "./DrawingOverlay.jsx";
|
import { DrawingOverlay } from "./DrawingOverlay.jsx";
|
||||||
|
|
@ -13,6 +13,43 @@ function getPlainText(element) {
|
||||||
return (element?.innerText || "").replace(/\n{3,}/g, "\n\n").trim();
|
return (element?.innerText || "").replace(/\n{3,}/g, "\n\n").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSelectionTextOffset(container) {
|
||||||
|
const selection = document.getSelection();
|
||||||
|
if (!container || !selection?.rangeCount || !container.contains(selection.anchorNode)) return null;
|
||||||
|
const range = selection.getRangeAt(0);
|
||||||
|
const prefix = range.cloneRange();
|
||||||
|
prefix.selectNodeContents(container);
|
||||||
|
prefix.setEnd(range.startContainer, range.startOffset);
|
||||||
|
return prefix.toString().length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreSelectionTextOffset(container, offset) {
|
||||||
|
if (!container || offset == null) return;
|
||||||
|
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
|
||||||
|
let remaining = offset;
|
||||||
|
let node = walker.nextNode();
|
||||||
|
while (node) {
|
||||||
|
const length = node.textContent.length;
|
||||||
|
if (remaining <= length) {
|
||||||
|
const range = document.createRange();
|
||||||
|
range.setStart(node, remaining);
|
||||||
|
range.collapse(true);
|
||||||
|
const selection = document.getSelection();
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
remaining -= length;
|
||||||
|
node = walker.nextNode();
|
||||||
|
}
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(container);
|
||||||
|
range.collapse(false);
|
||||||
|
const selection = document.getSelection();
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
}
|
||||||
|
|
||||||
function isHtmlEmpty(html, text) {
|
function isHtmlEmpty(html, text) {
|
||||||
return !text && !String(html || "").replace(/<br\s*\/?>/gi, "").replace(/<[^>]*>/g, "").replace(/ /gi, " ").trim();
|
return !text && !String(html || "").replace(/<br\s*\/?>/gi, "").replace(/<[^>]*>/g, "").replace(/ /gi, " ").trim();
|
||||||
}
|
}
|
||||||
|
|
@ -114,6 +151,9 @@ export function NotepadModule({ toolboxId, moduleId, context, editing = false })
|
||||||
const textContent = context.moduleText?.notepad || {};
|
const textContent = context.moduleText?.notepad || {};
|
||||||
const editorRef = useRef(null);
|
const editorRef = useRef(null);
|
||||||
const surfaceRef = useRef(null);
|
const surfaceRef = useRef(null);
|
||||||
|
const selectionOffsetRef = useRef(null);
|
||||||
|
const localSaveUpdatedAtRef = useRef("");
|
||||||
|
const moduleKeyRef = useRef(`${toolboxId}:${moduleId}`);
|
||||||
const [html, setHtml] = useState(normalizedData.html);
|
const [html, setHtml] = useState(normalizedData.html);
|
||||||
const [text, setText] = useState(normalizedData.text);
|
const [text, setText] = useState(normalizedData.text);
|
||||||
const [updatedAt, setUpdatedAt] = useState(normalizedData.updatedAt);
|
const [updatedAt, setUpdatedAt] = useState(normalizedData.updatedAt);
|
||||||
|
|
@ -137,6 +177,19 @@ export function NotepadModule({ toolboxId, moduleId, context, editing = false })
|
||||||
const [temporaryStrokes, setTemporaryStrokes] = useState([]);
|
const [temporaryStrokes, setTemporaryStrokes] = useState([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const moduleKey = `${toolboxId}:${moduleId}`;
|
||||||
|
if (moduleKeyRef.current !== moduleKey) {
|
||||||
|
moduleKeyRef.current = moduleKey;
|
||||||
|
localSaveUpdatedAtRef.current = "";
|
||||||
|
}
|
||||||
|
if (normalizedData.updatedAt && normalizedData.updatedAt === localSaveUpdatedAtRef.current) return;
|
||||||
|
const editor = editorRef.current;
|
||||||
|
if (document.activeElement === editor && getPlainText(editor) === normalizedData.text) {
|
||||||
|
setText(normalizedData.text);
|
||||||
|
setUpdatedAt(normalizedData.updatedAt);
|
||||||
|
setDrawingMode(normalizedData.drawingMode);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setHtml(normalizedData.html);
|
setHtml(normalizedData.html);
|
||||||
setText(normalizedData.text);
|
setText(normalizedData.text);
|
||||||
setUpdatedAt(normalizedData.updatedAt);
|
setUpdatedAt(normalizedData.updatedAt);
|
||||||
|
|
@ -145,10 +198,12 @@ export function NotepadModule({ toolboxId, moduleId, context, editing = false })
|
||||||
setEraseActive(false);
|
setEraseActive(false);
|
||||||
}, [toolboxId, moduleId, normalizedData.html, normalizedData.text, normalizedData.updatedAt, normalizedData.drawingMode]);
|
}, [toolboxId, moduleId, normalizedData.html, normalizedData.text, normalizedData.updatedAt, normalizedData.drawingMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!editorRef.current || editorRef.current.innerHTML === html) return;
|
if (!editorRef.current || editorRef.current.innerHTML === html) return;
|
||||||
|
if (document.activeElement === editorRef.current && getPlainText(editorRef.current) === text) return;
|
||||||
editorRef.current.innerHTML = html;
|
editorRef.current.innerHTML = html;
|
||||||
}, [html]);
|
if (document.activeElement === editorRef.current) restoreSelectionTextOffset(editorRef.current, selectionOffsetRef.current);
|
||||||
|
}, [html, text]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function updateActiveFormats() {
|
function updateActiveFormats() {
|
||||||
|
|
@ -199,6 +254,7 @@ export function NotepadModule({ toolboxId, moduleId, context, editing = false })
|
||||||
|
|
||||||
function saveContent(nextHtml, nextText) {
|
function saveContent(nextHtml, nextText) {
|
||||||
const nextUpdatedAt = new Date().toISOString();
|
const nextUpdatedAt = new Date().toISOString();
|
||||||
|
localSaveUpdatedAtRef.current = nextUpdatedAt;
|
||||||
setHtml(nextHtml);
|
setHtml(nextHtml);
|
||||||
setText(nextText);
|
setText(nextText);
|
||||||
setUpdatedAt(nextUpdatedAt);
|
setUpdatedAt(nextUpdatedAt);
|
||||||
|
|
@ -241,6 +297,7 @@ export function NotepadModule({ toolboxId, moduleId, context, editing = false })
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleEditorInput() {
|
function handleEditorInput() {
|
||||||
|
selectionOffsetRef.current = getSelectionTextOffset(editorRef.current);
|
||||||
const nextHtml = editorRef.current?.innerHTML || "";
|
const nextHtml = editorRef.current?.innerHTML || "";
|
||||||
const nextText = getPlainText(editorRef.current);
|
const nextText = getPlainText(editorRef.current);
|
||||||
saveContent(nextHtml, nextText);
|
saveContent(nextHtml, nextText);
|
||||||
|
|
|
||||||
|
|
@ -340,7 +340,8 @@ function exportCalculatorText(data) {
|
||||||
if (data.scrollResults) lines.push("@scrollResults: true");
|
if (data.scrollResults) lines.push("@scrollResults: true");
|
||||||
function visit(parentId = "", depth = 0) {
|
function visit(parentId = "", depth = 0) {
|
||||||
(data.entries || []).filter((entry) => (entry.parentId || "") === parentId).forEach((entry) => {
|
(data.entries || []).filter((entry) => (entry.parentId || "") === parentId).forEach((entry) => {
|
||||||
lines.push(`${" ".repeat(depth)}${entry.label || ""}: ${entry.value}`);
|
const formula = String(entry.formula || "").trim();
|
||||||
|
lines.push(`${" ".repeat(depth)}${entry.label || ""}: ${entry.value}${formula ? ` | formula=${formula}` : ""}`);
|
||||||
visit(entry.id, depth + 1);
|
visit(entry.id, depth + 1);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -363,9 +364,10 @@ function importCalculatorText(text, context) {
|
||||||
const separatorIndex = line.indexOf(":");
|
const separatorIndex = line.indexOf(":");
|
||||||
if (separatorIndex < 0) throw lineError(index, `ligne calculateur invalide: ${line.trim()}`);
|
if (separatorIndex < 0) throw lineError(index, `ligne calculateur invalide: ${line.trim()}`);
|
||||||
const label = line.slice(0, separatorIndex).trim();
|
const label = line.slice(0, separatorIndex).trim();
|
||||||
const value = parseNumber(line.slice(separatorIndex + 1), Number.NaN);
|
const [rawValue, rawFormula = ""] = line.slice(separatorIndex + 1).split(/\s+\|\s+formula=/);
|
||||||
|
const value = parseNumber(rawValue, Number.NaN);
|
||||||
if (!Number.isFinite(value)) throw lineError(index, `valeur calculateur invalide: ${line.trim()}`);
|
if (!Number.isFinite(value)) throw lineError(index, `valeur calculateur invalide: ${line.trim()}`);
|
||||||
const entry = { id: context.uid("calc"), parentId: parentStack[depth - 1] || "", label, value };
|
const entry = { id: context.uid("calc"), parentId: parentStack[depth - 1] || "", label, value, formula: rawFormula.trim() };
|
||||||
entries.push(entry);
|
entries.push(entry);
|
||||||
parentStack[depth] = entry.id;
|
parentStack[depth] = entry.id;
|
||||||
parentStack.length = depth + 1;
|
parentStack.length = depth + 1;
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,18 @@ function normalizeCalculatorValue(value) {
|
||||||
return Number.isFinite(parsed) ? parsed : 0;
|
return Number.isFinite(parsed) ? parsed : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeCalculatorFormula(value) {
|
||||||
|
return String(value || "").replaceAll(",", ".").trim();
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeCalculatorData(data) {
|
export function normalizeCalculatorData(data) {
|
||||||
const entries = (data?.entries || [])
|
const entries = (data?.entries || [])
|
||||||
.map((entry) => ({
|
.map((entry) => ({
|
||||||
id: entry?.id || uid("calc"),
|
id: entry?.id || uid("calc"),
|
||||||
parentId: String(entry?.parentId || ""),
|
parentId: String(entry?.parentId || ""),
|
||||||
label: String(entry?.label || "").trim(),
|
label: String(entry?.label || "").trim(),
|
||||||
value: normalizeCalculatorValue(entry?.value)
|
value: normalizeCalculatorValue(entry?.value),
|
||||||
|
formula: normalizeCalculatorFormula(entry?.formula)
|
||||||
}));
|
}));
|
||||||
const entryIds = new Set(entries.map((entry) => entry.id));
|
const entryIds = new Set(entries.map((entry) => entry.id));
|
||||||
return {
|
return {
|
||||||
|
|
@ -26,6 +31,7 @@ export function compactCalculatorDataForStorage(value) {
|
||||||
const entries = normalized.entries.map((entry) => {
|
const entries = normalized.entries.map((entry) => {
|
||||||
const compact = { id: entry.id, label: entry.label, value: entry.value };
|
const compact = { id: entry.id, label: entry.label, value: entry.value };
|
||||||
if (entry.parentId) compact.parentId = entry.parentId;
|
if (entry.parentId) compact.parentId = entry.parentId;
|
||||||
|
if (entry.formula) compact.formula = entry.formula;
|
||||||
return compact;
|
return compact;
|
||||||
});
|
});
|
||||||
if (!entries.length && !normalized.scrollResults) return null;
|
if (!entries.length && !normalized.scrollResults) return null;
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,18 @@
|
||||||
grid-template-columns: 30px auto minmax(0, 1fr) 34px 34px;
|
grid-template-columns: 30px auto minmax(0, 1fr) 34px 34px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tool-split-entry-list li.is-editing.can-edit-formula > .calculator-entry.has-drag-handle {
|
||||||
|
grid-template-columns: 30px minmax(92px, 0.9fr) minmax(80px, 1fr) 34px 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calculator-entry-formula {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calculator-entry-edit-fields {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
.calculator-drag-handle {
|
.calculator-drag-handle {
|
||||||
width: 30px;
|
width: 30px;
|
||||||
min-width: 30px;
|
min-width: 30px;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue